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

164 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-25 10:58 +0000

1import enum 

2from datetime import datetime 

3 

4from google.protobuf import empty_pb2 

5from sqlalchemy import ( 

6 BigInteger, 

7 Boolean, 

8 CheckConstraint, 

9 DateTime, 

10 Enum, 

11 ForeignKey, 

12 Index, 

13 Integer, 

14 String, 

15 UniqueConstraint, 

16 func, 

17) 

18from sqlalchemy import LargeBinary as Binary 

19from sqlalchemy.orm import Mapped, mapped_column, relationship 

20from sqlalchemy.sql import expression 

21 

22from couchers.constants import DATETIME_INFINITY 

23from couchers.models.base import Base 

24from couchers.proto import notification_data_pb2 

25 

26 

27class NotificationDeliveryType(enum.Enum): 

28 # send push notification to mobile/web 

29 push = enum.auto() 

30 # send individual email immediately 

31 email = enum.auto() 

32 # send in digest 

33 digest = enum.auto() 

34 

35 

36dt = NotificationDeliveryType 

37nd = notification_data_pb2 

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

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

40dt_empty: list[NotificationDeliveryType] = [] 

41 

42 

43class NotificationTopicAction(enum.Enum): 

44 def __init__( 

45 self, topic_action: str, defaults: list[NotificationDeliveryType], user_editable: bool, data_type: type 

46 ) -> None: 

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

48 self.defaults = defaults 

49 # for now user editable == not a security notification 

50 self.user_editable = user_editable 

51 

52 self.data_type = data_type 

53 

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

55 return self.topic, self.action 

56 

57 @property 

58 def display(self) -> str: 

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

60 

61 def __str__(self) -> str: 

62 return self.display 

63 

64 # topic, action, default delivery types 

65 friend_request__create = ("friend_request:create", dt_all, True, nd.FriendRequestCreate) 

66 friend_request__accept = ("friend_request:accept", dt_all, True, nd.FriendRequestAccept) 

67 

68 # host requests 

69 host_request__create = ("host_request:create", dt_all, True, nd.HostRequestCreate) 

70 host_request__accept = ("host_request:accept", dt_all, True, nd.HostRequestAccept) 

71 host_request__reject = ("host_request:reject", dt_all, True, nd.HostRequestReject) 

72 host_request__confirm = ("host_request:confirm", dt_all, True, nd.HostRequestConfirm) 

73 host_request__cancel = ("host_request:cancel", dt_all, True, nd.HostRequestCancel) 

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

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

76 host_request__reminder = ("host_request:reminder", dt_all, True, nd.HostRequestReminder) 

77 

78 activeness__probe = ("activeness:probe", dt_sec, False, nd.ActivenessProbe) 

79 

80 # you receive a friend ref 

81 reference__receive_friend = ("reference:receive_friend", dt_all, True, nd.ReferenceReceiveFriend) 

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

83 reference__receive_hosted = ("reference:receive_hosted", dt_all, True, nd.ReferenceReceiveHostRequest) 

84 # ... the surfer 

85 reference__receive_surfed = ("reference:receive_surfed", dt_all, True, nd.ReferenceReceiveHostRequest) 

86 

87 # you hosted 

88 reference__reminder_hosted = ("reference:reminder_hosted", dt_all, True, nd.ReferenceReminder) 

89 # you surfed 

90 reference__reminder_surfed = ("reference:reminder_surfed", dt_all, True, nd.ReferenceReminder) 

91 

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

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

94 

95 # group chats 

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

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

98 

99 # events 

100 # approved by mods 

101 event__create_approved = ("event:create_approved", dt_all, True, nd.EventCreate) 

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

103 event__create_any = ("event:create_any", dt_empty, True, nd.EventCreate) 

104 event__update = ("event:update", dt_all, True, nd.EventUpdate) 

105 event__cancel = ("event:cancel", dt_all, True, nd.EventCancel) 

106 event__delete = ("event:delete", dt_all, True, nd.EventDelete) 

