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

1""" 

2Shared host request helpers. 

3 

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: 

6 

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 

10 

11So "am I hosting?" is not the same question as "did I receive this?". 

12""" 

13 

14from sqlalchemy.sql import and_, case, exists, or_, select 

15from sqlalchemy.sql.elements import ColumnElement 

16 

17from couchers.models import HostRequest, Message 

18from couchers.models.notifications import NotificationTopicAction 

19 

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] 

31 

32 

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 ) 

39 

40 

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 ) 

47 

48 

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) 

55 

56 

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 ) 

67 

68 

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 )