Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #35: Implemented Frontend Subscribe Form #36

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.mkd
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# 1.2.0

- New Frontend Subscribe Form feature
- New optional setting `BIRDSONG_TEST_CONTACT`
- Potential backward incompatible changes:
- `birsong_contact.email` is now a UNIQUE db field
- BaseEmailBackend is now an abstract class with two abstract methods `send_campaign()` and `send_mail()`

# 1.1.2

- French translation added
Expand Down
63 changes: 63 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ You can override the default ``Contact`` model by setting an option on the admin

.. code-block:: python

# You should teach Birdsong how it should access your newly extended contact model
BIRDSONG_CONTACT_MODEL = 'your_app.ExtendedContact'

# You may want to redefine the test contact (used in previews) with your new ExtendedContact fields
BIRDSONG_TEST_CONTACT = {
'first_name': 'Wagtail', # new ExtendedContact field
Expand Down Expand Up @@ -212,6 +215,66 @@ Users will now be able to send campaigns to a subset of contacts based on locati



Subscribe form
===============

Included in birdsong is a basic way for contacts to subscribe.

.. image:: docs/birdsong-frontend-subscribe-form.gif
:width: 575
:alt: Birdsong Frontend Subscribe Form

First let's include Birdsong's urls in your project.

``urls.py``

.. code-block:: python

from birdsong import urls as birdsong_urls
from django.urls import include, path

urlpatterns = [
...
path('mail/', include(birdsong_urls)),
...
]


After that just use the ``birdsong_subscribe_form`` template tag in any of your templates.

``your_app/templates/your_template.html``

.. code-block:: html

...
{% load birdsong_tags %}
...
{% birdsong_subscribe_form %}
...


You can control the subscribe form by changing any of these optional settings:

.. code-block:: python

BIRDSONG_SUBSCRIBE_FORM_AJAX = True # turns on/off subscribe form's js (ajax) support
BIRDSONG_SUBSCRIBE_FORM_MSG_SUCCESS = 'You have been subscribed.'
BIRDSONG_SUBSCRIBE_FORM_MSG_FAILURE = 'Invalid email address'

BIRDSONG_ACTIVATION_REQUIRED = False # turns on/off email activation requirement for new subscriptions
BIRDSONG_ACTIVATION_REQUIRED_MSG = 'Check your e-mail to activate your subscription.'
BIRDSONG_ACTIVATION_EMAIL_SUBJECT = 'Activate Your ' + WAGTAIL_SITE_NAME + ' Mailing List Subscription'


You can further customize the subscribe form related functionality by overriding following templates:

- To change the ``birdsong_subscribe_form`` tag create ``your_app/templates/birdsong/tags/subscribe.html``
- To change the activation email create ``your_app/templates/birdsong/mail/activation_email.html``
- To change the subscription activation confirmation page create ``your_app/templates/birdsong/activate.html``
- To change the standalone subscribe form page create ``your_app/templates/birdsong/subscribe.html``



Unsubscribe url
===============

Expand Down
25 changes: 22 additions & 3 deletions birdsong/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
from abc import ABCMeta, abstractmethod

from django.conf import settings


class BaseEmailBackend:
def send_email(self, request, campaign, subject, contacts):
raise NotImplementedError
class BaseEmailBackend(metaclass=ABCMeta):
"""
Abstract class that should be inheritted by all BIRDSONG_BACKENDs.
"""

@abstractmethod
def send_campaign(self, request, campaign, contacts, test_send=False):
"""Required be implemented in all backends.

NOTE: Used by Send Campaign action (see `birdsong.options.CampaignAdmin.send_campaign`).
"""
raise NotImplementedError("You must implement send_campaign() in your BIRDSONG_BACKEND")

@abstractmethod
def send_mail(self, subject, template, contact, context):
"""Required to be implemented in all backends.

NOTE: Utilized by Subscribe Form (see `birdsong.views.subscribe`, `birdsong.views.subscribe_api`).
"""
raise NotImplementedError("You must implement send_mail() in your BIRDSONG_BACKEND")

@property
def from_email(self):
Expand Down
41 changes: 34 additions & 7 deletions birdsong/backends/smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
from django.db import close_old_connections, transaction
from django.template.loader import render_to_string
from django.utils import timezone
from django.core.mail import send_mail

from birdsong.models import Campaign, CampaignStatus, Contact
from birdsong.utils import send_mass_html_mail
from birdsong.utils import send_mass_html_mail, html_to_plaintext
import birdsong.models # NOTE: can't use "from birdsong.models import ..." syntax without risking circular dependency imports in client overloads
# NOTE: This is due to BIRDSONG_BACKEND module_loading in birdsong.conf

from . import BaseEmailBackend

Expand All @@ -27,17 +29,17 @@ def run(self):
send_mass_html_mail(self.messages)
logger.info("Emails finished sending")
with transaction.atomic():
Campaign.objects.filter(pk=self.campaign_pk).update(
status=CampaignStatus.SENT,
birdsong.models.Campaign.objects.filter(pk=self.campaign_pk).update(
status=birdsong.models.CampaignStatus.SENT,
sent_date=timezone.now(),
)
fresh_contacts = Contact.objects.filter(
fresh_contacts = birdsong.models.Contact.objects.filter(
pk__in=self.contact_pks)
Campaign.objects.get(
birdsong.models.Campaign.objects.get(
pk=self.campaign_pk).receipts.add(*fresh_contacts)
except SMTPException:
logger.exception(f"Problem sending campaign: {self.campaign_pk}")
self.campaign.status = CampaignStatus.FAILED
self.campaign.status = birdsong.models.CampaignStatus.FAILED
finally:
close_old_connections()

Expand Down Expand Up @@ -65,3 +67,28 @@ def send_campaign(self, request, campaign, contacts, test_send=False):
campaign_thread = SendCampaignThread(
campaign.pk, [c.pk for c in contacts], messages)
campaign_thread.start()

def send_mail(self, subject, template, contact, context):
"""Sends out a single email.
NOTE: This method is here so that birdsong can utilize backends to send subscription activation emails.

:param subject: Subject of the email
:type subject: str
:param template: Template to use for the email (e.g. 'birdsong/mail/activation_email.html')
:type template: str
:param contact: Contact to send the email to (see `bridsong.utils.get_contact_model`)
:type contact: class:`bridsong.models.Contact` or class defined by `BIRDSONG_CONTACT_MODEL` setting
:param context: Data for the template
:type context: dict

:return: 0 on failure, network connection otherwise (see `EmailMessage.send`),
:rtype: int|class (defnied by `settings.EMAIL_BACKEND`)
"""
html_message = render_to_string(template, context)
return send_mail(
subject,
html_to_plaintext(html_message),
self.from_email,
[contact.email],
html_message=html_message,
)
24 changes: 23 additions & 1 deletion birdsong/conf.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
from django.conf import settings
import django.utils.module_loading as module_loading
from django.utils.translation import gettext as _

### BIRDSONG_CONTACT ###
BIRDSONG_CONTACT_MODEL = getattr(settings, 'BIRDSONG_CONTACT_MODEL', 'birdsong.Contact')

### BIRDSONG_TEST_CONTACT ###
BIRDSONG_TEST_CONTACT = getattr(settings, 'BIRDSONG_TEST_CONTACT', { 'email': '[email protected]' })

### BIRDSONG_ADMIN ###
BIRDSONG_ADMIN_GROUP = getattr(settings, 'BIRDSONG_ADMIN_GROUP', True)

BIRDSONG_TEST_CONTACT = getattr(settings, 'BIRDSONG_TEST_CONTACT', { 'email': '[email protected]' })
### BIRDSONG_BACKEND ###
BIRDSONG_BACKEND = getattr(settings, 'BIRDSONG_BACKEND', 'birdsong.backends.smtp.SMTPEmailBackend')
BIRDSONG_BACKEND_CLASS = module_loading.import_string(BIRDSONG_BACKEND)

### BIRDSONG_SUBSCRIBE_FORM ###
BIRDSONG_SUBSCRIBE_FORM_AJAX = getattr(settings, 'BIRDSONG_SUBSCRIBE_FORM_AJAX', True) # post form with ajax
BIRDSONG_SUBSCRIBE_FORM_MSG_SUCCESS = _(getattr(settings, 'BIRDSONG_SUBSCRIBE_FORM_MSG_SUCCESS', 'You have been subscribed.')) # presented upon valid form submission
BIRDSONG_SUBSCRIBE_FORM_MSG_FAILURE = _(getattr(settings, 'BIRDSONG_SUBSCRIBE_FORM_MSG_FAILURE', 'Invalid email address')) # presented upon invalid form submission

### BIRDSONG_ACTIVATION ###
BIRDSONG_ACTIVATION_REQUIRED = getattr(settings, 'BIRDSONG_ACTIVATION_REQUIRED', False)
BIRDSONG_ACTIVATION_REQUIRED_MSG = _(getattr(settings, 'BIRDSONG_ACTIVATION_REQUIRED_MSG', 'Check your e-mail to activate your subscription.'))
BIRDSONG_ACTIVATION_EMAIL_SUBJECT = _(getattr(settings, 'BIRDSONG_ACTIVATION_EMAIL_SUBJECT', 'Activate Your ' +
getattr(settings, 'WAGTAIL_SITE_NAME', '') + ' Mailing List Subscription'))

4 changes: 4 additions & 0 deletions birdsong/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from django import forms

class SubscribeForm(forms.Form):
email = forms.EmailField(label="", max_length=255)
33 changes: 33 additions & 0 deletions birdsong/migrations/0009_subscribe_form_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.1.5 on 2023-03-12 14:38

import birdsong.models
from django.db import migrations, models

class Migration(migrations.Migration):

dependencies = [
('birdsong', '0008_translation_support'),
]

operations = [
migrations.AddField(
model_name='contact',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='contact',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='contact',
name='email',
field=models.EmailField(max_length=254, unique=True, verbose_name='email'),
),
migrations.AddField(
model_name='contact',
name='is_active',
field=models.BooleanField(default=birdsong.models.Contact.get_default_is_active),
),
]
36 changes: 36 additions & 0 deletions birdsong/migrations/0010_activate_all_contacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 4.1.5 on 2023-03-12 15:04

from django.db import migrations, models

def activate_all_contacts(apps, schema_editor):
"""Activates all existing contacts"""
Contact = apps.get_model('birdsong', 'Contact')
contacts = Contact.objects.all().exclude(is_active=True)
for contact in contacts:
contact.is_active = True
Contact.objects.bulk_update(contacts, ['is_active'])

class Migration(migrations.Migration):
"""This migration is necessary because we want to grandfather existing contacts by setting their `contact.is_active` field to True.

This should happen because there was no activation process for contacts in the past and so it makes sense to treat
all existing contacts as already active.

Explanation of why a bare AddField/AlterField solution wouldn't work:
It might be tempting to implement this by two migrations where AddField sets the default to True in the first
migration and then in the second migration have an AlterField operation that sets `contact.is_active` default
back to False. Unfortunately that solution would only work until the two migrations get squashed.

NOTE: This migration will activate all existing contacts and it will "survive" migration squashing.
"""

dependencies = [
('birdsong', '0009_subscribe_form_support'),
]

operations = [
migrations.RunPython(
code=activate_all_contacts,
reverse_code=migrations.RunPython.noop,
),
]
52 changes: 51 additions & 1 deletion birdsong/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,80 @@
from wagtail.core.models import Site
from wagtail.core.utils import camelcase_to_underscore

from django.contrib.auth.tokens import PasswordResetTokenGenerator

from birdsong.conf import BIRDSONG_ACTIVATION_REQUIRED

class ContactTag(TaggedItemBase):
content_object = ParentalKey(
'birdsong.Contact', on_delete=models.CASCADE, related_name='tagged_items')


class Contact(ClusterableModel):

def get_default_is_active():
"""Determines the default value for `is_active` field based on settings."""
return not bool(BIRDSONG_ACTIVATION_REQUIRED)

id = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(verbose_name=_('email'))
email = models.EmailField(verbose_name=_('email'), unique=True)
tags = ClusterTaggableManager(
through=ContactTag,
verbose_name=_('tags'),
blank=True,
)
is_active = models.BooleanField(default=get_default_is_active)
updated_at = models.DateTimeField(auto_now=True, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True)

panels = [
FieldPanel('email'),
FieldPanel('tags'),
FieldPanel('is_active'),
]

def __str__(self):
return self.email

def make_token(self):
"""Makes token using a `ContactActivationTokenGenerator`.

:return: Token
:rtype: str
"""
return ContactActivationTokenGenerator().make_token(self)

def check_token(self, token):
"""Checks validity of the token.

:param token: Token to validate e.g. `bkwxds-1d9acfc26be0a0e65b504cab0996718f`
:type token: str
:return: `True` if valid, `False` otherwise
:rtype: bool
"""
return ContactActivationTokenGenerator().check_token(self, token)

class ContactActivationTokenGenerator(PasswordResetTokenGenerator):
"""Strategy object used to generate and check tokens for the Contact subscription mechanism.

NOTE: It extends :class:`PasswordResetTokenGenerator` so that it can use its own hash value generator
"""

def _make_hash_value(self, contact, timestamp):
"""Hash composed out a couple of contact related fields and a timestamp.

It will be invalidated after contact activation because it utilizes the `is_active` contact field.
NOTE: Typing `is_active` to boolean first is deliberate so that `None` works the same as `False` or `0`

:param contact: Client object to generate the token for
:type contact: class:`birdsong.models.Contact` (see `birdsong.utils.get_contact_model`)
:param timestamp: Time in seconds to use to make the hash
:type timestamp: float
:return: Hash value that will be used during token operations
:rtype: str
"""
return str(bool(contact.is_active)) + str(contact.pk) + str(timestamp)

class CampaignStatus(models.IntegerChoices):
UNSENT = 0, _('unsent')
Expand Down
Loading