django-ctct 0.0.1.dev1__tar.gz → 0.0.1.dev2__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.dev2
4
4
  Summary: A Django interface for the Constant Contact API
5
5
  License: MIT
6
6
  Author: Geoffrey Eisenbarth
@@ -17,9 +17,10 @@ from django.utils.safestring import mark_safe
17
17
  from django.utils.translation import gettext_lazy as _
18
18
 
19
19
  from django_ctct.models import (
20
- Token, CTCTRemoteModel, ContactList,
21
- Contact, CustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
22
- EmailCampaign, CampaignActivity
20
+ Token, CTCTRemoteModel, ContactList, CustomField,
21
+ Contact,
22
+ ContactCustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
23
+ EmailCampaign, CampaignActivity, CampaignSummary,
23
24
  )
24
25
  from django_ctct.signals import remote_save, remote_delete
25
26
  from django_ctct.vendor import mute_signals
@@ -258,6 +259,14 @@ class ContactNoteInline(admin.TabularInline):
258
259
  return False
259
260
 
260
261
 
262
+ class ContactCustomFieldInline(admin.TabularInline):
263
+
264
+ model = ContactCustomField
265
+ excldue = ('api_id', )
266
+
267
+ extra = 1
268
+
269
+
261
270
  class ContactAdmin(RemoteModelAdmin):
262
271
  """Admin functionality for CTCT Contacts."""
263
272
 
@@ -317,6 +326,7 @@ class ContactAdmin(RemoteModelAdmin):
317
326
  )
318
327
  filter_horizontal = ('list_memberships', )
319
328
  inlines = (
329
+ ContactCustomFieldInline,
320
330
  ContactPhoneNumberInline,
321
331
  ContactStreetAddressInline,
322
332
  ContactNoteInline,
@@ -439,52 +449,19 @@ class EmailCampaignAdmin(RemoteModelAdmin):
439
449
  'updated_at',
440
450
  'current_status',
441
451
  'scheduled_datetime',
442
- 'open_rate',
443
- 'sends',
444
- 'bounces',
445
- 'clicks',
446
- 'optouts',
447
- 'abuse',
448
452
  'is_synced',
449
453
  )
450
454
 
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
455
  # ChangeView
456
+ fieldsets = (
457
+ (None, {
458
+ 'fields': (
459
+ 'name', 'current_status', 'scheduled_datetime', 'send_preview'
460
+ ),
461
+ }),
462
+ )
462
463
  inlines = (CampaignActivityInline, )
463
464
 
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
465
  def get_readonly_fields(self, request: HttpRequest, obj=None):
489
466
  readonly_fields = EmailCampaign.remote.API_READONLY_FIELDS
490
467
  if obj and obj.current_status == 'DONE':
@@ -557,6 +534,47 @@ class EmailCampaignAdmin(RemoteModelAdmin):
557
534
  self.message_user(request, message)
558
535
 
559
536
 
537
+ class CampaignSummaryAdmin(ViewModelAdmin):
538
+ """Admin functionality for CTCT EmailCampaign Summary Report."""
539
+
540
+ # ListView
541
+ search_fields = ('name', )
542
+ list_display = (
543
+ 'campaign',
544
+ 'open_rate',
545
+ 'sends',
546
+ 'bounces',
547
+ 'clicks',
548
+ 'optouts',
549
+ 'abuse',
550
+ )
551
+
552
+ def open_rate(self, obj: EmailCampaign) -> str:
553
+ if obj.current_status == 'DONE':
554
+ r = (obj.opens / obj.sends) if obj.sends else 0
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')
561
+
562
+ # ChangeView
563
+ fieldsets = (
564
+ (None, {
565
+ 'fields': (
566
+ 'campaign',
567
+ ),
568
+ }),
569
+ ('ANALYTICS', {
570
+ 'fields': (
571
+ 'sends', 'opens', 'clicks', 'forwards',
572
+ 'optouts', 'abuse', 'bounces', 'not_opened',
573
+ ),
574
+ }),
575
+ )
576
+
577
+
560
578
  if getattr(settings, 'CTCT_USE_ADMIN', False):
561
579
  admin.site.register(Token, TokenAdmin)
562
580
  admin.site.register(ContactList, ContactListAdmin)
@@ -564,3 +582,4 @@ if getattr(settings, 'CTCT_USE_ADMIN', False):
564
582
  admin.site.register(Contact, ContactAdmin)
565
583
  admin.site.register(ContactNote, ContactNoteAdmin)
566
584
  admin.site.register(EmailCampaign, EmailCampaignAdmin)
