Coverage for app/backend/src/couchers/notifications/push.py: 95%
36 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1from dataclasses import dataclass
2from typing import ClassVar
4from sqlalchemy import select
5from sqlalchemy.orm import Session
6from sqlalchemy.sql import func
8from couchers import urls
9from couchers.config import config
10from couchers.jobs.enqueue import queue_job
11from couchers.models import PushNotificationSubscription
12from couchers.notifications.send_raw_push_notification import send_raw_push_notification_v2
13from couchers.proto.internal import jobs_pb2
16@dataclass(frozen=True, slots=True)
17class PushNotificationContent:
18 """
19 Defines the user-visible content of a push notification.
21 Android and iOS use different enough styles that we can't abstract over both.
22 - Android allows longer titles with sentence-style capitalization.
23 - iOS has a short title but supports a subtitle and uses Title Case capitalization.
25 On other platforms, prefer Android-style since they don't require a subtitle.
27 Style examples:
28 - Message from a user. For example about an event:
29 - Android title: "{name} • {event}" (impersonates name)
30 - iOS title: "{name}"
31 - iOS subtitle: "Commented on {event}"
32 - Icon: User's avatar
33 - Body: "{message}"
34 - Message-like action from a user. For example a host request:
35 - Android title: "New host request by {name}" (mentions name)
36 - iOS title: "{name}"
37 - iOS subtitle: "New Host Request"
38 - Icon: User's avatar
39 - Body: "{name} requested to stay with you on {date}."
40 (start with the name to clarify it's not quoting them)
41 - New entity / entity changed:
42 - Android title "New discussion: {title}"
43 - iOS title: "New Discussion"
44 - iOS subtitle: "{title}"
45 - Body: "{name} started a discussion in {community}."
46 """
48 MAX_TITLE_LENGTH: ClassVar[int] = 500
49 MAX_BODY_LENGTH: ClassVar[int] = 2000
51 title: str
52 """
53 The notification title, as shown on Android and other platforms where there is no subtitle.
54 See class documentation for examples.
56 Guidelines:
57 - Use localized plain text with no prior escaping.
58 - Prefer 30-40 chars, though up to 65 will fit on most phones.
59 - Use sentence-style capitalization (not Title Case) and don't add a period.
61 References:
62 - https://developer.android.com/develop/ui/views/notifications
63 - https://m3.material.io/foundations/content-design/notifications
64 """
66 ios_title: str
67 """
68 The iOS-specific notification title, since iOS has a title/subtitle pair with different style.
69 See class documentation for examples.
71 Guidelines:
72 - Use localized plain text with no prior escaping.
73 - Keep below 25-30 chars as iOS truncates aggressively.
74 - Use Title Case capitalization and don't add a period.
76 Reference:
77 - https://developer.apple.com/documentation/usernotifications/unnotificationcontent
78 """
80 body: str
81 """
82 The text of the notification body.
84 Guidelines:
85 - Use localized palin text with no prior escaping.
86 - Keep the most important info within the first 40 chars (visible when collapsed) and the whole within 120 chars.
87 - If the title is a user's name, the body should either be what they said verbatim,
88 or mention them in the third person as in "{name} did x" so it is clear it is not quoting them.
89 """
91 ios_subtitle: str | None = None
92 """
93 The iOS-specific notification subtitle, since iOS has a title/subtitle pair with different style.
94 See class documentation for examples.
96 Guidelines:
97 - Use localized plain text with no prior escaping.
98 - Use Title Case capitalization and don't add a period.
99 """
101 action_url: str | None = None
102 """The URL to open when the notification is clicked. If None, will open the app's main URL."""
104 icon_url: str | None = None
105 """A URL to the icon to show in the notification. If None, will use the default app icon."""
108def push_to_subscription(
109 session: Session,
110 *,
111 push_notification_subscription_id: int,
112 user_id: int,
113 topic_action: str,
114 content: PushNotificationContent,
115 key: str | None = None,
116 ttl: int = 0,
117) -> None:
118 title = config.NOTIFICATION_PREFIX + content.title[: PushNotificationContent.MAX_TITLE_LENGTH]
119 body = content.body[: PushNotificationContent.MAX_BODY_LENGTH]
120 icon_url = content.icon_url or urls.icon_url()
121 action_url = content.action_url or ""
122 queue_job(
123 session,
124 job=send_raw_push_notification_v2,
125 payload=jobs_pb2.SendRawPushNotificationPayloadV2(
126 push_notification_subscription_id=push_notification_subscription_id,
127 ttl=ttl,
128 title=title,
129 ios_title=content.ios_title,
130 ios_subtitle=content.ios_subtitle,
131 body=body,
132 icon=icon_url,
133 url=action_url,
134 user_id=user_id,
135 topic_action=topic_action,
136 key=key or "",
137 ),
138 priority=7,
139 )
142def _push_to_user(
143 session: Session,
144 user_id: int,
145 topic_action: str,
146 content: PushNotificationContent,
147 key: str | None,
148 ttl: int,
149) -> None:
150 """
151 Same as above but for a given user
152 """
153 sub_ids = (
154 session.execute(
155 select(PushNotificationSubscription.id)
156 .where(PushNotificationSubscription.user_id == user_id)
157 .where(PushNotificationSubscription.disabled_at > func.now())
158 )
159 .scalars()
160 .all()
161 )
162 for sub_id in sub_ids: 162 ↛ 163line 162 didn't jump to line 163 because the loop on line 162 never started
163 push_to_subscription(
164 session,
165 push_notification_subscription_id=sub_id,
166 user_id=user_id,
167 topic_action=topic_action,
168 content=content,
169 key=key,
170 ttl=ttl,
171 )
174def push_to_user(
175 session: Session,
176 *,
177 user_id: int,
178 topic_action: str,
179 content: PushNotificationContent,
180 key: str | None = None,
181 ttl: int = 0,
182) -> None:
183 """
184 This indirection is so that this can be easily mocked. Not sure how to do it better :(
185 """
186 _push_to_user(
187 session,
188 user_id=user_id,
189 topic_action=topic_action,
190 content=content,
191 key=key,
192 ttl=ttl,
193 )