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.
- wagtail_newsletter_django_backend/__init__.py +1 -1
- wagtail_newsletter_django_backend/admin_viewsets.py +58 -0
- wagtail_newsletter_django_backend/apps.py +3 -1
- wagtail_newsletter_django_backend/campaign_backend.py +180 -0
- wagtail_newsletter_django_backend/forms.py +97 -0
- wagtail_newsletter_django_backend/management/__init__.py +0 -0
- wagtail_newsletter_django_backend/management/commands/__init__.py +0 -0
- wagtail_newsletter_django_backend/management/commands/send_scheduled_campaigns.py +8 -0
- wagtail_newsletter_django_backend/migrations/0001_initial.py +291 -0
- wagtail_newsletter_django_backend/models.py +315 -1
- wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/manage.html +16 -0
- wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/unsubscribe.html +22 -0
- wagtail_newsletter_django_backend/urls.py +17 -0
- wagtail_newsletter_django_backend/views.py +78 -2
- wagtail_newsletter_django_backend/wagtail_hooks.py +7 -0
- {wagtail_newsletter_django_backend-0.0.2.dist-info → wagtail_newsletter_django_backend-0.2.0.dist-info}/METADATA +7 -5
- wagtail_newsletter_django_backend-0.2.0.dist-info/RECORD +22 -0
- {wagtail_newsletter_django_backend-0.0.2.dist-info → wagtail_newsletter_django_backend-0.2.0.dist-info}/WHEEL +1 -1
- wagtail_newsletter_django_backend-0.0.2.dist-info/RECORD +0 -11
- {wagtail_newsletter_django_backend-0.0.2.dist-info → wagtail_newsletter_django_backend-0.2.0.dist-info/licenses}/LICENSE +0 -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
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
-
|
|
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>
|
wagtail_newsletter_django_backend/templates/wagtail_newsletter_django_backend/unsubscribe.html
ADDED
|
@@ -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
|
|
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
|
-
#
|
|
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.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: wagtail-newsletter-django-backend
|
|
3
|
-
Version: 0.0
|
|
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
|
-
|
|
10
|
-
Requires-Dist:
|
|
11
|
-
Requires-Dist: wagtail
|
|
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,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,,
|