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

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 # 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 ) 

139 

140 

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 ) 

171 

172 

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 )