Coverage for app/backend/src/couchers/notifications/notify.py: 95%

33 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1import logging 

2from collections.abc import Sequence 

3from dataclasses import dataclass 

4 

5from google.protobuf import empty_pb2 

6from google.protobuf.message import Message 

7from sqlalchemy import and_, or_, update 

8from sqlalchemy.orm import Session 

9 

10from couchers.jobs.enqueue import queue_job 

11from couchers.models import Notification 

12from couchers.models.notifications import NotificationTopicAction 

13from couchers.proto.internal import jobs_pb2 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18@dataclass(frozen=True, kw_only=True) 

19class PendingNotification: 

20 user_id: int 

21 topic_action: NotificationTopicAction 

22 key: str 

23 data: Message | None = None 

24 moderation_state_id: int | None = None 

25 

26 

27def notify( 

28 session: Session, 

29 *, 

30 user_id: int, 

31 topic_action: NotificationTopicAction, 

32 key: str, 

33 data: Message | None = None, 

34 moderation_state_id: int | None = None, 

35) -> None: 

36 """ 

37 Queues a notification given the notification and a target, i.e. a tuple (user_id, topic, key), and an action. 

38 

39 Notifications are sent to user identified by user_id, and are collapsed/grouped based on the combination of 

40 (topic, key). 

41 

42 For example, topic may be "chat" for a group chat/direct message, and the key might be the chat id; so that messages 

43 in the same group chat show up in one group. 

44 

45 The action is a simple identifier describing the action that caused the notification. For the above example, the 

46 action might be "add_admin" if the notification was caused by another user adding an admin into the gorup chat. 

47 

48 Each different notification type should have its own action. 

49 

50 If moderation_state_id is provided, the notification delivery is deferred until the linked content 

51 becomes VISIBLE or UNLISTED. This is used for notifications related to moderated content. 

52 

53 The key parameter is required. Pass key="" for notifications that intentionally don't have a key 

54 (e.g., security notifications like password changes, or aggregated notifications like chat:missed_messages). 

55 """ 

56 notify_many( 

57 session, 

58 [ 

59 PendingNotification( 

60 user_id=user_id, 

61 topic_action=topic_action, 

62 key=key, 

63 data=data, 

64 moderation_state_id=moderation_state_id, 

65 ) 

66 ], 

67 ) 

68 

69 

70def notify_many(session: Session, notifications: Sequence[PendingNotification]) -> None: 

71 # Import here to avoid circular dependency 

72 from couchers.notifications.background import handle_notification # noqa: PLC0415 

73 

74 rows = [] 

75 for notification in notifications: 

76 logger.info( 

77 f"Generating notification of type {notification.topic_action.display} for user {notification.user_id}" 

78 ) 

79 rows.append( 

80 Notification( 

81 user_id=notification.user_id, 

82 topic_action=notification.topic_action, 

83 key=notification.key, 

84 data=(notification.data or empty_pb2.Empty()).SerializeToString(), 

85 moderation_state_id=notification.moderation_state_id, 

86 ) 

87 ) 

88 session.add_all(rows) 

89 session.flush() 

90 

91 for row in rows: 

92 queue_job( 

93 session, 

94 job=handle_notification, 

95 payload=jobs_pb2.HandleNotificationPayload( 

96 notification_id=row.id, 

97 ), 

98 ) 

99 

100 

101def mark_notifications_seen( 

102 session: Session, 

103 *, 

104 user_id: int, 

105 topic_actions_and_keys: Sequence[tuple[Sequence[NotificationTopicAction], Sequence[str]]], 

106) -> None: 

107 """ 

108 Marks the user's unseen notifications matching any of the given topic action and key groups as seen. 

109 """ 

110 clauses = [ 

111 and_(Notification.topic_action.in_(topic_actions), Notification.key.in_(keys)) 

112 for topic_actions, keys in topic_actions_and_keys 

113 if topic_actions and keys 

114 ] 

115 if not clauses: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 return 

117 session.execute( 

118 update(Notification).values(is_seen=True).where(Notification.user_id == user_id).where(or_(*clauses)) 

119 )