Coverage for app/backend/src/couchers/helpers/host_requests.py: 100%
15 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 23:56 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 23:56 +0000
1"""
2Shared host request helpers.
4The role-based party predicates below exist because a host request has a fixed direction
5(initiator -> recipient), but the *stay-role* of each party depends on how the request came about:
7 - a normal request: the initiator is the surfer, the recipient is the host
8 - a public-trip offer: the roles are reversed — the initiator is offering their couch, so they are
9 the host, and the recipient (the trip owner) is the surfer
11So "am I hosting?" is not the same question as "did I receive this?".
12"""
14from sqlalchemy.sql import and_, case, exists, or_, select
15from sqlalchemy.sql.elements import ColumnElement
17from couchers.models import HostRequest, Message
18from couchers.models.notifications import NotificationTopicAction
20# topic actions whose notifications are marked seen alongside a host request being read
21HOST_REQUEST_NOTIFICATION_TOPIC_ACTIONS = [
22 NotificationTopicAction.host_request__create,
23 NotificationTopicAction.host_request__accept,
24 NotificationTopicAction.host_request__reject,
25 NotificationTopicAction.host_request__confirm,
26 NotificationTopicAction.host_request__cancel,
27 NotificationTopicAction.host_request__message,
28 NotificationTopicAction.host_request__missed_messages,
29 NotificationTopicAction.host_request__reminder,
30]
33def is_hosting_party(user_id: int) -> ColumnElement[bool]:
34 """The viewer is the host of the stay: a normal request they received, or an offer they sent."""
35 return or_(
36 and_(HostRequest.public_trip_id.is_(None), HostRequest.recipient_user_id == user_id),
37 and_(HostRequest.public_trip_id.isnot(None), HostRequest.initiator_user_id == user_id),
38 )
41def is_surfing_party(user_id: int) -> ColumnElement[bool]:
42 """The viewer is the guest of the stay: a normal request they sent, or an offer they received."""
43 return or_(
44 and_(HostRequest.public_trip_id.is_(None), HostRequest.initiator_user_id == user_id),
45 and_(HostRequest.public_trip_id.isnot(None), HostRequest.recipient_user_id == user_id),
46 )
49def is_public_trip_offer_recipient(user_id: int) -> ColumnElement[bool]:
50 """
51 An offer on one of the viewer's public trips: they own the trip, so they received the offer.
52 A strict subset of is_surfing_party.
53 """
54 return and_(HostRequest.public_trip_id.isnot(None), HostRequest.recipient_user_id == user_id)
57def viewer_last_seen_message_id(user_id: int) -> ColumnElement[int | None]:
58 """
59 The viewer's last-seen message id on a request, whichever side of it they are on, and NULL if
60 they are on neither. NULL rather than an else_ branch so that a user who isn't a party to the
61 request compares false rather than silently reading the other party's column.
62 """
63 return case(
64 (HostRequest.initiator_user_id == user_id, HostRequest.initiator_last_seen_message_id),
65 (HostRequest.recipient_user_id == user_id, HostRequest.recipient_last_seen_message_id),
66 )
69def has_unseen_host_request_messages(user_id: int) -> ColumnElement[bool]:
70 """
71 A request carrying at least one message the viewer hasn't seen. This is what the Ping badge
72 counts, so anything clearing the badge has to agree with it.
73 """
74 return exists(
75 select(1)
76 .where(Message.conversation_id == HostRequest.conversation_id)
77 .where(Message.id > viewer_last_seen_message_id(user_id))
78 )