Skip to content

Commit

Permalink
Add tags to mailchimp users #3995
Browse files Browse the repository at this point in the history
  • Loading branch information
boryanagoncharenko committed Dec 31, 2024
1 parent d98d11a commit 6624ab6
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 73 deletions.
40 changes: 0 additions & 40 deletions website/auth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import json
import logging
import os
import re
import urllib
from functools import wraps

import bcrypt
import boto3
import requests
from botocore.exceptions import ClientError as email_error
from botocore.exceptions import NoCredentialsError
from flask import request, session, redirect
Expand All @@ -20,7 +17,6 @@
from safe_format import safe_format
from utils import is_debug_mode, timems, times
from website import querylog

TOKEN_COOKIE_NAME = config["session"]["cookie_name"]

# A special value in the session, if this is set and we hit a 403 on the
Expand All @@ -36,42 +32,6 @@

env = os.getenv("HEROKU_APP_NAME")

MAILCHIMP_API_URL = None
MAILCHIMP_API_HEADERS = {}
if os.getenv("MAILCHIMP_API_KEY") and os.getenv("MAILCHIMP_AUDIENCE_ID"):
# The domain in the path is the server name, which is contained in the Mailchimp API key
MAILCHIMP_API_URL = (
"https://"
+ os.getenv("MAILCHIMP_API_KEY").split("-")[1]
+ ".api.mailchimp.com/3.0/lists/"
+ os.getenv("MAILCHIMP_AUDIENCE_ID")
)
MAILCHIMP_API_HEADERS = {
"Content-Type": "application/json",
"Authorization": "apikey " + os.getenv("MAILCHIMP_API_KEY"),
}


def mailchimp_subscribe_user(email, country):
# Request is always for teachers as only they can subscribe to newsletters
request_body = {"email_address": email, "status": "subscribed", "tags": [country, "teacher"]}
r = requests.post(MAILCHIMP_API_URL + "/members", headers=MAILCHIMP_API_HEADERS, data=json.dumps(request_body))

subscription_error = None
if r.status_code != 200 and r.status_code != 400:
subscription_error = True
# We can get a 400 if the email is already subscribed to the list. We should ignore this error.
if r.status_code == 400 and not re.match(".*already a list member", r.text):
subscription_error = True
# If there's an error in subscription through the API, we report it to the main email address
if subscription_error:
send_email(
config["email"]["sender"],
"ERROR - Subscription to Hedy newsletter on signup",
email,
"<p>" + email + "</p><pre>Status:" + str(r.status_code) + " Body:" + r.text + "</pre>",
)


@querylog.timed
def check_password(password, hash):
Expand Down
16 changes: 2 additions & 14 deletions website/auth_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from safe_format import safe_format
from hedy_content import ALL_LANGUAGES, COUNTRIES
from utils import extract_bcrypt_rounds, is_heroku, is_testing_request, timems, times, remove_class_preview
from website.newsletter import create_subscription
from website.auth import (
MAILCHIMP_API_URL,
RESET_LENGTH,
SESSION_LENGTH,
TOKEN_COOKIE_NAME,
Expand All @@ -20,13 +20,11 @@
forget_current_user,
is_admin,
is_teacher,
mailchimp_subscribe_user,
make_salt,
password_hash,
prepare_user_db,
remember_current_user,
requires_login,
send_email,
send_email_template,
send_localized_email_template,
validate_signup_data,
Expand Down Expand Up @@ -180,17 +178,7 @@ def signup(self):
user, resp = self.store_new_account(body, body["email"].strip().lower())

if not is_testing_request(request) and "subscribe" in body:
# If we have a Mailchimp API key, we use it to add the subscriber through the API
if MAILCHIMP_API_URL:
mailchimp_subscribe_user(user["email"], body["country"])
# Otherwise, we send an email to notify about the subscription to the main email address
else:
send_email(
config["email"]["sender"],
"Subscription to Hedy newsletter on signup",
user["email"],
"<p>" + user["email"] + "</p>",
)
create_subscription(user["email"], body.get("country"))

# We automatically login the user
cookie = make_salt()
Expand Down
2 changes: 2 additions & 0 deletions website/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from website.flask_helpers import render_template
from website.auth import current_user, is_teacher, requires_login, requires_teacher, \
refresh_current_user_from_db, is_second_teacher
from website.newsletter import add_class_created_to_subscription
from .database import Database
from .website_module import WebsiteModule, route

Expand Down Expand Up @@ -50,6 +51,7 @@ def create_class(self, user):
}

self.db.store_class(Class)
add_class_created_to_subscription(user['email'])
response = {"id": Class["id"]}
return make_response(response, 200)

