django-ctct 0.0.1.dev2__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.dev2
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,23 +1,24 @@
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
7
6
  from django.conf import settings
8
7
  from django.contrib import admin, messages
8
+ from django.contrib.auth import get_user_model
9
9
  from django.db.models import signals
10
- from django.db.models import Model, QuerySet
11
- from django.forms import ModelForm
10
+ from django.db.models import Model, QuerySet, When, Case, F, FloatField
11
+ from django.db.models.functions import Cast
12
+ from django.forms import ModelForm, BaseFormSet
12
13
  from django.forms.models import BaseInlineFormSet
13
14
  from django.http import HttpRequest
15
+ from django.urls import reverse
14
16
  from django.utils.html import format_html
15
17
  from django.utils.formats import date_format
16
- from django.utils.safestring import mark_safe
17
- from django.utils.translation import gettext_lazy as _
18
+ from django.utils.translation import gettext as _
18
19
 
19
20
  from django_ctct.models import (
20
- Token, CTCTRemoteModel, ContactList, CustomField,
21
+ CTCTEndpointModel, ContactList, CustomField,
21
22
  Contact,
22
23
  ContactCustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
23
24
  EmailCampaign, CampaignActivity, CampaignSummary,
@@ -26,46 +27,81 @@ from django_ctct.signals import remote_save, remote_delete
26
27
  from django_ctct.vendor import mute_signals
27
28
 
28
29
 
29
- def catch_api_errors(func):
30
- """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
+ """
31
45
 
32
46
  @functools.wraps(func)
33
- def wrapper(self, request, *args, **kwargs):
47
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
34
48
  try:
35
- return func(self, request, *args, **kwargs)
49
+ return func(*args, **kwargs)
36
50
  except HTTPError as e:
37
51
  if getattr(settings, 'CTCT_RAISE_FOR_API', False):
38
52
  raise e
39
53
  else:
40
- message = format_html(_(f"ConstantContact: {e}"))
41
- 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
+ )
42
62
 
43
63
  return wrapper
44
64
 
45
65
 
46
66
  class RemoteSyncMixin:
47
- 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:
48
73
  return (obj.api_id is not None)
49
- is_synced.boolean = True
50
- is_synced.admin_order_field = 'api_id'
51
- is_synced.short_description = _('Synced')
52
74
 
53
75
 
54
- class ViewModelAdmin(admin.ModelAdmin):
76
+ class ViewModelAdmin(admin.ModelAdmin[Model]):
55
77
  """Remove CRUD permissions."""
56
78
 
57
- 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:
58
86
  """Prevent creation in the Django admin."""
59
87
  return False
60
88
 
61
- 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:
62
94
  """Prevent updates in the Django admin."""
63
95
  return False
64
96
 
65
- 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, ...]:
66
102
  """Prevent updates in the Django admin."""
67
103
  if obj is not None:
