wagtail-newsletter-django-backend 0.5.10__tar.gz → 0.6.0__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 (29) hide show
  1. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/PKG-INFO +1 -1
  2. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/__init__.py +1 -1
  3. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/admin_viewsets.py +2 -2
  4. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/campaign_backend.py +30 -36
  5. wagtail_newsletter_django_backend-0.6.0/src/wagtail_newsletter_django_backend/migrations/0004_remove_audience_from_email_address_and_more.py +39 -0
  6. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/models.py +37 -64
  7. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/LICENSE +0 -0
  8. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/README.md +0 -0
  9. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/pyproject.toml +0 -0
  10. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/admin.py +0 -0
  11. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/apps.py +0 -0
  12. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/forms.py +0 -0
  13. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/management/__init__.py +0 -0
  14. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/management/commands/__init__.py +0 -0
  15. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/management/commands/send_scheduled_campaigns.py +0 -0
  16. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/migrations/0001_initial.py +0 -0
  17. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/migrations/0002_alter_audience_name_alter_audiencesegment_name_and_more.py +0 -0
  18. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/migrations/0003_alter_campaign_audience_segment.py +0 -0
  19. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/migrations/__init__.py +0 -0
  20. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/manage.html +0 -0
  21. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/manage_tag.html +0 -0
  22. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/unsubscribe.html +0 -0
  23. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/unsubscribe_tag.html +0 -0
  24. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/templatetags/__init__.py +0 -0
  25. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/templatetags/wagtail_newsletter_django_backend_tags.py +0 -0
  26. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/tests.py +0 -0
  27. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/urls.py +0 -0
  28. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/views.py +0 -0
  29. {wagtail_newsletter_django_backend-0.5.10 → wagtail_newsletter_django_backend-0.6.0}/src/wagtail_newsletter_django_backend/wagtail_hooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wagtail-newsletter-django-backend
3
- Version: 0.5.10
3
+ Version: 0.6.0
4
4
  Summary: An internal Django backend to wagtail-newsletter.
5
5
  Author-email: "Taylor C. Richberger" <taylor@axfive.net>
6
6
  Maintainer-email: "Taylor C. Richberger" <taylor@axfive.net>
@@ -3,4 +3,4 @@
3
3
  This is a simple, basic internal backend for wagtail-newsletter, allowing you to manage your mailing lists internally and send them out through Django's standard email capabilities.
