wagtail-newsletter-django-backend 0.0.2__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.
@@ -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.0.2'
6
+ __version__ = '0.2.0'
@@ -0,0 +1,58 @@
1
+ from typing import final
2
+ from wagtail.admin.viewsets.model import ModelViewSet, ModelViewSetGroup
3
+ from .models import Audience, AudienceSegment, Subscriber, Campaign
4
+
5
+ @final
6
+ class AudienceViewSet(ModelViewSet):
7
+ model = Audience
8
+ form_fields = ['site', 'name', 'description', 'smtp_user', 'smtp_password', 'from_email_address', 'from_email_name']
9
+ list_display = ['site', 'name', 'description'] # pyright: ignore[reportAssignmentType]
10
+ icon = 'group'
11
+ add_to_admin_menu = False
12
+ copy_view_enabled = True
13
+ inspect_view_enabled = True
14
+
15
+ @final
16
+ class AudienceSegmentViewSet(ModelViewSet):
17
+ model = AudienceSegment
18
+ form_fields = ['name', 'audience', 'description']
19
+ list_display = ['name', 'audience', 'description'] # pyright: ignore[reportAssignmentType]
20
+ icon = 'tag'
21
+ add_to_admin_menu = False
22
+ copy_view_enabled = True
23
+ inspect_view_enabled = True
24
+
25
+ @final
26
+ class SubscriberViewSet(ModelViewSet):
27
+ model = Subscriber
28
+ form_fields = ['email_address', 'email_name', 'audience', 'audience_segments']
29
+ list_display = ['email_address', 'email_name', 'audience'] # pyright: ignore[reportAssignmentType]
30
+ icon = 'user'
31
+ add_to_admin_menu = False
32
+ copy_view_enabled = True
33
+ inspect_view_enabled = True
34
+
35
+ @final
36
+ class CampaignViewSet(ModelViewSet):
37
+ model = Campaign
38
+ form_fields = ['subject', 'audience_segment', 'send_at', 'sent_at', 'html']
39
+ list_display = ['subject', 'audience_segment'] # pyright: ignore[reportAssignmentType]
40
+ icon = 'mail'
41
+ add_to_admin_menu = False
42
+ copy_view_enabled = True
43
+ inspect_view_enabled = True
44
+
45
+ @final
46
+ class MailingListGroup(ModelViewSetGroup):
47
+ items = (
48
+ AudienceViewSet('audiences'),
49
+ AudienceSegmentViewSet('audience_segments'),
50
+ SubscriberViewSet('subscribers'),
51
+ CampaignViewSet('campaign'),
52
+ )
53
+ menu_icon = 'mail'
54
+ menu_label = 'Mailing List' # pyright: ignore[reportAssignmentType]
55
+
56
+ mailing_list_group = MailingListGroup()
57
+
58
+ # Create your views here.
@@ -1,6 +1,8 @@
1
+ from typing import final
1
2
  from django.apps import AppConfig
2
3
 
3
-
4
+ @final
4
5
  class WagtailNewsletterDjangoBackendConfig(AppConfig):
5
6
  default_auto_field = 'django.db.models.BigAutoField'
6
7
  name = 'wagtail_newsletter_django_backend'
