Coverage for app / backend / src / couchers / models / notifications.py: 99%

166 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +0000

1import enum 

2from datetime import datetime 

3from typing import TYPE_CHECKING 

4 

5from google.protobuf import empty_pb2 

6from sqlalchemy import ( 

7 BigInteger, 

8 Boolean, 

9 CheckConstraint, 

10 DateTime, 

11 Enum, 

12 ForeignKey, 

13 Index, 

14 Integer, 

15 String, 

16 UniqueConstraint, 

17 func, 

18) 

19from sqlalchemy import LargeBinary as Binary 

20from sqlalchemy.orm import Mapped, mapped_column, relationship 

21from sqlalchemy.sql import expression 

22 

23from couchers.constants import DATETIME_INFINITY 

24from couchers.models import ModerationState 

25from couchers.models.base import Base 

26from couchers.proto import notification_data_pb2 

27 

28if TYPE_CHECKING: 

29 from couchers.models import User 

30 

31 

32class NotificationDeliveryType(enum.Enum): 

33 # send push notification to mobile/web 

34 push = enum.auto() 

35 # send individual email immediately 

36 email = enum.auto() 

37 # send in digest 

38 digest = enum.auto() 

39 

40 

41dt = NotificationDeliveryType 

42nd = notification_data_pb2 

43dt_sec = [dt.email, dt.push] 

44dt_all = [dt.email, dt.push, dt.digest] 

45dt_empty: list[NotificationDeliveryType] = [] 

46 

47 

48class NotificationTopicAction(enum.Enum): 

49 """ 

50 Identifies a type of notification by the topic it relates to and the action triggered. 

51 The grouping by topic allows users to unsubscribe from notifications in two ways: 

52 - All notifications of a certain type (topic+action), e.g. all friend requests. 

53 - All notifications about a certain instance of a topic, 

54 e.g. all notifications about an event, where the key is the event id. 

55 """ 

56 

57 def __init__( 

58 self, topic_action: str, defaults: list[NotificationDeliveryType], is_critical: bool, data_type: type 

59 ) -> None: 

60 self.topic, self.action = topic_action.split(":") 

61 self.defaults = defaults 

62 # Account security, moderation, donation receipts, etc. cannot be unsubscribed from. 

63 self.is_critical = is_critical 

64 self.data_type = data_type 

65 

66 def unpack(self) -> tuple[str, str]: 

67 return self.topic, self.action 

68 

69 @property 

70 def display(self) -> str: 

71 return f"{self.topic}:{self.action}" 

72 

73 def __str__(self) -> str: 

74 return self.display 

75 

76 # topic, action, default delivery types 

77 friend_request__create = ("friend_request:create", dt_all, False, nd.FriendRequestCreate) 

78 friend_request__accept = ("friend_request:accept", dt_all, False, nd.FriendRequestAccept) 

79 

80 # host requests 

81 host_request__create = ("host_request:create", dt_all, False, nd.HostRequestCreate) 

82 host_request__accept = ("host_request:accept", dt_all, False, nd.HostRequestAccept) 

83 host_request__reject = ("host_request:reject", dt_all, False, nd.HostRequestReject) 

84 host_request__confirm = ("host_request:confirm", dt_all, False, nd.HostRequestConfirm) 

85 host_request__cancel = ("host_request:cancel", dt_all, False, nd.HostRequestCancel) 

86 host_request__message = ("host_request:message", [dt.push, dt.digest], False, nd.HostRequestMessage) 

87 host_request__missed_messages = ("host_request:missed_messages", [dt.email], False, nd.HostRequestMissedMessages) 

88 host_request__reminder = ("host_request:reminder", dt_all, False, nd.HostRequestReminder) 

89 

90 activeness__probe = ("activeness:probe", dt_sec, True, nd.ActivenessProbe) 

91 

92 # you receive a friend ref 

93 reference__receive_friend = ("reference:receive_friend", dt_all, False, nd.ReferenceReceiveFriend) 

94 # you receive a reference from ... the host 

95 reference__receive_hosted = ("reference:receive_hosted", dt_all, False, nd.ReferenceReceiveHostRequest) 

96 # ... the surfer 

