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.
Files changed (20) hide show
  1. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/PKG-INFO +3 -6
  2. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/README.md +1 -4
  3. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/admin.py +23 -32
  4. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/apps.py +0 -10
  5. django_ctct-0.0.1.dev6/django_ctct/management/commands/__init__.py +0 -0
  6. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/management/commands/import_ctct.py +7 -11
  7. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/managers.py +65 -66
  8. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/migrations/0001_initial.py +5 -4
  9. django_ctct-0.0.1.dev6/django_ctct/migrations/__init__.py +0 -0
  10. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/models.py +31 -22
  11. django_ctct-0.0.1.dev6/django_ctct/signals.py +41 -0
  12. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/utils.py +3 -1
  13. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/views.py +0 -1
  14. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/pyproject.toml +2 -2
  15. django_ctct-0.0.1.dev5/django_ctct/signals.py +0 -93
  16. django_ctct-0.0.1.dev5/django_ctct/vendor.py +0 -108
  17. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/LICENSE +0 -0
  18. {django_ctct-0.0.1.dev5 → django_ctct-0.0.1.dev6}/django_ctct/__init__.py +0 -0
  19. {django_ctct-0.0.1.dev5/django_ctct/migrations → django_ctct-0.0.1.dev6/django_ctct/management}/__init__.py +0 -0
  20. {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.dev5
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 (>=3.2.0,<6.0.0)
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 Authenticatin Token:**
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 Authenticatin Token:**
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, Optional, Callable, Iterable
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: Optional[Model] = None,
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: Optional[Model] = None,
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: Optional[Model] = None,
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: Optional[Model] = None,
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
- # ChangeView
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.remote_sync:
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.remote_sync:
140
+ if self.sync_admin:
147
141
  queryset.model.remote.bulk_delete(queryset)
148
- with mute_signals(signals.pre_delete):
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
- with mute_signals(signals.m2m_changed):
168
- # ManyToMany information is sent to CTCT in PUT call
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.remote_sync:
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: Optional[Contact] = None, # type: ignore[override]
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: Optional[Contact] = None,
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
- with mute_signals(signals.m2m_changed):
405
- # ManyToMany information is sent to CTCT in PUT call
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
- ) -> Optional[Iterable[tuple[str, str]]]:
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: Optional[Model] = None,
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: Optional[CampaignActivity] = None,
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: Optional[EmailCampaign] = None,
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.remote_sync:
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)
@@ -1,6 +1,6 @@
1
1
  from argparse import ArgumentParser
2
2
  from collections import defaultdict
3
- from typing import Type, TypeVar, Optional, Any, Collection, Literal, cast
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: Optional[Collection[str]] = ['api_id'],
74
- update_fields: Optional[Collection[str]] = None,
75
- silent: Optional[bool] = None,
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: Optional[str] = None
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, Optional, NoReturn, Union, cast,
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
- from django_ctct.models import Token
53
+ if not hasattr(self, 'session'):
54
+ from django_ctct.models import Token
54
55
 
55
- token = Token.remote.get()
56
- self.session = requests.Session()
57
- self.session.headers.update({
58
- 'Authorization': f"{token.token_type} {token.access_token}"
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: Optional[str | UUID] = None,
70
- endpoint: Optional[str] = None,
71
- endpoint_suffix: Optional[str] = None,
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
- self.session = requests.Session()
129
- self.session.auth = (settings.CTCT_PUBLIC_KEY, settings.CTCT_SECRET_KEY)
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: Optional[int] = None
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: Optional[int] = None,
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: Optional[int] = None,
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 (pk := obj.pk):
421
+ if not obj.pk:
415
422
  raise ValueError('Must create object locally first.')
416
423
 
417
- self.check_api_limit()
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
- with mute_signals(signals.post_save):
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.check_api_limit()
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: Optional[str] = None,
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.check_api_limit()
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 not (pk := obj.pk):
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.check_api_limit()
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
- with mute_signals(signals.post_save):
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: Optional[str] = None,
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.check_api_limit()
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.check_api_limit()
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: Optional[ContactList] = None,
597
- contact_lists: Optional[QuerySet[ContactList]] = None,
598
- contacts: Optional[QuerySet[Contact]] = None,
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.check_api_limit()
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 not obj.pk:
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.check_api_limit()
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
- with mute_signals(signals.post_save):
699
- obj.api_id = api_id
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 not (pk := obj.pk):
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.check_api_limit()
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
- with mute_signals(signals.post_save):
775
- obj.save()
776
- if activity.pk is None:
777
- activity.campaign = obj
778
- activity.save()
779
- else:
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 not (pk := obj.pk):
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.check_api_limit()
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
- with mute_signals(signals.post_save):
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: Optional[list[str]] = None,
873
- message: Optional[str] = None,
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.check_api_limit()
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.check_api_limit()
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-03 18:31
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='Django CTCT', max_length=100, verbose_name='From Name')),
209
- ('from_email', models.EmailField(default='django@ctct.com', max_length=80, verbose_name='From Email')),
210
- ('reply_to_email', models.EmailField(default='django@ctct.com', max_length=80, verbose_name='Reply-to Email')),
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')),
@@ -2,7 +2,7 @@ import datetime as dt
2
2
  import re
3
3
  from typing import (
4
4
  Type, TypeAlias, ClassVar, TypeGuard,
5
- Optional, Any, Literal,
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: Optional[str] = None
77
- API_ENDPOINT_BULK_LIMIT: Optional[int] = None
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: Optional[str] = None,
214
- ) -> Optional[str]:
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) -> Optional[dt.datetime]: # noqa: E501
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
- if s := ''.join(re.findall(numbers, s)):
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) -> Optional[dt.datetime]: # noqa: E501
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=settings.CTCT_FROM_NAME,
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=settings.CTCT_FROM_EMAIL,
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=getattr(settings, 'CTCT_REPLY_TO_EMAIL', settings.CTCT_FROM_EMAIL),
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=5, # CustomCode API v3
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) -> Optional[dict[str, str]]:
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) -> Optional[str]:
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) -> Optional[str]:
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) -> Optional[str]:
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) -> Optional[str]:
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] + 'Z'
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
 
@@ -9,7 +9,6 @@ def auth(request: HttpRequest) -> HttpResponse:
9
9
  """Facilitates OAuth2 authentication with CTCT."""
10
10
 
11
11
  if auth_code := request.GET.get('code'):
12
- Token.remote.connect()
13
12
  try:
14
13
  Token.remote.create(auth_code)
15
14
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-ctct"
3
- version = "0.0.1.dev5"
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 (>=3.2.0,<6.0.0)",
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