Coverage for app / backend / src / couchers / models / host_requests.py: 97%
74 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +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
38 """
40 __tablename__ = "host_requests"
41 __moderation_author_column__ = "surfer_user_id"
43 conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True)
44 surfer_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
45 host_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
47 # Unified Moderation System
48 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
50 hosting_city: Mapped[str] = mapped_column(String)
51 hosting_location: Mapped[Geom] = mapped_column(Geometry("POINT", srid=4326))
52 hosting_radius: Mapped[float] = mapped_column(Float)
54 # TODO: proper timezone handling
55 timezone = "Etc/UTC"
57 # dates in the timezone above
58 from_date: Mapped[date] = mapped_column(Date)
59 to_date: Mapped[date] = mapped_column(Date)
61 # timezone-aware start and end times of the request, can be compared to now()
62 start_time = column_property(date_in_timezone(from_date, timezone))
63 end_time = column_property(date_in_timezone(to_date, timezone) + text("interval '1 days'"))
64 start_time_to_write_reference = column_property(date_in_timezone(to_date, timezone))
65 # notice 1 day for midnight at the *end of the day*, then 14 days to write a ref
66 end_time_to_write_reference = column_property(date_in_timezone(to_date, timezone) + text("interval '15 days'"))
68 status: Mapped[HostRequestStatus] = mapped_column(Enum(HostRequestStatus))
69 is_host_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
70 is_surfer_archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
72 host_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0)
73 surfer_last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0)
75 # number of reference reminders sent out
76 host_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
77 surfer_sent_reference_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
78 host_sent_request_reminders: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
79 last_sent_request_reminder_time: Mapped[datetime] = mapped_column(
80 DateTime(timezone=True), server_default=func.now(), init=False
81 )
83 # reason why the host/surfer marked that they didn't meet up
84 # if null then they haven't marked it such
85 host_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, default=None)
86 surfer_reason_didnt_meetup: Mapped[str | None] = mapped_column(String, default=None)
88 # Optional link to public trip if this request originated from one
89 public_trip_id: Mapped[int | None] = mapped_column(ForeignKey("public_trips.id"), index=True, default=None)
91 surfer: Mapped[User] = relationship(
92 init=False, backref="host_requests_sent", foreign_keys="HostRequest.surfer_user_id"
93 )
94 host: Mapped[User] = relationship(
95 init=False, backref="host_requests_received", foreign_keys="HostRequest.host_user_id"
96 )
97 conversation: Mapped[Conversation] = relationship(init=False)
98 moderation_state: Mapped[ModerationState] = relationship(init=False)
99 public_trip: Mapped[PublicTrip | None] = relationship(init=False, back_populates="host_requests")
101 __table_args__ = (
102 # allows fast lookup as to whether they didn't meet up
103 Index(
104 "ix_host_requests_host_didnt_meetup",
105 host_reason_didnt_meetup != None,
106 ),
107 Index(
108 "ix_host_requests_surfer_didnt_meetup",
109 surfer_reason_didnt_meetup != None,
110 ),
111 # Used for figuring out who needs a reminder to respond
112 Index(
113 "ix_host_requests_status_reminder_counts",
114 status,
115 host_sent_request_reminders,
116 last_sent_request_reminder_time,
117 from_date,
118 ),
119 )
121 @hybrid_property
122 def can_write_reference(self) -> Any:
123 return (
124 ((self.status == HostRequestStatus.confirmed) | (self.status == HostRequestStatus.accepted))
125 & (now() >= self.start_time_to_write_reference)
126 & (now() <= self.end_time_to_write_reference)
127 )
129 @can_write_reference.expression
130 def can_write_reference_expr(cls) -> Any: # noqa: ARG003,D102
131 return (
132 ((cls.status == HostRequestStatus.confirmed) | (cls.status == HostRequestStatus.accepted))
133 & (func.now() >= cls.start_time_to_write_reference)
134 & (func.now() <= cls.end_time_to_write_reference)
135 )
137 def __repr__(self) -> str:
138 return f"HostRequest(id={self.conversation_id}, surfer_user_id={self.surfer_user_id}, host_user_id={self.host_user_id}...)"
141class HostRequestFeedback(Base, kw_only=True):
142 """
143 Private feedback from a host about a host request
144 """
146 __tablename__ = "host_request_feedbacks"
148 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
149 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
150 host_request_id: Mapped[int] = mapped_column(ForeignKey("host_requests.id"))
152 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
153 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
155 request_quality: Mapped[HostRequestQuality | None] = mapped_column(Enum(HostRequestQuality), default=None)
156 decline_reason: Mapped[str | None] = mapped_column(String, default=None) # plain text
158 host_request: Mapped[HostRequest] = relationship(init=False)
160 __table_args__ = (
161 # Each user can leave at most one friend reference to another user
162 Index(
163 "ix_unique_host_req_feedback",
164 from_user_id,
165 to_user_id,
166 host_request_id,
167 unique=True,
168 ),
169 )