Coverage for app / backend / src / couchers / models / host_requests.py: 97%
74 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
1import enum
2from datetime import date, datetime
3from typing import TYPE_CHECKING, Any
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
11from couchers.models.base import Base, Geom
12from couchers.utils import date_in_timezone, now
14if TYPE_CHECKING:
15 from couchers.models.conversations import Conversation
16 from couchers.models.moderation import ModerationState
17 from couchers.models.public_trips import PublicTrip
18 from couchers.models.users import User
21class HostRequestStatus(enum.Enum):
22 pending = enum.auto()
23 accepted = enum.auto()
24 rejected = enum.auto()
25 confirmed = enum.auto()
26 cancelled = enum.auto()
29class HostRequestQuality(enum.Enum):
30 high_quality = enum.auto()
31 okay_quality = enum.auto()
32 low_quality = enum.auto()
35class HostRequest(Base, kw_only=True):
36 """
37 A request to stay with a host.
39 In a normal host request, initiator = surfer and recipient = host.
40 """
42 __tablename__ = "host_requests"
43 __moderation_author_column__ = "initiator_user_id"
45 conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True)
46 initiator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
47 recipient_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
49 # Unified Moderation System
50 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
52 hosting_city: Mapped[str] = mapped_column(String)
53 hosting_location: Mapped[Geom] = mapped_column(Geometry("POINT", srid=4326))
54 hosting_radius: Mapped[float] = mapped_column(Float)
56 # TODO: proper timezone handling
57 timezone = "Etc/UTC"
59 # dates in the timezone above
60 from_date: Mapped[date] = mapped_column(Date)
61 to_date: Mapped[date] = mapped_column(Date)
63 # timezone-aware start and end times of the request, can be compared to now()
64 start_time = column_property(date_in_timezone(from_date, timezone))
65 end_time = column_property(date_in_timezone(to_date, timezone) + text("interval '1 days'"))
66 start_time_to_write_reference = column_property(date_in_timezone(to_date, timezone))
67 # notice 1 day for midnight at the *end of the day*, then 14 days to write a ref
68 end_time_to_write_reference = column_property(date_in_timezone(to_date, timezone) + text("interval '15 days'"))
70 status: Mapped[HostRequestStatus] = mapped_column(Enum(HostRequestStatus))
71 is_recipient_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
72 is_initiator_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
74 recipient_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0)
75 initiator_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0)
77 # number of reference reminders sent out
78 recipient_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
79 initiator_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
80 recipient_sent_request_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
81 last_sent_request_reminder_time: Mapped[datetime] = mapped_column(
82 DateTime(timezone=True), server_default=func.now(), init=False
83 )
85 # reason why the initiator/recipient marked that they didn't meet up
86 # if null then they haven't marked it such
87 recipient_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, default=None)
88 initiator_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, default=None)
90 # Optional link to public trip if this request originated from one
91 public_trip_id: Mapped[int | None] = mapped_column(ForeignKey("public_trips.id"), index=True, default=None)
93 initiator: Mapped[User] = relationship(
94 init=False, backref="host_requests_initiated", foreign_keys="HostRequest.initiator_user_id"
95 )
96 recipient: Mapped[User] = relationship(
97 init=False, backref="host_requests_received", foreign_keys="HostRequest.recipient_user_id"
98 )
99 conversation: Mapped[Conversation] = relationship(init=False)
100 moderation_state: Mapped[ModerationState] = relationship(init=False)
101 public_trip: Mapped[PublicTrip | None] = relationship(init=False, back_populates="host_requests")
103 __table_args__ = (
104 # allows fast lookup as to whether they didn't meet up
105 Index(
106 "ix_host_requests_recipient_didnt_meetup",
107 recipient_reason_didnt_meetup != None,
108 ),
109 Index(
110 "ix_host_requests_initiator_didnt_meetup",
111 initiator_reason_didnt_meetup != None,
112 ),
113 # Used for figuring out who needs a reminder to respond
114 Index(
115 "ix_host_requests_status_reminder_counts",
116 status,
117 recipient_sent_request_reminders,
118 last_sent_request_reminder_time,
119 from_date,
120 ),
121 )
123 @hybrid_property
124 def can_write_reference(self) -> Any:
125 return (
126 ((self.status == HostRequestStatus.confirmed) | (self.status == HostRequestStatus.accepted))
127 & (now() >= self.start_time_to_write_reference)
128 & (now() <= self.end_time_to_write_reference)
129 )
131 @can_write_reference.expression
132 def can_write_reference_expr(cls) -> Any: # noqa: ARG003,D102
133 return (
134 ((cls.status == HostRequestStatus.confirmed) | (cls.status == HostRequestStatus.accepted))
135 & (func.now() >= cls.start_time_to_write_reference)
136 & (func.now() <= cls.end_time_to_write_reference)
137 )
139 def __repr__(self) -> str:
140 return f"HostRequest(id={self.conversation_id}, initiator_user_id={self.initiator_user_id}, recipient_user_id={self.recipient_user_id}...)"
143class HostRequestFeedback(Base, kw_only=True):
144 """
145 Private feedback from a host about a host request
146 """
148 __tablename__ = "host_request_feedbacks"
150 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
151 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
152 host_request_id: Mapped[int] = mapped_column(ForeignKey("host_requests.id"))
154 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
155 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
157 request_quality: Mapped[HostRequestQuality | None] = mapped_column(Enum(HostRequestQuality), default=None)
158 decline_reason: Mapped[str | None] = mapped_column(String, default=None) # plain text
160 host_request: Mapped[HostRequest] = relationship(init=False)
162 __table_args__ = (
163 # Each user can leave at most one friend reference to another user
164 Index(
165 "ix_unique_host_req_feedback",
166 from_user_id,
167 to_user_id,
168 host_request_id,
169 unique=True,
170 ),
171 )