Coverage for app / backend / src / couchers / models / rest.py: 99%
254 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 (
7 ARRAY,
8 JSON,
9 BigInteger,
10 Boolean,
11 CheckConstraint,
12 Date,
13 DateTime,
14 Enum,
15 Float,
16 ForeignKey,
17 Index,
18 Integer,
19 String,
20 UniqueConstraint,
21 func,
22 text,
23)
24from sqlalchemy import LargeBinary as Binary
25from sqlalchemy.dialects.postgresql import INET
26from sqlalchemy.ext.hybrid import hybrid_property
27from sqlalchemy.orm import Mapped, mapped_column, relationship
28from sqlalchemy.sql import expression
30from couchers.constants import GUIDELINES_VERSION
31from couchers.models.base import Base, Geom
32from couchers.models.users import HostingStatus
33from couchers.utils import now
35if TYPE_CHECKING:
36 from couchers.models import HostRequest, User
37 from couchers.models.moderation import ModerationState
40class UserBadge(Base, kw_only=True):
41 """
42 A badge on a user's profile
43 """
45 __tablename__ = "user_badges"
46 __table_args__ = (UniqueConstraint("user_id", "badge_id"),)
48 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
50 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
51 # corresponds to "id" in badges.json
52 badge_id: Mapped[str] = mapped_column(String, index=True)
54 # take this with a grain of salt, someone may get then lose a badge for whatever reason
55 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
57 user: Mapped[User] = relationship(init=False, back_populates="badges")
60class FriendStatus(enum.Enum):
61 pending = enum.auto()
62 accepted = enum.auto()
63 rejected = enum.auto()
64 cancelled = enum.auto()
67class FriendRelationship(Base, kw_only=True):
68 """
69 Friendship relations between users
71 TODO: make this better with sqlalchemy self-referential stuff
72 TODO: constraint on only one row per user pair where accepted or pending
73 """
75 __tablename__ = "friend_relationships"
76 __moderation_author_column__ = "from_user_id"
78 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
80 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
81 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
83 # Unified Moderation System
84 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
86 status: Mapped[FriendStatus] = mapped_column(Enum(FriendStatus), default=FriendStatus.pending)
88 # timezones should always be UTC
89 time_sent: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
90 time_responded: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
92 from_user: Mapped[User] = relationship(
93 init=False, backref="friends_from", foreign_keys="FriendRelationship.from_user_id"
94 )
95 to_user: Mapped[User] = relationship(init=False, backref="friends_to", foreign_keys="FriendRelationship.to_user_id")
96 moderation_state: Mapped[ModerationState] = relationship(init=False)
98 __table_args__ = (
99 # Ping looks up pending friend reqs, this speeds that up
100 Index(
101 "ix_friend_relationships_status_to_from",
102 status,
103 to_user_id,
104 from_user_id,
105 ),
106 )
109class ContributeOption(enum.Enum):
110 yes = enum.auto()
111 maybe = enum.auto()
112 no = enum.auto()
115class ContributorForm(Base, kw_only=True):
116 """
117 Someone filled in the contributor form
118 """
120 __tablename__ = "contributor_forms"
122 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
124 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
125 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
127 ideas: Mapped[str | None] = mapped_column(String, default=None)
128 features: Mapped[str | None] = mapped_column(String, default=None)
129 experience: Mapped[str | None] = mapped_column(String, default=None)
130 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None)
131 contribute_ways: Mapped[list[str]] = mapped_column(ARRAY(String))
132 expertise: Mapped[str | None] = mapped_column(String, default=None)
134 user: Mapped[User] = relationship(init=False, backref="contributor_forms")
136 @hybrid_property
137 def is_filled(self) -> Any:
138 """
139 Whether the form counts as having been filled
140 """
141 return (
142 (self.ideas != None)
143 | (self.features != None)
144 | (self.experience != None)
145 | (self.contribute != None)
146 | (self.contribute_ways != [])
147 | (self.expertise != None)
148 )
150 @property
151 def should_notify(self) -> bool:
152 """
153 If this evaluates to true, we send an email to the recruitment team.
155 We currently send if expertise is listed, or if they list a way to help outside of a set list
156 """
157 return False
160class SignupFlow(Base, kw_only=True):
161 """
162 Signup flows/incomplete users
164 Coinciding fields have the same meaning as in User
165 """
167 __tablename__ = "signup_flows"
169 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
171 # housekeeping
172 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
173 flow_token: Mapped[str] = mapped_column(String, unique=True)
174 email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
175 email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
176 email_token: Mapped[str | None] = mapped_column(String, default=None)
177 email_token_expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
179 ## Basic
180 name: Mapped[str] = mapped_column(String)
181 # TODO: unique across both tables
182 email: Mapped[str] = mapped_column(String, unique=True)
183 # TODO: invitation, attribution
185 ## Account
186 # TODO: unique across both tables
187 username: Mapped[str | None] = mapped_column(String, unique=True, default=None)
188 hashed_password: Mapped[bytes | None] = mapped_column(Binary, default=None)
189 birthdate: Mapped[date | None] = mapped_column(Date, default=None) # in the timezone of birthplace
190 gender: Mapped[str | None] = mapped_column(String, default=None)
191 hosting_status: Mapped[HostingStatus | None] = mapped_column(Enum(HostingStatus), default=None)
192 city: Mapped[str | None] = mapped_column(String, default=None)
193 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
194 geom_radius: Mapped[float | None] = mapped_column(Float, default=None)
196 accepted_tos: Mapped[int | None] = mapped_column(Integer, default=None)
197 accepted_community_guidelines: Mapped[int] = mapped_column(Integer, server_default="0", init=False)
199 opt_out_of_newsletter: Mapped[bool | None] = mapped_column(Boolean, default=None)
201 ## Feedback (now unused)
202 filled_feedback: Mapped[bool] = mapped_column(Boolean, default=False)
203 ideas: Mapped[str | None] = mapped_column(String, default=None)
204 features: Mapped[str | None] = mapped_column(String, default=None)
205 experience: Mapped[str | None] = mapped_column(String, default=None)
206 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None)
207 contribute_ways: Mapped[list[str] | None] = mapped_column(ARRAY(String), default=None)
208 expertise: Mapped[str | None] = mapped_column(String, default=None)
210 ## Motivations (how they heard about us and what they want to do)
211 filled_motivations: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), default=False)
212 heard_about_couchers: Mapped[str | None] = mapped_column(String, default=None)
213 signup_motivations: Mapped[list[str]] = mapped_column(ARRAY(String), server_default="{}", default_factory=list)
215 invite_code_id: Mapped[str | None] = mapped_column(ForeignKey("invite_codes.id"), default=None)
217 @hybrid_property
218 def token_is_valid(self) -> Any:
219 return (self.email_token != None) & (self.email_token_expiry >= now()) # type: ignore[operator]
221 @hybrid_property
222 def account_is_filled(self) -> Any:
223 return (
224 (self.username != None)
225 & (self.birthdate != None)
226 & (self.gender != None)
227 & (self.hosting_status != None)
228 & (self.city != None)
229 & (self.geom != None)
230 & (self.geom_radius != None)
231 & (self.accepted_tos != None)
232 & (self.opt_out_of_newsletter != None)
233 )
235 @hybrid_property
236 def is_completed(self) -> Any:
237 return (
238 self.email_verified
239 & self.account_is_filled
240 & (self.accepted_community_guidelines == GUIDELINES_VERSION)
241 & self.filled_motivations
242 )
245class AccountDeletionToken(Base, kw_only=True):
246 __tablename__ = "account_deletion_tokens"
248 token: Mapped[str] = mapped_column(String, primary_key=True)
250 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
252 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
253 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True))
255 user: Mapped[User] = relationship(init=False, backref="account_deletion_tokens")
257 @hybrid_property
258 def is_valid(self) -> Any:
259 return (self.created <= now()) & (self.expiry >= now())
261 def __repr__(self) -> str:
262 return f"AccountDeletionToken(token={self.token}, user_id={self.user_id}, created={self.created}, expiry={self.expiry})"
265class UserActivity(Base, kw_only=True):
266 """
267 User activity: for each unique (user_id, period, ip_address, user_agent) tuple, keep track of number of api calls
269 Used for user "last active" as well as admin stuff
270 """
272 __tablename__ = "user_activity"
274 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
276 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
277 # the start of a period of time, e.g. 1 hour during which we bin activeness
278 period: Mapped[datetime] = mapped_column(DateTime(timezone=True))
280 # details of the browser, if available
281 ip_address: Mapped[str | None] = mapped_column(INET, default=None)
282 user_agent: Mapped[str | None] = mapped_column(String, default=None)
284 # count of api calls made with this ip, user_agent, and period
285 api_calls: Mapped[int] = mapped_column(Integer, default=0)
287 __table_args__ = (
288 # helps look up this tuple quickly
289 Index(
290 "ix_user_activity_user_id_period_ip_address_user_agent",
291 user_id,
292 period,
293 ip_address,
294 user_agent,
295 unique=True,
296 ),
297 )
300class InviteCode(Base, kw_only=True):
301 __tablename__ = "invite_codes"
303 id: Mapped[str] = mapped_column(String, primary_key=True)
304 creator_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
305 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), init=False)
306 disabled: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
308 creator: Mapped[User] = relationship(init=False, foreign_keys=[creator_user_id])
311class ContentReport(Base, kw_only=True):
312 """
313 A piece of content reported to admins
314 """
316 __tablename__ = "content_reports"
318 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
320 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
322 # the user who reported or flagged the content
323 reporting_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
325 # reason, e.g. spam, inappropriate, etc
326 reason: Mapped[str] = mapped_column(String)
327 # a short description
328 description: Mapped[str] = mapped_column(String)
330 # a reference to the content, see //docs/content_ref.md
331 content_ref: Mapped[str] = mapped_column(String)
332 # the author of the content (e.g. the user who wrote the comment itself)
333 author_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
335 # details of the browser, if available
336 user_agent: Mapped[str] = mapped_column(String)
337 # the URL the user was on when reporting the content
338 page: Mapped[str] = mapped_column(String)
340 # see comments above for reporting vs author
341 reporting_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.reporting_user_id")
342 author_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.author_user_id")
345class Email(Base, kw_only=True):
346 """
347 Table of all dispatched emails for debugging purposes, etc.
348 """
350 __tablename__ = "emails"
352 id: Mapped[str] = mapped_column(String, primary_key=True)
354 # timezone should always be UTC
355 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
357 sender_name: Mapped[str] = mapped_column(String)
358 sender_email: Mapped[str] = mapped_column(String)
360 recipient: Mapped[str] = mapped_column(String)
361 subject: Mapped[str] = mapped_column(String)
363 plain: Mapped[str] = mapped_column(String)
364 html: Mapped[str] = mapped_column(String)
366 list_unsubscribe_header: Mapped[str | None] = mapped_column(String, default=None)
367 source_data: Mapped[str | None] = mapped_column(String, default=None)
370class SMS(Base, kw_only=True):
371 """
372 Table of all sent SMSs for debugging purposes, etc.
373 """
375 __tablename__ = "smss"
377 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
379 # timezone should always be UTC
380 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
381 # AWS message id
382 message_id: Mapped[str] = mapped_column(String)
384 # the SMS sender ID sent to AWS, name that the SMS appears to come from
385 sms_sender_id: Mapped[str] = mapped_column(String)
386 number: Mapped[str] = mapped_column(String)
387 message: Mapped[str] = mapped_column(String)
390class ReferenceType(enum.Enum):
391 friend = enum.auto()
392 surfed = enum.auto() # The "from" user surfed with the "to" user
393 hosted = enum.auto() # The "from" user hosted the "to" user
396class Reference(Base, kw_only=True):
397 """
398 Reference from one user to another
399 """
401 __tablename__ = "references"
403 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
404 # timezone should always be UTC
405 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
407 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
408 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
410 reference_type: Mapped[ReferenceType] = mapped_column(Enum(ReferenceType))
412 host_request_id: Mapped[int | None] = mapped_column(ForeignKey("host_requests.id"), default=None)
414 text: Mapped[str] = mapped_column(String) # plain text
415 # text that's only visible to mods
416 private_text: Mapped[str | None] = mapped_column(String, default=None) # plain text
418 rating: Mapped[float] = mapped_column(Float)
419 was_appropriate: Mapped[bool] = mapped_column(Boolean)
421 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
423 from_user: Mapped[User] = relationship(init=False, backref="references_from", foreign_keys="Reference.from_user_id")
424 to_user: Mapped[User] = relationship(init=False, backref="references_to", foreign_keys="Reference.to_user_id")
426 host_request: Mapped[HostRequest | None] = relationship(init=False, backref="references")
428 __table_args__ = (
429 # Rating must be between 0 and 1, inclusive
430 CheckConstraint(
431 "rating BETWEEN 0 AND 1",
432 name="rating_between_0_and_1",
433 ),
434 # Has host_request_id or it's a friend reference
435 CheckConstraint(
436 "(host_request_id IS NOT NULL) <> (reference_type = 'friend')",
437 name="host_request_id_xor_friend_reference",
438 ),
439 # Each user can leave at most one friend reference to another user
440 Index(
441 "ix_references_unique_friend_reference",
442 from_user_id,
443 to_user_id,
444 reference_type,
445 unique=True,
446 postgresql_where=(reference_type == ReferenceType.friend),
447 ),
448 # Each user can leave at most one reference to another user for each stay
449 Index(
450 "ix_references_unique_per_host_request",
451 from_user_id,
452 to_user_id,
453 host_request_id,
454 unique=True,
455 postgresql_where=(host_request_id != None),
456 ),
457 )
459 @property
460 def should_report(self) -> bool:
461 """
462 If this evaluates to true, we send a report to the moderation team.
463 """
464 return bool(self.rating <= 0.4 or not self.was_appropriate or self.private_text)
467class UserBlock(Base, kw_only=True):
468 """
469 Table of blocked users
470 """
472 __tablename__ = "user_blocks"
474 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
476 blocking_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
477 blocked_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
478 time_blocked: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
480 blocking_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocking_user_id")
481 blocked_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocked_user_id")
483 __table_args__ = (
484 UniqueConstraint("blocking_user_id", "blocked_user_id"),
485 Index("ix_user_blocks_blocking_user_id", blocking_user_id, blocked_user_id),
486 Index("ix_user_blocks_blocked_user_id", blocked_user_id, blocking_user_id),
487 )
490class AccountDeletionReason(Base, kw_only=True):
491 __tablename__ = "account_deletion_reason"
493 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
494 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
495 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
496 reason: Mapped[str | None] = mapped_column(String, default=None)
498 user: Mapped[User] = relationship(init=False)
501class ModerationUserList(Base, kw_only=True):
502 """
503 Represents a list of users listed together by a moderator
504 """
506 __tablename__ = "moderation_user_lists"
508 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
509 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
511 users: Mapped[list[User]] = relationship(
512 init=False, secondary="moderation_user_list_members", back_populates="moderation_user_lists"
513 )
516class ModerationUserListMember(Base, kw_only=True):
517 """
518 Association table for many-to-many relationship between users and moderation_user_lists
519 """
521 __tablename__ = "moderation_user_list_members"
523 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
524 moderation_list_id: Mapped[int] = mapped_column(ForeignKey("moderation_user_lists.id"), primary_key=True)
527class AntiBotLog(Base, kw_only=True):
528 __tablename__ = "antibot_logs"
530 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
531 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
532 user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
534 ip_address: Mapped[str | None] = mapped_column(String, default=None)
535 user_agent: Mapped[str | None] = mapped_column(String, default=None)
537 action: Mapped[str] = mapped_column(String)
538 token: Mapped[str] = mapped_column(String)
540 score: Mapped[float] = mapped_column(Float)
541 provider_data: Mapped[dict[str, Any]] = mapped_column(JSON)
544class RateLimitAction(enum.Enum):
545 """Possible user actions which can be rate limited."""
547 host_request = "host request"
548 friend_request = "friend request"
549 chat_initiation = "chat initiation"
552class RateLimitViolation(Base, kw_only=True):
553 __tablename__ = "rate_limit_violations"
555 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
556 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
557 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
558 action: Mapped[RateLimitAction] = mapped_column(Enum(RateLimitAction))
559 is_hard_limit: Mapped[bool] = mapped_column(Boolean)
561 user: Mapped[User] = relationship(init=False)
563 __table_args__ = (
564 # Fast lookup for rate limits in interval
565 Index("ix_rate_limits_by_user", user_id, action, is_hard_limit, created),
566 )
569class Volunteer(Base, kw_only=True):
570 __tablename__ = "volunteers"
572 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
573 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
575 display_name: Mapped[str | None] = mapped_column(String, default=None)
576 display_location: Mapped[str | None] = mapped_column(String, default=None)
578 role: Mapped[str] = mapped_column(String)
580 # custom sort order on team page, sorted ascending
581 sort_key: Mapped[float | None] = mapped_column(Float, default=None)
583 started_volunteering: Mapped[date] = mapped_column(Date, server_default=text("CURRENT_DATE"), init=False)
584 stopped_volunteering: Mapped[date | None] = mapped_column(Date, default=None)
586 link_type: Mapped[str | None] = mapped_column(String, default=None)
587 link_text: Mapped[str | None] = mapped_column(String, default=None)
588 link_url: Mapped[str | None] = mapped_column(String, default=None)
590 show_on_team_page: Mapped[bool] = mapped_column(Boolean, server_default=expression.true())
592 __table_args__ = (
593 # Link type, text, url should all be null or all not be null
594 CheckConstraint(
595 "(link_type IS NULL) = (link_text IS NULL) AND (link_type IS NULL) = (link_url IS NULL)",
596 name="link_type_text",
597 ),
598 )