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
« 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
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
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
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
39class HostRequestStatus(enum.Enum):
40 pending = enum.auto()
41 accepted = enum.auto()
42 rejected = enum.auto()
43 confirmed = enum.auto()
44 cancelled = enum.auto()
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()
54class HostRequestQuality(enum.Enum):
55 high_quality = enum.auto()
56 okay_quality = enum.auto()
57 low_quality = enum.auto()
60class HostRequest(Base, kw_only=True):
61 """
62 A request to stay with a host.
64 There are two independent role axes here, and they only coincide for a normal host request:
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.
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 """
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
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)
85 proposal_kind: Mapped[StayProposalKind] = mapped_column(Enum(StayProposalKind))
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 )
104 # Unified Moderation System
105 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
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)
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)
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 )
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'"))
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())
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)
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 )
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)
154 # set exactly when proposal_kind == invitation
155 public_trip_id: Mapped[int | None] = mapped_column(ForeignKey("public_trips.id"), index=True, default=None)
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")
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 )
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 )
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 )
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}...)"
222class HostRequestFeedback(Base, kw_only=True):
223 """
224 Private feedback from a host about a host request
225 """
227 __tablename__ = "host_request_feedbacks"
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"))
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)
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
239 host_request: Mapped[HostRequest] = relationship(init=False)
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 )