Coverage for app/backend/src/couchers/models/host_requests.py: 97%

76 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1import enum 

2from datetime import date, datetime 

3from typing import TYPE_CHECKING, Any 

4 

5from geoalchemy2 import Geometry 

6from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, Float, ForeignKey, Index, String, func, text 

7from sqlalchemy.ext.hybrid import hybrid_property 

8from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship 

9from sqlalchemy.sql import expression 

10 

11from couchers.models.base import Base, Geom 

12from couchers.models.moderation import ModerationObjectType 

13from couchers.utils import date_in_timezone, now 

14 

15if TYPE_CHECKING: 

16 from couchers.models.conversations import Conversation 

17 from couchers.models.moderation import ModerationState 

18 from couchers.models.public_trips import PublicTrip 

19 from couchers.models.users import User 

20 

21 

22class HostRequestStatus(enum.Enum): 

23 pending = enum.auto() 

24 accepted = enum.auto() 

25 rejected = enum.auto() 

26 confirmed = enum.auto() 

27 cancelled = enum.auto() 

28 

29 

30class HostRequestQuality(enum.Enum): 

31 high_quality = enum.auto() 

32 okay_quality = enum.auto() 

33 low_quality = enum.auto() 

34 

35 

36class HostRequest(Base, kw_only=True): 

37 """ 

38 A request to stay with a host. 

39 

40 In a normal host request, initiator = surfer and recipient = host. 

41 """ 

42 

43 __tablename__ = "host_requests" 

44 __moderation_author_column__ = "initiator_user_id" 

45 __moderation_object_type__ = ModerationObjectType.host_request 

46 

47 conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True) 

48 initiator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

49 recipient_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

50 

51 # Unified Moderation System 

52 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True) 

53 

54 hosting_city: Mapped[str] = mapped_column(String) 

55 hosting_location: Mapped[Geom] = mapped_column(Geometry("POINT", srid=4326)) 

56 hosting_radius: Mapped[float] = mapped_column(Float) 

57 

58 # TODO: proper timezone handling 

59 timezone = "Etc/UTC" 

60 

61 # dates in the timezone above 

62 from_date: Mapped[date] = mapped_column(Date) 

63 to_date: Mapped[date] = mapped_column(Date) 

64 

65 # timezone-aware start and end times of the request, can be compared to now() 

66 start_time = column_property(date_in_timezone(from_date, timezone)) 

67 end_time = column_property(date_in_timezone(to_date, timezone) + text("interval '1 days'")) 

68 start_time_to_write_reference = column_property(date_in_timezone(to_date, timezone)) 

69 # notice 1 day for midnight at the *end of the day*, then 14 days to write a ref 

70 end_time_to_write_reference = column_property(date_in_timezone(to_date, timezone) + text("interval '15 days'")) 

71 

72 status: Mapped[HostRequestStatus] = mapped_column(Enum(HostRequestStatus)) 

73 is_recipient_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false()) 

74 is_initiator_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false()) 

75 

76 recipient_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0) 

77 initiator_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0) 

78 

79 # number of reference reminders sent out 

80 recipient_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False) 

81 initiator_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False) 

82 recipient_sent_request_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False) 

83 last_sent_request_reminder_time: Mapped[datetime] = mapped_column( 

84 DateTime(timezone=True), server_default=func.now(), init=False 

85 ) 

86 

87 # reason why the initiator/recipient marked that they didn't meet up 

88 # if null then they haven't marked it such 

89 recipient_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, default=None) 

90 initiator_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, default=None) 

91 

92 # Optional link to public trip if this request originated from one 

93 public_trip_id: Mapped[int | None] = mapped_column(ForeignKey("public_trips.id"), index=True, default=None) 

94 

95 initiator: Mapped[User] = relationship( 

96 init=False, backref="host_requests_initiated", foreign_keys="HostRequest.initiator_user_id" 

97 ) 

98 recipient: Mapped[User] = relationship( 

99 init=False, backref="host_requests_received", foreign_keys="HostRequest.recipient_user_id" 

100 ) 

101 conversation: Mapped[Conversation] = relationship(init=False) 

102 moderation_state: Mapped[ModerationState] = relationship(init=False) 

103 public_trip: Mapped[PublicTrip | None] = relationship(init=False, back_populates="host_requests") 

104 

105 __table_args__ = ( 

106 # allows fast lookup as to whether they didn't meet up 

107 Index( 

108 "ix_host_requests_recipient_didnt_meetup", 

109 recipient_reason_didnt_meetup != None, 

110 ), 

111 Index( 

112 "ix_host_requests_initiator_didnt_meetup", 

113 initiator_reason_didnt_meetup != None, 

114 ), 

115 # Used for figuring out who needs a reminder to respond 

116 Index( 

117 "ix_host_requests_status_reminder_counts", 

118 status, 

119 recipient_sent_request_reminders, 

120 last_sent_request_reminder_time, 

121 from_date, 

122 ), 

123 ) 

124 

125 @hybrid_property 

126 def can_write_reference(self) -> Any: 

127 return ( 

128 ((self.status == HostRequestStatus.confirmed) | (self.status == HostRequestStatus.accepted)) 

129 & (now() >= self.start_time_to_write_reference) 

130 & (now() <= self.end_time_to_write_reference) 

131 ) 

132 

133 @can_write_reference.expression 

134 def can_write_reference_expr(cls) -> Any: # noqa: ARG003,D102 

135 return ( 

136 ((cls.status == HostRequestStatus.confirmed) | (cls.status == HostRequestStatus.accepted)) 

137 & (func.now() >= cls.start_time_to_write_reference) 

138 & (func.now() <= cls.end_time_to_write_reference) 

139 ) 

140 

141 def __repr__(self) -> str: 

142 return f"HostRequest(id={self.conversation_id}, initiator_user_id={self.initiator_user_id}, recipient_user_id={self.recipient_user_id}...)" 

143 

144 

145class HostRequestFeedback(Base, kw_only=True): 

146 """ 

147 Private feedback from a host about a host request 

148 """ 

149 

150 __tablename__ = "host_request_feedbacks" 

151 

152 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

153 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

154 host_request_id: Mapped[int] = mapped_column(ForeignKey("host_requests.id")) 

155 

156 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

157 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

158 

159 request_quality: Mapped[HostRequestQuality | None] = mapped_column(Enum(HostRequestQuality), default=None) 

160 decline_reason: Mapped[str | None] = mapped_column(String, default=None) # plain text 

161 

162 host_request: Mapped[HostRequest] = relationship(init=False) 

163 

164 __table_args__ = ( 

165 # Each user can leave at most one friend reference to another user 

166 Index( 

167 "ix_unique_host_req_feedback", 

168 from_user_id, 

169 to_user_id, 

170 host_request_id, 

171 unique=True, 

172 ), 

173 )