Coverage for src/couchers/notifications/quick_links.py: 80%
61 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +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
11from couchers import errors, urls
12from couchers.constants import DATETIME_INFINITY
13from couchers.context import make_one_off_interactive_user_context
14from couchers.crypto import UNSUBSCRIBE_KEY_NAME, b64encode, generate_hash_signature, get_secret, verify_hash_signature
15from couchers.models import GroupChatSubscription, HostingStatus, MeetupStatus, NotificationDeliveryType, User
16from couchers.notifications import settings
17from couchers.notifications.utils import enum_from_topic_action
18from couchers.servicers.requests import Requests
19from couchers.sql import couchers_select as select
20from couchers.utils import now
21from proto import conversations_pb2, requests_pb2
22from proto.internal import unsubscribe_pb2
24logger = logging.getLogger(__name__)
27def _generate_quick_link(payload):
28 payload.created.FromDatetime(now())
29 msg = payload.SerializeToString()
30 sig = generate_hash_signature(message=msg, key=get_secret(UNSUBSCRIBE_KEY_NAME))
31 return urls.quick_link(payload=b64encode(msg), sig=b64encode(sig))
34def generate_do_not_email(user):
35 return _generate_quick_link(
36 unsubscribe_pb2.UnsubscribePayload(
37 user_id=user.id,
38 do_not_email=unsubscribe_pb2.DoNotEmail(),
39 )
40 )
43def generate_unsub_topic_key(notification):
44 return _generate_quick_link(
45 unsubscribe_pb2.UnsubscribePayload(
46 user_id=notification.user_id,
47 topic_key=unsubscribe_pb2.UnsubscribeTopicKey(
48 topic=notification.topic,
49 key=notification.key,
50 ),
51 )
52 )
55def generate_unsub_topic_action(notification):
56 return _generate_quick_link(
57 unsubscribe_pb2.UnsubscribePayload(
58 user_id=notification.user_id,
59 topic_action=unsubscribe_pb2.UnsubscribeTopicAction(
60 topic=notification.topic,
61 action=notification.action,
62 ),
63 )
64 )
67def generate_quick_decline_link(host_request):
68 return _generate_quick_link(
69 unsubscribe_pb2.UnsubscribePayload(
70 user_id=host_request.host_user_id,
71 host_request_quick_decline=unsubscribe_pb2.HostRequestQuickDecline(
72 host_request_id=host_request.host_request_id,
73 ),
74 )
75 )
78def respond_quick_link(request, context, session):
79 """
80 Returns a response string or uses context.abort upon error
81 """
82 if not verify_hash_signature(message=request.payload, key=get_secret(UNSUBSCRIBE_KEY_NAME), sig=request.sig):
83 context.abort(grpc.StatusCode.PERMISSION_DENIED, errors.WRONG_SIGNATURE)
84 payload = unsubscribe_pb2.UnsubscribePayload.FromString(request.payload)
85 user = session.execute(select(User).where(User.id == payload.user_id)).scalar_one()
86 if payload.HasField("do_not_email"):
87 logger.info(f"User {user.name} turning of emails")
88 user.do_not_email = True
89 user.hosting_status = HostingStatus.cant_host
90 user.meetup_status = MeetupStatus.does_not_want_to_meetup
91 return "You will not receive any non-security emails, and your hosting status has been turned off. You may still receive the newsletter, and need to unsubscribe from it separately."
92 if payload.HasField("topic_action"):
93 logger.info(f"User {user.name} unsubscribing from topic_action")
94 topic = payload.topic_action.topic
95 action = payload.topic_action.action
96 topic_action = enum_from_topic_action[topic, action]
97 # disable emails for this type
98 settings.set_preference(session, user.id, topic_action, NotificationDeliveryType.email, False)
99 return "You've been unsubscribed from email notifications of that type."
100 if payload.HasField("topic_key"):
101 logger.info(f"User {user.name} unsubscribing from topic_key")
102 topic = payload.topic_key.topic
103 key = payload.topic_key.key
104 # a bunch of manual stuff
105 if topic == "chat":
106 group_chat_id = int(key)
107 subscription = session.execute(
108 select(GroupChatSubscription)
109 .where(GroupChatSubscription.group_chat_id == group_chat_id)
110 .where(GroupChatSubscription.user_id == user.id)
111 .where(GroupChatSubscription.left == None)
112 ).scalar_one_or_none()
114 if not subscription:
115 context.abort(grpc.StatusCode.NOT_FOUND, errors.CHAT_NOT_FOUND)
117 subscription.muted_until = DATETIME_INFINITY
118 return "That group chat has been muted."
119 else:
120 context.abort(grpc.StatusCode.UNIMPLEMENTED, errors.CANT_UNSUB_TOPIC)
121 if payload.HasField("host_request_quick_decline"):
122 Requests().RespondHostRequest(
123 request=requests_pb2.RespondHostRequestReq(
124 host_request_id=payload.host_request_quick_decline.host_request_id,
125 status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED,
126 ),
127 context=make_one_off_interactive_user_context(couchers_context=context, user_id=payload.user_id),
128 session=session,
129 )
130 return "Thank you for responding to the host request!"