Coverage for src/couchers/notifications/settings.py: 93%

61 statements  

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

1import logging 

2 

3from sqlalchemy.orm import Session 

4 

5from couchers.db import session_scope 

6from couchers.models import ( 

7 NotificationDelivery, 

8 NotificationDeliveryType, 

9 NotificationPreference, 

10 NotificationTopicAction, 

11) 

12from couchers.notifications.utils import enum_from_topic_action 

13from couchers.proto import notifications_pb2 

14from couchers.sql import couchers_select as select 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19def get_preference( 

20 session: Session, user_id: int, topic_action: NotificationTopicAction 

21) -> list[NotificationDeliveryType]: 

22 """ 

23 Gets the user's preference from the DB or otherwise falls back to defaults 

24 

25 Must be done in session scope 

26 

27 Returns list of delivery types 

28 """ 

29 overrides = { 

30 res.delivery_type: res.deliver 

31 for res in session.execute( 

32 select(NotificationPreference) 

33 .where(NotificationPreference.user_id == user_id) 

34 .where(NotificationPreference.topic_action == topic_action) 

35 ) 

36 .scalars() 

37 .all() 

38 } 

39 return [dt for dt in NotificationDeliveryType if overrides.get(dt, dt in topic_action.defaults)] 

40 

41 

42def get_topic_actions_by_delivery_type( 

43 session: Session, user_id: int, delivery_type: NotificationDeliveryType 

44) -> set[NotificationTopicAction]: 

45 """ 

46 Given push/email/digest, returns notifications that this user has enabled for that type. 

47 """ 

48 overrides: dict[NotificationTopicAction, bool] = {} 

49 for topic_action, deliver in session.execute( 

50 select(NotificationPreference.topic_action, NotificationPreference.deliver) 

51 .where(NotificationPreference.user_id == user_id) 

52 .where(NotificationPreference.delivery_type == delivery_type) 

53 ).all(): 

54 overrides[topic_action] = deliver 

55 return {t for t in NotificationTopicAction if overrides.get(t, delivery_type in t.defaults)} 

56 

57 

58def reset_preference( 

59 session: Session, 

60 user_id: int, 

61 topic_action: NotificationTopicAction, 

62 delivery_type: NotificationDeliveryType, 

63) -> None: 

64 current_pref = session.execute( 

65 select(NotificationPreference) 

66 .where(NotificationPreference.user_id == user_id) 

67 .where(NotificationPreference.topic_action == topic_action) 

68 .where(NotificationDelivery.delivery_type == delivery_type) 

69 ).scalar_one_or_none() 

70 if current_pref: 

71 session.delete(current_pref) 

72 session.flush() 

73 

74 

75class PreferenceNotUserEditableError(Exception): 

76 pass 

77 

78 

79def set_preference( 

80 session: Session, 

81 user_id: int, 

82 topic_action: NotificationTopicAction, 

83 delivery_type: NotificationDeliveryType, 

84 deliver: bool, 

85) -> None: 

86 if not topic_action.user_editable: 

87 raise PreferenceNotUserEditableError() 

88 current_pref = session.execute( 

89 select(NotificationPreference) 

90 .where(NotificationPreference.user_id == user_id) 

91 .where(NotificationPreference.topic_action == topic_action) 

92 .where(NotificationPreference.delivery_type == delivery_type) 

93 ).scalar_one_or_none() 

94 if current_pref: 

95 current_pref.deliver = deliver 

96 else: 

97 session.add( 

98 NotificationPreference( 

99 user_id=user_id, 

100 topic_action=topic_action, 

101 delivery_type=delivery_type, 

102 deliver=deliver, 

103 ) 

104 ) 

105 session.flush() 

106 

107 