8
+ verbose_name = 'Wagtail Newsletter Django Backend'
@@ -0,0 +1,180 @@
1
+ from datetime import datetime
2
+ from typing import Any, cast, override
3
+ from django.db import transaction
4
+ from django.core.mail import send_mail
5
+ from django.urls import reverse
6
+ from django.utils import timezone
7
+ from html2text import HTML2Text
8
+ from django.core.mail import get_connection
9
+ from django.core.mail.message import EmailMessage, EmailMultiAlternatives
10
+ from wagtail_newsletter import campaign_backends as wncb, audiences as wna, models as wnm
11
+ import re
12
+ from . import models
13
+
14
+ _unsubscribe_re = re.compile(r'\[\[unsubscribe\]\]|\\[\\[unsubscribe\\]\\]')
15
+ _manage_re = re.compile(r'\[\[manage\]\]|\\[\\[manage\\]\\]')
16
+
17
+ h2t = HTML2Text()
18
+ class CampaignBackend(wncb.CampaignBackend):
19
+ name: str = 'Django Backend'
20
+
21
+ @override
22
+ def get_audiences(self) -> "list[wna.Audience]":
23
+ return [
24
+ wna.Audience(
25
+ id=str(audience.id),
26
+ pk=str(audience.pk),
27
+ name=audience.name,
28
+ member_count=audience.subscriber_set.count(),
29
+ )
30
+ for audience in models.Audience.objects.all()
31
+ ]
32
+
33
+ @override
34
+ def get_audience_segments(
35
+ self,
36
+ audience_id: str
37
+ ) -> "list[wna.AudienceSegment]":
38
+ audience = models.Audience.objects.get(id=int(audience_id))
39
+ return [
40
+ wna.AudienceSegment(
41
+ id=str(audience_segment.id),
42
+ pk=str(audience_segment.pk),
43
+ name=audience_segment.name,
44
+ member_count=audience_segment.subscribers.count(),
45
+ )
46
+ for audience_segment in audience.audience_segment_set.all()
47
+ ]
48
+
49
+ @override
50
+ def save_campaign(
51
+ self,
52
+ *,
53
+ campaign_id: str | None = None,
54
+ recipients: "wnm.NewsletterRecipientsBase | None",
55
+ subject: str,
56
+ html: str,
57
+ ) -> str:
58
+ audience_segment: models.AudienceSegment | None = None
59
+ if recipients is not None and recipients.segment:
60
+ audience_segment = models.AudienceSegment.objects.get(id=int(recipients.segment))
61
+
62
+ with transaction.atomic():
63
+ campaign: models.Campaign
64
+ if campaign_id:
65
+ campaign = models.Campaign.objects.get(id=int(campaign_id))
66
+ campaign.subject = subject
67
+ campaign.html = html.strip()
68
+ if audience_segment is not None:
69
+ campaign.audience_segment = audience_segment
70
+ campaign.full_clean()
71
+ campaign.save()
72
+ else:
73
+ campaign = models.Campaign.objects.create(
74
+ subject=subject,
75
+ html=html.strip(),
76
+ audience_segment=audience_segment,
77
+ )
78
+ return str(campaign.id)
79
+
80
+ @override
81
+ def get_campaign(self, campaign_id: str) -> wncb.Campaign | None:
82
+ return cast('wncb.Campaign | None', models.Campaign.objects.filter(id=int(campaign_id)).first())
83
+
84
+ @override
85
+ def send_test_email(self, *, campaign_id: str, email: str) -> None:
86
+ campaign = models.Campaign.objects.get(id=int(campaign_id))
87
+ audience = campaign.audience_segment.audience
88
+
89
+ from_address = audience.from_address()
90
+ user, password = audience.smtp_auth()
91
+
92
+ _ = send_mail(
93
+ subject=campaign.subject,
94
+ message=h2t.handle(campaign.html),
95
+ from_email=from_address,
96
+ recipient_list=[email],
97
+ html_message=campaign.html,
98
+ auth_user=user,
99
+ auth_password=password,
100
+ )
101
+
102
+ @override
103
+ def send_campaign(self, campaign_id: str) -> None:
104
+ with transaction.atomic():
105
+ campaign = models.Campaign.objects.get(id=int(campaign_id))
106
+ audience = campaign.audience_segment.audience
107
+
108
+ site = audience.site
109
+ hostname = site.hostname
110
+ match site.port:
111
+ case 80:
112
+ url_base = f'http://{hostname}'
113
+ case 443:
114
+ url_base = f'https://{hostname}'
115
+ case port:
116
+ url_base = f'http://{hostname}:{port}'
117
+
118
+ from_address = audience.from_address()
119
+ user, password = audience.smtp_auth()
120
+
121
+ connection: Any = get_connection( # pyright: ignore[reportAny]
122
+ username=user,
123
+ password=password,
124
+ )
125
+
126
+ def subscriber_message(subscriber: models.Subscriber) -> EmailMessage:
127
+ unsubscribe_url = url_base + reverse('newsletter_subscriber_unsubscribe', kwargs={'key': subscriber.key})
128
+ manage_url = url_base + reverse('newsletter_subscriber_manage', kwargs={'key': subscriber.key})
129
+ html = _manage_re.sub(manage_url, _unsubscribe_re.sub(unsubscribe_url, campaign.html))
130
+ message = h2t.handle(html)
131
+ mail = EmailMultiAlternatives(
132
+ subject=campaign.subject,
133
+ body=message,
134
+ from_email=from_address,
135
+ to=[subscriber.email()],
136
+ connection=connection, # pyright: ignore[reportAny]
137
+ headers={
138
+ "List-Unsubscribe": unsubscribe_url,
139
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
140
+ },
141
+ )
142
+ mail.attach_alternative(html, "text/html")
143
+ return mail
144
+
145
+ messages = [
146
+ subscriber_message(subscriber)
147
+ for subscriber in campaign.audience_segment.subscribers.filter(verified=True)
148
+ ]
149
+
150
+ _ = connection.send_messages(messages) # pyright: ignore[reportAny]
151
+ campaign.sent_at = timezone.now()
152
+ campaign.save()
153
+ _ = models.SentMessage.objects.bulk_create([
154
+ models.SentMessage(
155
+ campaign=campaign,
156
+ subscriber=subscriber,
157
+ subscriber_hash=subscriber.pii_hash(),
158
+ )
159
+ for subscriber in campaign.audience_segment.subscribers.filter(verified=True)
160
+ ])
161
+
162
+ def send_scheduled_campaigns(self) -> None:
163
+ for campaign in models.Campaign.objects.filter(sent_at=None, send_at__gte=timezone.now()):
164
+ self.send_campaign(str(campaign.id))
165
+
166
+ @override
167
+ def schedule_campaign(self, campaign_id: str, schedule_time: datetime) -> None:
168
+ with transaction.atomic():
169
+ campaign = models.Campaign.objects.get(id=int(campaign_id))
170
+ campaign.send_at = schedule_time
171
+ campaign.full_clean()
172
+ campaign.save()
173
+
174
+ @override
175
+ def unschedule_campaign(self, campaign_id: str) -> None:
176
+ with transaction.atomic():
177
+ campaign = models.Campaign.objects.get(id=int(campaign_id))
178
+ campaign.send_at = None
179
+ campaign.full_clean()
180
+ campaign.save()
@@ -0,0 +1,97 @@
1
+ from collections.abc import Collection, Iterable
2
+ from typing import cast, override
3
+ from django import forms
4
+ from . import models
5
+
6
+ class ManageForm(forms.ModelForm):
7
+ '''
8
+ Form for managing subscriptions.
9
+
10
+ This must be constructed with the relevant instance. If the instance is
11
+ changed after the fact, the segments will not be correct.
12
+ '''
13
+ instance: models.Subscriber
14
+ audience_segments: forms.ModelMultipleChoiceField = forms.ModelMultipleChoiceField(
15
+ label='Categories',
16
+ queryset=None, # pyright: ignore[reportArgumentType]
17
+ widget=forms.CheckboxSelectMultiple(),
18
+ required=False,
19
+ )
20
+
21
+ class Meta:
22
+ model: type[models.Subscriber] = models.Subscriber
23
+ fields: Collection[str] = ('email_name', 'audience_segments')
24
+
25
+ def __init__(self, *args, **kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
26
+ super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType]
27
+
28
+ # Filter audience_segments to only show those belonging to this subscriber's audience
29
+ if self.instance:
30
+ cast(
31
+ forms.ModelMultipleChoiceField,
32
+ self.fields['audience_segments'],
33
+ ).queryset = models.AudienceSegment.objects.filter(
34
+ audience=self.instance.audience
35
+ )
36
+
37
+ @override
38
+ def save(self, commit: bool = True) -> models.Subscriber:
39
+ # Save the subscriber instance first
40
+ subscriber = cast(models.Subscriber, super().save(commit=commit))
41
+
42
+ if commit:
43
+ # Clear existing subscriptions and create new ones based on selected segments
44
+ _ = models.Subscription.objects.filter(subscriber=subscriber).delete()
45
+
46
+ for segment in cast(Iterable[models.AudienceSegment], self.cleaned_data['audience_segments']):
47
+ _ = models.Subscription.objects.create(
48
+ subscriber=subscriber,
49
+ audience_segment=segment
50
+ )
51
+
52
+ return subscriber
53
+
54
+ class SignupForm(forms.ModelForm):
55
+ '''
56
+ Form for creating subscriptions.
57
+ '''
58
+ instance: models.Subscriber
59
+ audience: forms.ModelChoiceField = forms.ModelChoiceField(
60
+ queryset=models.Audience.objects.all(),
61
+ widget=forms.HiddenInput(),
62
+ )
63
+ audience_segments: forms.ModelMultipleChoiceField = forms.ModelMultipleChoiceField(
64
+ label='Categories',
65
+ queryset=None, # pyright: ignore[reportArgumentType]
66
+ widget=forms.CheckboxSelectMultiple(),
67
+ required=False,
68
+ )
69
+
70
+ class Meta:
71
+ model: type[models.Subscriber] = models.Subscriber
72
+ fields: Collection[str] = ('email_name', 'email_address', 'audience', 'audience_segments')
73
+
74
+ def __init__(self, *args, audience: models.Audience, **kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
75
+ super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType]
76
+ # Filter audience_segments to only show those belonging to this subscriber's audience
77
+ cast(
78
+ forms.ModelMultipleChoiceField,
79
+ self.fields['audience_segments'],
80
+ ).queryset = audience.audience_segment_set.all()
81
+
82
+ @override
83
+ def save(self, commit: bool = True) -> models.Subscriber:
84
+ # Save the subscriber instance first
85
+ subscriber = cast(models.Subscriber, super().save(commit=commit))
86
+
87
+ if commit:
88
+ # Clear existing subscriptions and create new ones based on selected segments
89
+ _ = models.Subscription.objects.filter(subscriber=subscriber).delete()
90
+
91
+ for segment in cast(Iterable[models.AudienceSegment], self.cleaned_data['audience_segments']):
92
+ _ = models.Subscription.objects.create(
93
+ subscriber=subscriber,
94
+ audience_segment=segment
95
+ )
96
+
97
+ return subscriber
@@ -0,0 +1,8 @@
1
+ from django.core.management.base import BaseCommand, CommandError
2
+ from wagtail_newsletter_django_backend.campaign_backend import CampaignBackend
3
+
4
+ class Command(BaseCommand):
5
+ help = "Sends scheduled campaigns that are due"
6
+
7
+ def handle(self, *args, **options):
8
+ CampaignBackend().send_scheduled_campaigns()
@@ -0,0 +1,291 @@
1
+ # Generated by Django 5.2.5 on 2025-09-01 20:37
2
+
3
+ import django.db.models.deletion
4
+ import wagtail_newsletter_django_backend.models
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ("wagtailcore", "0095_groupsitepermission"),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name="Audience",
19
+ fields=[
20
+ (
21
+ "id",
22
+ models.BigAutoField(
23
+ auto_created=True,
24
+ primary_key=True,
25
+ serialize=False,
26
+ verbose_name="ID",
27
+ ),
28
+ ),
29
+ ("name", models.SlugField()),
30
+ ("description", models.TextField(blank=True)),
31
+ (
32
+ "smtp_user",
33
+ models.CharField(
34
+ blank=True,
35
+ default=None,
36
+ help_text="The SMTP login user. If absent, this will not be supplied to send_mail, and EMAIL_HOST_USER will be used instead",
37
+ max_length=256,
38
+ null=True,
39
+ ),
40
+ ),
41
+ (
42
+ "smtp_password",
43
+ models.CharField(
44
+ blank=True,
45
+ default=None,
46
+ help_text="The SMTP login password. If absent, this will not be supplied to send_mail, and EMAIL_HOST_PASSWORD will be used instead",
47
+ max_length=256,
48
+ null=True,
49
+ ),
50
+ ),
51
+ (
52
+ "from_email_address",
53
+ models.EmailField(
54
+ blank=True,
55
+ default=None,
56
+ 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",
57
+ max_length=254,
58
+ null=True,
59
+ ),
60
+ ),
61
+ (
62
+ "from_email_name",
63
+ models.CharField(
64
+ blank=True,
65
+ default=None,
66
+ help_text="The display name portion of the from email. This will be wrapped around the from email address.",
67
+ max_length=256,
68
+ null=True,
69
+ ),
70
+ ),
71
+ ("created_at", models.DateTimeField(auto_now_add=True)),
72
+ ("updated_at", models.DateTimeField(auto_now=True)),
73
+ (
74
+ "site",
75
+ models.ForeignKey(
76
+ on_delete=django.db.models.deletion.CASCADE,
77
+ to="wagtailcore.site",
78
+ ),
79
+ ),
80
+ ],
81
+ ),
82
+ migrations.CreateModel(
83
+ name="AudienceSegment",
84
+ fields=[
85
+ (
86
+ "id",
87
+ models.BigAutoField(
88
+ auto_created=True,
89
+ primary_key=True,
90
+ serialize=False,
91
+ verbose_name="ID",
92
+ ),
93
+ ),
94
+ ("name", models.SlugField(unique=True)),
95
+ ("description", models.TextField(blank=True)),
96
+ ("created_at", models.DateTimeField(auto_now_add=True)),
97
+ ("updated_at", models.DateTimeField(auto_now=True)),
98
+ (
99
+ "audience",
100
+ models.ForeignKey(
101
+ on_delete=django.db.models.deletion.CASCADE,
102
+ related_name="audience_segment_set",
103
+ related_query_name="audience_segment",
104
+ to="wagtail_newsletter_django_backend.audience",
105
+ ),
106
+ ),
107
+ ],
108
+ ),
109
+ migrations.CreateModel(
110
+ name="Campaign",
111
+ fields=[
112
+ (
113
+ "id",
114
+ models.BigAutoField(
115
+ auto_created=True,
116
+ primary_key=True,
117
+ serialize=False,
118
+ verbose_name="ID",
119
+ ),
120
+ ),
121
+ ("send_at", models.DateTimeField(blank=True, default=None, null=True)),
122
+ ("sent_at", models.DateTimeField(blank=True, default=None, null=True)),
123
+ ("subject", models.TextField()),
124
+ ("html", models.TextField()),
125
+ ("created_at", models.DateTimeField(auto_now_add=True)),
126
+ ("updated_at", models.DateTimeField(auto_now=True)),
127
+ (
128
+ "audience_segment",
129
+ models.ForeignKey(
130
+ on_delete=django.db.models.deletion.CASCADE,
131
+ to="wagtail_newsletter_django_backend.audiencesegment",
132
+ ),
133
+ ),
134
+ ],
135
+ ),
136
+ migrations.CreateModel(
137
+ name="Subscriber",
138
+ fields=[
139
+ (
140
+ "id",
141
+ models.BigAutoField(
142
+ auto_created=True,
143
+ primary_key=True,
144
+ serialize=False,
145
+ verbose_name="ID",
146
+ ),
147
+ ),
148
+ (
149
+ "key",
150
+ models.CharField(
151
+ default=wagtail_newsletter_django_backend.models.generate_subscriber_key,
152
+ help_text="The subscriber's lookup key for managing subscriptions",
153
+ max_length=64,
154
+ unique=True,
155
+ ),
156
+ ),
157
+ ("email_address", models.EmailField(max_length=254)),
158
+ (
159
+ "email_name",
160
+ models.CharField(
161
+ blank=True,
162
+ default=None,
163
+ help_text="The display name portion of the email address.",
164
+ max_length=256,
165
+ null=True,
166
+ ),
167
+ ),
168
+ ("verified", models.BooleanField(default=False)),
169
+ ("created_at", models.DateTimeField(auto_now_add=True)),
170
+ ("updated_at", models.DateTimeField(auto_now=True)),
171
+ (
172
+ "audience",
173
+ models.ForeignKey(
174
+ on_delete=django.db.models.deletion.CASCADE,
175
+ to="wagtail_newsletter_django_backend.audience",
176
+ ),
177
+ ),
178
+ ],
179
+ ),
180
+ migrations.CreateModel(
181
+ name="SentMessage",
182
+ fields=[
183
+ (
184
+ "id",
185
+ models.BigAutoField(
186
+ auto_created=True,
187
+ primary_key=True,
188
+ serialize=False,
189
+ verbose_name="ID",
190
+ ),
191
+ ),
192
+ ("subscriber_hash", models.CharField(db_index=True, max_length=64)),
193
+ ("created_at", models.DateTimeField(auto_now_add=True)),
194
+ ("updated_at", models.DateTimeField(auto_now=True)),
195
+ (
196
+ "campaign",
197
+ models.ForeignKey(
198
+ on_delete=django.db.models.deletion.CASCADE,
199
+ related_name="sent_message_set",
200
+ related_query_name="sent_message",
201
+ to="wagtail_newsletter_django_backend.campaign",
202
+ ),
203
+ ),
204
+ (
205
+ "subscriber",
206
+ models.ForeignKey(
207
+ blank=True,
208
+ null=True,
209
+ on_delete=django.db.models.deletion.SET_NULL,
210
+ related_name="sent_message_set",
211
+ related_query_name="sent_message",
212
+ to="wagtail_newsletter_django_backend.subscriber",
213
+ ),
214
+ ),
215
+ ],
216
+ ),
217
+ migrations.CreateModel(
218
+ name="Subscription",
219
+ fields=[
220
+ (
221
+ "id",
222
+ models.BigAutoField(
223
+ auto_created=True,
224
+ primary_key=True,
225
+ serialize=False,
226
+ verbose_name="ID",
227
+ ),
228
+ ),
229
+ ("created_at", models.DateTimeField(auto_now_add=True)),
230
+ ("updated_at", models.DateTimeField(auto_now=True)),
231
+ (
232
+ "audience_segment",
233
+ models.ForeignKey(
234
+ on_delete=django.db.models.deletion.CASCADE,
235
+ to="wagtail_newsletter_django_backend.audiencesegment",
236
+ ),
237
+ ),
238
+ (
239
+ "subscriber",
240
+ models.ForeignKey(
241
+ on_delete=django.db.models.deletion.CASCADE,
242
+ to="wagtail_newsletter_django_backend.subscriber",
243
+ ),
244
+ ),
245
+ ],
246
+ ),
247
+ migrations.AddField(
248
+ model_name="subscriber",
249
+ name="audience_segments",
250
+ field=models.ManyToManyField(
251
+ related_name="+",
252
+ through="wagtail_newsletter_django_backend.Subscription",
253
+ to="wagtail_newsletter_django_backend.audiencesegment",
254
+ ),
255
+ ),
256
+ migrations.AddField(
257
+ model_name="audiencesegment",
258
+ name="subscribers",
259
+ field=models.ManyToManyField(
260
+ related_name="+",
261
+ through="wagtail_newsletter_django_backend.Subscription",
262
+ to="wagtail_newsletter_django_backend.subscriber",
263
+ ),
264
+ ),
265
+ migrations.AddConstraint(
266
+ model_name="audience",
267
+ constraint=models.UniqueConstraint(
268
+ fields=("site", "name"), name="unique_site_name"
269
+ ),
270
+ ),
271
+ migrations.AddConstraint(
272
+ model_name="subscription",
273
+ constraint=models.UniqueConstraint(
274
+ fields=("audience_segment", "subscriber"),
275
+ name="unique_audience_segment_subscriber",
276
+ ),
277
+ ),
278
+ migrations.AddConstraint(
279
+ model_name="subscriber",
280
+ constraint=models.UniqueConstraint(
281
+ fields=("audience", "email_address"),
282
+ name="unique_audience_email_address",
283
+ ),
284
+ ),
285
+ migrations.AddConstraint(
286
+ model_name="audiencesegment",
287
+ constraint=models.UniqueConstraint(
288
+ fields=("audience", "name"), name="unique_audience_name"
289
+ ),
290
+ ),
291
+ ]
@@ -1,3 +1,317 @@
1
+ from collections.abc import Iterable, Sequence
2
+ from datetime import datetime
3
+ from functools import lru_cache
4
+ import hashlib
5
+ import hmac
6
+ from typing import TYPE_CHECKING, Any, LiteralString, NotRequired, TypedDict, Unpack, override, cast
7
+ from django.core.exceptions import ValidationError
8
+ from django.urls import reverse
9
+ from wagtail.models import Site
1
10
  from django.db import models
