django-ctct 0.0.1.dev1__py3-none-any.whl
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/__init__.py +0 -0
- django_ctct/admin.py +566 -0
- django_ctct/apps.py +36 -0
- django_ctct/management/commands/import_ctct.py +211 -0
- django_ctct/managers.py +1148 -0
- django_ctct/models.py +996 -0
- django_ctct/signals.py +69 -0
- django_ctct/urls.py +8 -0
- django_ctct/utils.py +34 -0
- django_ctct/vendor.py +107 -0
- django_ctct/views.py +24 -0
- django_ctct-0.0.1.dev1.dist-info/LICENSE +21 -0
- django_ctct-0.0.1.dev1.dist-info/METADATA +179 -0
- django_ctct-0.0.1.dev1.dist-info/RECORD +15 -0
- django_ctct-0.0.1.dev1.dist-info/WHEEL +4 -0
django_ctct/__init__.py
ADDED
|
File without changes
|
django_ctct/admin.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from requests.exceptions import HTTPError
|
|
5
|
+
|
|
6
|
+
from django import forms
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.contrib import admin, messages
|
|
9
|
+
from django.db.models import signals
|
|
10
|
+
from django.db.models import Model, QuerySet
|
|
11
|
+
from django.forms import ModelForm
|
|
12
|
+
from django.forms.models import BaseInlineFormSet
|
|
13
|
+
from django.http import HttpRequest
|
|
14
|
+
from django.utils.html import format_html
|
|
15
|
+
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
|
+
|
|
19
|
+
from django_ctct.models import (
|
|
20
|
+
Token, CTCTRemoteModel, ContactList,
|
|
21
|
+
Contact, CustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
|
|
22
|
+
EmailCampaign, CampaignActivity
|
|
23
|
+
)
|
|
24
|
+
from django_ctct.signals import remote_save, remote_delete
|
|
25
|
+
from django_ctct.vendor import mute_signals
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def catch_api_errors(func):
|
|
29
|
+
"""Decorator to catch HTTP errors from CTCT API."""
|
|
30
|
+
|
|
31
|
+
@functools.wraps(func)
|
|
32
|
+
def wrapper(self, request, *args, **kwargs):
|
|
33
|
+
try:
|
|
34
|
+
return func(self, request, *args, **kwargs)
|
|
35
|
+
except HTTPError as e:
|
|
36
|
+
if getattr(settings, 'CTCT_RAISE_FOR_API', False):
|
|
37
|
+
raise e
|
|
38
|
+
else:
|
|
39
|
+
message = format_html(_(f"ConstantContact: {e}"))
|
|
40
|
+
self.message_user(request, message, level=messages.ERROR)
|
|
41
|
+
|
|
42
|
+
return wrapper
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RemoteSyncMixin:
|
|
46
|
+
def is_synced(self, obj: CTCTRemoteModel) -> bool:
|
|
47
|
+
return (obj.api_id is not None)
|
|
48
|
+
is_synced.boolean = True
|
|
49
|
+
is_synced.admin_order_field = 'api_id'
|
|
50
|
+
is_synced.short_description = _('Synced')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ViewModelAdmin(admin.ModelAdmin):
|
|
54
|
+
"""Remove CRUD permissions."""
|
|
55
|
+
|
|
56
|
+
def has_add_permission(self, request: HttpRequest, obj=None):
|
|
57
|
+
"""Prevent creation in the Django admin."""
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def has_change_permission(self, request: HttpRequest, obj=None):
|
|
61
|
+
"""Prevent updates in the Django admin."""
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def get_readonly_fields(self, request: HttpRequest, obj=None):
|
|
65
|
+
"""Prevent updates in the Django admin."""
|
|
66
|
+
if obj is not None:
|
|
67
|
+
readonly_fields = (
|
|
68
|
+
field.name
|
|
69
|
+
for field in obj._meta.fields
|
|
70
|
+
if field.name != 'active'
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
readonly_fields = tuple()
|
|
74
|
+
return readonly_fields
|
|
75
|
+
|
|
76
|
+
def has_delete_permission(self, request: HttpRequest, obj=None):
|
|
77
|
+
"""Prevent deletion in the Django admin."""
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TokenAdmin(ViewModelAdmin):
|
|
82
|
+
"""Admin functionality for CTCT Tokens."""
|
|
83
|
+
|
|
84
|
+
# ListView
|
|
85
|
+
list_display_links = None
|
|
86
|
+
list_display = (
|
|
87
|
+
'scope',
|
|
88
|
+
'created_at',
|
|
89
|
+
'expires_at',
|
|
90
|
+
'copy_access_token',
|
|
91
|
+
'copy_refresh_token',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def copy_access_token(self, obj: Token) -> str:
|
|
95
|
+
html = format_html(
|
|
96
|
+
'<button class="button" onclick="{function}">{copy_icon}</button>',
|
|
97
|
+
function=f"navigator.clipboard.writeText('{obj.access_token}')",
|
|
98
|
+
copy_icon=mark_safe('📋'),
|
|
99
|
+
)
|
|
100
|
+
return html
|
|
101
|
+
copy_access_token.short_description = _('Access Token')
|
|
102
|
+
|
|
103
|
+
def copy_refresh_token(self, obj: Token) -> str:
|
|
104
|
+
html = format_html(
|
|
105
|
+
'<button class="button" onclick="{function}">{copy_icon}</button>',
|
|
106
|
+
function=f"navigator.clipboard.writeText('{obj.refresh_token}')",
|
|
107
|
+
copy_icon=mark_safe('📋'),
|
|
108
|
+
)
|
|
109
|
+
return html
|
|
110
|
+
copy_refresh_token.short_description = _('Refresh Token')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
|
|
114
|
+
"""Facilitate remote saving and deleting."""
|
|
115
|
+
|
|
116
|
+
# ChangeView
|
|
117
|
+
@property
|
|
118
|
+
def remote_sync(self) -> bool:
|
|
119
|
+
sync_admin = getattr(settings, 'CTCT_SYNC_ADMIN', False)
|
|
120
|
+
sync_signals = getattr(settings, 'CTCT_SYNC_SIGNALS', False)
|
|
121
|
+
return sync_admin and not sync_signals
|
|
122
|
+
|
|
123
|
+
@catch_api_errors
|
|
124
|
+
def delete_model(self, request: HttpRequest, obj: Model):
|
|
125
|
+
obj.delete()
|
|
126
|
+
if self.remote_sync:
|
|
127
|
+
remote_delete(sender=self.model, instance=obj)
|
|
128
|
+
|
|
129
|
+
@catch_api_errors
|
|
130
|
+
def delete_queryset(self, request: HttpRequest, queryset: QuerySet):
|
|
131
|
+
if self.remote_sync:
|
|
132
|
+
queryset.model.remote.bulk_delete(queryset)
|
|
133
|
+
with mute_signals(signals.pre_delete):
|
|
134
|
+
queryset.delete()
|
|
135
|
+
|
|
136
|
+
@catch_api_errors
|
|
137
|
+
def save_related(self, request: HttpRequest, form, formsets, change):
|
|
138
|
+
"""Default implementation with an added line for saving remotely.
|
|
139
|
+
|
|
140
|
+
Notes
|
|
141
|
+
-----
|
|
142
|
+
This gets called even if related fields don't exist, so we use it as a hook
|
|
143
|
+
for saving objects remotely.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
form.save_m2m()
|
|
147
|
+
for formset in formsets:
|
|
148
|
+
self.save_formset(request, form, formset, change=change)
|
|
149
|
+
self.save_remotely(request, form, formsets, change)
|
|
150
|
+
|
|
151
|
+
@catch_api_errors
|
|
152
|
+
def save_remotely(self, request, form, formsets, change):
|
|
153
|
+
if self.remote_sync:
|
|
154
|
+
# Remote save the primary object after related objects have been saved
|
|
155
|
+
remote_save(
|
|
156
|
+
sender=self.model,
|
|
157
|
+
instance=form.instance,
|
|
158
|
+
created=not change,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ContactListForm(forms.ModelForm):
|
|
163
|
+
"""Custom widget choices for ContactList admin."""
|
|
164
|
+
|
|
165
|
+
class Meta:
|
|
166
|
+
model = ContactList
|
|
167
|
+
widgets = {
|
|
168
|
+
'description': forms.Textarea,
|
|
169
|
+
}
|
|
170
|
+
fields = '__all__'
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ContactListAdmin(RemoteModelAdmin):
|
|
174
|
+
"""Admin functionality for CTCT ContactLists."""
|
|
175
|
+
|
|
176
|
+
# ListView
|
|
177
|
+
list_display = (
|
|
178
|
+
'name',
|
|
179
|
+
'membership',
|
|
180
|
+
'optouts',
|
|
181
|
+
'created_at',
|
|
182
|
+
'updated_at',
|
|
183
|
+
'favorite',
|
|
184
|
+
'is_synced',
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def membership(self, obj: ContactList) -> int:
|
|
188
|
+
return obj.members.all().count()
|
|
189
|
+
membership.short_description = _('Membership')
|
|
190
|
+
|
|
191
|
+
def optouts(self, obj: ContactList) -> int:
|
|
192
|
+
return obj.members.exclude(opt_out_source='').count()
|
|
193
|
+
optouts.short_description = _('Opt Outs')
|
|
194
|
+
|
|
195
|
+
# ChangeView
|
|
196
|
+
form = ContactListForm
|
|
197
|
+
fieldsets = (
|
|
198
|
+
(None, {
|
|
199
|
+
'fields': (
|
|
200
|
+
('name', 'favorite'),
|
|
201
|
+
'description',
|
|
202
|
+
),
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class CustomFieldAdmin(RemoteModelAdmin):
|
|
208
|
+
"""Admin functionality for CTCT CustomFields."""
|
|
209
|
+
|
|
210
|
+
# ListView
|
|
211
|
+
list_display = (
|
|
212
|
+
'label',
|
|
213
|
+
'type',
|
|
214
|
+
'created_at',
|
|
215
|
+
'is_synced',
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# ChangeView
|
|
219
|
+
exclude = ('api_id', )
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class ContactStreetAddressInline(admin.StackedInline):
|
|
223
|
+
"""Inline for adding ContactStreetAddresses to a Contact."""
|
|
224
|
+
|
|
225
|
+
model = ContactStreetAddress
|
|
226
|
+
exclude = ('api_id', )
|
|
227
|
+
|
|
228
|
+
extra = 0
|
|
229
|
+
max_num = Contact.remote.API_MAX_STREET_ADDRESSES
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class ContactPhoneNumberInline(admin.TabularInline):
|
|
233
|
+
"""Inline for adding ContactPhoneNumbers to a Contact."""
|
|
234
|
+
|
|
235
|
+
model = ContactPhoneNumber
|
|
236
|
+
exclude = ('api_id', )
|
|
237
|
+
|
|
238
|
+
extra = 0
|
|
239
|
+
max_num = Contact.remote.API_MAX_PHONE_NUMBERS
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class ContactNoteInline(admin.TabularInline):
|
|
243
|
+
"""Inline for adding ContactNotes to a Contact."""
|
|
244
|
+
|
|
245
|
+
model = ContactNote
|
|
246
|
+
exclude = ('api_id', )
|
|
247
|
+
|
|
248
|
+
extra = 1
|
|
249
|
+
max_num = Contact.remote.API_MAX_NOTES
|
|
250
|
+
|
|
251
|
+
readonly_fields = ('author', 'created_at')
|
|
252
|
+
|
|
253
|
+
def has_change_permission(
|
|
254
|
+
self,
|
|
255
|
+
request: HttpRequest,
|
|
256
|
+
obj: Optional[ContactNote] = None,
|
|
257
|
+
) -> bool:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ContactAdmin(RemoteModelAdmin):
|
|
262
|
+
"""Admin functionality for CTCT Contacts."""
|
|
263
|
+
|
|
264
|
+
# ListView
|
|
265
|
+
search_fields = (
|
|
266
|
+
'email',
|
|
267
|
+
'first_name',
|
|
268
|
+
'last_name',
|
|
269
|
+
'job_title',
|
|
270
|
+
'company_name',
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
list_display = (
|
|
274
|
+
'email',
|
|
275
|
+
'first_name',
|
|
276
|
+
'last_name',
|
|
277
|
+
'job_title',
|
|
278
|
+
'company_name',
|
|
279
|
+
'updated_at',
|
|
280
|
+
'opted_out',
|
|
281
|
+
'is_synced',
|
|
282
|
+
)
|
|
283
|
+
list_filter = (
|
|
284
|
+
'list_memberships',
|
|
285
|
+
)
|
|
286
|
+
empty_value_display = '(None)'
|
|
287
|
+
|
|
288
|
+
def opted_out(self, obj: Contact) -> bool:
|
|
289
|
+
return bool(obj.opt_out_source)
|
|
290
|
+
opted_out.boolean = True
|
|
291
|
+
opted_out.admin_order_field = 'opt_out_date'
|
|
292
|
+
opted_out.short_description = _('Opted Out')
|
|
293
|
+
|
|
294
|
+
# ChangeView
|
|
295
|
+
fieldsets = (
|
|
296
|
+
(None, {
|
|
297
|
+
'fields': (
|
|
298
|
+
'email',
|
|
299
|
+
'first_name',
|
|
300
|
+
'last_name',
|
|
301
|
+
'job_title',
|
|
302
|
+
'company_name',
|
|
303
|
+
),
|
|
304
|
+
}),
|
|
305
|
+
('CONTACT LISTS', {
|
|
306
|
+
'fields': (
|
|
307
|
+
'list_memberships',
|
|
308
|
+
('opt_out_source', 'opt_out_date', 'opt_out_reason'),
|
|
309
|
+
),
|
|
310
|
+
}),
|
|
311
|
+
('TIMESTAMPS', {
|
|
312
|
+
'fields': (
|
|
313
|
+
'created_at',
|
|
314
|
+
'updated_at',
|
|
315
|
+
),
|
|
316
|
+
}),
|
|
317
|
+
)
|
|
318
|
+
filter_horizontal = ('list_memberships', )
|
|
319
|
+
inlines = (
|
|
320
|
+
ContactPhoneNumberInline,
|
|
321
|
+
ContactStreetAddressInline,
|
|
322
|
+
ContactNoteInline,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def get_readonly_fields(
|
|
326
|
+
self,
|
|
327
|
+
request: HttpRequest,
|
|
328
|
+
obj: Optional[Contact] = None,
|
|
329
|
+
) -> List[str]:
|
|
330
|
+
readonly_fields = Contact.remote.API_READONLY_FIELDS
|
|
331
|
+
if obj and obj.opt_out_source and not request.user.is_superuser:
|
|
332
|
+
readonly_fields.append('list_memberships')
|
|
333
|
+
return readonly_fields
|
|
334
|
+
|
|
335
|
+
def save_formset(
|
|
336
|
+
self,
|
|
337
|
+
request: HttpRequest,
|
|
338
|
+
form: ModelForm,
|
|
339
|
+
formset: BaseInlineFormSet,
|
|
340
|
+
change: bool,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Set the current user as ContactNote author.
|
|
343
|
+
|
|
344
|
+
Notes
|
|
345
|
+
-----
|
|
346
|
+
We don't need to worry about calling the API after .delete() since we use a
|
|
347
|
+
PUT method, which overwrites all sub-resources.
|
|
348
|
+
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
instances = formset.save(commit=False)
|
|
352
|
+
for obj in formset.deleted_objects:
|
|
353
|
+
obj.delete()
|
|
354
|
+
for instance in instances:
|
|
355
|
+
if isinstance(instance, ContactNote) and instance.pk is None:
|
|
356
|
+
instance.author = request.user
|
|
357
|
+
instance.save()
|
|
358
|
+
formset.save_m2m()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
|
|
362
|
+
"""Admin functionality for ContactNotes."""
|
|
363
|
+
|
|
364
|
+
# ListView
|
|
365
|
+
search_fields = (
|
|
366
|
+
'content',
|
|
367
|
+
'contact__email',
|
|
368
|
+
'contact__first_name',
|
|
369
|
+
'contact__last_name',
|
|
370
|
+
'author__email',
|
|
371
|
+
'author__first_name',
|
|
372
|
+
'author__last_name',
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
list_display_links = None
|
|
376
|
+
list_display = (
|
|
377
|
+
'contact',
|
|
378
|
+
'content',
|
|
379
|
+
'created_at',
|
|
380
|
+
'author',
|
|
381
|
+
'is_synced',
|
|
382
|
+
)
|
|
383
|
+
list_filter = (
|
|
384
|
+
'created_at',
|
|
385
|
+
'author',
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def has_delete_permission(self, request: HttpRequest, obj=None):
|
|
389
|
+
"""Allow superusers to delete Notes."""
|
|
390
|
+
return request.user.is_superuser
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class CampaignActivityInlineForm(forms.ModelForm):
|
|
394
|
+
"""Custom widget choices for ContactList admin."""
|
|
395
|
+
|
|
396
|
+
html_content = forms.CharField(
|
|
397
|
+
widget=forms.Textarea,
|
|
398
|
+
label=_('HTML Content'),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
class Meta:
|
|
402
|
+
model = CampaignActivity
|
|
403
|
+
fields = '__all__'
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class CampaignActivityInline(admin.StackedInline):
|
|
407
|
+
"""Inline for adding CampaignActivity to a EmailCampaign."""
|
|
408
|
+
|
|
409
|
+
model = CampaignActivity
|
|
410
|
+
form = CampaignActivityInlineForm
|
|
411
|
+
fields = (
|
|
412
|
+
'role', 'current_status',
|
|
413
|
+
'from_name', 'from_email', 'reply_to_email',
|
|
414
|
+
'subject', 'preheader', 'html_content',
|
|
415
|
+
'contact_lists',
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
filter_horizontal = (
|
|
419
|
+
'contact_lists',
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
extra = 1
|
|
423
|
+
max_num = 1
|
|
424
|
+
|
|
425
|
+
def get_readonly_fields(self, request: HttpRequest, obj=None):
|
|
426
|
+
readonly_fields = CampaignActivity.remote.API_READONLY_FIELDS
|
|
427
|
+
if obj and obj.current_status == 'DONE':
|
|
428
|
+
readonly_fields += CampaignActivity.remote.API_EDITABLE_FIELDS
|
|
429
|
+
return readonly_fields
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class EmailCampaignAdmin(RemoteModelAdmin):
|
|
433
|
+
"""Admin functionality for CTCT EmailCampaigns."""
|
|
434
|
+
|
|
435
|
+
# ListView
|
|
436
|
+
search_fields = ('name', )
|
|
437
|
+
list_display = (
|
|
438
|
+
'name',
|
|
439
|
+
'updated_at',
|
|
440
|
+
'current_status',
|
|
441
|
+
'scheduled_datetime',
|
|
442
|
+
'open_rate',
|
|
443
|
+
'sends',
|
|
444
|
+
'bounces',
|
|
445
|
+
'clicks',
|
|
446
|
+
'optouts',
|
|
447
|
+
'abuse',
|
|
448
|
+
'is_synced',
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def open_rate(self, obj: EmailCampaign) -> str:
|
|
452
|
+
if obj.current_status == 'DONE':
|
|
453
|
+
r = (obj.opens / obj.sends) if obj.sends else 0
|
|
454
|
+
s = f'{r:0.2%}'
|
|
455
|
+
else:
|
|
456
|
+
s = '-'
|
|
457
|
+
return s
|
|
458
|
+
open_rate.admin_order_field = 'open_rate'
|
|
459
|
+
open_rate.short_description = _('Open Rate')
|
|
460
|
+
|
|
461
|
+
# ChangeView
|
|
462
|
+
inlines = (CampaignActivityInline, )
|
|
463
|
+
|
|
464
|
+
def get_fieldsets(self, request: HttpRequest, obj=None):
|
|
465
|
+
if obj and (obj.current_status == 'DONE'):
|
|
466
|
+
fieldsets = (
|
|
467
|
+
(None, {
|
|
468
|
+
'fields': ('name', 'current_status', 'scheduled_datetime'),
|
|
469
|
+
}),
|
|
470
|
+
('ANALYTICS', {
|
|
471
|
+
'fields': (
|
|
472
|
+
'sends', 'opens', 'clicks', 'forwards',
|
|
473
|
+
'optouts', 'abuse', 'bounces', 'not_opened',
|
|
474
|
+
),
|
|
475
|
+
}),
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
fieldsets = (
|
|
479
|
+
(None, {
|
|
480
|
+
'fields': (
|
|
481
|
+
'name', 'current_status', 'scheduled_datetime', 'send_preview'
|
|
482
|
+
),
|
|
483
|
+
}),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
return fieldsets
|
|
487
|
+
|
|
488
|
+
def get_readonly_fields(self, request: HttpRequest, obj=None):
|
|
489
|
+
readonly_fields = EmailCampaign.remote.API_READONLY_FIELDS
|
|
490
|
+
if obj and obj.current_status == 'DONE':
|
|
491
|
+
readonly_fields += ('scheduled_datetime', )
|
|
492
|
+
return readonly_fields
|
|
493
|
+
|
|
494
|
+
@catch_api_errors
|
|
495
|
+
def save_remotely(self, request, form, formsets, change) -> None:
|
|
496
|
+
if self.remote_sync:
|
|
497
|
+
|
|
498
|
+
campaign = form.instance
|
|
499
|
+
activity = formsets[0][0].instance
|
|
500
|
+
|
|
501
|
+
# Handle remote saving the EmailCampaign
|
|
502
|
+
campaign_created = not change
|
|
503
|
+
campaign_updated = change and ('name' in form.changed_data)
|
|
504
|
+
if campaign_created or campaign_updated:
|
|
505
|
+
# The only EmailCampaign field that can be updated is 'name'
|
|
506
|
+
remote_save(
|
|
507
|
+
sender=self.model,
|
|
508
|
+
instance=campaign,
|
|
509
|
+
created=campaign_created,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Handle remote saving the primary_email CampaignActivity
|
|
513
|
+
inline_changed = formsets[0][0].changed_data and not campaign_created
|
|
514
|
+
schedule_changed = ('scheduled_datetime' in form.changed_data)
|
|
515
|
+
preview_sent = ('send_preview' in form.changed_data) and campaign.send_preview # noqa: E501
|
|
516
|
+
recipients_changed = ('contact_lists' in formsets[0][0].changed_data)
|
|
517
|
+
|
|
518
|
+
if (
|
|
519
|
+
inline_changed or schedule_changed or preview_sent or recipients_changed # noqa: E501
|
|
520
|
+
):
|
|
521
|
+
# Refresh to get API id and remote save
|
|
522
|
+
activity.refresh_from_db()
|
|
523
|
+
remote_save(sender=CampaignActivity, instance=activity, created=False)
|
|
524
|
+
|
|
525
|
+
# Inform the user
|
|
526
|
+
self.ctct_message_user(request, form, formsets, change)
|
|
527
|
+
|
|
528
|
+
def ctct_message_user(self, request, form, formsets, change) -> None:
|
|
529
|
+
"""Inform the user of API actions."""
|
|
530
|
+
|
|
531
|
+
campaign = form.instance
|
|
532
|
+
|
|
533
|
+
if campaign.scheduled_datetime is not None:
|
|
534
|
+
date = date_format(campaign.scheduled_datetime, settings.DATETIME_FORMAT)
|
|
535
|
+
action = f"scheduled to be sent {date}"
|
|
536
|
+
elif change and ('scheduled_datetime' in form.changed_data):
|
|
537
|
+
action = "unscheduled remotely"
|
|
538
|
+
elif change:
|
|
539
|
+
action = "updated remotely"
|
|
540
|
+
else:
|
|
541
|
+
action = "created remotely"
|
|
542
|
+
|
|
543
|
+
if campaign.send_preview:
|
|
544
|
+
preview = " and a preview has been sent out"
|
|
545
|
+
else:
|
|
546
|
+
preview = ""
|
|
547
|
+
|
|
548
|
+
message = format_html(
|
|
549
|
+
_("The {name} “{obj}” has been {action}{preview}."),
|
|
550
|
+
**{
|
|
551
|
+
'name': campaign._meta.verbose_name,
|
|
552
|
+
'obj': campaign,
|
|
553
|
+
'action': action,
|
|
554
|
+
'preview': preview,
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
self.message_user(request, message)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
if getattr(settings, 'CTCT_USE_ADMIN', False):
|
|
561
|
+
admin.site.register(Token, TokenAdmin)
|
|
562
|
+
admin.site.register(ContactList, ContactListAdmin)
|
|
563
|
+
admin.site.register(CustomField, CustomFieldAdmin)
|
|
564
|
+
admin.site.register(Contact, ContactAdmin)
|
|
565
|
+
admin.site.register(ContactNote, ContactNoteAdmin)
|
|
566
|
+
admin.site.register(EmailCampaign, EmailCampaignAdmin)
|
django_ctct/apps.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.db.models.signals import post_save, m2m_changed, pre_delete
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CTCTConfig(AppConfig):
|
|
9
|
+
name = 'django_ctct'
|
|
10
|
+
verbose_name = _('Constant Contact')
|
|
11
|
+
ctct_settings = [
|
|
12
|
+
'CTCT_PUBLIC_KEY',
|
|
13
|
+
'CTCT_SECRET_KEY',
|
|
14
|
+
'CTCT_REDIRECT_URI',
|
|
15
|
+
'CTCT_FROM_NAME',
|
|
16
|
+
'CTCT_FROM_EMAIL',
|
|
17
|
+
'CTCT_ENQUEUE_DEFAULT',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def ready(self):
|
|
21
|
+
# Validate that necessary settings have been defined
|
|
22
|
+
for value in self.ctct_settings:
|
|
23
|
+
if not hasattr(settings, value):
|
|
24
|
+
message = _(
|
|
25
|
+
f"[django-ctct] {value} must be defined in settings.py."
|
|
26
|
+
)
|
|
27
|
+
raise ImproperlyConfigured(message)
|
|
28
|
+
|
|
29
|
+
# Hook up the signals
|
|
30
|
+
from django_ctct.signals import (
|
|
31
|
+
remote_save, remote_delete, remote_update_m2m
|
|
32
|
+
)
|
|
33
|
+
if getattr(settings, 'CTCT_SYNC_SIGNALS', False):
|
|
34
|
+
post_save.connect(remote_save)
|
|
35
|
+
pre_delete.connect(remote_delete)
|
|
36
|
+
m2m_changed.connect(remote_update_m2m)
|