django-ctct 0.0.1.dev3__tar.gz → 0.0.1.dev4__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,20 +1,19 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-ctct
3
- Version: 0.0.1.dev3
3
+ Version: 0.0.1.dev4
4
4
  Summary: A Django interface for the Constant Contact API
5
5
  License: MIT
6
6
  Author: Geoffrey Eisenbarth
7
7
  Author-email: geoffrey.eisenbarth@gmail.com
8
- Requires-Python: >=3.9
8
+ Requires-Python: >=3.10
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.9
12
11
  Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Requires-Dist: django (>=3.2.0,<6.0.0)
17
- Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
16
+ Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0)
18
17
  Requires-Dist: ratelimit (>=2.2.1,<3.0.0)
19
18
  Requires-Dist: requests (>=2.32.3,<3.0.0)
20
19
  Requires-Dist: tqdm (>=4.67.1,<5.0.0)
@@ -1,6 +1,5 @@
1
1
  import functools
2
- from typing import List, Optional
3
-
2
+ from typing import TypeVar, ParamSpec, Generic, Optional, Callable, Iterable
4
3
  from requests.exceptions import HTTPError
5
4
 
6
5
  from django import forms
@@ -10,16 +9,16 @@ from django.contrib.auth import get_user_model
10
9
  from django.db.models import signals
11
10
  from django.db.models import Model, QuerySet, When, Case, F, FloatField
12
11
  from django.db.models.functions import Cast
13
- from django.forms import ModelForm
12
+ from django.forms import ModelForm, BaseFormSet
14
13
  from django.forms.models import BaseInlineFormSet
15
14
  from django.http import HttpRequest
16
15
  from django.urls import reverse
17
16
  from django.utils.html import format_html
18
17
  from django.utils.formats import date_format
19
- from django.utils.translation import gettext_lazy as _
18
+ from django.utils.translation import gettext as _
20
19
 
