django-ctct 0.0.1.dev1__tar.gz → 0.0.1.dev3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-ctct
3
- Version: 0.0.1.dev1
3
+ Version: 0.0.1.dev3
4
4
  Summary: A Django interface for the Constant Contact API
5
5
  License: MIT
6
6
  Author: Geoffrey Eisenbarth
@@ -6,20 +6,23 @@ from requests.exceptions import HTTPError
6
6
  from django import forms
7
7
  from django.conf import settings
8
8
  from django.contrib import admin, messages
9
+ from django.contrib.auth import get_user_model
9
10
  from django.db.models import signals
10
- from django.db.models import Model, QuerySet
11
+ from django.db.models import Model, QuerySet, When, Case, F, FloatField
12
+ from django.db.models.functions import Cast
11
13
  from django.forms import ModelForm
12
14
  from django.forms.models import BaseInlineFormSet
13
15
  from django.http import HttpRequest
16
+ from django.urls import reverse
14
17
  from django.utils.html import format_html
15
18
  from django.utils.formats import date_format
16
- from django.utils.safestring import mark_safe
17
19
  from django.utils.translation import gettext_lazy as _
18
20
 
19
21
  from django_ctct.models import (
20
- Token, CTCTRemoteModel, ContactList,
21
- Contact, CustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
22
- EmailCampaign, CampaignActivity
22
+ CTCTRemoteModel, ContactList, CustomField,
23
+ Contact,
24
+ ContactCustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
25
+ EmailCampaign, CampaignActivity, CampaignSummary,
23
26
  )
24
27
  from django_ctct.signals import remote_save, remote_delete
25
28
  from django_ctct.vendor import mute_signals
@@ -78,38 +81,6 @@ class ViewModelAdmin(admin.ModelAdmin):
78
81
  return False
79
82
 
80
83
 
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
84
  class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
114
85
  """Facilitate remote saving and deleting."""
115
86
 
@@ -212,6 +183,7 @@ class CustomFieldAdmin(RemoteModelAdmin):
212
183
  'label',
213
184
  'type',
214
185
  'created_at',
186
+ 'updated_at',
215
187
  'is_synced',
216
188
  )
217
189
 
@@ -243,13 +215,11 @@ class ContactNoteInline(admin.TabularInline):
243
215
  """Inline for adding ContactNotes to a Contact."""
244
216
 
245
217
  model = ContactNote
246
- exclude = ('api_id', )
218
+ fields = ('content', )
247
219
 
248
- extra = 1
220
+ extra = 0
249
221
  max_num = Contact.remote.API_MAX_NOTES
250
222
 
251
- readonly_fields = ('author', 'created_at')
252
-
253
223
  def has_change_permission(
254
224
  self,
255
225
  request: HttpRequest,
@@ -258,6 +228,14 @@ class ContactNoteInline(admin.TabularInline):
258
228
  return False
259
229
 
260
230
 
231
+ class ContactCustomFieldInline(admin.TabularInline):
232
+
233
+ model = ContactCustomField
234
+ excldue = ('api_id', )
235
+
236
+ extra = 0
237
+
238
+
261
239
  class ContactAdmin(RemoteModelAdmin):
262
240
  """Admin functionality for CTCT Contacts."""
263
241
 
@@ -317,6 +295,7 @@ class ContactAdmin(RemoteModelAdmin):
317
295
  )
318
296
  filter_horizontal = ('list_memberships', )