68
- readonly_fields = (
104
+ readonly_fields = tuple(
69
105
  field.name
70
106
  for field in obj._meta.fields
71
107
  if field.name != 'active'
@@ -74,44 +110,18 @@ class ViewModelAdmin(admin.ModelAdmin):
74
110
  readonly_fields = tuple()
75
111
  return readonly_fields
76
112
 
77
- def has_delete_permission(self, request: HttpRequest, obj=None):
78
- """Prevent deletion in the Django admin."""
79
- return False
80
-
81
-
82
- class TokenAdmin(ViewModelAdmin):
83
- """Admin functionality for CTCT Tokens."""
84
-
85
- # ListView
86
- list_display_links = None
87
- list_display = (
88
- 'scope',
89
- 'created_at',
90
- 'expires_at',
91
- 'copy_access_token',
92
- 'copy_refresh_token',
93
- )
94
-
95
- def copy_access_token(self, obj: Token) -> str:
96
- html = format_html(
97
- '<button class="button" onclick="{function}">{copy_icon}</button>',
98
- function=f"navigator.clipboard.writeText('{obj.access_token}')",
99
- copy_icon=mark_safe('&#128203;'),
100
- )
101
- return html
102
- copy_access_token.short_description = _('Access Token')
103
-
104
- def copy_refresh_token(self, obj: Token) -> str:
105
- html = format_html(
106
- '<button class="button" onclick="{function}">{copy_icon}</button>',
107
- function=f"navigator.clipboard.writeText('{obj.refresh_token}')",
108
- copy_icon=mark_safe('&#128203;'),
109
- )
110
- return html
111
- copy_refresh_token.short_description = _('Refresh Token')
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
112
120
 
113
121
 
114
- class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
122
+ class RemoteModelAdmin(
123
+ RemoteSyncMixin, admin.ModelAdmin[E], Generic[E]
124
+ ):
115
125
  """Facilitate remote saving and deleting."""
116
126
 
117
127
  # ChangeView
@@ -122,20 +132,30 @@ class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
122
132
  return sync_admin and not sync_signals
123
133
 
124
134
  @catch_api_errors
125
- def delete_model(self, request: HttpRequest, obj: Model):
135
+ def delete_model(self, request: HttpRequest, obj: Model) -> None:
126
136
  obj.delete()
127
137
  if self.remote_sync:
128
138
  remote_delete(sender=self.model, instance=obj)
129
139
 
130
140
  @catch_api_errors
131
- def delete_queryset(self, request: HttpRequest, queryset: QuerySet):
141
+ def delete_queryset(
142
+ self,
143
+ request: HttpRequest,
144
+ queryset: QuerySet[CTCTEndpointModel],
145
+ ) -> None:
132
146
  if self.remote_sync:
133
147
  queryset.model.remote.bulk_delete(queryset)
134
148
  with mute_signals(signals.pre_delete):
135
149
  queryset.delete()
136
150
 
137
151
  @catch_api_errors
138
- 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:
139
159
  """Default implementation with an added line for saving remotely.
140
160
 
141
161
  Notes
@@ -144,13 +164,21 @@ class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
144
164
  for saving objects remotely.
145
165
 
146
166
  """
147
- form.save_m2m()
167
+ with mute_signals(signals.m2m_changed):
168
+ # ManyToMany information is sent to CTCT in PUT call
169
+ form.save_m2m()
148
170
  for formset in formsets:
149
171
  self.save_formset(request, form, formset, change=change)
150
172
  self.save_remotely(request, form, formsets, change)
151
173
 
152
174
  @catch_api_errors
153
- 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:
154
182
  if self.remote_sync:
155
183
  # Remote save the primary object after related objects have been saved
156
184
  remote_save(
@@ -160,7 +188,7 @@ class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
160
188
  )
161
189
 
162
190
 
163
- class ContactListForm(forms.ModelForm):
191
+ class ContactListForm(forms.ModelForm[ContactList]):
164
192
  """Custom widget choices for ContactList admin."""
165
193
 
166
194
  class Meta:
@@ -171,7 +199,7 @@ class ContactListForm(forms.ModelForm):
171
199
  fields = '__all__'
172
200
 
173
201
 
174
- class ContactListAdmin(RemoteModelAdmin):
202
+ class ContactListAdmin(RemoteModelAdmin[ContactList]):
175
203
  """Admin functionality for CTCT ContactLists."""
176
204
 
177
205
  # ListView
@@ -185,13 +213,13 @@ class ContactListAdmin(RemoteModelAdmin):
185
213
  'is_synced',
186
214
  )
187
215
 
216
+ @admin.display(description=_('Membership'))
188
217
  def membership(self, obj: ContactList) -> int:
189
218
  return obj.members.all().count()
190
- membership.short_description = _('Membership')
191
219
 
220
+ @admin.display(description=_('Opt Outs'))
192
221
  def optouts(self, obj: ContactList) -> int:
193
222
  return obj.members.exclude(opt_out_source='').count()
194
- optouts.short_description = _('Opt Outs')
195
223
 
196
224
  # ChangeView
197
225
  form = ContactListForm
@@ -205,7 +233,7 @@ class ContactListAdmin(RemoteModelAdmin):
205
233
  )
206
234
 
207
235
 