21
20
  from django_ctct.models import (
22
- CTCTRemoteModel, ContactList, CustomField,
21
+ CTCTEndpointModel, ContactList, CustomField,
23
22
  Contact,
24
23
  ContactCustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
25
24
  EmailCampaign, CampaignActivity, CampaignSummary,
@@ -28,46 +27,81 @@ from django_ctct.signals import remote_save, remote_delete
28
27
  from django_ctct.vendor import mute_signals
29
28
 
30
29
 
31
- def catch_api_errors(func):
32
- """Decorator to catch HTTP errors from CTCT API."""
30
+ P = ParamSpec('P')
31
+ M = TypeVar('M', bound=Model)
32
+ E = TypeVar('E', bound=CTCTEndpointModel)
33
+
34
+
35
+ def catch_api_errors(func: Callable[P, None]) -> Callable[P, None]:
36
+ """Decorator to catch HTTP errors from CTCT API.
37
+
38
+ Notes
39
+ -----
40
+ If the wrapped functioms (e.g., `save_related()` didn't return `None`, then
41
+ we would need to adjust the types above to `Callable[P, R]`, where R is
42
+ defined as TypeVar('R').
43
+
44
+ """
33
45
 
34
46
  @functools.wraps(func)
35
- def wrapper(self, request, *args, **kwargs):
47
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
36
48
  try:
37
- return func(self, request, *args, **kwargs)
49
+ return func(*args, **kwargs)
38
50
  except HTTPError as e:
39
51
  if getattr(settings, 'CTCT_RAISE_FOR_API', False):
40
52
  raise e
41
53
  else:
42
- message = format_html(_(f"ConstantContact: {e}"))
43
- self.message_user(request, message, level=messages.ERROR)
54
+ self, request, *_ = args
55
+ assert isinstance(self, admin.ModelAdmin)
56
+ assert isinstance(request, HttpRequest)
57
+ self.message_user(
58
+ request=request,
59
+ message=format_html(_(f"ConstantContact: {e}")),
60
+ level=messages.ERROR,
61
+ )
44
62
 
45
63
  return wrapper
46
64
 
47
65
 
48
66
  class RemoteSyncMixin:
49
- def is_synced(self, obj: CTCTRemoteModel) -> bool:
67
+ @admin.display(
68
+ boolean=True,
69
+ description=_('Synced'),
70
+ ordering='api_id',
71
+ )
72
+ def is_synced(self, obj: CTCTEndpointModel) -> bool:
50
73
  return (obj.api_id is not None)
51
- is_synced.boolean = True
52
- is_synced.admin_order_field = 'api_id'
53
- is_synced.short_description = _('Synced')
54
74
 
55
75
 
56
- class ViewModelAdmin(admin.ModelAdmin):
76
+ class ViewModelAdmin(admin.ModelAdmin[Model]):
57
77
  """Remove CRUD permissions."""
58
78
 
59
- def has_add_permission(self, request: HttpRequest, obj=None):
79
+ actions = None
80
+
81
+ def has_add_permission(
82
+ self,
83
+ request: HttpRequest,
84
+ obj: Optional[Model] = None,
85
+ ) -> bool:
60
86
  """Prevent creation in the Django admin."""
61
87
  return False
62
88
 
63
- def has_change_permission(self, request: HttpRequest, obj=None):
89
+ def has_change_permission(
90
+ self,
91
+ request: HttpRequest,
92
+ obj: Optional[Model] = None,
93
+ ) -> bool:
64
94
  """Prevent updates in the Django admin."""
65
95
  return False
66
96
 
67
- def get_readonly_fields(self, request: HttpRequest, obj=None):
97
+ def get_readonly_fields(
98
+ self,
99
+ request: HttpRequest,
100
+ obj: Optional[Model] = None,
101
+ ) -> tuple[str, ...]:
68
102
  """Prevent updates in the Django admin."""
69
103
  if obj is not None:
70
- readonly_fields = (
104
+ readonly_fields = tuple(
71
105
  field.name
72
106
  for field in obj._meta.fields
73
107
  if field.name != 'active'
@@ -76,12 +110,18 @@ class ViewModelAdmin(admin.ModelAdmin):
76
110
  readonly_fields = tuple()
77
111
  return readonly_fields
78
112
 
79
- def has_delete_permission(self, request: HttpRequest, obj=None):
80
- """Prevent deletion in the Django admin."""
81
- return False
113
+ def has_delete_permission(
114
+ self,
115
+ request: HttpRequest,
116
+ obj: Optional[Model] = None,
117
+ ) -> bool:
118
+ """Allow superusers to delete objects."""
119
+ return request.user.is_superuser
82
120
 
83
121
 
84
- class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
122
+ class RemoteModelAdmin(
123
+ RemoteSyncMixin, admin.ModelAdmin[E], Generic[E]
124
+ ):
85
125
  """Facilitate remote saving and deleting."""
86
126
 
87
127
  # ChangeView
@@ -92,20 +132,30 @@ class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
92
132
  return sync_admin and not sync_signals
93
133
 
94
134
  @catch_api_errors
95
- def delete_model(self, request: HttpRequest, obj: Model):
135
+ def delete_model(self, request: HttpRequest, obj: Model) -> None:
96
136
  obj.delete()
97
137
  if self.remote_sync:
98
138
  remote_delete(sender=self.model, instance=obj)
99
139
 
100
140
  @catch_api_errors
101
- def delete_queryset(self, request: HttpRequest, queryset: QuerySet):
141
+ def delete_queryset(
142
+ self,
143
+ request: HttpRequest,
144
+ queryset: QuerySet[CTCTEndpointModel],
145
+ ) -> None:
102
146
  if self.remote_sync:
103
147
  queryset.model.remote.bulk_delete(queryset)
104
148
  with mute_signals(signals.pre_delete):
105
149
  queryset.delete()
106
150
 
107
151
  @catch_api_errors
108
- def save_related(self, request: HttpRequest, form, formsets, change):
152
+ def save_related(
153
+ self,
154
+ request: HttpRequest,
155
+ form: ModelForm[CTCTEndpointModel],
156
+ formsets: list[BaseFormSet[ModelForm[Model]]],
157
+ change: bool,
158
+ ) -> None:
109
159
  """Default implementation with an added line for saving remotely.
110
160
 
111
161
  Notes
@@ -114,13 +164,21 @@ class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
114
164
  for saving objects remotely.
115
165
 
116
166
  """
117
- form.save_m2m()
167
+ with mute_signals(signals.m2m_changed):
168
+ # ManyToMany information is sent to CTCT in PUT call
169
+ form.save_m2m()
118
170
  for formset in formsets:
119
171
  self.save_formset(request, form, formset, change=change)
120
172
  self.save_remotely(request, form, formsets, change)
121
173
 
122
174
  @catch_api_errors
123
- def save_remotely(self, request, form, formsets, change):
175
+ def save_remotely(
176
+ self,
177
+ request: HttpRequest,
178
+ form: ModelForm[CTCTEndpointModel],
179
+ formsets: list[BaseFormSet[ModelForm[Model]]],
180
+ change: bool,
181
+ ) -> None:
124
182
  if self.remote_sync:
125
183
  # Remote save the primary object after related objects have been saved
126
184
  remote_save(
@@ -130,7 +188,7 @@ class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
130
188
  )
131
189
 
132
190
 
133
- class ContactListForm(forms.ModelForm):
191
+ class ContactListForm(forms.ModelForm[ContactList]):
134
192
  """Custom widget choices for ContactList admin."""
135
193
 
136
194
  class Meta:
@@ -141,7 +199,7 @@ class ContactListForm(forms.ModelForm):
141
199
  fields = '__all__'
142
200
 
143
201
 
144
- class ContactListAdmin(RemoteModelAdmin):
202
+ class ContactListAdmin(RemoteModelAdmin[ContactList]):
145
203
  """Admin functionality for CTCT ContactLists."""
146
204
 
147
205
  # ListView
@@ -155,13 +213,13 @@ class ContactListAdmin(RemoteModelAdmin):
155
213
  'is_synced',
156
214
  )
157
215
 
216
+ @admin.display(description=_('Membership'))
158
217
  def membership(self, obj: ContactList) -> int:
159
218
  return obj.members.all().count()
160
- membership.short_description = _('Membership')
161
219
 
220
+ @admin.display(description=_('Opt Outs'))
162
221
  def optouts(self, obj: ContactList) -> int:
163
222
  return obj.members.exclude(opt_out_source='').count()
164
- optouts.short_description = _('Opt Outs')
165
223
 
166
224
  # ChangeView
167
225
  form = ContactListForm
@@ -175,7 +233,7 @@ class ContactListAdmin(RemoteModelAdmin):
175
233
  )
176
234
 
177
235
 
178
- class CustomFieldAdmin(RemoteModelAdmin):
236
+ class CustomFieldAdmin(RemoteModelAdmin[CustomField]):
179
237
  """Admin functionality for CTCT CustomFields."""
180
238
 
181
239
  # ListView
@@ -191,44 +249,50 @@ class CustomFieldAdmin(RemoteModelAdmin):
191
249
  exclude = ('api_id', )
192
250
 
193
251
 
194
- class ContactStreetAddressInline(admin.StackedInline):
252
+ class ContactStreetAddressInline(
253
+ admin.StackedInline[ContactStreetAddress, Contact]
254
+ ):
195
255
  """Inline for adding ContactStreetAddresses to a Contact."""
196
256
 
197
257
  model = ContactStreetAddress
198
258
  exclude = ('api_id', )
199
259
 
200
260
  extra = 0
201
- max_num = Contact.remote.API_MAX_STREET_ADDRESSES
261
+ max_num = Contact.API_MAX_NUM['street_addresses']
202
262
 
203
263
 
204
- class ContactPhoneNumberInline(admin.TabularInline):
264
+ class ContactPhoneNumberInline(
265
+ admin.TabularInline[ContactPhoneNumber, Contact]
266
+ ):
205
267
  """Inline for adding ContactPhoneNumbers to a Contact."""
206
268
 
207
269
  model = ContactPhoneNumber
208
270
  exclude = ('api_id', )
209
271
 
210
272
  extra = 0
211
- max_num = Contact.remote.API_MAX_PHONE_NUMBERS
273
+ max_num = Contact.API_MAX_NUM['phone_numbers']
212
274
 
213
275
 
214
- class ContactNoteInline(admin.TabularInline):
276
+ class ContactNoteInline(admin.TabularInline[ContactNote, Contact]):
215
277
  """Inline for adding ContactNotes to a Contact."""
216
278
 
217
279
  model = ContactNote
218
280
  fields = ('content', )
219
281
 
220
282
  extra = 0
221
- max_num = Contact.remote.API_MAX_NOTES
283
+ max_num = Contact.API_MAX_NUM['notes']
222
284
 
223
285
  def has_change_permission(
224
286
  self,
225
287
  request: HttpRequest,
226
- obj: Optional[ContactNote] = None,
288
+ obj: Optional[Contact] = None, # type: ignore[override]
227
289
  ) -> bool:
228
290
  return False
229
291
 
230
292
 
231
- class ContactCustomFieldInline(admin.TabularInline):
293
+ class ContactCustomFieldInline(
294
+ admin.TabularInline[ContactCustomField, Contact]
295
+ ):
232
296
 
233
297
  model = ContactCustomField
234
298
  excldue = ('api_id', )
@@ -236,7 +300,7 @@ class ContactCustomFieldInline(admin.TabularInline):
236
300
  extra = 0
237
301
 
238
302
 
239
- class ContactAdmin(RemoteModelAdmin):
303
+ class ContactAdmin(RemoteModelAdmin[Contact]):
240
304
  """Admin functionality for CTCT Contacts."""
241
305
 
242
306
  # ListView
@@ -263,11 +327,13 @@ class ContactAdmin(RemoteModelAdmin):
263
327
  )