108settings_layout = [ 

109 ( 

110 "Core Features", 

111 [ 

112 ( 

113 "host_request", 

114 "Host requests", 

115 [ 

116 ("create", "Someone sends you a host request"), 

117 ("accept", "Someone accepts your host request"), 

118 ("confirm", "Someone confirms their host request"), 

119 ("reject", "Someone declines your host request"), 

120 ("cancel", "Someone cancels their host request"), 

121 ("message", "Someone sends a message in a host request"), 

122 ("missed_messages", "You miss messages in a host request"), 

123 ("reminder", "You don't respond to a pending host request for a while"), 

124 ], 

125 ), 

126 ( 

127 "activeness", 

128 "Activity Check-in", 

129 [ 

130 ("probe", "Check in to see if you are still hosting after a long period of inactivity"), 

131 ], 

132 ), 

133 ( 

134 "chat", 

135 "Messaging", 

136 [ 

137 ("message", "Someone sends you a message"), 

138 ("missed_messages", "You miss messages in a chat"), 

139 ], 

140 ), 

141 ( 

142 "reference", 

143 "References", 

144 [ 

145 ("receive_hosted", "You receive a reference from someone who hosted you"), 

146 ("receive_surfed", "You receive a reference from someone you hosted"), 

147 ("receive_friend", "You received a reference from a friend"), 

148 ("reminder_hosted", "Reminder to write a reference to someone you hosted"), 

149 ("reminder_surfed", "Reminder to write a reference to someone you surfed with"), 

150 ], 

151 ), 

152 ], 

153 ), 

154 ( 

155 "Community Features", 

156 [ 

157 ( 

158 "friend_request", 

159 "Friend requests", 

160 [ 

161 ("create", "Someone sends you a friend request"), 

162 ("accept", "Someone accepts your friend request"), 

163 ], 

164 ), 

165 ( 

166 "event", 

167 "Events", 

168 [ 

169 ("create_approved", "An event that is approved by the moderators is created in your community"), 

170 ("create_any", "A user creates any event in your community (not checked by an admin)"), 

171 ("update", "An event you are attending is updated"), 

172 ("comment", "Someone comments on an event you are organizing or attending"), 

173 ("cancel", "An event you are attending is cancelled"), 

174 ("delete", "An event you are attending is deleted"), 

175 ("invite_organizer", "Someone invites you to co-organize an event"), 

176 ("reminder", "Reminder for an upcoming event"), 

177 ], 

178 ), 

179 ( 

180 "discussion", 

181 "Community discussions", 

182 [ 

183 ("create", "Someone creates a discussion in one of your communities"), 

184 ("comment", "Someone comments on a discussion you authored"), 

185 ], 

186 ), 

187 ( 

188 "thread", 

189 "Threads, Comments, & Replies", 

190 [ 

191 ("reply", "Someone replies to your comment"), 

192 ], 

193 ), 

194 ], 

195 ), 

196 ( 

197 "Account Settings", 

198 [ 

199 ( 

200 "onboarding", 

201 "Onboarding", 

202 [ 

203 ("reminder", "Reminder to complete your profile after signing up"), 

204 ], 

205 ), 

206 ( 

207 "badge", 

208 "Updates to Badges on your profile", 

209 [ 

210 ("add", "A badge is added to your account"), 

211 ("remove", "A badge is removed from your account"), 

212 ], 

213 ), 

214 ( 

215 "donation", 

216 "Donations", 

217 [ 

218 ("received", "Your donation is received"), 

219 ], 

220 ), 

221 ], 

222 ), 

223 ( 

224 "Account Security", 

225 [ 

226 ( 

227 "password", 

228 "Password change", 

229 [ 

230 ("change", "Your password is changed"), 

231 ], 

232 ), 

233 ( 

234 "password_reset", 

235 "Password reset", 

236 [ 

237 ("start", "Password reset is initiated"), 

238 ("complete", "Password reset is completed"), 

239 ], 

240 ), 

241 ( 

242 "email_address", 

243 "Email address change", 

244 [ 

245 ("change", "Email change is initiated"), 

246 ("verify", "Your new email is verified"), 

247 ], 

248 ), 

249 ( 

250 "account_deletion", 

251 "Account deletion", 

252 [ 

253 ("start", "You initiate account deletion"), 

254 ("complete", "Your account is deleted"), 

255 ("recovered", "Your account is recovered (undeleted)"), 

256 ], 

257 ), 

258 ( 

259 "api_key", 

260 "API keys", 

261 [ 

262 ("create", "An API key is created for your account"), 

263 ], 

264 ), 

265 ( 

266 "phone_number", 

267 "Phone number change", 

268 [ 

269 ("change", "Your phone number is changed"), 

270 ("verify", "Your phone number is verified"), 

271 ], 

272 ), 

273 ( 

274 "birthdate", 

275 "Birthdate change", 

276 [ 

277 ("change", "Your birthdate is changed"), 

278 ], 

279 ), 

280 ( 

281 "gender", 

282 "Displayed gender change", 

283 [ 

284 ("change", "The gender displayed on your profile is changed"), 

285 ], 

286 ), 

287 ( 

288 "modnote", 

289 "Moderator notes", 

290 [ 

291 ("create", "You receive a moderator note"), 

292 ], 

293 ), 

294 ( 

295 "verification", 

296 "Verification", 

297 [ 

298 ("sv_fail", "Strong Verification fails"), 

299 ("sv_success", "Strong Verification succeeds"), 

300 ], 

301 ), 

302 ( 

303 "postal_verification", 

304 "Postal Verification", 

305 [ 

306 ("postcard_sent", "Verification postcard is sent"), 

307 ("success", "Postal Verification succeeds"), 

308 ("failed", "Postal Verification fails"), 

309 ], 

310 ), 

311 ], 

312 ), 

313 ( 

314 "Other Notifications", 

315 [ 

316 ( 

317 "general", 

318 "General", 

319 [ 

320 ("new_blog_post", "We published a new blog post"), 

321 ], 

322 ), 

323 ], 

324 ), 

325] 

