Skip to content
Merged
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
1 change: 1 addition & 0 deletions api/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ src/user_service_gen/models/update_notification_subscription_request.py
src/user_service_gen/models/update_user_request.py
src/user_service_gen/models/user_profile.py
src/user_service_gen/security_api.py
src/user_service_gen/models/subscription_feed.py
24 changes: 16 additions & 8 deletions api/src/shared/database/users_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,17 @@ def wrapper(*args, **kwargs):

@event.listens_for(mapper, "mapper_configured")
def _configure_users_passive_deletes(mapper_, class_):
"""Enable ``passive_deletes`` on ``NotificationSubscription.notification_logs``.
"""Enable ``delete-orphan`` + ``passive_deletes`` on ``NotificationSubscription`` children.

Deleting a subscription then relies on the database ``ON DELETE CASCADE`` to remove its
``notification_log`` rows, instead of SQLAlchemy trying to NULL the NOT NULL
``notification_log.subscription_id`` (which would raise a NotNullViolation).
Applies to both child collections:
* ``notification_logs`` — delivery history rows.
* ``notification_subscription_feeds`` — feed-scoped subscription targets (issue #1778).

Deleting a subscription then relies on the database ``ON DELETE CASCADE`` to remove these
rows, instead of SQLAlchemy trying to NULL the NOT NULL child foreign keys
(``notification_log.subscription_id`` / ``notification_subscription_feed.subscription_id``),
which would raise a NotNullViolation. ``delete-orphan`` additionally lets a feed be dropped
from a subscription simply by removing it from the in-memory collection.

This mirrors the ``set_cascade`` mechanism in ``shared.database.database`` but is scoped to
the users models, so ``users_database`` stays independent of the feeds ``database_gen``.
Expand All @@ -132,7 +138,9 @@ def _configure_users_passive_deletes(mapper_, class_):
module does not require ``shared.users_database_gen`` to be present (functions that symlink
``shared`` without the users models must still be able to import ``users_database``).
"""
if class_.__name__ == "NotificationSubscription" and "notification_logs" in mapper_.relationships:
rel = mapper_.relationships["notification_logs"]
rel.cascade = "all, delete-orphan"
rel.passive_deletes = True
if class_.__name__ == "NotificationSubscription":
for rel_name in ("notification_logs", "notification_subscription_feeds"):
if rel_name in mapper_.relationships:
rel = mapper_.relationships[rel_name]
rel.cascade = "all, delete-orphan"
rel.passive_deletes = True
22 changes: 21 additions & 1 deletion api/src/shared/db_models/notification_subscription_impl.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from shared.users_database_gen.sqlacodegen_models import NotificationSubscription as NotificationSubscriptionOrm
from user_service_gen.models.notification_subscription import NotificationSubscription
from user_service_gen.models.subscription_feed import SubscriptionFeed


class NotificationSubscriptionImpl(NotificationSubscription):
Expand All @@ -11,13 +12,32 @@ class Config:
from_attributes = True

@classmethod
def from_orm(cls, sub: NotificationSubscriptionOrm | None) -> NotificationSubscription | None:
def from_orm(
cls,
sub: NotificationSubscriptionOrm | None,
feed_metadata: dict | None = None,
) -> NotificationSubscription | None:
if not sub:
return None
# The subscription's targeted feeds come from the notification_subscription_feed join
# table (sorted by stable ID for a stable response). Each feed is enriched with
# data_type/provider/feed_name resolved (by the caller) from the feeds DB; metadata is
# absent for a feed that no longer exists, so its fields stay null.
feed_metadata = feed_metadata or {}
feeds = [
SubscriptionFeed(
feed_id=stable_id,
data_type=feed_metadata.get(stable_id, {}).get("data_type"),
provider=feed_metadata.get(stable_id, {}).get("provider"),
feed_name=feed_metadata.get(stable_id, {}).get("feed_name"),
)
for stable_id in sorted(f.feed_stable_id for f in sub.notification_subscription_feeds)
]
return cls(
id=sub.id,
user_id=sub.user_id,
notification_id=sub.notification_type_id,
active=sub.active,
created_at=sub.created_at,
feeds=feeds or None,
)
20 changes: 20 additions & 0 deletions api/src/shared/db_models/notification_type_impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from shared.users_database_gen.sqlacodegen_models import NotificationType as NotificationTypeOrm
from user_service_gen.models.notification_type import NotificationType


class NotificationTypeImpl(NotificationType):
"""Implementation of the NotificationType model.
Converts a SQLAlchemy NotificationType ORM object to a Pydantic NotificationType model.
"""

class Config:
from_attributes = True

@classmethod
def from_orm(cls, notification_type: NotificationTypeOrm | None) -> NotificationType | None:
if not notification_type:
return None
return cls(
id=notification_type.id,
description=notification_type.description,
)
31 changes: 24 additions & 7 deletions api/src/user_service/impl/notifications_api_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,33 @@
#
from typing import List

from fastapi import HTTPException

from middleware.request_context import get_request_context
from shared.database.users_database import with_users_db_session
from shared.db_models.notification_type_impl import NotificationTypeImpl
from shared.users_database_gen.sqlacodegen_models import NotificationType as NotificationTypeOrm
from user_service.impl.subscription_helpers import (
ADMIN_EVENT_SUMMARY_NOTIFICATION_TYPE_ID,
ADMIN_SUMMARY_FEATURE_FLAG_ID,
feature_flag_enabled,
)
from user_service_gen.apis.notifications_api_base import BaseNotificationsApi
from user_service_gen.models.notification_type import NotificationType

_NOT_IMPLEMENTED = "Not yet implemented."


class NotificationsApiImpl(BaseNotificationsApi):
"""Stub implementation — scheduled for a follow-up issue."""
"""Implementation of the User Service notifications API."""

@with_users_db_session
def get_notifications(self, db_session=None) -> List[NotificationType]:
"""Returns the notification types the caller can subscribe to, ordered by id.

``admin.event_summary`` is only included for users with the
``isAdminSummarySubscriptionEnabled`` flag, mirroring the create/update gate.
"""
types = db_session.query(NotificationTypeOrm).order_by(NotificationTypeOrm.id).all()

user_id = get_request_context().get("user_id")
if not (user_id and feature_flag_enabled(db_session, user_id, ADMIN_SUMMARY_FEATURE_FLAG_ID)):
types = [t for t in types if t.id != ADMIN_EVENT_SUMMARY_NOTIFICATION_TYPE_ID]

def get_notifications(self) -> List[NotificationType]:
raise HTTPException(status_code=501, detail=_NOT_IMPLEMENTED)
return [NotificationTypeImpl.from_orm(t) for t in types]
95 changes: 94 additions & 1 deletion api/src/user_service/impl/subscription_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,109 @@
get_announcements_list_id,
remove_contact_from_list,
)
from shared.database.database import generate_unique_id
from shared.database.database import generate_unique_id, with_db_session
from shared.database_gen.sqlacodegen_models import Feed
from shared.users_database_gen.sqlacodegen_models import (
AppUser,
FeatureFlag,
NotificationSubscription as NotificationSubscriptionOrm,
UserFeatureFlag,
)

logger = logging.getLogger(__name__)

# Shared 403 detail returned when a user lacks the feature flag gating a subscription action.
ERROR_MESSAGE_USER_FEATURE_NOT_ENABLED = "This feature is not enabled for the user."

ANNOUNCEMENTS_NOTIFICATION_TYPE_ID = "api.announcements"

# Feature flag (feature_flag.id) that gates notification-subscription management: a user must
# have this boolean flag resolved to true to create, update or delete subscriptions.
NOTIFICATIONS_FEATURE_FLAG_ID = "isNotificationsEnabled"

# The admin dispatch-summary notification type and the additional feature flag required to
# subscribe to (or manage a subscription for) it. This is layered on top of the general
# NOTIFICATIONS_FEATURE_FLAG_ID gate.
ADMIN_EVENT_SUMMARY_NOTIFICATION_TYPE_ID = "admin.event_summary"
ADMIN_SUMMARY_FEATURE_FLAG_ID = "isAdminSummarySubscriptionEnabled"

# Notification types that are scoped to specific feeds. A subscription to any of
# these must carry a non-empty list of feed stable IDs (persisted in the
# notification_subscription_feed join table); other types must not carry feeds.
FEED_SCOPED_NOTIFICATION_TYPE_IDS = frozenset(
{
"feed.url_updated",
"feed.url_availability",
"feed.coverage",
}
)


def feature_flag_enabled(db_session, user_id: str, flag_id: str) -> bool:
"""Resolve a boolean feature flag for a user.

The user's override wins, falling back to the flag's default value; a missing flag denies
(nothing to resolve). The ``disabled`` column is deliberately NOT consulted here: it only
controls consumer-facing *exposure* (whether the flag is surfaced in the user profile's
``features`` list) — it is a backend concern and must not block a user who has been granted
access from subscribing. Otherwise a disabled-but-granted flag would leave no way to subscribe.
"""
flag = db_session.get(FeatureFlag, flag_id)
if flag is None:
logger.debug("feature flag %r not found; denying user %s", flag_id, user_id)
return False
override = db_session.get(UserFeatureFlag, (user_id, flag_id))
value = override.value if override is not None and override.value is not None else flag.default_value
enabled = value is True
logger.debug(
"feature flag %r for user %s: override=%r default=%r resolved=%r enabled=%s",
flag_id,
user_id,
getattr(override, "value", None),
flag.default_value,
value,
enabled,
)
return enabled


@with_db_session
def find_unknown_feed_ids(feed_ids, db_session=None) -> list:
"""Return the supplied feed stable IDs that do not exist as a ``Feed`` in the feeds DB.

Order is preserved and duplicates collapsed. ``db_session`` is the feeds-DB session injected
by ``@with_db_session`` (feeds live in a separate database from subscriptions).
"""
unique_ids = list(dict.fromkeys(feed_ids or []))
if not unique_ids:
return []
rows = db_session.query(Feed.stable_id).filter(Feed.stable_id.in_(unique_ids)).all()
existing = {stable_id for (stable_id,) in rows}
return [feed_id for feed_id in unique_ids if feed_id not in existing]


@with_db_session
def resolve_feed_metadata(stable_ids, db_session=None) -> dict:
"""Resolve display metadata for feeds by stable ID from the feeds DB.

Returns ``{stable_id: {"data_type", "provider", "feed_name"}}`` for the feeds that exist.
Feed metadata is intentionally NOT persisted on the subscription; it is resolved at read
time so responses always reflect current feed data. ``db_session`` is the feeds-DB session
injected by ``@with_db_session``.
"""
unique_ids = list(dict.fromkeys(stable_ids or []))
if not unique_ids:
return {}
rows = (
db_session.query(Feed.stable_id, Feed.data_type, Feed.provider, Feed.feed_name)
.filter(Feed.stable_id.in_(unique_ids))
.all()
)
return {
row.stable_id: {"data_type": row.data_type, "provider": row.provider, "feed_name": row.feed_name}
for row in rows
}


def sync_announcements(
email: str,
Expand Down
24 changes: 23 additions & 1 deletion api/src/user_service/impl/subscriptions_api_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
# limitations under the License.
#

import logging

from fastapi import HTTPException

from shared.database.users_database import with_users_db_session
Expand All @@ -24,11 +26,17 @@
)
from user_service.impl.subscription_helpers import (
ANNOUNCEMENTS_NOTIFICATION_TYPE_ID,
ERROR_MESSAGE_USER_FEATURE_NOT_ENABLED,
NOTIFICATIONS_FEATURE_FLAG_ID,
feature_flag_enabled,
resolve_feed_metadata,
set_announcements_optin,
)
from user_service_gen.apis.subscriptions_api_base import BaseSubscriptionsApi
from user_service_gen.models.notification_subscription import NotificationSubscription

logger = logging.getLogger(__name__)


class SubscriptionsApiImpl(BaseSubscriptionsApi):
"""Public, unauthenticated subscription management.
Expand All @@ -41,7 +49,9 @@ def get_subscription(self, id: str, db_session=None) -> NotificationSubscription
sub = db_session.get(NotificationSubscriptionOrm, id)
if sub is None:
raise HTTPException(status_code=404, detail="Subscription not found.")
return NotificationSubscriptionImpl.from_orm(sub)
stable_ids = [f.feed_stable_id for f in sub.notification_subscription_feeds]
feed_metadata = resolve_feed_metadata(stable_ids) if stable_ids else {}
return NotificationSubscriptionImpl.from_orm(sub, feed_metadata)

@with_users_db_session
def delete_subscription(self, id: str, db_session=None) -> None:
Expand All @@ -53,6 +63,18 @@ def delete_subscription(self, id: str, db_session=None) -> None:
if sub is None:
raise HTTPException(status_code=404, detail="Subscription not found.")

# Gated by the subscription owner's isNotificationsEnabled flag (this endpoint is
# unauthenticated; the subscription UUID is the capability, so we resolve the flag for
# the subscription's owner rather than a request user).
if not feature_flag_enabled(db_session, sub.user_id, NOTIFICATIONS_FEATURE_FLAG_ID):
logger.info(
"Public delete denied for subscription %s (owner %s): feature flag %r not enabled.",
id,
sub.user_id,
NOTIFICATIONS_FEATURE_FLAG_ID,
)
raise HTTPException(status_code=403, detail=ERROR_MESSAGE_USER_FEATURE_NOT_ENABLED)

if sub.notification_type_id == ANNOUNCEMENTS_NOTIFICATION_TYPE_ID:
user = db_session.get(AppUser, sub.user_id)
if user is not None:
Expand Down
Loading
Loading