97 reference__receive_surfed = ("reference:receive_surfed", dt_all, False, nd.ReferenceReceiveHostRequest) 

98 

99 # you hosted 

100 reference__reminder_hosted = ("reference:reminder_hosted", dt_all, False, nd.ReferenceReminder) 

101 # you surfed 

102 reference__reminder_surfed = ("reference:reminder_surfed", dt_all, False, nd.ReferenceReminder) 

103 

104 badge__add = ("badge:add", [dt.push, dt.digest], False, nd.BadgeAdd) 

105 badge__remove = ("badge:remove", [dt.push, dt.digest], False, nd.BadgeRemove) 

106 

107 # group chats 

108 chat__message = ("chat:message", [dt.push, dt.digest], False, nd.ChatMessage) 

109 chat__missed_messages = ("chat:missed_messages", [dt.email], False, nd.ChatMissedMessages) 

110 

111 # events 

112 # approved by mods 

113 event__create_approved = ("event:create_approved", dt_all, False, nd.EventCreate) 

114 # any user creates any event, default to no notifications 

115 event__create_any = ("event:create_any", dt_empty, False, nd.EventCreate) 

116 event__update = ("event:update", dt_all, False, nd.EventUpdate) 

117 event__cancel = ("event:cancel", dt_all, False, nd.EventCancel) 

118 event__delete = ("event:delete", dt_all, False, nd.EventDelete) 

119 event__invite_organizer = ("event:invite_organizer", dt_all, False, nd.EventInviteOrganizer) 

120 event__reminder = ("event:reminder", dt_all, False, nd.EventReminder) 

121 # toplevel comment on an event 

122 event__comment = ("event:comment", dt_all, False, nd.EventComment) 

123 

124 # discussion created 

125 discussion__create = ("discussion:create", [dt.digest], False, nd.DiscussionCreate) 

126 # someone comments on your discussion 

127 discussion__comment = ("discussion:comment", dt_all, False, nd.DiscussionComment) 

128 

129 # someone responds to any of your top-level comment across the platform 

130 thread__reply = ("thread:reply", dt_all, False, nd.ThreadReply) 

131 

132 # account settings 

133 password__change = ("password:change", dt_sec, True, empty_pb2.Empty) 

134 email_address__change = ("email_address:change", dt_sec, True, nd.EmailAddressChange) 

135 email_address__verify = ("email_address:verify", dt_sec, True, empty_pb2.Empty) 

136 phone_number__change = ("phone_number:change", dt_sec, True, nd.PhoneNumberChange) 

137 phone_number__verify = ("phone_number:verify", dt_sec, True, nd.PhoneNumberVerify) 

138 # reset password 

139 password_reset__start = ("password_reset:start", dt_sec, True, nd.PasswordResetStart) 

140 password_reset__complete = ("password_reset:complete", dt_sec, True, empty_pb2.Empty) 

141 

142 # account deletion 

143 account_deletion__start = ("account_deletion:start", dt_sec, True, nd.AccountDeletionStart) 

144 # no more pushing to do 

145 account_deletion__complete = ("account_deletion:complete", dt_sec, True, nd.AccountDeletionComplete) 

146 # undeleted 

147 account_deletion__recovered = ("account_deletion:recovered", dt_sec, True, empty_pb2.Empty) 

148 

149 # admin actions 

150 gender__change = ("gender:change", dt_sec, True, nd.GenderChange) 

151 birthdate__change = ("birthdate:change", dt_sec, True, nd.BirthdateChange) 

152 api_key__create = ("api_key:create", dt_sec, True, nd.ApiKeyCreate) 

153 

154 # Can't unsubscribe because email includes receipt. 

155 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived) 

156 

157 onboarding__reminder = ("onboarding:reminder", dt_sec, False, empty_pb2.Empty) 

158 

159 modnote__create = ("modnote:create", dt_sec, True, empty_pb2.Empty) 

160 

161 verification__sv_fail = ("verification:sv_fail", dt_sec, True, nd.VerificationSVFail) 

162 verification__sv_success = ("verification:sv_success", dt_sec, True, empty_pb2.Empty) 

163 

164 # postal verification 

