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.
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/PKG-INFO +3 -4
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/admin.py +191 -74
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/apps.py +1 -1
- django_ctct-0.0.1.dev4/django_ctct/management/commands/import_ctct.py +360 -0
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/managers.py +321 -532
- django_ctct-0.0.1.dev4/django_ctct/migrations/0001_initial.py +224 -0
- django_ctct-0.0.1.dev4/django_ctct/migrations/__init__.py +0 -0
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/models.py +502 -200
- django_ctct-0.0.1.dev4/django_ctct/signals.py +93 -0
- django_ctct-0.0.1.dev4/django_ctct/utils.py +40 -0
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/vendor.py +2 -1
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/views.py +1 -1
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/pyproject.toml +10 -4
- django_ctct-0.0.1.dev3/django_ctct/management/commands/import_ctct.py +0 -259
- django_ctct-0.0.1.dev3/django_ctct/signals.py +0 -69
- django_ctct-0.0.1.dev3/django_ctct/utils.py +0 -30
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/LICENSE +0 -0
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/README.md +0 -0
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/__init__.py +0 -0
- {django_ctct-0.0.1.dev3 → django_ctct-0.0.1.dev4}/django_ctct/urls.py +0 -0
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: django-ctct
|
|
3
|
-
Version: 0.0.1.
|
|
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.
|
|
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
|
|
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
|
|
18
|
+
from django.utils.translation import gettext as _
|
|
20
19
|
|
|
21
20
|
from django_ctct.models import (
|
|
22
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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(
|
|
47
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
|
|
36
48
|
try:
|
|
37
|
-
return func(
|
|
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
|
-
|
|
43
|
-
self
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
261
|
+
max_num = Contact.API_MAX_NUM['street_addresses']
|
|
202
262
|
|
|
203
263
|
|
|
204
|
-
class ContactPhoneNumberInline(
|
|
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.
|
|
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.
|
|
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[
|
|
288
|
+
obj: Optional[Contact] = None, # type: ignore[override]
|
|
227
289
|
) -> bool:
|
|
228
290
|
return False
|
|
229
291
|
|
|
230
292
|
|
|
231
|
-
class ContactCustomFieldInline(
|
|
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
|
-
) ->
|
|
309
|
-
readonly_fields = Contact.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
351
|
-
|
|
352
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
431
|
-
|
|
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.
|
|
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(
|
|
462
|
-
|
|
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
|
|
566
|
+
readonly_fields.append('scheduled_datetime')
|
|
465
567
|
return readonly_fields
|
|
466
568
|
|
|
467
569
|
@catch_api_errors
|
|
468
|
-
def save_remotely(
|
|
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(
|
|
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 = (
|