Coverage for src/couchers/models/notifications.py: 99%
137 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
1import enum
3from google.protobuf import empty_pb2
4from sqlalchemy import (
5 BigInteger,
6 Boolean,
7 Column,
8 DateTime,
9 Enum,
10 ForeignKey,
11 Index,
12 Integer,
13 String,
14 UniqueConstraint,
15 func,
16)
17from sqlalchemy import LargeBinary as Binary
18from sqlalchemy.orm import relationship
19from sqlalchemy.sql import expression
21from couchers.constants import DATETIME_INFINITY
22from couchers.models.base import Base
23from proto import notification_data_pb2
26class NotificationDeliveryType(enum.Enum):
27 # send push notification to mobile/web
28 push = enum.auto()
29 # send individual email immediately
30 email = enum.auto()
31 # send in digest
32 digest = enum.auto()
35dt = NotificationDeliveryType
36nd = notification_data_pb2
37dt_sec = [dt.email, dt.push]
38dt_all = [dt.email, dt.push, dt.digest]
41class NotificationTopicAction(enum.Enum):
42 def __init__(self, topic_action, defaults, user_editable, data_type):
43 self.topic, self.action = topic_action.split(":")
44 self.defaults = defaults
45 # for now user editable == not a security notification
46 self.user_editable = user_editable
48 self.data_type = data_type
50 def unpack(self):
51 return self.topic, self.action
53 @property
54 def display(self):
55 return f"{self.topic}:{self.action}"
57 def __str__(self):
58 return self.display
60 # topic, action, default delivery types
61 friend_request__create = ("friend_request:create", dt_all, True, nd.FriendRequestCreate)
62 friend_request__accept = ("friend_request:accept", dt_all, True, nd.FriendRequestAccept)
64 # host requests
65 host_request__create = ("host_request:create", dt_all, True, nd.HostRequestCreate)
66 host_request__accept = ("host_request:accept", dt_all, True, nd.HostRequestAccept)
67 host_request__reject = ("host_request:reject", dt_all, True, nd.HostRequestReject)
68 host_request__confirm = ("host_request:confirm", dt_all, True, nd.HostRequestConfirm)
69 host_request__cancel = ("host_request:cancel", dt_all, True, nd.HostRequestCancel)
70 host_request__message = ("host_request:message", [dt.push, dt.digest], True, nd.HostRequestMessage)
71 host_request__missed_messages = ("host_request:missed_messages", [dt.email], True, nd.HostRequestMissedMessages)
72 host_request__reminder = ("host_request:reminder", dt_all, True, nd.HostRequestReminder)
74 activeness__probe = ("activeness:probe", dt_sec, False, nd.ActivenessProbe)
76 # you receive a friend ref
77 reference__receive_friend = ("reference:receive_friend", dt_all, True, nd.ReferenceReceiveFriend)
78 # you receive a reference from ... the host
79 reference__receive_hosted = ("reference:receive_hosted", dt_all, True, nd.ReferenceReceiveHostRequest)
80 # ... the surfer
81 reference__receive_surfed = ("reference:receive_surfed", dt_all, True, nd.ReferenceReceiveHostRequest)
83 # you hosted
84 reference__reminder_hosted = ("reference:reminder_hosted", dt_all, True, nd.ReferenceReminder)
85 # you surfed
86 reference__reminder_surfed = ("reference:reminder_surfed", dt_all, True, nd.ReferenceReminder)
88 badge__add = ("badge:add", [dt.push, dt.digest], True, nd.BadgeAdd)
89 badge__remove = ("badge:remove", [dt.push, dt.digest], True, nd.BadgeRemove)
91 # group chats
92 chat__message = ("chat:message", [dt.push, dt.digest], True, nd.ChatMessage)
93 chat__missed_messages = ("chat:missed_messages", [dt.email], True, nd.ChatMissedMessages)
95 # events
96 # approved by mods
97 event__create_approved = ("event:create_approved", dt_all, True, nd.EventCreate)
98 # any user creates any event, default to no notifications
99 event__create_any = ("event:create_any", [], True, nd.EventCreate)
100 event__update = ("event:update", dt_all, True, nd.EventUpdate)
101 event__cancel = ("event:cancel", dt_all, True, nd.EventCancel)
102 event__delete = ("event:delete", dt_all, True, nd.EventDelete)
103 event__invite_organizer = ("event:invite_organizer", dt_all, True, nd.EventInviteOrganizer)
104 event__reminder = ("event:reminder", dt_all, True, nd.EventReminder)
105 # toplevel comment on an event
106 event__comment = ("event:comment", dt_all, True, nd.EventComment)
108 # discussion created
109 discussion__create = ("discussion:create", [dt.digest], True, nd.DiscussionCreate)
110 # someone comments on your discussion
111 discussion__comment = ("discussion:comment", dt_all, True, nd.DiscussionComment)
113 # someone responds to any of your top-level comment across the platform
114 thread__reply = ("thread:reply", dt_all, True, nd.ThreadReply)
116 # account settings
117 password__change = ("password:change", dt_sec, False, empty_pb2.Empty)
118 email_address__change = ("email_address:change", dt_sec, False, nd.EmailAddressChange)
119 email_address__verify = ("email_address:verify", dt_sec, False, empty_pb2.Empty)
120 phone_number__change = ("phone_number:change", dt_sec, False, nd.PhoneNumberChange)
121 phone_number__verify = ("phone_number:verify", dt_sec, False, nd.PhoneNumberVerify)
122 # reset password
123 password_reset__start = ("password_reset:start", dt_sec, False, nd.PasswordResetStart)
124 password_reset__complete = ("password_reset:complete", dt_sec, False, empty_pb2.Empty)
126 # account deletion
127 account_deletion__start = ("account_deletion:start", dt_sec, False, nd.AccountDeletionStart)
128 # no more pushing to do
129 account_deletion__complete = ("account_deletion:complete", dt_sec, False, nd.AccountDeletionComplete)
130 # undeleted
131 account_deletion__recovered = ("account_deletion:recovered", dt_sec, False, empty_pb2.Empty)
133 # admin actions
134 gender__change = ("gender:change", dt_sec, False, nd.GenderChange)
135 birthdate__change = ("birthdate:change", dt_sec, False, nd.BirthdateChange)
136 api_key__create = ("api_key:create", dt_sec, False, nd.ApiKeyCreate)
138 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived)
140 onboarding__reminder = ("onboarding:reminder", dt_sec, True, empty_pb2.Empty)
142 modnote__create = ("modnote:create", dt_sec, False, empty_pb2.Empty)
144 verification__sv_fail = ("verification:sv_fail", dt_sec, False, nd.VerificationSVFail)
145 verification__sv_success = ("verification:sv_success", dt_sec, False, empty_pb2.Empty)
147 # general announcements
148 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], True, nd.GeneralNewBlogPost)
151class NotificationPreference(Base):
152 __tablename__ = "notification_preferences"
154 id = Column(BigInteger, primary_key=True)
155 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
157 topic_action = Column(Enum(NotificationTopicAction), nullable=False)
158 delivery_type = Column(Enum(NotificationDeliveryType), nullable=False)
159 deliver = Column(Boolean, nullable=False)
161 user = relationship("User", foreign_keys="NotificationPreference.user_id")
163 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),)
166class Notification(Base):
167 """
168 Table for accumulating notifications until it is time to send email digest
169 """
171 __tablename__ = "notifications"
173 id = Column(BigInteger, primary_key=True)
174 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
176 # recipient user id
177 user_id = Column(ForeignKey("users.id"), nullable=False)
179 topic_action = Column(Enum(NotificationTopicAction), nullable=False)
180 key = Column(String, nullable=False)
182 data = Column(Binary, nullable=False)
184 # whether the user has marked this notification as seen or not
185 is_seen = Column(Boolean, nullable=False, server_default=expression.false())
187 user = relationship("User", foreign_keys="Notification.user_id")
189 __table_args__ = (
190 # used in looking up which notifications need delivery
191 Index(
192 "ix_notifications_created",
193 created,
194 ),
195 # Fast lookup for unseen notification count
196 Index(
197 "ix_notifications_unseen",
198 user_id,
199 topic_action,
200 postgresql_where=(is_seen == False),
201 ),
202 # Fast lookup for latest notifications
203 Index(
204 "ix_notifications_latest",
205 user_id,
206 id.desc(),
207 topic_action,
208 ),
209 )
211 @property
212 def topic(self):
213 return self.topic_action.topic
215 @property
216 def action(self):
217 return self.topic_action.action
220class NotificationDelivery(Base):
221 __tablename__ = "notification_deliveries"
223 id = Column(BigInteger, primary_key=True)
224 notification_id = Column(ForeignKey("notifications.id"), nullable=False, index=True)
225 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
226 delivered = Column(DateTime(timezone=True), nullable=True)
227 read = Column(DateTime(timezone=True), nullable=True)
228 # todo: enum of "phone, web, digest"
229 delivery_type = Column(Enum(NotificationDeliveryType), nullable=False)
230 # todo: device id
231 # todo: receipt id, etc
232 notification = relationship("Notification", foreign_keys="NotificationDelivery.notification_id")
234 __table_args__ = (
235 UniqueConstraint("notification_id", "delivery_type"),
236 # used in looking up which notifications need delivery
237 Index(
238 "ix_notification_deliveries_delivery_type",
239 delivery_type,
240 postgresql_where=(delivered != None),
241 ),
242 Index(
243 "ix_notification_deliveries_dt_ni_dnull",
244 delivery_type,
245 notification_id,
246 delivered == None,
247 ),
248 )
251class PushNotificationSubscription(Base):
252 __tablename__ = "push_notification_subscriptions"
254 id = Column(BigInteger, primary_key=True)
255 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
257 # which user this is connected to
258 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
260 # these come from https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription
261 # the endpoint
262 endpoint = Column(String, nullable=False)
263 # the "auth" key
264 auth_key = Column(Binary, nullable=False)
265 # the "p256dh" key
266 p256dh_key = Column(Binary, nullable=False)
268 full_subscription_info = Column(String, nullable=False)
270 # the browse user-agent, so we can tell the user what browser notifications are going to
271 user_agent = Column(String, nullable=True)
273 # when it was disabled
274 disabled_at = Column(DateTime(timezone=True), nullable=False, server_default=DATETIME_INFINITY.isoformat())
276 user = relationship("User")
279class PushNotificationDeliveryAttempt(Base):
280 __tablename__ = "push_notification_delivery_attempt"
282 id = Column(BigInteger, primary_key=True)
283 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
285 push_notification_subscription_id = Column(
286 ForeignKey("push_notification_subscriptions.id"), nullable=False, index=True
287 )
289 success = Column(Boolean, nullable=False)
290 # the HTTP status code, 201 is success
291 status_code = Column(Integer, nullable=False)
293 # can be null if it was a success
294 response = Column(String, nullable=True)
296 push_notification_subscription = relationship("PushNotificationSubscription")