Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions api/src/shared/notifications/brevo_notification_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
from datetime import datetime, timezone
from html import escape as _html_escape
from typing import Any, Dict, List, Optional
from urllib.parse import quote

from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
Expand Down Expand Up @@ -130,16 +131,29 @@ def _feed_page_url(stable_id: Optional[str]) -> Optional[str]:
return f"{_website_url()}/feeds/{stable_id}" if stable_id else None


# Website route backing the one-click unsubscribe link. It reads the subscription
# id from the ``id`` query param and calls ``DELETE /v1/subscriptions/{id}``
# server-side (see docs/notifications-subscription-flows.md).
_UNSUBSCRIBE_PATH = "/notifications/unsubscribe"


def _subscriptions_management_url() -> str:
"""Link to the subscriptions management page.
"""Link to the account page where a signed-in user manages their subscriptions
(the "Manage subscriptions" footer link)."""
return f"{_website_url()}/account/notifications"


Used for both the "Manage subscriptions" and "Unsubscribe" footer links —
there is no one-click unsubscribe endpoint yet (see docs/notifications.md
"Future Work"), so "Unsubscribe" currently points here too rather than to
a dead link.
TODO: implement a one-click unsubscribe endpoint and update the footer link to point there.
def _unsubscribe_url(subscription_id: Optional[str]) -> Optional[str]:
"""One-click unsubscribe link for a specific subscription, keyed on its id.

The id is the subscription's own UUID (``notification_subscription.id``) — the
capability the unsubscribe page passes to ``DELETE /v1/subscriptions/{id}``.
Returns None when no id is available, so the footer omits the "Unsubscribe"
link rather than pointing at an incomplete URL.
"""
return f"{_website_url()}/account/notifications"
if not subscription_id:
return None
return f"{_website_url()}{_UNSUBSCRIBE_PATH}?id={quote(str(subscription_id), safe='')}"


# Brevo's transactional email API allows up to 1000 requests/second. Default
Expand Down Expand Up @@ -314,6 +328,7 @@ def build_params_feed_url_updated(events: List, subscription):
return {
"event_count": len(events),
"subscription_id": subscription.id,
"unsubscribe_url": _unsubscribe_url(subscription.id),
"events": [_event_row_dict(e) for e in events],
}

Expand All @@ -323,6 +338,7 @@ def build_params_admin_event_summary(events: List, subscription):
return {
"event_count": len(events),
"subscription_id": subscription.id,
"unsubscribe_url": _unsubscribe_url(subscription.id),
"summary": event_payload(summary_event) if summary_event else {},
}

Expand All @@ -345,8 +361,11 @@ def _subscription_footer_context(subscription) -> Dict[str, str]:
(e.g. a template rendered outside the subscription-driven send path)."""
if subscription is None:
return {}
url = _subscriptions_management_url()
return {"subscriptions_url": url, "unsubscribe_url": url}
context = {"subscriptions_url": _subscriptions_management_url()}
unsubscribe_url = _unsubscribe_url(subscription.id)
if unsubscribe_url:
context["unsubscribe_url"] = unsubscribe_url
return context


def build_single_html(event, subscription=None) -> str:
Expand Down
55 changes: 45 additions & 10 deletions api/src/user_service/impl/subscriptions_api_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#

import logging
from typing import Final

from fastapi import HTTPException

Expand All @@ -37,11 +38,15 @@

logger = logging.getLogger(__name__)

# Values of the ?scope query parameter on DELETE /v1/subscriptions/{id}.
SCOPE_ALL: Final = "all"


class SubscriptionsApiImpl(BaseSubscriptionsApi):
"""Public, unauthenticated subscription management.
"""Subscription management keyed on the subscription ID, with no end-user auth.

