Skip to content

Commit

Permalink
Schedule campaign sending (#65)
Browse files Browse the repository at this point in the history
  • Loading branch information
mgax authored Sep 6, 2024
1 parent d5fa94f commit ceda44a
Show file tree
Hide file tree
Showing 26 changed files with 1,003 additions and 247 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docs/_static/screenshot.png filter=lfs diff=lfs merge=lfs -text
11 changes: 11 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ build:
os: ubuntu-22.04
tools:
python: "3.12"
jobs:
post_checkout:
# https://docs.readthedocs.io/en/stable/build-customization.html#support-git-lfs-large-file-storage
- wget https://github.com/git-lfs/git-lfs/releases/download/v3.1.4/git-lfs-linux-amd64-v3.1.4.tar.gz
- tar xvfz git-lfs-linux-amd64-v3.1.4.tar.gz
- git config filter.lfs.process "`pwd`/git-lfs filter-process"
- git config filter.lfs.smudge "`pwd`/git-lfs smudge -- %f"
- git config filter.lfs.clean "`pwd`/git-lfs clean -- %f"
- ./git-lfs install
- ./git-lfs fetch
- ./git-lfs checkout

sphinx:
configuration: docs/conf.py
Expand Down
3 changes: 3 additions & 0 deletions docs/_static/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ newsletter campaigns for individual Wagtail content pages. It comes with a
Mailchimp_ backend out of the box, and includes support for MJML_ to render
email-compatible HTML.

.. image:: https://github.com/wagtail/wagtail-newsletter/assets/27617/35fd29fc-730d-4e69-a886-d0d8fe4ac182
.. image:: /_static/screenshot.png
:width: 600px

.. _Mailchimp: https://mailchimp.com
Expand Down
12 changes: 12 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ When you're ready to unleash the campaign upon your audience, click *Send
campaign* in the *Newsletter* editor tab. This will save a page revision,
upload the content to the campaign provider, and trigger campaign sending.

Schedule campaign
-----------------

If you'd like to send the campaign later, at a time when the emails are more
likely to be opened, you can click on *Schedule campaign* in the *Newsletter*
tab. If you're using the Mailchimp backend, make sure to select a time that is
a multiple of 15 minutes.

After a campaign is scheduled, and before it's time to send it, you can still
abort: click on the *Unschedule* button and the campaign will revert to a
draft.

Save campaign without sending
-----------------------------

Expand Down
12 changes: 12 additions & 0 deletions tests/actions/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import pytest

from wagtail.models import Site

from wagtail_newsletter.test.models import ArticlePage


@pytest.fixture
def page():
page = ArticlePage(title="Test Article")
Site.objects.get().root_page.add_child(instance=page)
return page
71 changes: 71 additions & 0 deletions tests/actions/test_restrictions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from unittest.mock import Mock

import pytest

from django.test import Client
from django.urls import reverse

from tests.conftest import MemoryCampaignBackend
from wagtail_newsletter.test.models import ArticlePage


pytestmark = pytest.mark.django_db

CAMPAIGN_ID = "test-campaign-id"
CAMPAIGN_URL = "http://campaign.example.com"


@pytest.mark.parametrize(
"action,label",
[
("save_campaign", "Newsletter: Save campaign"),
("send_test_email", "Newsletter: Send test email"),
("send_campaign", "Newsletter: Send campaign"),
("schedule_campaign", "Newsletter: Schedule campaign"),
("unschedule_campaign", "Newsletter: Unschedule campaign"),
],
)
def test_action_restricted(
page: ArticlePage,
admin_client: Client,
memory_backend: MemoryCampaignBackend,
monkeypatch: pytest.MonkeyPatch,
action: str,
label: str,
):
memory_backend.save_campaign = Mock(return_value=CAMPAIGN_ID)
memory_backend.get_campaign = Mock(return_value=Mock(url=CAMPAIGN_URL))
memory_backend.send_test_email = Mock()
memory_backend.send_campaign = Mock()
memory_backend.schedule_campaign = Mock()
memory_backend.unschedule_campaign = Mock()

monkeypatch.setattr(
ArticlePage, "has_newsletter_permission", Mock(return_value=False)
)

if action == "unschedule_campaign":
response = admin_client.post(
reverse("wagtail_newsletter:unschedule", kwargs={"page_id": page.pk}),
follow=True,
)

else:
url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": action,
}
response = admin_client.post(url, data, follow=True)

