django-ctct 0.0.1.dev2__tar.gz → 0.0.1.dev3__tar.gz

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