585
+ 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,233 @@
1
+ from argparse import ArgumentParser
2
+ from typing import Optional, Type
3
+
4
+ from tqdm import tqdm
5
+
6
+ import django
7
+ from django.core.management.base import BaseCommand
8
+
9
+ from django_ctct.models import (
10
+ CTCTModel, ContactList, CustomField,
11
+ Contact, ContactCustomField,
12
+ EmailCampaign, CampaignActivity, CampaignSummary,
13
+ )
14
+
15
+
16
+ class Command(BaseCommand):
17
+ """Imports django-ctct model instances from CTCT servers.
18
+
19
+ Notes
20
+ -----
21
+ CTCT does not provide an endpoint for fetching bulk CampaignActivities.
22
+ As a result, we must loop through the EmailCampaigns, make a request to get
23
+ the associated CampaignActivities, and then make a second request to get the
24
+ details of the CampaignActivity.
25
+
26
+ As a result, importing CampaignActivities will be slow, and running it
27
+ multiple times may result in exceeding CTCT's 10,000 requests per day
28
+ limit.
29
+
30
+ """
31
+
32
+ help = 'Imports data from ConstantContact'
33
+
34
+ CTCT_MODELS = [
35
+ ContactList,
36
+ CustomField,
37
+ Contact,
38
+ EmailCampaign,
39
+ CampaignActivity,
40
+ CampaignSummary,
41
+ ]
42
+
43
+ def get_id_to_pk(self, model: Type[CTCTModel]) -> dict:
44
+ """Returns a dictionary to convert CTCT API ids to Django pks."""
45
+ id_to_pk = {
46
+ str(api_id): int(pk)
47
+ for (api_id, pk) in model.objects.values_list('api_id', 'pk')
48
+ }
49
+ return id_to_pk
50
+
51
+ def upsert(
52
+ self,
53
+ model: CTCTModel,
54
+ objs: list[CTCTModel],
55
+ update_conflicts: bool = True,
56
+ unique_fields: list[str] = ['api_id'],
57
+ update_fields: Optional[list[str]] = None,
58
+ silent: bool = False,
59
+ ) -> list[CTCTModel]:
60
+
61
+ verb = 'Imported' if (update_fields is None) else 'Updated'
62
+
63
+ # Perform upsert using `bulk_create()`
64
+ if model._meta.auto_created or (model is ContactCustomField):
65
+ # TODO: Should we delete existing through model instances?
66
+ update_conflicts = False
67
+ unique_fields = update_fields = None
68
+ elif model is CampaignSummary:
69
+ update_conflicts = True
70
+ unique_fields = ['campaign_id']
71
+ update_fields = model.remote.API_READONLY_FIELDS[1:]
72
+ elif update_fields is None:
73
+ update_fields = [
74
+ f.name
75
+ for f in model._meta.fields
76
+ if not f.primary_key and (f.name != 'api_id')
77
+ ]
78
+
79
+ objs_w_pks = model.objects.bulk_create(
80
+ objs=objs,
81
+ update_conflicts=update_conflicts,
82
+ unique_fields=unique_fields,
83
+ update_fields=update_fields,
84
+ )
85
+ if update_conflicts and (django.get_version() < '5.0'):
86
+ # In older versions, enabling the update_conflicts parameter prevented
87
+ # setting the primary key on each model instance.
88
+ if model is not CampaignSummary:
89
+ # CampaignSummary doesn't have `api_id` field (or related_objs)
90
+ # so it's okay to skip this part
91
+ id_to_pk = self.get_id_to_pk(model)
92
+ [setattr(o, 'pk', id_to_pk[o.api_id]) for o in objs_w_pks]
93
+
94
+ # Inform the user
95
+ if not silent:
96
+ message = self.style.SUCCESS(
97
+ f'{verb} {len(objs)} {model.__name__} instances.'
98
+ )
99
+ self.stdout.write(message)
100
+
101
+ return objs_w_pks
102
+
103
+ def set_direct_object_pks(
104
+ self,
105
+ model: Type[CTCTModel],
106
+ instances: list[CTCTModel],
107
+ ) -> None:
108
+ """Sets pk values for OneToOne and ForeignKeys defined on `model`."""
109
+ for field in model._meta.get_fields():
110
+ if field.one_to_one or field.many_to_one:
111
+ # Convert API id to Django pk (hits db)
112
+ id_to_pk = self.get_id_to_pk(field.remote_field.model)
113
+ converter = lambda o: id_to_pk[getattr(o, field.attname)]
114
+
115
+ [setattr(o, field.attname, converter(o)) for o in instances]
116
+
117
+ def set_related_object_pks(
118
+ self,
119
+ model: Type[CTCTModel],
120
+ obj_w_pk: CTCTModel,
121
+ related_model: Type[CTCTModel],
122
+ instances: list[CTCTModel],
123
+ ) -> None:
124
+ """Sets pk values for various related object."""
125
+ for field in related_model._meta.get_fields():
126
+ if field.remote_field:
127
+ if field.name == 'author':
128
+ # CTCT doesn't store Author info
129
+ continue
130
+ if field.many_to_many and (instances[0].pk is None):
131
+ # Can't save ManyToMany until parent object has pk
132
+ continue
133
+ elif field.remote_field.model is model:
134
+ # No need to hit the db, we know the pk is obj_w_pk.pk
135
+ converter = lambda _: obj_w_pk.pk
136
+ else:
137
+ # Convert API id to Django pk (hits db)
138
+ id_to_pk = self.get_id_to_pk(field.remote_field.model)
139
+ converter = lambda o: id_to_pk[getattr(o, field.attname)]
140
+
141
+ # Set pks on related objects
142
+ [setattr(o, field.attname, converter(o)) for o in instances]
143
+
144
+ def import_model(self, model: CTCTModel) -> None:
145
+ """Imports objects from CTCT into Django's database."""
146
+
147
+ if model is CampaignActivity:
148
+ # CampaignActivities do not have a bulk API endpoint
149
+ return self.import_campaign_activities()
150
+
151
+ model.remote.connect()
152
+ try:
153
+ objs, related_objs = zip(*model.remote.all())
154
+ except ValueError:
155
+ # No values returned
156
+ return
157
+
158
+ if model is CampaignSummary:
159
+ # Convert API id to pk for the OneToOneField with EmailCampaign
160
+ self.set_direct_object_pks(model, objs)
161
+
162
+ # Upsert models to get Django pks
163
+ objs_w_pks = self.upsert(model, objs)
164
+
165
+ for obj_w_pk, related_objs in zip(objs_w_pks, related_objs):
166
+ for related_model, instances in related_objs.items():
167
+ self.set_related_object_pks(model, obj_w_pk, related_model, instances)
168
+
169
+ # Upsert now that pks have been set
170
+ self.upsert(related_model, instances, silent=True)
171
+
172
+ def import_campaign_activities(self) -> None:
173
+ """CampaignActivities must be imported one at a time."""
174
+
175
+ model = CampaignActivity
176
+
177
+ objs_and_related_objs = []
178
+
179
+ model.remote.connect()
180
+ for activity in tqdm(model.objects.filter(role='primary_email')):
181
+ obj, related_objs = model.remote.get(activity.api_id)
182
+ obj.pk = activity.pk
183
+ obj.campaign_id = activity.campaign_id
184
+
185
+ objs_and_related_objs.append((obj, related_objs))
186
+
187
+ # Upsert objects to update fields
188
+ self.upsert(
189
+ model=model,
190
+ objs=[_[0] for _ in objs_and_related_objs],
191
+ unique_fields=['campaign_id', 'role'],
192
+ update_fields=['role', 'subject', 'preheader', 'html_content']
193
+ )
194
+
195
+ for obj_w_pk, related_objs in objs_and_related_objs:
196
+ for related_model, instances in related_objs.items():
197
+ self.set_related_object_pks(model, obj_w_pk, related_model, instances)
198
+
199
+ # Upsert now that pks have been set
200
+ self.upsert(related_model, instances, silent=True)
201
+
202
+ def add_arguments(self, parser: ArgumentParser) -> None:
203
+ """Allow optional keyword arguments."""
204
+
205
+ parser.add_argument(
206
+ '--noinput',
207
+ action='store_true',
208
+ default=False,
209
+ help='Automatic yes to prompts',
210
+ )
211
+ parser.add_argument(
212
+ '--stats_only',
213
+ action='store_true',
214
+ default=False,
215
+ help='Only fetch EmailCampaign statistics',
216
+ )
217
+
218
+ def handle(self, *args, **kwargs):
219
+ """Primary access point for Django management command."""
220
+
221
+ self.noinput = kwargs['noinput']
222
+ self.stats_only = kwargs['stats_only']
223
+
224
+ if self.stats_only:
225
+ self.CTCT_MODELS = [CampaignSummary]
226
+
227
+ for model in self.CTCT_MODELS:
228
+ question = f'Import {model.__name__}? (y/n): '
229
+ if self.noinput or (input(question).lower()[0] == 'y'):
230
+ self.import_model(model)
231
+ else:
232
+ message = f'Skipping {model.__name__}'
233
+ self.stdout.write(self.style.NOTICE(message))
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime as dt
3
4
  import logging