11
+ from django.conf import settings
12
+ from email.utils import parseaddr, formataddr
13
+ import secrets
14
+ import string
15
+ from wagtail_newsletter import campaign_backends as wncb
2
16
 
3
- # Create your models here.
17
+ if TYPE_CHECKING:
18
+ from django.db.models.manager import RelatedManager
19
+
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
28
+ return name, email
29
+
30
+ class SaveDict(TypedDict):
31
+ force_insert: NotRequired[bool]
32
+ force_update: NotRequired[bool]
33
+ using: NotRequired[str]
34
+ update_fields: NotRequired[Iterable[str]]
35
+
36
+ def patch_blanks(record: models.Model, update_fields: Iterable[str] | None, keys: Iterable[str]) -> None:
37
+ '''Patch blank string fields to None.
38
+ '''
39
+ update_keys: Iterable[str]
40
+ if update_fields is None:
41
+ update_keys = keys
42
+ else:
43
+ update_keys = frozenset(update_fields) | frozenset(keys)
44
+
45
+ # Change all blank string fields to NULL
46
+ for key in update_keys:
47
+ if getattr(record, key) == '':
48
+ setattr(record, key, None)
49
+
50
+ class Audience(models.Model):
51
+ pk: int
52
+ id: int # pyright: ignore[reportUninitializedInstanceVariable]
53
+ audience_segment_set: 'RelatedManager[AudienceSegment]' # pyright: ignore[reportUninitializedInstanceVariable]
54
+ subscriber_set: 'RelatedManager[Subscriber]' # pyright: ignore[reportUninitializedInstanceVariable]
55
+
56
+ site: 'models.ForeignKey[Site]' = models.ForeignKey(Site, blank=False, null=False, on_delete=models.CASCADE)
57
+ name: 'models.SlugField[str]' = models.SlugField(blank=False, null=False)
58
+ 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
+
87
+ created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
88
+ updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
89
+
90
+ class Meta:
91
+ constraints: Sequence[models.UniqueConstraint] = (
92
+ models.UniqueConstraint(
93
+ fields=('site', 'name'),
94
+ name='unique_site_name',
95
+ ),
96
+ )
97
+
98
+ @override
99
+ def __str__(self) -> str:
100
+ return self.name
101
+
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
+ class AudienceSegment(models.Model):
132
+ pk: int
133
+ id: int # pyright: ignore[reportUninitializedInstanceVariable]
134
+
135
+ audience: 'models.ForeignKey[Audience]' = models.ForeignKey(Audience, blank=False, null=False, on_delete=models.CASCADE, related_name='audience_segment_set', related_query_name='audience_segment')
136
+ name: 'models.SlugField[str]' = models.SlugField(blank=False, null=False, unique=True)
137
+ description: 'models.TextField[str]' = models.TextField(blank=True, null=False)
138
+ subscribers: 'models.ManyToManyField[Subscriber, Subscription]' = models.ManyToManyField(
139
+ 'Subscriber',
140
+ through='Subscription',
141
+ related_name='+',
142
+ )
143
+
144
+ created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
145
+ updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
146
+
147
+ class Meta:
148
+ constraints: Sequence[models.UniqueConstraint] = (
149
+ models.UniqueConstraint(
150
+ fields=('audience', 'name'),
151
+ name='unique_audience_name',
152
+ ),
153
+ )
154
+
155
+ @override
156
+ def __str__(self) -> str:
157
+ return self.name
158
+
159
+ _SUBSCRIBER_KEY_CHARS: LiteralString = string.ascii_letters + string.digits
160
+
161
+ def generate_subscriber_key() -> str:
162
+ return ''.join(secrets.choice(_SUBSCRIBER_KEY_CHARS) for _ in range(64))
163
+
164
+ class Subscriber(models.Model):
165
+ key: models.CharField[str] = models.CharField(
166
+ max_length=64,
167
+ blank=False,
168
+ null=False,
169
+ default=generate_subscriber_key,
170
+ help_text="The subscriber's lookup key for managing subscriptions",
171
+ unique=True,
172
+ )
173
+ audience: 'models.ForeignKey[Audience]' = models.ForeignKey(Audience, blank=False, null=False, on_delete=models.CASCADE)
174
+ email_address: 'models.EmailField[str]' = models.EmailField(blank=False, null=False)
175
+ email_name: 'models.CharField[str | None]' = models.CharField(
176
+ max_length=256,
177
+ blank=True,
178
+ null=True,
179
+ default=None,
180
+ help_text='The display name portion of the email address.',
181
+ )
182
+ audience_segments: 'models.ManyToManyField[AudienceSegment, Subscription]' = models.ManyToManyField(
183
+ AudienceSegment,
184
+ through='Subscription',
185
+ related_name='+',
186
+ )
187
+ verified: models.BooleanField[bool] = models.BooleanField(blank=False, null=False, default=False)
188
+ created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
189
+ updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
190
+
191
+ sent_message_set: 'RelatedManager[SentMessage]' # pyright: ignore[reportUninitializedInstanceVariable]
192
+
193
+ @override
194
+ def __str__(self) -> str:
195
+ return self.email()
196
+
197
+ def email(self) -> str:
198
+ if self.email_name:
199
+ return formataddr((self.email_name, self.email_address))
200
+ else:
201
+ return self.email_address
202
+
203
+ def pii_hash(self) -> str:
204
+ normalized = self.email_address.lower().strip().encode('utf-8')
205
+ key = cast(str, settings.PII_HASHING_SALT).encode('utf-8')
206
+ return hmac.new(key, normalized, hashlib.sha256).hexdigest()
207
+
208
+ class Meta:
209
+ constraints: Sequence[models.UniqueConstraint] = (
210
+ models.UniqueConstraint(
211
+ fields=('audience', 'email_address'),
212
+ name='unique_audience_email_address',
213
+ ),
214
+ )
215
+
216
+ _NULLABLE_FIELDS: Sequence[str] = ('email_name',)
217
+
218
+ @override
219
+ def save(self, **kwargs: Unpack[SaveDict]) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
220
+ patch_blanks(self, kwargs.get('update_fields'), Subscriber._NULLABLE_FIELDS)
221
+ return super().save(**kwargs)
222
+
223
+ class Subscription(models.Model):
224
+ audience_segment: 'models.ForeignKey[AudienceSegment]' = models.ForeignKey(AudienceSegment, on_delete=models.CASCADE)
225
+ subscriber: 'models.ForeignKey[Subscriber]' = models.ForeignKey(Subscriber, on_delete=models.CASCADE)
226
+
227
+ created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
228
+ updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
229
+
230
+ class Meta:
231
+ constraints: Sequence[models.UniqueConstraint] = (
232
+ models.UniqueConstraint(
233
+ fields=['audience_segment', 'subscriber'],
234
+ name='unique_audience_segment_subscriber',
235
+ ),
236
+ )
237
+
238
+ @override
239
+ def __str__(self) -> str:
240
+ return f'{self.subscriber} -> {self.audience_segment}'
241
+
242
+ @override
243
+ def clean(self) -> None:
244
+ if self.audience_segment.audience != self.subscriber.audience:
245
+ raise ValidationError('The audience_segment and subscriber must have the same audience')
246
+
247
+ class Campaign(models.Model):
248
+ pk: int
249
+ id: int # pyright: ignore[reportUninitializedInstanceVariable]
250
+
251
+ send_at: 'models.DateTimeField[datetime | None]' = models.DateTimeField(blank=True, null=True, default=None)
252
+ sent_at: 'models.DateTimeField[datetime | None]' = models.DateTimeField(blank=True, null=True, default=None)
253
+ subject: 'models.TextField[str]' = models.TextField(blank=False, null=False)
254
+ html: 'models.TextField[str]' = models.TextField(blank=False, null=False)
255
+ audience_segment: models.ForeignKey[AudienceSegment] = models.ForeignKey(AudienceSegment, blank=False, null=False, on_delete=models.CASCADE)
256
+
257
+ created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
258
+ updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
259
+
260
+ @property
261
+ def is_sent(self) -> bool:
262
+ return self.sent_at is not None
263
+
264
+ @property
265
+ def is_scheduled(self) -> bool:
266
+ return self.sent_at is None and self.send_at is not None
267
+
268
+ @property
269
+ def url(self) -> str:
270
+ return reverse('campaign:edit', args=[self.pk])
271
+
272
+ @property
273
+ def status(self) -> str:
274
+ if self.sent_at is not None:
275
+ return 'Sent'
276
+ elif self.send_at is not None:
277
+ return 'Scheduled'
278
+ else:
279
+ return 'Saved'
280
+
281
+ def get_report(self) -> dict[str, Any]:
282
+ report = {
283
+ 'id': self.id,
284
+ 'emails_sent': SentMessage.objects.filter(campaign=self).count(),
285
+ 'send_time': self.sent_at,
286
+ 'bounces': 0,
287
+ 'delivery_status': {
288
+ 'status': 'poop',
289
+ },
290
+ }
291
+ if self.send_at is not None:
292
+ report['send_time'] = self.send_at
293
+ return report
294
+
295
+ @override
296
+ def __str__(self) -> str:
297
+ return self.subject
298
+
299
+ _ = wncb.Campaign.register(Campaign)
300
+
301
+ class SentMessage(models.Model):
302
+ pk: int
303
+ id: int # pyright: ignore[reportUninitializedInstanceVariable]
304
+
305
+ # Null if the subscriber has unsubscribed, deleting their email address
306
+ campaign: 'models.ForeignKey[Campaign]' = models.ForeignKey(Campaign, blank=False, null=False, on_delete=models.CASCADE, related_name='sent_message_set', related_query_name='sent_message')
307
+ subscriber: 'models.ForeignKey[Subscriber | None]' = models.ForeignKey(Subscriber, blank=True, null=True, on_delete=models.SET_NULL, related_name='sent_message_set', related_query_name='sent_message')
308
+ subscriber_hash: 'models.CharField[str]' = models.CharField(max_length = 64, null=False, blank=False, db_index=True)
309
+
310
+ created_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now_add=True)
311
+ updated_at: 'models.DateTimeField[datetime]' = models.DateTimeField(auto_now=True)
312
+
313
+ @override
314
+ def save(self, **kwargs: Unpack[SaveDict]) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
315
+ if self.subscriber is not None and self.subscriber_hash is None: # pyright: ignore[reportUnnecessaryComparison]
316
+ self.subscriber_hash = self.subscriber.pii_hash()
317
+ super().save(**kwargs)
@@ -0,0 +1,16 @@
1
+ <html>
2
+ <body>
3
+ {% if messages %}
4
+ {% for message in messages %}
5
+ <div class="alert alert-{{ message.tags }}">
6
+ {{ message }}
7
+ </div>
8
+ {% endfor %}
9
+ {% endif %}
10
+ <p><a href="{% url 'newsletter_subscriber_unsubscribe' key=form.instance.key %}">Unsubscribe from all</a></p>
11
+ <form method="post">{% csrf_token %}
12
+ {{ form.as_p }}
13
+ <input type="submit" value="Save preferences">
14
+ </form>
15
+ </body>
16
+ </html>
@@ -0,0 +1,22 @@
1
+ <html>
2
+ <body>
3
+ {% if messages %}
4
+ {% for message in messages %}
5
+ <div class="alert alert-{{ message.tags }}">
6
+ {{ message }}
7
+ </div>
8
+ {% endfor %}
9
+ {% endif %}
10
+ {% if subscriber %}
11
+ <p>Really unsubscribe for {{subscriber}}?</p>
12
+ <form method="post" action="{{ request.path }}">{% csrf_token %}
13
+ <input type="submit" value="Unsubscribe">
14
+ </form>
15
+ <p><a href="{% url 'newsletter_subscriber_manage' key=subscriber.key %}">Manage my preferences instead</a></p>
16
+ <p><a href="/">Go back to the main site</a></p>
17
+ {% else %}
18
+ <p>This subscriber does not exist</p>
19
+ <p><a href="/">Go back to the main site</a></p>
20
+ {% endif %}
21
+ </body>
22
+ </html>
@@ -0,0 +1,17 @@
1
+ # from django.contrib import admin
2
+ # from django.urls import include, path
3
+ # from wagtail.admin import urls as wagtailadmin_urls
4
+ # from wagtail import urls as wagtail_urls
5
+ # from wagtail.documents import urls as wagtaildocs_urls
6
+
7
+ from django.urls import path
8
+ from wagtail_newsletter_django_backend import views
9
+
10
+
11
+ app_name = 'wagtail_newsletter_django_backend'
12
+ urlpatterns = [
13
+ # The manage and verify route
14
+ path('manage/<str:key>/', views.ManageView.as_view(), name='newsletter_subscriber_manage'),
15
+ path('unsubscribe/<str:key>/', views.UnsubscribeView.as_view(), name='newsletter_subscriber_unsubscribe'),
16
+ ]
17
+
@@ -1,3 +1,79 @@
1
- from django.shortcuts import render
1
+ from contextlib import _RedirectStream
2
+ from typing import Any, override
3
+ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
4
+ from django.http.response import HttpResponseRedirectBase
5
+ from django.urls import reverse
6
+ from django.views.generic.edit import FormView
7
+ from django.views.generic.base import TemplateView
8
+ from .forms import ManageForm
9
+ from django.shortcuts import get_object_or_404
10
+ from django.contrib import messages
11
+ from . import models
2
12
 