208
- class CustomFieldAdmin(RemoteModelAdmin):
236
+ class CustomFieldAdmin(RemoteModelAdmin[CustomField]):
209
237
  """Admin functionality for CTCT CustomFields."""
210
238
 
211
239
  # ListView
@@ -213,6 +241,7 @@ class CustomFieldAdmin(RemoteModelAdmin):
213
241
  'label',
214
242
  'type',
215
243
  'created_at',
244
+ 'updated_at',
216
245
  'is_synced',
217
246
  )
218
247
 
@@ -220,54 +249,58 @@ class CustomFieldAdmin(RemoteModelAdmin):
220
249
  exclude = ('api_id', )
221
250
 
222
251
 
223
- class ContactStreetAddressInline(admin.StackedInline):
252
+ class ContactStreetAddressInline(
253
+ admin.StackedInline[ContactStreetAddress, Contact]
254
+ ):
224
255
  """Inline for adding ContactStreetAddresses to a Contact."""
225
256
 
226
257
  model = ContactStreetAddress
227
258
  exclude = ('api_id', )
228
259
 
229
260
  extra = 0
230
- max_num = Contact.remote.API_MAX_STREET_ADDRESSES
261
+ max_num = Contact.API_MAX_NUM['street_addresses']
231
262
 
232
263
 
233
- class ContactPhoneNumberInline(admin.TabularInline):
264
+ class ContactPhoneNumberInline(
265
+ admin.TabularInline[ContactPhoneNumber, Contact]
266
+ ):
234
267
  """Inline for adding ContactPhoneNumbers to a Contact."""
235
268
 
236
269
  model = ContactPhoneNumber
237
270
  exclude = ('api_id', )
238
271
 
239
272
  extra = 0
240
- max_num = Contact.remote.API_MAX_PHONE_NUMBERS
273
+ max_num = Contact.API_MAX_NUM['phone_numbers']
241
274
 
242
275
 
243
- class ContactNoteInline(admin.TabularInline):
276
+ class ContactNoteInline(admin.TabularInline[ContactNote, Contact]):
244
277
  """Inline for adding ContactNotes to a Contact."""
245
278
 
246
279
  model = ContactNote
247
- exclude = ('api_id', )
280
+ fields = ('content', )
248
281
 
249
- extra = 1
250
- max_num = Contact.remote.API_MAX_NOTES
251
-
252
- readonly_fields = ('author', 'created_at')
282
+ extra = 0
283
+ max_num = Contact.API_MAX_NUM['notes']
253
284
 
254
285
  def has_change_permission(
255
286
  self,
256
287
  request: HttpRequest,
257
- obj: Optional[ContactNote] = None,
288
+ obj: Optional[Contact] = None, # type: ignore[override]
258
289
  ) -> bool:
259
290
  return False
260
291
 
261
292
 
262
- class ContactCustomFieldInline(admin.TabularInline):
293
+ class ContactCustomFieldInline(
294
+ admin.TabularInline[ContactCustomField, Contact]
295
+ ):
263
296
 
264
297
  model = ContactCustomField
265
298
  excldue = ('api_id', )
266
299
 
267
- extra = 1
300
+ extra = 0
268
301
 
269
302
 
270
- class ContactAdmin(RemoteModelAdmin):
303
+ class ContactAdmin(RemoteModelAdmin[Contact]):
271
304
  """Admin functionality for CTCT Contacts."""
272
305
 
273
306
  # ListView
@@ -294,11 +327,13 @@ class ContactAdmin(RemoteModelAdmin):
294
327
  )
295
328
  empty_value_display = '(None)'
296
329
 
330
+ @admin.display(
331
+ boolean=True,
332
+ description=_('Opted Out'),
333
+ ordering='opt_out_date',
334
+ )
297
335
  def opted_out(self, obj: Contact) -> bool:
298
336
  return bool(obj.opt_out_source)
299
- opted_out.boolean = True
300
- opted_out.admin_order_field = 'opt_out_date'
301
- opted_out.short_description = _('Opted Out')
302
337
 
303
338
  # ChangeView
