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.
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('&#128203;'),
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('&#128203;'),
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)