From 6b37ec2c7e520cf08c3c2fcc632b2acee82ad225 Mon Sep 17 00:00:00 2001 From: Tom Usher Date: Thu, 5 Sep 2024 07:59:50 +0000 Subject: [PATCH 1/3] Replace Campaign Monitor with Mailchimp - Mailchimp tags are now synced to MailingListSegments - Subscriptions create/update contacts on the Mailchimp list - Job Title/Organization removed from subscription form as these are no longer collected --- ..._campaign_monitor.py => sync_mailchimp.py} | 2 +- home/wagtail_hooks.py | 4 +- newamericadotorg/api/subscribe/views.py | 22 +-- .../react/home-panels/pages/Subscribe.js | 2 - .../program-page/components/Subscribe.js | 4 - newamericadotorg/settings/base.py | 6 +- pyproject.toml | 1 - requirements.txt | 3 - subscribe/campaign_monitor.py | 74 ---------- subscribe/mailchimp.py | 130 ++++++++++++++++++ subscribe/models.py | 2 +- ..._monitor_sync.html => mailchimp_sync.html} | 4 +- subscribe/views.py | 16 ++- subscribe/wagtail_hooks.py | 10 +- 14 files changed, 160 insertions(+), 120 deletions(-) rename home/management/commands/{sync_campaign_monitor.py => sync_mailchimp.py} (72%) delete mode 100644 subscribe/campaign_monitor.py create mode 100644 subscribe/mailchimp.py rename subscribe/templates/wagtailadmin/{campaign_monitor_sync.html => mailchimp_sync.html} (76%) diff --git a/home/management/commands/sync_campaign_monitor.py b/home/management/commands/sync_mailchimp.py similarity index 72% rename from home/management/commands/sync_campaign_monitor.py rename to home/management/commands/sync_mailchimp.py index 468cd3bec..86936477c 100644 --- a/home/management/commands/sync_campaign_monitor.py +++ b/home/management/commands/sync_mailchimp.py @@ -1,5 +1,5 @@ from django.core.management.base import BaseCommand -from subscribe.campaign_monitor import update_segments +from subscribe.mailchimp import update_segments class Command(BaseCommand): diff --git a/home/wagtail_hooks.py b/home/wagtail_hooks.py index 16fb5d781..68ebe073e 100644 --- a/home/wagtail_hooks.py +++ b/home/wagtail_hooks.py @@ -37,8 +37,8 @@ def register_command_urls(): @hooks.register("register_admin_menu_item") def register_commands_menu_item(): sync_menu_item = AdminOnlyMenuItem( - "Sync Campaign Monitor", - reverse("campaign_monitor:sync"), + "Sync Mailchimp", + reverse("mailchimp:sync"), classname="icon icon-mail", order=10, ) diff --git a/newamericadotorg/api/subscribe/views.py b/newamericadotorg/api/subscribe/views.py index 2f52602d9..322e20c08 100644 --- a/newamericadotorg/api/subscribe/views.py +++ b/newamericadotorg/api/subscribe/views.py @@ -6,7 +6,7 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response -from subscribe.campaign_monitor import update_subscriber +from subscribe.mailchimp import update_subscriber from subscribe.models import MailingListSegment @@ -29,24 +29,16 @@ def subscribe(request): return Response({"status": "UNVERIFIED"}) subscriptions = params.getlist("subscriptions[]", []) - subscription_titles = MailingListSegment.objects.filter( + subscription_titles = list(MailingListSegment.objects.filter( pk__in=subscriptions, - ).values_list('title', flat=True) - job_title = params.get("job_title", None) - org = params.get("organization", None) + ).values_list('title', flat=True)) + zipcode = params.get("zipcode", None) - custom_fields = [] + custom_fields = {} - if job_title: - custom_fields.append({"key": "JobTitle", "value": job_title}) - if org: - custom_fields.append({"key": "Organization", "value": org}) if zipcode: - custom_fields.append({"key": "MailingZip/PostalCode", "value": zipcode}) - if subscription_titles: - for s in subscription_titles: - custom_fields.append({"key": "Subscriptions", "value": s}) + custom_fields['ZIP'] = zipcode - status = update_subscriber(params.get("email"), params.get("name"), custom_fields) + status = update_subscriber(params.get("email"), params.get("name"), subscription_titles, custom_fields) return Response({"status": status}) diff --git a/newamericadotorg/assets/react/home-panels/pages/Subscribe.js b/newamericadotorg/assets/react/home-panels/pages/Subscribe.js index 6ae29f275..fce50c31c 100644 --- a/newamericadotorg/assets/react/home-panels/pages/Subscribe.js +++ b/newamericadotorg/assets/react/home-panels/pages/Subscribe.js @@ -27,8 +27,6 @@ export class HomeSubscribe extends Subscribe {
- - recaptchaInstance = e} diff --git a/newamericadotorg/assets/react/program-page/components/Subscribe.js b/newamericadotorg/assets/react/program-page/components/Subscribe.js index 1654f1304..cb4aea6c9 100644 --- a/newamericadotorg/assets/react/program-page/components/Subscribe.js +++ b/newamericadotorg/assets/react/program-page/components/Subscribe.js @@ -68,8 +68,6 @@ export default class Subscribe extends Component { params: { email: email || '', name: '', - organization: '', - job_title: '', zipcode: '', }, subscriptions @@ -210,8 +208,6 @@ export default class Subscribe extends Component {
1 && 'col-md-6'}`}> - - recaptchaInstance = e} diff --git a/newamericadotorg/settings/base.py b/newamericadotorg/settings/base.py index 6c9d88f09..fd336ef86 100644 --- a/newamericadotorg/settings/base.py +++ b/newamericadotorg/settings/base.py @@ -408,8 +408,8 @@ CSP_REPORT_URI = os.environ.get('CSP_REPORT_URI') CSP_REPORT_PERCENTAGE = 0.05 -CREATESEND_API_KEY = os.getenv("CREATESEND_API_KEY") -CREATESEND_CLIENTID = os.getenv("CREATESEND_CLIENTID") -CREATESEND_LISTID = os.getenv("CREATESEND_LISTID") +MAILCHIMP_HOST = os.getenv("MAILCHIMP_HOST") +MAILCHIMP_API_KEY = os.getenv("MAILCHIMP_API_KEY") +MAILCHIMP_LIST_ID = os.getenv("MAILCHIMP_LIST_ID") RECAPTCHA_SECRET_KEY = os.getenv("NEW_RECAPTCHA_SECRET_KEY") diff --git a/pyproject.toml b/pyproject.toml index 030251cac..3c358104e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ # Other packages 'Wand >= 0.6.11', - 'createsend >= 7.0, <8', 'psycopg2 >=2.8, <3', 'WeasyPrint==51', 'python-docx >= 0.8.11', diff --git a/requirements.txt b/requirements.txt index 7cfce015b..f41b29807 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,8 +32,6 @@ cffi==1.15.1 # weasyprint charset-normalizer==3.1.0 # via requests -createsend==7.0.0 - # via newamerica-cms (pyproject.toml) cssselect2==0.7.0 # via # cairosvg @@ -167,7 +165,6 @@ sentry-sdk==1.19.1 # via newamerica-cms (pyproject.toml) six==1.16.0 # via - # createsend # html5lib # l18n # python-dateutil diff --git a/subscribe/campaign_monitor.py b/subscribe/campaign_monitor.py deleted file mode 100644 index d550ae985..000000000 --- a/subscribe/campaign_monitor.py +++ /dev/null @@ -1,74 +0,0 @@ -import traceback - -import createsend -from django.conf import settings - -from subscribe.models import MailingListSegment - -auth = {"api_key": settings.CREATESEND_API_KEY} - - -def update_segments(): - newamerica_list = createsend.List(auth, settings.CREATESEND_LISTID) - - fields = newamerica_list.custom_fields() - segments = None - for f in fields: - if f.FieldName == "Subscriptions": - segments = set(f.FieldOptions) - - existing_segments = set( - MailingListSegment.objects.values_list("title", flat=True) - ) - created = 0 - for segment in segments.difference(existing_segments): - MailingListSegment.objects.create(title=segment) - created += 1 - return created - - -def update_subscriber(email, name, custom_fields): - subscriber = createsend.Subscriber(auth, settings.CREATESEND_LISTID, email) - try: - s = subscriber.get() - # prevent overwriting Subscriptions by merging existing data - for cf in s.CustomFields: - exists = False - for new_cf in custom_fields: - if cf.Key == "Subscriptions" and cf.Value == new_cf["value"]: - exists = True - if not exists: - custom_fields.append({"key": cf.Key, "value": cf.Value}) - - subscriber.update(email, name, custom_fields, True, "Unchanged") - except createsend.BadRequest: - try: - subscriber.add( - settings.CREATESEND_LISTID, - email, - name, - custom_fields, - True, - "Unchanged", - ) - except Exception: - return "BAD_REQUEST" - - return "OK" - - -def get_error(exc_info): - exc_type, exc_val, exc_tb = exc_info - tb = traceback.extract_tb(exc_tb) - cause = tb[len(tb) - 1] - - filename = cause[0] - line = cause[1] - msg = traceback.format_exception_only(exc_type, exc_val)[0].replace( - "\n", "; " - ) - - error = 'file=%s line=%s msg="%s"' % (filename, line, msg) - context = {"traceback": traceback.format_tb(exc_tb)} - - return {"msg": error, "context": context} diff --git a/subscribe/mailchimp.py b/subscribe/mailchimp.py new file mode 100644 index 000000000..6ff72d00d --- /dev/null +++ b/subscribe/mailchimp.py @@ -0,0 +1,130 @@ +import traceback +import urllib.parse +import requests +import hashlib +from enum import Enum +from django.conf import settings +from urllib.parse import urljoin + +from subscribe.models import MailingListSegment + + +class StatusEnum(Enum): + OK = "OK" + BAD_REQUEST = "BAD_REQUEST" + + +class MailchimpError(Exception): + pass + + +class MailchimpClient: + def __init__(self, *, host, api_key): + self.api_key = api_key + self.host = host + + def _build_url(self, path): + return urljoin(f"https://{self.host}", path) + + def _do_request(self, method, path, **kwargs): + try: + response = requests.request( + method, + self._build_url(path), + headers={"Authorization": f"Bearer {self.api_key}"}, + **kwargs, + ) + return response.json() + except requests.exceptions.RequestException as e: + raise MailchimpError(f"Request to Mailchimp failed") from e + + def get(self, path): + return self._do_request("GET", path) + + def post(self, path, data): + return self._do_request("POST", path, json=data) + + def put(self, path, data): + return self._do_request("PUT", path, json=data) + + def _get_subscriber_hash(self, email): + return hashlib.md5(email.lower().encode()).hexdigest() + + def get_tags(self, list_id): + tags = [] + offset = 0 + count = 100 + + while True: + response = self.get( + f"/3.0/lists/{list_id}/tag-search?count={count}&offset={offset}" + ) + tags.extend(response["tags"]) + + total_items = response["total_items"] + if offset + count >= total_items: + break + + offset += count + + return tags + + def subscribe(self, *, list_id, email, name, merge_fields, tags): + subscriber_hash = self._get_subscriber_hash(email) + return self.put( + f"/3.0/lists/{list_id}/members/{subscriber_hash}", + { + "email_address": email, + "status": "pending", + "merge_fields": merge_fields, + "tags": tags, + }, + ) + + def get_subscriber(self, *, list_id, email): + return self.get(f"/3.0/lists/{list_id}/members/{email}") + + +def _get_client(): + return MailchimpClient( + host=settings.MAILCHIMP_HOST, api_key=settings.MAILCHIMP_API_KEY + ) + + +def update_segments(): + client = _get_client() + list_id = settings.MAILCHIMP_LIST_ID + + tags = client.get_tags(list_id) + existing_tag_names = set(MailingListSegment.objects.values_list("title", flat=True)) + + # Process tags and create/update MailingListSegment objects + current_tag_names = set() + for tag in tags: + tag_name = tag["name"] + current_tag_names.add(tag_name) + + MailingListSegment.objects.update_or_create( + title=tag_name, + ) + + # Delete MailingListSegment objects for tags that no longer exist + tags_to_delete = existing_tag_names - current_tag_names + MailingListSegment.objects.filter(title__in=tags_to_delete).delete() + + return len(current_tag_names) + + +def update_subscriber(email, name, tags, custom_fields): + client = _get_client() + try: + client.subscribe( + list_id=settings.MAILCHIMP_LIST_ID, + email=email, + name=name, + merge_fields=custom_fields, + tags=tags, + ) + return StatusEnum.OK.value + except MailchimpError as e: + return StatusEnum.BAD_REQUEST.value diff --git a/subscribe/models.py b/subscribe/models.py index 7d26a7ec5..9f24af23f 100644 --- a/subscribe/models.py +++ b/subscribe/models.py @@ -12,7 +12,7 @@ class MailingListSegment(models.Model): class Meta: permissions = [ - ("can_sync_from_campaign_monitor", "Can sync from Campaign Monitor"), + ("can_sync_from_mailchimp", "Can sync from Mailchimp"), ] def __str__(self): diff --git a/subscribe/templates/wagtailadmin/campaign_monitor_sync.html b/subscribe/templates/wagtailadmin/mailchimp_sync.html similarity index 76% rename from subscribe/templates/wagtailadmin/campaign_monitor_sync.html rename to subscribe/templates/wagtailadmin/mailchimp_sync.html index dc70268ff..9e38ff7ed 100644 --- a/subscribe/templates/wagtailadmin/campaign_monitor_sync.html +++ b/subscribe/templates/wagtailadmin/mailchimp_sync.html @@ -1,9 +1,9 @@ {% extends "wagtailadmin/base.html" %} -{% block titletag %}Commands: Sync Campaign Monitor{% endblock %} +{% block titletag %}Commands: Sync Mailchimp{% endblock %} {% block content %} - {% include "wagtailadmin/shared/header.html" with title="Sync Campaign Monitor" icon="mail" %} + {% include "wagtailadmin/shared/header.html" with title="Sync Mailchimp" icon="mail" %}
{% csrf_token %} diff --git a/subscribe/views.py b/subscribe/views.py index f2411f156..3497a7e2d 100644 --- a/subscribe/views.py +++ b/subscribe/views.py @@ -4,14 +4,16 @@ from wagtail.admin import messages from wagtail.admin.auth import permission_required -from subscribe.campaign_monitor import update_segments +from subscribe.mailchimp import update_segments -@permission_required("subscribe.can_sync_from_campaign_monitor") -def campaign_monitor_sync_view(request): - if request.method == 'POST': +@permission_required("subscribe.can_sync_from_mailchimp") +def mailchimp_sync_view(request): + if request.method == "POST": created = update_segments() - messages.success(request, f"Campaign Monitor sync complete: {created} segments created.") - return redirect(reverse('wagtailadmin_home')) + messages.success( + request, f"Mailchimp sync complete: {created} segments created." + ) + return redirect(reverse("wagtailadmin_home")) else: - return TemplateResponse(request, 'wagtailadmin/campaign_monitor_sync.html') + return TemplateResponse(request, "wagtailadmin/mailchimp_sync.html") diff --git a/subscribe/wagtail_hooks.py b/subscribe/wagtail_hooks.py index fd7882cd7..dd1f87814 100644 --- a/subscribe/wagtail_hooks.py +++ b/subscribe/wagtail_hooks.py @@ -1,26 +1,26 @@ from django.urls import include, path from wagtail import hooks -from .views import campaign_monitor_sync_view +from .views import mailchimp_sync_view @hooks.register("register_admin_urls") def register_commands_urls(): return [ path( - "campaign_monitor/", + "mailchimp/", include( ( [ path( "", - campaign_monitor_sync_view, + mailchimp_sync_view, name="sync", ) ], - "campaign_monitor", + "mailchimp", ), - namespace="campaign_monitor", + namespace="mailchimp", ), ) ] From f3478e1011db56b546b2a56e70170bce2bf38195 Mon Sep 17 00:00:00 2001 From: Tom Usher Date: Thu, 5 Sep 2024 08:34:51 +0000 Subject: [PATCH 2/3] Add migration to rename sync permission --- .../migrations/0005_rename_sync_permission.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 subscribe/migrations/0005_rename_sync_permission.py diff --git a/subscribe/migrations/0005_rename_sync_permission.py b/subscribe/migrations/0005_rename_sync_permission.py new file mode 100644 index 000000000..24c3733fd --- /dev/null +++ b/subscribe/migrations/0005_rename_sync_permission.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.18 on 2024-09-05 08:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscribe', '0004_remove_legacy_subscribe_page_models'), + ] + + operations = [ + migrations.AlterModelOptions( + name='mailinglistsegment', + options={'permissions': [('can_sync_from_mailchimp', 'Can sync from Mailchimp')]}, + ), + ] From 6311f2451b0d63acf166b67fd05062c902ec9055 Mon Sep 17 00:00:00 2001 From: Tom Usher Date: Thu, 5 Sep 2024 09:36:24 +0000 Subject: [PATCH 3/3] Improve error handling --- subscribe/mailchimp.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/subscribe/mailchimp.py b/subscribe/mailchimp.py index 6ff72d00d..5b0f53a67 100644 --- a/subscribe/mailchimp.py +++ b/subscribe/mailchimp.py @@ -5,9 +5,11 @@ from enum import Enum from django.conf import settings from urllib.parse import urljoin - +import logging from subscribe.models import MailingListSegment +logger = logging.getLogger(__name__) + class StatusEnum(Enum): OK = "OK" @@ -32,8 +34,10 @@ def _do_request(self, method, path, **kwargs): method, self._build_url(path), headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=10, **kwargs, ) + response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: raise MailchimpError(f"Request to Mailchimp failed") from e @@ -48,7 +52,7 @@ def put(self, path, data): return self._do_request("PUT", path, json=data) def _get_subscriber_hash(self, email): - return hashlib.md5(email.lower().encode()).hexdigest() + return hashlib.md5(email.lower().strip().encode()).hexdigest() def get_tags(self, list_id): tags = [] @@ -86,9 +90,17 @@ def get_subscriber(self, *, list_id, email): def _get_client(): - return MailchimpClient( - host=settings.MAILCHIMP_HOST, api_key=settings.MAILCHIMP_API_KEY - ) + host = settings.MAILCHIMP_HOST + api_key = settings.MAILCHIMP_API_KEY + + if not host: + raise ValueError( + "MAILCHIMP_HOST is not set. Set this to the base API host for your Mailchimp account, for example: us22.api.mailchimp.com" + ) + if not api_key: + raise ValueError("MAILCHIMP_API_KEY is not set. Set this to your Mailchimp API key.") + + return MailchimpClient(host=host, api_key=api_key) def update_segments(): @@ -126,5 +138,6 @@ def update_subscriber(email, name, tags, custom_fields): tags=tags, ) return StatusEnum.OK.value - except MailchimpError as e: + except MailchimpError: + logger.warning("Failed to create subscriber in Mailchimp", exc_info=True) return StatusEnum.BAD_REQUEST.value