django-ctct 0.0.1.dev4__tar.gz → 0.0.1.dev5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: django-ctct
3
- Version: 0.0.1.dev4
3
+ Version: 0.0.1.dev5
4
4
  Summary: A Django interface for the Constant Contact API
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Author: Geoffrey Eisenbarth
7
8
  Author-email: geoffrey.eisenbarth@gmail.com
8
9
  Requires-Python: >=3.10
@@ -12,7 +13,9 @@ Classifier: Programming Language :: Python :: 3.10
12
13
  Classifier: Programming Language :: Python :: 3.11
13
14
  Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
15
17
  Requires-Dist: django (>=3.2.0,<6.0.0)
18
+ Requires-Dist: mypy (>=1.19.0,<2.0.0)
16
19
  Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0)
17
20
  Requires-Dist: ratelimit (>=2.2.1,<3.0.0)
18
21
  Requires-Dist: requests (>=2.32.3,<3.0.0)
@@ -25,7 +28,9 @@ Description-Content-Type: text/markdown
25
28
 
26
29
  This Django app provides a seamless interface to the Constant Contact API, allowing you to manage contacts, email campaigns, and other Constant Contact functionalities directly from your Django project.
27
30
 
28
- **Warning:** This package is under active development. While it is our intention to develop with a consistent API going forward, we will not make promises until a later version is released.
31
+ **Warning:** This package is under active development.
32
+ While it is our intention to develop with a consistent API going forward, we will not make promises until a later version is released.
33
+
29
34
 
30
35
  ## Installation
31
36
 
@@ -33,6 +38,7 @@ This Django app provides a seamless interface to the Constant Contact API, allow
33
38
  pip install django-ctct
34
39
  ```
35
40
 
41
+
36
42
  ## Configuration
37
43
 
38
44
  1) **Add to `INSTALLED_APPS`:**
@@ -61,6 +67,7 @@ urlpatterns = [
61
67
  # ... other URL patterns
62
68
  ]
63
69
  ```
70
+
64
71
  3) **ConstantContact API Credentials:**
65
72
 
66
73
  Head to https://app.constantcontact.com/pages/dma/portal/ to set up your application with ConstantContact.
@@ -110,6 +117,7 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
110
117
  * `CTCT_PREVIEW_MESSAGE` will be blank by default.
111
118
 
112
119
  3) **Run Migrations:**
120
+
113
121
  ```bash
114
122
  > ./manage.py makemigrations
115
123
  > ./manage.py migrate
@@ -118,9 +126,10 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
118
126
  4) **Create Authenticatin Token:**
119
127
 
120
128
  After the app has been installed and configured, you must generate your first auth token:
121
- * Open up the `CTCT_REDIRECT_URI` address in your browser
122
- * Use your ConstantContact credentials to log in
123
- * From this point forward `django-ctct` should use refresh tokens, so no need to manually log in again
129
+
130
+ * Open up the `CTCT_REDIRECT_URI` address in your browser
131
+ * Use your ConstantContact credentials to log in
132
+ * From this point forward `django-ctct` should use refresh tokens, so no need to manually log in again
124
133
 
125
134
 
126
135
  ## Usage
@@ -130,12 +139,16 @@ If you wish to import data from ConstantContact.com into your local database (re
130
139
  > ./manage.py import_ctct
131
140
  ```
132
141
 
133
- You will be asked before each model type is imported. **Note** ConstantContact does not provide a bulk API endpoint for fetching Campaign Activities, so depending on the size of your account, this might take some time and possible put you over their 10,000 request per day limit if you run it regularly.
142
+ You will be asked before each model type is imported.
143
+ **Note** ConstantContact does not provide a bulk API endpoint for fetching Campaign Activities, so depending on the size of your account, this might take some time and possible put you over their 10,000 request per day limit if you run it regularly.
134
144
 
135
- Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database. You can use the `--no-input` flag to bypass the interactive questions. The `--stats-only` flag is useful for running a cron job to keep EmailCampaign statistics updated.
145
+ Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database.
146
+ You can use the `--no-input` flag to bypass the interactive questions.
147
+ The `--stats-only` flag is useful for running a cron job to keep EmailCampaign statistics updated.
136
148
 
137
149
  If you wish to use the Django admin to interact with ConstantContact, you must explicitly set the `CTCT_USE_ADMIN` and `CTCT_SYNC_ADMIN` settings to `True`.
138
150
 
151
+
139
152
  ## Testing
140
153
 
141
154
  To install dev dependencies:
@@ -155,13 +168,16 @@ To run tests:
155
168
  > poetry run coverage report
156
169
  ```
157
170
 
171
+
158
172
  ## Contributing
159
173
 
160
174
  Once version 0.0.1 is released on PyPI, we hope to implement the following new features (in no particular order):
161
175
 
162
- * Support for API syncing using signals (`post_save`, `pre_delete`, `m2m_changed`, etc). This will be controlled by the `CTCT_SYNC_SIGNALS` setting. **Update** This probably won't work as desired since the primary object will be saved before related objects are.
163
- * Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
164
- * Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
176
+ * Support for API syncing using signals (`post_save`, `pre_delete`, `m2m_changed`, etc).
177
+ This will be controlled by the `CTCT_SYNC_SIGNALS` setting.
178
+ **Update** This probably won't work as desired since the primary object will be saved before related objects are.
179
+ * Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
180
+ * Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
165
181
 
166
182
 
167
183
  I'm always open to new suggestions, so please reach out on GitHub: https://github.com/geoffrey-eisenbarth/django-ctct/
@@ -4,7 +4,9 @@
4
4
 
5
5
  This Django app provides a seamless interface to the Constant Contact API, allowing you to manage contacts, email campaigns, and other Constant Contact functionalities directly from your Django project.
6
6
 
7
- **Warning:** This package is under active development. While it is our intention to develop with a consistent API going forward, we will not make promises until a later version is released.
7
+ **Warning:** This package is under active development.
8
+ While it is our intention to develop with a consistent API going forward, we will not make promises until a later version is released.
9
+
8
10
 
9
11
  ## Installation
10
12
 
@@ -12,6 +14,7 @@ This Django app provides a seamless interface to the Constant Contact API, allow
12
14
  pip install django-ctct
13
15
  ```