326 

327 

328def check_settings() -> None: 

329 # check settings contain all actions+topics 

330 actions_by_topic: dict[str, list[str]] = {} 

331 for t in NotificationTopicAction: 

332 actions_by_topic[t.topic] = actions_by_topic.get(t.topic, []) + [t.action] 

333 

334 actions_by_topic_check = {} 

335 

336 for heading, group in settings_layout: 

337 for topic, name, items in group: 

338 actions = [] 

339 for action, description in items: 

340 actions.append(action) 

341 actions_by_topic_check[topic] = actions 

342 

343 for topic, actions in actions_by_topic.items(): 

344 assert sorted(actions) == sorted(actions_by_topic_check[topic]), ( 

345 f"Expected {actions} == {actions_by_topic_check[topic]} for {topic}" 

346 ) 

347 assert sorted(actions_by_topic.keys()) == sorted(actions_by_topic_check.keys()) 

348 

349 

350check_settings() 

351 

352 

353def get_user_setting_groups(user_id: int) -> list[notifications_pb2.NotificationGroup]: 

354 with session_scope() as session: 

355 groups = [] 

356 for heading, group in settings_layout: 

357 topics = [] 

358 for topic, name, items in group: 

359 actions = [] 

360 for action, description in items: 

361 topic_action = enum_from_topic_action[topic, action] 

362 delivery_types = get_preference(session, user_id, topic_action) 

363 actions.append( 

364 notifications_pb2.NotificationItem( 

365 action=action, 

366 description=description, 

367 user_editable=topic_action.user_editable, 

368 push=NotificationDeliveryType.push in delivery_types, 

369 email=NotificationDeliveryType.email in delivery_types, 

370 digest=NotificationDeliveryType.digest in delivery_types, 

371 ) 

372 ) 

373 topics.append( 

374 notifications_pb2.NotificationTopic( 

375 topic=topic, 

376 name=name, 

377 items=actions, 

378 ) 

379 ) 

380 groups.append( 

381 notifications_pb2.NotificationGroup( 

382 heading=heading, 

383 topics=topics, 

384 ) 

385 ) 

386 return groups