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

1import enum 

2 

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 

20 

21from couchers.constants import DATETIME_INFINITY 

22from couchers.models.base import Base 

23from proto import notification_data_pb2 

24 

25 

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

33 

34 

35dt = NotificationDeliveryType 

36nd = notification_data_pb2 

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

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

39 

40 

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 

47 

48 self.data_type = data_type 

49 

50 def unpack(self): 

51 return self.topic, self.action 

52 

53 @property 

54 def display(self): 

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

56 

57 def __str__(self): 

58 return self.display 

59 

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) 

63 

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) 

73 

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

75 

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) 

82 

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) 

87 

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

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

90 

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) 

94 

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) 

107 

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) 

112 

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

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

115 

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) 

125 

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) 

132 

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) 

137 

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

139 

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

141 

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

143 

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) 

146 

147 # general announcements 

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

149 

150 

151class NotificationPreference(Base): 

152 __tablename__ = "notification_preferences" 

153 

154 id = Column(BigInteger, primary_key=True) 

155 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

156 

157 topic_action = Column(Enum(NotificationTopicAction), nullable=False) 

158 delivery_type = Column(Enum(NotificationDeliveryType), nullable=False) 

159 deliver = Column(Boolean, nullable=False) 

160 

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

162 

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

164 

165 

166class Notification(Base): 

167 """ 

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

169 """ 

170 

171 __tablename__ = "notifications" 

172 

173 id = Column(BigInteger, primary_key=True) 

174 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

175 

176 # recipient user id 

177 user_id = Column(ForeignKey("users.id"), nullable=False) 

178 

179 topic_action = Column(Enum(NotificationTopicAction), nullable=False) 

180 key = Column(String, nullable=False) 

181 

182 data = Column(Binary, nullable=False) 

183 

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

185 is_seen = Column(Boolean, nullable=False, server_default=expression.false()) 

186 

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

188 

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 ) 

210 

211 @property 

212 def topic(self): 

213 return self.topic_action.topic 

214 

215 @property 

216 def action(self): 

217 return self.topic_action.action 

218 

219 

220class NotificationDelivery(Base): 

221 __tablename__ = "notification_deliveries" 

222 

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

233 

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 ) 

249 

250 

251class PushNotificationSubscription(Base): 

252 __tablename__ = "push_notification_subscriptions" 

253 

254 id = Column(BigInteger, primary_key=True) 

255 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

256 

257 # which user this is connected to 

258 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

259 

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) 

267 

268 full_subscription_info = Column(String, nullable=False) 

269 

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

271 user_agent = Column(String, nullable=True) 

272 

273 # when it was disabled 

274 disabled_at = Column(DateTime(timezone=True), nullable=False, server_default=DATETIME_INFINITY.isoformat()) 

275 

276 user = relationship("User") 

277 

278 

279class PushNotificationDeliveryAttempt(Base): 

280 __tablename__ = "push_notification_delivery_attempt" 

281 

282 id = Column(BigInteger, primary_key=True) 

283 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

284 

285 push_notification_subscription_id = Column( 

286 ForeignKey("push_notification_subscriptions.id"), nullable=False, index=True 

287 ) 

288 

289 success = Column(Boolean, nullable=False) 

290 # the HTTP status code, 201 is success 

291 status_code = Column(Integer, nullable=False) 

292 

293 # can be null if it was a success 

294 response = Column(String, nullable=True) 

295 

296 push_notification_subscription = relationship("PushNotificationSubscription")