Coverage for app / backend / src / couchers / models / rest.py: 99%
248 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +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
39class UserBadge(Base, kw_only=True):
40 """
41 A badge on a user's profile
42 """
44 __tablename__ = "user_badges"
45 __table_args__ = (UniqueConstraint("user_id", "badge_id"),)
47 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
49 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
50 # corresponds to "id" in badges.json
51 badge_id: Mapped[str] = mapped_column(String, index=True)
53 # take this with a grain of salt, someone may get then lose a badge for whatever reason
54 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
56 user: Mapped[User] = relationship(init=False, back_populates="badges")
59class FriendStatus(enum.Enum):
60 pending = enum.auto()
61 accepted = enum.auto()
62 rejected = enum.auto()
63 cancelled = enum.auto()
66class FriendRelationship(Base, kw_only=True):
67 """
68 Friendship relations between users
70 TODO: make this better with sqlalchemy self-referential stuff
71 TODO: constraint on only one row per user pair where accepted or pending
72 """
74 __tablename__ = "friend_relationships"
76 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
78 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
79 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
81 status: Mapped[FriendStatus] = mapped_column(Enum(FriendStatus), default=FriendStatus.pending)
83 # timezones should always be UTC
84 time_sent: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
85 time_responded: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
87 from_user: Mapped[User] = relationship(
88 init=False, backref="friends_from", foreign_keys="FriendRelationship.from_user_id"
89 )
90 to_user: Mapped[User] = relationship(init=False, backref="friends_to", foreign_keys="FriendRelationship.to_user_id")
92 __table_args__ = (
93 # Ping looks up pending friend reqs, this speeds that up
94 Index(
95 "ix_friend_relationships_status_to_from",
96 status,
97 to_user_id,
98 from_user_id,
99 ),
100 )
103class ContributeOption(enum.Enum):
104 yes = enum.auto()
105 maybe = enum.auto()
106 no = enum.auto()
109class ContributorForm(Base, kw_only=True):
110 """
111 Someone filled in the contributor form
112 """
114 __tablename__ = "contributor_forms"
116 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
118 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
119 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
121 ideas: Mapped[str | None] = mapped_column(String, default=None)
122 features: Mapped[str | None] = mapped_column(String, default=None)
123 experience: Mapped[str | None] = mapped_column(String, default=None)
124 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None)
125 contribute_ways: Mapped[list[str]] = mapped_column(ARRAY(String))
126 expertise: Mapped[str | None] = mapped_column(String, default=None)
128 user: Mapped[User] = relationship(init=False, backref="contributor_forms")
130 @hybrid_property
131 def is_filled(self) -> Any:
132 """
133 Whether the form counts as having been filled
134 """
135 return (
136 (self.ideas != None)
137 | (self.features != None)
138 | (self.experience != None)
139 | (self.contribute != None)
140 | (self.contribute_ways != [])
141 | (self.expertise != None)
142 )
144 @property
145 def should_notify(self) -> bool:
146 """
147 If this evaluates to true, we send an email to the recruitment team.
149 We currently send if expertise is listed, or if they list a way to help outside of a set list
150 """
151 return False
154class SignupFlow(Base, kw_only=True):
155 """
156 Signup flows/incomplete users
158 Coinciding fields have the same meaning as in User
159 """
161 __tablename__ = "signup_flows"
163 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
165 # housekeeping
166 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
167 flow_token: Mapped[str] = mapped_column(String, unique=True)
168 email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
169 email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
170 email_token: Mapped[str | None] = mapped_column(String, default=None)
171 email_token_expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
173 ## Basic
174 name: Mapped[str] = mapped_column(String)
175 # TODO: unique across both tables
176 email: Mapped[str] = mapped_column(String, unique=True)
177 # TODO: invitation, attribution
179 ## Account
180 # TODO: unique across both tables
181 username: Mapped[str | None] = mapped_column(String, unique=True, default=None)
182 hashed_password: Mapped[bytes | None] = mapped_column(Binary, default=None)
183 birthdate: Mapped[date | None] = mapped_column(Date, default=None) # in the timezone of birthplace
184 gender: Mapped[str | None] = mapped_column(String, default=None)
185 hosting_status: Mapped[HostingStatus | None] = mapped_column(Enum(HostingStatus), default=None)
186 city: Mapped[str | None] = mapped_column(String, default=None)
187 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
188 geom_radius: Mapped[float | None] = mapped_column(Float, default=None)
190 accepted_tos: Mapped[int | None] = mapped_column(Integer, default=None)
191 accepted_community_guidelines: Mapped[int] = mapped_column(Integer, server_default="0", init=False)
193 opt_out_of_newsletter: Mapped[bool | None] = mapped_column(Boolean, default=None)
195 ## Feedback (now unused)
196 filled_feedback: Mapped[bool] = mapped_column(Boolean, default=False)
197 ideas: Mapped[str | None] = mapped_column(String, default=None)
198 features: Mapped[str | None] = mapped_column(String, default=None)
199 experience: Mapped[str | None] = mapped_column(String, default=None)
200 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None)
201 contribute_ways: Mapped[list[str] | None] = mapped_column(ARRAY(String), default=None)
202 expertise: Mapped[str | None] = mapped_column(String, default=None)
204 invite_code_id: Mapped[str | None] = mapped_column(ForeignKey("invite_codes.id"), default=None)
206 @hybrid_property
207 def token_is_valid(self) -> Any:
208 return (self.email_token != None) & (self.email_token_expiry >= now()) # type: ignore[operator]
210 @hybrid_property
211 def account_is_filled(self) -> Any:
212 return (
213 (self.username != None)
214 & (self.birthdate != None)
215 & (self.gender != None)
216 & (self.hosting_status != None)
217 & (self.city != None)
218 & (self.geom != None)
219 & (self.geom_radius != None)
220 & (self.accepted_tos != None)
221 & (self.opt_out_of_newsletter != None)
222 )
224 @hybrid_property
225 def is_completed(self) -> Any:
226 return self.email_verified & self.account_is_filled & (self.accepted_community_guidelines == GUIDELINES_VERSION)
229class AccountDeletionToken(Base, kw_only=True):
230 __tablename__ = "account_deletion_tokens"
232 token: Mapped[str] = mapped_column(String, primary_key=True)
234 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
236 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
237 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True))
239 user: Mapped[User] = relationship(init=False, backref="account_deletion_tokens")
241 @hybrid_property
242 def is_valid(self) -> Any:
243 return (self.created <= now()) & (self.expiry >= now())
245 def __repr__(self) -> str:
246 return f"AccountDeletionToken(token={self.token}, user_id={self.user_id}, created={self.created}, expiry={self.expiry})"
249class UserActivity(Base, kw_only=True):
250 """
251 User activity: for each unique (user_id, period, ip_address, user_agent) tuple, keep track of number of api calls
253 Used for user "last active" as well as admin stuff
254 """
256 __tablename__ = "user_activity"
258 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
260 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
261 # the start of a period of time, e.g. 1 hour during which we bin activeness
262 period: Mapped[datetime] = mapped_column(DateTime(timezone=True))
264 # details of the browser, if available
265 ip_address: Mapped[str | None] = mapped_column(INET, default=None)
266 user_agent: Mapped[str | None] = mapped_column(String, default=None)
268 # count of api calls made with this ip, user_agent, and period
269 api_calls: Mapped[int] = mapped_column(Integer, default=0)
271 __table_args__ = (
272 # helps look up this tuple quickly
273 Index(
274 "ix_user_activity_user_id_period_ip_address_user_agent",
275 user_id,
276 period,
277 ip_address,
278 user_agent,
279 unique=True,
280 ),
281 )
284class InviteCode(Base, kw_only=True):
285 __tablename__ = "invite_codes"
287 id: Mapped[str] = mapped_column(String, primary_key=True)
288 creator_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
289 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), init=False)
290 disabled: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
292 creator: Mapped[User] = relationship(init=False, foreign_keys=[creator_user_id])
295class ContentReport(Base, kw_only=True):
296 """
297 A piece of content reported to admins
298 """
300 __tablename__ = "content_reports"
302 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
304 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
306 # the user who reported or flagged the content
307 reporting_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
309 # reason, e.g. spam, inappropriate, etc
310 reason: Mapped[str] = mapped_column(String)
311 # a short description
312 description: Mapped[str] = mapped_column(String)
314 # a reference to the content, see //docs/content_ref.md
315 content_ref: Mapped[str] = mapped_column(String)
316 # the author of the content (e.g. the user who wrote the comment itself)
317 author_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
319 # details of the browser, if available
320 user_agent: Mapped[str] = mapped_column(String)
321 # the URL the user was on when reporting the content
322 page: Mapped[str] = mapped_column(String)
324 # see comments above for reporting vs author
325 reporting_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.reporting_user_id")
326 author_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.author_user_id")
329class Email(Base, kw_only=True):
330 """
331 Table of all dispatched emails for debugging purposes, etc.
332 """
334 __tablename__ = "emails"
336 id: Mapped[str] = mapped_column(String, primary_key=True)
338 # timezone should always be UTC
339 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
341 sender_name: Mapped[str] = mapped_column(String)
342 sender_email: Mapped[str] = mapped_column(String)
344 recipient: Mapped[str] = mapped_column(String)
345 subject: Mapped[str] = mapped_column(String)
347 plain: Mapped[str] = mapped_column(String)
348 html: Mapped[str] = mapped_column(String)
350 list_unsubscribe_header: Mapped[str | None] = mapped_column(String, default=None)
351 source_data: Mapped[str | None] = mapped_column(String, default=None)
354class SMS(Base, kw_only=True):
355 """
356 Table of all sent SMSs for debugging purposes, etc.
357 """
359 __tablename__ = "smss"
361 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
363 # timezone should always be UTC
364 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
365 # AWS message id
366 message_id: Mapped[str] = mapped_column(String)
368 # the SMS sender ID sent to AWS, name that the SMS appears to come from
369 sms_sender_id: Mapped[str] = mapped_column(String)
370 number: Mapped[str] = mapped_column(String)
371 message: Mapped[str] = mapped_column(String)
374class ReferenceType(enum.Enum):
375 friend = enum.auto()
376 surfed = enum.auto() # The "from" user surfed with the "to" user
377 hosted = enum.auto() # The "from" user hosted the "to" user
380class Reference(Base, kw_only=True):
381 """
382 Reference from one user to another
383 """
385 __tablename__ = "references"
387 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
388 # timezone should always be UTC
389 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
391 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
392 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
394 reference_type: Mapped[ReferenceType] = mapped_column(Enum(ReferenceType))
396 host_request_id: Mapped[int | None] = mapped_column(ForeignKey("host_requests.id"), default=None)
398 text: Mapped[str] = mapped_column(String) # plain text
399 # text that's only visible to mods
400 private_text: Mapped[str | None] = mapped_column(String, default=None) # plain text
402 rating: Mapped[float] = mapped_column(Float)
403 was_appropriate: Mapped[bool] = mapped_column(Boolean)
405 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
407 from_user: Mapped[User] = relationship(init=False, backref="references_from", foreign_keys="Reference.from_user_id")
408 to_user: Mapped[User] = relationship(init=False, backref="references_to", foreign_keys="Reference.to_user_id")
410 host_request: Mapped[HostRequest | None] = relationship(init=False, backref="references")
412 __table_args__ = (
413 # Rating must be between 0 and 1, inclusive
414 CheckConstraint(
415 "rating BETWEEN 0 AND 1",
416 name="rating_between_0_and_1",
417 ),
418 # Has host_request_id or it's a friend reference
419 CheckConstraint(
420 "(host_request_id IS NOT NULL) <> (reference_type = 'friend')",
421 name="host_request_id_xor_friend_reference",
422 ),
423 # Each user can leave at most one friend reference to another user
424 Index(
425 "ix_references_unique_friend_reference",
426 from_user_id,
427 to_user_id,
428 reference_type,
429 unique=True,
430 postgresql_where=(reference_type == ReferenceType.friend),
431 ),
432 # Each user can leave at most one reference to another user for each stay
433 Index(
434 "ix_references_unique_per_host_request",
435 from_user_id,
436 to_user_id,
437 host_request_id,
438 unique=True,
439 postgresql_where=(host_request_id != None),
440 ),
441 )
443 @property
444 def should_report(self) -> bool:
445 """
446 If this evaluates to true, we send a report to the moderation team.
447 """
448 return bool(self.rating <= 0.4 or not self.was_appropriate or self.private_text)
451class UserBlock(Base, kw_only=True):
452 """
453 Table of blocked users
454 """
456 __tablename__ = "user_blocks"
458 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
460 blocking_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
461 blocked_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
462 time_blocked: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
464 blocking_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocking_user_id")
465 blocked_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocked_user_id")
467 __table_args__ = (
468 UniqueConstraint("blocking_user_id", "blocked_user_id"),
469 Index("ix_user_blocks_blocking_user_id", blocking_user_id, blocked_user_id),
470 Index("ix_user_blocks_blocked_user_id", blocked_user_id, blocking_user_id),
471 )
474class AccountDeletionReason(Base, kw_only=True):
475 __tablename__ = "account_deletion_reason"
477 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
478 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
479 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
480 reason: Mapped[str | None] = mapped_column(String, default=None)
482 user: Mapped[User] = relationship(init=False)
485class ModerationUserList(Base, kw_only=True):
486 """
487 Represents a list of users listed together by a moderator
488 """
490 __tablename__ = "moderation_user_lists"
492 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
493 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
495 users: Mapped[list[User]] = relationship(
496 init=False, secondary="moderation_user_list_members", back_populates="moderation_user_lists"
497 )
500class ModerationUserListMember(Base, kw_only=True):
501 """
502 Association table for many-to-many relationship between users and moderation_user_lists
503 """
505 __tablename__ = "moderation_user_list_members"
507 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
508 moderation_list_id: Mapped[int] = mapped_column(ForeignKey("moderation_user_lists.id"), primary_key=True)
511class AntiBotLog(Base, kw_only=True):
512 __tablename__ = "antibot_logs"
514 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
515 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
516 user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
518 ip_address: Mapped[str | None] = mapped_column(String, default=None)
519 user_agent: Mapped[str | None] = mapped_column(String, default=None)
521 action: Mapped[str] = mapped_column(String)
522 token: Mapped[str] = mapped_column(String)
524 score: Mapped[float] = mapped_column(Float)
525 provider_data: Mapped[dict[str, Any]] = mapped_column(JSON)
528class RateLimitAction(enum.Enum):
529 """Possible user actions which can be rate limited."""
531 host_request = "host request"
532 friend_request = "friend request"
533 chat_initiation = "chat initiation"
536class RateLimitViolation(Base, kw_only=True):
537 __tablename__ = "rate_limit_violations"
539 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
540 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
541 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
542 action: Mapped[RateLimitAction] = mapped_column(Enum(RateLimitAction))
543 is_hard_limit: Mapped[bool] = mapped_column(Boolean)
545 user: Mapped[User] = relationship(init=False)
547 __table_args__ = (
548 # Fast lookup for rate limits in interval
549 Index("ix_rate_limits_by_user", user_id, action, is_hard_limit, created),
550 )
553class Volunteer(Base, kw_only=True):
554 __tablename__ = "volunteers"
556 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
557 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
559 display_name: Mapped[str | None] = mapped_column(String, default=None)
560 display_location: Mapped[str | None] = mapped_column(String, default=None)
562 role: Mapped[str] = mapped_column(String)
564 # custom sort order on team page, sorted ascending
565 sort_key: Mapped[float | None] = mapped_column(Float, default=None)
567 started_volunteering: Mapped[date] = mapped_column(Date, server_default=text("CURRENT_DATE"), init=False)
568 stopped_volunteering: Mapped[date | None] = mapped_column(Date, default=None)
570 link_type: Mapped[str | None] = mapped_column(String, default=None)
571 link_text: Mapped[str | None] = mapped_column(String, default=None)
572 link_url: Mapped[str | None] = mapped_column(String, default=None)
574 show_on_team_page: Mapped[bool] = mapped_column(Boolean, server_default=expression.true())
576 __table_args__ = (
577 # Link type, text, url should all be null or all not be null
578 CheckConstraint(
579 "(link_type IS NULL) = (link_text IS NULL) AND (link_type IS NULL) = (link_url IS NULL)",
580 name="link_type_text",
581 ),
582 )