107 event__invite_organizer = ("event:invite_organizer", dt_all, True, nd.EventInviteOrganizer) 

108 event__reminder = ("event:reminder", dt_all, True, nd.EventReminder) 

109 # toplevel comment on an event 

110 event__comment = ("event:comment", dt_all, True, nd.EventComment) 

111 

112 # discussion created 

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

114 # someone comments on your discussion 

115 discussion__comment = ("discussion:comment", dt_all, True, nd.DiscussionComment) 

116 

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

118 thread__reply = ("thread:reply", dt_all, True, nd.ThreadReply) 

119 

120 # account settings 

121 password__change = ("password:change", dt_sec, False, empty_pb2.Empty) 

122 email_address__change = ("email_address:change", dt_sec, False, nd.EmailAddressChange) 

123 email_address__verify = ("email_address:verify", dt_sec, False, empty_pb2.Empty) 

124 phone_number__change = ("phone_number:change", dt_sec, False, nd.PhoneNumberChange) 

125 phone_number__verify = ("phone_number:verify", dt_sec, False, nd.PhoneNumberVerify) 

126 # reset password 

127 password_reset__start = ("password_reset:start", dt_sec, False, nd.PasswordResetStart) 

128 password_reset__complete = ("password_reset:complete", dt_sec, False, empty_pb2.Empty) 

129 

130 # account deletion 

131 account_deletion__start = ("account_deletion:start", dt_sec, False, nd.AccountDeletionStart) 

132 # no more pushing to do 

133 account_deletion__complete = ("account_deletion:complete", dt_sec, False, nd.AccountDeletionComplete) 

134 # undeleted 

135 account_deletion__recovered = ("account_deletion:recovered", dt_sec, False, empty_pb2.Empty) 

136 

137 # admin actions 

138 gender__change = ("gender:change", dt_sec, False, nd.GenderChange) 

139 birthdate__change = ("birthdate:change", dt_sec, False, nd.BirthdateChange) 

140 api_key__create = ("api_key:create", dt_sec, False, nd.ApiKeyCreate) 

141 

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

143 

144 onboarding__reminder = ("onboarding:reminder", dt_sec, True, empty_pb2.Empty) 

145 

146 modnote__create = ("modnote:create", dt_sec, False, empty_pb2.Empty) 

147 

148 verification__sv_fail = ("verification:sv_fail", dt_sec, False, nd.VerificationSVFail) 

149 verification__sv_success = ("verification:sv_success", dt_sec, False, empty_pb2.Empty) 

150 

151 # postal verification 

152 postal_verification__postcard_sent = ( 

153 "postal_verification:postcard_sent", 

154 dt_sec, 

155 False, 

156 nd.PostalVerificationPostcardSent, 

157 ) 

158 postal_verification__success = ("postal_verification:success", dt_sec, False, empty_pb2.Empty) 

159 postal_verification__failed = ("postal_verification:failed", dt_sec, False, nd.PostalVerificationFailed) 

160 

161 # general announcements 

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

163 

164 

165class NotificationPreference(Base): 

166 __tablename__ = "notification_preferences" 

167 

168 id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 

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

170 

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

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

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

174 

175 user = relationship("User", foreign_keys="NotificationPreference.user_id") 

176 

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

178 

179 

180class Notification(Base): 

181 """ 

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

183 """ 

184 

185 __tablename__ = "notifications" 

186 

187 id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 

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

189 

190 # recipient user id 

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

192 

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

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

195 

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

197 

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

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

200 

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

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

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

204 ForeignKey("moderation_states.id"), nullable=True, index=True 

205 ) 

206 

207 user = relationship("User", foreign_keys="Notification.user_id") 

208 moderation_state = relationship("ModerationState", foreign_keys="Notification.moderation_state_id") 

209 