165 postal_verification__postcard_sent = ( 

166 "postal_verification:postcard_sent", 

167 dt_sec, 

168 False, 

169 nd.PostalVerificationPostcardSent, 

170 ) 

171 postal_verification__success = ("postal_verification:success", dt_sec, True, empty_pb2.Empty) 

172 postal_verification__failed = ("postal_verification:failed", dt_sec, True, nd.PostalVerificationFailed) 

173 

174 # general announcements 

175 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], False, nd.GeneralNewBlogPost) 

176 

177 

178class NotificationPreference(Base, kw_only=True): 

179 __tablename__ = "notification_preferences" 

180 

181 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

182 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

183 

184 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction)) 

185 delivery_type: Mapped[NotificationDeliveryType] = mapped_column(Enum(NotificationDeliveryType)) 

186 deliver: Mapped[bool] = mapped_column(Boolean) 

187 

188 user: Mapped[User] = relationship(init=False, foreign_keys="NotificationPreference.user_id") 

189 

190 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),) 

191 

192 

193class Notification(Base, kw_only=True): 

194 """ 

195 Table for accumulating notifications until it is time to send email digest 

196 """ 

197 

198 __tablename__ = "notifications" 

199 

200 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

201 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

202 

203 # recipient user id 

204 user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

205 

206 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction)) 

207 key: Mapped[str] = mapped_column(String) 

208 

209 data: Mapped[bytes] = mapped_column(Binary) 

210 

211 # whether the user has marked this notification as seen or not 

212 is_seen: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False) 

213 

214 # optional link to moderation state for content that requires moderation approval 

215 # if set, notification delivery is deferred until content becomes VISIBLE/UNLISTED 

216 moderation_state_id: Mapped[int | None] = mapped_column( 

217 ForeignKey("moderation_states.id"), default=None, index=True 

218 ) 

219 

220 user: Mapped[User] = relationship(init=False, foreign_keys="Notification.user_id") 

221 moderation_state: Mapped[ModerationState] = relationship( 

222 init=False, foreign_keys="Notification.moderation_state_id" 

223 ) 

224 

225 __table_args__ = ( 

226 # used in looking up which notifications need delivery 

227 Index( 

228 "ix_notifications_created", 

229 created, 

230 ), 

231 # Fast lookup for unseen notification count 

232 Index( 

233 "ix_notifications_unseen", 

234 user_id, 

235 topic_action, 

236 postgresql_where=(is_seen == False), 

237 ), 

238 # Fast lookup for latest notifications 

239 Index( 

240 "ix_notifications_latest", 

241 user_id, 

242 id.desc(), 

243 topic_action, 

244 ), 

245 ) 

246 

247 @property 

248 def topic(self) -> str: 

249 return self.topic_action.topic 

250 

251 @property 

252 def action(self) -> str: 

253 return self.topic_action.action 

254 

255 

256class NotificationDelivery(Base, kw_only=True): 

257 __tablename__ = "notification_deliveries" 

258 

259 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

260 notification_id: Mapped[int] = mapped_column(ForeignKey("notifications.id"), index=True) 

261 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

262 delivered: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

263 read: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

264 # todo: enum of "phone, web, digest" 

265 delivery_type: Mapped[NotificationDeliveryType] = mapped_column(Enum(NotificationDeliveryType)) 

266 # todo: device id 

267 # todo: receipt id, etc 

268 notification: Mapped[Notification] = relationship(init=False, foreign_keys="NotificationDelivery.notification_id") 

269 

270 __table_args__ = ( 

271 UniqueConstraint("notification_id", "delivery_type"), 

272 # used in looking up which notifications need delivery 

273 Index( 

274 "ix_notification_deliveries_delivery_type", 

275 delivery_type, 

276 postgresql_where=(delivered != None), 

277 ), 

278 Index( 

279 "ix_notification_deliveries_dt_ni_dnull", 

280 delivery_type, 

281 notification_id, 

282 delivered == None, 

283 ), 

284 ) 

285 

286 

287class DeviceType(enum.Enum): 

288 ios = enum.auto() 

289 android = enum.auto() 

290 

291 

