Coverage for app/backend/src/couchers/notifications/notify.py: 92%
23 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import logging
2from collections.abc import Sequence
4from google.protobuf import empty_pb2
5from google.protobuf.message import Message
6from sqlalchemy import and_, or_, update
7from sqlalchemy.orm import Session
9from couchers.jobs.enqueue import queue_job
10from couchers.models import Notification
11from couchers.models.notifications import NotificationTopicAction
12from couchers.proto.internal import jobs_pb2
14logger = logging.getLogger(__name__)
17def notify(
18 session: Session,
19 *,
20 user_id: int,
21 topic_action: NotificationTopicAction,
22 key: str,
23 data: Message | None = None,
24 moderation_state_id: int | None = None,
25) -> None:
26 """
27 Queues a notification given the notification and a target, i.e. a tuple (user_id, topic, key), and an action.
29 Notifications are sent to user identified by user_id, and are collapsed/grouped based on the combination of
30 (topic, key).
32 For example, topic may be "chat" for a group chat/direct message, and the key might be the chat id; so that messages
33 in the same group chat show up in one group.
35 The action is a simple identifier describing the action that caused the notification. For the above example, the
36 action might be "add_admin" if the notification was caused by another user adding an admin into the gorup chat.
38 Each different notification type should have its own action.
40 If moderation_state_id is provided, the notification delivery is deferred until the linked content
41 becomes VISIBLE or UNLISTED. This is used for notifications related to moderated content.
43 The key parameter is required. Pass key="" for notifications that intentionally don't have a key
44 (e.g., security notifications like password changes, or aggregated notifications like chat:missed_messages).
45 """
46 logger.info(f"Generating notification of type {topic_action.display} for user {user_id}")
47 # Import here to avoid circular dependency
48 from couchers.notifications.background import handle_notification # noqa: PLC0415
50 notification = Notification(
51 user_id=user_id,
52 topic_action=topic_action,
53 key=key,
54 data=(data or empty_pb2.Empty()).SerializeToString(),
55 moderation_state_id=moderation_state_id,
56 )
57 session.add(notification)
58 session.flush()
60 queue_job(
61 session,
62 job=handle_notification,
63 payload=jobs_pb2.HandleNotificationPayload(
64 notification_id=notification.id,
65 ),
66 )
69def mark_notifications_seen(
70 session: Session,
71 *,
72 user_id: int,
73 topic_actions_and_keys: Sequence[tuple[Sequence[NotificationTopicAction], Sequence[str]]],
74) -> None:
75 """
76 Marks the user's unseen notifications matching any of the given topic action and key groups as seen.
77 """
78 clauses = [
79 and_(Notification.topic_action.in_(topic_actions), Notification.key.in_(keys))
80 for topic_actions, keys in topic_actions_and_keys
81 if topic_actions and keys
82 ]
83 if not clauses: 83 ↛ 84line 83 didn't jump to line 84 because the condition on line 83 was never true
84 return
85 session.execute(
86 update(Notification).values(is_seen=True).where(Notification.user_id == user_id).where(or_(*clauses))
87 )