210 __table_args__ = ( 

211 # used in looking up which notifications need delivery 

212 Index( 

213 "ix_notifications_created", 

214 created, 

215 ), 

216 # Fast lookup for unseen notification count 

217 Index( 

218 "ix_notifications_unseen", 

219 user_id, 

220 topic_action, 

221 postgresql_where=(is_seen == False), 

222 ), 

223 # Fast lookup for latest notifications 

224 Index( 

225 "ix_notifications_latest", 

226 user_id, 

227 id.desc(), 

228 topic_action, 

229 ), 

230 ) 

231 

232 @property 

233 def topic(self) -> str: 

234 return self.topic_action.topic 

235 

236 @property 

237 def action(self) -> str: 

238 return self.topic_action.action 

239 

240 

241class NotificationDelivery(Base): 

242 __tablename__ = "notification_deliveries" 

243 

244 id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 

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

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

247 delivered: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 

248 read: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 

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

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

251 # todo: device id 

252 # todo: receipt id, etc 

253 notification = relationship("Notification", foreign_keys="NotificationDelivery.notification_id") 

254 

255 __table_args__ = ( 

256 UniqueConstraint("notification_id", "delivery_type"), 

257 # used in looking up which notifications need delivery 

258 Index( 

259 "ix_notification_deliveries_delivery_type", 

260 delivery_type, 

261 postgresql_where=(delivered != None), 

262 ), 

263 Index( 

264 "ix_notification_deliveries_dt_ni_dnull", 

265 delivery_type, 

266 notification_id, 

267 delivered == None, 

268 ), 

269 ) 

270 

271 

272class DeviceType(enum.Enum): 

273 ios = enum.auto() 

274 android = enum.auto() 

275 

276 

277class PushNotificationPlatform(enum.Enum): 

278 web_push = enum.auto() 

279 expo = enum.auto() 

280 

281 

282class PushNotificationDeliveryOutcome(enum.Enum): 

283 success = enum.auto() 

284 # transient failure, will retry 

285 transient_failure = enum.auto() 

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

287 permanent_message_failure = enum.auto() 

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

289 permanent_subscription_failure = enum.auto() 

290 

291 

292class PushNotificationSubscription(Base): 

293 __tablename__ = "push_notification_subscriptions" 

294 

295 id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 

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

297 

298 # which user this is connected to 

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

300 

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

302 

303 ## platform specific: web_push 

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

305 # the endpoint 

306 endpoint: Mapped[str | None] = mapped_column(String) 

307 # the "auth" key 

308 auth_key: Mapped[bytes | None] = mapped_column(Binary) 

309 # the "p256dh" key 

310 p256dh_key: Mapped[bytes | None] = mapped_column(Binary) 

311 

312 full_subscription_info: Mapped[str | None] = mapped_column(String) 

313 

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

315 user_agent: Mapped[str | None] = mapped_column(String) 

316 

317 ## platform specific: expo 

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

319 device_name: Mapped[str | None] = mapped_column(String) 

320 device_type: Mapped[DeviceType | None] = mapped_column(Enum(DeviceType)) 

321 

322 # when it was disabled 

323 disabled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=DATETIME_INFINITY.isoformat()) 

324 

325 user = relationship("User") 

326 

327 __table_args__ = ( 

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

329 # expo platform requires: token 

330 CheckConstraint( 

331 """ 

332 (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) 

333 OR 

334 (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) 

335 """, 

336 name="platform_columns", 

337 ), 

338 ) 

339 

340 

341class PushNotificationDeliveryAttempt(Base): 

342 __tablename__ = "push_notification_delivery_attempt" 

343 

344 id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 

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

346 

347 push_notification_subscription_id: Mapped[int] = mapped_column( 

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

349 ) 

350 

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

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

353 status_code: Mapped[int | None] = mapped_column(Integer) 

354 

355 # can be null if it was a success 

356 response: Mapped[str | None] = mapped_column(String) 

357 

358 # Expo-specific: ticket ID for receipt checking 

359 expo_ticket_id: Mapped[str | None] = mapped_column(String) 

360 

361 # Receipt check results (populated by delayed job) 

362 receipt_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 

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

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

365 

366 push_notification_subscription = relationship("PushNotificationSubscription")