The subscription UUID is the access capability
The subscription UUID (``notification_subscription.id``) is the access
capability; these methods never read the request context.
"""

@with_users_db_session
Expand All @@ -54,10 +59,17 @@ def get_subscription(self, id: str, db_session=None) -> NotificationSubscription
return NotificationSubscriptionImpl.from_orm(sub, feed_metadata)

@with_users_db_session
def delete_subscription(self, id: str, db_session=None) -> None:
"""Removes a subscription by ID.
def delete_subscription(self, id: str, scope: str = None, db_session=None) -> None:
"""Unsubscribe by subscription ID.

``scope='all'`` uses ``id`` only to resolve the owning user and
unsubscribes that user from every notification type they hold. Any other
value (the default ``'one'``) affects just the subscription identified by
``id``.

The announcements subscription cannot be deleted; it is disabled instead.
In both cases the ``api.announcements`` subscription is never deleted, only
disabled (and its Brevo contact + opt-in flag are cleared); every other
type is hard-deleted.
"""
sub = db_session.get(NotificationSubscriptionOrm, id)
if sub is None:
Expand All @@ -75,22 +87,45 @@ def delete_subscription(self, id: str, db_session=None) -> None:
)
raise HTTPException(status_code=403, detail=ERROR_MESSAGE_USER_FEATURE_NOT_ENABLED)

if scope == SCOPE_ALL:
subscriptions = (
db_session.query(NotificationSubscriptionOrm)
.filter(NotificationSubscriptionOrm.user_id == sub.user_id)
.all()
)
# Keep the whole batch in one transaction: release_connection_before_brevo
# stays False so a Brevo failure on the announcements row rolls back every
# deletion in this call instead of leaving the user half-unsubscribed.
for subscription in subscriptions:
self._unsubscribe(db_session, subscription, release_connection_before_brevo=False)
else:
# Single unsubscribe: only reads happened above, so the announcements
# path can commit and release the pooled connection before the
# (possibly slow) Brevo call.
self._unsubscribe(db_session, sub, release_connection_before_brevo=True)

db_session.flush()