304
339
  fieldsets = (
@@ -336,8 +371,8 @@ class ContactAdmin(RemoteModelAdmin):
336
371
  self,
337
372
  request: HttpRequest,
338
373
  obj: Optional[Contact] = None,
339
- ) -> List[str]:
340
- readonly_fields = Contact.remote.API_READONLY_FIELDS
374
+ ) -> list[str]:
375
+ readonly_fields = list(Contact.API_READONLY_FIELDS)
341
376
  if obj and obj.opt_out_source and not request.user.is_superuser:
342
377
  readonly_fields.append('list_memberships')
343
378
  return readonly_fields
@@ -345,8 +380,8 @@ class ContactAdmin(RemoteModelAdmin):
345
380
  def save_formset(
346
381
  self,
347
382
  request: HttpRequest,
348
- form: ModelForm,
349
- formset: BaseInlineFormSet,
383
+ form: ModelForm[Contact],
384
+ formset: BaseInlineFormSet[M, Contact, ModelForm[M]],
350
385
  change: bool,
351
386
  ) -> None:
352
387
  """Set the current user as ContactNote author.
@@ -365,7 +400,35 @@ class ContactAdmin(RemoteModelAdmin):
365
400
  if isinstance(instance, ContactNote) and instance.pk is None:
366
401
  instance.author = request.user
367
402
  instance.save()
368
- 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()
407
+
408
+
409
+ class ContactNoteAuthorFilter(admin.SimpleListFilter):
410
+ """Only display Users that have authored a ContactNote."""
411
+
412
+ title = _('Author')
413
+ parameter_name = 'author'
414
+
415
+ def lookups(
416
+ self,
417
+ request: HttpRequest,
418
+ model_admin: admin.ModelAdmin[Model],
419
+ ) -> Optional[Iterable[tuple[str, str]]]:
420
+ authors = get_user_model().objects.exclude(notes__isnull=True)
421
+ return [(str(obj.id), str(obj)) for obj in authors]
422
+
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))
431
+ return queryset
369
432
 
370
433
 
371
434
  class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
@@ -384,23 +447,39 @@ class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
384
447
 
385
448
  list_display_links = None
386
449
  list_display = (
387
- 'contact',
450
+ 'contact_link',
388
451
  'content',
389
- 'created_at',
390
452
  'author',
453
+ 'created_at',
391
454
  'is_synced',
392
455
  )
393
456
  list_filter = (
394
457
  'created_at',
395
- 'author',
458
+ ContactNoteAuthorFilter,
396
459
  )
397
460
 
398
- def has_delete_permission(self, request: HttpRequest, obj=None):
461
+ @admin.display(
462
+ description=_('Contact'),
463
+ ordering='contact__email',
464
+ )
465
+ def contact_link(self, obj: ContactNote) -> str:
466
+ url = reverse(
467
+ 'admin:django_ctct_contact_change',
468
+ args=[obj.contact.pk],
469
+ )
470
+ html = format_html('<a href="{}">{}</a>', url, obj.contact)
471
+ return html
472
+
473
+ def has_delete_permission(
474
+ self,
475
+ request: HttpRequest,
476
+ obj: Optional[Model] = None,
477
+ ) -> bool:
399
478
  """Allow superusers to delete Notes."""
400
479
  return request.user.is_superuser
401
480
 
402
481
 
403
- class CampaignActivityInlineForm(forms.ModelForm):
482
+ class CampaignActivityInlineForm(forms.ModelForm[CampaignActivity]):
404
483
  """Custom widget choices for ContactList admin."""
405
484
 
406
485
  html_content = forms.CharField(
@@ -413,7 +492,9 @@ class CampaignActivityInlineForm(forms.ModelForm):
413
492
  fields = '__all__'
414
493
 
415
494
 
416
- class CampaignActivityInline(admin.StackedInline):
495
+ class CampaignActivityInline(
496
+ admin.StackedInline[CampaignActivity, EmailCampaign]
497
+ ):
417
498
  """Inline for adding CampaignActivity to a EmailCampaign."""
418
499
 
419
500
  model = CampaignActivity
@@ -432,23 +513,36 @@ class CampaignActivityInline(admin.StackedInline):
432
513
  extra = 1
433
514
  max_num = 1
434
515
 
435
- def get_readonly_fields(self, request: HttpRequest, obj=None):
436
- 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)
437
522
  if obj and obj.current_status == 'DONE':
438
- readonly_fields += CampaignActivity.remote.API_EDITABLE_FIELDS
523
+ readonly_fields += list(CampaignActivity.API_EDITABLE_FIELDS)
439
524
  return readonly_fields
440
525
 
441
526
 
442
- class EmailCampaignAdmin(RemoteModelAdmin):
443
- """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
+ """
444
536
 
445
537
  # ListView
538
+ actions = None
446
539
  search_fields = ('name', )
447
540
  list_display = (
448
541
  'name',
449
- 'updated_at',
450
542
  'current_status',
451
543
  'scheduled_datetime',
544
+ 'created_at',
545
+ 'updated_at',
452
546
  'is_synced',
453
547
  )
454
548
 
@@ -462,14 +556,24 @@ class EmailCampaignAdmin(RemoteModelAdmin):
462
556
  )
463
557
  inlines = (CampaignActivityInline, )
464
558
 
465
- def get_readonly_fields(self, request: HttpRequest, obj=None):
466
- 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)
467
565
  if obj and obj.current_status == 'DONE':
468
- readonly_fields += ('scheduled_datetime', )
566
+ readonly_fields.append('scheduled_datetime')
469
567
  return readonly_fields
470
568
 
471
569
  @catch_api_errors
472
- 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:
473
577
  if self.remote_sync:
474
578
 
475
579
  campaign = form.instance
@@ -502,7 +606,13 @@ class EmailCampaignAdmin(RemoteModelAdmin):
502
606
  # Inform the user
503
607
  self.ctct_message_user(request, form, formsets, change)
504
608
 
505
- 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:
506
616
  """Inform the user of API actions."""
507
617
 
508
618
  campaign = form.instance
@@ -543,21 +653,33 @@ class CampaignSummaryAdmin(ViewModelAdmin):
543
653
  'campaign',
544
654
  'open_rate',
545
655
  'sends',
656
+ 'opens',
546
657
  'bounces',
547
658
  'clicks',
548
659
  'optouts',
549
660
  'abuse',
550
661
  )
551
662
 
663
+ def get_queryset(self, request: HttpRequest) -> QuerySet[Model]:
664
+ qs = super().get_queryset(request)
665
+ qs = qs.annotate(
666
+ open_rate=Case(
667
+ When(sends=0, then=0.0),
668
+ default=Cast(
669
+ F('opens') * 1.0 / F('sends'), # Avoid int division
670
+ output_field=FloatField(),
671
+ ),
672
+ ),
673
+ )
674
+ return qs
675
+
676
+ @admin.display(
677
+ description=_('Open Rate'),
678
+ ordering='open_rate',
679
+ )
552
680
  def open_rate(self, obj: EmailCampaign) -> str:
553
- if obj.current_status == 'DONE':
554
- r = (obj.opens / obj.sends) if obj.sends else 0
555
- s = f'{r:0.2%}'
556
- else:
557
- s = '-'
558
- return s
559
- open_rate.admin_order_field = 'open_rate'
560
- open_rate.short_description = _('Open Rate')
681
+ assert hasattr(obj, 'open_rate')
682
+ return f'{obj.open_rate:0.0%}'
561
683
 
562
684
  # ChangeView
563
685
  fieldsets = (
@@ -576,7 +698,6 @@ class CampaignSummaryAdmin(ViewModelAdmin):
576
698
 
577
699
 
578
700
  if getattr(settings, 'CTCT_USE_ADMIN', False):
579
- admin.site.register(Token, TokenAdmin)
580
701
  admin.site.register(ContactList, ContactListAdmin)
581
702
  admin.site.register(CustomField, CustomFieldAdmin)
582
703
  admin.site.register(Contact, ContactAdmin)
@@ -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):