Expand Down
6 changes: 6 additions & 0 deletions website/for_teachers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from website.server_types import SortedAdventure
from website.flask_helpers import render_template
from website.newsletter import add_class_customized_to_subscription
from website.auth import (
is_admin,
is_teacher,
Expand Down Expand Up @@ -887,6 +888,7 @@ def add_adventure(self, user, level):
customizations["other_settings"].remove("hide_parsons")

self.db.update_class_customizations(customizations)
add_class_customized_to_subscription(user['email'])
available_adventures = self.get_unused_adventures(adventures, teacher_adventures, adventure_names)

return render_partial('customize-class/partial-sortable-adventures.html',
Expand Down Expand Up @@ -924,6 +926,7 @@ def remove_adventure_from_class(self, user):
is_command_adventure=adventure_id in hedy_content.KEYWORDS_ADVENTURES)
adventures[int(level)].remove(sorted_adventure)
self.db.update_class_customizations(customizations)
add_class_customized_to_subscription(user['email'])
available_adventures = self.get_unused_adventures(adventures, teacher_adventures, adventure_names)

return render_partial('customize-class/partial-sortable-adventures.html',
Expand Down Expand Up @@ -967,6 +970,7 @@ def sort_adventures_in_class(self, user):
self.reorder_adventures(adventures[int(level)], from_sorted_adv_class=True)
self.reorder_adventures(customizations['sorted_adventures'][level])
self.db.update_class_customizations(customizations)
add_class_customized_to_subscription(user['email'])

return render_partial('customize-class/partial-sortable-adventures.html',
level=level,
Expand Down Expand Up @@ -1344,6 +1348,7 @@ def update_customizations(self, user, class_id):
}

self.db.update_class_customizations(customizations)
add_class_customized_to_subscription(user['email'])
response = {"success": gettext("class_customize_success")}

return make_response(response, 200)
Expand Down Expand Up @@ -1770,6 +1775,7 @@ def add_adventure_to_class_level(self, user, class_id, adventure_id, level, remo

self.reorder_adventures(customizations['sorted_adventures'][level])
self.db.update_class_customizations(customizations)
add_class_customized_to_subscription(user['email'])

@route("/create-adventure/", methods=["POST"])
@route("/create-adventure/<class_id>", methods=["POST"])
Expand Down
144 changes: 144 additions & 0 deletions website/newsletter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import os
import json
import hashlib
import requests
from config import config
from hedy_content import COUNTRIES
from website.auth import send_email


MAILCHIMP_API_URL = None
MAILCHIMP_API_HEADERS = {}
if os.getenv("MAILCHIMP_API_KEY") and os.getenv("MAILCHIMP_AUDIENCE_ID"):
# The domain in the path is the server name, which is contained in the Mailchimp API key
MAILCHIMP_API_URL = (
"https://"
+ os.getenv("MAILCHIMP_API_KEY").split("-")[1]
+ ".api.mailchimp.com/3.0/lists/"
+ os.getenv("MAILCHIMP_AUDIENCE_ID")
)
MAILCHIMP_API_HEADERS = {
"Content-Type": "application/json",
"Authorization": "apikey " + os.getenv("MAILCHIMP_API_KEY"),
}


def create_subscription(email, country):
""" Subscribes the user to the newsletter. Currently, users can subscribe to the newsletter only on signup and
only if they are creating a teacher account. """
# If there is a Mailchimp API key, use it to add the subscriber through the API
if MAILCHIMP_API_URL:
create_mailchimp_subscriber(email, [country, MailchimpTag.TEACHER])
# Otherwise, email to notify about the subscription to the main email address
else:
recipient = config["email"]["sender"]
send_email(recipient, "Subscription to Hedy newsletter on signup", email, f"<p>{email}</p>")


def update_subscription(current_email, new_email, new_country):
""" Updates the newsletter subscription when the user changes their email or/and their country """
if not MAILCHIMP_API_URL:
# TODO: Why do we send an email to [email protected] if a user subscribes and there is no mailchimp api key
# but if the user changes their email and we do not have a key, we do nothing?
return
r = get_mailchimp_subscriber(current_email)
if r.status_code == 200:
current_tags = r.json().get('tags', [])
if new_email != current_email:
# If user is subscribed, we remove the old email from the list and add the new one
new_tags = [t.get('name') for t in current_tags if t.get('name') not in COUNTRIES] + [new_country]
create_mailchimp_subscriber(new_email, new_tags)
delete_mailchimp_subscriber(current_email)
else:
# If the user did not change their email, check if the country needs to be updated
tags_to_update = get_country_tag_changes(current_tags, new_country)
if tags_to_update:
update_mailchimp_tags(current_email, tags_to_update)


def add_class_created_to_subscription(email):
create_subscription_event(email, MailchimpTag.CREATED_CLASS)


def add_class_customized_to_subscription(email):
create_subscription_event(email, MailchimpTag.CUSTOMIZED_CLASS)


def create_subscription_event(email, tag):
""" When certain events occur, e.g. a newsletter subscriber creates or customizes a class, these events
should be reflected in their subscription, so that we can send them relevant content """
if not MAILCHIMP_API_URL:
return
r = get_mailchimp_subscriber(email)
if r.status_code == 200:
current_tags = r.json().get('tags', [])
if not any([t for t in current_tags if t.get('name') == tag]):
new_tags = current_tags + [to_mailchimp_tag(tag)]
update_mailchimp_tags(email, new_tags)


def get_country_tag_changes(current_tags, country):
""" Returns the necessary alterations to the tags of the newsletter subscriber when they change their country.
The old country tag, if existing, should be removed, and the new country, if existing, should be added. """
current_country_tags = [t.get('name') for t in current_tags if t.get('name') in COUNTRIES]

if country in current_country_tags:
return []

changes = [to_mailchimp_tag(t, active=False) for t in current_country_tags]
if country:
changes.append(to_mailchimp_tag(country))
return changes


def to_mailchimp_tag(tag, active=True):
# https://mailchimp.com/developer/marketing/api/list-member-tags/
status = 'active' if active else 'inactive'
return {'name': tag, 'status': status}


class MailchimpTag:
TEACHER = 'teacher'
CREATED_CLASS = "created_class"
CUSTOMIZED_CLASS = "customized_class"


def create_mailchimp_subscriber(email, tag_names):
tag_names = [t for t in tag_names if t] # the country can be None, so filter it
request_body = {"email_address": email, "status": "subscribed", "tags": tag_names}
request_path = MAILCHIMP_API_URL + "/members/"
r = requests.post(request_path, headers=MAILCHIMP_API_HEADERS, data=json.dumps(request_body))

subscription_error = None
if r.status_code != 200 and r.status_code != 400:
subscription_error = True
# We can get a 400 if the email is already subscribed to the list. We should ignore this error.
if r.status_code == 400 and "already a list member" not in r.text:
subscription_error = True
# If there's an error in subscription through the API, we report it to the main email address
if subscription_error:
send_email(
config["email"]["sender"],
"ERROR - Subscription to Hedy newsletter",
f"email: {email} status: {r.status_code} body: {r.text}",
f"<p>{email}</p><pre>Status:{r.status_code} Body:{r.text}</pre>")


def get_mailchimp_subscriber(email):
request_path = f'{MAILCHIMP_API_URL}/members/{get_subscriber_hash(email)}'
return requests.get(request_path, headers=MAILCHIMP_API_HEADERS)


def update_mailchimp_tags(email, tags):
request_path = f'{MAILCHIMP_API_URL}/members/{get_subscriber_hash(email)}/tags'
return requests.post(request_path, headers=MAILCHIMP_API_HEADERS, data=json.dumps({'tags': tags}))


def delete_mailchimp_subscriber(email):
request_path = f'{MAILCHIMP_API_URL}/members/{get_subscriber_hash(email)}'
requests.delete(request_path, headers=MAILCHIMP_API_HEADERS)


def get_subscriber_hash(email):
""" We hash the email with md5 to avoid emails with unescaped characters triggering errors """
return hashlib.md5(email.encode("utf-8")).hexdigest()
29 changes: 10 additions & 19 deletions website/profile.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import datetime
import hashlib

import requests
from flask import make_response, request, session
from website.flask_helpers import gettext_with_fallback as gettext

from safe_format import safe_format
from hedy_content import ALL_KEYWORD_LANGUAGES, ALL_LANGUAGES, COUNTRIES
from utils import is_testing_request, timems, valid_email
from website.auth import (
MAILCHIMP_API_HEADERS,
MAILCHIMP_API_URL,
SESSION_LENGTH,
create_verify_link,
mailchimp_subscribe_user,
make_salt,
password_hash,
remember_current_user,
requires_login,
send_email_template,
)
from website.newsletter import update_subscription

from .database import Database
from .website_module import WebsiteModule, route
Expand Down Expand Up @@ -71,7 +67,7 @@ def update_profile(self, user):
if "email" in body:
email = body["email"].strip().lower()
old_user_email = user.get("email")
if email != user.get("email"):
if email != old_user_email:
exists = self.db.user_by_email(email)
if exists:
return make_response(gettext("exists_email"), 403)
Expand All @@ -95,19 +91,6 @@ def update_profile(self, user):
# Add a notification to the response, still process the changes
print(f"Profile changes processed for {user['username']}, mail sending invalid")

# We check whether the user is in the Mailchimp list.
if not is_testing_request(request) and MAILCHIMP_API_URL:
# We hash the email with md5 to avoid emails with unescaped characters triggering errors
request_path = (
MAILCHIMP_API_URL + "/members/" + hashlib.md5(old_user_email.encode("utf-8")).hexdigest()
)
r = requests.get(request_path, headers=MAILCHIMP_API_HEADERS)
# If user is subscribed, we remove the old email from the list and add the new one
if r.status_code == 200:
r = requests.delete(request_path, headers=MAILCHIMP_API_HEADERS)
self.db.get_username_role(user["username"])
mailchimp_subscribe_user(email, body["country"])

username = user["username"]

updates = {}
Expand All @@ -117,6 +100,14 @@ def update_profile(self, user):
else:
updates[field] = None

if not is_testing_request(request):
current_email = user.get('email')
current_country = user.get('country')
new_email = body.get('email', '').strip().lower()
new_country = body.get('country')
if current_email != new_email or current_country != new_country:
update_subscription(current_email, new_email, new_country)

if updates:
self.db.update_user(username, updates)

Expand Down

0 comments on commit 6624ab6

Please sign in to comment.