django-ctct 0.0.1.dev5__tar.gz → 0.0.1.dev6__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.dev5 → django_ctct-0.0.1.dev6}/PKG-INFO +3 -6
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/README.md +1 -4
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/admin.py +23 -32
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/apps.py +0 -10
- django_ctct-0.0.1.dev6/django_ctct/management/commands/__init__.py +0 -0
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/management/commands/import_ctct.py +7 -11
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/managers.py +65 -66
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/migrations/0001_initial.py +5 -4
- django_ctct-0.0.1.dev6/django_ctct/migrations/__init__.py +0 -0
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/models.py +31 -22
- django_ctct-0.0.1.dev6/django_ctct/signals.py +41 -0
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/utils.py +3 -1
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/views.py +0 -1
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/pyproject.toml +2 -2
- django_ctct-0.0.1.dev5/django_ctct/signals.py +0 -93
- django_ctct-0.0.1.dev5/django_ctct/vendor.py +0 -108
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/LICENSE +0 -0
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/__init__.py +0 -0
- {django_ctct-0.0.1.dev5/django_ctct/migrations → django_ctct-0.0.1.dev6/django_ctct/management}/__init__.py +0 -0
- {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/urls.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-ctct
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.dev6
|
|
4
4
|
Summary: A Django interface for the Constant Contact API
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
-
Requires-Dist: django (>=
|
|
17
|
+
Requires-Dist: django (>=4.2)
|
|
18
18
|
Requires-Dist: mypy (>=1.19.0,<2.0.0)
|
|
19
19
|
Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0)
|
|
20
20
|
Requires-Dist: ratelimit (>=2.2.1,<3.0.0)
|
|
@@ -123,7 +123,7 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
|
|
|
123
123
|
> ./manage.py migrate
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
4) **Create
|
|
126
|
+
4) **Create Authentication Token:**
|
|
127
127
|
|
|
128
128
|
After the app has been installed and configured, you must generate your first auth token:
|
|
129
129
|
|
|
@@ -173,9 +173,6 @@ To run tests:
|
|
|
173
173
|
|
|
174
174
|
Once version 0.0.1 is released on PyPI, we hope to implement the following new features (in no particular order):
|
|
175
175
|
|
|
176
|
-
* Support for API syncing using signals (`post_save`, `pre_delete`, `m2m_changed`, etc).
|
|
177
|
-
This will be controlled by the `CTCT_SYNC_SIGNALS` setting.
|
|
178
|
-
**Update** This probably won't work as desired since the primary object will be saved before related objects are.
|
|
179
176
|
* Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
|
|
180
177
|
* Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
|
|
181
178
|
|
|
@@ -99,7 +99,7 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
|
|
|
99
99
|
> ./manage.py migrate
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
4) **Create
|
|
102
|
+
4) **Create Authentication Token:**
|
|
103
103
|
|
|
104
104
|
After the app has been installed and configured, you must generate your first auth token:
|
|
105
105
|
|
|
@@ -149,9 +149,6 @@ To run tests:
|
|
|
149
149
|
|
|
150
150
|
Once version 0.0.1 is released on PyPI, we hope to implement the following new features (in no particular order):
|
|
151
151
|
|
|
152
|
-
* Support for API syncing using signals (`post_save`, `pre_delete`, `m2m_changed`, etc).
|
|
153
|
-
This will be controlled by the `CTCT_SYNC_SIGNALS` setting.
|
|
154
|
-
**Update** This probably won't work as desired since the primary object will be saved before related objects are.
|
|
155
152
|
* Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
|
|
156
153
|
* Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
|
|
157
154
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import functools
|
|
2
|
-
from typing import TypeVar, ParamSpec, Generic,
|
|
2
|
+
from typing import TypeVar, ParamSpec, Generic, Callable, Iterable
|
|
3
3
|
from requests.exceptions import HTTPError
|
|
4
4
|
|
|
5
5
|
from django import forms
|
|
6
6
|
from django.conf import settings
|
|
7
7
|
from django.contrib import admin, messages
|
|
8
8
|
from django.contrib.auth import get_user_model
|
|
9
|
-
from django.db.models import signals
|
|
10
9
|
from django.db.models import Model, QuerySet, When, Case, F, FloatField
|
|
11
10
|
from django.db.models.functions import Cast
|
|
12
11
|
from django.forms import ModelForm, BaseFormSet
|
|
@@ -24,7 +23,6 @@ from django_ctct.models import (
|
|
|
24
23
|
EmailCampaign, CampaignActivity, CampaignSummary,
|
|
25
24
|
)
|
|
26
25
|
from django_ctct.signals import remote_save, remote_delete
|
|
27
|
-
from django_ctct.vendor import mute_signals
|
|
28
26
|
|
|
29
27
|
|
|
30
28
|
P = ParamSpec('P')
|
|
@@ -81,7 +79,7 @@ class ViewModelAdmin(admin.ModelAdmin[Model]):
|
|
|
81
79
|
def has_add_permission(
|
|
82
80
|
self,
|
|
83
81
|
request: HttpRequest,
|
|
84
|
-
obj:
|
|
82
|
+
obj: Model | None = None,
|
|
85
83
|
) -> bool:
|
|
86
84
|
"""Prevent creation in the Django admin."""
|
|
87
85
|
return False
|
|
@@ -89,7 +87,7 @@ class ViewModelAdmin(admin.ModelAdmin[Model]):
|
|
|
89
87
|
def has_change_permission(
|
|
90
88
|
self,
|
|
91
89
|
request: HttpRequest,
|
|
92
|
-
obj:
|
|
90
|
+
obj: Model | None = None,
|
|
93
91
|
) -> bool:
|
|
94
92
|
"""Prevent updates in the Django admin."""
|
|
95
93
|
return False
|
|
@@ -97,7 +95,7 @@ class ViewModelAdmin(admin.ModelAdmin[Model]):
|
|
|
97
95
|
def get_readonly_fields(
|
|
98
96
|
self,
|
|
99
97
|
request: HttpRequest,
|
|
100
|
-
obj:
|
|
98
|
+
obj: Model | None = None,
|
|
101
99
|
) -> tuple[str, ...]:
|
|
102
100
|
"""Prevent updates in the Django admin."""
|
|
103
101
|
if obj is not None:
|
|
@@ -113,7 +111,7 @@ class ViewModelAdmin(admin.ModelAdmin[Model]):
|
|
|
113
111
|
def has_delete_permission(
|
|
114
112
|
self,
|
|
115
113
|
request: HttpRequest,
|
|
116
|
-
obj:
|
|
114
|
+
obj: Model | None = None,
|
|
117
115
|
) -> bool:
|
|
118
116
|
"""Allow superusers to delete objects."""
|
|
119
117
|
return request.user.is_superuser
|
|
@@ -124,17 +122,13 @@ class RemoteModelAdmin(
|
|
|
124
122
|
):
|
|
125
123
|
"""Facilitate remote saving and deleting."""
|
|
126
124
|
|
|
127
|
-
|
|
128
|
-
@property
|
|
129
|
-
def remote_sync(self) -> bool:
|
|
130
|
-
sync_admin = getattr(settings, 'CTCT_SYNC_ADMIN', False)
|
|
131
|
-
sync_signals = getattr(settings, 'CTCT_SYNC_SIGNALS', False)
|
|
132
|
-
return sync_admin and not sync_signals
|
|
125
|
+
sync_admin: bool = getattr(settings, 'CTCT_SYNC_ADMIN', False)
|
|
133
126
|
|
|
127
|
+
# ChangeView
|
|
134
128
|
@catch_api_errors
|
|
135
129
|
def delete_model(self, request: HttpRequest, obj: Model) -> None:
|
|
136
130
|
obj.delete()
|
|
137
|
-
if self.
|
|
131
|
+
if self.sync_admin:
|
|
138
132
|
remote_delete(sender=self.model, instance=obj)
|
|
139
133
|
|
|
140
134
|
@catch_api_errors
|
|
@@ -143,10 +137,9 @@ class RemoteModelAdmin(
|
|
|
143
137
|
request: HttpRequest,
|
|
144
138
|
queryset: QuerySet[E],
|
|
145
139
|
) -> None:
|
|
146
|
-
if self.
|
|
140
|
+
if self.sync_admin:
|
|
147
141
|
queryset.model.remote.bulk_delete(queryset)
|
|
148
|
-
|
|
149
|
-
queryset.delete()
|
|
142
|
+
queryset.delete()
|
|
150
143
|
|
|
151
144
|
@catch_api_errors
|
|
152
145
|
def save_related(
|
|
@@ -164,9 +157,8 @@ class RemoteModelAdmin(
|
|
|
164
157
|
for saving objects remotely.
|
|
165
158
|
|
|
166
159
|
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
form.save_m2m()
|
|
160
|
+
# ManyToMany information is sent to CTCT in PUT call
|
|
161
|
+
form.save_m2m()
|
|
170
162
|
for formset in formsets:
|
|
171
163
|
self.save_formset(request, form, formset, change=change)
|
|
172
164
|
self.save_remotely(request, form, formsets, change)
|
|
@@ -179,7 +171,7 @@ class RemoteModelAdmin(
|
|
|
179
171
|
formsets: list[BaseFormSet[ModelForm[Model]]],
|
|
180
172
|
change: bool,
|
|
181
173
|
) -> None:
|
|
182
|
-
if self.
|
|
174
|
+
if self.sync_admin:
|
|
183
175
|
# Remote save the primary object after related objects have been saved
|
|
184
176
|
remote_save(
|
|
185
177
|
sender=self.model,
|
|
@@ -285,7 +277,7 @@ class ContactNoteInline(admin.TabularInline[ContactNote, Contact]):
|
|
|
285
277
|
def has_change_permission(
|
|
286
278
|
self,
|
|
287
279
|
request: HttpRequest,
|
|
288
|
-
obj:
|
|
280
|
+
obj: Contact | None = None, # type: ignore[override]
|
|
289
281
|
) -> bool:
|
|
290
282
|
return False
|
|
291
283
|
|
|
@@ -370,7 +362,7 @@ class ContactAdmin(RemoteModelAdmin[Contact]):
|
|
|
370
362
|
def get_readonly_fields(
|
|
371
363
|
self,
|
|
372
364
|
request: HttpRequest,
|
|
373
|
-
obj:
|
|
365
|
+
obj: Contact | None = None,
|
|
374
366
|
) -> list[str]:
|
|
375
367
|
readonly_fields = list(Contact.API_READONLY_FIELDS)
|
|
376
368
|
if obj and obj.opt_out_source and not request.user.is_superuser:
|
|
@@ -401,9 +393,8 @@ class ContactAdmin(RemoteModelAdmin[Contact]):
|
|
|
401
393
|
instance.author = request.user
|
|
402
394
|
instance.save()
|
|
403
395
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
formset.save_m2m()
|
|
396
|
+
# ManyToMany information is sent to CTCT in PUT call
|
|
397
|
+
formset.save_m2m()
|
|
407
398
|
|
|
408
399
|
|
|
409
400
|
class ContactNoteAuthorFilter(admin.SimpleListFilter):
|
|
@@ -416,7 +407,7 @@ class ContactNoteAuthorFilter(admin.SimpleListFilter):
|
|
|
416
407
|
self,
|
|
417
408
|
request: HttpRequest,
|
|
418
409
|
model_admin: admin.ModelAdmin[Model],
|
|
419
|
-
) ->
|
|
410
|
+
) -> Iterable[tuple[str, str]] | None:
|
|
420
411
|
authors = get_user_model().objects.exclude(notes__isnull=True)
|
|
421
412
|
return [(str(obj.id), str(obj)) for obj in authors]
|
|
422
413
|
|
|
@@ -473,7 +464,7 @@ class ContactNoteAdmin(RemoteSyncMixin, ViewModelAdmin):
|
|
|
473
464
|
def has_delete_permission(
|
|
474
465
|
self,
|
|
475
466
|
request: HttpRequest,
|
|
476
|
-
obj:
|
|
467
|
+
obj: Model | None = None,
|
|
477
468
|
) -> bool:
|
|
478
469
|
"""Allow superusers to delete Notes."""
|
|
479
470
|
return request.user.is_superuser
|
|
@@ -516,7 +507,7 @@ class CampaignActivityInline(
|
|
|
516
507
|
def get_readonly_fields(
|
|
517
508
|
self,
|
|
518
509
|
request: HttpRequest,
|
|
519
|
-
obj:
|
|
510
|
+
obj: CampaignActivity | None = None,
|
|
520
511
|
) -> list[str]:
|
|
521
512
|
readonly_fields = list(CampaignActivity.API_READONLY_FIELDS)
|
|
522
513
|
if obj and obj.current_status == 'DONE':
|
|
@@ -559,7 +550,7 @@ class EmailCampaignAdmin(RemoteModelAdmin[EmailCampaign]):
|
|
|
559
550
|
def get_readonly_fields(
|
|
560
551
|
self,
|
|
561
552
|
request: HttpRequest,
|
|
562
|
-
obj:
|
|
553
|
+
obj: EmailCampaign | None = None,
|
|
563
554
|
) -> list[str]:
|
|
564
555
|
readonly_fields = list(EmailCampaign.API_READONLY_FIELDS)
|
|
565
556
|
if obj and obj.current_status == 'DONE':
|
|
@@ -574,16 +565,16 @@ class EmailCampaignAdmin(RemoteModelAdmin[EmailCampaign]):
|
|
|
574
565
|
formsets: list[BaseFormSet[ModelForm[Model]]],
|
|
575
566
|
change: bool,
|
|
576
567
|
) -> None:
|
|
577
|
-
if self.
|
|
568
|
+
if self.sync_admin:
|
|
578
569
|
|
|
579
570
|
campaign = form.instance
|
|
580
571
|
activity = formsets[0][0].instance
|
|
581
572
|
|
|
582
573
|
# Handle remote saving the EmailCampaign
|
|
574
|
+
# NOTE: The only EmailCampaign field that can be updated is 'name'
|
|
583
575
|
campaign_created = not change
|
|
584
576
|
campaign_updated = change and ('name' in form.changed_data)
|
|
585
577
|
if campaign_created or campaign_updated:
|
|
586
|
-
# The only EmailCampaign field that can be updated is 'name'
|
|
587
578
|
remote_save(
|
|
588
579
|
sender=self.model,
|
|
589
580
|
instance=campaign,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
from django.apps import AppConfig
|
|
2
|
-
from django.db.models.signals import post_save, m2m_changed, pre_delete
|
|
3
2
|
from django.conf import settings
|
|
4
3
|
from django.core.exceptions import ImproperlyConfigured
|
|
5
4
|
from django.utils.translation import gettext_lazy as _
|
|
@@ -24,12 +23,3 @@ class CTCTConfig(AppConfig):
|
|
|
24
23
|
f"[django-ctct] {value} must be defined in settings.py."
|
|
25
24
|
)
|
|
26
25
|
raise ImproperlyConfigured(message)
|
|
27
|
-
|
|
28
|
-
# Hook up the signals
|
|
29
|
-
from django_ctct.signals import (
|
|
30
|
-
remote_save, remote_delete, remote_update_m2m
|
|
31
|
-
)
|
|
32
|
-
if getattr(settings, 'CTCT_SYNC_SIGNALS', False):
|
|
33
|
-
post_save.connect(remote_save)
|
|
34
|
-
pre_delete.connect(remote_delete)
|
|
35
|
-
m2m_changed.connect(remote_update_m2m)
|
|
File without changes
|
{django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/management/commands/import_ctct.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from argparse import ArgumentParser
|
|
2
2
|
from collections import defaultdict
|
|
3
|
-
from typing import Type, TypeVar,
|
|
3
|
+
from typing import Type, TypeVar, Any, Collection, Literal, cast
|
|
4
4
|
from uuid import UUID
|
|
5
5
|
|
|
6
6
|
from tqdm import tqdm
|
|
@@ -70,9 +70,9 @@ class Command(BaseCommand):
|
|
|
70
70
|
model: Type[M],
|
|
71
71
|
objs: list[M],
|
|
72
72
|
update_conflicts: bool = True,
|
|
73
|
-
unique_fields:
|
|
74
|
-
update_fields:
|
|
75
|
-
silent:
|
|
73
|
+
unique_fields: Collection[str] | None = ['api_id'],
|
|
74
|
+
update_fields: Collection[str] | None = None,
|
|
75
|
+
silent: bool | None = None,
|
|
76
76
|
) -> list[Model]:
|
|
77
77
|
"""Perform upsert using `bulk_create()`."""
|
|
78
78
|
|
|
@@ -101,7 +101,7 @@ class Command(BaseCommand):
|
|
|
101
101
|
]
|
|
102
102
|
|
|
103
103
|
# Remove possible duplicates (CTCT API can't be trusted)
|
|
104
|
-
id_field:
|
|
104
|
+
id_field: str | None = None
|
|
105
105
|
if model is CampaignSummary:
|
|
106
106
|
id_field = 'campaign_id'
|
|
107
107
|
elif issubclass(model, CTCTModel):
|
|
@@ -131,7 +131,7 @@ class Command(BaseCommand):
|
|
|
131
131
|
setattr(o, 'pk', id_to_pk[str(o.api_id)])
|
|
132
132
|
|
|
133
133
|
# Inform the user
|
|
134
|
-
if not silent:
|
|
134
|
+
if not silent: # pragma: no cover
|
|
135
135
|
message = self.style.SUCCESS(
|
|
136
136
|
f'{verb} {len(objs):,} {model.__name__} instances.'
|
|
137
137
|
)
|
|
@@ -173,7 +173,6 @@ class Command(BaseCommand):
|
|
|
173
173
|
# CampaignActivities do not have a bulk API endpoint
|
|
174
174
|
return self.import_campaign_activities()
|
|
175
175
|
|
|
176
|
-
model.remote.connect()
|
|
177
176
|
try:
|
|
178
177
|
# Split apart so we can save objs to db and get pks
|
|
179
178
|
list_of_tuples = model.remote.all()
|
|
@@ -207,7 +206,6 @@ class Command(BaseCommand):
|
|
|
207
206
|
"""CampaignActivities must be imported one at a time."""
|
|
208
207
|
|
|
209
208
|
# First, make sure all CampaignActivity API id's are stored locally
|
|
210
|
-
EmailCampaign.remote.connect()
|
|
211
209
|
for campaign in EmailCampaign.objects.exclude(api_id__isnull=True):
|
|
212
210
|
# Fetch from API
|
|
213
211
|
assert isinstance(campaign.api_id, UUID)
|
|
@@ -226,8 +224,6 @@ class Command(BaseCommand):
|
|
|
226
224
|
obj.save()
|
|
227
225
|
|
|
228
226
|
# Then, fetch CampaignActivity details
|
|
229
|
-
CampaignActivity.remote.connect()
|
|
230
|
-
|
|
231
227
|
activities = CampaignActivity.objects.filter(
|
|
232
228
|
role='primary_email',
|
|
233
229
|
api_id__isnull=False,
|
|
@@ -309,6 +305,6 @@ class Command(BaseCommand):
|
|
|
309
305
|
|
|
310
306
|
if self.noinput or (input(question).lower()[0] == 'y'):
|
|
311
307
|
self.import_model(model)
|
|
312
|
-
else:
|
|
308
|
+
else: # pragma: no cover
|
|
313
309
|
message = _(f'Skipping {model.__name__}')
|
|
314
310
|
self.stdout.write(self.style.NOTICE(message))
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import datetime as dt
|
|
4
4
|
from typing import (
|
|
5
5
|
TYPE_CHECKING, TypeVar, ClassVar,
|
|
6
|
-
Iterable, Literal,
|
|
6
|
+
Iterable, Literal, NoReturn, Union, cast,
|
|
7
7
|
)
|
|
8
8
|
from urllib.parse import urlencode
|
|
9
9
|
from uuid import UUID
|
|
@@ -17,7 +17,6 @@ from requests.models import Response
|
|
|
17
17
|
from django.conf import settings
|
|
18
18
|
from django.core.exceptions import ImproperlyConfigured
|
|
19
19
|
from django.db import models
|
|
20
|
-
from django.db.models import signals
|
|
21
20
|
from django.db.models.manager import Manager
|
|
22
21
|
from django.db.models.query import QuerySet
|
|
23
22
|
from django.http import HttpRequest, Http404
|
|
@@ -26,10 +25,9 @@ from django.urls import reverse
|
|
|
26
25
|
from django.utils.translation import gettext_lazy as _
|
|
27
26
|
|
|
28
27
|
from django_ctct.utils import get_related_fields
|
|
29
|
-
from django_ctct.vendor import mute_signals
|
|
30
28
|
|
|
31
29
|
|
|
32
|
-
if TYPE_CHECKING:
|
|
30
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
33
31
|
from django_ctct.models import (
|
|
34
32
|
JsonDict, RelatedObjects,
|
|
35
33
|
EndpointMixin, SerialModel, CTCTModel, CTCTEndpointModel,
|
|
@@ -49,14 +47,17 @@ class ConnectionManagerMixin(Manager[T]):
|
|
|
49
47
|
API_LIMIT_CALLS: int = 4 # four calls
|
|
50
48
|
API_LIMIT_PERIOD: int = 1 # per second
|
|
51
49
|
|
|
50
|
+
session: requests.Session
|
|
51
|
+
|
|
52
52
|
def connect(self) -> None:
|
|
53
|
-
|
|
53
|
+
if not hasattr(self, 'session'):
|
|
54
|
+
from django_ctct.models import Token
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
token = Token.remote.get()
|
|
57
|
+
self.session = requests.Session()
|
|
58
|
+
self.session.headers.update({
|
|
59
|
+
'Authorization': f"{token.token_type} {token.access_token}"
|
|
60
|
+
})
|
|
60
61
|
|
|
61
62
|
@sleep_and_retry
|
|
62
63
|
@limits(calls=API_LIMIT_CALLS, period=API_LIMIT_PERIOD)
|
|
@@ -64,11 +65,15 @@ class ConnectionManagerMixin(Manager[T]):
|
|
|
64
65
|
"""Honor the API's rate limit."""
|
|
65
66
|
pass
|
|
66
67
|
|
|
68
|
+
def _pre_api_call(self) -> None:
|
|
69
|
+
self.connect()
|
|
70
|
+
self.check_api_limit()
|
|
71
|
+
|
|
67
72
|
def get_url(
|
|
68
73
|
self,
|
|
69
|
-
api_id:
|
|
70
|
-
endpoint:
|
|
71
|
-
endpoint_suffix:
|
|
74
|
+
api_id: str | UUID | None = None,
|
|
75
|
+
endpoint: str | None = None,
|
|
76
|
+
endpoint_suffix: str | None = None,
|
|
72
77
|
) -> str:
|
|
73
78
|
endpoint = endpoint or self.model.API_ENDPOINT
|
|
74
79
|
if not endpoint.startswith(self.model.API_VERSION):
|
|
@@ -125,8 +130,9 @@ class TokenRemoteManager(ConnectionManagerMixin['Token'], Manager['Token']):
|
|
|
125
130
|
return url
|
|
126
131
|
|
|
127
132
|
def connect(self) -> None:
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
if not hasattr(self, 'session'):
|
|
134
|
+
self.session = requests.Session()
|
|
135
|
+
self.session.auth = (settings.CTCT_PUBLIC_KEY, settings.CTCT_SECRET_KEY)
|
|
130
136
|
|
|
131
137
|
def create(self, auth_code: str) -> 'Token': # type: ignore[override]
|
|
132
138
|
"""Creates the initial Token using an `auth_code` from CTCT.
|
|
@@ -138,6 +144,7 @@ class TokenRemoteManager(ConnectionManagerMixin['Token'], Manager['Token']):
|
|
|
138
144
|
|
|
139
145
|
"""
|
|
140
146
|
|
|
147
|
+
self.connect()
|
|
141
148
|
response = self.session.post(
|
|
142
149
|
url=self.get_url(),
|
|
143
150
|
data={
|
|
@@ -164,7 +171,6 @@ class TokenRemoteManager(ConnectionManagerMixin['Token'], Manager['Token']):
|
|
|
164
171
|
try:
|
|
165
172
|
token.decode()
|
|
166
173
|
except ExpiredSignatureError:
|
|
167
|
-
self.connect()
|
|
168
174
|
token = self.update(token)
|
|
169
175
|
|
|
170
176
|
return token
|
|
@@ -172,6 +178,7 @@ class TokenRemoteManager(ConnectionManagerMixin['Token'], Manager['Token']):
|
|
|
172
178
|
def update(self, token: 'Token') -> 'Token': # type: ignore[override]
|
|
173
179
|
"""Obtain a new Token from CTCT using the refresh code."""
|
|
174
180
|
|
|
181
|
+
self.connect()
|
|
175
182
|
response = self.session.post(
|
|
176
183
|
url=self.get_url(),
|
|
177
184
|
data={
|
|
@@ -264,7 +271,7 @@ class Serializer(Manager[S]):
|
|
|
264
271
|
def deserialize_related_obj_fields(
|
|
265
272
|
self,
|
|
266
273
|
data: JsonDict,
|
|
267
|
-
parent_pk:
|
|
274
|
+
parent_pk: int | None = None
|
|
268
275
|
) -> JsonDict:
|
|
269
276
|
"""Deserialize ForeignKeys and OneToOneFields.
|
|
270
277
|
|
|
@@ -292,7 +299,7 @@ class Serializer(Manager[S]):
|
|
|
292
299
|
def deserialize_related_objs_fields(
|
|
293
300
|
self,
|
|
294
301
|
data: JsonDict,
|
|
295
|
-
parent_pk:
|
|
302
|
+
parent_pk: int | None = None,
|
|
296
303
|
) -> tuple[JsonDict, list[RelatedObjects]]:
|
|
297
304
|
"""Deserialize ManyToManyFields and ReverseForeignKeys."""
|
|
298
305
|
|
|
@@ -346,7 +353,7 @@ class Serializer(Manager[S]):
|
|
|
346
353
|
def deserialize(
|
|
347
354
|
self,
|
|
348
355
|
data: JsonDict,
|
|
349
|
-
pk:
|
|
356
|
+
pk: int | None = None,
|
|
350
357
|
) -> tuple[S, list[RelatedObjects]]:
|
|
351
358
|
"""Convert from API response body to Django object."""
|
|
352
359
|
|
|
@@ -411,10 +418,10 @@ class RemoteManager(
|
|
|
411
418
|
|
|
412
419
|
"""
|
|
413
420
|
|
|
414
|
-
if not
|
|
421
|
+
if not obj.pk:
|
|
415
422
|
raise ValueError('Must create object locally first.')
|
|
416
423
|
|
|
417
|
-
self.
|
|
424
|
+
self._pre_api_call()
|
|
418
425
|
response = self.session.post(
|
|
419
426
|
url=self.get_url(),
|
|
420
427
|
json=self.serialize(obj),
|
|
@@ -423,12 +430,10 @@ class RemoteManager(
|
|
|
423
430
|
|
|
424
431
|
# NOTE: We don't need to do anything with `related_objs` since they were
|
|
425
432
|
# set locally before the API request.
|
|
426
|
-
obj, _ = self.deserialize(data, pk=pk)
|
|
433
|
+
obj, _ = self.deserialize(data, pk=obj.pk)
|
|
427
434
|
|
|
428
|
-
# TODO: GH #11?
|
|
429
435
|
# Overwrite local obj with CTCT's response
|
|
430
|
-
|
|
431
|
-
obj.save()
|
|
436
|
+
obj.save()
|
|
432
437
|
|
|
433
438
|
return obj
|
|
434
439
|
|
|
@@ -445,7 +450,7 @@ class RemoteManager(
|
|
|
445
450
|
|
|
446
451
|
"""
|
|
447
452
|
|
|
448
|
-
self.
|
|
453
|
+
self._pre_api_call()
|
|
449
454
|
|
|
450
455
|
response = self.session.get(
|
|
451
456
|
url=self.get_url(api_id),
|
|
@@ -462,7 +467,7 @@ class RemoteManager(
|
|
|
462
467
|
|
|
463
468
|
def all( # type: ignore[override]
|
|
464
469
|
self,
|
|
465
|
-
endpoint:
|
|
470
|
+
endpoint: str | None = None,
|
|
466
471
|
) -> list[tuple[E, list[RelatedObjects]]]:
|
|
467
472
|
"""Gets all existing objects from the remote server.
|
|
468
473
|
|
|
@@ -476,7 +481,7 @@ class RemoteManager(
|
|
|
476
481
|
|
|
477
482
|
paginated = True
|
|
478
483
|
while paginated:
|
|
479
|
-
self.
|
|
484
|
+
self._pre_api_call()
|
|
480
485
|
|
|
481
486
|
response = self.session.get(
|
|
482
487
|
url=self.get_url(endpoint=endpoint),
|
|
@@ -507,12 +512,12 @@ class RemoteManager(
|
|
|
507
512
|
|
|
508
513
|
"""
|
|
509
514
|
|
|
510
|
-
if
|
|
515
|
+
if obj.pk is None:
|
|
511
516
|
raise ValueError('Must create object locally first.')
|
|
512
517
|
elif obj.api_id is None:
|
|
513
518
|
raise ValueError('Must create object remotely first.')
|
|
514
519
|
|
|
515
|
-
self.
|
|
520
|
+
self._pre_api_call()
|
|
516
521
|
response = self.session.put(
|
|
517
522
|
url=self.get_url(obj.api_id),
|
|
518
523
|
json=self.serialize(obj),
|
|
@@ -521,12 +526,10 @@ class RemoteManager(
|
|
|
521
526
|
|
|
522
527
|
# NOTE: We don't need to do anything with `related_objs` since they were
|
|
523
528
|
# set locally before the API request.
|
|
524
|
-
obj, _ = self.deserialize(data, pk=pk)
|
|
529
|
+
obj, _ = self.deserialize(data, pk=obj.pk)
|
|
525
530
|
|
|
526
|
-
# TODO: GH #11?
|
|
527
531
|
# Overwrite local obj with CTCT's response
|
|
528
|
-
|
|
529
|
-
obj.save()
|
|
532
|
+
obj.save()
|
|
530
533
|
|
|
531
534
|
return obj
|
|
532
535
|
|
|
@@ -534,7 +537,7 @@ class RemoteManager(
|
|
|
534
537
|
def delete(
|
|
535
538
|
self,
|
|
536
539
|
obj: E,
|
|
537
|
-
endpoint_suffix:
|
|
540
|
+
endpoint_suffix: str | None = None,
|
|
538
541
|
) -> None:
|
|
539
542
|
"""Deletes existing Django object(s) on the remote server.
|
|
540
543
|
|
|
@@ -549,7 +552,7 @@ class RemoteManager(
|
|
|
549
552
|
"""
|
|
550
553
|
|
|
551
554
|
url = self.get_url(obj.api_id, endpoint_suffix=endpoint_suffix)
|
|
552
|
-
self.
|
|
555
|
+
self._pre_api_call()
|
|
553
556
|
response = self.session.delete(url)
|
|
554
557
|
|
|
555
558
|
if response.status_code != 404:
|
|
@@ -579,7 +582,7 @@ class RemoteManager(
|
|
|
579
582
|
|
|
580
583
|
# Remote delete in batches
|
|
581
584
|
for i in range(0, len(api_ids), self.model.API_ENDPOINT_BULK_LIMIT):
|
|
582
|
-
self.
|
|
585
|
+
self._pre_api_call()
|
|
583
586
|
response = self.session.post(
|
|
584
587
|
url=self.get_url(endpoint=self.model.API_ENDPOINT_BULK_DELETE),
|
|
585
588
|
json={api_id_label: api_ids[i:i + self.model.API_ENDPOINT_BULK_LIMIT]},
|
|
@@ -593,9 +596,9 @@ class ContactListRemoteManager(RemoteManager['ContactList']):
|
|
|
593
596
|
# @task(queue_name='ctct')
|
|
594
597
|
def add_list_memberships(
|
|
595
598
|
self,
|
|
596
|
-
contact_list:
|
|
597
|
-
contact_lists:
|
|
598
|
-
contacts:
|
|
599
|
+
contact_list: ContactList | None = None,
|
|
600
|
+
contact_lists: QuerySet[ContactList] | None = None,
|
|
601
|
+
contacts: QuerySet[Contact] | None = None,
|
|
599
602
|
) -> None:
|
|
600
603
|
"""Adds multiple Contacts to (multiple) ContactLists."""
|
|
601
604
|
|
|
@@ -627,7 +630,7 @@ class ContactListRemoteManager(RemoteManager['ContactList']):
|
|
|
627
630
|
raise ValueError(message)
|
|
628
631
|
|
|
629
632
|
for i in range(0, len(contact_ids), step_size):
|
|
630
|
-
self.
|
|
633
|
+
self._pre_api_call()
|
|
631
634
|
response = self.session.post(
|
|
632
635
|
url=self.get_url(endpoint='/activities/add_list_memberships'),
|
|
633
636
|
json={
|
|
@@ -675,14 +678,14 @@ class ContactRemoteManager(RemoteManager['Contact']):
|
|
|
675
678
|
|
|
676
679
|
"""
|
|
677
680
|
|
|
678
|
-
if
|
|
681
|
+
if obj.pk is None:
|
|
679
682
|
raise ValueError('Must create object locally first.')
|
|
680
683
|
|
|
681
684
|
# This endpoint expects a slightly different serialization
|
|
682
685
|
data = self.serialize(obj)
|
|
683
686
|
data['email_address'] = data.pop('email_address')['address']
|
|
684
687
|
|
|
685
|
-
self.
|
|
688
|
+
self._pre_api_call()
|
|
686
689
|
response = self.session.post(
|
|
687
690
|
url=self.get_url(endpoint_suffix='/sign_up_form'),
|
|
688
691
|
json=data,
|
|
@@ -695,9 +698,8 @@ class ContactRemoteManager(RemoteManager['Contact']):
|
|
|
695
698
|
raise ValueError(f'Unexpected response data: {data}.')
|
|
696
699
|
|
|
697
700
|
# Save the API id
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
obj.save(update_fields=['api_id'])
|
|
701
|
+
obj.api_id = api_id
|
|
702
|
+
obj.save(update_fields=['api_id'])
|
|
701
703
|
|
|
702
704
|
return obj
|
|
703
705
|
|
|
@@ -732,7 +734,7 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
|
|
|
732
734
|
from django_ctct.models import CampaignActivity
|
|
733
735
|
|
|
734
736
|
# Validate
|
|
735
|
-
if
|
|
737
|
+
if obj.pk is None:
|
|
736
738
|
raise ValueError('Must create object locally first.')
|
|
737
739
|
try:
|
|
738
740
|
activity = obj.campaign_activities.get(role='primary_email')
|
|
@@ -746,7 +748,7 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
|
|
|
746
748
|
activity = CampaignActivity()
|
|
747
749
|
|
|
748
750
|
# Create EmailCampaign and CampaignActivity remotely
|
|
749
|
-
self.
|
|
751
|
+
self._pre_api_call()
|
|
750
752
|
response = self.session.post(
|
|
751
753
|
url=self.get_url(),
|
|
752
754
|
json={
|
|
@@ -758,7 +760,7 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
|
|
|
758
760
|
)
|
|
759
761
|
data = self.raise_or_json(response)
|
|
760
762
|
|
|
761
|
-
obj, list_of_related_objs = self.deserialize(data, pk=pk)
|
|
763
|
+
obj, list_of_related_objs = self.deserialize(data, pk=obj.pk)
|
|
762
764
|
|
|
763
765
|
# Set CTCT's assigned api_id on our local CampaignActivity instance
|
|
764
766
|
for (model, related_objs) in list_of_related_objs:
|
|
@@ -771,13 +773,12 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
|
|
|
771
773
|
break
|
|
772
774
|
|
|
773
775
|
# Overwrite local obj with CTCT's response
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
activity.save(update_fields=['api_id'])
|
|
776
|
+
obj.save()
|
|
777
|
+
if activity.pk is None:
|
|
778
|
+
activity.campaign = obj
|
|
779
|
+
activity.save()
|
|
780
|
+
else:
|
|
781
|
+
activity.save(update_fields=['api_id'])
|
|
781
782
|
|
|
782
783
|
# Send preview and/or schedule the campaign
|
|
783
784
|
if obj.send_preview or (obj.scheduled_datetime is not None):
|
|
@@ -797,12 +798,12 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
|
|
|
797
798
|
preview, the `primary_email` CampaignActivity must be updated remotely.
|
|
798
799
|
|
|
799
800
|
"""
|
|
800
|
-
if
|
|
801
|
+
if obj.pk is None:
|
|
801
802
|
raise ValueError('Must create object locally first.')
|
|
802
803
|
elif obj.api_id is None:
|
|
803
804
|
raise ValueError('Must create object remotely first.')
|
|
804
805
|
|
|
805
|
-
self.
|
|
806
|
+
self._pre_api_call()
|
|
806
807
|
response = self.session.patch(
|
|
807
808
|
url=self.get_url(obj.api_id),
|
|
808
809
|
json=self.serialize(obj),
|
|
@@ -811,12 +812,10 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
|
|
|
811
812
|
|
|
812
813
|
# NOTE: We don't need to do anything with `related_objs` since they were
|
|
813
814
|
# set locally before the API request.
|
|
814
|
-
obj, _ = self.deserialize(data, pk=pk)
|
|
815
|
+
obj, _ = self.deserialize(data, pk=obj.pk)
|
|
815
816
|
|
|
816
|
-
# TODO: GH #11?
|
|
817
817
|
# Overwrite local obj with CTCT's response
|
|
818
|
-
|
|
819
|
-
obj.save()
|
|
818
|
+
obj.save()
|
|
820
819
|
|
|
821
820
|
return obj
|
|
822
821
|
|
|
@@ -869,8 +868,8 @@ class CampaignActivityRemoteManager(RemoteManager['CampaignActivity']):
|
|
|
869
868
|
def send_preview(
|
|
870
869
|
self,
|
|
871
870
|
obj: 'CampaignActivity',
|
|
872
|
-
recipients:
|
|
873
|
-
message:
|
|
871
|
+
recipients: list[str] | None = None,
|
|
872
|
+
message: str | None = None,
|
|
874
873
|
) -> None:
|
|
875
874
|
"""Sends a preview of the EmailCampaign."""
|
|
876
875
|
|
|
@@ -881,7 +880,7 @@ class CampaignActivityRemoteManager(RemoteManager['CampaignActivity']):
|
|
|
881
880
|
if message is None:
|
|
882
881
|
message = getattr(settings, 'CTCT_PREVIEW_MESSAGE', '')
|
|
883
882
|
|
|
884
|
-
self.
|
|
883
|
+
self._pre_api_call()
|
|
885
884
|
response = self.session.post(
|
|
886
885
|
url=self.get_url(obj.api_id, endpoint_suffix='/tests'),
|
|
887
886
|
json={
|
|
@@ -922,7 +921,7 @@ class CampaignActivityRemoteManager(RemoteManager['CampaignActivity']):
|
|
|
922
921
|
raise ValueError(message)
|
|
923
922
|
|
|
924
923
|
# Schedule the CampaignActivity
|
|
925
|
-
self.
|
|
924
|
+
self._pre_api_call()
|
|
926
925
|
response = self.session.post(
|
|
927
926
|
url=self.get_url(obj.api_id, endpoint_suffix='/schedules'),
|
|
928
927
|
json={'scheduled_date': obj.campaign.scheduled_datetime.isoformat()},
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
# Generated by Django 4.2.20 on 2025-12-
|
|
1
|
+
# Generated by Django 4.2.20 on 2025-12-05 13:58
|
|
2
2
|
|
|
3
3
|
from django.conf import settings
|
|
4
4
|
from django.db import migrations, models
|
|
5
5
|
import django.db.models.deletion
|
|
6
6
|
import django.utils.timezone
|
|
7
|
+
import django_ctct.models
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Migration(migrations.Migration):
|
|
@@ -205,9 +206,9 @@ class Migration(migrations.Migration):
|
|
|
205
206
|
fields=[
|
|
206
207
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
207
208
|
('api_id', models.UUIDField(default=None, null=True, unique=True, verbose_name='API ID')),
|
|
208
|
-
('from_name', models.CharField(default=
|
|
209
|
-
('from_email', models.EmailField(default=
|
|
210
|
-
('reply_to_email', models.EmailField(default=
|
|
209
|
+
('from_name', models.CharField(default=django_ctct.models.campaign_activity__from_name__default, max_length=100, verbose_name='From Name')),
|
|
210
|
+
('from_email', models.EmailField(default=django_ctct.models.campaign_activity__from_email__default, max_length=80, verbose_name='From Email')),
|
|
211
|
+
('reply_to_email', models.EmailField(default=django_ctct.models.campaign_activity__reply_to_email__default, max_length=80, verbose_name='Reply-to Email')),
|
|
211
212
|
('subject', models.CharField(help_text='The text to display in the subject line that describes the email campaign activity', max_length=200, verbose_name='Subject')),
|
|
212
213
|
('preheader', models.CharField(help_text='Contacts will view your preheader as a short summary that follows the subject line in their email client', max_length=250, verbose_name='Preheader')),
|
|
213
214
|
('html_content', models.CharField(help_text='The HTML content for the email campaign activity', max_length=150000, verbose_name='HTML Content')),
|
|
File without changes
|
|
@@ -2,7 +2,7 @@ import datetime as dt
|
|
|
2
2
|
import re
|
|
3
3
|
from typing import (
|
|
4
4
|
Type, TypeAlias, ClassVar, TypeGuard,
|
|
5
|
-
|
|
5
|
+
Any, Literal,
|
|
6
6
|
)
|
|
7
7
|
from typing_extensions import Self
|
|
8
8
|
|
|
@@ -73,8 +73,8 @@ class EndpointMixin(Model):
|
|
|
73
73
|
API_VERSION: str = '/v3'
|
|
74
74
|
API_ENDPOINT: str
|
|
75
75
|
API_GET_QUERIES: dict[str, str] = {}
|
|
76
|
-
API_ENDPOINT_BULK_DELETE:
|
|
77
|
-
API_ENDPOINT_BULK_LIMIT:
|
|
76
|
+
API_ENDPOINT_BULK_DELETE: str | None = None
|
|
77
|
+
API_ENDPOINT_BULK_LIMIT: int | None = None
|
|
78
78
|
|
|
79
79
|
class Meta:
|
|
80
80
|
abstract = True
|
|
@@ -210,12 +210,12 @@ class CTCTModel(SerialModel):
|
|
|
210
210
|
cls,
|
|
211
211
|
field_name: str,
|
|
212
212
|
data: JsonDict,
|
|
213
|
-
default:
|
|
214
|
-
) ->
|
|
213
|
+
default: str | None = None,
|
|
214
|
+
) -> str | None:
|
|
215
215
|
if default is None:
|
|
216
216
|
field = cls._meta.get_field(field_name)
|
|
217
217
|
assert hasattr(field, 'default')
|
|
218
|
-
if field.default is NOT_PROVIDED:
|
|
218
|
+
if field.default is NOT_PROVIDED: # pragma: no cover
|
|
219
219
|
message = _(
|
|
220
220
|
f"Must provide a default value for {cls.__name__}.{field_name}."
|
|
221
221
|
)
|
|
@@ -350,7 +350,7 @@ class ContactCustomField(SerialModel):
|
|
|
350
350
|
def __str__(self) -> str:
|
|
351
351
|
try:
|
|
352
352
|
s = f'[{self.custom_field.label}] {self.value}'
|
|
353
|
-
except CustomField.DoesNotExist:
|
|
353
|
+
except CustomField.DoesNotExist: # pragma: no cover
|
|
354
354
|
s = super().__str__()
|
|
355
355
|
return s
|
|
356
356
|
|
|
@@ -617,7 +617,7 @@ class Contact(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
617
617
|
return s
|
|
618
618
|
|
|
619
619
|
@classmethod
|
|
620
|
-
def clean_remote_opt_out_date(cls, data: JsonDict) ->
|
|
620
|
+
def clean_remote_opt_out_date(cls, data: JsonDict) -> dt.datetime | None:
|
|
621
621
|
assert isinstance(data['email_address'], dict)
|
|
622
622
|
if opt_out_date := data['email_address'].get('opt_out_date', None):
|
|
623
623
|
assert isinstance(opt_out_date, str)
|
|
@@ -764,10 +764,7 @@ class ContactPhoneNumber(CreatedAtMixin, UpdatedAtMixin, CTCTModel):
|
|
|
764
764
|
numbers = r'\d+'
|
|
765
765
|
s = data.get('phone_number', '')
|
|
766
766
|
assert isinstance(s, str)
|
|
767
|
-
|
|
768
|
-
pass
|
|
769
|
-
else:
|
|
770
|
-
s = cls.MISSING_NUMBER
|
|
767
|
+
s = ''.join(re.findall(numbers, s)) or cls.MISSING_NUMBER
|
|
771
768
|
return s
|
|
772
769
|
|
|
773
770
|
|
|
@@ -957,7 +954,7 @@ class EmailCampaign(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
957
954
|
return self.name
|
|
958
955
|
|
|
959
956
|
@classmethod
|
|
960
|
-
def clean_remote_scheduled_datetime(cls, data: JsonDict) ->
|
|
957
|
+
def clean_remote_scheduled_datetime(cls, data: JsonDict) -> dt.datetime | None: # noqa: E501
|
|
961
958
|
if last_sent_date := data.get('last_sent_date', None):
|
|
962
959
|
# Not sure why this ts_format is different
|
|
963
960
|
assert isinstance(last_sent_date, str)
|
|
@@ -967,6 +964,18 @@ class EmailCampaign(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
967
964
|
return last_sent_date
|
|
968
965
|
|
|
969
966
|
|
|
967
|
+
def campaign_activity__from_name__default() -> str:
|
|
968
|
+
return settings.CTCT_FROM_NAME
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def campaign_activity__from_email__default() -> str:
|
|
972
|
+
return settings.CTCT_FROM_EMAIL
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def campaign_activity__reply_to_email__default() -> str:
|
|
976
|
+
return getattr(settings, 'CTCT_REPLY_TO_EMAIL', settings.CTCT_FROM_EMAIL)
|
|
977
|
+
|
|
978
|
+
|
|
970
979
|
class CampaignActivity(CTCTEndpointModel):
|
|
971
980
|
"""Django implementation of a CTCT CampaignActivity.
|
|
972
981
|
|
|
@@ -1045,17 +1054,17 @@ class CampaignActivity(CTCTEndpointModel):
|
|
|
1045
1054
|
# API editable fields
|
|
1046
1055
|
from_name = models.CharField(
|
|
1047
1056
|
max_length=API_MAX_LENGTH['from_name'],
|
|
1048
|
-
default=
|
|
1057
|
+
default=campaign_activity__from_name__default,
|
|
1049
1058
|
verbose_name=_('From Name'),
|
|
1050
1059
|
)
|
|
1051
1060
|
from_email = models.EmailField(
|
|
1052
1061
|
max_length=API_MAX_LENGTH['from_email'],
|
|
1053
|
-
default=
|
|
1062
|
+
default=campaign_activity__from_email__default,
|
|
1054
1063
|
verbose_name=_('From Email'),
|
|
1055
1064
|
)
|
|
1056
1065
|
reply_to_email = models.EmailField(
|
|
1057
1066
|
max_length=API_MAX_LENGTH['reply_to_email'],
|
|
1058
|
-
default=
|
|
1067
|
+
default=campaign_activity__reply_to_email__default,
|
|
1059
1068
|
verbose_name=_('Reply-to Email'),
|
|
1060
1069
|
)
|
|
1061
1070
|
subject = models.CharField(
|
|
@@ -1103,12 +1112,12 @@ class CampaignActivity(CTCTEndpointModel):
|
|
|
1103
1112
|
# but imports could have other values
|
|
1104
1113
|
format_type = models.IntegerField(
|
|
1105
1114
|
choices=FORMAT_TYPES,
|
|
1106
|
-
default=
|
|
1115
|
+
default=FORMAT_TYPES[4][0], # evals to 5
|
|
1107
1116
|
verbose_name=_('Format Type'),
|
|
1108
1117
|
)
|
|
1109
1118
|
|
|
1110
1119
|
@property
|
|
1111
|
-
def physical_address_in_footer(self) ->
|
|
1120
|
+
def physical_address_in_footer(self) -> dict[str, str] | None:
|
|
1112
1121
|
"""Returns the company address for email footers.
|
|
1113
1122
|
|
|
1114
1123
|
Notes
|
|
@@ -1148,19 +1157,19 @@ class CampaignActivity(CTCTEndpointModel):
|
|
|
1148
1157
|
return data
|
|
1149
1158
|
|
|
1150
1159
|
@classmethod
|
|
1151
|
-
def clean_remote_from_name(cls, data: JsonDict) ->
|
|
1160
|
+
def clean_remote_from_name(cls, data: JsonDict) -> str | None:
|
|
1152
1161
|
return cls.clean_remote_string_with_default('from_name', data)
|
|
1153
1162
|
|
|
1154
1163
|
@classmethod
|
|
1155
|
-
def clean_remote_from_email(cls, data: JsonDict) ->
|
|
1164
|
+
def clean_remote_from_email(cls, data: JsonDict) -> str | None:
|
|
1156
1165
|
return cls.clean_remote_string_with_default('from_email', data)
|
|
1157
1166
|
|
|
1158
1167
|
@classmethod
|
|
1159
|
-
def clean_remote_reply_to_email(cls, data: JsonDict) ->
|
|
1168
|
+
def clean_remote_reply_to_email(cls, data: JsonDict) -> str | None:
|
|
1160
1169
|
return cls.clean_remote_string_with_default('reply_to_email', data)
|
|
1161
1170
|
|
|
1162
1171
|
@classmethod
|
|
1163
|
-
def clean_remote_subject(cls, data: JsonDict) ->
|
|
1172
|
+
def clean_remote_subject(cls, data: JsonDict) -> str | None:
|
|
1164
1173
|
"""Pass a `default` here so it won't appear in admin forms."""
|
|
1165
1174
|
default = cls.MISSING_SUBJECT
|
|
1166
1175
|
return cls.clean_remote_string_with_default('subject', data, default)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any, Type
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db.models import Model
|
|
5
|
+
|
|
6
|
+
from django_ctct.models import CTCTEndpointModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def remote_save(sender: Type[Model], instance: Model, **kwargs: Any) -> None:
|
|
10
|
+
"""Create or update the instance on CTCT servers."""
|
|
11
|
+
|
|
12
|
+
if (
|
|
13
|
+
issubclass(sender, CTCTEndpointModel) and
|
|
14
|
+
isinstance(instance, CTCTEndpointModel)
|
|
15
|
+
):
|
|
16
|
+
if instance.api_id:
|
|
17
|
+
task = sender.remote.update
|
|
18
|
+
else:
|
|
19
|
+
task = sender.remote.create
|
|
20
|
+
|
|
21
|
+
enqueue = getattr(settings, 'CTCT_ENQUEUE_DEFAULT', False)
|
|
22
|
+
if getattr(instance, 'enqueue', enqueue) and hasattr(task, 'enqueue'):
|
|
23
|
+
task.enqueue(obj=instance)
|
|
24
|
+
else:
|
|
25
|
+
task(obj=instance)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def remote_delete(sender: Type[Model], instance: Model, **kwargs: Any) -> None:
|
|
29
|
+
"""Delete the instance from CTCT servers."""
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
issubclass(sender, CTCTEndpointModel) and
|
|
33
|
+
isinstance(instance, CTCTEndpointModel)
|
|
34
|
+
):
|
|
35
|
+
task = sender.remote.delete
|
|
36
|
+
|
|
37
|
+
enqueue = getattr(settings, 'CTCT_ENQUEUE_DEFAULT', False)
|
|
38
|
+
if getattr(instance, 'enqueue', enqueue) and hasattr(task, 'enqueue'):
|
|
39
|
+
task.enqueue(obj=instance)
|
|
40
|
+
else:
|
|
41
|
+
task(obj=instance)
|
|
@@ -20,7 +20,9 @@ RelatedFields: TypeAlias = tuple[
|
|
|
20
20
|
def to_dt(s: str, ts_format: str = '%Y-%m-%dT%H:%M:%SZ') -> dt.datetime:
|
|
21
21
|
if '.' in s:
|
|
22
22
|
# Remove milliseconds
|
|
23
|
-
s = s.split('.')[0]
|
|
23
|
+
s = s.split('.')[0]
|
|
24
|
+
if ts_format.endswith('Z') and not s.endswith('Z'):
|
|
25
|
+
s += 'Z'
|
|
24
26
|
return timezone.make_aware(dt.datetime.strptime(s, ts_format))
|
|
25
27
|
|
|
26
28
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "django-ctct"
|
|
3
|
-
version = "0.0.1.
|
|
3
|
+
version = "0.0.1.dev6"
|
|
4
4
|
description = "A Django interface for the Constant Contact API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Geoffrey Eisenbarth",email = "geoffrey.eisenbarth@gmail.com"}
|
|
@@ -9,7 +9,7 @@ license = {text = "MIT"}
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"django (>=
|
|
12
|
+
"django (>=4.2)",
|
|
13
13
|
"requests (>=2.32.3,<3.0.0)",
|
|
14
14
|
"ratelimit (>=2.2.1,<3.0.0)",
|
|
15
15
|
"tqdm (>=4.67.1,<5.0.0)",
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
from typing import Type, Literal, Any, cast
|
|
2
|
-
|
|
3
|
-
from django.conf import settings
|
|
4
|
-
from django.db.models import Model
|
|
5
|
-
|
|
6
|
-
from django_ctct.models import (
|
|
7
|
-
CTCTEndpointModel, Contact, ContactList, CampaignActivity
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def remote_save(sender: Type[Model], instance: Model, **kwargs: Any) -> None:
|
|
12
|
-
"""Create or update the instance on CTCT servers."""
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
issubclass(sender, CTCTEndpointModel) and
|
|
16
|
-
isinstance(instance, CTCTEndpointModel)
|
|
17
|
-
):
|
|
18
|
-
sender.remote.connect()
|
|
19
|
-
|
|
20
|
-
if instance.api_id:
|
|
21
|
-
task = sender.remote.update
|
|
22
|
-
else:
|
|
23
|
-
task = sender.remote.create
|
|
24
|
-
|
|
25
|
-
enqueue = getattr(settings, 'CTCT_ENQUEUE_DEFAULT', False)
|
|
26
|
-
if getattr(instance, 'enqueue', enqueue) and hasattr(task, 'enqueue'):
|
|
27
|
-
task.enqueue(obj=instance)
|
|
28
|
-
else:
|
|
29
|
-
task(obj=instance)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def remote_delete(sender: Type[Model], instance: Model, **kwargs: Any) -> None:
|
|
33
|
-
"""Delete the instance from CTCT servers."""
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
issubclass(sender, CTCTEndpointModel) and
|
|
37
|
-
isinstance(instance, CTCTEndpointModel)
|
|
38
|
-
):
|
|
39
|
-
sender.remote.connect()
|
|
40
|
-
|
|
41
|
-
task = sender.remote.delete
|
|
42
|
-
|
|
43
|
-
enqueue = getattr(settings, 'CTCT_ENQUEUE_DEFAULT', False)
|
|
44
|
-
if getattr(instance, 'enqueue', enqueue) and hasattr(task, 'enqueue'):
|
|
45
|
-
task.enqueue(obj=instance)
|
|
46
|
-
else:
|
|
47
|
-
task(obj=instance)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# TODO: GH #15
|
|
51
|
-
def remote_update_m2m(
|
|
52
|
-
sender: Type[Model],
|
|
53
|
-
instance: Model,
|
|
54
|
-
action: Literal['pre_add', 'post_add', 'pre_remove', 'post_remove', 'pre_clear', 'post_clear'], # noqa: E501
|
|
55
|
-
**kwargs: Any,
|
|
56
|
-
) -> None:
|
|
57
|
-
"""Updates a Contact's list membership on CTCT servers."""
|
|
58
|
-
|
|
59
|
-
senders = (
|
|
60
|
-
Contact.list_memberships.through,
|
|
61
|
-
ContactList.members.through,
|
|
62
|
-
CampaignActivity.contact_lists.through,
|
|
63
|
-
ContactList.campaign_activities.through,
|
|
64
|
-
)
|
|
65
|
-
actions = ['post_add', 'post_remove', 'post_clear']
|
|
66
|
-
|
|
67
|
-
if (sender in senders) and (action in actions):
|
|
68
|
-
|
|
69
|
-
if isinstance(instance, (Contact, CampaignActivity)):
|
|
70
|
-
# Just update the instance using PUT
|
|
71
|
-
task_name = 'update'
|
|
72
|
-
kwargs = {'obj': instance}
|
|
73
|
-
elif isinstance(instance, ContactList):
|
|
74
|
-
# Must use special methods defined on the remote manager
|
|
75
|
-
if sender is ContactList.members.through:
|
|
76
|
-
task_name = 'add_list_memberships'
|
|
77
|
-
kwargs = {
|
|
78
|
-
'contact_list': instance,
|
|
79
|
-
'contacts': Contact.objects.filter(pk__in=kwargs['pk_set']),
|
|
80
|
-
}
|
|
81
|
-
elif sender is ContactList.campaign_activities.through:
|
|
82
|
-
raise NotImplementedError
|
|
83
|
-
|
|
84
|
-
model = cast(Type[CTCTEndpointModel], instance._meta.model)
|
|
85
|
-
model.remote.connect()
|
|
86
|
-
|
|
87
|
-
task = getattr(model.remote, task_name)
|
|
88
|
-
|
|
89
|
-
enqueue = getattr(settings, 'CTCT_ENQUEUE_DEFAULT', False)
|
|
90
|
-
if getattr(instance, 'enqueue', enqueue) and hasattr(task, 'enqueue'):
|
|
91
|
-
task.enqueue(**kwargs)
|
|
92
|
-
else:
|
|
93
|
-
task(**kwargs)
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import functools
|
|
2
|
-
import logging
|
|
3
|
-
|
|
4
|
-
from django.dispatch import Signal
|
|
5
|
-
|
|
6
|
-
logger = logging.getLogger('factory.generate')
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class mute_signals:
|
|
10
|
-
"""Temporarily disables and then restores any django signals.
|
|
11
|
-
|
|
12
|
-
Args:
|
|
13
|
-
*signals (django.dispatch.dispatcher.Signal): any django signals
|
|
14
|
-
|
|
15
|
-
Examples:
|
|
16
|
-
with mute_signals(pre_init):
|
|
17
|
-
user = UserFactory.build()
|
|
18
|
-
...
|
|
19
|
-
|
|
20
|
-
@mute_signals(pre_save, post_save)
|
|
21
|
-
class UserFactory(factory.Factory):
|
|
22
|
-
...
|
|
23
|
-
|
|
24
|
-
@mute_signals(post_save)
|
|
25
|
-
def generate_users():
|
|
26
|
-
UserFactory.create_batch(10)
|
|
27
|
-
|
|
28
|
-
License:
|
|
29
|
-
Copyright (c) 2010 Mark Sandstrom
|
|
30
|
-
Copyright (c) 2011-2015 Raphaël Barrois
|
|
31
|
-
Copyright (c) The FactoryBoy project
|
|
32
|
-
|
|
33
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
34
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
35
|
-
in the Software without restriction, including without limitation the rights
|
|
36
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
37
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
38
|
-
furnished to do so, subject to the following conditions:
|
|
39
|
-
|
|
40
|
-
The above copyright notice and this permission notice shall be included in
|
|
41
|
-
all copies or substantial portions of the Software.
|
|
42
|
-
|
|
43
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
44
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
45
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
46
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
47
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
48
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
49
|
-
THE SOFTWARE.
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
def __init__(self, *signals: Signal):
|
|
53
|
-
self.signals = signals
|
|
54
|
-
self.paused = {}
|
|
55
|
-
|
|
56
|
-
def __enter__(self):
|
|
57
|
-
for signal in self.signals:
|
|
58
|
-
logger.debug('mute_signals: Disabling signal handlers %r',
|
|
59
|
-
signal.receivers)
|
|
60
|
-
|
|
61
|
-
# Note that we're using implementation details of
|
|
62
|
-
# django.signals, since arguments to signal.connect()
|
|
63
|
-
# are lost in signal.receivers
|
|
64
|
-
self.paused[signal] = signal.receivers
|
|
65
|
-
signal.receivers = []
|
|
66
|
-
|
|
67
|
-
def __exit__(self, exc_type, exc_value, traceback):
|
|
68
|
-
for signal, receivers in self.paused.items():
|
|
69
|
-
logger.debug('mute_signals: Restoring signal handlers %r',
|
|
70
|
-
receivers)
|
|
71
|
-
|
|
72
|
-
signal.receivers = receivers + signal.receivers
|
|
73
|
-
with signal.lock:
|
|
74
|
-
# Django uses some caching for its signals.
|
|
75
|
-
# Since we're bypassing signal.connect and signal.disconnect,
|
|
76
|
-
# we have to keep messing with django's internals.
|
|
77
|
-
signal.sender_receivers_cache.clear()
|
|
78
|
-
self.paused = {}
|
|
79
|
-
|
|
80
|
-
def copy(self):
|
|
81
|
-
return mute_signals(*self.signals)
|
|
82
|
-
|
|
83
|
-
def __call__(self, callable_obj):
|
|
84
|
-
if isinstance(callable_obj, base.FactoryMetaClass):
|
|
85
|
-
# Retrieve __func__, the *actual* callable object.
|
|
86
|
-
callable_obj._create = self.wrap_method(callable_obj._create.__func__)
|
|
87
|
-
callable_obj._generate = self.wrap_method(callable_obj._generate.__func__)
|
|
88
|
-
callable_obj._after_postgeneration = self.wrap_method(
|
|
89
|
-
callable_obj._after_postgeneration.__func__
|
|
90
|
-
)
|
|
91
|
-
return callable_obj
|
|
92
|
-
|
|
93
|
-
else:
|
|
94
|
-
@functools.wraps(callable_obj)
|
|
95
|
-
def wrapper(*args, **kwargs):
|
|
96
|
-
# A mute_signals() object is not reentrant; use a copy every time.
|
|
97
|
-
with self.copy():
|
|
98
|
-
return callable_obj(*args, **kwargs)
|
|
99
|
-
return wrapper
|
|
100
|
-
|
|
101
|
-
def wrap_method(self, method):
|
|
102
|
-
@classmethod
|
|
103
|
-
@functools.wraps(method)
|
|
104
|
-
def wrapped_method(*args, **kwargs):
|
|
105
|
-
# A mute_signals() object is not reentrant; use a copy every time.
|
|
106
|
-
with self.copy():
|
|
107
|
-
return method(*args, **kwargs)
|
|
108
|
-
return wrapped_method
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|