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
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
1import logging
3from sqlalchemy.orm import Session
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
16logger = logging.getLogger(__name__)
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
25 Must be done in session scope
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)]
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)}
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()
75class PreferenceNotUserEditableError(Exception):
76 pass
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()
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]
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]
334 actions_by_topic_check = {}
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
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())
350check_settings()
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