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.
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev4}/PKG-INFO +3 -4
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev4}/django_ctct/admin.py +237 -116
- {django_ctct-0.0.1.dev2 → 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.dev2 → django_ctct-0.0.1.dev4}/django_ctct/managers.py +323 -537
- 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.dev2 → django_ctct-0.0.1.dev4}/django_ctct/models.py +515 -204
- 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.dev2 → django_ctct-0.0.1.dev4}/django_ctct/vendor.py +2 -1
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev4}/django_ctct/views.py +1 -1
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev4}/pyproject.toml +18 -8
- django_ctct-0.0.1.dev2/django_ctct/management/commands/import_ctct.py +0 -233
- django_ctct-0.0.1.dev2/django_ctct/signals.py +0 -69
- django_ctct-0.0.1.dev2/django_ctct/utils.py +0 -34
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev4}/LICENSE +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev4}/README.md +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev4}/django_ctct/__init__.py +0 -0
- {django_ctct-0.0.1.dev2 → 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,23 +1,24 @@
|
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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(
|
|
47
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
|
|
34
48
|
try:
|
|
35
|
-
return func(
|
|
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
|
-
|
|
41
|
-
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
|
+
)
|
|
42
62
|
|
|
43
63
|
return wrapper
|
|
44
64
|
|
|
45
65
|
|
|
46
66
|
class RemoteSyncMixin:
|
|
47
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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('📋'),
|
|
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('📋'),
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
261
|
+
max_num = Contact.API_MAX_NUM['street_addresses']
|
|
231
262
|
|
|
232
263
|
|
|
233
|
-
class ContactPhoneNumberInline(
|
|
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.
|
|
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
|
-
|
|
280
|
+
fields = ('content', )
|
|
248
281
|
|
|
249
|
-
extra =
|
|
250
|
-
max_num = Contact.
|
|
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[
|
|
288
|
+
obj: Optional[Contact] = None, # type: ignore[override]
|
|
258
289
|
) -> bool:
|
|
259
290
|
return False
|
|
260
291
|
|
|
261
292
|
|
|
262
|
-
class ContactCustomFieldInline(
|
|
293
|
+
class ContactCustomFieldInline(
|
|
294
|
+
admin.TabularInline[ContactCustomField, Contact]
|
|
295
|
+
):
|
|
263
296
|
|
|
264
297
|
model = ContactCustomField
|
|
265
298
|
excldue = ('api_id', )
|
|
266
299
|
|
|
267
|
-
extra =
|
|
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
|
-
) ->
|
|
340
|
-
readonly_fields = Contact.
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
458
|
+
ContactNoteAuthorFilter,
|
|
396
459
|
)
|
|
397
460
|
|
|
398
|
-
|
|
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(
|
|
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(
|
|
436
|
-
|
|
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.
|
|
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(
|
|
466
|
-
|
|
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
|
|
566
|
+
readonly_fields.append('scheduled_datetime')
|
|
469
567
|
return readonly_fields
|
|
470
568
|
|
|
471
569
|
@catch_api_errors
|
|
472
|
-
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:
|
|
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(
|
|
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
|
-
|
|
554
|
-
|
|
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)
|