diff --git a/website/auth.py b/website/auth.py index 6e2e38069d9..af1065f3958 100644 --- a/website/auth.py +++ b/website/auth.py @@ -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 @@ -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 @@ -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, - "
" + email + "
Status:" + str(r.status_code) + " Body:" + r.text + "", - ) - @querylog.timed def check_password(password, hash): diff --git a/website/auth_pages.py b/website/auth_pages.py index d708fdad6d7..484c1ee3b98 100644 --- a/website/auth_pages.py +++ b/website/auth_pages.py @@ -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, @@ -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, @@ -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"], - "
" + user["email"] + "
", - ) + create_subscription(user["email"], body.get("country")) # We automatically login the user cookie = make_salt() diff --git a/website/classes.py b/website/classes.py index 14c86ec7db0..0418278ccb7 100644 --- a/website/classes.py +++ b/website/classes.py @@ -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 @@ -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) diff --git a/website/for_teachers.py b/website/for_teachers.py index b84de674a23..af5b39086c7 100644 --- a/website/for_teachers.py +++ b/website/for_teachers.py @@ -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, @@ -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', @@ -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', @@ -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, @@ -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) @@ -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/{email}
") + + +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 hello@hedy.org 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"{email}
Status:{r.status_code} Body:{r.text}") + + +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() diff --git a/website/profile.py b/website/profile.py index a68dbd1eadd..5d82a485e47 100644 --- a/website/profile.py +++ b/website/profile.py @@ -1,7 +1,5 @@ import datetime -import hashlib -import requests from flask import make_response, request, session from website.flask_helpers import gettext_with_fallback as gettext @@ -9,17 +7,15 @@ 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 @@ -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) @@ -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 = {} @@ -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)