264
328
  empty_value_display = '(None)'
265
329
 
330
+ @admin.display(
331
+ boolean=True,
332
+ description=_('Opted Out'),
333
+ ordering='opt_out_date',
334
+ )
266
335
  def opted_out(self, obj: Contact) -> bool:
267
336
  return bool(obj.opt_out_source)
268
- opted_out.boolean = True
269
- opted_out.admin_order_field = 'opt_out_date'
270
- opted_out.short_description = _('Opted Out')
271
337
 
272
338
  # ChangeView
273
339
  fieldsets = (
@@ -305,8 +371,8 @@ class ContactAdmin(RemoteModelAdmin):
305
371
  self,
306
372
  request: HttpRequest,
307
373
  obj: Optional[Contact] = None,
308
- ) -> List[str]:
309
- readonly_fields = Contact.remote.API_READONLY_FIELDS
374
+ ) -> list[str]:
375
+ readonly_fields = list(Contact.API_READONLY_FIELDS)
310
376
  if obj and obj.opt_out_source and not request.user.is_superuser:
311
377
  readonly_fields.append('list_memberships')
312
378
  return readonly_fields
@@ -314,8 +380,8 @@ class ContactAdmin(RemoteModelAdmin):
314
380
  def save_formset(
315
381
  self,
316
382
  request: HttpRequest,
317
- form: ModelForm,
318
- formset: BaseInlineFormSet,
383
+ form: ModelForm[Contact],
384
+ formset: BaseInlineFormSet[M, Contact, ModelForm[M]],
319
385
  change: bool,
320
386
  ) -> None:
