Skip to content
Open
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
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
5 changes: 5 additions & 0 deletions api/src/shared/db_models/notification_subscription_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ class Config:
def from_orm(cls, sub: NotificationSubscriptionOrm | None) -> NotificationSubscription | None:
if not sub:
return None
# feed_ids is the set of feed stable IDs this subscription targets, taken from the
# notification_subscription_feed join table. Sorted for a stable response; None when
# the subscription targets no feeds (i.e. non feed-scoped types).
feed_ids = 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,
feed_ids=feed_ids 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]
78 changes: 77 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,92 @@
get_announcements_list_id,
remove_contact_from_list,
)
from shared.database.database import generate_unique_id
from shared.database.database import Database, generate_unique_id
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


def find_unknown_feed_ids(feed_ids, feeds_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. Feeds live in a separate database from
subscriptions, so this opens its own feeds-DB session unless one is injected (tests).
"""
unique_ids = list(dict.fromkeys(feed_ids or []))
if not unique_ids:
return []

def _missing(session) -> list:
rows = 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]

if feeds_db_session is not None:
return _missing(feeds_db_session)
with Database().start_db_session() as session:
return _missing(session)


def sync_announcements(
email: str,
Expand Down
19 changes: 19 additions & 0 deletions 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,16 @@
)
from user_service.impl.subscription_helpers import (
ANNOUNCEMENTS_NOTIFICATION_TYPE_ID,
ERROR_MESSAGE_USER_FEATURE_NOT_ENABLED,
NOTIFICATIONS_FEATURE_FLAG_ID,
feature_flag_enabled,
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 @@ -53,6 +60,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