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

86 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +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 CheckConstraint, 

10 Computed, 

11 Date, 

12 DateTime, 

13 Enum, 

14 Float, 

15 ForeignKey, 

16 Index, 

17 String, 

18 and_, 

19 func, 

20 select, 

21 text, 

22) 

23from sqlalchemy.ext.hybrid import hybrid_property 

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

25from sqlalchemy.sql import expression 

26 

27from couchers.models.base import Base, Geom 

28from couchers.models.moderation import ModerationObjectType 

29from couchers.models.static import TimezoneArea 

30from couchers.utils import date_in_timezone, now 

31 

32if TYPE_CHECKING: 

33 from couchers.models.conversations import Conversation 

34 from couchers.models.moderation import ModerationState 

35 from couchers.models.public_trips import PublicTrip 

36 from couchers.models.users import User 

37 

38 

39class HostRequestStatus(enum.Enum): 

40 pending = enum.auto() 

41 accepted = enum.auto() 

42 rejected = enum.auto() 

43 confirmed = enum.auto() 

44 cancelled = enum.auto() 

45 

46 

47class StayProposalKind(enum.Enum): 

48 # a surfer asking a host to stay 

49 request = enum.auto() 

50 # a host inviting the traveller of a public trip to stay 

51 invitation = enum.auto() 

52 

53 

54class HostRequestQuality(enum.Enum): 

55 high_quality = enum.auto() 

56 okay_quality = enum.auto() 

57 low_quality = enum.auto() 

58 

59 

60class HostRequest(Base, kw_only=True): 

61 """ 

62 A request to stay with a host. 

63 

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

65 

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

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

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

69 off this axis. 

70 

71 An invitation on a public trip (proposal_kind == invitation) reverses them: the host initiates and the traveller 

72 responds. surfer_user_id and host_user_id are generated from proposal_kind so the stay role can never drift out of 

73 sync. 

74 """ 

75 

76 __tablename__ = "host_requests" 

77 __moderation_author_column__ = "initiator_user_id" 

78 __moderation_object_type__ = ModerationObjectType.host_request 

79 __moderation_has_own_visibility_mechanism__ = False 

80 

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

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

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

84 

85 proposal_kind: Mapped[StayProposalKind] = mapped_column(Enum(StayProposalKind)) 

86 

87 surfer_user_id: Mapped[int] = mapped_column( 

88 ForeignKey("users.id"), 

89 Computed( 

90 "case when proposal_kind = 'request' then initiator_user_id else recipient_user_id end", persisted=True 

91 ), 

92 index=True, 

93 init=False, 

94 ) 

95 host_user_id: Mapped[int] = mapped_column( 

96 ForeignKey("users.id"), 

97 Computed( 

98 "case when proposal_kind = 'request' then recipient_user_id else initiator_user_id end", persisted=True 

99 ), 

100 index=True, 

101 init=False, 

102 ) 

103 

104 # Unified Moderation System 

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

106 

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

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

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

110 

111 # from/to dates, local to the stay's timezone 

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

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

114 

115 # Timezone at the location of the stay. 

116 # Not deferred because we always need it to interpret the dates. 

117 timezone: Mapped[str] = column_property( 

118 func.coalesce( 

119 select(TimezoneArea.tzid) 

120 .where(func.ST_Contains(TimezoneArea.geom, hosting_location)) 

121 .limit(1) 

122 .scalar_subquery(), 

123 "Etc/UTC", 

124 ) 

125 ) 

126 

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

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

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

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

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

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

133 

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

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

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

137 

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

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

140 

141 # number of reference reminders sent out 

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

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

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

145 last_sent_request_reminder_time: Mapped[datetime] = mapped_column( 

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

147 ) 

148 

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

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

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

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

153 

154 # set exactly when proposal_kind == invitation 

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

156 

157 initiator: Mapped[User] = relationship( 

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

159 ) 

160 recipient: Mapped[User] = relationship( 

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

162 ) 

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

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

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

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

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

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

169 

170 __table_args__ = ( 

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

172 Index( 

173 "ix_host_requests_recipient_didnt_meetup", 

174 recipient_reason_didnt_meetup != None, 

175 ), 

176 Index( 

177 "ix_host_requests_initiator_didnt_meetup", 

178 initiator_reason_didnt_meetup != None, 

179 ), 

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

181 Index( 

182 "ix_host_requests_status_reminder_counts", 

183 status, 

184 recipient_sent_request_reminders, 

185 last_sent_request_reminder_time, 

186 from_date, 

187 ), 

188 # A host can have at most one non-withdrawn offer per public trip 

189 Index( 

190 "ix_host_requests_one_active_offer_per_trip", 

191 public_trip_id, 

192 initiator_user_id, 

193 unique=True, 

194 postgresql_where=and_(proposal_kind == StayProposalKind.invitation, status != HostRequestStatus.cancelled), 

195 ), 

196 CheckConstraint( 

197 "(proposal_kind = 'invitation') = (public_trip_id IS NOT NULL)", 

198 name="public_trip_id_iff_invitation", 

199 ), 

200 ) 

201 

202 @hybrid_property 

203 def can_write_reference(self) -> Any: 

204 return ( 

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

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

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

208 ) 

209 

210 @can_write_reference.expression 

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

212 return ( 

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

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

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

216 ) 

217 

218 def __repr__(self) -> str: 

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

220 

221 

222class HostRequestFeedback(Base, kw_only=True): 

223 """ 

224 Private feedback from a host about a host request 

225 """ 

226 

227 __tablename__ = "host_request_feedbacks" 

228 

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

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

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

232 

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

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

235 

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

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

238 

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

240 

241 __table_args__ = ( 

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

243 Index( 

244 "ix_unique_host_req_feedback", 

245 from_user_id, 

246 to_user_id, 

247 host_request_id, 

248 unique=True, 

249 ), 

250 )