django-ctct 0.0.1.dev4__tar.gz → 0.0.1.dev5__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.dev4 → django_ctct-0.0.1.dev5}/PKG-INFO +27 -11
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/README.md +22 -9
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/admin.py +6 -6
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/management/commands/import_ctct.py +38 -84
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/managers.py +55 -56
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/migrations/0001_initial.py +20 -3
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/models.py +107 -91
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/utils.py +3 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/pyproject.toml +2 -1
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/LICENSE +0 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/__init__.py +0 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/apps.py +0 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/migrations/__init__.py +0 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/signals.py +0 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/urls.py +0 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/vendor.py +0 -0
- {django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/views.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: django-ctct
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.dev5
|
|
4
4
|
Summary: A Django interface for the Constant Contact API
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Geoffrey Eisenbarth
|
|
7
8
|
Author-email: geoffrey.eisenbarth@gmail.com
|
|
8
9
|
Requires-Python: >=3.10
|
|
@@ -12,7 +13,9 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
17
|
Requires-Dist: django (>=3.2.0,<6.0.0)
|
|
18
|
+
Requires-Dist: mypy (>=1.19.0,<2.0.0)
|
|
16
19
|
Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0)
|
|
17
20
|
Requires-Dist: ratelimit (>=2.2.1,<3.0.0)
|
|
18
21
|
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
@@ -25,7 +28,9 @@ Description-Content-Type: text/markdown
|
|
|
25
28
|
|
|
26
29
|
This Django app provides a seamless interface to the Constant Contact API, allowing you to manage contacts, email campaigns, and other Constant Contact functionalities directly from your Django project.
|
|
27
30
|
|
|
28
|
-
**Warning:** This package is under active development.
|
|
31
|
+
**Warning:** This package is under active development.
|
|
32
|
+
While it is our intention to develop with a consistent API going forward, we will not make promises until a later version is released.
|
|
33
|
+
|
|
29
34
|
|
|
30
35
|
## Installation
|
|
31
36
|
|
|
@@ -33,6 +38,7 @@ This Django app provides a seamless interface to the Constant Contact API, allow
|
|
|
33
38
|
pip install django-ctct
|
|
34
39
|
```
|
|
35
40
|
|
|
41
|
+
|
|
36
42
|
## Configuration
|
|
37
43
|
|
|
38
44
|
1) **Add to `INSTALLED_APPS`:**
|
|
@@ -61,6 +67,7 @@ urlpatterns = [
|
|
|
61
67
|
# ... other URL patterns
|
|
62
68
|
]
|
|
63
69
|
```
|
|
70
|
+
|
|
64
71
|
3) **ConstantContact API Credentials:**
|
|
65
72
|
|
|
66
73
|
Head to https://app.constantcontact.com/pages/dma/portal/ to set up your application with ConstantContact.
|
|
@@ -110,6 +117,7 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
|
|
|
110
117
|
* `CTCT_PREVIEW_MESSAGE` will be blank by default.
|
|
111
118
|
|
|
112
119
|
3) **Run Migrations:**
|
|
120
|
+
|
|
113
121
|
```bash
|
|
114
122
|
> ./manage.py makemigrations
|
|
115
123
|
> ./manage.py migrate
|
|
@@ -118,9 +126,10 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
|
|
|
118
126
|
4) **Create Authenticatin Token:**
|
|
119
127
|
|
|
120
128
|
After the app has been installed and configured, you must generate your first auth token:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
|
|
130
|
+
* Open up the `CTCT_REDIRECT_URI` address in your browser
|
|
131
|
+
* Use your ConstantContact credentials to log in
|
|
132
|
+
* From this point forward `django-ctct` should use refresh tokens, so no need to manually log in again
|
|
124
133
|
|
|
125
134
|
|
|
126
135
|
## Usage
|
|
@@ -130,12 +139,16 @@ If you wish to import data from ConstantContact.com into your local database (re
|
|
|
130
139
|
> ./manage.py import_ctct
|
|
131
140
|
```
|
|
132
141
|
|
|
133
|
-
You will be asked before each model type is imported.
|
|
142
|
+
You will be asked before each model type is imported.
|
|
143
|
+
**Note** ConstantContact does not provide a bulk API endpoint for fetching Campaign Activities, so depending on the size of your account, this might take some time and possible put you over their 10,000 request per day limit if you run it regularly.
|
|
134
144
|
|
|
135
|
-
Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database.
|
|
145
|
+
Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database.
|
|
146
|
+
You can use the `--no-input` flag to bypass the interactive questions.
|
|
147
|
+
The `--stats-only` flag is useful for running a cron job to keep EmailCampaign statistics updated.
|
|
136
148
|
|
|
137
149
|
If you wish to use the Django admin to interact with ConstantContact, you must explicitly set the `CTCT_USE_ADMIN` and `CTCT_SYNC_ADMIN` settings to `True`.
|
|
138
150
|
|
|
151
|
+
|
|
139
152
|
## Testing
|
|
140
153
|
|
|
141
154
|
To install dev dependencies:
|
|
@@ -155,13 +168,16 @@ To run tests:
|
|
|
155
168
|
> poetry run coverage report
|
|
156
169
|
```
|
|
157
170
|
|
|
171
|
+
|
|
158
172
|
## Contributing
|
|
159
173
|
|
|
160
174
|
Once version 0.0.1 is released on PyPI, we hope to implement the following new features (in no particular order):
|
|
161
175
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
* Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
|
|
180
|
+
* Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
|
|
165
181
|
|
|
166
182
|
|
|
167
183
|
I'm always open to new suggestions, so please reach out on GitHub: https://github.com/geoffrey-eisenbarth/django-ctct/
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
This Django app provides a seamless interface to the Constant Contact API, allowing you to manage contacts, email campaigns, and other Constant Contact functionalities directly from your Django project.
|
|
6
6
|
|
|
7
|
-
**Warning:** This package is under active development.
|
|
7
|
+
**Warning:** This package is under active development.
|
|
8
|
+
While it is our intention to develop with a consistent API going forward, we will not make promises until a later version is released.
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
## Installation
|
|
10
12
|
|
|
@@ -12,6 +14,7 @@ This Django app provides a seamless interface to the Constant Contact API, allow
|
|
|
12
14
|
pip install django-ctct
|
|
13
15
|
```
|
|
14
16
|
|
|
17
|
+
|
|
15
18
|
## Configuration
|
|
16
19
|
|
|
17
20
|
1) **Add to `INSTALLED_APPS`:**
|
|
@@ -40,6 +43,7 @@ urlpatterns = [
|
|
|
40
43
|
# ... other URL patterns
|
|
41
44
|
]
|
|
42
45
|
```
|
|
46
|
+
|
|
43
47
|
3) **ConstantContact API Credentials:**
|
|
44
48
|
|
|
45
49
|
Head to https://app.constantcontact.com/pages/dma/portal/ to set up your application with ConstantContact.
|
|
@@ -89,6 +93,7 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
|
|
|
89
93
|
* `CTCT_PREVIEW_MESSAGE` will be blank by default.
|
|
90
94
|
|
|
91
95
|
3) **Run Migrations:**
|
|
96
|
+
|
|
92
97
|
```bash
|
|
93
98
|
> ./manage.py makemigrations
|
|
94
99
|
> ./manage.py migrate
|
|
@@ -97,9 +102,10 @@ CTCT_SYNC_ADMIN = False # Django admin CRUD operations will sync with ctct
|
|
|
97
102
|
4) **Create Authenticatin Token:**
|
|
98
103
|
|
|
99
104
|
After the app has been installed and configured, you must generate your first auth token:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
|
|
106
|
+
* Open up the `CTCT_REDIRECT_URI` address in your browser
|
|
107
|
+
* Use your ConstantContact credentials to log in
|
|
108
|
+
* From this point forward `django-ctct` should use refresh tokens, so no need to manually log in again
|
|
103
109
|
|
|
104
110
|
|
|
105
111
|
## Usage
|
|
@@ -109,12 +115,16 @@ If you wish to import data from ConstantContact.com into your local database (re
|
|
|
109
115
|
> ./manage.py import_ctct
|
|
110
116
|
```
|
|
111
117
|
|
|
112
|
-
You will be asked before each model type is imported.
|
|
118
|
+
You will be asked before each model type is imported.
|
|
119
|
+
**Note** ConstantContact does not provide a bulk API endpoint for fetching Campaign Activities, so depending on the size of your account, this might take some time and possible put you over their 10,000 request per day limit if you run it regularly.
|
|
113
120
|
|
|
114
|
-
Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database.
|
|
121
|
+
Since ConstantContact does not offer any webhooks, you will need to set up a cron job if you want your account to remain syncronized with ConstantContact's database.
|
|
122
|
+
You can use the `--no-input` flag to bypass the interactive questions.
|
|
123
|
+
The `--stats-only` flag is useful for running a cron job to keep EmailCampaign statistics updated.
|
|
115
124
|
|
|
116
125
|
If you wish to use the Django admin to interact with ConstantContact, you must explicitly set the `CTCT_USE_ADMIN` and `CTCT_SYNC_ADMIN` settings to `True`.
|
|
117
126
|
|
|
127
|
+
|
|
118
128
|
## Testing
|
|
119
129
|
|
|
120
130
|
To install dev dependencies:
|
|
@@ -134,13 +144,16 @@ To run tests:
|
|
|
134
144
|
> poetry run coverage report
|
|
135
145
|
```
|
|
136
146
|
|
|
147
|
+
|
|
137
148
|
## Contributing
|
|
138
149
|
|
|
139
150
|
Once version 0.0.1 is released on PyPI, we hope to implement the following new features (in no particular order):
|
|
140
151
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
* Background task support using `django-tasks` (which hopefully will merge into Django). This will be controlled by the `CTCT_ENQUEUE_DEFAULT` setting.
|
|
156
|
+
* Add `models.CheckConstraint` and `models.UniqueConstraint` constraints that are currently commented out.
|
|
144
157
|
|
|
145
158
|
|
|
146
159
|
I'm always open to new suggestions, so please reach out on GitHub: https://github.com/geoffrey-eisenbarth/django-ctct/
|
|
@@ -51,7 +51,7 @@ def catch_api_errors(func: Callable[P, None]) -> Callable[P, None]:
|
|
|
51
51
|
if getattr(settings, 'CTCT_RAISE_FOR_API', False):
|
|
52
52
|
raise e
|
|
53
53
|
else:
|
|
54
|
-
self, request, *
|
|
54
|
+
self, request, *x = args
|
|
55
55
|
assert isinstance(self, admin.ModelAdmin)
|
|
56
56
|
assert isinstance(request, HttpRequest)
|
|
57
57
|
self.message_user(
|
|
@@ -141,7 +141,7 @@ class RemoteModelAdmin(
|
|
|
141
141
|
def delete_queryset(
|
|
142
142
|
self,
|
|
143
143
|
request: HttpRequest,
|
|
144
|
-
queryset: QuerySet[
|
|
144
|
+
queryset: QuerySet[E],
|
|
145
145
|
) -> None:
|
|
146
146
|
if self.remote_sync:
|
|
147
147
|
queryset.model.remote.bulk_delete(queryset)
|
|
@@ -152,7 +152,7 @@ class RemoteModelAdmin(
|
|
|
152
152
|
def save_related(
|
|
153
153
|
self,
|
|
154
154
|
request: HttpRequest,
|
|
155
|
-
form: ModelForm[
|
|
155
|
+
form: ModelForm[E],
|
|
156
156
|
formsets: list[BaseFormSet[ModelForm[Model]]],
|
|
157
157
|
change: bool,
|
|
158
158
|
) -> None:
|
|
@@ -175,7 +175,7 @@ class RemoteModelAdmin(
|
|
|
175
175
|
def save_remotely(
|
|
176
176
|
self,
|
|
177
177
|
request: HttpRequest,
|
|
178
|
-
form: ModelForm[
|
|
178
|
+
form: ModelForm[E],
|
|
179
179
|
formsets: list[BaseFormSet[ModelForm[Model]]],
|
|
180
180
|
change: bool,
|
|
181
181
|
) -> None:
|
|
@@ -295,7 +295,7 @@ class ContactCustomFieldInline(
|
|
|
295
295
|
):
|
|
296
296
|
|
|
297
297
|
model = ContactCustomField
|
|
298
|
-
|
|
298
|
+
exclude = ('api_id', )
|
|
299
299
|
|
|
300
300
|
extra = 0
|
|
301
301
|
|
|
@@ -570,7 +570,7 @@ class EmailCampaignAdmin(RemoteModelAdmin[EmailCampaign]):
|
|
|
570
570
|
def save_remotely(
|
|
571
571
|
self,
|
|
572
572
|
request: HttpRequest,
|
|
573
|
-
form: ModelForm[EmailCampaign],
|
|
573
|
+
form: ModelForm[EmailCampaign],
|
|
574
574
|
formsets: list[BaseFormSet[ModelForm[Model]]],
|
|
575
575
|
change: bool,
|
|
576
576
|
) -> None:
|
{django_ctct-0.0.1.dev4 → django_ctct-0.0.1.dev5}/django_ctct/management/commands/import_ctct.py
RENAMED
|
@@ -82,12 +82,13 @@ class Command(BaseCommand):
|
|
|
82
82
|
|
|
83
83
|
if model._meta.auto_created and hasattr(model, 'contactlist_id'):
|
|
84
84
|
# Delete existing through model instances
|
|
85
|
-
model.objects.all().delete()
|
|
85
|
+
model.objects.all().delete() # type: ignore
|
|
86
86
|
update_conflicts = False
|
|
87
87
|
unique_fields = update_fields = None
|
|
88
88
|
elif issubclass(model, ContactCustomField):
|
|
89
|
-
update_conflicts =
|
|
90
|
-
unique_fields =
|
|
89
|
+
update_conflicts = True
|
|
90
|
+
unique_fields = ['contact_id', 'custom_field_id']
|
|
91
|
+
update_fields = ['value']
|
|
91
92
|
elif issubclass(model, CampaignSummary):
|
|
92
93
|
update_conflicts = True
|
|
93
94
|
unique_fields = ['campaign_id']
|
|
@@ -116,7 +117,7 @@ class Command(BaseCommand):
|
|
|
116
117
|
unique_objs = objs
|
|
117
118
|
|
|
118
119
|
# Perform the upsert
|
|
119
|
-
objs_w_pks = model.objects.bulk_create(
|
|
120
|
+
objs_w_pks = model.objects.bulk_create( # type: ignore
|
|
120
121
|
objs=unique_objs,
|
|
121
122
|
update_conflicts=update_conflicts,
|
|
122
123
|
unique_fields=unique_fields,
|
|
@@ -132,76 +133,34 @@ class Command(BaseCommand):
|
|
|
132
133
|
# Inform the user
|
|
133
134
|
if not silent:
|
|
134
135
|
message = self.style.SUCCESS(
|
|
135
|
-
f'{verb} {len(objs)} {model.__name__} instances.'
|
|
136
|
+
f'{verb} {len(objs):,} {model.__name__} instances.'
|
|
136
137
|
)
|
|
137
138
|
self.stdout.write(message)
|
|
138
139
|
|
|
139
140
|
return objs_w_pks
|
|
140
141
|
|
|
141
|
-
def set_direct_object_pks(
|
|
142
|
-
self,
|
|
143
|
-
model: Type[M],
|
|
144
|
-
instances: list[M],
|
|
145
|
-
) -> None:
|
|
146
|
-
"""Sets Django pk values for OneToOne and ForeignKeys objects."""
|
|
147
|
-
|
|
148
|
-
otos, _, fks, _ = get_related_fields(model)
|
|
149
|
-
|
|
150
|
-
fields = otos + fks
|
|
151
|
-
for field in fields:
|
|
152
|
-
if id_to_pk := self.get_id_to_pk(field.related_model):
|
|
153
|
-
for o in instances:
|
|
154
|
-
setattr(o, field.attname, id_to_pk[str(getattr(o, field.attname))])
|
|
155
|
-
|
|
156
142
|
def set_related_object_pks(
|
|
157
143
|
self,
|
|
158
|
-
model: Type[
|
|
144
|
+
model: Type[E],
|
|
159
145
|
objs_w_pks: list[M],
|
|
160
146
|
per_obj_list_of_related_objs: list[list[RelatedObjects]],
|
|
161
147
|
) -> None:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
m2m_attnames = {
|
|
170
|
-
field.remote_field.through: (
|
|
171
|
-
field.m2m_column_name(), field.m2m_reverse_name()
|
|
172
|
-
)
|
|
173
|
-
for field in m2ms
|
|
174
|
-
}
|
|
175
|
-
if m2ms:
|
|
176
|
-
id_to_pk = {
|
|
177
|
-
m2m.remote_field.through: self.get_id_to_pk(m2m.related_model)
|
|
178
|
-
for m2m in m2ms
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
rfk_attnames = {
|
|
182
|
-
rel.related_model: rel.field.attname
|
|
183
|
-
for rel in rfks
|
|
148
|
+
_, mtms, _, rfks = get_related_fields(model)
|
|
149
|
+
field_name = {
|
|
150
|
+
field.remote_field.through: field.m2m_field_name()
|
|
151
|
+
for field in mtms
|
|
152
|
+
} | {
|
|
153
|
+
field.related_model: field.remote_field.name
|
|
154
|
+
for field in rfks
|
|
184
155
|
}
|
|
185
156
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
for obj_w_pk, list_of_related_objs in zip(objs_w_pks, per_obj_list_of_related_objs): # noqa: E501
|
|
194
|
-
for related_model, objs in list_of_related_objs:
|
|
195
|
-
if m2m_attname := m2m_attnames.get(related_model):
|
|
196
|
-
column_name, reverse_name = m2m_attname
|
|
197
|
-
for o in objs:
|
|
198
|
-
api_id = str(getattr(o, reverse_name))
|
|
199
|
-
setattr(o, column_name, obj_w_pk.pk)
|
|
200
|
-
setattr(o, reverse_name, id_to_pk[related_model][api_id])
|
|
201
|
-
|
|
202
|
-
elif rfk_attname := rfk_attnames.get(related_model):
|
|
203
|
-
for o in objs:
|
|
204
|
-
setattr(o, rfk_attname, obj_w_pk.pk)
|
|
157
|
+
for obj_w_pk, list_of_related_objs in zip(
|
|
158
|
+
objs_w_pks,
|
|
159
|
+
per_obj_list_of_related_objs,
|
|
160
|
+
):
|
|
161
|
+
for related_model, related_objs in list_of_related_objs:
|
|
162
|
+
for related_obj in related_objs:
|
|
163
|
+
setattr(related_obj, field_name[related_model], obj_w_pk)
|
|
205
164
|
|
|
206
165
|
def import_model(self, model: Type[E]) -> None:
|
|
207
166
|
"""Imports objects from CTCT into Django's database."""
|
|
@@ -223,18 +182,16 @@ class Command(BaseCommand):
|
|
|
223
182
|
# No values returned
|
|
224
183
|
return
|
|
225
184
|
|
|
226
|
-
# Convert API id to Django pks
|
|
227
|
-
self.set_direct_object_pks(model, objs)
|
|
228
|
-
|
|
229
185
|
# Upsert models to get Django pks
|
|
230
186
|
objs_w_pks = self.upsert(model, objs)
|
|
231
187
|
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
188
|
+
# Set Django object PK on related objects
|
|
189
|
+
if any(per_obj_list_of_related_objs):
|
|
190
|
+
self.set_related_object_pks(
|
|
191
|
+
model,
|
|
192
|
+
objs_w_pks,
|
|
193
|
+
per_obj_list_of_related_objs,
|
|
194
|
+
)
|
|
238
195
|
|
|
239
196
|
# Reshape related_objs for efficiency
|
|
240
197
|
dict_of_related_objs = defaultdict(list)
|
|
@@ -249,13 +206,9 @@ class Command(BaseCommand):
|
|
|
249
206
|
def import_campaign_activities(self) -> None:
|
|
250
207
|
"""CampaignActivities must be imported one at a time."""
|
|
251
208
|
|
|
252
|
-
# First make sure CampaignActivity API id's are stored locally
|
|
209
|
+
# First, make sure all CampaignActivity API id's are stored locally
|
|
253
210
|
EmailCampaign.remote.connect()
|
|
254
|
-
for campaign in EmailCampaign.objects.exclude(
|
|
255
|
-
api_id__isnull=True,
|
|
256
|
-
campaign_activities__role='primary_email',
|
|
257
|
-
campaign_activities__api_id__isnull=False,
|
|
258
|
-
):
|
|
211
|
+
for campaign in EmailCampaign.objects.exclude(api_id__isnull=True):
|
|
259
212
|
# Fetch from API
|
|
260
213
|
assert isinstance(campaign.api_id, UUID)
|
|
261
214
|
try:
|
|
@@ -272,7 +225,7 @@ class Command(BaseCommand):
|
|
|
272
225
|
obj.campaign_id = campaign.pk
|
|
273
226
|
obj.save()
|
|
274
227
|
|
|
275
|
-
#
|
|
228
|
+
# Then, fetch CampaignActivity details
|
|
276
229
|
CampaignActivity.remote.connect()
|
|
277
230
|
|
|
278
231
|
activities = CampaignActivity.objects.filter(
|
|
@@ -304,12 +257,13 @@ class Command(BaseCommand):
|
|
|
304
257
|
update_fields=['role', 'subject', 'preheader', 'html_content']
|
|
305
258
|
)
|
|
306
259
|
|
|
307
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
260
|
+
# Set Django object PK on related objects
|
|
261
|
+
if any(per_obj_list_of_related_objs):
|
|
262
|
+
self.set_related_object_pks(
|
|
263
|
+
CampaignActivity,
|
|
264
|
+
objs_w_pks,
|
|
265
|
+
per_obj_list_of_related_objs,
|
|
266
|
+
)
|
|
313
267
|
|
|
314
268
|
# Reshape related_objs for efficiency
|
|
315
269
|
dict_of_related_objs = defaultdict(list)
|
|
@@ -17,7 +17,7 @@ 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
|
|
20
|
+
from django.db.models import signals
|
|
21
21
|
from django.db.models.manager import Manager
|
|
22
22
|
from django.db.models.query import QuerySet
|
|
23
23
|
from django.http import HttpRequest, Http404
|
|
@@ -32,7 +32,7 @@ from django_ctct.vendor import mute_signals
|
|
|
32
32
|
if TYPE_CHECKING:
|
|
33
33
|
from django_ctct.models import (
|
|
34
34
|
JsonDict, RelatedObjects,
|
|
35
|
-
EndpointMixin, CTCTModel, CTCTEndpointModel,
|
|
35
|
+
EndpointMixin, SerialModel, CTCTModel, CTCTEndpointModel,
|
|
36
36
|
Token, ContactList, Contact,
|
|
37
37
|
EmailCampaign, CampaignActivity, CampaignSummary,
|
|
38
38
|
)
|
|
@@ -40,6 +40,7 @@ if TYPE_CHECKING:
|
|
|
40
40
|
T = TypeVar('T', bound='EndpointMixin')
|
|
41
41
|
E = TypeVar('E', bound='CTCTEndpointModel')
|
|
42
42
|
C = TypeVar('C', bound='CTCTModel')
|
|
43
|
+
S = TypeVar('S', bound='SerialModel')
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
class ConnectionManagerMixin(Manager[T]):
|
|
@@ -183,13 +184,13 @@ class TokenRemoteManager(ConnectionManagerMixin['Token'], Manager['Token']):
|
|
|
183
184
|
return token
|
|
184
185
|
|
|
185
186
|
|
|
186
|
-
class Serializer(Manager[
|
|
187
|
+
class Serializer(Manager[S]):
|
|
187
188
|
|
|
188
189
|
TS_FORMAT: ClassVar[str] = '%Y-%m-%dT%H:%M:%SZ'
|
|
189
190
|
|
|
190
191
|
def serialize(
|
|
191
192
|
self,
|
|
192
|
-
obj:
|
|
193
|
+
obj: S,
|
|
193
194
|
field_types: Literal['editable', 'readonly', 'all'] = 'editable',
|
|
194
195
|
) -> JsonDict:
|
|
195
196
|
"""Convert from Django object to API request body."""
|
|
@@ -241,21 +242,15 @@ class Serializer(Manager[C]):
|
|
|
241
242
|
data[field_name] = list(map(str, qs))
|
|
242
243
|
elif hasattr(value.model, 'serializer'):
|
|
243
244
|
# ReverseForeignKey: serialize QuerySet
|
|
245
|
+
if value.model.__name__ == 'ContactCustomField':
|
|
246
|
+
# Model behaves as a through model, must get related ids
|
|
247
|
+
field_types = 'all'
|
|
244
248
|
qs = value.all()
|
|
245
249
|
data[field_name] = [
|
|
246
250
|
qs.model.serializer.serialize(o, field_types)
|
|
247
251
|
for o in qs
|
|
248
252
|
]
|
|
249
|
-
|
|
250
|
-
# TODO: GH #14
|
|
251
|
-
data[field_name] = [
|
|
252
|
-
{
|
|
253
|
-
'custom_field_id': str(row['custom_field__api_id']),
|
|
254
|
-
'value': row['value'],
|
|
255
|
-
}
|
|
256
|
-
for row in value.values('custom_field__api_id', 'value')
|
|
257
|
-
]
|
|
258
|
-
elif isinstance(value, Model):
|
|
253
|
+
elif isinstance(value, models.Model):
|
|
259
254
|
raise NotImplementedError
|
|
260
255
|
else:
|
|
261
256
|
raise NotImplementedError
|
|
@@ -301,54 +296,50 @@ class Serializer(Manager[C]):
|
|
|
301
296
|
) -> tuple[JsonDict, list[RelatedObjects]]:
|
|
302
297
|
"""Deserialize ManyToManyFields and ReverseForeignKeys."""
|
|
303
298
|
|
|
304
|
-
from django_ctct.models import
|
|
299
|
+
from django_ctct.models import is_model, is_serial
|
|
305
300
|
|
|
301
|
+
related_objs: RelatedObjects
|
|
306
302
|
list_of_related_objs: list[RelatedObjects] = []
|
|
307
|
-
objs: list[Model]
|
|
303
|
+
objs: list[models.Model]
|
|
308
304
|
|
|
309
305
|
_, m2ms, _, rfks = get_related_fields(self.model)
|
|
310
306
|
for rfk_field in filter(lambda f: f.name in data, rfks):
|
|
311
307
|
# Reverse ForeignKeys get deserialized into model instances
|
|
312
|
-
|
|
308
|
+
RelatedModel = rfk_field.related_model
|
|
313
309
|
parent = {rfk_field.remote_field.attname: parent_pk}
|
|
314
|
-
|
|
315
|
-
if is_model(related_model) and (
|
|
316
|
-
related_model.__name__ == 'ContactCustomField'
|
|
317
|
-
):
|
|
318
|
-
# TODO: GH #14
|
|
319
|
-
objs = [
|
|
320
|
-
related_model(**datum | parent)
|
|
321
|
-
for datum in data.pop(rfk_field.name)
|
|
322
|
-
]
|
|
323
|
-
elif is_ctct(related_model):
|
|
310
|
+
if is_serial(RelatedModel):
|
|
324
311
|
objs = [
|
|
325
|
-
|
|
312
|
+
RelatedModel.serializer.deserialize(datum | parent)[0]
|
|
326
313
|
for datum in data.pop(rfk_field.name)
|
|
327
314
|
]
|
|
328
315
|
else:
|
|
329
316
|
continue
|
|
330
317
|
|
|
331
318
|
if objs:
|
|
332
|
-
related_objs = (
|
|
319
|
+
related_objs = (RelatedModel, objs)
|
|
333
320
|
list_of_related_objs.append(related_objs)
|
|
334
321
|
|
|
335
322
|
for m2m_field in filter(lambda f: f.name in data, m2ms):
|
|
336
323
|
# ManyToManyFields get deserialized into "through model" instances
|
|
337
|
-
#
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
continue
|
|
324
|
+
# NOTE: Contact.custom_field_set is handled by rfk of ContactCustomField
|
|
325
|
+
ThroughModel = m2m_field.remote_field.through
|
|
326
|
+
RelatedModel = m2m_field.related_model
|
|
341
327
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
m2m_field.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
328
|
+
if is_model(ThroughModel) and is_model(RelatedModel):
|
|
329
|
+
related_obj_pks = RelatedModel.objects.filter( # type: ignore
|
|
330
|
+
api_id__in=data.pop(m2m_field.name)
|
|
331
|
+
).values_list('pk', flat=True)
|
|
332
|
+
|
|
333
|
+
objs = [
|
|
334
|
+
ThroughModel(**{
|
|
335
|
+
m2m_field.m2m_column_name(): parent_pk, # Might be None
|
|
336
|
+
m2m_field.m2m_reverse_name(): related_obj_pk,
|
|
337
|
+
})
|
|
338
|
+
for related_obj_pk in related_obj_pks
|
|
339
|
+
]
|
|
340
|
+
if objs:
|
|
341
|
+
related_objs = (ThroughModel, objs)
|
|
342
|
+
list_of_related_objs.append(related_objs)
|
|
352
343
|
|
|
353
344
|
return (data, list_of_related_objs)
|
|
354
345
|
|
|
@@ -356,12 +347,13 @@ class Serializer(Manager[C]):
|
|
|
356
347
|
self,
|
|
357
348
|
data: JsonDict,
|
|
358
349
|
pk: Optional[int] = None,
|
|
359
|
-
) -> tuple[
|
|
350
|
+
) -> tuple[S, list[RelatedObjects]]:
|
|
360
351
|
"""Convert from API response body to Django object."""
|
|
361
352
|
|
|
362
353
|
# If API_ID_LABEL is not a model field name, it will be removed later
|
|
363
354
|
data = data.copy()
|
|
364
|
-
|
|
355
|
+
if hasattr(self.model, 'API_ID_LABEL'):
|
|
356
|
+
data['api_id'] = data[self.model.API_ID_LABEL]
|
|
365
357
|
|
|
366
358
|
# Clean field values, must be done before field restriction
|
|
367
359
|
model_fields = self.model._meta.get_fields()
|
|
@@ -382,6 +374,13 @@ class Serializer(Manager[C]):
|
|
|
382
374
|
if k in [getattr(f, 'attname', f.name) for f in model_fields]
|
|
383
375
|
}
|
|
384
376
|
|
|
377
|
+
# Convert any remaining API ids to Django PKs
|
|
378
|
+
for k, v in data.items():
|
|
379
|
+
if k.endswith('_id') and isinstance(v, str):
|
|
380
|
+
RelatedModel = self.model._meta.get_field(k).related_model
|
|
381
|
+
if RelatedModel is not None:
|
|
382
|
+
data[k] = RelatedModel.objects.get(api_id=v).pk # type: ignore
|
|
383
|
+
|
|
385
384
|
if pk:
|
|
386
385
|
# Preserve unrelated db fields (e.g. EmailCampaign.send_preview)
|
|
387
386
|
obj = self.model.objects.get(pk=pk)
|
|
@@ -424,9 +423,9 @@ class RemoteManager(
|
|
|
424
423
|
|
|
425
424
|
# NOTE: We don't need to do anything with `related_objs` since they were
|
|
426
425
|
# set locally before the API request.
|
|
427
|
-
# TODO: GH #11?
|
|
428
426
|
obj, _ = self.deserialize(data, pk=pk)
|
|
429
427
|
|
|
428
|
+
# TODO: GH #11?
|
|
430
429
|
# Overwrite local obj with CTCT's response
|
|
431
430
|
with mute_signals(signals.post_save):
|
|
432
431
|
obj.save()
|
|
@@ -490,7 +489,7 @@ class RemoteManager(
|
|
|
490
489
|
data = next(iter(metadata.values()))
|
|
491
490
|
list_of_tuples += map(self.deserialize, data)
|
|
492
491
|
|
|
493
|
-
if links
|
|
492
|
+
if links:
|
|
494
493
|
endpoint = links['next']['href']
|
|
495
494
|
else:
|
|
496
495
|
paginated = False
|
|
@@ -522,9 +521,9 @@ class RemoteManager(
|
|
|
522
521
|
|
|
523
522
|
# NOTE: We don't need to do anything with `related_objs` since they were
|
|
524
523
|
# set locally before the API request.
|
|
525
|
-
# TODO: GH #11?
|
|
526
524
|
obj, _ = self.deserialize(data, pk=pk)
|
|
527
525
|
|
|
526
|
+
# TODO: GH #11?
|
|
528
527
|
# Overwrite local obj with CTCT's response
|
|
529
528
|
with mute_signals(signals.post_save):
|
|
530
529
|
obj.save()
|
|
@@ -602,7 +601,7 @@ class ContactListRemoteManager(RemoteManager['ContactList']):
|
|
|
602
601
|
|
|
603
602
|
from django_ctct.models import is_ctct
|
|
604
603
|
|
|
605
|
-
Contact = self.model._meta.get_field('members').related_model
|
|
604
|
+
Contact = self.model._meta.get_field('members').related_model # type: ignore # noqa: E501
|
|
606
605
|
if is_ctct(Contact) and hasattr(Contact, 'API_ENDPOINT_BULK_LIMIT'):
|
|
607
606
|
step_size = Contact.API_ENDPOINT_BULK_LIMIT
|
|
608
607
|
else:
|
|
@@ -664,15 +663,15 @@ class ContactRemoteManager(RemoteManager['Contact']):
|
|
|
664
663
|
when creating Contacts that may already exist in ConstantContact's
|
|
665
664
|
database, even if they've been "deleted" before.
|
|
666
665
|
|
|
667
|
-
Updates to existing contacts are partial updates
|
|
668
|
-
|
|
669
|
-
new contact lists or custom fields
|
|
670
|
-
`custom_fields` arrays.
|
|
666
|
+
Updates to existing contacts are partial updates and this endpoint will
|
|
667
|
+
only update the fields that are included in the request body. Updates
|
|
668
|
+
append new contact lists or custom fields the existing `list_memberships`
|
|
669
|
+
or `custom_fields` arrays. As a result, we cannot use this endpoint to
|
|
670
|
+
remove a list from `list_memberships`, but must use `.update()`.
|
|
671
671
|
|
|
672
672
|
The PUT call (e.g. just using update()) will overwrite all properties not
|
|
673
673
|
included in the request body with NULL, so the `serialize()` method must
|
|
674
|
-
includes all important fields.
|
|
675
|
-
supports partial updates, it won't allow us to remove a ContactList.
|
|
674
|
+
includes all important fields.
|
|
676
675
|
|
|
677
676
|
"""
|
|
678
677
|
|
|
@@ -812,9 +811,9 @@ class EmailCampaignRemoteManager(RemoteManager['EmailCampaign']):
|
|
|
812
811
|
|
|
813
812
|
# NOTE: We don't need to do anything with `related_objs` since they were
|
|
814
813
|
# set locally before the API request.
|
|
815
|
-
# TODO: GH #11?
|
|
816
814
|
obj, _ = self.deserialize(data, pk=pk)
|
|
817
815
|
|
|
816
|
+
# TODO: GH #11?
|
|
818
817
|
# Overwrite local obj with CTCT's response
|
|
819
818
|
with mute_signals(signals.post_save):
|
|
820
819
|
obj.save()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Generated by Django 4.2.20 on 2025-
|
|
1
|
+
# Generated by Django 4.2.20 on 2025-12-03 18:31
|
|
2
2
|
|
|
3
3
|
from django.conf import settings
|
|
4
4
|
from django.db import migrations, models
|
|
@@ -64,7 +64,7 @@ class Migration(migrations.Migration):
|
|
|
64
64
|
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At')),
|
|
65
65
|
('updated_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Updated At')),
|
|
66
66
|
('api_id', models.UUIDField(default=None, null=True, unique=True, verbose_name='API ID')),
|
|
67
|
-
('label', models.CharField(help_text='The display name for the custom_field shown in the UI as free-form text', max_length=50, verbose_name='Label')),
|
|
67
|
+
('label', models.CharField(help_text='The display name for the custom_field shown in the UI as free-form text', max_length=50, unique=True, verbose_name='Label')),
|
|
68
68
|
('type', models.CharField(choices=[('string', 'Text'), ('date', 'Date')], default='string', help_text='Specifies the type of value the custom_field field accepts', max_length=6, verbose_name='Type')),
|
|
69
69
|
],
|
|
70
70
|
options={
|
|
@@ -79,7 +79,7 @@ class Migration(migrations.Migration):
|
|
|
79
79
|
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At')),
|
|
80
80
|
('updated_at', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Updated At')),
|
|
81
81
|
('api_id', models.UUIDField(default=None, null=True, unique=True, verbose_name='API ID')),
|
|
82
|
-
('name', models.CharField(max_length=80, verbose_name='Name')),
|
|
82
|
+
('name', models.CharField(max_length=80, unique=True, verbose_name='Name')),
|
|
83
83
|
('scheduled_datetime', models.DateTimeField(blank=True, help_text='Leave blank to unschedule', null=True, verbose_name='Scheduled')),
|
|
84
84
|
('send_preview', models.BooleanField(default=False, verbose_name='Send Preview')),
|
|
85
85
|
('current_status', models.CharField(choices=[('NONE', 'Processing'), ('DRAFT', 'Draft'), ('SCHEDULED', 'Scheduled'), ('EXECUTING', 'Executing'), ('DONE', 'Sent'), ('ERROR', 'Error'), ('REMOVED', 'Removed')], default='DRAFT', max_length=20, verbose_name='Current Status')),
|
|
@@ -169,6 +169,11 @@ class Migration(migrations.Migration):
|
|
|
169
169
|
'verbose_name_plural': 'Custom Fields',
|
|
170
170
|
},
|
|
171
171
|
),
|
|
172
|
+
migrations.AddField(
|
|
173
|
+
model_name='contact',
|
|
174
|
+
name='custom_field_set',
|
|
175
|
+
field=models.ManyToManyField(through='django_ctct.ContactCustomField', to='django_ctct.customfield'),
|
|
176
|
+
),
|
|
172
177
|
migrations.AddField(
|
|
173
178
|
model_name='contact',
|
|
174
179
|
name='list_memberships',
|
|
@@ -217,6 +222,18 @@ class Migration(migrations.Migration):
|
|
|
217
222
|
'verbose_name_plural': 'Email Campaign Activities',
|
|
218
223
|
},
|
|
219
224
|
),
|
|
225
|
+
migrations.AddConstraint(
|
|
226
|
+
model_name='contactstreetaddress',
|
|
227
|
+
constraint=models.UniqueConstraint(fields=('contact', 'kind'), name='django_ctct_unique_street_address'),
|
|
228
|
+
),
|
|
229
|
+
migrations.AddConstraint(
|
|
230
|
+
model_name='contactphonenumber',
|
|
231
|
+
constraint=models.UniqueConstraint(fields=('contact', 'kind'), name='django_ctct_unique_phone_number'),
|
|
232
|
+
),
|
|
233
|
+
migrations.AddConstraint(
|
|
234
|
+
model_name='contactcustomfield',
|
|
235
|
+
constraint=models.UniqueConstraint(fields=('contact', 'custom_field'), name='django_ctct_unique_custom_field'),
|
|
236
|
+
),
|
|
220
237
|
migrations.AddConstraint(
|
|
221
238
|
model_name='campaignactivity',
|
|
222
239
|
constraint=models.UniqueConstraint(fields=('campaign', 'role'), name='django_ctct_unique_campaign_activity'),
|
|
@@ -166,14 +166,24 @@ class Token(CreatedAtMixin, EndpointMixin, Model):
|
|
|
166
166
|
return data
|
|
167
167
|
|
|
168
168
|
|
|
169
|
-
class
|
|
170
|
-
"""Common CTCT model methods and properties."""
|
|
171
|
-
|
|
169
|
+
class SerialModel(Model):
|
|
172
170
|
API_ID_LABEL: str
|
|
173
171
|
API_EDITABLE_FIELDS: tuple[str, ...] = tuple()
|
|
174
172
|
API_READONLY_FIELDS: tuple[str, ...] = (
|
|
175
173
|
'api_id',
|
|
176
174
|
)
|
|
175
|
+
|
|
176
|
+
# Must explicitly specify both
|
|
177
|
+
objects: ClassVar[models.Manager[Self]] = models.Manager()
|
|
178
|
+
serializer: ClassVar[Serializer[Self]] = Serializer()
|
|
179
|
+
|
|
180
|
+
class Meta:
|
|
181
|
+
abstract = True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class CTCTModel(SerialModel):
|
|
185
|
+
"""Common CTCT model methods and properties."""
|
|
186
|
+
|
|
177
187
|
API_MAX_LENGTH: dict[str, int] = {}
|
|
178
188
|
|
|
179
189
|
api_id = models.UUIDField(
|
|
@@ -183,10 +193,6 @@ class CTCTModel(Model):
|
|
|
183
193
|
verbose_name=_('API ID'),
|
|
184
194
|
)
|
|
185
195
|
|
|
186
|
-
# Must explicitly specify both
|
|
187
|
-
objects: ClassVar[models.Manager[Self]] = models.Manager()
|
|
188
|
-
serializer: ClassVar[Serializer[Self]] = Serializer()
|
|
189
|
-
|
|
190
196
|
class Meta:
|
|
191
197
|
abstract = True
|
|
192
198
|
|
|
@@ -289,6 +295,66 @@ class ContactList(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
289
295
|
return self.name
|
|
290
296
|
|
|
291
297
|
|
|
298
|
+
class ContactCustomField(SerialModel):
|
|
299
|
+
"""Django implementation of a CTCT Contact's CustomField.
|
|
300
|
+
|
|
301
|
+
Notes
|
|
302
|
+
-----
|
|
303
|
+
CTCT does not provide UUIDs for these, so we do not inherit from CTCTModel.
|
|
304
|
+
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
API_EDITABLE_FIELDS = (
|
|
308
|
+
'value',
|
|
309
|
+
)
|
|
310
|
+
API_READONLY_FIELDS = (
|
|
311
|
+
'custom_field_id',
|
|
312
|
+
)
|
|
313
|
+
API_MAX_LENGTH = {
|
|
314
|
+
'value': 255,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
contact = models.ForeignKey(
|
|
318
|
+
'Contact',
|
|
319
|
+
related_name='custom_fields',
|
|
320
|
+
on_delete=models.CASCADE,
|
|
321
|
+
verbose_name=_('Contact'),
|
|
322
|
+
)
|
|
323
|
+
custom_field = models.ForeignKey(
|
|
324
|
+
'CustomField',
|
|
325
|
+
related_name='contacts',
|
|
326
|
+
on_delete=models.CASCADE,
|
|
327
|
+
verbose_name=_('Field'),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
value = models.CharField(
|
|
331
|
+
max_length=API_MAX_LENGTH['value'],
|
|
332
|
+
verbose_name=_('Value'),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
class Meta:
|
|
336
|
+
verbose_name = _('Custom Field')
|
|
337
|
+
verbose_name_plural = _('Custom Fields')
|
|
338
|
+
|
|
339
|
+
constraints = [
|
|
340
|
+
models.UniqueConstraint(
|
|
341
|
+
fields=['contact', 'custom_field'],
|
|
342
|
+
name='django_ctct_unique_custom_field',
|
|
343
|
+
),
|
|
344
|
+
# models.CheckConstraint( # TODO: GH #8
|
|
345
|
+
# check=Q(contact__custom_fields__count__lte=ContactRemoteManager.API_MAX_NUM['custom_fields']),
|
|
346
|
+
# name='django_ctct_limit_custom_fields',
|
|
347
|
+
# ),
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
def __str__(self) -> str:
|
|
351
|
+
try:
|
|
352
|
+
s = f'[{self.custom_field.label}] {self.value}'
|
|
353
|
+
except CustomField.DoesNotExist:
|
|
354
|
+
s = super().__str__()
|
|
355
|
+
return s
|
|
356
|
+
|
|
357
|
+
|
|
292
358
|
class CustomField(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
293
359
|
"""Django implementation of a CTCT Contact's CustomField."""
|
|
294
360
|
|
|
@@ -303,13 +369,11 @@ class CustomField(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
303
369
|
)
|
|
304
370
|
API_READONLY_FIELDS = (
|
|
305
371
|
'api_id',
|
|
306
|
-
'name',
|
|
307
372
|
'created_at',
|
|
308
373
|
'updated_at',
|
|
309
374
|
)
|
|
310
375
|
API_MAX_LENGTH = {
|
|
311
376
|
'label': 50,
|
|
312
|
-
'name': 50,
|
|
313
377
|
}
|
|
314
378
|
|
|
315
379
|
TYPES = (
|
|
@@ -320,6 +384,7 @@ class CustomField(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
320
384
|
# API editable fields
|
|
321
385
|
label = models.CharField(
|
|
322
386
|
max_length=API_MAX_LENGTH['label'],
|
|
387
|
+
unique=True,
|
|
323
388
|
verbose_name=_('Label'),
|
|
324
389
|
help_text=_(
|
|
325
390
|
'The display name for the custom_field shown in the UI as free-form text'
|
|
@@ -466,6 +531,10 @@ class Contact(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
466
531
|
verbose_name=_('List Memberships'),
|
|
467
532
|
blank=True,
|
|
468
533
|
)
|
|
534
|
+
custom_field_set = models.ManyToManyField(
|
|
535
|
+
CustomField,
|
|
536
|
+
through=ContactCustomField, # c.f. django-stubs GH PR #1719
|
|
537
|
+
)
|
|
469
538
|
|
|
470
539
|
permission_to_send = models.CharField(
|
|
471
540
|
max_length=20,
|
|
@@ -676,16 +745,16 @@ class ContactPhoneNumber(CreatedAtMixin, UpdatedAtMixin, CTCTModel):
|
|
|
676
745
|
verbose_name = _('Phone Number')
|
|
677
746
|
verbose_name_plural = _('Phone Numbers')
|
|
678
747
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
748
|
+
constraints = [
|
|
749
|
+
models.UniqueConstraint(
|
|
750
|
+
fields=['contact', 'kind'],
|
|
751
|
+
name='django_ctct_unique_phone_number',
|
|
752
|
+
),
|
|
753
|
+
# models.CheckConstraint( # TODO: GH #8
|
|
754
|
+
# check=Q(contact__phone_numbers__count__lte=ContactRemoteManager.API_MAX_NUM['phone_numbers'])
|
|
755
|
+
# name='django_ctct_limit_phone_numbers',
|
|
756
|
+
# ),
|
|
757
|
+
]
|
|
689
758
|
|
|
690
759
|
def __str__(self) -> str:
|
|
691
760
|
return f'[{self.get_kind_display()}] {self.phone_number}'
|
|
@@ -777,16 +846,16 @@ class ContactStreetAddress(CreatedAtMixin, UpdatedAtMixin, CTCTModel):
|
|
|
777
846
|
verbose_name = _('Street Address')
|
|
778
847
|
verbose_name_plural = _('Street Addresses')
|
|
779
848
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
849
|
+
constraints = [
|
|
850
|
+
models.UniqueConstraint(
|
|
851
|
+
fields=['contact', 'kind'],
|
|
852
|
+
name='django_ctct_unique_street_address',
|
|
853
|
+
),
|
|
854
|
+
# models.CheckConstraint( # TODO: GH #8
|
|
855
|
+
# check=Q(contact__street_addresses__count__lte=ContactRemoteManager.API_MAX_NUM['street_addresses']),
|
|
856
|
+
# name='django_ctct_limit_street_addresses',
|
|
857
|
+
# ),
|
|
858
|
+
]
|
|
790
859
|
|
|
791
860
|
def __str__(self) -> str:
|
|
792
861
|
field_names = ['street', 'city', 'state']
|
|
@@ -816,65 +885,6 @@ class ContactStreetAddress(CreatedAtMixin, UpdatedAtMixin, CTCTModel):
|
|
|
816
885
|
return cls.clean_remote_string('country', data)
|
|
817
886
|
|
|
818
887
|
|
|
819
|
-
# TODO: GH #14
|
|
820
|
-
class ContactCustomField(models.Model):
|
|
821
|
-
"""Django implementation of a CTCT Contact's CustomField.
|
|
822
|
-
|
|
823
|
-
Notes
|
|
824
|
-
-----
|
|
825
|
-
CTCT does not provide UUIDs for these, so we do not inherit from CTCTModel.
|
|
826
|
-
|
|
827
|
-
"""
|
|
828
|
-
|
|
829
|
-
API_EDITABLE_FIELDS = (
|
|
830
|
-
'custom_field_id',
|
|
831
|
-
'value',
|
|
832
|
-
)
|
|
833
|
-
API_MAX_LENGTH = {
|
|
834
|
-
'value': 255,
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
contact = models.ForeignKey(
|
|
838
|
-
Contact,
|
|
839
|
-
on_delete=models.CASCADE,
|
|
840
|
-
related_name='custom_fields',
|
|
841
|
-
verbose_name=_('Contact'),
|
|
842
|
-
)
|
|
843
|
-
custom_field = models.ForeignKey(
|
|
844
|
-
CustomField,
|
|
845
|
-
on_delete=models.CASCADE,
|
|
846
|
-
related_name='contacts',
|
|
847
|
-
verbose_name=_('Field'),
|
|
848
|
-
)
|
|
849
|
-
|
|
850
|
-
value = models.CharField(
|
|
851
|
-
max_length=API_MAX_LENGTH['value'],
|
|
852
|
-
verbose_name=_('Value'),
|
|
853
|
-
)
|
|
854
|
-
|
|
855
|
-
class Meta:
|
|
856
|
-
verbose_name = _('Custom Field')
|
|
857
|
-
verbose_name_plural = _('Custom Fields')
|
|
858
|
-
|
|
859
|
-
# constraints = [
|
|
860
|
-
# models.UniqueConstraint( # TODO: GH #7
|
|
861
|
-
# fields=['contact', 'custom_field'],
|
|
862
|
-
# name='django_ctct_unique_custom_field',
|
|
863
|
-
# ),
|
|
864
|
-
# models.CheckConstraint( # TODO: GH #8
|
|
865
|
-
# check=Q(contact__custom_fields__count__lte=ContactRemoteManager.API_MAX_NUM['custom_fields']),
|
|
866
|
-
# name='django_ctct_limit_custom_fields',
|
|
867
|
-
# ),
|
|
868
|
-
# ]
|
|
869
|
-
|
|
870
|
-
def __str__(self) -> str:
|
|
871
|
-
try:
|
|
872
|
-
s = f'[{self.custom_field.label}] {self.value}'
|
|
873
|
-
except CustomField.DoesNotExist:
|
|
874
|
-
s = super().__str__()
|
|
875
|
-
return s
|
|
876
|
-
|
|
877
|
-
|
|
878
888
|
class EmailCampaign(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
879
889
|
"""Django implementation of a CTCT EmailCampaign."""
|
|
880
890
|
|
|
@@ -913,7 +923,7 @@ class EmailCampaign(CreatedAtMixin, UpdatedAtMixin, CTCTEndpointModel):
|
|
|
913
923
|
# API editable fields
|
|
914
924
|
name = models.CharField(
|
|
915
925
|
max_length=API_MAX_LENGTH['name'],
|
|
916
|
-
|
|
926
|
+
unique=True,
|
|
917
927
|
verbose_name=_('Name'),
|
|
918
928
|
)
|
|
919
929
|
scheduled_datetime = models.DateTimeField(
|
|
@@ -1306,12 +1316,18 @@ class CampaignSummary(CTCTEndpointModel):
|
|
|
1306
1316
|
|
|
1307
1317
|
|
|
1308
1318
|
def is_ctct(
|
|
1309
|
-
val: Type[BaseModel] | Literal['self']
|
|
1319
|
+
val: Type[BaseModel] | Literal['self'] | None
|
|
1310
1320
|
) -> TypeGuard[Type[CTCTModel]]:
|
|
1311
1321
|
return isinstance(val, type) and issubclass(val, CTCTModel)
|
|
1312
1322
|
|
|
1313
1323
|
|
|
1314
1324
|
def is_model(
|
|
1315
|
-
val: Type[BaseModel] | Literal['self']
|
|
1325
|
+
val: Type[BaseModel] | Literal['self'] | None
|
|
1316
1326
|
) -> TypeGuard[Type[Model]]:
|
|
1317
1327
|
return isinstance(val, type) and issubclass(val, Model)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def is_serial(
|
|
1331
|
+
val: Type[BaseModel] | Literal['self'] | None
|
|
1332
|
+
) -> TypeGuard[Type[SerialModel]]:
|
|
1333
|
+
return isinstance(val, type) and issubclass(val, SerialModel)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "django-ctct"
|
|
3
|
-
version = "0.0.1.
|
|
3
|
+
version = "0.0.1.dev5"
|
|
4
4
|
description = "A Django interface for the Constant Contact API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Geoffrey Eisenbarth",email = "geoffrey.eisenbarth@gmail.com"}
|
|
@@ -14,6 +14,7 @@ dependencies = [
|
|
|
14
14
|
"ratelimit (>=2.2.1,<3.0.0)",
|
|
15
15
|
"tqdm (>=4.67.1,<5.0.0)",
|
|
16
16
|
"pyjwt[crypto] (>=2.10.1,<3.0.0)",
|
|
17
|
+
"mypy (>=1.19.0,<2.0.0)",
|
|
17
18
|
]
|
|
18
19
|
|
|
19
20
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|