3
- # Create your views here.
13
+ class ManageView(FormView): # pyright: ignore[reportMissingTypeArgument]
14
+ template_name = 'wagtail_newsletter_django_backend/manage.html'
15
+ form_class = ManageForm
16
+
17
+ def get_object(self):
18
+ """Get the subscriber instance from the URL key parameter."""
19
+ key = self.kwargs['key']
20
+ return get_object_or_404(models.Subscriber, key=key)
21
+
22
+ def get_form_kwargs(self):
23
+ """Add the subscriber instance to form kwargs."""
24
+ kwargs = super().get_form_kwargs()
25
+ kwargs['instance'] = self.get_object()
26
+ return kwargs
27
+
28
+ def form_valid(self, form):
29
+ """Save the form and redirect on success."""
30
+ form.save()
31
+ messages.success(self.request, 'Your subscription preferences have been updated successfully!')
32
+
33
+ return super().form_valid(form)
34
+
35
+ def get_success_url(self):
36
+ """Redirect back to the same page after successful form submission."""
37
+ return self.request.path
38
+
39
+ def get(self, request: HttpRequest, *args, **kwargs):
40
+ """Handle GET request and verify subscriber if needed."""
41
+ subscriber = self.get_object()
42
+
43
+ # Verify the subscriber if not already verified
44
+ if not subscriber.verified:
45
+ subscriber.verified = True
46
+ subscriber.save(update_fields=['verified'])
47
+ messages.success(request, 'Your email address has been successfully verified!')
48
+
49
+ return super().get(request, *args, **kwargs)
50
+
51
+ class HttpResponseSeeOther(HttpResponseRedirectBase):
52
+ status_code: int = 303
53
+
54
+ class UnsubscribeView(TemplateView):
55
+ template_name = 'wagtail_newsletter_django_backend/unsubscribe.html'
56
+ subscriber: models.Subscriber | None = None
57
+ method: str | None = None
58
+
59
+ @override
60
+ def setup(self, request: HttpRequest, key: str, *args, **kwargs) -> None:
61
+ super().setup(request, *args, **kwargs)
62
+ self.subscriber = models.Subscriber.objects.filter(key=key).first()
63
+ self.method = request.method
64
+
65
+ @override
66
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: # pyright: ignore[reportAny]
67
+ data = super().get_context_data(**kwargs)
68
+ data['subscriber'] = self.subscriber
69
+ if self.method:
70
+ data['method'] = self.method.lower()
71
+ return data
72
+
73
+ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
74
+ if self.subscriber is not None:
75
+ _ = self.subscriber.delete()
76
+ self.subscriber = None
77
+ messages.success(request, 'This subscriber has been successfully deleted')
78
+ return HttpResponseSeeOther(reverse('newsletter_subscriber_unsubscribe', args=args, kwargs=kwargs))
79
+
@@ -0,0 +1,7 @@
1
+ from wagtail import hooks
2
+ from .admin_viewsets import MailingListGroup, mailing_list_group
3
+
4
+ @hooks.register('register_admin_viewset') # pyright: ignore[reportOptionalCall, reportUntypedFunctionDecorator, reportUnknownMemberType]
5
+ def register_viewset() -> MailingListGroup:
6
+ return mailing_list_group
7
+
@@ -1,14 +1,16 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: wagtail-newsletter-django-backend
3
- Version: 0.0.2
3
+ Version: 0.2.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>
7
7
  Requires-Python: >= 3.10