292class PushNotificationPlatform(enum.Enum): 

293 web_push = enum.auto() 

294 expo = enum.auto() 

295 

296 

297class PushNotificationDeliveryOutcome(enum.Enum): 

298 success = enum.auto() 

299 # transient failure, will retry 

300 transient_failure = enum.auto() 

301 # message can't be delivered (e.g. too long), but subscription is fine 

302 permanent_message_failure = enum.auto() 

303 # subscription is broken (e.g. device unregistered), was disabled 

304 permanent_subscription_failure = enum.auto() 

305 

306 

307class PushNotificationSubscription(Base, kw_only=True): 

308 __tablename__ = "push_notification_subscriptions" 

309 

310 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

311 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

312 

313 # which user this is connected to 

314 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

315 

316 platform: Mapped[PushNotificationPlatform] = mapped_column(Enum(PushNotificationPlatform)) 

317 

318 ## platform specific: web_push 

319 # these come from https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription 

320 # the endpoint 

321 endpoint: Mapped[str | None] = mapped_column(String, default=None) 

322 # the "auth" key 

323 auth_key: Mapped[bytes | None] = mapped_column(Binary, default=None) 

324 # the "p256dh" key 

325 p256dh_key: Mapped[bytes | None] = mapped_column(Binary, default=None) 

326 

327 full_subscription_info: Mapped[str | None] = mapped_column(String, default=None) 

328 

329 # the browser user-agent, so we can tell the user what browser notifications are going to 

330 user_agent: Mapped[str | None] = mapped_column(String, default=None) 

331 

332 ## platform specific: expo 

333 token: Mapped[str | None] = mapped_column(String, unique=True, index=True, default=None) 

334 device_name: Mapped[str | None] = mapped_column(String, default=None) 

335 device_type: Mapped[DeviceType | None] = mapped_column(Enum(DeviceType), default=None) 

336 

337 # when it was disabled 

338 disabled_at: Mapped[datetime] = mapped_column( 

339 DateTime(timezone=True), server_default=DATETIME_INFINITY.isoformat(), init=False 

340 ) 

341 

342 user: Mapped[User] = relationship(init=False) 

343 

344 __table_args__ = ( 

345 # web_push platform requires: endpoint, auth_key, p256dh_key, full_subscription_info 

346 # expo platform requires: token 

347 CheckConstraint( 

348 """ 

349 (platform = 'web_push' AND endpoint IS NOT NULL AND auth_key IS NOT NULL AND p256dh_key IS NOT NULL AND full_subscription_info IS NOT NULL AND token IS NULL) 

350 OR 

351 (platform = 'expo' AND token IS NOT NULL AND endpoint IS NULL AND auth_key IS NULL AND p256dh_key IS NULL AND full_subscription_info IS NULL) 

352 """, 

353 name="platform_columns", 

354 ), 

355 ) 

356 

357 

358class PushNotificationDeliveryAttempt(Base, kw_only=True): 

359 __tablename__ = "push_notification_delivery_attempt" 

360 

361 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

362 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

363 

364 push_notification_subscription_id: Mapped[int] = mapped_column( 

365 ForeignKey("push_notification_subscriptions.id"), index=True 

366 ) 

367 

368 outcome: Mapped[PushNotificationDeliveryOutcome] = mapped_column(Enum(PushNotificationDeliveryOutcome)) 

369 # the HTTP status code, 201 is success, or similar for other platforms 

370 status_code: Mapped[int | None] = mapped_column(Integer, default=None) 

371 

372 # can be null if it was a success 

373 response: Mapped[str | None] = mapped_column(String, default=None) 

374 

375 # Expo-specific: ticket ID for receipt checking 

376 expo_ticket_id: Mapped[str | None] = mapped_column(String, default=None) 

377 

378 # Receipt check results (populated by delayed job) 

379 receipt_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

380 receipt_status: Mapped[str | None] = mapped_column(String, default=None) # "ok" or "error" 

381 receipt_error_code: Mapped[str | None] = mapped_column(String, default=None) # e.g., "DeviceNotRegistered" 

382 

383 push_notification_subscription: Mapped[PushNotificationSubscription] = relationship(init=False)