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
« 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
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
24from couchers.models.base import Base, Geom
25from couchers.models.moderation import ModerationObjectType
26from couchers.utils import date_in_timezone, now
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
35class HostRequestStatus(enum.Enum):
36 pending = enum.auto()
37 accepted = enum.auto()
38 rejected = enum.auto()
39 confirmed = enum.auto()
40 cancelled = enum.auto()
43class HostRequestQuality(enum.Enum):
44 high_quality = enum.auto()
45 okay_quality = enum.auto()
46 low_quality = enum.auto()
49class HostRequest(Base, kw_only=True):
50 """
51 A request to stay with a host.
53 There are two independent role axes here, and they only coincide for a normal host request:
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.
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 """
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
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)
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 )
86 # Unified Moderation System
87 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
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)
93 # TODO: proper timezone handling
94 timezone = "Etc/UTC"
96 # dates in the timezone above
97 from_date: Mapped[date] = mapped_column(Date)
98 to_date: Mapped[date] = mapped_column(Date)
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'"))
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())
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)
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 )
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)
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)
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")
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 )
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 )
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 )
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}...)"
183class HostRequestFeedback(Base, kw_only=True):
184 """
185 Private feedback from a host about a host request
186 """
188 __tablename__ = "host_request_feedbacks"
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"))
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)
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
200 host_request: Mapped[HostRequest] = relationship(init=False)
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 )