4
4
  '''
5
5
 
6
- __version__ = '0.5.10'
6
+ __version__ = '0.6.0'
@@ -5,7 +5,7 @@ from .models import Audience, AudienceSegment, Subscriber, Campaign
5
5
  @final
6
6
  class AudienceViewSet(ModelViewSet):
7
7
  model = Audience
8
- form_fields = ['site', 'name', 'description', 'smtp_user', 'smtp_password', 'from_email_address', 'from_email_name']
8
+ form_fields = ['site', 'name', 'description']
9
9
  list_display = ['site', 'name', 'description'] # pyright: ignore[reportAssignmentType]
10
10
  icon = 'group'
11
11
  add_to_admin_menu = False
@@ -35,7 +35,7 @@ class SubscriberViewSet(ModelViewSet):
35
35
  @final
36
36
  class CampaignViewSet(ModelViewSet):
37
37
  model = Campaign
38
- form_fields = ['subject', 'audience_segment', 'send_at', 'sent_at', 'html']
38
+ form_fields = ['subject', 'audience_segment', 'send_at', 'sent_at', 'html', 'from_email_address', 'from_email_name']
39
39
  list_display = ['subject', 'audience_segment'] # pyright: ignore[reportAssignmentType]
40
40
  icon = 'mail'
41
41
  add_to_admin_menu = False
@@ -1,3 +1,4 @@
1
+ from collections.abc import Iterable
1
2
  from datetime import datetime
2
3
  from typing import Any, cast, override
3
4
  from django.db import transaction
@@ -8,11 +9,12 @@ from html2text import HTML2Text
8
9
  from django.core.mail import get_connection
9
10
  from django.core.mail.message import EmailMessage, EmailMultiAlternatives
10
11
  from wagtail_newsletter import campaign_backends as wncb, audiences as wna, models as wnm
12
+ from wagtail.models import Site
11
13
  import re
12
14
  from . import models
13
15
 
14
- _unsubscribe_re = re.compile(r'\[\[unsubscribe\]\]|\\[\\[unsubscribe\\]\\]')
15
- _manage_re = re.compile(r'\[\[manage\]\]|\\[\\[manage\\]\\]')
16
+ _unsubscribe_re = re.compile(r'\[\[unsubscribe\]\]')
17
+ _manage_re = re.compile(r'\[\[manage\]\]')
16
18
 
17
19
  h2t = HTML2Text()
18
20
  class CampaignBackend(wncb.CampaignBackend):
@@ -27,7 +29,7 @@ class CampaignBackend(wncb.CampaignBackend):
27
29
  name=audience.name,
28
30
  member_count=audience.subscriber_set.filter(verified=True).count(),
29
31
  )
30
- for audience in models.Audience.objects.all()
32
+ for audience in cast(Iterable[models.Audience], models.Audience.objects.all())
31
33
  ]
32
34
 
33
35
  @override
@@ -35,7 +37,7 @@ class CampaignBackend(wncb.CampaignBackend):
35
37
  self,
36
38
  audience_id: str
37
39
  ) -> "list[wna.AudienceSegment]":
38
- audience = models.Audience.objects.get(id=int(audience_id))
40
+ audience = cast(models.Audience, models.Audience.objects.get(id=int(audience_id)))
39
41
  return [
40
42
  wna.AudienceSegment(
41
43
  id=f'{audience_id}/{audience_segment.id}',
@@ -43,7 +45,7 @@ class CampaignBackend(wncb.CampaignBackend):
43
45
  name=audience_segment.name,
44
46
  member_count=audience_segment.subscribers.filter(verified=True).count(),
45
47
  )
46
- for audience_segment in audience.audience_segment_set.all()
48
+ for audience_segment in cast(Iterable[models.AudienceSegment], audience.audience_segment_set.all())
47
49
  ]
48
50
 
49
51
  @override
@@ -60,33 +62,39 @@ class CampaignBackend(wncb.CampaignBackend):
60
62
  with transaction.atomic():
61
63
  audience_segment: models.AudienceSegment | None = None
62
64
  if recipients is not None and recipients.segment:
63
- audience_segment = models.AudienceSegment.objects.get(id=int(recipients.segment.split('/')[-1]))
65
+ audience_segment = cast(models.AudienceSegment, models.AudienceSegment.objects.get(id=int(recipients.segment.split('/')[-1])))
64
66
 
65
67
  campaign: models.Campaign | None
66
68
  if campaign_id:
67
69
  int_campaign_id = int(campaign_id)
68
- campaign = models.Campaign.objects.filter(id=int_campaign_id).first()
70
+ campaign = cast(models.Campaign | None, models.Campaign.objects.filter(id=int_campaign_id).first())
69
71
  if campaign is None:
70
72
  # No campaign exists with that ID anymore; recreate it.
71
- campaign = models.Campaign.objects.create(
73
+ campaign = cast(models.Campaign, models.Campaign.objects.create(
72
74
  id=int_campaign_id,
73
75
  subject=subject,
74
76
  html=html.strip(),
75
77
  audience_segment=audience_segment,
76
- )
78
+ from_email_address=reply_to,
79
+ from_email_name=from_name,
80
+ ))
77
81
  else:
78
82
  campaign.subject = subject
79
83
  campaign.html = html.strip()
84
+ campaign.from_email_address = reply_to
85
+ campaign.from_email_name = from_name
80
86
  if audience_segment is not None:
81
87
  campaign.audience_segment = audience_segment
82
88
  campaign.full_clean()
83
89
  campaign.save()
84
90
  else:
85
- campaign = models.Campaign.objects.create(
91
+ campaign = cast(models.Campaign, models.Campaign.objects.create(
86
92
  subject=subject,
87
93
  html=html.strip(),
88
94
  audience_segment=audience_segment,
89
- )
95
+ from_email_address=reply_to,
96
+ from_email_name=from_name,
97
+ ))
90
98
  return str(campaign.id)
91
99
 
92
100
  @override
@@ -95,17 +103,9 @@ class CampaignBackend(wncb.CampaignBackend):
95
103
 
96
104
  @override
97
105
  def send_test_email(self, *, campaign_id: str, email: str) -> None:
98
- campaign = models.Campaign.objects.get(id=int(campaign_id))
99
- audience: models.Audience
100
- if campaign.audience_segment is not None:
101
- audience = campaign.audience_segment.audience
102
- else:
103
- # Build empty audience. Will have no subscribers, and no smtp data.
104
- # This will not work without default data in the config.
105
- audience = models.Audience()
106
-
107
- from_address = audience.from_address()
108
- user, password = audience.smtp_auth()
106
+ campaign = cast(models.Campaign, models.Campaign.objects.get(id=int(campaign_id)))
107
+
108
+ from_address = campaign.from_address()
109
109
 
110
110
  _ = send_mail(
111
111
  subject=campaign.subject,
@@ -113,21 +113,19 @@ class CampaignBackend(wncb.CampaignBackend):
113
113
  from_email=from_address,
114
114
  recipient_list=[email],
115
115
  html_message=campaign.html,
116
- auth_user=user,
117
- auth_password=password,
118
116
  )
119
117
 
120
118
  @override
121
119
  def send_campaign(self, campaign_id: str) -> None:
122
120
  with transaction.atomic():
123
- campaign = models.Campaign.objects.get(id=int(campaign_id))
121
+ campaign = cast(models.Campaign, models.Campaign.objects.get(id=int(campaign_id)))
124
122
  if campaign.audience_segment is None:
125
123
  raise RuntimeError("Campaign can't be sent without an audience segment")
126
124
 
127
- audience = campaign.audience_segment.audience
125
+ audience = cast(models.Audience, campaign.audience_segment.audience)
128
126
 
129
- site = audience.site
130
- hostname = site.hostname
127
+ site = cast(Site, audience.site)
128
+ hostname = cast(str, site.hostname)
131
129
  match site.port:
132
130
  case 80:
133
131
  url_base = f'http://{hostname}'
@@ -136,13 +134,9 @@ class CampaignBackend(wncb.CampaignBackend):
136
134
  case port:
137
135
  url_base = f'http://{hostname}:{port}'
138
136
 
139
- from_address = audience.from_address()
140
- user, password = audience.smtp_auth()
137
+ from_address = campaign.from_address()
141
138
 
142
- connection: Any = get_connection( # pyright: ignore[reportAny]
143
- username=user,
144
- password=password,
145
- )
139
+ connection: Any = get_connection()
146
140
 
147
141
  def subscriber_message(subscriber: models.Subscriber) -> EmailMessage:
148
142
  unsubscribe_url = url_base + reverse('wagtail_newsletter_django_backend:newsletter_subscriber_unsubscribe', kwargs={'key': subscriber.key})
@@ -187,7 +181,7 @@ class CampaignBackend(wncb.CampaignBackend):
187
181
  @override
188
182
  def schedule_campaign(self, campaign_id: str, schedule_time: datetime) -> None:
189
183
  with transaction.atomic():
190
- campaign = models.Campaign.objects.get(id=int(campaign_id))
184
+ campaign = cast(models.Campaign, models.Campaign.objects.get(id=int(campaign_id)))
191
185
  campaign.send_at = schedule_time
192
186
  campaign.full_clean()
193
187
  campaign.save()
@@ -195,7 +189,7 @@ class CampaignBackend(wncb.CampaignBackend):
195
189
  @override
196
190
  def unschedule_campaign(self, campaign_id: str) -> None:
197
191
  with transaction.atomic():
198
- campaign = models.Campaign.objects.get(id=int(campaign_id))
192
+ campaign = cast(models.Campaign, models.Campaign.objects.get(id=int(campaign_id)))
199
193
  campaign.send_at = None
200
194
  campaign.full_clean()
201
195
  campaign.save()
@@ -0,0 +1,39 @@
1
+ # Generated by Django 5.2.5 on 2026-02-04 04:16
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('wagtail_newsletter_django_backend', '0003_alter_campaign_audience_segment'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name='audience',
15
+ name='from_email_address',
16
+ ),
17
+ migrations.RemoveField(
18
+ model_name='audience',
19
+ name='from_email_name',
20
+ ),
21
+ migrations.RemoveField(
22
+ model_name='audience',
23
+ name='smtp_password',
24
+ ),
25
+ migrations.RemoveField(
26
+ model_name='audience',
27
+ name='smtp_user',
28
+ ),
29
+ migrations.AddField(
30
+ model_name='campaign',
31
+ name='from_email_address',
32
+ field=models.EmailField(blank=True, default=None, help_text='The From address for the email. If not present, this will default to WAGTAIL_NEWSLETTER_REPLY_TO.', max_length=254, null=True),
33
+ ),
34
+ migrations.AddField(
35
+ model_name='campaign',
36
+ name='from_email_name',
37
+ field=models.CharField(blank=True, default=None, help_text='The display name portion of the from email. If not present, this will default to WAGTAIL_NEWSLETTER_FROM_NAME.', max_length=256, null=True),
38
+ ),
39
+ ]
@@ -15,16 +15,12 @@ import string
15
15
  from wagtail_newsletter import campaign_backends as wncb
16
16
 
17
17
  if TYPE_CHECKING:
18
- from django.db.models.manager import RelatedManager
18
+ from django.db.models.manager import RelatedManager, Manager
19
19
 
20
20
  @lru_cache
21
- def _default_from_parts() -> tuple[str | None, str | None]:
22
- email = cast(str | None, settings.DEFAULT_FROM_EMAIL)
23
- name: str | None = None
24
- if email:
25
- name, email = parseaddr(email)
26
- email = email or None
27
- name = name or None
21
+ def _default_from_parts() -> tuple[str | None, str]:
22
+ name = cast(str | None, getattr(settings, 'WAGTAIL_NEWSLETTER_FROM_NAME', None))
23
+ email = cast(str, settings.WAGTAIL_NEWSLETTER_REPLY_TO)
28
24
  return name, email
29
25
 
30
26
  class SaveDict(TypedDict):
@@ -56,33 +52,6 @@ class Audience(models.Model):
56
52
  site: 'models.ForeignKey[Site]' = models.ForeignKey(Site, blank=False, null=False, on_delete=models.CASCADE)
57
53
  name: 'models.CharField[str]' = models.CharField(max_length=64, blank=False, null=False)
58
54
  description: 'models.TextField[str]' = models.TextField(blank=True, null=False)
59
- smtp_user: 'models.CharField[str | None]' = models.CharField(
60
- max_length=256,
61
- blank=True,
62
- null=True,
63
- default=None,
64
- help_text='The SMTP login user. If absent, this will not be supplied to send_mail, and EMAIL_HOST_USER will be used instead',
65
- )
66
- smtp_password: 'models.CharField[str | None]' = models.CharField(
67
- max_length=256,
68
- blank=True,
69
- null=True,
70
- default=None,
71
- help_text='The SMTP login password. If absent, this will not be supplied to send_mail, and EMAIL_HOST_PASSWORD will be used instead',
72
- )
73
- from_email_address: 'models.EmailField[str | None]' = models.EmailField(
74
- blank=True,
75
- null=True,
76
- default=None,
77
- help_text='The From address for the email. If not present, this will not be supplied to send_mail, and DEFAULT_FROM_EMAIL will be used instead',
78
- )
79
- from_email_name: 'models.CharField[str | None]' = models.CharField(
80
- max_length=256,
81
- blank=True,
82
- null=True,
83
- default=None,
84
- help_text='The display name portion of the from email. This will be wrapped around the from email address.',
85
- )
86
55
 
87
56
  created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
88
57
  updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
@@ -99,35 +68,6 @@ class Audience(models.Model):
99
68
  def __str__(self) -> str:
100
69
  return self.name
101
70
 
102
- _NULLABLE_FIELDS: Sequence[str] = ('smtp_user', 'smtp_password', 'from_email_address', 'from_email_name')
103
-
104
- @override
105
- def save(self, **kwargs: Unpack[SaveDict]) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
106
- patch_blanks(self, kwargs.get('update_fields'), Audience._NULLABLE_FIELDS)
107
- return super().save(**kwargs)
108
-
109
- def from_address(self) -> str:
110
- default_from_name, default_from_email = _default_from_parts()
111
- from_email = self.from_email_address or default_from_email
112
- if from_email is None:
113
- raise RuntimeError('from email address must be set')
114
- from_name = self.from_email_name or default_from_name
115
- if from_name:
116
- return formataddr((from_name, from_email))
117
- else:
118
- return from_email
119
-
120
- def smtp_auth(self) -> tuple[str, str]:
121
- smtp_user = self.smtp_user or cast(str | None, settings.EMAIL_HOST_USER)
122
- smtp_password = self.smtp_password or cast(str | None, settings.EMAIL_HOST_PASSWORD)
123
- if smtp_user is None:
124
- raise RuntimeError('smtp_user must be set')
125
-
126
- if smtp_password is None:
127
- raise RuntimeError('smtp_password must be set')
128
-
129
- return smtp_user, smtp_password
130
-
131
71
  class AudienceSegment(models.Model):
132
72
  pk: int
133
73
  id: int # pyright: ignore[reportUninitializedInstanceVariable]
@@ -245,6 +185,8 @@ class Subscription(models.Model):
245
185
  raise ValidationError('The audience_segment and subscriber must have the same audience')
246
186
 
247
187
  class Campaign(models.Model):
188
+ objects: 'Manager[Campaign]'
189
+
248
190
  pk: int
249
191
  id: int # pyright: ignore[reportUninitializedInstanceVariable]
250
192
 
@@ -254,9 +196,40 @@ class Campaign(models.Model):
254
196
  html: 'models.TextField[str]' = models.TextField(blank=False, null=False)
255
197
  audience_segment: 'models.ForeignKey[AudienceSegment | None]' = models.ForeignKey(AudienceSegment, blank=False, null=True, on_delete=models.SET_NULL)
256
198
 
199
+ from_email_address: 'models.EmailField[str | None]' = models.EmailField(
200
+ blank=True,
201
+ null=True,
202
+ default=None,
203
+ help_text='The From address for the email. If not present, this will default to WAGTAIL_NEWSLETTER_REPLY_TO.',
204
+ )
205
+
206
+ from_email_name: 'models.CharField[str | None]' = models.CharField(
207
+ max_length=256,
208
+ blank=True,
209
+ null=True,
210
+ default=None,
211
+ help_text='The display name portion of the from email. If not present, this will default to WAGTAIL_NEWSLETTER_FROM_NAME.',
212
+ )
213
+
257
214
  created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
258
215
  updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
259
216
 
217
+ _NULLABLE_FIELDS: Sequence[str] = ('from_email_address', 'from_email_name')
218
+
219
+ @override
220
+ def save(self, **kwargs: Unpack[SaveDict]) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
221
+ patch_blanks(self, kwargs.get('update_fields'), Campaign._NULLABLE_FIELDS)
222
+ return super().save(**kwargs)
223
+
224
+ def from_address(self) -> str:
225
+ default_from_name, default_from_email = _default_from_parts()
226
+ from_email = cast(str | None, self.from_email_address) or default_from_email
227
+ from_name = cast(str | None, self.from_email_name) or default_from_name
228
+ if from_name:
229
+ return formataddr((from_name, from_email))
230
+ else:
231
+ return from_email
232
+
260
233
  @property
261
234
  def is_sent(self) -> bool:
262
235
  return self.sent_at is not None