Coverage for app / backend / src / couchers / notifications / push.py: 95%
36 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +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 # TODO(#7617): Support iOS-style title/subtitles
119 title = config["NOTIFICATION_PREFIX"] + content.title[: PushNotificationContent.MAX_TITLE_LENGTH]
120 body = content.body[: PushNotificationContent.MAX_BODY_LENGTH]
121 icon_url = content.icon_url or urls.icon_url()
122 action_url = content.action_url or ""
123 queue_job(
124 session,
125 job=send_raw_push_notification_v2,
126 payload=jobs_pb2.SendRawPushNotificationPayloadV2(
127 push_notification_subscription_id=push_notification_subscription_id,
128 ttl=ttl,
129 title=title,
130 ios_title=content.ios_title,
131 ios_subtitle=content.ios_subtitle,
132 body=body,
133 icon=icon_url,
134 url=action_url,
135 user_id=user_id,
136 topic_action=topic_action,
137 key=key or "",
138 ),
139 priority=7,
140 )
143def _push_to_user(
144 session: Session,
145 user_id: int,
146 topic_action: str,
147 content: PushNotificationContent,
148 key: str | None,
149 ttl: int,
150) -> None:
151 """
152 Same as above but for a given user
153 """
154 sub_ids = (
155 session.execute(
156 select(PushNotificationSubscription.id)
157 .where(PushNotificationSubscription.user_id == user_id)
158 .where(PushNotificationSubscription.disabled_at > func.now())
159 )
160 .scalars()
161 .all()
162 )
163 for sub_id in sub_ids: 163 ↛ 164line 163 didn't jump to line 164 because the loop on line 163 never started
164 push_to_subscription(
165 session,
166 push_notification_subscription_id=sub_id,
167 user_id=user_id,
168 topic_action=topic_action,
169 content=content,
170 key=key,
171 ttl=ttl,
172 )
175def push_to_user(
176 session: Session,
177 *,
178 user_id: int,
179 topic_action: str,
180 content: PushNotificationContent,
181 key: str | None = None,
182 ttl: int = 0,
183) -> None:
184 """
185 This indirection is so that this can be easily mocked. Not sure how to do it better :(
186 """
187 _push_to_user(
188 session,
189 user_id=user_id,
190 topic_action=topic_action,
191 content=content,
192 key=key,
193 ttl=ttl,
194 )