319
297
  inlines = (
298
+ ContactCustomFieldInline,
320
299
  ContactPhoneNumberInline,
321
300
  ContactStreetAddressInline,
322
301
  ContactNoteInline,
@@ -358,6 +337,22 @@ class ContactAdmin(RemoteModelAdmin):
358
337
  formset.save_m2m()
359
338
 
360
339
 
340
+ class ContactNoteAuthorFilter(admin.SimpleListFilter):
341
+ """Only display Users that have authored a ContactNote."""
342
+
343
+ title = _('Author')
344
+ parameter_name = 'author'
345
+
346
+ def lookups(self, request, model_admin):
347
+ authors = get_user_model().objects.exclude(notes__isnull=True)
348
+ return [(obj.id, str(obj)) for obj in authors]
349
+
350
+ def queryset(self, request, queryset):
351
+ if self.value():
352
+ queryset = queryset.filter(author_id=self.value())
353
+ return queryset
354
+
355
+
361
356
  class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
362
357
  """Admin functionality for ContactNotes."""
363
358
 
@@ -374,17 +369,27 @@ class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
374
369
 
375
370
  list_display_links = None
376
371
  list_display = (
377
- 'contact',
372
+ 'contact_link',
378
373
  'content',
379
- 'created_at',
380
374
  'author',
375
+ 'created_at',
381
376
  'is_synced',
382
377
  )
383
378
  list_filter = (
384
379
  'created_at',
385
- 'author',
380
+ ContactNoteAuthorFilter,
386
381
  )
387
382
 
383
+ def contact_link(self, obj: ContactNote) -> str:
384
+ url = reverse(
385
+ 'admin:django_ctct_contact_change',
386
+ args=[obj.contact.pk],
387
+ )
388
+ html = format_html('<a href="{}">{}</a>', url, obj.contact)
389
+ return html
390
+ contact_link.short_description = _('Contact')
391
+ contact_link.admin_order_field = 'contact__email'
392
+
388
393
  def has_delete_permission(self, request: HttpRequest, obj=None):
389
394
  """Allow superusers to delete Notes."""
390
395
  return request.user.is_superuser
@@ -436,55 +441,23 @@ class EmailCampaignAdmin(RemoteModelAdmin):
436
441
  search_fields = ('name', )
437
442
  list_display = (
438
443
  'name',
439
- 'updated_at',
440
444
  'current_status',
441
445
  'scheduled_datetime',
442
- 'open_rate',
443
- 'sends',
444
- 'bounces',
445
- 'clicks',
446
- 'optouts',
447
- 'abuse',
446
+ 'created_at',
447
+ 'updated_at',
448
448
  'is_synced',
449
449
  )
450
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
451
  # ChangeView
452
+ fieldsets = (
453
+ (None, {
454
+ 'fields': (
455
+ 'name', 'current_status', 'scheduled_datetime', 'send_preview'
456
+ ),
457
+ }),
458
+ )
462
459
  inlines = (CampaignActivityInline, )
463
460
 
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
461
  def get_readonly_fields(self, request: HttpRequest, obj=None):
489
462
  readonly_fields = EmailCampaign.remote.API_READONLY_FIELDS
490
463
  if obj and obj.current_status == 'DONE':
@@ -557,10 +530,60 @@ class EmailCampaignAdmin(RemoteModelAdmin):
557
530
  self.message_user(request, message)
558
531
 
559
532
 
533
+ class CampaignSummaryAdmin(ViewModelAdmin):
534
+ """Admin functionality for CTCT EmailCampaign Summary Report."""
535
+
536
+ # ListView
537
+ search_fields = ('name', )
538
+ list_display = (
539
+ 'campaign',
540
+ 'open_rate',
541
+ 'sends',
542
+ 'opens',
543
+ 'bounces',
544
+ 'clicks',
545
+ 'optouts',
546
+ 'abuse',
547
+ )
548
+
549
+ def get_queryset(self, request):
550
+ qs = super().get_queryset(request)
551
+ qs = qs.annotate(
552
+ open_rate=Case(
553
+ When(sends=0, then=0.0),
554
+ default=Cast(
555
+ F('opens') * 1.0 / F('sends'), # Avoid int division
556
+ output_field=FloatField(),
557
+ ),
558
+ ),
559
+ )
560
+ return qs
561
+
562
+ def open_rate(self, obj: EmailCampaign) -> str:
563
+ return f'{obj.open_rate:0.0%}'
564
+ open_rate.admin_order_field = 'open_rate'
565
+ open_rate.short_description = _('Open Rate')
566
+
567
+ # ChangeView
568
+ fieldsets = (
569
+ (None, {
570
+ 'fields': (
571
+ 'campaign',
572
+ ),
573
+ }),
574
+ ('ANALYTICS', {
575
+ 'fields': (
576
+ 'sends', 'opens', 'clicks', 'forwards',
577
+ 'optouts', 'abuse', 'bounces', 'not_opened',
578
+ ),
579
+ }),
580
+ )
581
+
582
+
560
583
  if getattr(settings, 'CTCT_USE_ADMIN', False):
561
- admin.site.register(Token, TokenAdmin)
562
584
  admin.site.register(ContactList, ContactListAdmin)
563
585
  admin.site.register(CustomField, CustomFieldAdmin)
564
586
  admin.site.register(Contact, ContactAdmin)
565
587
  admin.site.register(ContactNote, ContactNoteAdmin)
566
588
  admin.site.register(EmailCampaign, EmailCampaignAdmin)
589
+ admin.site.register(CampaignSummary, CampaignSummaryAdmin)
@@ -14,7 +14,6 @@ class CTCTConfig(AppConfig):
14
14
  'CTCT_REDIRECT_URI',
15
15
  'CTCT_FROM_NAME',
16
16
  'CTCT_FROM_EMAIL',
17
- 'CTCT_ENQUEUE_DEFAULT',
18
17
  ]
