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