Coverage for src / couchers / notifications / push.py: 95%
36 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 05:02 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 05:02 +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 body=body,
131 icon=icon_url,
132 url=action_url,
133 user_id=user_id,
134 topic_action=topic_action,
135 key=key or "",
136 ),
137 priority=7,
138 )
141def _push_to_user(
142 session: Session,
143 user_id: int,
144 topic_action: str,
145 content: PushNotificationContent,
146 key: str | None,
147 ttl: int,
148) -> None:
149 """
150 Same as above but for a given user
151 """
152 sub_ids = (
153 session.execute(
154 select(PushNotificationSubscription.id)
155 .where(PushNotificationSubscription.user_id == user_id)
156 .where(PushNotificationSubscription.disabled_at > func.now())
157 )
158 .scalars()
159 .all()
160 )
161 for sub_id in sub_ids: 161 ↛ 162line 161 didn't jump to line 162 because the loop on line 161 never started
162 push_to_subscription(
163 session,
164 push_notification_subscription_id=sub_id,
165 user_id=user_id,
166 topic_action=topic_action,
167 content=content,
168 key=key,
169 ttl=ttl,
170 )
173def push_to_user(
174 session: Session,
175 *,
176 user_id: int,
177 topic_action: str,
178 content: PushNotificationContent,
179 key: str | None = None,
180 ttl: int = 0,
181) -> None:
182 """
183 This indirection is so that this can be easily mocked. Not sure how to do it better :(
184 """
185 _push_to_user(
186 session,
187 user_id=user_id,
188 topic_action=topic_action,
189 content=content,
190 key=key,
191 ttl=ttl,
192 )