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.
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/PKG-INFO +1 -1
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/admin.py +109 -86
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/apps.py +0 -1
- django_ctct-0.0.1.dev3/django_ctct/management/commands/import_ctct.py +259 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/managers.py +81 -60
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/models.py +142 -123
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/signals.py +1 -1
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/utils.py +0 -4
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/pyproject.toml +12 -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.dev3}/LICENSE +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/README.md +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/__init__.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/urls.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/vendor.py +0 -0
- {django_ctct-0.0.1.dev1 → django_ctct-0.0.1.dev3}/django_ctct/views.py +0 -0
|
@@ -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
|
-
|
|
21
|
-
Contact,
|
|
22
|
-
|
|
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('📋'),
|
|
99
|
-
)
|
|
100
|
-
return html
|
|
101
|
-
copy_access_token.short_description = _('Access Token')
|
|
102
|
-
|
|
103
|
-
def copy_refresh_token(self, obj: Token) -> str:
|
|
104
|
-
html = format_html(
|
|
105
|
-
'<button class="button" onclick="{function}">{copy_icon}</button>',
|
|
106
|
-
function=f"navigator.clipboard.writeText('{obj.refresh_token}')",
|
|
107
|
-
copy_icon=mark_safe('📋'),
|
|
108
|
-
)
|
|
109
|
-
return html
|
|
110
|
-
copy_refresh_token.short_description = _('Refresh Token')
|
|
111
|
-
|
|
112
|
-
|
|
113
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
|
-
|
|
218
|
+
fields = ('content', )
|
|
247
219
|
|
|
248
|
-
extra =
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
'
|
|
443
|
-
'
|
|
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)
|
|
@@ -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))
|