19
18
 
20
19
  def ready(self):
@@ -0,0 +1,259 @@
1
+ from argparse import ArgumentParser
2
+ from collections import defaultdict
3
+ from typing import Optional, Type
4
+
5
+ from tqdm import tqdm
6
+
7
+ import django
8
+ from django.utils.translation import gettext_lazy as _
9
+ from django.core.management.base import BaseCommand
10
+
11
+ from django_ctct.models import (
12
+ CTCTModel, ContactList, CustomField,
13
+ Contact, ContactCustomField,
14
+ EmailCampaign, CampaignActivity, CampaignSummary,
15
+ )
16
+
17
+
18
+ class Command(BaseCommand):
19
+ """Imports django-ctct model instances from CTCT servers.
20
+
21
+ Notes
22
+ -----
23
+ CTCT does not provide an endpoint for fetching bulk CampaignActivities.
24
+ As a result, we must loop through the EmailCampaigns, make a request to get
25
+ the associated CampaignActivities, and then make a second request to get the
26
+ details of the CampaignActivity.
27
+
28
+ As a result, importing CampaignActivities will be slow, and running it
29
+ multiple times may result in exceeding CTCT's 10,000 requests per day
30
+ limit.
31
+
32
+ """
33
+
34
+ help = 'Imports data from ConstantContact'
35
+
36
+ CTCT_MODELS = [
37
+ ContactList,
38
+ CustomField,
39
+ Contact,
40
+ EmailCampaign,
41
+ CampaignActivity,
42
+ CampaignSummary,
43
+ ]
44
+
45
+ def get_id_to_pk(self, model: Type[CTCTModel]) -> dict:
46
+ """Returns a dictionary to convert CTCT API ids to Django pks."""
47
+ id_to_pk = {
48
+ str(api_id): int(pk)
49
+ for (api_id, pk) in model.objects.values_list('api_id', 'pk')
50
+ }
51
+ return id_to_pk
52
+
53
+ def upsert(
54
+ self,
55
+ model: CTCTModel,
56
+ objs: list[CTCTModel],
57
+ update_conflicts: bool = True,
58
+ unique_fields: list[str] = ['api_id'],
59
+ update_fields: Optional[list[str]] = None,
60
+ silent: Optional[bool] = None,
61
+ ) -> list[CTCTModel]:
62
+
63
+ verb = 'Imported' if (update_fields is None) else 'Updated'
64
+ if silent is None:
65
+ silent = self.noinput
66
+
67
+ # Perform upsert using `bulk_create()`
68
+ if model._meta.auto_created or (model is ContactCustomField):
69
+ if not issubclass(model, CTCTModel):
70
+ # Delete ManyToMany objects
71
+ model.objects.all().delete()
72
+ update_conflicts = False
73
+ unique_fields = update_fields = None
74
+ elif model is CampaignSummary:
75
+ update_conflicts = True
76
+ unique_fields = ['campaign_id']
77
+ update_fields = model.remote.API_READONLY_FIELDS[1:]
78
+ elif update_fields is None:
79
+ update_fields = [
80
+ f.name
81
+ for f in model._meta.fields
82
+ if not f.primary_key and (f.name != 'api_id')
83
+ ]
84
+
85
+ objs_w_pks = model.objects.bulk_create(
86
+ objs=objs,
87
+ update_conflicts=update_conflicts,
88
+ unique_fields=unique_fields,
89
+ update_fields=update_fields,
90
+ )
91
+ if update_conflicts and (django.get_version() < '5.0'):
92
+ # In older versions, enabling the update_conflicts parameter prevented
93
+ # setting the primary key on each model instance.
94
+ if model is not CampaignSummary:
95
+ # CampaignSummary doesn't have `api_id` field (or related_objs)
96
+ # so it's okay to skip this part
97
+ id_to_pk = self.get_id_to_pk(model)
98
+ [setattr(o, 'pk', id_to_pk[o.api_id]) for o in objs_w_pks]
99
+
100
+ # Inform the user
101
+ if not silent:
102
+ message = self.style.SUCCESS(
103
+ f'{verb} {len(objs)} {model.__name__} instances.'
104
+ )
105
+ self.stdout.write(message)
106
+
107
+ return objs_w_pks
108
+
109
+ def set_direct_object_pks(
110
+ self,
111
+ model: Type[CTCTModel],
112
+ instances: list[CTCTModel],
113
+ ) -> None:
114
+ """Sets Django pk values for OneToOne and ForeignKeys objects."""
115
+ for field in model._meta.get_fields():
116
+ if field.one_to_one or field.many_to_one:
117
+ # Convert API id to Django pk (hits db)
118
+ id_to_pk = self.get_id_to_pk(field.remote_field.model)
119
+ converter = lambda o: id_to_pk[getattr(o, field.attname)]
120
+
121
+ [setattr(o, field.attname, converter(o)) for o in instances]
122
+
123
+ def set_related_object_pks(
124
+ self,
125
+ model: CTCTModel,
126
+ objs_w_pks: list[CTCTModel],
127
+ related_fields: list[dict],
128
+ ) -> None:
129
+ """Sets Django pk values for ManyToMany and ReverseForeignKey objects."""
130
+ for obj_w_pk, related_fields in zip(objs_w_pks, related_fields):
131
+ for related_model, related_objs in related_fields.items():
132
+ for field in related_model._meta.get_fields():
133
+ if field.remote_field:
134
+ if field.name == 'author':
135
+ # CTCT doesn't store Author info
136
+ continue
137
+ if field.many_to_many and (related_objs[0].pk is None):
138
+ # Can't save ManyToMany until parent object has pk
139
+ continue
140
+ elif field.remote_field.model is model:
141
+ # No need to hit the db, we know the pk is obj_w_pk.pk
142
+ converter = lambda _: obj_w_pk.pk
143
+ else:
144
+ # Convert API id to Django pk (hits db)
145
+ id_to_pk = self.get_id_to_pk(field.remote_field.model)
146
+ converter = lambda o: id_to_pk[getattr(o, field.attname)]
147
+
148
+ # Set pks on related objects
149
+ [setattr(o, field.attname, converter(o)) for o in related_objs]
150
+
151
+ def import_model(self, model: CTCTModel) -> None:
152
+ """Imports objects from CTCT into Django's database."""
153
+
154
+ if model is CampaignActivity:
155
+ # CampaignActivities do not have a bulk API endpoint
156
+ return self.import_campaign_activities()
157
+
158
+ model.remote.connect()
159
+ try:
160
+ objs, related_fields = zip(*model.remote.all())
161
+ except ValueError:
162
+ # No values returned
163
+ return
164
+
165
+ if model is CampaignSummary:
166
+ # Convert API id to Django pk for the OneToOneField with EmailCampaign
167
+ self.set_direct_object_pks(model, objs)
168
+
169
+ # Upsert models to get Django pks
170
+ objs_w_pks = self.upsert(model, objs)
171
+
172
+ # Convert API id to Django pk for related objects
173
+ self.set_related_object_pks(model, objs_w_pks, related_fields)
174
+
175
+ # Reshape related_fields for efficiency
176
+ rows, related_fields = related_fields, defaultdict(list)
177
+ for row in rows:
178
+ for related_model, related_objs in row.items():
179
+ related_fields[related_model].extend(related_objs)
180
+
181
+ # Upsert related_objs
182
+ for related_model, related_objs in related_fields.items():
183
+ self.upsert(related_model, related_objs)
184
+
185
+ def import_campaign_activities(self) -> None:
186
+ """CampaignActivities must be imported one at a time."""
187
+
188
+ model = CampaignActivity
189
+
190
+ objs_and_related_fields = []
191
+
192
+ model.remote.connect()
193
+ activities = model.objects.filter(role='primary_email')
194
+ for activity in tqdm(activities, disable=self.noinput):
195
+ obj, related_fields = model.remote.get(activity.api_id)
196
+ obj.pk = activity.pk
197
+ obj.campaign_id = activity.campaign_id
198
+
199
+ objs_and_related_fields.append((obj, related_fields))
200
+
201
+ # Upsert objects to update fields
202
+ self.upsert(
203
+ model=model,
204
+ objs=[o for (o, _) in objs_and_related_fields],
205
+ unique_fields=['campaign_id', 'role'],
206
+ update_fields=['role', 'subject', 'preheader', 'html_content']
207
+ )
208
+
209
+ # Convert API id to Django pk for related objects
210
+ objs_w_pks, related_fields = zip(*objs_and_related_fields)
211
+ self.set_related_object_pks(model, objs_w_pks, related_fields)
212
+
213
+ # Reshape related_fields for efficiency
214
+ rows, related_fields = related_fields, defaultdict(list)
215
+ for row in rows:
216
+ for related_model, related_objs in row.items():
217
+ related_fields[related_model].extend(related_objs)
218
+
219
+ # Upsert related_objs
220
+ for related_model, related_objs in related_fields.items():
221
+ self.upsert(related_model, related_objs)
222
+
223
+ def add_arguments(self, parser: ArgumentParser) -> None:
224
+ """Allow optional keyword arguments."""
225
+
226
+ parser.add_argument(
227
+ '--noinput',
228
+ action='store_true',
229
+ default=False,
230
+ help='Automatic yes to prompts',
231
+ )
232
+ parser.add_argument(
233
+ '--stats_only',
234
+ action='store_true',
235
+ default=False,
236
+ help='Only fetch EmailCampaign statistics',
237
+ )
238
+
239
+ def handle(self, *args, **kwargs):
240
+ """Primary access point for Django management command."""
241
+
242
+ self.noinput = kwargs['noinput']
243
+ self.stats_only = kwargs['stats_only']
244
+
245
+ if self.stats_only:
246
+ self.CTCT_MODELS = [CampaignSummary]
247
+
248
+ for model in self.CTCT_MODELS:
249
+ if model is CampaignActivity:
250
+ note = "Note: This will result in 1 API request per EmailCampaign! "
251
+ else:
252
+ note = ""
253
+ question = _(f'Import {model.__name__}? {note}(y/n): ')
254
+
255
+ if self.noinput or (input(question).lower()[0] == 'y'):
256
+ self.import_model(model)
257
+ else:
258
+ message = _(f'Skipping {model.__name__}')
259
+ self.stdout.write(self.style.NOTICE(message))