4
5
  from functools import partial
5
6
  from typing import TYPE_CHECKING, Literal, Optional, NoReturn
6
7
  from urllib.parse import urlencode
8
+ from uuid import UUID
7
9
 
8
10
  from jwt import ExpiredSignatureError
9
11
  from ratelimit import limits, sleep_and_retry
@@ -207,9 +209,6 @@ class RemoteManager(BaseRemoteManager):
207
209
  API_LIMIT_CALLS = 4 # four calls
208
210
  API_LIMIT_PERIOD = 1 # per second
209
211
 
210
- API_ENDPOINT = ''
211
- API_ID_LABEL = ''
212
-
213
212
  API_GET_QUERIES = {}
214
213
  API_EDITABLE_FIELDS = tuple()
215
214
  API_READONLY_FIELDS = (
@@ -249,27 +248,35 @@ class RemoteManager(BaseRemoteManager):
249
248
  }[field_types]
250
249
 
251
250
  for field_name in field_names:
251
+ try:
252
+ value = getattr(obj, field_name, None)
253
+ except ValueError:
254
+ print('SERIALIZE ERROR', type(obj), field_name)
255
+ continue
256
+
257
+ if value is None:
258
+ # Don't include null values
259
+ continue
260
+ elif isinstance(value, UUID):
261
+ # Convert UUID to string
262
+ value = str(value)
263
+ elif isinstance(value, dt.datetime):
264
+ # Convert datetime to string
265
+ value = value.strftime(self.TS_FORMAT)
266
+
267
+ # The field determines how the value is serialized
252
268
  try:
253
269
  field = self.model._meta.get_field(field_name)
254
270
  except FieldDoesNotExist:
255
- # Check if the API field was defined as a @property
256
- if value := getattr(obj, field_name, None):
257
- data[field_name] = value
271
+ # The API field was defined as a @property
272
+ data[field_name] = value
258
273
  continue
259
274
 
260
- if field_name.endswith('_id'):
261
- # Convert related object UUID to string
262
- if (value := getattr(obj, field_name)) is not None:
263
- value = str(value)
264
- if field_name == 'api_id':
265
- field_name = self.model.remote.API_ID_LABEL
266
-
267
- elif isinstance(field, models.DateTimeField):
268
- # Convert datetime to string
269
- if (value := getattr(obj, field_name)) is not None:
270
- value = value.strftime(self.TS_FORMAT)
271
- elif not field.is_relation:
272
- value = getattr(obj, field_name)
275
+ if field_name == 'api_id':
276
+ field_name = self.model.remote.API_ID_LABEL
277
+ elif field_name.endswith('_id'):
278
+ api_id = getattr(obj, field_name[:-3]).api_id
279
+ value = str(api_id)
273
280
  elif field.many_to_many:
274
281
  if obj.pk:
275
282
  qs = getattr(obj, field_name).values_list('api_id', flat=True)
@@ -318,19 +325,8 @@ class RemoteManager(BaseRemoteManager):
318
325
  else:
319
326
  data = data.copy()
320
327
 
321
- try:
328
+ if hasattr(self, 'API_ID_LABEL'):
322
329
  data['api_id'] = data.pop(self.API_ID_LABEL)