14
16
 
17
+
15
18
  ## Configuration
16
19
 
17
20
  1) **Add to `INSTALLED_APPS`:**
@@ -40,6 +43,7 @@ urlpatterns = [
40
43
  # ... other URL patterns
41
44
  ]
42
45
  ```
46
+
43
47
  3) **ConstantContact API Credentials:**
44
48
 
45
49
  Head to https://app.constantcontact.com/pages/dma/portal/ to set up your application with ConstantContact.
@@ -89,6 +93,7 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
89
93
  * `CTCT_PREVIEW_MESSAGE` will be blank by default.
90
94
 
91
95
  3) **Run Migrations:**
96
+
92
97
  ```bash
93
98
  > ./manage.py makemigrations
94
99
  > ./manage.py migrate
@@ -97,9 +102,10 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
97
102
  4) **Create Authenticatin Token:**
98
103
 
99
104
  After the app has been installed and configured, you must generate your first auth token:
100
- * Open up the `CTCT_REDIRECT_URI` address in your browser
101
- * Use your ConstantContact credentials to log in
102
- * From this point forward `django-ctct` should use refresh tokens, so no need to manually log in again
105
+
106
+ * Open up the `CTCT_REDIRECT_URI` address in your browser
107
+ * Use your ConstantContact credentials to log in
108
+ * From this point forward `django-ctct` should use refresh tokens, so no need to manually log in again
103
109
 
104
110
 
105
111
  ## Usage