html = response.content.decode()

if action != "unschedule_campaign":
assert f"Page '{page.title}' has been updated" in html

assert f"You do not have permission to perform the action {label}" in html

assert memory_backend.save_campaign.mock_calls == []
assert memory_backend.send_test_email.mock_calls == []
assert memory_backend.send_campaign.mock_calls == []
66 changes: 66 additions & 0 deletions tests/actions/test_save_campaign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from unittest.mock import ANY, Mock, call

import pytest

from django.test import Client
from django.urls import reverse

from tests.conftest import MemoryCampaignBackend
from wagtail_newsletter.campaign_backends import CampaignBackendError
from wagtail_newsletter.test.models import ArticlePage


pytestmark = pytest.mark.django_db

CAMPAIGN_ID = "test-campaign-id"
CAMPAIGN_URL = "http://campaign.example.com"
EMAIL = "[email protected]"


def test_save_campaign(
page: ArticlePage, admin_client: Client, memory_backend: MemoryCampaignBackend
):
memory_backend.save_campaign = Mock(return_value=CAMPAIGN_ID)
memory_backend.get_campaign = Mock(return_value=Mock(url=CAMPAIGN_URL))

url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": "save_campaign",
}
response = admin_client.post(url, data, follow=True)

html = response.content.decode()
assert f"Page '{page.title}' has been updated" in html
assert (
f"Newsletter campaign '{page.title}' has been saved to Testing"
in html
)
assert f'href="{CAMPAIGN_URL}"' in html

assert memory_backend.save_campaign.mock_calls == [
call(campaign_id="", recipients=None, subject=page.title, html=ANY)
]

page.refresh_from_db()
assert page.newsletter_campaign == CAMPAIGN_ID


def test_save_campaign_failed_to_save(
page: ArticlePage, admin_client: Client, memory_backend: MemoryCampaignBackend
):
memory_backend.save_campaign = Mock(side_effect=CampaignBackendError("Mock error"))

url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": "save_campaign",
}
response = admin_client.post(url, data, follow=True)

assert "Mock error" in response.content.decode()

page.refresh_from_db()
assert page.newsletter_campaign == ""
100 changes: 100 additions & 0 deletions tests/actions/test_schedule_campaign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from datetime import date, datetime, time, timedelta, timezone
from unittest.mock import ANY, Mock, call

import pytest

from django.test import Client
from django.urls import reverse
from django.utils.formats import localize

from tests.conftest import MemoryCampaignBackend
from wagtail_newsletter.campaign_backends import CampaignBackendError
from wagtail_newsletter.test.models import ArticlePage


pytestmark = pytest.mark.django_db

CAMPAIGN_ID = "test-campaign-id"
CAMPAIGN_URL = "http://campaign.example.com"
EMAIL = "[email protected]"


def get_schedule_time(delta: timedelta):
return datetime.combine(date.today() + delta, time(12))


def test_schedule_campaign(
page: ArticlePage, admin_client: Client, memory_backend: MemoryCampaignBackend
):
memory_backend.save_campaign = Mock(return_value=CAMPAIGN_ID)
memory_backend.get_campaign = Mock(return_value=Mock(url=CAMPAIGN_URL))
memory_backend.schedule_campaign = Mock()

url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
schedule_time = get_schedule_time(timedelta(days=1))
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": "schedule_campaign",
"newsletter-schedule-schedule_time": schedule_time.isoformat(),
}
response = admin_client.post(url, data, follow=True)

