Coverage for app / backend / src / couchers / notifications / quick_links.py: 74%
69 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1"""
2Quick email actions, based on signed URLs that don't require login to submit, such as unsubscribe, quick decline
4It is called "unsubscribe" in some places for historical reasons (it was the first use case).
5"""
7import logging
9import grpc
10from google.protobuf.message import Message
11from sqlalchemy import select
12from sqlalchemy.orm import Session
14from couchers import urls
15from couchers.constants import DATETIME_INFINITY
16from couchers.context import CouchersContext, make_one_off_interactive_user_context
17from couchers.crypto import UNSUBSCRIBE_KEY_NAME, b64encode, generate_hash_signature, get_secret, verify_hash_signature
18from couchers.models import (
19 GroupChat,
20 GroupChatSubscription,
21 HostingStatus,
22 MeetupStatus,
23 Notification,
24 NotificationDeliveryType,
25 NotificationTopicAction,
26 User,
27)
28from couchers.notifications import settings
29from couchers.notifications.utils import enum_from_topic_action
30from couchers.proto import auth_pb2, conversations_pb2, requests_pb2
31from couchers.proto.internal import unsubscribe_pb2
32from couchers.servicers.requests import Requests
33from couchers.sql import where_moderated_content_visible
34from couchers.utils import now
36logger = logging.getLogger(__name__)
39def _generate_quick_link(payload: Message) -> str:
40 payload.created.FromDatetime(now()) # type: ignore[attr-defined]
41 msg = payload.SerializeToString()
42 sig = generate_hash_signature(message=msg, key=get_secret(UNSUBSCRIBE_KEY_NAME))
43 return urls.quick_link(payload=b64encode(msg), sig=b64encode(sig))
46def generate_do_not_email(user: User) -> str:
47 return _generate_quick_link(
48 unsubscribe_pb2.UnsubscribePayload(
49 user_id=user.id,
50 do_not_email=unsubscribe_pb2.DoNotEmail(),
51 )
52 )
55def generate_unsub_topic_key(notification: Notification) -> str:
56 return _generate_quick_link(
57 unsubscribe_pb2.UnsubscribePayload(
58 user_id=notification.user_id,
59 topic_key=unsubscribe_pb2.UnsubscribeTopicKey(
60 topic=notification.topic,
61 key=notification.key,
62 ),
63 )
64 )
67def generate_unsub_topic_action(notification: Notification) -> str:
68 return _generate_quick_link(
69 unsubscribe_pb2.UnsubscribePayload(
70 user_id=notification.user_id,
71 topic_action=unsubscribe_pb2.UnsubscribeTopicAction(
72 topic=notification.topic,
73 action=notification.action,
74 ),
75 )
76 )
79def generate_quick_decline_link(host_request: requests_pb2.HostRequest) -> str:
80 return _generate_quick_link(
81 unsubscribe_pb2.UnsubscribePayload(
82 user_id=host_request.host_user_id,
83 host_request_quick_decline=unsubscribe_pb2.HostRequestQuickDecline(
84 host_request_id=host_request.host_request_id,
85 ),
86 )
87 )
90def can_unsubscribe_topic_key(topic: str | NotificationTopicAction) -> bool:
91 """
92 Determines whether a user can unsubscribe from all notification actions
93 concerning a given topic.
94 """
95 if isinstance(topic, NotificationTopicAction): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 topic = topic.topic
97 # We currently only support unsubscribing from chat topics
98 return topic == NotificationTopicAction.chat__message.topic
101def respond_quick_link(request: auth_pb2.UnsubscribeReq, context: CouchersContext, session: Session) -> str:
102 """
103 Returns a response string or uses context.abort upon error
104 """
105 if not verify_hash_signature(message=request.payload, key=get_secret(UNSUBSCRIBE_KEY_NAME), sig=request.sig): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "wrong_signature")
107 payload = unsubscribe_pb2.UnsubscribePayload.FromString(request.payload)
108 user = session.execute(select(User).where(User.id == payload.user_id)).scalar_one()
109 if payload.HasField("do_not_email"):
110 logger.info(f"User {user.name} turning of emails")
111 user.do_not_email = True
112 user.hosting_status = HostingStatus.cant_host
113 user.meetup_status = MeetupStatus.does_not_want_to_meetup
114 return context.localization.localize_string("quick_links.do_not_email")
115 if payload.HasField("topic_action"):
116 logger.info(f"User {user.name} unsubscribing from topic_action")
117 topic = payload.topic_action.topic
118 action = payload.topic_action.action
119 topic_action = enum_from_topic_action[topic, action]
120 # disable emails for this type
121 settings.set_preference(session, user.id, topic_action, NotificationDeliveryType.email, False)
122 return context.localization.localize_string("quick_links.topic_action")
123 if payload.HasField("topic_key"): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 logger.info(f"User {user.name} unsubscribing from topic_key")
125 topic = payload.topic_key.topic
126 key = payload.topic_key.key
127 # a bunch of manual stuff
128 if topic == "chat":
129 group_chat_id = int(key)
130 subscription = session.execute(
131 where_moderated_content_visible(
132 select(GroupChatSubscription).join(
133 GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id
134 ),
135 context,
136 GroupChat,
137 is_list_operation=False,
138 )
139 .where(GroupChatSubscription.group_chat_id == group_chat_id)
140 .where(GroupChatSubscription.user_id == user.id)
141 .where(GroupChatSubscription.left == None)
142 ).scalar_one_or_none()
144 if subscription is None:
145 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
147 subscription.muted_until = DATETIME_INFINITY
148 return context.localization.localize_string("quick_links.chat_unsub")
149 else:
150 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "cant_unsub_topic")
151 if payload.HasField("host_request_quick_decline"): 151 ↛ 161line 151 didn't jump to line 161 because the condition on line 151 was always true
152 Requests().RespondHostRequest(
153 request=requests_pb2.RespondHostRequestReq(
154 host_request_id=payload.host_request_quick_decline.host_request_id,
155 status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED,
156 ),
157 context=make_one_off_interactive_user_context(couchers_context=context, user_id=payload.user_id),
158 session=session,
159 )
160 return context.localization.localize_string("quick_links.host_request_quick_decline")
161 raise Exception("Unhandled quick link type")