8
8
  Description-Content-Type: text/markdown
9
- Requires-Dist: Django>=5.2,<5.3
10
- Requires-Dist: wagtail>=7.1,<7.2
11
- Requires-Dist: wagtail-newsletter>=0.2.2,<0.3.0
9
+ License-File: LICENSE
10
+ Requires-Dist: Django >=5.2, <5.3
11
+ Requires-Dist: wagtail >=7.1, <7.2
12
+ Requires-Dist: wagtail-newsletter >=0.2.2, <0.3.0
13
+ Requires-Dist: html2text >= 2025.4.15
12
14
  Project-URL: documentation, https://wagtail-newsletter-django-backend.readthedocs.io/en/latest/
13
15
  Project-URL: repository, https://forge.axfive.net/Taylor/wagtail-newsletter-django-backend
14
16
 
@@ -0,0 +1,22 @@
1
+ wagtail_newsletter_django_backend/__init__.py,sha256=8MajxYquD2zP0KOr24i0Dvy40Dq9tor1oytBhpY0MyU,263
2
+ wagtail_newsletter_django_backend/admin.py,sha256=suMo4x8I3JBxAFBVIdE-5qnqZ6JAZV0FESABHOSc-vg,63
3
+ wagtail_newsletter_django_backend/admin_viewsets.py,sha256=JRUz8lRqZPGzlnorNr605OQzA9GmAuzSu_oe1tCoosc,2011
4
+ wagtail_newsletter_django_backend/apps.py,sha256=fFm2QZzxZlpYBQTNRKzBXpzOUzjK5xbW2xCQdv5fNOA,281
5
+ wagtail_newsletter_django_backend/campaign_backend.py,sha256=5GGPKfDZ5ecQBqzLM2I3FBXvlqv1GfaczA-DmjyQlJc,6929
6
+ wagtail_newsletter_django_backend/forms.py,sha256=5UK_-Fd7KuOtRkPZW_uyHM9D6BU5_JANKcM3WhAnew0,4009
7
+ wagtail_newsletter_django_backend/models.py,sha256=QjGIqSNn-uQ_T3hWchTx6K4XhcYhyze5mULoFXzftQk,12887
8
+ wagtail_newsletter_django_backend/tests.py,sha256=mrbGGRNg5jwbTJtWWa7zSKdDyeB4vmgZCRc2nk6VY-g,60
9
+ wagtail_newsletter_django_backend/urls.py,sha256=650q2IQKPOj6OSjVIB3QzxPl4n67M2MJwtFFEvJ3vOk,620
10
+ wagtail_newsletter_django_backend/views.py,sha256=8xh-aOdy7CS0g8CeieB-_flOtHB7VZ8tr1syzcbYme4,3166
11
+ wagtail_newsletter_django_backend/wagtail_hooks.py,sha256=3ll7lcwAKUMS9AoxUq9WuBjGAfHHgwGNJO6yt7r0Ra4,305
12
+ wagtail_newsletter_django_backend/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ wagtail_newsletter_django_backend/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ wagtail_newsletter_django_backend/management/commands/send_scheduled_campaigns.py,sha256=YA6KLCUyHrHJqxc_qHP-JEBFh_U-0T2BPKiUeue8czA,320
15
+ wagtail_newsletter_django_backend/migrations/0001_initial.py,sha256=DYuyU3MxhnhzKBPKlxZn2erP-lB9rGRUR4KngXRrBXw,11129
16
+ wagtail_newsletter_django_backend/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/manage.html,sha256=ahu_XV6aKdrL7uj2VEC_ISdw7OFKsMsiamMOY_COZrA,535
18
+ wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/unsubscribe.html,sha256=sg8AwxuoLiBV-6QazL8X1cd6vQp8arR1_Uj6Jep2Lwo,810
19
+ wagtail_newsletter_django_backend-0.2.0.dist-info/licenses/LICENSE,sha256=mLNEGyMZ9q73ep-adKoMoTX06QDYEd949WxgTKhl93A,33970
20
+ wagtail_newsletter_django_backend-0.2.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
21
+ wagtail_newsletter_django_backend-0.2.0.dist-info/METADATA,sha256=3uKZaGyM0g_LvsEkkhgQquEv9pqpebGKNIhQye3ofOk,836
22
+ wagtail_newsletter_django_backend-0.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: flit 3.10.1
2
+ Generator: flit 3.12.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- wagtail_newsletter_django_backend/__init__.py,sha256=BC8UtHiLA2WiHnRKp4TssBot9Fpl8UjOYzgv2zIwwcU,263
2
- wagtail_newsletter_django_backend/admin.py,sha256=suMo4x8I3JBxAFBVIdE-5qnqZ6JAZV0FESABHOSc-vg,63
3
- wagtail_newsletter_django_backend/apps.py,sha256=HiQChXPqKrnPGTL5BX-dVcMEeMmY3qmXXz7NrSeX2YY,195
4
- wagtail_newsletter_django_backend/models.py,sha256=Vjc0p2XbAPgE6HyTF6vll98A4eDhA5AvaQqsc4kQ9AQ,57
5
- wagtail_newsletter_django_backend/tests.py,sha256=mrbGGRNg5jwbTJtWWa7zSKdDyeB4vmgZCRc2nk6VY-g,60
6
- wagtail_newsletter_django_backend/views.py,sha256=xc1IQHrsij7j33TUbo-_oewy3vs03pw_etpBWaMYJl0,63
7
- wagtail_newsletter_django_backend/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- wagtail_newsletter_django_backend-0.0.2.dist-info/LICENSE,sha256=mLNEGyMZ9q73ep-adKoMoTX06QDYEd949WxgTKhl93A,33970
9
- wagtail_newsletter_django_backend-0.0.2.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
10
- wagtail_newsletter_django_backend-0.0.2.dist-info/METADATA,sha256=dSDmfz_ML2iwkeDGnmH1Ws3ltl7LE3vnkVZZRsDmTPY,770
11
- wagtail_newsletter_django_backend-0.0.2.dist-info/RECORD,,