323
- except AttributeError:
324
- message = _(
325
- f"{self} is missing the `API_ID_LABEL` attribute."
326
- )
327
- raise ImproperlyConfigured(message)
328
- except KeyError as e:
329
- if self.API_ID_LABEL is None:
330
- # e.g. ContactCustomField
331
- pass
332
- else:
333
- raise e
334
330
 
335
331
  # Clean field values, must be done before field restriction
336
332
  model_fields = self.model._meta.get_fields()
@@ -377,9 +373,10 @@ class RemoteManager(BaseRemoteManager):
377
373
 
378
374
  """
379
375
 
380
- otos, _, fks, _ = get_related_fields(self.model)
381
- for field in filter(lambda f: f.attname in data, otos + fks):
382
- data[field.attname] = parent_pk
376
+ if parent_pk:
377
+ otos, _, fks, _ = get_related_fields(self.model)
378
+ for field in filter(lambda f: f.attname in data, otos + fks):
379
+ data[field.attname] = parent_pk
383
380
  return data
384
381
 
385
382
  def deserialize_related_objs_fields(
@@ -502,8 +499,9 @@ class RemoteManager(BaseRemoteManager):
502
499
  )
503
500
  data = self.raise_or_json(response)
504
501
 
505
- # Data only contains two keys: '_links' and e.g. 'contacts' or 'lists'
502
+ # Data only contains two keys: '_links' and e.g. 'lists' or 'contacts'
506
503
  links = data.pop('_links', None)
504
+ data = next(iter(data.values()))
507
505
  objs.extend(map(self.deserialize, data))
508
506
 
509
507
  try:
@@ -856,7 +854,6 @@ class ContactStreetAddressRemoteManager(RemoteManager):
856
854
  class ContactCustomFieldRemoteManager(RemoteManager):
857
855
  """Extend RemoteManager to handle ContactCustomFields."""
858
856
 
859
- API_ID_LABEL = 'custom_field_id'
860
857
  API_EDITABLE_FIELDS = (
861
858
  'custom_field_id',
862
859
  'value',
@@ -878,27 +875,25 @@ class EmailCampaignRemoteManager(RemoteManager):
878
875
  API_READONLY_FIELDS = (
879
876
  'api_id',
880
877
  'current_status',
878
+ 'campaign_activities',
881
879
  'created_at',
882
880
  'updated_at',
883
- 'sends',
884
- 'opens',
885
- 'clicks',
886
- 'forwards',
887
- 'optouts',
888
- 'abuse',
889
- 'bounces',
890
- 'not_opened',
891
881
  )
892
882
  API_MAX_LENGTH = {
893
883
  'name': 80,
894
884
  }
895
885
 
896
- def serialize(self, obj: Model) -> dict:
897
- if obj.api_id:
886
+ def serialize(
887
+ self,
888
+ obj: Model,
889
+ field_types: Literal['editable', 'readonly', 'all'] = 'editable',
890
+ ) -> dict:
891
+ if obj.api_id and (field_types == 'editable'):
898
892
  # The only field that the API will update
899
- return {'name': obj.name}
893
+ data = {'name': obj.name}
900
894
  else:
901
- return super().serialize(obj)
895
+ data = super().serialize(obj, field_types)
896
+ return data
902
897
 
903
898
  # @task(queue_name='ctct')
904
899
  def create(self, obj: EmailCampaign) -> EmailCampaign:
@@ -1019,7 +1014,7 @@ class CampaignActivityRemoteManager(RemoteManager):
1019
1014
  'from_email': 80,
1020
1015
  'reply_to_email': 80,
1021
1016
  'subject': 200, # Not documented
1022
- 'preheader': 130, # Not documentred
1017
+ 'preheader': 130, # Not documented # TODO: Can it be bigger?
1023
1018
  'html_content': int(15e4),
1024
1019
  }
1025
1020
  API_GET_QUERIES = {
@@ -1146,3 +1141,32 @@ class CampaignActivityRemoteManager(RemoteManager):
1146
1141
  f"Cannot unschedule CampaignActivities with role '{obj.role}'."
1147
1142
  )
1148
1143
  raise ValueError(message)
1144
+
1145
+
1146
+ class CampaignSummaryRemoteManager(RemoteManager):
1147
+ """Extend RemoteManager to handle creating EmailCampaignSummarys."""
1148
+
1149
+ API_ENDPOINT = '/reports/summary_reports/email_campaign_summaries'
1150
+ API_READONLY_FIELDS = (
1151
+ 'campaign_id',
1152
+ 'sends',
1153
+ 'opens',
1154
+ 'clicks',
1155
+ 'forwards',
1156
+ 'optouts',
1157
+ 'abuse',
1158
+ 'bounces',
1159
+ 'not_opened',
1160
+ )
1161
+
1162
+ def serialize(
1163
+ self,
1164
+ obj: Model,
1165
+ field_types: Literal['editable', 'readonly', 'all'] = 'editable',
1166
+ ) -> dict:
1167
+ data = super().serialize(obj, field_types)
1168
+ data['unique_counts'] = {
1169
+ stat_field: data.pop(stat_field)
1170
+ for stat_field in self.API_READONLY_FIELDS[1:]
1171
+ }
1172
+ return data
@@ -19,7 +19,8 @@ from django_ctct.managers import (
19
19
  ContactRemoteManager, ContactNoteRemoteManager,
20
20
  ContactPhoneNumberRemoteManager, ContactStreetAddressRemoteManager,
21
21
  ContactCustomFieldRemoteManager,
22
- EmailCampaignRemoteManager, CampaignActivityRemoteManager,
22
+ EmailCampaignRemoteManager,
23
+ CampaignActivityRemoteManager, CampaignSummaryRemoteManager,
23
24
  )
24
25
 
25
26
 
@@ -111,10 +112,6 @@ class CTCTModel(Model):
111
112
  class Meta:
112
113
  abstract = True
113
114
 
114
- @classmethod
115
- def clean_remote_counts(cls, field_name: str, data: dict) -> int:
116
- return data.get('unique_counts', {}).get(field_name, 0)
117
-
118
115
  @classmethod
119
116
  def clean_remote_string(cls, field_name: str, data: dict) -> str:
120
117
  s = data.get(field_name, '')
@@ -226,6 +223,7 @@ class CustomField(CTCTRemoteModel):
226
223
  type = models.CharField(
227
224
  max_length=6,
228
225
  choices=TYPES,
226
+ default=TYPES[0][0],
229
227
  verbose_name=_('Type'),
230
228
  help_text=_(
231
229
  'Specifies the type of value the custom_field field accepts'
@@ -423,6 +421,7 @@ class ContactNote(CTCTModel):
423
421
  settings.AUTH_USER_MODEL,
424
422
  on_delete=models.CASCADE,
425
423
  null=True,
424
+ related_name='notes',
426
425
  verbose_name=_('Author'),
427
426
  )
428
427
 
@@ -446,7 +445,7 @@ class ContactNote(CTCTModel):
446
445
  # constraints = [
447
446
  # models.CheckConstraint(
448
447
  # check=Q(contact__notes__count__lte=ContactRemoteManager.API_MAX_NOTES),
449
- # name='limit_notes'
448
+ # name='django_ctct_limit_notes'
450
449
  # ),
451
450
  # ]
452
451
 
@@ -494,12 +493,12 @@ class ContactPhoneNumber(CTCTModel):
494
493
  # TODO PUSH: UniqueConstraint not enforced by CTCT?
495
494
  # models.UniqueConstraint(
496
495
  # fields=['contact', 'kind'],
497
- # name='unique_phone_number',
496
+ # name='django_ctct_unique_phone_number',
498
497
  # ),
499
498
  # TODO PUSH: CheckConstraint
500
499
  # models.CheckConstraint(
501
500
  # check=Q(contact__phone_numbers__count__lte=ContactRemoteManager.API_MAX_PHONE_NUMBERS),
502
- # name='limit_phone_numbers',
501
+ # name='django_ctct_limit_phone_numbers',
503
502
  # ),
504
503
  ]
505
504
 
@@ -577,12 +576,12 @@ class ContactStreetAddress(CTCTModel):
577
576
  # TODO PUSH: UniqueConstraint not enforced by CTCT?
578
577
  # models.UniqueConstraint(
579
578
  # fields=['contact', 'kind'],
580
- # name='unique_street_address',
579
+ # name='django_ctct_unique_street_address',
581
580
  # ),
582
581
  # TODO PUSH: CheckConstraint
583
582
  # models.CheckConstraint(
584
583
  # check=Q(contact__street_addresses__count__lte=ContactRemoteManager.API_MAX_STREET_ADDRESSES),
585
- # name='limit_street_addresses',
584
+ # name='django_ctct_limit_street_addresses',
586
585
  # ),
587
586
  ]
588
587
 
@@ -614,14 +613,12 @@ class ContactStreetAddress(CTCTModel):
614
613
  return cls.clean_remote_string('country', data)
615
614
 
616
615
 
617
- class ContactCustomField(CTCTModel):
616
+ class ContactCustomField(models.Model):
618
617
  """Django implementation of a CTCT Contact's CustomField.
