django-ctct 0.0.1.dev2__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.dev2 → django_ctct-0.0.1.dev3}/PKG-INFO +1 -1
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/admin.py +55 -51
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/management/commands/import_ctct.py +71 -45
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/managers.py +12 -15
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/models.py +19 -10
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/signals.py +1 -1
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/utils.py +0 -4
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/pyproject.toml +10 -6
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/LICENSE +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/README.md +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/__init__.py +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/apps.py +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/urls.py +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/vendor.py +0 -0
- {django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/views.py +0 -0
|
@@ -6,18 +6,20 @@ 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
|
-
|
|
22
|
+
CTCTRemoteModel, ContactList, CustomField,
|
|
21
23
|
Contact,
|
|
22
24
|
ContactCustomField, ContactStreetAddress, ContactPhoneNumber, ContactNote,
|
|
23
25
|
EmailCampaign, CampaignActivity, CampaignSummary,
|
|
@@ -79,38 +81,6 @@ class ViewModelAdmin(admin.ModelAdmin):
|
|
|
79
81
|
return False
|
|
80
82
|
|
|
81
83
|
|
|
82
|
-
class TokenAdmin(ViewModelAdmin):
|
|
83
|
-
"""Admin functionality for CTCT Tokens."""
|
|
84
|
-
|
|
85
|
-
# ListView
|
|
86
|
-
list_display_links = None
|
|
87
|
-
list_display = (
|
|
88
|
-
'scope',
|
|
89
|
-
'created_at',
|
|
90
|
-
'expires_at',
|
|
91
|
-
'copy_access_token',
|
|
92
|
-
'copy_refresh_token',
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
def copy_access_token(self, obj: Token) -> str:
|
|
96
|
-
html = format_html(
|
|
97
|
-
'<button class="button" onclick="{function}">{copy_icon}</button>',
|
|
98
|
-
function=f"navigator.clipboard.writeText('{obj.access_token}')",
|
|
99
|
-
copy_icon=mark_safe('📋'),
|
|
100
|
-
)
|
|
101
|
-
return html
|
|
102
|
-
copy_access_token.short_description = _('Access Token')
|
|
103
|
-
|
|
104
|
-
def copy_refresh_token(self, obj: Token) -> str:
|
|
105
|
-
html = format_html(
|
|
106
|
-
'<button class="button" onclick="{function}">{copy_icon}</button>',
|
|
107
|
-
function=f"navigator.clipboard.writeText('{obj.refresh_token}')",
|
|
108
|
-
copy_icon=mark_safe('📋'),
|
|
109
|
-
)
|
|
110
|
-
return html
|
|
111
|
-
copy_refresh_token.short_description = _('Refresh Token')
|
|
112
|
-
|
|
113
|
-
|
|
114
84
|
class RemoteModelAdmin(RemoteSyncMixin, admin.ModelAdmin):
|
|
115
85
|
"""Facilitate remote saving and deleting."""
|
|
116
86
|
|
|
@@ -213,6 +183,7 @@ class CustomFieldAdmin(RemoteModelAdmin):
|
|
|
213
183
|
'label',
|
|
214
184
|
'type',
|
|
215
185
|
'created_at',
|
|
186
|
+
'updated_at',
|
|
216
187
|
'is_synced',
|
|
217
188
|
)
|
|
218
189
|
|
|
@@ -244,13 +215,11 @@ class ContactNoteInline(admin.TabularInline):
|
|
|
244
215
|
"""Inline for adding ContactNotes to a Contact."""
|
|
245
216
|
|
|
246
217
|
model = ContactNote
|
|
247
|
-
|
|
218
|
+
fields = ('content', )
|
|
248
219
|
|
|
249
|
-
extra =
|
|
220
|
+
extra = 0
|
|
250
221
|
max_num = Contact.remote.API_MAX_NOTES
|
|
251
222
|
|
|
252
|
-
readonly_fields = ('author', 'created_at')
|
|
253
|
-
|
|
254
223
|
def has_change_permission(
|
|
255
224
|
self,
|
|
256
225
|
request: HttpRequest,
|
|
@@ -264,7 +233,7 @@ class ContactCustomFieldInline(admin.TabularInline):
|
|
|
264
233
|
model = ContactCustomField
|
|
265
234
|
excldue = ('api_id', )
|
|
266
235
|
|
|
267
|
-
extra =
|
|
236
|
+
extra = 0
|
|
268
237
|
|
|
269
238
|
|
|
270
239
|
class ContactAdmin(RemoteModelAdmin):
|
|
@@ -368,6 +337,22 @@ class ContactAdmin(RemoteModelAdmin):
|
|
|
368
337
|
formset.save_m2m()
|
|
369
338
|
|
|
370
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
|
+
|
|
371
356
|
class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
|
|
372
357
|
"""Admin functionality for ContactNotes."""
|
|
373
358
|
|
|
@@ -384,17 +369,27 @@ class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
|
|
|
384
369
|
|
|
385
370
|
list_display_links = None
|
|
386
371
|
list_display = (
|
|
387
|
-
'
|
|
372
|
+
'contact_link',
|
|
388
373
|
'content',
|
|
389
|
-
'created_at',
|
|
390
374
|
'author',
|
|
375
|
+
'created_at',
|
|
391
376
|
'is_synced',
|
|
392
377
|
)
|
|
393
378
|
list_filter = (
|
|
394
379
|
'created_at',
|
|
395
|
-
|
|
380
|
+
ContactNoteAuthorFilter,
|
|
396
381
|
)
|
|
397
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
|
+
|
|
398
393
|
def has_delete_permission(self, request: HttpRequest, obj=None):
|
|
399
394
|
"""Allow superusers to delete Notes."""
|
|
400
395
|
return request.user.is_superuser
|
|
@@ -446,9 +441,10 @@ class EmailCampaignAdmin(RemoteModelAdmin):
|
|
|
446
441
|
search_fields = ('name', )
|
|
447
442
|
list_display = (
|
|
448
443
|
'name',
|
|
449
|
-
'updated_at',
|
|
450
444
|
'current_status',
|
|
451
445
|
'scheduled_datetime',
|
|
446
|
+
'created_at',
|
|
447
|
+
'updated_at',
|
|
452
448
|
'is_synced',
|
|
453
449
|
)
|
|
454
450
|
|
|
@@ -543,19 +539,28 @@ class CampaignSummaryAdmin(ViewModelAdmin):
|
|
|
543
539
|
'campaign',
|
|
544
540
|
'open_rate',
|
|
545
541
|
'sends',
|
|
542
|
+
'opens',
|
|
546
543
|
'bounces',
|
|
547
544
|
'clicks',
|
|
548
545
|
'optouts',
|
|
549
546
|
'abuse',
|
|
550
547
|
)
|
|
551
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
|
+
|
|
552
562
|
def open_rate(self, obj: EmailCampaign) -> str:
|
|
553
|
-
|
|
554
|
-
r = (obj.opens / obj.sends) if obj.sends else 0
|
|
555
|
-
s = f'{r:0.2%}'
|
|
556
|
-
else:
|
|
557
|
-
s = '-'
|
|
558
|
-
return s
|
|
563
|
+
return f'{obj.open_rate:0.0%}'
|
|
559
564
|
open_rate.admin_order_field = 'open_rate'
|
|
560
565
|
open_rate.short_description = _('Open Rate')
|
|
561
566
|
|
|
@@ -576,7 +581,6 @@ class CampaignSummaryAdmin(ViewModelAdmin):
|
|
|
576
581
|
|
|
577
582
|
|
|
578
583
|
if getattr(settings, 'CTCT_USE_ADMIN', False):
|
|
579
|
-
admin.site.register(Token, TokenAdmin)
|
|
580
584
|
admin.site.register(ContactList, ContactListAdmin)
|
|
581
585
|
admin.site.register(CustomField, CustomFieldAdmin)
|
|
582
586
|
admin.site.register(Contact, ContactAdmin)
|
{django_ctct-0.0.1.dev2 → django_ctct-0.0.1.dev3}/django_ctct/management/commands/import_ctct.py
RENAMED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from argparse import ArgumentParser
|
|
2
|
+
from collections import defaultdict
|
|
2
3
|
from typing import Optional, Type
|
|
3
4
|
|
|
4
5
|
from tqdm import tqdm
|
|
5
6
|
|
|
6
7
|
import django
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
9
|
from django.core.management.base import BaseCommand
|
|
8
10
|
|
|
9
11
|
from django_ctct.models import (
|
|
@@ -55,14 +57,18 @@ class Command(BaseCommand):
|
|
|
55
57
|
update_conflicts: bool = True,
|
|
56
58
|
unique_fields: list[str] = ['api_id'],
|
|
57
59
|
update_fields: Optional[list[str]] = None,
|
|
58
|
-
silent: bool =
|
|
60
|
+
silent: Optional[bool] = None,
|
|
59
61
|
) -> list[CTCTModel]:
|
|
60
62
|
|
|
61
63
|
verb = 'Imported' if (update_fields is None) else 'Updated'
|
|
64
|
+
if silent is None:
|
|
65
|
+
silent = self.noinput
|
|
62
66
|
|
|
63
67
|
# Perform upsert using `bulk_create()`
|
|
64
68
|
if model._meta.auto_created or (model is ContactCustomField):
|
|
65
|
-
|
|
69
|
+
if not issubclass(model, CTCTModel):
|
|
70
|
+
# Delete ManyToMany objects
|
|
71
|
+
model.objects.all().delete()
|
|
66
72
|
update_conflicts = False
|
|
67
73
|
unique_fields = update_fields = None
|
|
68
74
|
elif model is CampaignSummary:
|
|
@@ -105,7 +111,7 @@ class Command(BaseCommand):
|
|
|
105
111
|
model: Type[CTCTModel],
|
|
106
112
|
instances: list[CTCTModel],
|
|
107
113
|
) -> None:
|
|
108
|
-
"""Sets pk values for OneToOne and ForeignKeys
|
|
114
|
+
"""Sets Django pk values for OneToOne and ForeignKeys objects."""
|
|
109
115
|
for field in model._meta.get_fields():
|
|
110
116
|
if field.one_to_one or field.many_to_one:
|
|
111
117
|
# Convert API id to Django pk (hits db)
|
|
@@ -116,30 +122,31 @@ class Command(BaseCommand):
|
|
|
116
122
|
|
|
117
123
|
def set_related_object_pks(
|
|
118
124
|
self,
|
|
119
|
-
model:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
instances: list[CTCTModel],
|
|
125
|
+
model: CTCTModel,
|
|
126
|
+
objs_w_pks: list[CTCTModel],
|
|
127
|
+
related_fields: list[dict],
|
|
123
128
|
) -> None:
|
|
124
|
-
"""Sets pk values for
|
|
125
|
-
for
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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]
|
|
143
150
|
|
|
144
151
|
def import_model(self, model: CTCTModel) -> None:
|
|
145
152
|
"""Imports objects from CTCT into Django's database."""
|
|
@@ -150,54 +157,68 @@ class Command(BaseCommand):
|
|
|
150
157
|
|
|
151
158
|
model.remote.connect()
|
|
152
159
|
try:
|
|
153
|
-
objs,
|
|
160
|
+
objs, related_fields = zip(*model.remote.all())
|
|
154
161
|
except ValueError:
|
|
155
162
|
# No values returned
|
|
156
163
|
return
|
|
157
164
|
|
|
158
165
|
if model is CampaignSummary:
|
|
159
|
-
# Convert API id to pk for the OneToOneField with EmailCampaign
|
|
166
|
+
# Convert API id to Django pk for the OneToOneField with EmailCampaign
|
|
160
167
|
self.set_direct_object_pks(model, objs)
|
|
161
168
|
|
|
162
169
|
# Upsert models to get Django pks
|
|
163
170
|
objs_w_pks = self.upsert(model, objs)
|
|
164
171
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
self.set_related_object_pks(model, obj_w_pk, related_model, instances)
|
|
172
|
+
# Convert API id to Django pk for related objects
|
|
173
|
+
self.set_related_object_pks(model, objs_w_pks, related_fields)
|
|
168
174
|
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
171
184
|
|
|
172
185
|
def import_campaign_activities(self) -> None:
|
|
173
186
|
"""CampaignActivities must be imported one at a time."""
|
|
174
187
|
|
|
175
188
|
model = CampaignActivity
|
|
176
189
|
|
|
177
|
-
|
|
190
|
+
objs_and_related_fields = []
|
|
178
191
|
|
|
179
192
|
model.remote.connect()
|
|
180
|
-
|
|
181
|
-
|
|
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)
|
|
182
196
|
obj.pk = activity.pk
|
|
183
197
|
obj.campaign_id = activity.campaign_id
|
|
184
198
|
|
|
185
|
-
|
|
199
|
+
objs_and_related_fields.append((obj, related_fields))
|
|
186
200
|
|
|
187
201
|
# Upsert objects to update fields
|
|
188
202
|
self.upsert(
|
|
189
203
|
model=model,
|
|
190
|
-
objs=[
|
|
204
|
+
objs=[o for (o, _) in objs_and_related_fields],
|
|
191
205
|
unique_fields=['campaign_id', 'role'],
|
|
192
206
|
update_fields=['role', 'subject', 'preheader', 'html_content']
|
|
193
207
|
)
|
|
194
208
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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)
|
|
198
218
|
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
# Upsert related_objs
|
|
220
|
+
for related_model, related_objs in related_fields.items():
|
|
221
|
+
self.upsert(related_model, related_objs)
|
|
201
222
|
|
|
202
223
|
def add_arguments(self, parser: ArgumentParser) -> None:
|
|
203
224
|
"""Allow optional keyword arguments."""
|
|
@@ -225,9 +246,14 @@ class Command(BaseCommand):
|
|
|
225
246
|
self.CTCT_MODELS = [CampaignSummary]
|
|
226
247
|
|
|
227
248
|
for model in self.CTCT_MODELS:
|
|
228
|
-
|
|
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
|
+
|
|
229
255
|
if self.noinput or (input(question).lower()[0] == 'y'):
|
|
230
256
|
self.import_model(model)
|
|
231
257
|
else:
|
|
232
|
-
message = f'Skipping {model.__name__}'
|
|
258
|
+
message = _(f'Skipping {model.__name__}')
|
|
233
259
|
self.stdout.write(self.style.NOTICE(message))
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import datetime as dt
|
|
4
|
-
import logging
|
|
5
4
|
from functools import partial
|
|
6
5
|
from typing import TYPE_CHECKING, Literal, Optional, NoReturn
|
|
7
6
|
from urllib.parse import urlencode
|
|
@@ -35,12 +34,6 @@ if TYPE_CHECKING:
|
|
|
35
34
|
)
|
|
36
35
|
|
|
37
36
|
|
|
38
|
-
# Configure logging
|
|
39
|
-
logging.basicConfig(level=logging.DEBUG)
|
|
40
|
-
log = logging.getLogger('urllib3')
|
|
41
|
-
log.setLevel(logging.DEBUG)
|
|
42
|
-
|
|
43
|
-
|
|
44
37
|
class BaseRemoteManager(models.Manager):
|
|
45
38
|
"""Base manager for utilizing an API."""
|
|
46
39
|
|
|
@@ -250,9 +243,12 @@ class RemoteManager(BaseRemoteManager):
|
|
|
250
243
|
for field_name in field_names:
|
|
251
244
|
try:
|
|
252
245
|
value = getattr(obj, field_name, None)
|
|
253
|
-
except ValueError:
|
|
254
|
-
|
|
255
|
-
|
|
246
|
+
except ValueError as e:
|
|
247
|
+
if obj._meta.get_field(field_name).many_to_many and (obj.pk is None):
|
|
248
|
+
# Can't access related field when obj.pk is None
|
|
249
|
+
continue
|
|
250
|
+
else:
|
|
251
|
+
raise e
|
|
256
252
|
|
|
257
253
|
if value is None:
|
|
258
254
|
# Don't include null values
|
|
@@ -399,7 +395,8 @@ class RemoteManager(BaseRemoteManager):
|
|
|
399
395
|
related_objs[field.related_model] = objs
|
|
400
396
|
elif all(isinstance(_, str) for _ in related_data):
|
|
401
397
|
# ManyToManyField, make a list of "through model" instances
|
|
402
|
-
# TODO
|
|
398
|
+
# TODO: GH #12
|
|
399
|
+
# NOTE: This relies on old code where API ids were used
|
|
403
400
|
ThroughModel = getattr(self.model, field.name).through
|
|
404
401
|
model_attname = f'{field.model._meta.model_name}_id'
|
|
405
402
|
other_attname = f'{field.related_model._meta.model_name}_id'
|
|
@@ -880,7 +877,7 @@ class EmailCampaignRemoteManager(RemoteManager):
|
|
|
880
877
|
'updated_at',
|
|
881
878
|
)
|
|
882
879
|
API_MAX_LENGTH = {
|
|
883
|
-
'name': 80,
|
|
880
|
+
'name': 80, # TODO: GH #2
|
|
884
881
|
}
|
|
885
882
|
|
|
886
883
|
def serialize(
|
|
@@ -1002,7 +999,7 @@ class CampaignActivityRemoteManager(RemoteManager):
|
|
|
1002
999
|
'html_content',
|
|
1003
1000
|
'contact_lists',
|
|
1004
1001
|
'format_type', # Must include in request
|
|
1005
|
-
'physical_address_in_footer', # Must
|
|
1002
|
+
'physical_address_in_footer', # Must include in request
|
|
1006
1003
|
)
|
|
1007
1004
|
API_READONLY_FIELDS = (
|
|
1008
1005
|
'api_id',
|
|
@@ -1013,8 +1010,8 @@ class CampaignActivityRemoteManager(RemoteManager):
|
|
|
1013
1010
|
'from_name': 100,
|
|
1014
1011
|
'from_email': 80,
|
|
1015
1012
|
'reply_to_email': 80,
|
|
1016
|
-
'subject': 200,
|
|
1017
|
-
'preheader':
|
|
1013
|
+
'subject': 200,
|
|
1014
|
+
'preheader': 250,
|
|
1018
1015
|
'html_content': int(15e4),
|
|
1019
1016
|
}
|
|
1020
1017
|
API_GET_QUERIES = {
|
|
@@ -71,10 +71,11 @@ class Token(Model):
|
|
|
71
71
|
ordering = ('-created_at', )
|
|
72
72
|
|
|
73
73
|
def __str__(self) -> str:
|
|
74
|
-
|
|
75
|
-
timezone.localtime(self.
|
|
74
|
+
expires_at = formats.date_format(
|
|
75
|
+
timezone.localtime(self.expires_at),
|
|
76
76
|
settings.DATETIME_FORMAT,
|
|
77
77
|
)
|
|
78
|
+
s = f"{self.token_type} Token (Expires: {expires_at})"
|
|
78
79
|
return s
|
|
79
80
|
|
|
80
81
|
@property
|
|
@@ -441,7 +442,7 @@ class ContactNote(CTCTModel):
|
|
|
441
442
|
verbose_name = _('Note')
|
|
442
443
|
verbose_name_plural = _('Notes')
|
|
443
444
|
|
|
444
|
-
# TODO
|
|
445
|
+
# TODO: GH #8
|
|
445
446
|
# constraints = [
|
|
446
447
|
# models.CheckConstraint(
|
|
447
448
|
# check=Q(contact__notes__count__lte=ContactRemoteManager.API_MAX_NOTES),
|
|
@@ -449,6 +450,14 @@ class ContactNote(CTCTModel):
|
|
|
449
450
|
# ),
|
|
450
451
|
# ]
|
|
451
452
|
|
|
453
|
+
def __str__(self) -> str:
|
|
454
|
+
author = self.author or _('Unknown author')
|
|
455
|
+
created_at = formats.date_format(
|
|
456
|
+
timezone.localtime(self.created_at),
|
|
457
|
+
settings.DATETIME_FORMAT,
|
|
458
|
+
)
|
|
459
|
+
return f'{author} on {created_at}'
|
|
460
|
+
|
|
452
461
|
|
|
453
462
|
class ContactPhoneNumber(CTCTModel):
|
|
454
463
|
"""Django implementation of a CTCT Contact's PhoneNumber."""
|
|
@@ -490,12 +499,12 @@ class ContactPhoneNumber(CTCTModel):
|
|
|
490
499
|
verbose_name_plural = _('Phone Numbers')
|
|
491
500
|
|
|
492
501
|
constraints = [
|
|
493
|
-
# TODO
|
|
502
|
+
# TODO: GH #7
|
|
494
503
|
# models.UniqueConstraint(
|
|
495
504
|
# fields=['contact', 'kind'],
|
|
496
505
|
# name='django_ctct_unique_phone_number',
|
|
497
506
|
# ),
|
|
498
|
-
# TODO
|
|
507
|
+
# TODO: GH #8
|
|
499
508
|
# models.CheckConstraint(
|
|
500
509
|
# check=Q(contact__phone_numbers__count__lte=ContactRemoteManager.API_MAX_PHONE_NUMBERS),
|
|
501
510
|
# name='django_ctct_limit_phone_numbers',
|
|
@@ -573,12 +582,12 @@ class ContactStreetAddress(CTCTModel):
|
|
|
573
582
|
verbose_name_plural = _('Street Addresses')
|
|
574
583
|
|
|
575
584
|
constraints = [
|
|
576
|
-
# TODO
|
|
585
|
+
# TODO: GH #7
|
|
577
586
|
# models.UniqueConstraint(
|
|
578
587
|
# fields=['contact', 'kind'],
|
|
579
588
|
# name='django_ctct_unique_street_address',
|
|
580
589
|
# ),
|
|
581
|
-
# TODO
|
|
590
|
+
# TODO: GH #8
|
|
582
591
|
# models.CheckConstraint(
|
|
583
592
|
# check=Q(contact__street_addresses__count__lte=ContactRemoteManager.API_MAX_STREET_ADDRESSES),
|
|
584
593
|
# name='django_ctct_limit_street_addresses',
|
|
@@ -649,12 +658,12 @@ class ContactCustomField(models.Model):
|
|
|
649
658
|
verbose_name_plural = _('Custom Fields')
|
|
650
659
|
|
|
651
660
|
constraints = [
|
|
652
|
-
# TODO
|
|
661
|
+
# TODO: GH #7
|
|
653
662
|
# models.UniqueConstraint(
|
|
654
663
|
# fields=['contact', 'custom_field'],
|
|
655
664
|
# name='django_ctct_unique_custom_field',
|
|
656
665
|
# ),
|
|
657
|
-
# TODO
|
|
666
|
+
# TODO: GH #8
|
|
658
667
|
# models.CheckConstraint(
|
|
659
668
|
# check=Q(contact__custom_fields__count__lte=ContactRemoteManager.API_MAX_CUSTOM_FIELDS),
|
|
660
669
|
# name='django_ctct_limit_custom_fields',
|
|
@@ -689,7 +698,7 @@ class EmailCampaign(CTCTRemoteModel):
|
|
|
689
698
|
# API editable fields
|
|
690
699
|
name = models.CharField(
|
|
691
700
|
max_length=remote.API_MAX_LENGTH['name'],
|
|
692
|
-
# unique=True, # TODO
|
|
701
|
+
# unique=True, # TODO: GH #7
|
|
693
702
|
verbose_name=_('Name'),
|
|
694
703
|
)
|
|
695
704
|
scheduled_datetime = models.DateTimeField(
|
|
@@ -47,7 +47,7 @@ def remote_update_m2m(sender, instance, action, **kwargs):
|
|
|
47
47
|
"""Updates a Contact's list membership on CTCT servers."""
|
|
48
48
|
|
|
49
49
|
actions = ['post_add', 'post_remove', 'post_clear']
|
|
50
|
-
# TODO
|
|
50
|
+
# TODO: GH #11
|
|
51
51
|
# if (sender is ContactAndContactList) and (action in actions):
|
|
52
52
|
if action in actions:
|
|
53
53
|
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import datetime as dt
|
|
2
|
-
import logging
|
|
3
2
|
|
|
4
3
|
from django.db.models import Model
|
|
5
4
|
from django.utils import timezone
|
|
6
5
|
|
|
7
6
|
|
|
8
|
-
logger = logging.getLogger('django_ctct')
|
|
9
|
-
|
|
10
|
-
|
|
11
7
|
def to_dt(s: str, ts_format: str = '%Y-%m-%dT%H:%M:%SZ') -> dt.datetime:
|
|
12
8
|
return timezone.make_aware(dt.datetime.strptime(s, ts_format))
|
|
13
9
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "django-ctct"
|
|
3
|
-
version = "0.0.1.
|
|
3
|
+
version = "0.0.1.dev3"
|
|
4
4
|
description = "A Django interface for the Constant Contact API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Geoffrey Eisenbarth",email = "geoffrey.eisenbarth@gmail.com"}
|
|
@@ -32,6 +32,8 @@ requests-mock = "^1.12.1"
|
|
|
32
32
|
flake8 = "^7.1.2"
|
|
33
33
|
codecov = "^2.1.13"
|
|
34
34
|
mypy = "^1.15.0"
|
|
35
|
+
django-stubs = {extras = ["compatible-mypy"], version = "^5.1.3"}
|
|
36
|
+
types-requests = "^2.32.0.20250306"
|
|
35
37
|
|
|
36
38
|
[tool.pytest.ini_options]
|
|
37
39
|
DJANGO_SETTINGS_MODULE = "project.settings"
|
|
@@ -39,8 +41,10 @@ DJANGO_SETTINGS_MODULE = "project.settings"
|
|
|
39
41
|
[tool.coverage.report]
|
|
40
42
|
show_missing = true
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
python_version = "3.9"
|
|
46
|
+
plugins = ["mypy_django_plugin.main"]
|
|
47
|
+
ignore_missing_imports = true
|
|
48
|
+
|
|
49
|
+
[tool.django-stubs]
|
|
50
|
+
django_settings_module = "tests.project.project.settings"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|