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

81 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +0000

1import enum 

2from datetime import date, datetime 

3from typing import TYPE_CHECKING, Any 

4 

5from geoalchemy2 import Geometry 

6from sqlalchemy import ( 

7 BigInteger, 

8 Boolean, 

9 Computed, 

10 Date, 

11 DateTime, 

12 Enum, 

13 Float, 

14 ForeignKey, 

15 Index, 

16 String, 

17 func, 

18 text, 

19) 

20from sqlalchemy.ext.hybrid import hybrid_property 

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

22from sqlalchemy.sql import expression 

23 

24from couchers.models.base import Base, Geom 

25from couchers.models.moderation import ModerationObjectType 

26from couchers.utils import date_in_timezone, now 

27 

28if TYPE_CHECKING: 

29 from couchers.models.conversations import Conversation 

30 from couchers.models.moderation import ModerationState 

31 from couchers.models.public_trips import PublicTrip 

32 from couchers.models.users import User 

33 

34 

35class HostRequestStatus(enum.Enum): 

36 pending = enum.auto() 

37 accepted = enum.auto() 

38 rejected = enum.auto() 

39 confirmed = enum.auto() 

40 cancelled = enum.auto() 

41 

42 

43class HostRequestQuality(enum.Enum): 

44 high_quality = enum.auto() 

45 okay_quality = enum.auto() 

46 low_quality = enum.auto() 

47 

48 

49class HostRequest(Base, kw_only=True): 

50 """ 

51 A request to stay with a host. 

52 

53 There are two independent role axes here, and they only coincide for a normal host request: 

54 

55 * initiator/recipient is the conversation role: who proposed the stay and who responds to it. The whole 

56 status state machine, unread tracking and archiving key off this axis. 

57 * surfer/host is the stay role: whose couch it is. References, feedback, response rates and metrics key 

58 off this axis. 

59 

60 An offer on a public trip reverses them: the host initiates and the traveller responds. surfer_user_id 

61 and host_user_id are generated from that so the stay role can never drift out of sync. 

62 """ 

63 

64 __tablename__ = "host_requests" 

65 __moderation_author_column__ = "initiator_user_id" 

66 __moderation_object_type__ = ModerationObjectType.host_request 

67 __moderation_has_own_visibility_mechanism__ = False 

68 

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

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

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

72 

73 surfer_user_id: Mapped[int] = mapped_column( 

74 ForeignKey("users.id"), 

75 Computed("case when public_trip_id is null then initiator_user_id else recipient_user_id end", persisted=True), 

76 index=True, 

77 init=False, 

78 ) 

79 host_user_id: Mapped[int] = mapped_column( 

80 ForeignKey("users.id"), 

81 Computed("case when public_trip_id is null then recipient_user_id else initiator_user_id end", persisted=True), 

82 index=True, 

83 init=False, 

84 ) 

85 

86 # Unified Moderation System 

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

88 

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

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

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

92 

93 # TODO: proper timezone handling 

94 timezone = "Etc/UTC" 

95 

96 # dates in the timezone above 

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

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

99 

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

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

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

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

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

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

106 

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

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

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

110 

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

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

113 

114 # number of reference reminders sent out 

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

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

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

118 last_sent_request_reminder_time: Mapped[datetime] = mapped_column( 

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

120 ) 

121 

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

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

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

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

126 

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

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

129 

130 initiator: Mapped[User] = relationship( 

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

132 ) 

133 recipient: Mapped[User] = relationship( 

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

135 ) 

136 # viewonly since the underlying columns are generated and can't be written through 

137 surfer: Mapped[User] = relationship(init=False, foreign_keys="HostRequest.surfer_user_id", viewonly=True) 

138 host: Mapped[User] = relationship(init=False, foreign_keys="HostRequest.host_user_id", viewonly=True) 

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

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

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

142 

143 __table_args__ = ( 

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

145 Index( 

146 "ix_host_requests_recipient_didnt_meetup", 

147 recipient_reason_didnt_meetup != None, 

148 ), 

149 Index( 

150 "ix_host_requests_initiator_didnt_meetup", 

151 initiator_reason_didnt_meetup != None, 

152 ), 

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

154 Index( 

155 "ix_host_requests_status_reminder_counts", 

156 status, 

157 recipient_sent_request_reminders, 

158 last_sent_request_reminder_time, 

159 from_date, 

160 ), 

161 ) 

162 

163 @hybrid_property 

164 def can_write_reference(self) -> Any: 

165 return ( 

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

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

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

169 ) 

170 

171 @can_write_reference.expression 

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

173 return ( 

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

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

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

177 ) 

178 

179 def __repr__(self) -> str: 

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

181 

182 

183class HostRequestFeedback(Base, kw_only=True): 

184 """ 

185 Private feedback from a host about a host request 

186 """ 

187 

188 __tablename__ = "host_request_feedbacks" 

189 

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

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

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

193 

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

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

196 

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

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

199 

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

201 

202 __table_args__ = ( 

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

204 Index( 

205 "ix_unique_host_req_feedback", 

206 from_user_id, 

207 to_user_id, 

208 host_request_id, 

209 unique=True, 

210 ), 

211 )