619
618
 
620
619
  Notes
621
620
  -----
622
- It's important to specify `custom_field_id` in `API_EDITABLE_FIELDS` instead
623
- of `custom_field`; using the latter will result in a serialized CustomField
624
- instance when Contact is serialized.
621
+ CTCT does not provide UUIDs for these, so we do not inherit from CTCTModel.
625
622
 
626
623
  """
627
624
 
@@ -635,14 +632,13 @@ class ContactCustomField(CTCTModel):
635
632
  related_name='custom_fields',
636
633
  verbose_name=_('Contact'),
637
634
  )
638
-
639
- # API editable fields
640
635
  custom_field = models.ForeignKey(
641
636
  CustomField,
642
637
  on_delete=models.CASCADE,
643
- related_name='instances',
638
+ related_name='contacts',
644
639
  verbose_name=_('Field'),
645
640
  )
641
+
646
642
  value = models.CharField(
647
643
  max_length=remote.API_MAX_LENGTH['value'],
648
644
  verbose_name=_('Value'),
@@ -656,12 +652,12 @@ class ContactCustomField(CTCTModel):
656
652
  # TODO PUSH: UniqueConstraint not enforced by CTCT?
657
653
  # models.UniqueConstraint(
658
654
  # fields=['contact', 'custom_field'],
659
- # name='unique_custom_field',
655
+ # name='django_ctct_unique_custom_field',
660
656
  # ),
661
657
  # TODO PUSH: CheckConstraint
662
658
  # models.CheckConstraint(
663
659
  # check=Q(contact__custom_fields__count__lte=ContactRemoteManager.API_MAX_CUSTOM_FIELDS),
664
- # name='limit_custom_fields',
660
+ # name='django_ctct_limit_custom_fields',
665
661
  # ),
666
662
  ]
667
663
 
@@ -672,12 +668,7 @@ class ContactCustomField(CTCTModel):
672
668
  s = super().__str__()
673
669
  return s
674
670
 
675
- @classmethod
676
- def clean_remote_custom_field(cls, data: dict) -> str:
677
- return data['custom_field_id']
678
-
679
671
 
680
- # TODO: EmailCampaign.current_status vs CampaignActivity.current_status
681
672
  class EmailCampaign(CTCTRemoteModel):
682
673
  """Django implementation of a CTCT EmailCampaign."""
683
674
 
@@ -721,54 +712,6 @@ class EmailCampaign(CTCTRemoteModel):
721
712
  default='DRAFT',
722
713
  verbose_name=_('Current Status'),
723
714
  )
724
- sends = models.IntegerField(
725
- null=True,
726
- default=None,
727
- verbose_name=_('Sends'),
728
- help_text=_('The total number of unique sends'),
729
- )
730
- opens = models.IntegerField(
731
- null=True,
732
- default=None,
733
- verbose_name=_('Opens'),
734
- help_text=_('The total number of unique opens'),
735
- )
736
- clicks = models.IntegerField(
737
- null=True,
738
- default=None,
739
- verbose_name=_('Clicks'),
740
- help_text=_('The total number of unique clicks'),
741
- )
742
- forwards = models.IntegerField(
743
- null=True,
744
- default=None,
745
- verbose_name=_('Forwards'),
746
- help_text=_('The total number of unique forwards'),
747
- )
748
- optouts = models.IntegerField(
749
- null=True,
750
- default=None,
751
- verbose_name=_('Opt Out'),
752
- help_text=_('The total number of people who unsubscribed'),
753
- )
754
- abuse = models.IntegerField(
755
- null=True,
756
- default=None,
757
- verbose_name=_('Spam'),
758
- help_text=_('The total number of people who marked as spam'),
759
- )
760
- bounces = models.IntegerField(
761
- null=True,
762
- default=None,
763
- verbose_name=_('Bounces'),
764
- help_text=_('The total number of bounces'),
765
- )
766
- not_opened = models.IntegerField(
767
- null=True,
768
- default=None,
769
- verbose_name=_('Not Opened'),
770
- help_text=_('The total number of people who didn\'t open'),
771
- )
772
715
 
773
716
  class Meta:
774
717
  verbose_name = _('Email Campaign')
@@ -779,46 +722,6 @@ class EmailCampaign(CTCTRemoteModel):
779
722
  def __str__(self) -> str:
780
723
  return self.name
781
724
 
782
- @classmethod
783
- def clean_remote_sends(cls, data: dict) -> int:
784
- return cls.clean_remote_counts('sends', data)
785
-
786
- @classmethod
787
- def clean_remote_opens(cls, data: dict) -> int:
788
- return cls.clean_remote_counts('opens', data)
789
-
790
- @classmethod
791
- def clean_remote_clicks(cls, data: dict) -> int:
792
- return cls.clean_remote_counts('clicks', data)
793
-
794
- @classmethod
795
- def clean_remote_forwards(cls, data: dict) -> int:
796
- return cls.clean_remote_counts('forwards', data)
797
-
798
- @classmethod
799
- def clean_remote_optouts(cls, data: dict) -> int:
800
- return cls.clean_remote_counts('optouts', data)
801
-
802
- @classmethod
803
- def clean_remote_abuse(cls, data: dict) -> int:
804
- return cls.clean_remote_counts('abuse', data)
805
-
806
- @classmethod
807
- def clean_remote_bounces(cls, data: dict) -> int:
808
- return cls.clean_remote_counts('bounces', data)
809
-
810
- @classmethod
811
- def clean_remote_not_opened(cls, data: dict) -> int:
812
- return cls.clean_remote_counts('not_opened', data)
813
-
814
- @classmethod
815
- def clean_remote_current_status(cls, data: dict) -> str:
816
- if data.get('unique_counts'):
817
- current_status = 'DONE'
818
- else:
819
- current_status = data.get('current_status')
820
- return current_status
821
-
822
725
  @classmethod
823
726
  def clean_remote_scheduled_datetime(cls, data: dict) -> Optional[dt.datetime]: # noqa: E501
824
727
  if scheduled_datetime := data.get('last_sent_date'):
@@ -941,7 +844,7 @@ class CampaignActivity(CTCTRemoteModel):
941
844
  constraints = [
942
845
  models.UniqueConstraint(
943
846
  fields=['campaign', 'role'],
944
- name='unique_campaign_activity',
847
+ name='django_ctct_unique_campaign_activity',
945
848
  ),
946
849
  ]
947
850
 
@@ -994,3 +897,110 @@ class CampaignActivity(CTCTRemoteModel):
994
897
  @classmethod
995
898
  def clean_remote_contact_lists(cls, data: dict) -> list[str]:
996
899
  return data.pop('contact_list_ids', [])
900
+
901
+
902
+ class CampaignSummary(models.Model):
903
+ """Django implementation of a CTCT EmailCampaign report."""
904
+
905
+ # Must explicitly specify both
906
+ objects = models.Manager()
907
+ remote = CampaignSummaryRemoteManager()
908
+
909
+ campaign = models.OneToOneField(
910
+ EmailCampaign,
911
+ on_delete=models.CASCADE,
912
+ related_name='summary',
913
+ verbose_name=_('Email Campaign'),
914
+ )
915
+
916
+ # API read-only fields
917
+ sends = models.IntegerField(
918
+ null=True,
919
+ default=None,
920
+ verbose_name=_('Sends'),
921
+ help_text=_('The total number of unique sends'),
922
+ )
923
+ opens = models.IntegerField(
924
+ null=True,
925
+ default=None,
926
+ verbose_name=_('Opens'),
927
+ help_text=_('The total number of unique opens'),
928
+ )
929
+ clicks = models.IntegerField(
930
+ null=True,
931
+ default=None,
932
+ verbose_name=_('Clicks'),
933
+ help_text=_('The total number of unique clicks'),
934
+ )
935
+ forwards = models.IntegerField(
936
+ null=True,
937
+ default=None,
938
+ verbose_name=_('Forwards'),
939
+ help_text=_('The total number of unique forwards'),
940
+ )
941
+ optouts = models.IntegerField(
942
+ null=True,
943
+ default=None,
944
+ verbose_name=_('Opt Out'),
945
+ help_text=_('The total number of people who unsubscribed'),
946
+ )
947
+ abuse = models.IntegerField(
948
+ null=True,
949
+ default=None,
950
+ verbose_name=_('Spam'),
951
+ help_text=_('The total number of people who marked as spam'),
952
+ )
953
+ bounces = models.IntegerField(
954
+ null=True,
955
+ default=None,
956
+ verbose_name=_('Bounces'),
957
+ help_text=_('The total number of bounces'),
958
+ )
959
+ not_opened = models.IntegerField(
960
+ null=True,
961
+ default=None,
962
+ verbose_name=_('Not Opened'),
963
+ help_text=_('The total number of people who didn\'t open'),
964
+ )
965
+
966
+ class Meta:
967
+ verbose_name = _('Email Campaign Report')
968
+ verbose_name_plural = _('Email Campaign Reports')
969
+
970
+ ordering = ('-campaign', )
971
+
972
+ @classmethod
973
+ def clean_remote_counts(cls, field_name: str, data: dict) -> int:
974
+ return data.get('unique_counts', {}).get(field_name, 0)
975
+
976
+ @classmethod
977
+ def clean_remote_sends(cls, data: dict) -> int:
978
+ return cls.clean_remote_counts('sends', data)
979
+
980
+ @classmethod
981
+ def clean_remote_opens(cls, data: dict) -> int:
982
+ return cls.clean_remote_counts('opens', data)
983
+
984
+ @classmethod
985
+ def clean_remote_clicks(cls, data: dict) -> int:
986
+ return cls.clean_remote_counts('clicks', data)
987
+
988
+ @classmethod
989
+ def clean_remote_forwards(cls, data: dict) -> int:
990
+ return cls.clean_remote_counts('forwards', data)
991
+
992
+ @classmethod
993
+ def clean_remote_optouts(cls, data: dict) -> int:
994
+ return cls.clean_remote_counts('optouts', data)
995
+
996
+ @classmethod
997
+ def clean_remote_abuse(cls, data: dict) -> int:
998
+ return cls.clean_remote_counts('abuse', data)
999
+
1000
+ @classmethod
1001
+ def clean_remote_bounces(cls, data: dict) -> int:
1002
+ return cls.clean_remote_counts('bounces', data)
1003
+
1004
+ @classmethod
1005
+ def clean_remote_not_opened(cls, data: dict) -> int:
1006
+ return cls.clean_remote_counts('not_opened', data)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-ctct"
3
- version = "0.0.1.dev1"
3
+ version = "0.0.1.dev2"
4
4
  description = "A Django interface for the Constant Contact API"
5
5
  authors = [
6
6
  {name = "Geoffrey Eisenbarth",email = "geoffrey.eisenbarth@gmail.com"}
@@ -31,9 +31,16 @@ factory-boy = "^3.3.3"
31
31
  requests-mock = "^1.12.1"
32
32
  flake8 = "^7.1.2"
33
33
  codecov = "^2.1.13"
34
+ mypy = "^1.15.0"
34
35
 
35
36
  [tool.pytest.ini_options]
36
37
  DJANGO_SETTINGS_MODULE = "project.settings"
37
38
 
38
39
  [tool.coverage.report]
39
40
  show_missing = true
41
+
42
+ # [tool.mypy]
43
+ # python_version = 3.9
44
+ # django_any_from_any = true
45
+ # plugins = mypy_django_plugin.*
46
+ # ignore_missing_imports = true
@@ -1,211 +0,0 @@
1
- from argparse import ArgumentParser
2
- from collections import defaultdict
3
- from operator import attrgetter
4
- from typing import Optional
5
-
6
- from tqdm import tqdm
7
-
8
- from django.core.management.base import BaseCommand
9
-
10
- from django_ctct.models import (
11
- CTCTModel, CustomField,
12
- ContactList, Contact,
13
- EmailCampaign, CampaignActivity,
14
- )
15
-
16
-
17
- class Command(BaseCommand):
18
- """Imports django-ctct model instances from CTCT servers.
19
-
20
- Notes
21
- -----
22
- CTCT does not provide an endpoint for fetching bulk CampaignActivities.
23
- As a result, we must loop through the EmailCampaigns, make a request to get
24
- the associated CampaignActivities, and then make a second request to get the
25
- details of the CampaignActivity.
26
-
27
- As a result, importing CampaignActivities will be slow, and running it
28
- multiple times may result in exceeding CTCT's 10,000 requests per day
29
- limit.
30
-
31
- """
32
-
33
- help = 'Imports data from ConstantContact'
34
-
35
- CTCT_MODELS = [
36
- ContactList,
37
- CustomField,
38
- Contact,
39
- EmailCampaign,
40
- CampaignActivity,
41
- ]
42
-
43
- def upsert(
44
- self,
45
- model: CTCTModel,
46
- objs: list[CTCTModel],
47
- unique_fields: list[str] = ['api_id'],
48
- update_fields: Optional[list[str]] = None,
49
- silent: bool = False,
50
- ) -> list[CTCTModel]:
51
-
52
- verb = 'Imported' if (update_fields is None) else 'Updated'
53
-
54
- # TODO: is_through_model depreciated
55
- if getattr(model, 'is_through_model', False):
56
- breakpoint()
57
- model.objects.all().delete()
58
- objs = model.objects.bulk_create(objs)
59
- else:
60
- # Perform upsert using `bulk_create()`
61
- if update_fields is None:
62
- update_fields = [
63
- f.name
64
- for f in model._meta.fields
65
- if not f.primary_key and (f.name != 'api_id')
66
- ]
67
-
68
- objs = model.objects.bulk_create(
69
- objs=objs,
70
- update_conflicts=True,
71
- unique_fields=unique_fields,
72
- update_fields=update_fields,
73
- )
74
-
75
- # Inform the user
76
- if not silent:
77
- message = self.style.SUCCESS(
78
- f'{verb} {len(objs)} {model.__name__} instances.'
79
- )
80
- self.stdout.write(message)
81
-
82
- return objs
83
-
84
- def import_model(self, model: CTCTModel) -> None:
85
- """Imports objects from CTCT into Django's database."""
86
-
87
- if model is CampaignActivity:
88
- return self.import_campaign_activities()
89
-
90
- model.remote.connect()
91
- try:
92
- objs, related_objs = zip(*model.remote.all())
93
- except ValueError:
94
- # No values returned
95
- return
96
-
97
- # Upsert models to get Django pks
98
- objs_w_pks = self.upsert(model, objs)
99
-
100
- for obj, related_objs in zip(objs_w_pks, related_objs):
101
- for related_model, objs in related_objs.items():
102
-
103
- # TODO:
104
- if related_model.__name__ == 'Contact_list_memberships':
105
- continue
106
-
107
- # Set the parent object pk
108
- for field in related_model._meta.get_fields():
109
- if getattr(field.remote_field, 'model', None) is model:
110
- [setattr(o, field.attname, obj.pk) for o in objs]
111
-
112
- # Upsert now that parent object pk has been set
113
- objs = self.upsert(related_model, objs, silent=True)
114
-
115
- def import_campaign_activities(self) -> None:
116
- """Imports CampaignActivities from CTCT into Django's database."""
117
-
118
- objs, related_objs = [], defaultdict(list)
119
-
120
- EmailCampaign.remote.connect()
121
- CampaignActivity.remote.connect()
122
-
123
- # Use the EmailCampaign detail endpoint to get CampaignActivity api_ids
124
- for campaign in tqdm(EmailCampaign.objects.exclude(api_id=None)):
125
- _, _related_objs = EmailCampaign.remote.get(campaign.api_id)
126
-
127
- # Now we use the CampaignActivity detail endpoint to get remaining fields
128
- primary_emails = filter(
129
- lambda obj: obj.role == 'primary_email',
130
- _related_objs.get(CampaignActivity, [])
131
- )
132
- for api_id in map(attrgetter('api_id'), primary_emails):
133
- obj, _related_objs = CampaignActivity.remote.get(api_id)
134
- obj.campaign_id = campaign.pk
135
-
136
- # Store objects for later
137
- # NOTE We updated `related_obj` with the values from `_related_objs`
138
- for related_model, instances in _related_objs.items():
139
- related_objs[related_model].extend(instances)
140
- if obj is not None:
141
- objs.append(obj)
142
-
143
- # Upsert objects and related objects
144
- objs = self.upsert(
145
- model=CampaignActivity,
146
- objs=objs,
147
- unique_fields=['campaign_id', 'role'],
148
- update_fields=['role', 'subject', 'preheader', 'html_content']
149
- )
150
-
151
- for related_model, objs in related_objs.items():
152
- # TODO:
153
- if related_model.__name__ == 'CampaignActivity_contact_lists':
154
- continue
155
- objs = self.upsert(related_model, objs)
156
-
157
- def import_campaign_stats(self) -> None:
158
- """"Imports EmailCampaign stats from CTCT into Django's database."""
159
-
160
- endpoint = '/reports/summary_reports/email_campaign_summaries'
161
- update_fields = [
162
- 'current_status', 'created_at', 'updated_at', 'scheduled_datetime',
163
- 'sends', 'opens', 'clicks', 'forwards',
164
- 'optouts', 'abuse', 'bounces', 'not_opened',
165
- ]
166
-
167
- EmailCampaign.remote.connect()
168
- try:
169
- objs, _ = zip(*EmailCampaign.remote.all(endpoint=endpoint))
170
- except ValueError:
171
- # No values returned
172
- return
173
- else:
174
- objs = self.upsert(EmailCampaign, objs, update_fields=update_fields)
175
-
176
- def add_arguments(self, parser: ArgumentParser) -> None:
177
- """Allow optional keyword arguments."""
178
-
179
- parser.add_argument(
180
- '--noinput',
181
- action='store_true',
182
- default=False,
183
- help='Automatic yes to prompts',
184
- )
185
- parser.add_argument(
186
- '--stats_only',
187
- action='store_true',
188
- default=False,
189
- help='Only fetch EmailCampaign statistics',
190
- )
191
-
192
- def handle(self, *args, **kwargs):
193
- """Primary access point for Django management command."""
194
-
195
- self.noinput = kwargs['noinput']
196
- self.stats_only = kwargs['stats_only']
197
-
198
- if self.stats_only:
199
- self.CTCT_MODELS = []
200
-
201
- for model in self.CTCT_MODELS:
202
- question = f'Import {model.__name__}? (y/n): '
203
- if self.noinput or (input(question).lower()[0] == 'y'):
204
- self.import_model(model)
205
- else:
206
- message = f'Skipping {model.__name__}'
207
- self.stdout.write(self.style.NOTICE(message))
208
-
209
- question = 'Update EmailCampaign statistics? (y/n): '
210
- if self.noinput or (input(question).lower()[0] == 'y'):
211
- self.import_campaign_stats()