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
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
1import enum
2from datetime import datetime
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
22from couchers.constants import DATETIME_INFINITY
23from couchers.models.base import Base
24from couchers.proto import notification_data_pb2
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()
36dt = NotificationDeliveryType
37nd = notification_data_pb2
38dt_sec = [dt.email, dt.push]
39dt_all = [dt.email, dt.push, dt.digest]
40dt_empty: list[NotificationDeliveryType] = []
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
52 self.data_type = data_type
54 def unpack(self) -> tuple[str, str]:
55 return self.topic, self.action
57 @property
58 def display(self) -> str:
59 return f"{self.topic}:{self.action}"
61 def __str__(self) -> str:
62 return self.display
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)
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)
78 activeness__probe = ("activeness:probe", dt_sec, False, nd.ActivenessProbe)
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)
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)
92 badge__add = ("badge:add", [dt.push, dt.digest], True, nd.BadgeAdd)
93 badge__remove = ("badge:remove", [dt.push, dt.digest], True, nd.BadgeRemove)
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)
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)
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)
117 # someone responds to any of your top-level comment across the platform
118 thread__reply = ("thread:reply", dt_all, True, nd.ThreadReply)
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)
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)
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)
142 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived)
144 onboarding__reminder = ("onboarding:reminder", dt_sec, True, empty_pb2.Empty)
146 modnote__create = ("modnote:create", dt_sec, False, empty_pb2.Empty)
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)
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)
161 # general announcements
162 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], True, nd.GeneralNewBlogPost)
165class NotificationPreference(Base):
166 __tablename__ = "notification_preferences"
168 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
169 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
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)
175 user = relationship("User", foreign_keys="NotificationPreference.user_id")
177 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),)
180class Notification(Base):
181 """
182 Table for accumulating notifications until it is time to send email digest
183 """
185 __tablename__ = "notifications"
187 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
188 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
190 # recipient user id
191 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
193 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction))
194 key: Mapped[str] = mapped_column(String)
196 data: Mapped[bytes] = mapped_column(Binary)
198 # whether the user has marked this notification as seen or not
199 is_seen: Mapped[bool] = mapped_column(Boolean, server_default=expression.false())
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 )
207 user = relationship("User", foreign_keys="Notification.user_id")
208 moderation_state = relationship("ModerationState", foreign_keys="Notification.moderation_state_id")
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 )
232 @property
233 def topic(self) -> str:
234 return self.topic_action.topic
236 @property
237 def action(self) -> str:
238 return self.topic_action.action
241class NotificationDelivery(Base):
242 __tablename__ = "notification_deliveries"
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")
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 )
272class DeviceType(enum.Enum):
273 ios = enum.auto()
274 android = enum.auto()
277class PushNotificationPlatform(enum.Enum):
278 web_push = enum.auto()
279 expo = enum.auto()
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()
292class PushNotificationSubscription(Base):
293 __tablename__ = "push_notification_subscriptions"
295 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
296 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
298 # which user this is connected to
299 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
301 platform: Mapped[PushNotificationPlatform] = mapped_column(Enum(PushNotificationPlatform))
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)
312 full_subscription_info: Mapped[str | None] = mapped_column(String)
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)
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))
322 # when it was disabled
323 disabled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=DATETIME_INFINITY.isoformat())
325 user = relationship("User")
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 )
341class PushNotificationDeliveryAttempt(Base):
342 __tablename__ = "push_notification_delivery_attempt"
344 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
345 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
347 push_notification_subscription_id: Mapped[int] = mapped_column(
348 ForeignKey("push_notification_subscriptions.id"), index=True
349 )
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)
355 # can be null if it was a success
356 response: Mapped[str | None] = mapped_column(String)
358 # Expo-specific: ticket ID for receipt checking
359 expo_ticket_id: Mapped[str | None] = mapped_column(String)
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"
366 push_notification_subscription = relationship("PushNotificationSubscription")