Coverage for src / couchers / notifications / push.py: 94%
33 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 16:21 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 16:21 +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 """Defines the user-visible content of a push notification."""
20 # Android reference: https://developer.android.com/develop/ui/views/notifications
21 # iOS reference: https://developer.apple.com/documentation/usernotifications/unnotificationcontent
23 MAX_TITLE_LENGTH: ClassVar[int] = 500
24 MAX_BODY_LENGTH: ClassVar[int] = 2000
26 title: str
27 """A localized title for the notification, this should be a very short string (2-4 words)."""
28 body: str
29 """The localized text of the notification body."""
30 action_url: str | None = None
31 """The URL to open when the notification is clicked. If None, will open the app's main URL."""
32 icon_url: str | None = None
33 """A URL to the icon to show in the notification. If None, will use the default app icon."""
36def push_to_subscription(
37 session: Session,
38 *,
39 push_notification_subscription_id: int,
40 user_id: int,
41 topic_action: str,
42 content: PushNotificationContent,
43 key: str | None = None,
44 ttl: int = 0,
45) -> None:
46 title = config["NOTIFICATION_PREFIX"] + content.title[: PushNotificationContent.MAX_TITLE_LENGTH]
47 body = content.body[: PushNotificationContent.MAX_BODY_LENGTH]
48 icon_url = content.icon_url or urls.icon_url()
49 action_url = content.action_url or ""
50 queue_job(
51 session,
52 job=send_raw_push_notification_v2,
53 payload=jobs_pb2.SendRawPushNotificationPayloadV2(
54 push_notification_subscription_id=push_notification_subscription_id,
55 ttl=ttl,
56 title=title,
57 body=body,
58 icon=icon_url,
59 url=action_url,
60 user_id=user_id,
61 topic_action=topic_action,
62 key=key or "",
63 ),
64 priority=7,
65 )
68def _push_to_user(
69 session: Session,
70 user_id: int,
71 topic_action: str,
72 content: PushNotificationContent,
73 key: str | None,
74 ttl: int,
75) -> None:
76 """
77 Same as above but for a given user
78 """
79 sub_ids = (
80 session.execute(
81 select(PushNotificationSubscription.id)
82 .where(PushNotificationSubscription.user_id == user_id)
83 .where(PushNotificationSubscription.disabled_at > func.now())
84 )
85 .scalars()
86 .all()
87 )
88 for sub_id in sub_ids: 88 ↛ 89line 88 didn't jump to line 89 because the loop on line 88 never started
89 push_to_subscription(
90 session,
91 push_notification_subscription_id=sub_id,
92 user_id=user_id,
93 topic_action=topic_action,
94 content=content,
95 key=key,
96 ttl=ttl,
97 )
100def push_to_user(
101 session: Session,
102 *,
103 user_id: int,
104 topic_action: str,
105 content: PushNotificationContent,
106 key: str | None = None,
107 ttl: int = 0,
108) -> None:
109 """
110 This indirection is so that this can be easily mocked. Not sure how to do it better :(
111 """
112 _push_to_user(
113 session,
114 user_id=user_id,
115 topic_action=topic_action,
116 content=content,
117 key=key,
118 ttl=ttl,
119 )