Coverage for src/couchers/models/rest.py: 99%
247 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
1import enum
3from geoalchemy2 import Geometry
4from sqlalchemy import (
5 ARRAY,
6 JSON,
7 BigInteger,
8 Boolean,
9 CheckConstraint,
10 Column,
11 Date,
12 DateTime,
13 Enum,
14 Float,
15 ForeignKey,
16 Index,
17 Integer,
18 String,
19 UniqueConstraint,
20 func,
21 text,
22)
23from sqlalchemy import LargeBinary as Binary
24from sqlalchemy.dialects.postgresql import INET
25from sqlalchemy.ext.hybrid import hybrid_property
26from sqlalchemy.orm import relationship
27from sqlalchemy.sql import expression
29from couchers.constants import GUIDELINES_VERSION
30from couchers.models.base import Base
31from couchers.models.users import HostingStatus
32from couchers.utils import now
35class UserBadge(Base):
36 """
37 A badge on a user's profile
38 """
40 __tablename__ = "user_badges"
41 __table_args__ = (UniqueConstraint("user_id", "badge_id"),)
43 id = Column(BigInteger, primary_key=True)
45 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
46 # corresponds to "id" in badges.json
47 badge_id = Column(String, nullable=False, index=True)
49 # take this with a grain of salt, someone may get then lose a badge for whatever reason
50 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
52 user = relationship("User", backref="badges")
55class FriendStatus(enum.Enum):
56 pending = enum.auto()
57 accepted = enum.auto()
58 rejected = enum.auto()
59 cancelled = enum.auto()
62class FriendRelationship(Base):
63 """
64 Friendship relations between users
66 TODO: make this better with sqlalchemy self-referential stuff
67 TODO: constraint on only one row per user pair where accepted or pending
68 """
70 __tablename__ = "friend_relationships"
72 id = Column(BigInteger, primary_key=True)
74 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
75 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
77 status = Column(Enum(FriendStatus), nullable=False, default=FriendStatus.pending)
79 # timezones should always be UTC
80 time_sent = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
81 time_responded = Column(DateTime(timezone=True), nullable=True)
83 from_user = relationship("User", backref="friends_from", foreign_keys="FriendRelationship.from_user_id")
84 to_user = relationship("User", backref="friends_to", foreign_keys="FriendRelationship.to_user_id")
86 __table_args__ = (
87 # Ping looks up pending friend reqs, this speeds that up
88 Index(
89 "ix_friend_relationships_status_to_from",
90 status,
91 to_user_id,
92 from_user_id,
93 ),
94 )
97class ContributeOption(enum.Enum):
98 yes = enum.auto()
99 maybe = enum.auto()
100 no = enum.auto()
103class ContributorForm(Base):
104 """
105 Someone filled in the contributor form
106 """
108 __tablename__ = "contributor_forms"
110 id = Column(BigInteger, primary_key=True)
112 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
113 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
115 ideas = Column(String, nullable=True)
116 features = Column(String, nullable=True)
117 experience = Column(String, nullable=True)
118 contribute = Column(Enum(ContributeOption), nullable=True)
119 contribute_ways = Column(ARRAY(String), nullable=False)
120 expertise = Column(String, nullable=True)
122 user = relationship("User", backref="contributor_forms")
124 @hybrid_property
125 def is_filled(self):
126 """
127 Whether the form counts as having been filled
128 """
129 return (
130 (self.ideas != None)
131 | (self.features != None)
132 | (self.experience != None)
133 | (self.contribute != None)
134 | (self.contribute_ways != [])
135 | (self.expertise != None)
136 )
138 @property
139 def should_notify(self):
140 """
141 If this evaluates to true, we send an email to the recruitment team.
143 We currently send if expertise is listed, or if they list a way to help outside of a set list
144 """
145 return False
148class SignupFlow(Base):
149 """
150 Signup flows/incomplete users
152 Coinciding fields have the same meaning as in User
153 """
155 __tablename__ = "signup_flows"
157 id = Column(BigInteger, primary_key=True)
159 # housekeeping
160 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
161 flow_token = Column(String, nullable=False, unique=True)
162 email_verified = Column(Boolean, nullable=False, default=False)
163 email_sent = Column(Boolean, nullable=False, default=False)
164 email_token = Column(String, nullable=True)
165 email_token_expiry = Column(DateTime(timezone=True), nullable=True)
167 ## Basic
168 name = Column(String, nullable=False)
169 # TODO: unique across both tables
170 email = Column(String, nullable=False, unique=True)
171 # TODO: invitation, attribution
173 ## Account
174 # TODO: unique across both tables
175 username = Column(String, nullable=True, unique=True)
176 hashed_password = Column(Binary, nullable=True)
177 birthdate = Column(Date, nullable=True) # in the timezone of birthplace
178 gender = Column(String, nullable=True)
179 hosting_status = Column(Enum(HostingStatus), nullable=True)
180 city = Column(String, nullable=True)
181 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
182 geom_radius = Column(Float, nullable=True)
184 accepted_tos = Column(Integer, nullable=True)
185 accepted_community_guidelines = Column(Integer, nullable=False, server_default="0")
187 opt_out_of_newsletter = Column(Boolean, nullable=True)
189 ## Feedback (now unused)
190 filled_feedback = Column(Boolean, nullable=False, default=False)
191 ideas = Column(String, nullable=True)
192 features = Column(String, nullable=True)
193 experience = Column(String, nullable=True)
194 contribute = Column(Enum(ContributeOption), nullable=True)
195 contribute_ways = Column(ARRAY(String), nullable=True)
196 expertise = Column(String, nullable=True)
198 invite_code_id = Column(ForeignKey("invite_codes.id"), nullable=True)
200 @hybrid_property
201 def token_is_valid(self):
202 return (self.email_token != None) & (self.email_token_expiry >= now())
204 @hybrid_property
205 def account_is_filled(self):
206 return (
207 (self.username != None)
208 & (self.birthdate != None)
209 & (self.gender != None)
210 & (self.hosting_status != None)
211 & (self.city != None)
212 & (self.geom != None)
213 & (self.geom_radius != None)
214 & (self.accepted_tos != None)
215 & (self.opt_out_of_newsletter != None)
216 )
218 @hybrid_property
219 def is_completed(self):
220 return self.email_verified & self.account_is_filled & (self.accepted_community_guidelines == GUIDELINES_VERSION)
223class AccountDeletionToken(Base):
224 __tablename__ = "account_deletion_tokens"
226 token = Column(String, primary_key=True)
228 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
230 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
231 expiry = Column(DateTime(timezone=True), nullable=False)
233 user = relationship("User", backref="account_deletion_tokens")
235 @hybrid_property
236 def is_valid(self):
237 return (self.created <= now()) & (self.expiry >= now())
239 def __repr__(self):
240 return f"AccountDeletionToken(token={self.token}, user_id={self.user_id}, created={self.created}, expiry={self.expiry})"
243class UserActivity(Base):
244 """
245 User activity: for each unique (user_id, period, ip_address, user_agent) tuple, keep track of number of api calls
247 Used for user "last active" as well as admin stuff
248 """
250 __tablename__ = "user_activity"
252 id = Column(BigInteger, primary_key=True)
254 user_id = Column(ForeignKey("users.id"), nullable=False)
255 # the start of a period of time, e.g. 1 hour during which we bin activeness
256 period = Column(DateTime(timezone=True), nullable=False)
258 # details of the browser, if available
259 ip_address = Column(INET, nullable=True)
260 user_agent = Column(String, nullable=True)
262 # count of api calls made with this ip, user_agent, and period
263 api_calls = Column(Integer, nullable=False, default=0)
265 __table_args__ = (
266 # helps look up this tuple quickly
267 Index(
268 "ix_user_activity_user_id_period_ip_address_user_agent",
269 user_id,
270 period,
271 ip_address,
272 user_agent,
273 unique=True,
274 ),
275 )
278class InviteCode(Base):
279 __tablename__ = "invite_codes"
281 id = Column(String, primary_key=True)
282 creator_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
283 created = Column(DateTime(timezone=True), nullable=False, default=func.now())
284 disabled = Column(DateTime(timezone=True), nullable=True)
286 creator = relationship("User", foreign_keys=[creator_user_id])
289class ContentReport(Base):
290 """
291 A piece of content reported to admins
292 """
294 __tablename__ = "content_reports"
296 id = Column(BigInteger, primary_key=True)
298 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
300 # the user who reported or flagged the content
301 reporting_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
303 # reason, e.g. spam, inappropriate, etc
304 reason = Column(String, nullable=False)
305 # a short description
306 description = Column(String, nullable=False)
308 # a reference to the content, see //docs/content_ref.md
309 content_ref = Column(String, nullable=False)
310 # the author of the content (e.g. the user who wrote the comment itself)
311 author_user_id = Column(ForeignKey("users.id"), nullable=False)
313 # details of the browser, if available
314 user_agent = Column(String, nullable=False)
315 # the URL the user was on when reporting the content
316 page = Column(String, nullable=False)
318 # see comments above for reporting vs author
319 reporting_user = relationship("User", foreign_keys="ContentReport.reporting_user_id")
320 author_user = relationship("User", foreign_keys="ContentReport.author_user_id")
323class Email(Base):
324 """
325 Table of all dispatched emails for debugging purposes, etc.
326 """
328 __tablename__ = "emails"
330 id = Column(String, primary_key=True)
332 # timezone should always be UTC
333 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
335 sender_name = Column(String, nullable=False)
336 sender_email = Column(String, nullable=False)
338 recipient = Column(String, nullable=False)
339 subject = Column(String, nullable=False)
341 plain = Column(String, nullable=False)
342 html = Column(String, nullable=False)
344 list_unsubscribe_header = Column(String, nullable=True)
345 source_data = Column(String, nullable=True)
348class SMS(Base):
349 """
350 Table of all sent SMSs for debugging purposes, etc.
351 """
353 __tablename__ = "smss"
355 id = Column(BigInteger, primary_key=True)
357 # timezone should always be UTC
358 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
359 # AWS message id
360 message_id = Column(String, nullable=False)
362 # the SMS sender ID sent to AWS, name that the SMS appears to come from
363 sms_sender_id = Column(String, nullable=False)
364 number = Column(String, nullable=False)
365 message = Column(String, nullable=False)
368class ReferenceType(enum.Enum):
369 friend = enum.auto()
370 surfed = enum.auto() # The "from" user surfed with the "to" user
371 hosted = enum.auto() # The "from" user hosted the "to" user
374class Reference(Base):
375 """
376 Reference from one user to another
377 """
379 __tablename__ = "references"
381 id = Column(BigInteger, primary_key=True)
382 # timezone should always be UTC
383 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
385 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
386 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
388 reference_type = Column(Enum(ReferenceType), nullable=False)
390 host_request_id = Column(ForeignKey("host_requests.id"), nullable=True)
392 text = Column(String, nullable=False) # plain text
393 # text that's only visible to mods
394 private_text = Column(String, nullable=True) # plain text
396 rating = Column(Float, nullable=False)
397 was_appropriate = Column(Boolean, nullable=False)
399 is_deleted = Column(Boolean, nullable=False, default=False, server_default=expression.false())
401 from_user = relationship("User", backref="references_from", foreign_keys="Reference.from_user_id")
402 to_user = relationship("User", backref="references_to", foreign_keys="Reference.to_user_id")
404 host_request = relationship("HostRequest", backref="references")
406 __table_args__ = (
407 # Rating must be between 0 and 1, inclusive
408 CheckConstraint(
409 "rating BETWEEN 0 AND 1",
410 name="rating_between_0_and_1",
411 ),
412 # Has host_request_id or it's a friend reference
413 CheckConstraint(
414 "(host_request_id IS NOT NULL) <> (reference_type = 'friend')",
415 name="host_request_id_xor_friend_reference",
416 ),
417 # Each user can leave at most one friend reference to another user
418 Index(
419 "ix_references_unique_friend_reference",
420 from_user_id,
421 to_user_id,
422 reference_type,
423 unique=True,
424 postgresql_where=(reference_type == ReferenceType.friend),
425 ),
426 # Each user can leave at most one reference to another user for each stay
427 Index(
428 "ix_references_unique_per_host_request",
429 from_user_id,
430 to_user_id,
431 host_request_id,
432 unique=True,
433 postgresql_where=(host_request_id != None),
434 ),
435 )
437 @property
438 def should_report(self):
439 """
440 If this evaluates to true, we send a report to the moderation team.
441 """
442 return self.rating <= 0.4 or not self.was_appropriate or self.private_text
445class UserBlock(Base):
446 """
447 Table of blocked users
448 """
450 __tablename__ = "user_blocks"
452 id = Column(BigInteger, primary_key=True)
454 blocking_user_id = Column(ForeignKey("users.id"), nullable=False)
455 blocked_user_id = Column(ForeignKey("users.id"), nullable=False)
456 time_blocked = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
458 blocking_user = relationship("User", foreign_keys="UserBlock.blocking_user_id")
459 blocked_user = relationship("User", foreign_keys="UserBlock.blocked_user_id")
461 __table_args__ = (
462 UniqueConstraint("blocking_user_id", "blocked_user_id"),
463 Index("ix_user_blocks_blocking_user_id", blocking_user_id, blocked_user_id),
464 Index("ix_user_blocks_blocked_user_id", blocked_user_id, blocking_user_id),
465 )
468class AccountDeletionReason(Base):
469 __tablename__ = "account_deletion_reason"
471 id = Column(BigInteger, primary_key=True)
472 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
473 user_id = Column(ForeignKey("users.id"), nullable=False)
474 reason = Column(String, nullable=True)
476 user = relationship("User")
479class ModerationUserList(Base):
480 """
481 Represents a list of users listed together by a moderator
482 """
484 __tablename__ = "moderation_user_lists"
486 id = Column(BigInteger, primary_key=True)
487 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
489 # Relationships
490 users = relationship("User", secondary="moderation_user_list_members", back_populates="moderation_user_lists")
493class ModerationUserListMember(Base):
494 """
495 Association table for many-to-many relationship between users and moderation_user_lists
496 """
498 __tablename__ = "moderation_user_list_members"
500 user_id = Column(ForeignKey("users.id"), primary_key=True)
501 moderation_list_id = Column(ForeignKey("moderation_user_lists.id"), primary_key=True)
503 __table_args__ = (UniqueConstraint("user_id", "moderation_list_id"),)
506class AntiBotLog(Base):
507 __tablename__ = "antibot_logs"
509 id = Column(BigInteger, primary_key=True)
510 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
511 user_id = Column(ForeignKey("users.id"), nullable=True)
513 ip_address = Column(String, nullable=True)
514 user_agent = Column(String, nullable=True)
516 action = Column(String, nullable=False)
517 token = Column(String, nullable=False)
519 score = Column(Float, nullable=False)
520 provider_data = Column(JSON, nullable=False)
523class RateLimitAction(enum.Enum):
524 """Possible user actions which can be rate limited."""
526 host_request = "host request"
527 friend_request = "friend request"
528 chat_initiation = "chat initiation"
531class RateLimitViolation(Base):
532 __tablename__ = "rate_limit_violations"
534 id = Column(BigInteger, primary_key=True)
535 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
536 user_id = Column(ForeignKey("users.id"), nullable=False)
537 action = Column(Enum(RateLimitAction), nullable=False)
538 is_hard_limit = Column(Boolean, nullable=False)
540 user = relationship("User")
542 __table_args__ = (
543 # Fast lookup for rate limits in interval
544 Index("ix_rate_limits_by_user", user_id, action, is_hard_limit, created),
545 )
548class Volunteer(Base):
549 __tablename__ = "volunteers"
551 id = Column(BigInteger, primary_key=True)
552 user_id = Column(ForeignKey("users.id"), nullable=False, unique=True)
554 display_name = Column(String, nullable=True)
555 display_location = Column(String, nullable=True)
557 role = Column(String, nullable=False)
559 # custom sort order on team page, sorted ascending
560 sort_key = Column(Float, nullable=True)
562 started_volunteering = Column(Date, nullable=False, server_default=text("CURRENT_DATE"))
563 stopped_volunteering = Column(Date, nullable=True, default=None)
565 link_type = Column(String, nullable=True)
566 link_text = Column(String, nullable=True)
567 link_url = Column(String, nullable=True)
569 show_on_team_page = Column(Boolean, nullable=False, server_default=expression.true())
571 __table_args__ = (
572 # Link type, text, url should all be null or all not be null
573 CheckConstraint(
574 "(link_type IS NULL) = (link_text IS NULL) AND (link_type IS NULL) = (link_url IS NULL)",
575 name="link_type_text",
576 ),
577 )