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

1from dataclasses import dataclass 

2from typing import ClassVar 

3 

4from sqlalchemy import select 

5from sqlalchemy.orm import Session 

6from sqlalchemy.sql import func 

7 

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 

14 

15 

16@dataclass(frozen=True, slots=True) 

17class PushNotificationContent: 

18 """ 

19 Defines the user-visible content of a push notification. 

20 

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. 

24 

25 On other platforms, prefer Android-style since they don't require a subtitle. 

26 

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 """ 

47 

48 MAX_TITLE_LENGTH: ClassVar[int] = 500 

49 MAX_BODY_LENGTH: ClassVar[int] = 2000 

50 

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. 

55 

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. 

60 

61 References: 

62 - https://developer.android.com/develop/ui/views/notifications 

63 - https://m3.material.io/foundations/content-design/notifications 

64 """ 

65 

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. 

70 

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. 

75 

76 Reference: 

77 - https://developer.apple.com/documentation/usernotifications/unnotificationcontent 

78 """ 

79 

80 body: str 

81 """ 

82 The text of the notification body. 

83 

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 """ 

90 

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. 

95 

96 Guidelines: 

97 - Use localized plain text with no prior escaping. 

98 - Use Title Case capitalization and don't add a period. 

99 """ 

100 

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.""" 

103 

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.""" 

106 

107 

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 ) 

140 

141 

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 ) 

172 

173 

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 )