html = response.content.decode()
assert f"Page '{page.title}' has been updated" in html
assert (
f"Newsletter campaign '{page.title}' has been saved to Testing"
in html
)
assert f"Campaign scheduled to send at {localize(schedule_time)}" in html

assert memory_backend.save_campaign.mock_calls == [
call(campaign_id="", recipients=None, subject=page.title, html=ANY)
]
assert memory_backend.schedule_campaign.mock_calls == [
call(
campaign_id=CAMPAIGN_ID,
schedule_time=schedule_time.replace(tzinfo=timezone.utc),
)
]


def test_schedule_campaign_failed_to_schedule(
page: ArticlePage, admin_client: Client, memory_backend: MemoryCampaignBackend
):
memory_backend.save_campaign = Mock(return_value=CAMPAIGN_ID)
memory_backend.get_campaign = Mock(return_value=Mock(url=CAMPAIGN_URL))
memory_backend.schedule_campaign = Mock(
side_effect=CampaignBackendError("Mock error")
)

url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
schedule_time = get_schedule_time(timedelta(days=1))
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": "schedule_campaign",
"newsletter-schedule-schedule_time": schedule_time.isoformat(),
}
response = admin_client.post(url, data, follow=True)

assert "Mock error" in response.content.decode()


def test_schedule_in_the_past(
page: ArticlePage, admin_client: Client, memory_backend: MemoryCampaignBackend
):
memory_backend.save_campaign = Mock(return_value=CAMPAIGN_ID)
memory_backend.get_campaign = Mock(return_value=Mock(url=CAMPAIGN_URL))

url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
schedule_time = get_schedule_time(timedelta(days=-1))
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": "schedule_campaign",
"newsletter-schedule-schedule_time": schedule_time.isoformat(),
}
response = admin_client.post(url, data, follow=True)

assert "Schedule time: Date must be in the future." in response.content.decode()
64 changes: 64 additions & 0 deletions tests/actions/test_send_campaign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from unittest.mock import ANY, Mock, call

import pytest

from django.test import Client
from django.urls import reverse

from tests.conftest import MemoryCampaignBackend
from wagtail_newsletter.campaign_backends import CampaignBackendError
from wagtail_newsletter.test.models import ArticlePage


pytestmark = pytest.mark.django_db

CAMPAIGN_ID = "test-campaign-id"
CAMPAIGN_URL = "http://campaign.example.com"
EMAIL = "[email protected]"


def test_send_campaign(
page: ArticlePage, admin_client: Client, memory_backend: MemoryCampaignBackend
):
memory_backend.save_campaign = Mock(return_value=CAMPAIGN_ID)
memory_backend.get_campaign = Mock(return_value=Mock(url=CAMPAIGN_URL))
memory_backend.send_campaign = Mock()

url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": "send_campaign",
}
response = admin_client.post(url, data, follow=True)

html = response.content.decode()
assert f"Page '{page.title}' has been updated" in html
assert (
f"Newsletter campaign '{page.title}' has been saved to Testing"
in html
)
assert "Newsletter campaign is now sending" in html

assert memory_backend.save_campaign.mock_calls == [
call(campaign_id="", recipients=None, subject=page.title, html=ANY)
]
assert memory_backend.send_campaign.mock_calls == [call(CAMPAIGN_ID)]


def test_send_campaign_failed_to_send(
page: ArticlePage, admin_client: Client, memory_backend: MemoryCampaignBackend
):
memory_backend.save_campaign = Mock(return_value=CAMPAIGN_ID)
memory_backend.get_campaign = Mock(return_value=Mock(url=CAMPAIGN_URL))
memory_backend.send_campaign = Mock(side_effect=CampaignBackendError("Mock error"))

url = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.pk})
data = {
"title": page.title,
"slug": page.slug,
"newsletter-action": "send_campaign",
}
response = admin_client.post(url, data, follow=True)

assert "Mock error" in response.content.decode()
Loading

0 comments on commit ceda44a

Please sign in to comment.