Coverage for app / backend / src / couchers / models / rest.py: 99%
254 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 (
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 """
74 __tablename__ = "friend_relationships"
75 __moderation_author_column__ = "from_user_id"
77 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
79 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
80 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
82 # Unified Moderation System
83 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
85 status: Mapped[FriendStatus] = mapped_column(Enum(FriendStatus), default=FriendStatus.pending)
87 # timezones should always be UTC
88 time_sent: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
89 time_responded: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
91 from_user: Mapped[User] = relationship(
92 init=False, backref="friends_from", foreign_keys="FriendRelationship.from_user_id"
93 )
94 to_user: Mapped[User] = relationship(init=False, backref="friends_to", foreign_keys="FriendRelationship.to_user_id")
95 moderation_state: Mapped[ModerationState] = relationship(init=False)
97 __table_args__ = (
98 # Ping looks up pending friend reqs, this speeds that up
99 Index(
100 "ix_friend_relationships_status_to_from",
101 status,
102 to_user_id,
103 from_user_id,
104 ),
105 # At most one active (pending or accepted) relationship per unordered user pair
106 Index(
107 "uq_friend_relationships_active_pair",
108 func.least(from_user_id, to_user_id),
109 func.greatest(from_user_id, to_user_id),
110 unique=True,
111 postgresql_where=status.in_([FriendStatus.pending, FriendStatus.accepted]),
112 ),
113 )
116class ContributeOption(enum.Enum):
117 yes = enum.auto()
118 maybe = enum.auto()
119 no = enum.auto()
122class ContributorForm(Base, kw_only=True):
123 """
124 Someone filled in the contributor form
125 """
127 __tablename__ = "contributor_forms"
129 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
131 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
132 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
134 ideas: Mapped[str | None] = mapped_column(String, default=None)
135 features: Mapped[str | None] = mapped_column(String, default=None)
136 experience: Mapped[str | None] = mapped_column(String, default=None)
137 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None)
138 contribute_ways: Mapped[list[str]] = mapped_column(ARRAY(String))
139 expertise: Mapped[str | None] = mapped_column(String, default=None)
141 user: Mapped[User] = relationship(init=False, backref="contributor_forms")
143 @hybrid_property
144 def is_filled(self) -> Any:
145 """
146 Whether the form counts as having been filled
147 """
148 return (
149 (self.ideas != None)
150 | (self.features != None)
151 | (self.experience != None)
152 | (self.contribute != None)
153 | (self.contribute_ways != [])
154 | (self.expertise != None)
155 )
157 @property
158 def should_notify(self) -> bool:
159 """
160 If this evaluates to true, we send an email to the recruitment team.
162 We currently send if expertise is listed, or if they list a way to help outside of a set list
163 """
164 return False
167class SignupFlow(Base, kw_only=True):
168 """
169 Signup flows/incomplete users
171 Coinciding fields have the same meaning as in User
172 """
174 __tablename__ = "signup_flows"
176 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
178 # housekeeping
179 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
180 flow_token: Mapped[str] = mapped_column(String, unique=True)
181 email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
182 email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
183 email_token: Mapped[str | None] = mapped_column(String, default=None)
184 email_token_expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
186 ## Basic
187 name: Mapped[str] = mapped_column(String)
188 # TODO: unique across both tables
189 email: Mapped[str] = mapped_column(String, unique=True)
190 # TODO: invitation, attribution
192 ## Account
193 # TODO: unique across both tables
194 username: Mapped[str | None] = mapped_column(String, unique=True, default=None)
195 hashed_password: Mapped[bytes | None] = mapped_column(Binary, default=None)
196 birthdate: Mapped[date | None] = mapped_column(Date, default=None) # in the timezone of birthplace
197 gender: Mapped[str | None] = mapped_column(String, default=None)
198 hosting_status: Mapped[HostingStatus | None] = mapped_column(Enum(HostingStatus), default=None)
199 city: Mapped[str | None] = mapped_column(String, default=None)
200 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
201 geom_radius: Mapped[float | None] = mapped_column(Float, default=None)
203 accepted_tos: Mapped[int | None] = mapped_column(Integer, default=None)
204 accepted_community_guidelines: Mapped[int] = mapped_column(Integer, server_default="0", init=False)
206 opt_out_of_newsletter: Mapped[bool | None] = mapped_column(Boolean, default=None)
208 ## Feedback (now unused)
209 filled_feedback: Mapped[bool] = mapped_column(Boolean, default=False)
210 ideas: Mapped[str | None] = mapped_column(String, default=None)
211 features: Mapped[str | None] = mapped_column(String, default=None)
212 experience: Mapped[str | None] = mapped_column(String, default=None)
213 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None)
214 contribute_ways: Mapped[list[str] | None] = mapped_column(ARRAY(String), default=None)
215 expertise: Mapped[str | None] = mapped_column(String, default=None)
217 ## Motivations (how they heard about us and what they want to do)
218 filled_motivations: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), default=False)
219 heard_about_couchers: Mapped[str | None] = mapped_column(String, default=None)
220 signup_motivations: Mapped[list[str]] = mapped_column(ARRAY(String), server_default="{}", default_factory=list)
222 invite_code_id: Mapped[str | None] = mapped_column(ForeignKey("invite_codes.id"), default=None)
224 @hybrid_property
225 def token_is_valid(self) -> Any:
226 return (self.email_token != None) & (self.email_token_expiry >= now()) # type: ignore[operator]
228 @hybrid_property
229 def account_is_filled(self) -> Any:
230 return (
231 (self.username != None)
232 & (self.birthdate != None)
233 & (self.gender != None)
234 & (self.hosting_status != None)
235 & (self.city != None)
236 & (self.geom != None)
237 & (self.geom_radius != None)
238 & (self.accepted_tos != None)
239 & (self.opt_out_of_newsletter != None)
240 )
242 @hybrid_property
243 def is_completed(self) -> Any:
244 return (
245 self.email_verified
246 & self.account_is_filled
247 & (self.accepted_community_guidelines == GUIDELINES_VERSION)
248 & self.filled_motivations
249 )
252class AccountDeletionToken(Base, kw_only=True):
253 __tablename__ = "account_deletion_tokens"
255 token: Mapped[str] = mapped_column(String, primary_key=True)
257 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
259 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
260 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True))
262 user: Mapped[User] = relationship(init=False, backref="account_deletion_tokens")
264 @hybrid_property
265 def is_valid(self) -> Any:
266 return (self.created <= now()) & (self.expiry >= now())
268 def __repr__(self) -> str:
269 return f"AccountDeletionToken(token={self.token}, user_id={self.user_id}, created={self.created}, expiry={self.expiry})"
272class UserActivity(Base, kw_only=True):
273 """
274 User activity: for each unique (user_id, period, ip_address, user_agent) tuple, keep track of number of api calls
276 Used for user "last active" as well as admin stuff
277 """
279 __tablename__ = "user_activity"
281 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
283 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
284 # the start of a period of time, e.g. 1 hour during which we bin activeness
285 period: Mapped[datetime] = mapped_column(DateTime(timezone=True))
287 # details of the browser, if available
288 ip_address: Mapped[str | None] = mapped_column(INET, default=None)
289 user_agent: Mapped[str | None] = mapped_column(String, default=None)
291 # count of api calls made with this ip, user_agent, and period
292 api_calls: Mapped[int] = mapped_column(Integer, default=0)
294 __table_args__ = (
295 # helps look up this tuple quickly
296 Index(
297 "ix_user_activity_user_id_period_ip_address_user_agent",
298 user_id,
299 period,
300 ip_address,
301 user_agent,
302 unique=True,
303 ),
304 )
307class InviteCode(Base, kw_only=True):
308 __tablename__ = "invite_codes"
310 id: Mapped[str] = mapped_column(String, primary_key=True)
311 creator_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
312 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), init=False)
313 disabled: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
315 creator: Mapped[User] = relationship(init=False, foreign_keys=[creator_user_id])
318class ContentReport(Base, kw_only=True):
319 """
320 A piece of content reported to admins
321 """
323 __tablename__ = "content_reports"
325 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
327 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
329 # the user who reported or flagged the content
330 reporting_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
332 # reason, e.g. spam, inappropriate, etc
333 reason: Mapped[str] = mapped_column(String)
334 # a short description
335 description: Mapped[str] = mapped_column(String)
337 # a reference to the content, see //docs/content_ref.md
338 content_ref: Mapped[str] = mapped_column(String)
339 # the author of the content (e.g. the user who wrote the comment itself)
340 author_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
342 # details of the browser, if available
343 user_agent: Mapped[str] = mapped_column(String)
344 # the URL the user was on when reporting the content
345 page: Mapped[str] = mapped_column(String)
347 # see comments above for reporting vs author
348 reporting_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.reporting_user_id")
349 author_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.author_user_id")
352class Email(Base, kw_only=True):
353 """
354 Table of all dispatched emails for debugging purposes, etc.
355 """
357 __tablename__ = "emails"
359 id: Mapped[str] = mapped_column(String, primary_key=True)
361 # timezone should always be UTC
362 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
364 sender_name: Mapped[str] = mapped_column(String)
365 sender_email: Mapped[str] = mapped_column(String)
367 recipient: Mapped[str] = mapped_column(String)
368 subject: Mapped[str] = mapped_column(String)
370 plain: Mapped[str] = mapped_column(String)
371 html: Mapped[str] = mapped_column(String)
373 list_unsubscribe_header: Mapped[str | None] = mapped_column(String, default=None)
374 source_data: Mapped[str | None] = mapped_column(String, default=None)
377class SMS(Base, kw_only=True):
378 """
379 Table of all sent SMSs for debugging purposes, etc.
380 """
382 __tablename__ = "smss"
384 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
386 # timezone should always be UTC
387 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
388 # AWS message id
389 message_id: Mapped[str] = mapped_column(String)
391 # the SMS sender ID sent to AWS, name that the SMS appears to come from
392 sms_sender_id: Mapped[str] = mapped_column(String)
393 number: Mapped[str] = mapped_column(String)
394 message: Mapped[str] = mapped_column(String)
397class ReferenceType(enum.Enum):
398 friend = enum.auto()
399 surfed = enum.auto() # The "from" user surfed with the "to" user
400 hosted = enum.auto() # The "from" user hosted the "to" user
403class Reference(Base, kw_only=True):
404 """
405 Reference from one user to another
406 """
408 __tablename__ = "references"
410 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
411 # timezone should always be UTC
412 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
414 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
415 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
417 reference_type: Mapped[ReferenceType] = mapped_column(Enum(ReferenceType))
419 host_request_id: Mapped[int | None] = mapped_column(ForeignKey("host_requests.id"), default=None)
421 text: Mapped[str] = mapped_column(String) # plain text
422 # text that's only visible to mods
423 private_text: Mapped[str | None] = mapped_column(String, default=None) # plain text
425 rating: Mapped[float] = mapped_column(Float)
426 was_appropriate: Mapped[bool] = mapped_column(Boolean)
428 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
430 from_user: Mapped[User] = relationship(init=False, backref="references_from", foreign_keys="Reference.from_user_id")
431 to_user: Mapped[User] = relationship(init=False, backref="references_to", foreign_keys="Reference.to_user_id")
433 host_request: Mapped[HostRequest | None] = relationship(init=False, backref="references")
435 __table_args__ = (
436 # Rating must be between 0 and 1, inclusive
437 CheckConstraint(
438 "rating BETWEEN 0 AND 1",
439 name="rating_between_0_and_1",
440 ),
441 # Has host_request_id or it's a friend reference
442 CheckConstraint(
443 "(host_request_id IS NOT NULL) <> (reference_type = 'friend')",
444 name="host_request_id_xor_friend_reference",
445 ),
446 # Each user can leave at most one friend reference to another user
447 Index(
448 "ix_references_unique_friend_reference",
449 from_user_id,
450 to_user_id,
451 reference_type,
452 unique=True,
453 postgresql_where=(reference_type == ReferenceType.friend),
454 ),
455 # Each user can leave at most one reference to another user for each stay
456 Index(
457 "ix_references_unique_per_host_request",
458 from_user_id,
459 to_user_id,
460 host_request_id,
461 unique=True,
462 postgresql_where=(host_request_id != None),
463 ),
464 )
466 @property
467 def should_report(self) -> bool:
468 """
469 If this evaluates to true, we send a report to the moderation team.
470 """
471 return bool(self.rating <= 0.4 or not self.was_appropriate or self.private_text)
474class UserBlock(Base, kw_only=True):
475 """
476 Table of blocked users
477 """
479 __tablename__ = "user_blocks"
481 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
483 blocking_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
484 blocked_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
485 time_blocked: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
487 blocking_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocking_user_id")
488 blocked_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocked_user_id")
490 __table_args__ = (
491 UniqueConstraint("blocking_user_id", "blocked_user_id"),
492 Index("ix_user_blocks_blocking_user_id", blocking_user_id, blocked_user_id),
493 Index("ix_user_blocks_blocked_user_id", blocked_user_id, blocking_user_id),
494 )
497class AccountDeletionReason(Base, kw_only=True):
498 __tablename__ = "account_deletion_reason"
500 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
501 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
502 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
503 reason: Mapped[str | None] = mapped_column(String, default=None)
505 user: Mapped[User] = relationship(init=False)
508class ModerationUserList(Base, kw_only=True):
509 """
510 Represents a list of users listed together by a moderator
511 """
513 __tablename__ = "moderation_user_lists"
515 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
516 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
518 users: Mapped[list[User]] = relationship(
519 init=False, secondary="moderation_user_list_members", back_populates="moderation_user_lists"
520 )
523class ModerationUserListMember(Base, kw_only=True):
524 """
525 Association table for many-to-many relationship between users and moderation_user_lists
526 """
528 __tablename__ = "moderation_user_list_members"
530 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
531 moderation_list_id: Mapped[int] = mapped_column(ForeignKey("moderation_user_lists.id"), primary_key=True)
534class AntiBotLog(Base, kw_only=True):
535 __tablename__ = "antibot_logs"
537 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
538 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
539 user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
541 ip_address: Mapped[str | None] = mapped_column(String, default=None)
542 user_agent: Mapped[str | None] = mapped_column(String, default=None)
544 action: Mapped[str] = mapped_column(String)
545 token: Mapped[str] = mapped_column(String)
547 score: Mapped[float] = mapped_column(Float)
548 provider_data: Mapped[dict[str, Any]] = mapped_column(JSON)
551class RateLimitAction(enum.Enum):
552 """Possible user actions which can be rate limited."""
554 host_request = "host request"
555 friend_request = "friend request"
556 chat_initiation = "chat initiation"
559class RateLimitViolation(Base, kw_only=True):
560 __tablename__ = "rate_limit_violations"
562 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
563 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
564 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
565 action: Mapped[RateLimitAction] = mapped_column(Enum(RateLimitAction))
566 is_hard_limit: Mapped[bool] = mapped_column(Boolean)
568 user: Mapped[User] = relationship(init=False)
570 __table_args__ = (
571 # Fast lookup for rate limits in interval
572 Index("ix_rate_limits_by_user", user_id, action, is_hard_limit, created),
573 )
576class Volunteer(Base, kw_only=True):
577 __tablename__ = "volunteers"
579 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
580 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
582 display_name: Mapped[str | None] = mapped_column(String, default=None)
583 display_location: Mapped[str | None] = mapped_column(String, default=None)
585 role: Mapped[str] = mapped_column(String)
587 # custom sort order on team page, sorted ascending
588 sort_key: Mapped[float | None] = mapped_column(Float, default=None)
590 started_volunteering: Mapped[date] = mapped_column(Date, server_default=text("CURRENT_DATE"), init=False)
591 stopped_volunteering: Mapped[date | None] = mapped_column(Date, default=None)
593 link_type: Mapped[str | None] = mapped_column(String, default=None)
594 link_text: Mapped[str | None] = mapped_column(String, default=None)
595 link_url: Mapped[str | None] = mapped_column(String, default=None)
597 show_on_team_page: Mapped[bool] = mapped_column(Boolean, server_default=expression.true())
599 __table_args__ = (
600 # Link type, text, url should all be null or all not be null
601 CheckConstraint(
602 "(link_type IS NULL) = (link_text IS NULL) AND (link_type IS NULL) = (link_url IS NULL)",
603 name="link_type_text",
604 ),
605 )