Skip to content

Commit 46333aa

Browse files
authored
Task/newsletter endpoint (#2692)
* Integrate pyairtable & Newsletter plumbing - Added `pyairtable` to the project - Implemented a barebones `AirtableClient` for quick access to the pyairtable api - Implemented a NewsletterSubscriber pyairtable ORM model that: - Behaves similarly to SQLAlchemy models - Handles adding an unconfirmed subscriber, confirm subscriber, unsubscribe, update language, and reactivate subscription functionality - Added an AirtableMixin class to enhance table management allowing us to: - Check if the table associated with a model exists already - Create the table on the fly when creating models if it doesn't exist yet - Ensure implementing models define a table_schema with which to generate said table from * Add flask ctx to model, enhance mixin impl - The airtable mixin can now have the flask application context injected into it - NewsletterSubscriber's Meta class now uses static methods for the `api_key` and `base_id` as they need to be dynamically fetched from the app context - The mixin now also overrides the `pyairtable.orm.Model.save()` method, encapsulating the table generation logic within - Added unit tests * Update env vars & squash a couple client bugs * Add route `/newsletter` for newsletter management Added the following new routes for interacting with the mailing list airtable client / model - `/newsletter/confirm/<subscriber_id>` - `/newsletter/unconfirmed-subscriber` - `/newsletter/unsubscribe/<subscriber_id>` - `/newsletter/update-language/<subscriber_id>` - `/newsletter/find-subscriber` * Fix typing issues and tests * Correct route methods & cleanup tests * Refresh poetry.lock * Refresh poetry.lock * Fix tests * Use a separate airtable for staging * Small typo fix * Added a `/resubscribe` endpoint - Updated endpoints to make use of `InvalidRequest` instead of jsonifying error messages - Updated tests * Update auth requirements for newsletter endpoint - Clean up ugly tests - Use request args for `/find-subscriber` * Cleanup config * Add confirmation email sending
1 parent 6dcd851 commit 46333aa

File tree

7 files changed

+438
-2
lines changed

7 files changed

+438
-2
lines changed

app/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def register_blueprint(application):
206206
from app.job.rest import job_blueprint
207207
from app.letter_branding.letter_branding_rest import letter_branding_blueprint
208208
from app.letters.rest import letter_job
209+
from app.newsletter.rest import newsletter_blueprint
209210
from app.notifications.notifications_letter_callback import (
210211
letter_callback_blueprint,
211212
)
@@ -289,6 +290,8 @@ def register_blueprint(application):
289290

290291
register_notify_blueprint(application, report_blueprint, requires_admin_auth)
291292

293+
register_notify_blueprint(application, newsletter_blueprint, requires_admin_auth)
294+
292295

293296
def register_v2_blueprints(application):
294297
from app.authentication.auth import requires_auth, requires_no_auth

app/clients/airtable/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def __init__(self, **fields):
9494
created_at = F.DatetimeField("Created At")
9595
confirmed_at = F.DatetimeField("Confirmed At")
9696
unsubscribed_at = F.DatetimeField("Unsubscribed At")
97-
has_resubscribed = F.CheckboxField("HasResubscribed")
97+
has_resubscribed = F.CheckboxField("Has Resubscribed")
9898

9999
class Languages(Enum):
100100
EN = "en"
@@ -152,6 +152,7 @@ def reactivate_subscription(self, language: str) -> SaveResult:
152152
self.status = self.Statuses.SUBSCRIBED.value
153153
self.language = language
154154
self.has_resubscribed = True
155+
self.confirmed_at = datetime.now()
155156
return self.save()
156157

157158
@classmethod

app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ class Config(object):
213213

214214
# Airtable
215215
AIRTABLE_API_KEY = os.getenv("AIRTABLE_API_KEY")
216-
AIRTABLE_NEWSLETTER_BASE_ID = os.getenv("AIRTABLE_NEWSLETTER_BASE_ID")
216+
AIRTABLE_NEWSLETTER_BASE_ID = os.getenv("AIRTABLE_NEWSLETTER_BASE_ID", "appCP2c4xvXxQOfhN")
217217
AIRTABLE_NEWSLETTER_TABLE_NAME = os.getenv("AIRTABLE_NEWSLETTER_TABLE_NAME", "Mailing List")
218218

219219
# Salesforce

app/newsletter/__init__.py

Whitespace-only changes.

app/newsletter/rest.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from flask import Blueprint, current_app, jsonify, request
2+
3+
from app.clients.airtable.models import NewsletterSubscriber
4+
from app.config import QueueNames
5+
from app.dao.templates_dao import dao_get_template_by_id
6+
from app.errors import InvalidRequest, register_errors
7+
from app.models import EMAIL_TYPE, KEY_TYPE_NORMAL, Service
8+
from app.notifications.process_notifications import persist_notification, send_notification_to_queue
9+
10+
newsletter_blueprint = Blueprint("newsletter", __name__, url_prefix="/newsletter")
11+
register_errors(newsletter_blueprint)
12+
13+
14+
@newsletter_blueprint.route("/unconfirmed-subscriber", methods=["POST"])
15+
def create_unconfirmed_subscription():
16+
data = request.get_json()
17+
email = data.get("email")
18+
language = data.get("language", "en")
19+
20+
if not email:
21+
raise InvalidRequest("Email is required", status_code=400)
22+
23+
# Create a new unconfirmed subscriber
24+
subscriber = NewsletterSubscriber(email=email, language=language)
25+
result = subscriber.save_unconfirmed_subscriber()
26+
27+
# Check if the save operation succeeded
28+
if not result.saved:
29+
current_app.logger.error("Failed to create unconfirmed mailing list subscriber. Record was not saved")
30+
raise InvalidRequest("Failed to create unconfirmed mailing list subscriber.", status_code=500)
31+
32+
send_confirmation_email(subscriber.id, subscriber.email, subscriber.language)
33+
34+
return jsonify(result="success", subscriber_id=subscriber.id), 201
35+
36+
37+
@newsletter_blueprint.route("/confirm/<subscriber_id>", methods=["POST"])
38+
def confirm_subscription(subscriber_id):
39+
subscriber = NewsletterSubscriber.from_id(record_id=subscriber_id)
40+
41+
if not subscriber:
42+
raise InvalidRequest("Subscriber not found", status_code=404)
43+
44+
result = subscriber.confirm_subscription()
45+
46+
if not result.saved:
47+
current_app.logger.error(
48+
f"Failed to confirm newsletter subscription for subscriber_id: {subscriber.id}. Record was not saved"
49+
)
50+
raise InvalidRequest("Subscription confirmation failed", status_code=500)
51+
52+
return jsonify(result="success", message="Subscription confirmed", subscriber_id=subscriber.id), 200
53+
54+
55+
@newsletter_blueprint.route("/unsubscribe/<subscriber_id>", methods=["POST"])
56+
def unsubscribe(subscriber_id):
57+
subscriber = NewsletterSubscriber.from_id(subscriber_id)
58+
59+
if not subscriber:
60+
raise InvalidRequest("Subscriber not found", status_code=404)
61+
62+
result = subscriber.unsubscribe_user()
63+
64+
if not result.saved:
65+
current_app.logger.error(f"Failed to unsubscribe newsletter subscriber: {subscriber.id}. Record was not saved")
66+
raise InvalidRequest("Unsubscription failed", status_code=500)
67+
68+
return jsonify(result="success", message="Unsubscribed successfully", subscriber_id=subscriber.id), 200
69+
70+
71+
@newsletter_blueprint.route("/update-language/<subscriber_id>", methods=["POST"])
72+
def update_language_preferences(subscriber_id):
73+
data = request.get_json()
74+
new_language = data.get("language")
75+
76+
if not new_language:
77+
raise InvalidRequest("New language is required", status_code=400)
78+
79+
subscriber = NewsletterSubscriber.from_id(subscriber_id)
80+
81+
if not subscriber:
82+
raise InvalidRequest("Subscriber not found", status_code=404)
83+
84+
result = subscriber.update_language(new_language)
85+
86+
if not result.saved:
87+
current_app.logger.error(
88+
f"Failed to update language preferences for newsletter subscriber: {subscriber.id}. Record was not saved"
89+
)
90+
raise InvalidRequest("Language update failed", status_code=500)
91+
92+
return jsonify(result="success", message="Language updated successfully", subscriber_id=subscriber.id), 200
93+
94+
95+
@newsletter_blueprint.route("/resubscribe/<subscriber_id>", methods=["POST"])
96+
def reactivate_subscription(subscriber_id):
97+
data = request.get_json()
98+
language = data.get("language")
99+
100+
if not language:
101+
raise InvalidRequest("Language is required to resubscribe", status_code=400)
102+
103+
subscriber = NewsletterSubscriber.from_id(subscriber_id)
104+
105+
if not subscriber:
106+
raise InvalidRequest("Subscriber not found", status_code=404)
107+
108+
result = subscriber.reactivate_subscription(language)
109+
110+
if not result.saved:
111+
current_app.logger.error(
112+
f"Failed to reactivate newsletter subscription for subscriber: {subscriber.id}. Record was not saved"
113+
)
114+
raise InvalidRequest("Resubscription failed", status_code=500)
115+
116+
return jsonify(result="success", message="Resubscribed successfully", subscriber_id=subscriber.id), 200
117+
118+
119+
@newsletter_blueprint.route("/find-subscriber", methods=["GET"])
120+
def get_subscriber():
121+
subscriber_id = request.args.get("subscriber_id")
122+
email = request.args.get("email")
123+
124+
if not subscriber_id and not email:
125+
raise InvalidRequest("Subscriber ID or email is required", status_code=400)
126+
127+
if subscriber_id:
128+
subscriber = NewsletterSubscriber.from_id(subscriber_id)
129+
elif email:
130+
subscriber = NewsletterSubscriber.from_email(email)
131+
132+
if not subscriber:
133+
raise InvalidRequest("Subscriber not found", status_code=404)
134+
135+
subscriber_data = {
136+
"id": subscriber.id,
137+
"email": subscriber.email,
138+
"language": subscriber.language,
139+
"status": subscriber.status,
140+
"created_at": subscriber.created_at,
141+
"confirmed_at": subscriber.confirmed_at,
142+
"unsubscribed_at": subscriber.unsubscribed_at,
143+
}
144+
145+
return jsonify(result="success", subscriber=subscriber_data), 200
146+
147+
148+
def send_confirmation_email(subscriber_id, recipient_email, language):
149+
template_id = (
150+
current_app.config["NEWSLETTER_CONFIRMATION_EMAIL_TEMPLATE_ID_EN"]
151+
if language == "en"
152+
else current_app.config["NEWSLETTER_CONFIRMATION_EMAIL_TEMPLATE_ID_FR"]
153+
)
154+
template = dao_get_template_by_id(template_id)
155+
service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"])
156+
157+
from notifications_utils.url_safe_token import generate_token
158+
159+
token = generate_token(subscriber_id, current_app.config["SECRET_KEY"])
160+
# TODO: update this URL when we know for sure what the admin endpoint will be
161+
url = f"{current_app.config["ADMIN_BASE_URL"]}/newsletter-subscription/confirm/{token}"
162+
163+
saved_notification = persist_notification(
164+
template_id=template_id,
165+
template_version=template.version,
166+
recipient=recipient_email,
167+
service=service,
168+
personalisation={"confirmation_link": url},
169+
notification_type=EMAIL_TYPE,
170+
api_key_id=None,
171+
key_type=KEY_TYPE_NORMAL,
172+
)
173+
174+
send_notification_to_queue(saved_notification, False, queue=QueueNames.NOTIFY)

tests/app/newsletter/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)