321
387
  """Set the current user as ContactNote author.
@@ -334,7 +400,10 @@ class ContactAdmin(RemoteModelAdmin):
334
400
  if isinstance(instance, ContactNote) and instance.pk is None:
335
401
  instance.author = request.user
336
402
  instance.save()
337
- formset.save_m2m()
403
+
404
+ with mute_signals(signals.m2m_changed):
405
+ # ManyToMany information is sent to CTCT in PUT call
406
+ formset.save_m2m()
338
407
 
339
408
 
340
409
  class ContactNoteAuthorFilter(admin.SimpleListFilter):
@@ -343,13 +412,22 @@ class ContactNoteAuthorFilter(admin.SimpleListFilter):
343
412
  title = _('Author')
344
413
  parameter_name = 'author'
345
414
 
346
- def lookups(self, request, model_admin):
415
+ def lookups(
416
+ self,
417
+ request: HttpRequest,
418
+ model_admin: admin.ModelAdmin[Model],
419
+ ) -> Optional[Iterable[tuple[str, str]]]:
347
420
  authors = get_user_model().objects.exclude(notes__isnull=True)
348
- return [(obj.id, str(obj)) for obj in authors]
421
+ return [(str(obj.id), str(obj)) for obj in authors]
349
422
 
350
- def queryset(self, request, queryset):
351
- if self.value():
352
- queryset = queryset.filter(author_id=self.value())
423
+ def queryset(
424
+ self,
425
+ request: HttpRequest,
426
+ queryset: QuerySet[ContactNote],
427
+ ) -> QuerySet[ContactNote]:
428
+ author_id = self.value()
429
+ if author_id is not None and author_id.isdigit():
430
+ queryset = queryset.filter(author_id=int(author_id))
353
431
  return queryset
354
432
 
355
433
 
@@ -380,6 +458,10 @@ class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
380
458
  ContactNoteAuthorFilter,
381
459
  )
382
460
 
461
+ @admin.display(
462
+ description=_('Contact'),
463
+ ordering='contact__email',
464
+ )
383
465
  def contact_link(self, obj: ContactNote) -> str:
384
466
  url = reverse(
385
467
  'admin:django_ctct_contact_change',
@@ -387,15 +469,17 @@ class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
387
469
  )
388
470
  html = format_html('<a href="{}">{}</a>', url, obj.contact)
389
471
  return html
390
- contact_link.short_description = _('Contact')
391
- contact_link.admin_order_field = 'contact__email'
392
472
 
393
- def has_delete_permission(self, request: HttpRequest, obj=None):
473
+ def has_delete_permission(
474
+ self,
475
+ request: HttpRequest,
476
+ obj: Optional[Model] = None,
477
+ ) -> bool:
394
478
  """Allow superusers to delete Notes."""
395
479
  return request.user.is_superuser
396
480
 
397
481
 
398
- class CampaignActivityInlineForm(forms.ModelForm):
482
+ class CampaignActivityInlineForm(forms.ModelForm[CampaignActivity]):
399
483
  """Custom widget choices for ContactList admin."""
400
484
 
401
485
  html_content = forms.CharField(
@@ -408,7 +492,9 @@ class CampaignActivityInlineForm(forms.ModelForm):
408
492
  fields = '__all__'
409
493
 
410
494
 
411
- class CampaignActivityInline(admin.StackedInline):
495
+ class CampaignActivityInline(
496
+ admin.StackedInline[CampaignActivity, EmailCampaign]
497
+ ):
412
498
  """Inline for adding CampaignActivity to a EmailCampaign."""
413
499
 
414
500
  model = CampaignActivity
@@ -427,17 +513,29 @@ class CampaignActivityInline(admin.StackedInline):
427
513
  extra = 1
428
514
  max_num = 1
429
515
 
430
- def get_readonly_fields(self, request: HttpRequest, obj=None):
431
- readonly_fields = CampaignActivity.remote.API_READONLY_FIELDS
516
+ def get_readonly_fields(
517
+ self,
518
+ request: HttpRequest,
519
+ obj: Optional[CampaignActivity] = None,
520
+ ) -> list[str]:
521
+ readonly_fields = list(CampaignActivity.API_READONLY_FIELDS)
432
522
  if obj and obj.current_status == 'DONE':
433
- readonly_fields += CampaignActivity.remote.API_EDITABLE_FIELDS
523
+ readonly_fields += list(CampaignActivity.API_EDITABLE_FIELDS)
434
524
  return readonly_fields
435
525
 
436
526
 
437
- class EmailCampaignAdmin(RemoteModelAdmin):
438
- """Admin functionality for CTCT EmailCampaigns."""
527
+ class EmailCampaignAdmin(RemoteModelAdmin[EmailCampaign]):
528
+ """Admin functionality for CTCT EmailCampaigns.
529
+
530
+ Notes
531
+ -----
532
+ CTCT does not provide an endpoint for bulk deleting EmailCampaigns, so we set
533
+ `actions` to None.
534
+
535
+ """
439
536
 
440
537
  # ListView
538
+ actions = None
441
539
  search_fields = ('name', )
442
540
  list_display = (
443
541
  'name',
@@ -458,14 +556,24 @@ class EmailCampaignAdmin(RemoteModelAdmin):
458
556
  )
459
557
  inlines = (CampaignActivityInline, )
460
558
 
461
- def get_readonly_fields(self, request: HttpRequest, obj=None):
462
- readonly_fields = EmailCampaign.remote.API_READONLY_FIELDS
559
+ def get_readonly_fields(
560
+ self,
561
+ request: HttpRequest,
562
+ obj: Optional[EmailCampaign] = None,
563
+ ) -> list[str]:
564
+ readonly_fields = list(EmailCampaign.API_READONLY_FIELDS)
463
565
  if obj and obj.current_status == 'DONE':
464
- readonly_fields += ('scheduled_datetime', )
566
+ readonly_fields.append('scheduled_datetime')
465
567
  return readonly_fields
466
568
 
467
569
  @catch_api_errors
468
- def save_remotely(self, request, form, formsets, change) -> None:
570
+ def save_remotely(
571
+ self,
572
+ request: HttpRequest,
573
+ form: ModelForm[EmailCampaign], # type: ignore[override]
574
+ formsets: list[BaseFormSet[ModelForm[Model]]],
575
+ change: bool,
576
+ ) -> None:
469
577
  if self.remote_sync:
470
578
 
471
579
  campaign = form.instance
@@ -498,7 +606,13 @@ class EmailCampaignAdmin(RemoteModelAdmin):
498
606
  # Inform the user
499
607
  self.ctct_message_user(request, form, formsets, change)
500
608
 
501
- def ctct_message_user(self, request, form, formsets, change) -> None:
609
+ def ctct_message_user(
610
+ self,
611
+ request: HttpRequest,
612
+ form: ModelForm[EmailCampaign],
613
+ formsets: list[BaseFormSet[ModelForm[Model]]],
614
+ change: bool,
615
+ ) -> None:
502
616
  """Inform the user of API actions."""
503
617
 
504
618
  campaign = form.instance
@@ -546,7 +660,7 @@ class CampaignSummaryAdmin(ViewModelAdmin):
546
660
  'abuse',
547
661
  )
548
662
 
549
- def get_queryset(self, request):
663
+ def get_queryset(self, request: HttpRequest) -> QuerySet[Model]:
550
664
  qs = super().get_queryset(request)
551
665
  qs = qs.annotate(
552
666
  open_rate=Case(
@@ -559,10 +673,13 @@ class CampaignSummaryAdmin(ViewModelAdmin):
559
673
  )
560
674
  return qs
561
675
 
676
+ @admin.display(
677
+ description=_('Open Rate'),
678
+ ordering='open_rate',
679
+ )
562
680
  def open_rate(self, obj: EmailCampaign) -> str:
681
+ assert hasattr(obj, 'open_rate')
563
682
  return f'{obj.open_rate:0.0%}'
564
- open_rate.admin_order_field = 'open_rate'
565
- open_rate.short_description = _('Open Rate')
566
683
 
567
684
  # ChangeView
568
685
  fieldsets = (
@@ -16,7 +16,7 @@ class CTCTConfig(AppConfig):
16
16
  'CTCT_FROM_EMAIL',
17
17
  ]
18
18
 
19
- def ready(self):
19
+ def ready(self) -> None:
20
20
  # Validate that necessary settings have been defined
21
21
  for value in self.ctct_settings:
22
22
  if not hasattr(settings, value):