@staticmethod
def _unsubscribe(
db_session,
sub: NotificationSubscriptionOrm,
*,
release_connection_before_brevo: bool,
) -> None:
"""Disable-and-sync an ``api.announcements`` subscription, or hard-delete any other type."""
if sub.notification_type_id == ANNOUNCEMENTS_NOTIFICATION_TYPE_ID:
user = db_session.get(AppUser, sub.user_id)
if user is not None:
# release_connection_before_brevo=True: only reads happened above,
# so committing just returns the pooled connection before the
# (possibly slow) Brevo call. Also clears the app_user opt-in flag.
set_announcements_optin(
db_session,
user,
subscribe=False,
subscription=sub,
release_connection_before_brevo=True,
release_connection_before_brevo=release_connection_before_brevo,
)
else:
# Orphaned subscription (no owning user): just disable it.
sub.active = False
else:
db_session.delete(sub)
db_session.flush()
45 changes: 45 additions & 0 deletions api/tests/unittest/test_brevo_notification_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
target_feed,
_int_env,
_send,
_unsubscribe_url,
)
from shared.notifications.notification_constants import (
FeedUrlUpdateType,
Expand Down Expand Up @@ -202,6 +203,7 @@ def test_build_params_feed_url_updated():
params = build_params_feed_url_updated([event], _SUBSCRIPTION)
assert params["event_count"] == 1
assert params["subscription_id"] == "sub-1"
assert params["unsubscribe_url"] == "https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"
item = params["events"][0]
assert item["feed_stable_id"] == "mdb-1"
assert item["target_feed_stable_id"] == "mdb-2"
Expand Down Expand Up @@ -231,12 +233,32 @@ def test_build_params_admin_event_summary_with_and_without_events():
params = build_params_admin_event_summary([event], _SUBSCRIPTION)
assert params["event_count"] == 1
assert params["summary"] == {"emails_sent": 5}
assert params["unsubscribe_url"] == "https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"

empty = build_params_admin_event_summary([], _SUBSCRIPTION)
assert empty["event_count"] == 0
assert empty["summary"] == {}


# ---------------------------------------------------------------------------
# Unsubscribe link
# ---------------------------------------------------------------------------


def test_unsubscribe_url_builds_capability_link():
assert _unsubscribe_url("sub-1") == "https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"


def test_unsubscribe_url_none_when_no_id():
assert _unsubscribe_url(None) is None
assert _unsubscribe_url("") is None


def test_unsubscribe_url_encodes_id_and_respects_website_env(monkeypatch):
monkeypatch.setenv("MOBILITY_DATABASE_WEBSITE_URL", "https://example.test/")
assert _unsubscribe_url("a/b c") == "https://example.test/notifications/unsubscribe?id=a%2Fb%20c"


def test_build_params_by_notification_dispatch_and_unsupported():
feed_event = _event()
assert "events" in build_params_by_notification(NotificationTypeId.FEED_URL_UPDATED, [feed_event], _SUBSCRIPTION)
Expand Down Expand Up @@ -276,6 +298,29 @@ def test_build_single_html_redirected_and_replaced():
assert "https://old" in html


def test_build_single_html_footer_has_manage_and_one_click_unsubscribe_links():
event = _event(feeds=[_feed("mdb-1", NotificationFeedRole.SUBJECT)], payload={})
html = build_single_html(event, _SUBSCRIPTION)
# "Manage subscriptions" points at the account page; "Unsubscribe" is the
# one-click capability link keyed on the subscription id.
assert 'href="https://mobilitydatabase.org/account/notifications"' in html
assert 'href="https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"' in html
assert "Unsubscribe" in html


def test_build_single_html_without_subscription_has_no_footer_links():
event = _event(feeds=[_feed("mdb-1", NotificationFeedRole.SUBJECT)], payload={})
html = build_single_html(event)
assert "/notifications/unsubscribe" not in html
assert "/account/notifications" not in html


def test_build_digest_html_footer_has_one_click_unsubscribe_link():
event = _event(feeds=[_feed("mdb-1", NotificationFeedRole.SUBJECT)], payload={})
html = build_digest_html([event], _SUBSCRIPTION)
assert 'href="https://mobilitydatabase.org/notifications/unsubscribe?id=sub-1"' in html


def test_build_single_html_url_replaced_shows_single_view_feed_button():
"""A plain URL update (one feed, not deprecated) must show only the single
"View Feed" CTA button — not the two-button deprecated/redirected layout."""
Expand Down
47 changes: 47 additions & 0 deletions api/tests/unittest/user_service/test_subscriptions_api_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,53 @@ def test_delete_missing_returns_404(self):
self.assertEqual(ctx.exception.status_code, 404)
self.mock_session.delete.assert_not_called()

def _wire_scope_all(self, subs, user):
"""Point the initial get() at the first sub, AppUser lookups at ``user``, and the
'all subscriptions for this user' query at ``subs``."""

def _get(model, key):
return subs[0] if model is NotificationSubscriptionOrm else user

self.mock_session.get.side_effect = _get
self.mock_session.query.return_value.filter.return_value.all.return_value = subs

def test_delete_all_deletes_normal_and_disables_announcement(self):
feed_sub = _make_sub(id="sub-feed", notification_type_id="feed.url_updated")
rt_sub = _make_sub(id="sub-rt", notification_type_id="gtfs_rt.feed_down")
ann_sub = _make_sub(id="sub-ann", notification_type_id="api.announcements")
self._wire_scope_all([feed_sub, rt_sub, ann_sub], _make_user())

with patch.object(helpers, "remove_contact_from_list") as rem, patch.object(
helpers, "get_announcements_list_id", return_value=42
):
self.api.delete_subscription("sub-feed", scope="all", db_session=self.mock_session)

# Every non-announcement subscription is hard-deleted.
deleted = {c.args[0] for c in self.mock_session.delete.call_args_list}
self.assertEqual(deleted, {feed_sub, rt_sub})
# The announcement subscription is disabled (never deleted) and removed from Brevo.
self.assertNotIn(ann_sub, deleted)
self.assertFalse(ann_sub.active)
rem.assert_called_once_with("user@example.com", 42)

def test_delete_all_without_announcement_skips_brevo(self):
feed_sub = _make_sub(id="sub-feed", notification_type_id="feed.url_updated")
self._wire_scope_all([feed_sub], _make_user())

with patch.object(helpers, "remove_contact_from_list") as rem:
self.api.delete_subscription("sub-feed", scope="all", db_session=self.mock_session)

self.mock_session.delete.assert_called_once_with(feed_sub)
rem.assert_not_called()

def test_delete_all_missing_returns_404(self):
self.mock_session.get.return_value = None
with self.assertRaises(HTTPException) as ctx:
self.api.delete_subscription("missing", scope="all", db_session=self.mock_session)
self.assertEqual(ctx.exception.status_code, 404)
self.mock_session.query.assert_not_called()
self.mock_session.delete.assert_not_called()

def test_delete_announcement_brevo_failure_502(self):
import sib_api_v3_sdk

Expand Down
Loading
Loading