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.
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/PKG-INFO +1 -1
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/admin.py +62 -43
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/apps.py +0 -1
- django_ctct-0.0.1.dev2/django_ctct/management/commands/import_ctct.py +233 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/managers.py +73 -49
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/models.py +123 -113
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/pyproject.toml +8 -1
- django_ctct-0.0.1.dev1/django_ctct/management/commands/import_ctct.py +0 -211
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/LICENSE +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/README.md +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/__init__.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/signals.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/urls.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/utils.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/vendor.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev2}/django_ctct/views.py +0 -0
|
@@ -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,
|
|
22
|
-
|
|
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)
|
|
@@ -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
|
-
#
|
|
256
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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. '
|
|
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(
|
|
897
|
-
|
|
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
|
-
|
|
893
|
+
data = {'name': obj.name}
|
|
900
894
|
else:
|
|
901
|
-
|
|
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
|
|
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,
|
|
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='
|
|
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='
|
|
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='
|
|
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='
|
|
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='
|
|
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(
|
|
616
|
+
class ContactCustomField(models.Model):
|
|
618
617
|
"""Django implementation of a CTCT Contact's CustomField.
|
|
619
618
|
|
|
620
619
|
Notes
|
|
621
620
|
-----
|
|
622
|
-
|
|
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='
|
|
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='
|
|
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='
|
|
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='
|
|
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.
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|