@@ -109,12 +115,16 @@ If you wish to import data from ConstantContact.com into your local database (re
109
115
  > ./manage.py import_ctct
110
116
  ```
111
117
 
112
- You will be asked before each model type is imported. **Note** ConstantContact does not provide a bulk API endpoint for fetching Campaign Activities, so depending on the size of your account, this might take some time and possible put you over their 10,000 request per day limit if you run it regularly.
118
+ You will be asked before each model type is imported.
119
+ **Note** ConstantContact does not provide a bulk API endpoint for fetching Campaign Activities, so depending on the size of your account, this might take some time and possible put you over their 10,000 request per day limit if you run it regularly.
113
120
 
114
- Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database. You can use the `--no-input` flag to bypass the interactive questions. The `--stats-only` flag is useful for running a cron job to keep EmailCampaign statistics updated.
121
+ Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database.
122
+ You can use the `--no-input` flag to bypass the interactive questions.
123
+ The `--stats-only` flag is useful for running a cron job to keep EmailCampaign statistics updated.
115
124
 
116
125
  If you wish to use the Django admin to interact with ConstantContact, you must explicitly set the `CTCT_USE_ADMIN` and `CTCT_SYNC_ADMIN` settings to `True`.
117
126
 
127
+
118
128
  ## Testing
119
129
 
120
130
  To install dev dependencies:
@@ -134,13 +144,16 @@ To run tests:
134
144
  > poetry run coverage report
135
145
  ```
136
146
 
147
+
137
148
  ## Contributing
138
149
 
139
150
  Once version 0.0.1 is released on PyPI, we hope to implement the following new features (in no particular order):
140
151
 
141
- * Support for API syncing using signals (`post_save`, `pre_delete`, `m2m_changed`, etc). This will be controlled by the `CTCT_SYNC_SIGNALS` setting. **Update** This probably won't work as desired since the primary object will be saved before related objects are.
142
- * Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
143
- * Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
152
+ * Support for API syncing using signals (`post_save`, `pre_delete`, `m2m_changed`, etc).
153
+ This will be controlled by the `CTCT_SYNC_SIGNALS` setting.
154
+ **Update** This probably won't work as desired since the primary object will be saved before related objects are.
155
+ * Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
156
+ * Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
144
157
 
145
158
 
146
159
  I'm always open to new suggestions, so please reach out on GitHub: https://github.com/geoffrey-eisenbarth/django-ctct/
@@ -51,7 +51,7 @@ def catch_api_errors(func: Callable[P, None]) -> Callable[P, None]:
51
51
  if getattr(settings, 'CTCT_RAISE_FOR_API', False):
52
52
  raise e
53
53
  else:
54
- self, request, *_ = args
54
+ self, request, *x = args
55
55
  assert isinstance(self, admin.ModelAdmin)
56
56
  assert isinstance(request, HttpRequest)
57
57
  self.message_user(
@@ -141,7 +141,7 @@ class RemoteModelAdmin(
141
141
  def delete_queryset(
142
142
  self,
143
143
  request: HttpRequest,
144
- queryset: QuerySet[CTCTEndpointModel],
144
+ queryset: QuerySet[E],
145
145
  ) -> None:
146
146
  if self.remote_sync:
147
147
  queryset.model.remote.bulk_delete(queryset)
@@ -152,7 +152,7 @@ class RemoteModelAdmin(
152
152
  def save_related(
153
153
  self,
154
154
  request: HttpRequest,
155
- form: ModelForm[CTCTEndpointModel],
155
+ form: ModelForm[E],
156
156
  formsets: list[BaseFormSet[ModelForm[Model]]],
157
157
  change: bool,
158
158
  ) -> None:
@@ -175,7 +175,7 @@ class RemoteModelAdmin(
175
175
  def save_remotely(
176
176
  self,
177
177
  request: HttpRequest,
178
- form: ModelForm[CTCTEndpointModel],
178
+ form: ModelForm[E],
179
179
  formsets: list[BaseFormSet[ModelForm[Model]]],
180
180
  change: bool,
181
181
  ) -> None:
@@ -295,7 +295,7 @@ class ContactCustomFieldInline(
295
295
  ):
296
296
 
297
297
  model = ContactCustomField
298
- excldue = ('api_id', )
298
+ exclude = ('api_id', )
299
299
 
300
300
  extra = 0
301
301
 
@@ -570,7 +570,7 @@ class EmailCampaignAdmin(RemoteModelAdmin[EmailCampaign]):
570
570
  def save_remotely(
571
571
  self,
572
572
  request: HttpRequest,
573
- form: ModelForm[EmailCampaign], # type: ignore[override]
573
+ form: ModelForm[EmailCampaign],
574
574
  formsets: list[BaseFormSet[ModelForm[Model]]],
575
575
  change: bool,
576
576
  ) -> None:
@@ -82,12 +82,13 @@ class Command(BaseCommand):
82
82
 
83
83
  if model._meta.auto_created and hasattr(model, 'contactlist_id'):
84
84
  # Delete existing through model instances
85
- model.objects.all().delete()
85
+ model.objects.all().delete() # type: ignore
86
86
  update_conflicts = False
87
87
  unique_fields = update_fields = None
88
88
  elif issubclass(model, ContactCustomField):
89
- update_conflicts = False
90
- unique_fields = update_fields = None
89
+ update_conflicts = True
90
+ unique_fields = ['contact_id', 'custom_field_id']
91
+ update_fields = ['value']
91
92
  elif issubclass(model, CampaignSummary):
92
93
  update_conflicts = True
93
94
  unique_fields = ['campaign_id']
@@ -116,7 +117,7 @@ class Command(BaseCommand):
116
117
  unique_objs = objs
117
118
 
118
119
  # Perform the upsert
119
- objs_w_pks = model.objects.bulk_create(
120
+ objs_w_pks = model.objects.bulk_create( # type: ignore
120
121
  objs=unique_objs,
121
122
  update_conflicts=update_conflicts,
122
123
  unique_fields=unique_fields,
@@ -132,76 +133,34 @@ class Command(BaseCommand):
132
133
  # Inform the user
133
134
  if not silent:
134
135
  message = self.style.SUCCESS(
135
- f'{verb} {len(objs)} {model.__name__} instances.'
136
+ f'{verb} {len(objs):,} {model.__name__} instances.'
136
137
  )
137
138
  self.stdout.write(message)
138
139
 
139
140
  return objs_w_pks
140
141
 
141
- def set_direct_object_pks(
142
- self,
143
- model: Type[M],
144
- instances: list[M],
145
- ) -> None:
146
- """Sets Django pk values for OneToOne and ForeignKeys objects."""
147
-
148
- otos, _, fks, _ = get_related_fields(model)
149
-
150
- fields = otos + fks
151
- for field in fields:
152
- if id_to_pk := self.get_id_to_pk(field.related_model):
153
- for o in instances:
154
- setattr(o, field.attname, id_to_pk[str(getattr(o, field.attname))])
155
-
156
142
  def set_related_object_pks(
157
143
  self,
158
- model: Type[M],
144
+ model: Type[E],
159
145
  objs_w_pks: list[M],
160
146
  per_obj_list_of_related_objs: list[list[RelatedObjects]],
161
147
  ) -> None:
162
- """Sets Django pk values for ManyToMany and ReverseForeignKey objects."""
163
-
164
- if not any(per_obj_list_of_related_objs):
165
- return
166
-
167
- _, m2ms, _, rfks = get_related_fields(model)
168
-
169
- m2m_attnames = {
170
- field.remote_field.through: (
171
- field.m2m_column_name(), field.m2m_reverse_name()
172
- )
173
- for field in m2ms
174
- }
175
- if m2ms:
176
- id_to_pk = {
177
- m2m.remote_field.through: self.get_id_to_pk(m2m.related_model)
178
- for m2m in m2ms
179
- }
180
-
181
- rfk_attnames = {
182
- rel.related_model: rel.field.attname
183
- for rel in rfks
148
+ _, mtms, _, rfks = get_related_fields(model)
149
+ field_name = {
150
+ field.remote_field.through: field.m2m_field_name()
151
+ for field in mtms
152
+ } | {
153
+ field.related_model: field.remote_field.name
154
+ for field in rfks
184
155
  }
185
156
 
186
- if model is Contact:
187
- # TODO: GH #14 ContactCustomField is kind of a ManyToMany
188
- m2m_attnames[ContactCustomField] = (
189
- rfk_attnames.pop(ContactCustomField), 'custom_field_id'
190
- )
191
- id_to_pk[ContactCustomField] = self.get_id_to_pk(CustomField)
192
-
193
- for obj_w_pk, list_of_related_objs in zip(objs_w_pks, per_obj_list_of_related_objs): # noqa: E501
194
- for related_model, objs in list_of_related_objs:
195
- if m2m_attname := m2m_attnames.get(related_model):
196
- column_name, reverse_name = m2m_attname
197
- for o in objs:
198
- api_id = str(getattr(o, reverse_name))
199
- setattr(o, column_name, obj_w_pk.pk)
200
- setattr(o, reverse_name, id_to_pk[related_model][api_id])
201
-
202
- elif rfk_attname := rfk_attnames.get(related_model):
203
- for o in objs:
204
- setattr(o, rfk_attname, obj_w_pk.pk)
157
+ for obj_w_pk, list_of_related_objs in zip(
158
+ objs_w_pks,
159
+ per_obj_list_of_related_objs,
160
+ ):
161
+ for related_model, related_objs in list_of_related_objs:
162
+ for related_obj in related_objs:
163
+ setattr(related_obj, field_name[related_model], obj_w_pk)
205
164
 
206
165
  def import_model(self, model: Type[E]) -> None:
207
166
  """Imports objects from CTCT into Django's database."""
@@ -223,18 +182,16 @@ class Command(BaseCommand):
223
182
  # No values returned
224
183
  return
225
184
 
226
- # Convert API id to Django pks
227
- self.set_direct_object_pks(model, objs)
228
-
229
185
  # Upsert models to get Django pks
230
186
  objs_w_pks = self.upsert(model, objs)
231
187
 
232
- # Convert API ids to Django pks for related objects
233
- self.set_related_object_pks(
234
- model,
235
- objs_w_pks,
236
- per_obj_list_of_related_objs,
237
- )
188
+ # Set Django object PK on related objects
189
+ if any(per_obj_list_of_related_objs):
190
+ self.set_related_object_pks(
191
+ model,
192
+ objs_w_pks,
193
+ per_obj_list_of_related_objs,
194
+ )
238
195
 
239
196
  # Reshape related_objs for efficiency
240
197
  dict_of_related_objs = defaultdict(list)
@@ -249,13 +206,9 @@ class Command(BaseCommand):
249
206
  def import_campaign_activities(self) -> None:
250
207
  """CampaignActivities must be imported one at a time."""
251
208
 
252
- # First make sure CampaignActivity API id's are stored locally
209
+ # First, make sure all CampaignActivity API id's are stored locally
253
210
  EmailCampaign.remote.connect()
254
- for campaign in EmailCampaign.objects.exclude(
255
- api_id__isnull=True,
256
- campaign_activities__role='primary_email',
257
- campaign_activities__api_id__isnull=False,
258
- ):
211
+ for campaign in EmailCampaign.objects.exclude(api_id__isnull=True):
259
212
  # Fetch from API
260
213
  assert isinstance(campaign.api_id, UUID)
261
214
  try:
@@ -272,7 +225,7 @@ class Command(BaseCommand):
272
225
  obj.campaign_id = campaign.pk
273
226
  obj.save()
274
227
 
275
- # Now fetch CampaignActivity details
228
+ # Then, fetch CampaignActivity details
276
229
  CampaignActivity.remote.connect()
277
230
 
278
231
  activities = CampaignActivity.objects.filter(
@@ -304,12 +257,13 @@ class Command(BaseCommand):
304
257
  update_fields=['role', 'subject', 'preheader', 'html_content']
305
258
  )
306
259
 
307
- # Convert API id to Django pk for related objects
308
- self.set_related_object_pks(
309
- CampaignActivity,
310
- objs_w_pks,
311
- per_obj_list_of_related_objs,
312
- )
260
+ # Set Django object PK on related objects
261
+ if any(per_obj_list_of_related_objs):
262
+ self.set_related_object_pks(
263
+ CampaignActivity,
264
+ objs_w_pks,
265
+ per_obj_list_of_related_objs,
266
+ )
313
267
 
314
268
  # Reshape related_objs for efficiency
315
269
  dict_of_related_objs = defaultdict(list)
@@ -17,7 +17,7 @@ from requests.models import Response
17
17
  from django.conf import settings
18
18
  from django.core.exceptions import ImproperlyConfigured
19
19
  from django.db import models
20
- from django.db.models import signals, Model
20
+ from django.db.models import signals
21
21
  from django.db.models.manager import Manager
22
22
  from django.db.models.query import QuerySet
23
23
  from django.http import HttpRequest, Http404
@@ -32,7 +32,7 @@ from django_ctct.vendor import mute_signals
32
32
  if TYPE_CHECKING:
33
33
  from django_ctct.models import (
34
34
  JsonDict, RelatedObjects,
35
- EndpointMixin, CTCTModel, CTCTEndpointModel,
35
+ EndpointMixin, SerialModel, CTCTModel, CTCTEndpointModel,
36
36
  Token, ContactList, Contact,
37
37
  EmailCampaign, CampaignActivity, CampaignSummary,
38
38
  )
@@ -40,6 +40,7 @@ if TYPE_CHECKING:
40
40
  T = TypeVar('T', bound='EndpointMixin')
41
41
  E = TypeVar('E', bound='CTCTEndpointModel')
42
42
  C = TypeVar('C', bound='CTCTModel')
43
+ S = TypeVar('S', bound='SerialModel')
43
44
 
44
45
 
45
46
  class ConnectionManagerMixin(Manager[T]):
@@ -183,13 +184,13 @@ class TokenRemoteManager(ConnectionManagerMixin['Token'], Manager['Token']):
183
184
  return token
184
185
 
185
186
 
186
- class Serializer(Manager[C]):
187
+ class Serializer(Manager[S]):
187
188
 
188
189
  TS_FORMAT: ClassVar[str] = '%Y-%m-%dT%H:%M:%SZ'
189
190
 
190
191
  def serialize(
191
192
  self,
192
- obj: C,
193
+ obj: S,
193
194
  field_types: Literal['editable', 'readonly', 'all'] = 'editable',
194
195
  ) -> JsonDict:
195
196
  """Convert from Django object to API request body."""
@@ -241,21 +242,15 @@ class Serializer(Manager[C]):
241
242
  data[field_name] = list(map(str, qs))
242
243
  elif hasattr(value.model, 'serializer'):
243
244
  # ReverseForeignKey: serialize QuerySet
245
+ if value.model.__name__ == 'ContactCustomField':
246
+ # Model behaves as a through model, must get related ids
247
+ field_types = 'all'
244
248
  qs = value.all()
245
249
  data[field_name] = [
246
250
  qs.model.serializer.serialize(o, field_types)
247
251
  for o in qs
248
252
  ]
249
- elif value.model.__name__ == 'ContactCustomField':
250
- # TODO: GH #14
251
- data[field_name] = [
252
- {
253
- 'custom_field_id': str(row['custom_field__api_id']),
254
- 'value': row['value'],
255
- }
256
- for row in value.values('custom_field__api_id', 'value')
257
- ]
258
- elif isinstance(value, Model):
253
+ elif isinstance(value, models.Model):
259
254
  raise NotImplementedError
260
255
  else:
261
256
  raise NotImplementedError
@@ -301,54 +296,50 @@ class Serializer(Manager[C]):
301
296
  ) -> tuple[JsonDict, list[RelatedObjects]]:
302
297
  """Deserialize ManyToManyFields and ReverseForeignKeys."""
303
298
 
304
- from django_ctct.models import is_ctct, is_model
299
+ from django_ctct.models import is_model, is_serial
305
300
 
301
+ related_objs: RelatedObjects
306
302
  list_of_related_objs: list[RelatedObjects] = []
307
- objs: list[Model]
303
+ objs: list[models.Model]
308
304
 
309
305
  _, m2ms, _, rfks = get_related_fields(self.model)
310
306
  for rfk_field in filter(lambda f: f.name in data, rfks):
311
307
  # Reverse ForeignKeys get deserialized into model instances
312
- related_model = rfk_field.related_model
308
+ RelatedModel = rfk_field.related_model
313
309
  parent = {rfk_field.remote_field.attname: parent_pk}
314
-
315
- if is_model(related_model) and (
316
- related_model.__name__ == 'ContactCustomField'
317
- ):
318
- # TODO: GH #14
319
- objs = [
320
- related_model(**datum | parent)
321
- for datum in data.pop(rfk_field.name)
322
- ]
323
- elif is_ctct(related_model):
310
+ if is_serial(RelatedModel):
324
311
  objs = [
325
- related_model.serializer.deserialize(datum | parent)[0]
312
+ RelatedModel.serializer.deserialize(datum | parent)[0]
326
313
  for datum in data.pop(rfk_field.name)
327
314
  ]
328
315
  else:
329
316
  continue
330
317
 
331
318
  if objs:
332
- related_objs = (related_model, objs)
319
+ related_objs = (RelatedModel, objs)
333
320
  list_of_related_objs.append(related_objs)
334
321
 
335
322
  for m2m_field in filter(lambda f: f.name in data, m2ms):
336
323
  # ManyToManyFields get deserialized into "through model" instances
337
- # TODO: GH #12, why are we setting contact_id = api_id?
338
- through_model = m2m_field.remote_field.through
339
- if through_model is None:
340
- continue
324
+ # NOTE: Contact.custom_field_set is handled by rfk of ContactCustomField
325
+ ThroughModel = m2m_field.remote_field.through
326
+ RelatedModel = m2m_field.related_model
341
327
 
342
- objs = [
343
- through_model(**{
344
- m2m_field.m2m_column_name(): data['api_id'],
345
- m2m_field.m2m_reverse_name(): related_obj_api_id,
346
- })
347
- for related_obj_api_id in data.pop(m2m_field.name)
348
- ]
349
- if objs:
350
- related_objs = (through_model, objs)
351
- list_of_related_objs.append(related_objs)
328
+ if is_model(ThroughModel) and is_model(RelatedModel):
329
+ related_obj_pks = RelatedModel.objects.filter( # type: ignore
330
+ api_id__in=data.pop(m2m_field.name)
331
+ ).values_list('pk', flat=True)
332
+
333
+ objs = [
334
+ ThroughModel(**{
335
+ m2m_field.m2m_column_name(): parent_pk, # Might be None
336
+ m2m_field.m2m_reverse_name(): related_obj_pk,
337
+ })
338
+ for related_obj_pk in related_obj_pks
339
+ ]
340
+ if objs:
341
+ related_objs = (ThroughModel, objs)
342
+ list_of_related_objs.append(related_objs)
352
343
 
353
344
  return (data, list_of_related_objs)
354
345
 
@@ -356,12 +347,13 @@ class Serializer(Manager[C]):
356
347
  self,
357
348
  data: JsonDict,
358
349
  pk: Optional[int] = None,
359
- ) -> tuple[C, list[RelatedObjects]]:
350
+ ) -> tuple[S, list[RelatedObjects]]:
360
351
  """Convert from API response body to Django object."""
361
352
 
362
353
  # If API_ID_LABEL is not a model field name, it will be removed later
363
354
  data = data.copy()
364
- data['api_id'] = data[self.model.API_ID_LABEL]
355
+ if hasattr(self.model, 'API_ID_LABEL'):
356
+ data['api_id'] = data[self.model.API_ID_LABEL]
365
357
 
366
358
  # Clean field values, must be done before field restriction
367
359
  model_fields = self.model._meta.get_fields()
@@ -382,6 +374,13 @@ class Serializer(Manager[C]):
382
374
  if k in [getattr(f, 'attname', f.name) for f in model_fields]
383
375
  }
384
376
 
377
+ # Convert any remaining API ids to Django PKs
378
+ for k, v in data.items():
379
+ if k.endswith('_id') and isinstance(v, str):
380
+ RelatedModel = self.model._meta.get_field(k).related_model
381
+ if RelatedModel is not None:
382
+ data[k] = RelatedModel.objects.get(api_id=v).pk # type: ignore
383
+
385
384
  if pk:
386
385
  # Preserve unrelated db fields (e.g. EmailCampaign.send_preview)
387
386
  obj = self.model.objects.get(pk=pk)
@@ -424,9 +423,9 @@ class RemoteManager(
424
423
 
425
424
  # NOTE: We don't need to do anything with `related_objs` since they were
426
425
  # set locally before the API request.
427
- # TODO: GH #11?
428
426
  obj, _ = self.deserialize(data, pk=pk)
429
427
 
428
+ # TODO: GH #11?
430
429
  # Overwrite local obj with CTCT's response
431
430
  with mute_signals(signals.post_save):
432
431
  obj.save()
@@ -490,7 +489,7 @@ class RemoteManager(
490
489
  data = next(iter(metadata.values()))
491
490
  list_of_tuples += map(self.deserialize, data)
492
491
 
493
- if links is not None:
492
+ if links:
494
493
  endpoint = links['next']['href']
495
494
  else:
496
495
  paginated = False
@@ -522,9 +521,9 @@ class RemoteManager(
522
521
 
523
522
  # NOTE: We don't need to do anything with `related_objs` since they were
524
523
  # set locally before the API request.
525
- # TODO: GH #11?
526
524
  obj, _ = self.deserialize(data, pk=pk)
527
525
 
526
+ # TODO: GH #11?
528
527
  # Overwrite local obj with CTCT's response
529
528
  with mute_signals(signals.post_save):
530
529
  obj.save()
@@ -602,7 +601,7 @@ class ContactListRemoteManager(RemoteManager['ContactList']):
602
601
 
603
602
  from django_ctct.models import is_ctct
604
603
 
605
- Contact = self.model._meta.get_field('members').related_model
604
+ Contact = self.model._meta.get_field('members').related_model # type: ignore # noqa: E501
606
605
  if is_ctct(Contact) and hasattr(Contact, 'API_ENDPOINT_BULK_LIMIT'):
607
606
  step_size = Contact.API_ENDPOINT_BULK_LIMIT
608
607
  else:
@@ -664,15 +663,15 @@ class ContactRemoteManager(RemoteManager['Contact']):
664
663
  when creating Contacts that may already exist in ConstantContact's
665
664
  database, even if they've been "deleted" before.
666
665
 
667
- Updates to existing contacts are partial updates. This endpoint only
668
- updates the fields that are included in the request body. Updates append
669
- new contact lists or custom fields to the existing `list_memberships` or
670
- `custom_fields` arrays.
666
+ Updates to existing contacts are partial updates and this endpoint will
667
+ only update the fields that are included in the request body. Updates
668
+ append new contact lists or custom fields the existing `list_memberships`
669
+ or `custom_fields` arrays. As a result, we cannot use this endpoint to
670
+ remove a list from `list_memberships`, but must use `.update()`.
671
671
 
672
672
  The PUT call (e.g. just using update()) will overwrite all properties not
673
673
  included in the request body with NULL, so the `serialize()` method must
674
- includes all important fields. While the `create_or_update()` method
675
- supports partial updates, it won't allow us to remove a ContactList.
674
+ includes all important fields.
676
675
 
677
676
  """
678
677
 
@@ -812,9 +811,9 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
812
811
 
813
812
  # NOTE: We don't need to do anything with `related_objs` since they were
814
813
  # set locally before the API request.
815
- # TODO: GH #11?
816
814
  obj, _ = self.deserialize(data, pk=pk)
817
815
 
816
+ # TODO: GH #11?
818
817
  # Overwrite local obj with CTCT's response
819
818
  with mute_signals(signals.post_save):
820
819
  obj.save()
@@ -1,4 +1,4 @@
1
- # Generated by Django 4.2.20 on 2025-06-04 15:01
1
+ # Generated by Django 4.2.20 on 2025-12-03 18:31
2
2
 
3
3
  from django.conf import settings
4
4
  from django.db import migrations, models
@@ -64,7 +64,7 @@ class Migration(migrations.Migration):
64
64
  ('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At')),
65
65
  ('updated_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Updated At')),
66
66
  ('api_id', models.UUIDField(default=None, null=True, unique=True, verbose_name='API ID')),
67
- ('label', models.CharField(help_text='The display name for the custom_field shown in the UI as free-form text', max_length=50, verbose_name='Label')),
67
+ ('label', models.CharField(help_text='The display name for the custom_field shown in the UI as free-form text', max_length=50, unique=True, verbose_name='Label')),
68
68
  ('type', models.CharField(choices=[('string', 'Text'), ('date', 'Date')], default='string', help_text='Specifies the type of value the custom_field field accepts', max_length=6, verbose_name='Type')),
69
69
  ],
70
70
  options={
@@ -79,7 +79,7 @@ class Migration(migrations.Migration):
79
79
  ('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At')),
80
80
  ('updated_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Updated At')),
81
81
  ('api_id', models.UUIDField(default=None, null=True, unique=True, verbose_name='API ID')),
82
- ('name', models.CharField(max_length=80, verbose_name='Name')),
82
+ ('name', models.CharField(max_length=80, unique=True, verbose_name='Name')),
83
83
  ('scheduled_datetime', models.DateTimeField(blank=True, help_text='Leave blank to unschedule', null=True, verbose_name='Scheduled')),
84
84
  ('send_preview', models.BooleanField(default=False, verbose_name='Send Preview')),
85
85
  ('current_status', models.CharField(choices=[('NONE', 'Processing'), ('DRAFT', 'Draft'), ('SCHEDULED', 'Scheduled'), ('EXECUTING', 'Executing'), ('DONE', 'Sent'), ('ERROR', 'Error'), ('REMOVED', 'Removed')], default='DRAFT', max_length=20, verbose_name='Current Status')),
@@ -169,6 +169,11 @@ class Migration(migrations.Migration):
169
169
  'verbose_name_plural': 'Custom Fields',
170
170
  },
171
171
  ),
172
+ migrations.AddField(
173
+ model_name='contact',
174
+ name='custom_field_set',
175
+ field=models.ManyToManyField(through='django_ctct.ContactCustomField', to='django_ctct.customfield'),
176
+ ),
172
177
  migrations.AddField(
173
178
  model_name='contact',
174
179
  name='list_memberships',
@@ -217,6 +222,18 @@ class Migration(migrations.Migration):
217
222
  'verbose_name_plural': 'Email Campaign Activities',
218
223
  },
219
224
  ),
225
+ migrations.AddConstraint(
226
+ model_name='contactstreetaddress',
227
+ constraint=models.UniqueConstraint(fields=('contact', 'kind'), name='django_ctct_unique_street_address'),
228
+ ),
229
+ migrations.AddConstraint(
230
+ model_name='contactphonenumber',
231
+ constraint=models.UniqueConstraint(fields=('contact', 'kind'), name='django_ctct_unique_phone_number'),
232
+ ),
233
+ migrations.AddConstraint(
234
+ model_name='contactcustomfield',
235
+ constraint=models.UniqueConstraint(fields=('contact', 'custom_field'), name='django_ctct_unique_custom_field'),
236
+ ),
220
237
  migrations.AddConstraint(
221
238
  model_name='campaignactivity',
222
239
  constraint=models.UniqueConstraint(fields=('campaign', 'role'), name='django_ctct_unique_campaign_activity'),
@@ -166,14 +166,24 @@ class Token(CreatedAtMixin, EndpointMixin, Model):
166
166
  return data
167
167
 
168
168
 
169
- class CTCTModel(Model):
170
- """Common CTCT model methods and properties."""
171
-
169
+ class SerialModel(Model):
172
170
  API_ID_LABEL: str
173
171
  API_EDITABLE_FIELDS: tuple[str, ...] = tuple()
174
172
  API_READONLY_FIELDS: tuple[str, ...] = (
175
173
  'api_id',
176
174
  )
175
+
176
+ # Must explicitly specify both
177
+ objects: ClassVar[models.Manager[Self]] = models.Manager()
178
+ serializer: ClassVar[Serializer[Self]] = Serializer()
179
+
180
+ class Meta:
181
+ abstract = True
182
+
183
+
184
+ class CTCTModel(SerialModel):
185
+ """Common CTCT model methods and properties."""
186
+
177
187
  API_MAX_LENGTH: dict[str, int] = {}
178
188
 
179
189
  api_id = models.UUIDField(
@@ -183,10 +193,6 @@ class CTCTModel(Model):
183
193
  verbose_name=_('API ID'),
184
194
  )
185
195
 
186
- # Must explicitly specify both
187
- objects: ClassVar[models.Manager[Self]] = models.Manager()
188
- serializer: ClassVar[Serializer[Self]] = Serializer()
189
-
190
196
  class Meta:
191
197
  abstract = True
192
198
 
@@ -289,6 +295,66 @@ class ContactList(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
289
295
  return self.name
290
296
 
291
297
 
298
+ class ContactCustomField(SerialModel):
299
+ """Django implementation of a CTCT Contact's CustomField.
300
+
301
+ Notes
302
+ -----
303
+ CTCT does not provide UUIDs for these, so we do not inherit from CTCTModel.
304
+
305
+ """
306
+
307
+ API_EDITABLE_FIELDS = (
308
+ 'value',
309
+ )
310
+ API_READONLY_FIELDS = (
311
+ 'custom_field_id',
312
+ )
313
+ API_MAX_LENGTH = {
314
+ 'value': 255,
315
+ }
316
+
317
+ contact = models.ForeignKey(
318
+ 'Contact',
319
+ related_name='custom_fields',
320
+ on_delete=models.CASCADE,
321
+ verbose_name=_('Contact'),
322
+ )
323
+ custom_field = models.ForeignKey(
324
+ 'CustomField',
325
+ related_name='contacts',
326
+ on_delete=models.CASCADE,
327
+ verbose_name=_('Field'),
328
+ )
329
+
330
+ value = models.CharField(
331
+ max_length=API_MAX_LENGTH['value'],
332
+ verbose_name=_('Value'),
333
+ )
334
+
335
+ class Meta:
336
+ verbose_name = _('Custom Field')
337
+ verbose_name_plural = _('Custom Fields')
338
+
339
+ constraints = [
340
+ models.UniqueConstraint(
341
+ fields=['contact', 'custom_field'],
342
+ name='django_ctct_unique_custom_field',
343
+ ),
344
+ # models.CheckConstraint( # TODO: GH #8
345
+ # check=Q(contact__custom_fields__count__lte=ContactRemoteManager.API_MAX_NUM['custom_fields']),
346
+ # name='django_ctct_limit_custom_fields',
347
+ # ),
348
+ ]
349
+
350
+ def __str__(self) -> str:
351
+ try:
352
+ s = f'[{self.custom_field.label}] {self.value}'
353
+ except CustomField.DoesNotExist:
354
+ s = super().__str__()
355
+ return s
356
+
357
+
292
358
  class CustomField(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
293
359
  """Django implementation of a CTCT Contact's CustomField."""
294
360
 
@@ -303,13 +369,11 @@ class CustomField(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
303
369
  )
304
370
  API_READONLY_FIELDS = (
305
371
  'api_id',
306
- 'name',
307
372
  'created_at',
308
373
  'updated_at',
309
374
  )
310
375
  API_MAX_LENGTH = {
311
376
  'label': 50,
312
- 'name': 50,
313
377
  }
314
378
 
315
379
  TYPES = (
@@ -320,6 +384,7 @@ class CustomField(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
320
384
  # API editable fields
321
385
  label = models.CharField(
322
386
  max_length=API_MAX_LENGTH['label'],
387
+ unique=True,
323
388
  verbose_name=_('Label'),
324
389
  help_text=_(
325
390
  'The display name for the custom_field shown in the UI as free-form text'
@@ -466,6 +531,10 @@ class Contact(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
466
531
  verbose_name=_('List Memberships'),
467
532
  blank=True,
468
533
  )
534
+ custom_field_set = models.ManyToManyField(
535
+ CustomField,
536
+ through=ContactCustomField, # c.f. django-stubs GH PR #1719
537
+ )
469
538
 
470
539
  permission_to_send = models.CharField(
471
540
  max_length=20,
@@ -676,16 +745,16 @@ class ContactPhoneNumber(CreatedAtMixin, UpdatedAtMixin, CTCTModel):
676
745
  verbose_name = _('Phone Number')
677
746
  verbose_name_plural = _('Phone Numbers')
678
747
 
679
- # constraints = [
680
- # models.UniqueConstraint( # TODO: GH #7
681
- # fields=['contact', 'kind'],
682
- # name='django_ctct_unique_phone_number',
683
- # ),
684
- # models.CheckConstraint( # TODO: GH #8
685
- # check=Q(contact__phone_numbers__count__lte=ContactRemoteManager.API_MAX_NUM['phone_numbers'])
686
- # name='django_ctct_limit_phone_numbers',
687
- # ),
688
- # ]
748
+ constraints = [
749
+ models.UniqueConstraint(
750
+ fields=['contact', 'kind'],
751
+ name='django_ctct_unique_phone_number',
752
+ ),
753
+ # models.CheckConstraint( # TODO: GH #8
754
+ # check=Q(contact__phone_numbers__count__lte=ContactRemoteManager.API_MAX_NUM['phone_numbers'])
755
+ # name='django_ctct_limit_phone_numbers',
756
+ # ),
757
+ ]
689
758
 
690
759
  def __str__(self) -> str:
691
760
  return f'[{self.get_kind_display()}] {self.phone_number}'
@@ -777,16 +846,16 @@ class ContactStreetAddress(CreatedAtMixin, UpdatedAtMixin, CTCTModel):
777
846
  verbose_name = _('Street Address')
778
847
  verbose_name_plural = _('Street Addresses')
779
848
 
780
- # constraints = [
781
- # models.UniqueConstraint( # TODO: GH #7
782
- # fields=['contact', 'kind'],
783
- # name='django_ctct_unique_street_address',
784
- # ),
785
- # models.CheckConstraint( # TODO: GH #8
786
- # check=Q(contact__street_addresses__count__lte=ContactRemoteManager.API_MAX_NUM['street_addresses']),
787
- # name='django_ctct_limit_street_addresses',
788
- # ),
789
- # ]
849
+ constraints = [
850
+ models.UniqueConstraint(
851
+ fields=['contact', 'kind'],
852
+ name='django_ctct_unique_street_address',
853
+ ),
854
+ # models.CheckConstraint( # TODO: GH #8
855
+ # check=Q(contact__street_addresses__count__lte=ContactRemoteManager.API_MAX_NUM['street_addresses']),
856
+ # name='django_ctct_limit_street_addresses',
857
+ # ),
858
+ ]
790
859
 
791
860
  def __str__(self) -> str:
792
861
  field_names = ['street', 'city', 'state']
@@ -816,65 +885,6 @@ class ContactStreetAddress(CreatedAtMixin, UpdatedAtMixin, CTCTModel):
816
885
  return cls.clean_remote_string('country', data)
817
886
 
818
887
 
819
- # TODO: GH #14
820
- class ContactCustomField(models.Model):
821
- """Django implementation of a CTCT Contact's CustomField.
822
-
823
- Notes
824
- -----
825
- CTCT does not provide UUIDs for these, so we do not inherit from CTCTModel.
826
-
827
- """
828
-
829
- API_EDITABLE_FIELDS = (
830
- 'custom_field_id',
831
- 'value',
832
- )
833
- API_MAX_LENGTH = {
834
- 'value': 255,
835
- }
836
-
837
- contact = models.ForeignKey(
838
- Contact,
839
- on_delete=models.CASCADE,
840
- related_name='custom_fields',
841
- verbose_name=_('Contact'),
842
- )
843
- custom_field = models.ForeignKey(
844
- CustomField,
845
- on_delete=models.CASCADE,
846
- related_name='contacts',
847
- verbose_name=_('Field'),
848
- )
849
-
850
- value = models.CharField(
851
- max_length=API_MAX_LENGTH['value'],
852
- verbose_name=_('Value'),
853
- )
854
-
855
- class Meta:
856
- verbose_name = _('Custom Field')
857
- verbose_name_plural = _('Custom Fields')
858
-
859
- # constraints = [
860
- # models.UniqueConstraint( # TODO: GH #7
861
- # fields=['contact', 'custom_field'],
862
- # name='django_ctct_unique_custom_field',
863
- # ),
864
- # models.CheckConstraint( # TODO: GH #8
865
- # check=Q(contact__custom_fields__count__lte=ContactRemoteManager.API_MAX_NUM['custom_fields']),
866
- # name='django_ctct_limit_custom_fields',
867
- # ),
868
- # ]
869
-
870
- def __str__(self) -> str:
871
- try:
872
- s = f'[{self.custom_field.label}] {self.value}'
873
- except CustomField.DoesNotExist:
874
- s = super().__str__()
875
- return s
876
-
877
-
878
888
  class EmailCampaign(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
879
889
  """Django implementation of a CTCT EmailCampaign."""
880
890
 
@@ -913,7 +923,7 @@ class EmailCampaign(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
913
923
  # API editable fields
914
924
  name = models.CharField(
915
925
  max_length=API_MAX_LENGTH['name'],
916
- # unique=True, # TODO: GH #7
926
+ unique=True,
917
927
  verbose_name=_('Name'),
918
928
  )
919
929
  scheduled_datetime = models.DateTimeField(
@@ -1306,12 +1316,18 @@ class CampaignSummary(CTCTEndpointModel):
1306
1316
 
1307
1317
 
1308
1318
  def is_ctct(
1309
- val: Type[BaseModel] | Literal['self']
1319
+ val: Type[BaseModel] | Literal['self'] | None
1310
1320
  ) -> TypeGuard[Type[CTCTModel]]:
1311
1321
  return isinstance(val, type) and issubclass(val, CTCTModel)
1312
1322
 
1313
1323
 
1314
1324
  def is_model(
1315
- val: Type[BaseModel] | Literal['self']
1325
+ val: Type[BaseModel] | Literal['self'] | None
1316
1326
  ) -> TypeGuard[Type[Model]]:
1317
1327
  return isinstance(val, type) and issubclass(val, Model)
1328
+
1329
+
1330
+ def is_serial(
1331
+ val: Type[BaseModel] | Literal['self'] | None
1332
+ ) -> TypeGuard[Type[SerialModel]]:
1333
+ return isinstance(val, type) and issubclass(val, SerialModel)
@@ -18,6 +18,9 @@ RelatedFields: TypeAlias = tuple[
18
18
 
19
19
 
20
20
  def to_dt(s: str, ts_format: str = '%Y-%m-%dT%H:%M:%SZ') -> dt.datetime:
21
+ if '.' in s:
22
+ # Remove milliseconds
23
+ s = s.split('.')[0] + 'Z'
21
24
  return timezone.make_aware(dt.datetime.strptime(s, ts_format))
22
25
 
23
26
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-ctct"
3
- version = "0.0.1.dev4"
3
+ version = "0.0.1.dev5"
4
4
  description = "A Django interface for the Constant Contact API"
5
5
  authors = [
6
6
  {name = "Geoffrey Eisenbarth",email = "geoffrey.eisenbarth@gmail.com"}
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "ratelimit (>=2.2.1,<3.0.0)",
15
15
  "tqdm (>=4.67.1,<5.0.0)",
16
16
  "pyjwt[crypto] (>=2.10.1,<3.0.0)",
17
+ "mypy (>=1.19.0,<2.0.0)",
17
18
  ]
18
19
 
19
20