Coverage for app/backend/src/couchers/models/users.py: 100%
302 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1import enum
2from datetime import date, datetime, timedelta
3from typing import TYPE_CHECKING, Any
5from geoalchemy2 import Geometry
6from sqlalchemy import (
7 ARRAY,
8 BigInteger,
9 Boolean,
10 CheckConstraint,
11 Computed,
12 Date,
13 DateTime,
14 Enum,
15 Float,
16 ForeignKey,
17 Index,
18 Integer,
19 Interval,
20 String,
21 UniqueConstraint,
22 and_,
23 func,
24 or_,
25 select,
26 text,
27)
28from sqlalchemy import LargeBinary as Binary
29from sqlalchemy.ext.hybrid import hybrid_property
30from sqlalchemy.orm import DynamicMapped, Mapped, column_property, mapped_column, relationship
31from sqlalchemy.sql import expression
32from sqlalchemy.sql.elements import ColumnElement
34from couchers.constants import (
35 COMPLETED_PROFILE_MINIMUM_CHAR_LENGTH,
36 EMAIL_REGEX,
37 GUIDELINES_VERSION,
38 PHONE_VERIFICATION_LIFETIME,
39 SMS_CODE_LIFETIME,
40 TOS_VERSION,
41)
42from couchers.models.activeness_probe import ActivenessProbe
43from couchers.models.base import Base, Geom
44from couchers.models.mod_note import ModNote
45from couchers.models.moderation import ModerationObjectType, ModerationState
46from couchers.models.static import Language, Region, TimezoneArea
47from couchers.utils import get_coordinates, last_active_coarsen, now
49if TYPE_CHECKING:
50 from couchers.models import UserBadge
51 from couchers.models.admin import UserAdminTag
52 from couchers.models.public_trips import PublicTrip
53 from couchers.models.rest import InviteCode, ModerationUserList
54 from couchers.models.uploads import PhotoGallery
57class HostingStatus(enum.Enum):
58 can_host = enum.auto()
59 maybe = enum.auto()
60 cant_host = enum.auto()
63class MeetupStatus(enum.Enum):
64 wants_to_meetup = enum.auto()
65 open_to_meetup = enum.auto()
66 does_not_want_to_meetup = enum.auto()
69class HostingMeetupStatusSource(enum.Enum):
70 # the statuses the account was created with
71 signup = enum.auto()
72 # the user edited their profile
73 profile_edit = enum.auto()
74 # the user enabled "do not email" in their notification settings
75 do_not_email = enum.auto()
76 # the user used the "do not email" quick link in an email
77 unsubscribe_link = enum.auto()
78 # the user responded to an activeness probe
79 activeness_probe_response = enum.auto()
80 # the user let an activeness probe expire, so we downgraded them
81 activeness_probe_expired = enum.auto()
84class SmokingLocation(enum.Enum):
85 yes = enum.auto()
86 window = enum.auto()
87 outside = enum.auto()
88 no = enum.auto()
91class SleepingArrangement(enum.Enum):
92 private = enum.auto()
93 common = enum.auto()
94 shared_room = enum.auto()
97class ParkingDetails(enum.Enum):
98 free_onsite = enum.auto()
99 free_offsite = enum.auto()
100 paid_onsite = enum.auto()
101 paid_offsite = enum.auto()
104class ProfilePublicVisibility(enum.Enum):
105 # no public info
106 nothing = enum.auto()
107 # only show on map, randomized, unclickable
108 map_only = enum.auto()
109 # name, gender, location, hosting/meetup status, badges, number of references, and signup time
110 limited = enum.auto()
111 # full about me except additional info (hide my home)
112 most = enum.auto()
113 # all but references
114 full = enum.auto()
117class User(Base, kw_only=True):
118 """
119 Basic user and profile details
120 """
122 __tablename__ = "users"
123 __moderation_author_column__ = "id"
124 __moderation_object_type__ = ModerationObjectType.user
125 __moderation_has_own_visibility_mechanism__ = True
127 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
129 username: Mapped[str] = mapped_column(String, unique=True)
130 email: Mapped[str] = mapped_column(String, index=True)
131 # stored in libsodium hash format, can be null for email login
132 hashed_password: Mapped[bytes] = mapped_column(Binary)
133 # phone number in E.164 format with leading +, for example "+46701740605"
134 phone: Mapped[str | None] = mapped_column(String, default=None, server_default=expression.null())
135 # language preference -- defaults to empty string
136 ui_language_preference: Mapped[str | None] = mapped_column(String, default=None, server_default="")
138 # timezones should always be UTC
139 ## location
140 # point describing their location. EPSG4326 is the SRS (spatial ref system, = way to describe a point on earth) used
141 # by GPS, it has the WGS84 geoid with lat/lon
142 geom: Mapped[Geom] = mapped_column(Geometry(geometry_type="POINT", srid=4326))
143 # randomized coordinates within a radius of 0.02-0.1 degrees, equates to about 2-10 km
144 randomized_geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
145 # their display location (displayed to other users), in meters
146 geom_radius: Mapped[float] = mapped_column(Float)
147 # the display address (text) shown on their profile
148 city: Mapped[str] = mapped_column(String)
149 # "Grew up in" on profile
150 hometown: Mapped[str | None] = mapped_column(String, default=None)
152 regions_visited: Mapped[list[Region]] = relationship(
153 init=False, secondary="regions_visited", order_by="Region.name"
154 )
155 regions_lived: Mapped[list[Region]] = relationship(init=False, secondary="regions_lived", order_by="Region.name")
157 timezone = column_property(
158 select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, geom)).limit(1).scalar_subquery(),
159 deferred=True,
160 )
162 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
163 last_active: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
164 profile_last_updated: Mapped[datetime] = mapped_column(
165 DateTime(timezone=True), server_default=func.now(), init=False
166 )
168 public_visibility: Mapped[ProfilePublicVisibility] = mapped_column(
169 Enum(ProfilePublicVisibility), server_default="map_only", init=False
170 )
171 has_modified_public_visibility: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
173 # id of the last message that they received a notification about
174 last_notified_message_id: Mapped[int] = mapped_column(BigInteger, default=0)
175 # same as above for host requests
176 last_notified_request_message_id: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
178 # display name
179 name: Mapped[str] = mapped_column(String)
180 gender: Mapped[str] = mapped_column(String)
181 pronouns: Mapped[str | None] = mapped_column(String, default=None)
182 birthdate: Mapped[date] = mapped_column(Date) # in the timezone of birthplace
184 # Profile photo gallery for this user (photos about themselves)
185 # The first photo in the gallery (by position) is used as the avatar
186 profile_gallery_id: Mapped[int | None] = mapped_column(ForeignKey("photo_galleries.id"), default=None)
188 hosting_status: Mapped[HostingStatus] = mapped_column(Enum(HostingStatus))
189 meetup_status: Mapped[MeetupStatus] = mapped_column(Enum(MeetupStatus), server_default="open_to_meetup", init=False)
191 # community standing score
192 community_standing: Mapped[float | None] = mapped_column(Float, default=None)
194 occupation: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
195 education: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
197 # "Who I am" under "About Me" tab
198 about_me: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
199 # kept in sync by the database so profile-completeness checks never detoast about_me; doing so was the dominant
200 # cost of both the lite_users refresh and the profile metrics, which scan every user several times a minute
201 about_me_length: Mapped[int] = mapped_column(
202 Integer, Computed("coalesce(character_length(about_me), 0)", persisted=True), init=False
203 )
204 # "What I do in my free time" under "About Me" tab
205 things_i_like: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
206 # "About my home" under "My Home" tab
207 about_place: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
208 # "Additional information" under "About Me" tab
209 additional_information: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
211 banned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
212 deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
213 shadowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
214 moderation_auto_approve_deadline_seconds: Mapped[int | None] = mapped_column(Integer, default=None)
216 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
218 is_superuser: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
219 is_editor: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
221 # the undelete token allows a user to recover their account for a couple of days after deletion in case it was
222 # accidental or they changed their mind
223 # constraints make sure these are non-null only if deleted_at is set and that these are null in unison
224 undelete_token: Mapped[str | None] = mapped_column(String, default=None)
225 # validity of the undelete token
226 undelete_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
228 # hosting preferences
229 max_guests: Mapped[int | None] = mapped_column(Integer, default=None)
230 last_minute: Mapped[bool | None] = mapped_column(Boolean, default=None)
231 has_pets: Mapped[bool | None] = mapped_column(Boolean, default=None)
232 accepts_pets: Mapped[bool | None] = mapped_column(Boolean, default=None)
233 pet_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
234 has_kids: Mapped[bool | None] = mapped_column(Boolean, default=None)
235 accepts_kids: Mapped[bool | None] = mapped_column(Boolean, default=None)
236 kid_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
237 has_housemates: Mapped[bool | None] = mapped_column(Boolean, default=None)
238 housemate_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
239 wheelchair_accessible: Mapped[bool | None] = mapped_column(Boolean, default=None)
240 smoking_allowed: Mapped[SmokingLocation | None] = mapped_column(Enum(SmokingLocation), default=None)
241 smokes_at_home: Mapped[bool | None] = mapped_column(Boolean, default=None)
242 drinking_allowed: Mapped[bool | None] = mapped_column(Boolean, default=None)
243 drinks_at_home: Mapped[bool | None] = mapped_column(Boolean, default=None)
244 # "Additional information" under "My Home" tab
245 other_host_info: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
247 # "Sleeping privacy" (not long-form text)
248 sleeping_arrangement: Mapped[SleepingArrangement | None] = mapped_column(Enum(SleepingArrangement), default=None)
249 # "Sleeping arrangement" under "My Home" tab
250 sleeping_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
251 # "Local area information" under "My Home" tab
252 area: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
253 # "House rules" under "My Home" tab
254 house_rules: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
255 parking: Mapped[bool | None] = mapped_column(Boolean, default=None)
256 parking_details: Mapped[ParkingDetails | None] = mapped_column(
257 Enum(ParkingDetails), default=None
258 ) # CommonMark without images
259 camping_ok: Mapped[bool | None] = mapped_column(Boolean, default=None)
261 accepted_tos: Mapped[int] = mapped_column(Integer, default=0)
262 accepted_community_guidelines: Mapped[int] = mapped_column(Integer, server_default="0", init=False)
263 # whether the user has filled in the contributor form
264 filled_contributor_form: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
266 # number of onboarding emails sent
267 onboarding_emails_sent: Mapped[int] = mapped_column(Integer, server_default="0", init=False)
268 last_onboarding_email_sent: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
270 # whether we need to sync the user's newsletter preferences with the newsletter server
271 in_sync_with_newsletter: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
272 # opted out of the newsletter
273 opt_out_of_newsletter: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
275 # set to null to receive no digests
276 digest_frequency: Mapped[timedelta | None] = mapped_column(Interval, default=None)
277 last_digest_sent: Mapped[datetime] = mapped_column(
278 DateTime(timezone=True), server_default=text("to_timestamp(0)"), init=False
279 )
281 # for changing their email
282 new_email: Mapped[str | None] = mapped_column(String, default=None)
284 new_email_token: Mapped[str | None] = mapped_column(String, default=None)
285 new_email_token_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
286 new_email_token_expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
288 recommendation_score: Mapped[float] = mapped_column(Float, server_default="0", init=False)
290 mod_score: Mapped[float] = mapped_column(Float, server_default="1", init=False)
292 # Columns for verifying their phone number. State chart:
293 # ,-------------------,
294 # | Start |
295 # | phone = None | someone else
296 # ,-----------------, | token = None | verifies ,-----------------------,
297 # | Code Expired | | sent = 1970 or zz | phone xx | Verification Expired |
298 # | phone = xx | time passes | verified = None | <------, | phone = xx |
299 # | token = yy | <------------, | attempts = 0 | | | token = None |
300 # | sent = zz (exp.)| | '-------------------' | | sent = zz |
301 # | verified = None | | V ^ +-----------< | verified = ww (exp.) |
302 # | attempts = 0..2 | >--, | | | ChangePhone("") | | attempts = 0 |
303 # '-----------------' +-------- | ------+----+--------------------+ '-----------------------'
304 # | | | | ChangePhone(xx) | ^ time passes
305 # | | ^ V | |
306 # ,-----------------, | | ,-------------------, | ,-----------------------,
307 # | Too Many | >--' '--< | Code sent | >------+ | Verified |
308 # | phone = xx | | phone = xx | | | phone = xx |
309 # | token = yy | VerifyPhone(wrong)| token = yy | '-----------< | token = None |
310 # | sent = zz | <------+--------< | sent = zz | | sent = zz |
311 # | verified = None | | | verified = None | VerifyPhone(correct) | verified = ww |
312 # | attempts = 3 | '--------> | attempts = 0..2 | >------------------> | attempts = 0 |
313 # '-----------------' '-------------------' '-----------------------'
315 # randomly generated Luhn 6-digit string
316 phone_verification_token: Mapped[str | None] = mapped_column(
317 String(6), default=None, server_default=expression.null(), init=False
318 )
320 phone_verification_sent: Mapped[datetime] = mapped_column(
321 DateTime(timezone=True), server_default=text("to_timestamp(0)"), init=False
322 )
323 phone_verification_verified: Mapped[datetime | None] = mapped_column(
324 DateTime(timezone=True), default=None, server_default=expression.null(), init=False
325 )
326 phone_verification_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"), init=False)
328 # the stripe customer identifier if the user has donated to Couchers
329 # e.g. cus_JjoXHttuZopv0t
330 # for new US entity
331 stripe_customer_id: Mapped[str | None] = mapped_column(String, default=None)
332 # for old AU entity
333 stripe_customer_id_old: Mapped[str | None] = mapped_column(String, default=None)
335 has_passport_sex_gender_exception: Mapped[bool] = mapped_column(
336 Boolean, server_default=expression.false(), init=False
337 )
339 # checking for phone verification
340 last_donated: Mapped[datetime | None] = mapped_column(
341 DateTime(timezone=True), default=None, server_default=expression.null()
342 )
344 # whether this user has all emails turned off
345 do_not_email: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
347 profile_gallery: Mapped[PhotoGallery | None] = relationship(init=False, foreign_keys="User.profile_gallery_id")
349 admin_note: Mapped[str] = mapped_column(String, server_default=text("''"), init=False)
351 # whether mods have marked this user has having to update their location
352 needs_to_update_location: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
354 last_antibot: Mapped[datetime] = mapped_column(
355 DateTime(timezone=True), server_default=text("to_timestamp(0)"), init=False
356 )
358 age = column_property(func.date_part("year", func.age(birthdate)))
360 # ID of the invite code used to sign up (if any)
361 invite_code_id: Mapped[str | None] = mapped_column(ForeignKey("invite_codes.id"), default=None)
362 invite_code: Mapped[InviteCode | None] = relationship(init=False, foreign_keys=[invite_code_id])
364 # Signup motivations - how they heard about us and what they want to do
365 heard_about_couchers: Mapped[str | None] = mapped_column(String, default=None)
366 signup_motivations: Mapped[list[str] | None] = mapped_column(ARRAY(String), default=None)
368 moderation_user_lists: Mapped[list[ModerationUserList]] = relationship(
369 init=False, secondary="moderation_user_list_members", back_populates="users"
370 )
371 language_abilities: Mapped[list[LanguageAbility]] = relationship(init=False, back_populates="user")
372 galleries: Mapped[list[PhotoGallery]] = relationship(
373 init=False, foreign_keys="PhotoGallery.owner_user_id", back_populates="owner_user"
374 )
375 mod_notes: DynamicMapped[ModNote] = relationship(
376 init=False, foreign_keys="ModNote.user_id", back_populates="user", lazy="dynamic"
377 )
379 badges: Mapped[list[UserBadge]] = relationship(init=False, back_populates="user")
381 admin_tags: Mapped[list[UserAdminTag]] = relationship(
382 init=False, foreign_keys="UserAdminTag.user_id", overlaps="user"
383 )
385 pending_activeness_probe: Mapped[ActivenessProbe | None] = relationship(
386 init=False,
387 primaryjoin="and_(ActivenessProbe.user_id == User.id, ActivenessProbe.is_pending)",
388 uselist=False,
389 back_populates="user",
390 )
392 public_trips: Mapped[list[PublicTrip]] = relationship(init=False, back_populates="user")
394 moderation_state: Mapped[ModerationState] = relationship(init=False)
396 __table_args__ = (
397 # Verified phone numbers should be unique
398 Index(
399 "ix_users_unique_phone",
400 phone,
401 unique=True,
402 postgresql_where=phone_verification_verified != None,
403 ),
404 Index(
405 "ix_users_unique_email",
406 email,
407 unique=True,
408 postgresql_where=deleted_at == None,
409 ),
410 # These three are each looked up by equality as though the value named exactly one user, so the database
411 # needs to enforce that; partial as the columns are null for almost every user
412 Index(
413 "ix_users_unique_undelete_token",
414 undelete_token,
415 unique=True,
416 postgresql_where=undelete_token != None,
417 ),
418 Index(
419 "ix_users_unique_new_email_token",
420 new_email_token,
421 unique=True,
422 postgresql_where=new_email_token != None,
423 ),
424 Index(
425 "ix_users_unique_stripe_customer_id",
426 stripe_customer_id,
427 unique=True,
428 postgresql_where=stripe_customer_id != None,
429 ),
430 Index(
431 "ix_users_active",
432 id,
433 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
434 ),
435 Index(
436 "ix_users_geom_active",
437 geom,
438 id,
439 username,
440 postgresql_using="gist",
441 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
442 ),
443 Index(
444 "ix_users_by_id",
445 id,
446 postgresql_using="hash",
447 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
448 ),
449 Index(
450 "ix_users_by_username",
451 username,
452 postgresql_using="hash",
453 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
454 ),
455 Index(
456 "ix_users_visible_with_about_me",
457 id,
458 postgresql_where=and_(
459 banned_at.is_(None),
460 deleted_at.is_(None),
461 profile_gallery_id.isnot(None),
462 about_me_length >= COMPLETED_PROFILE_MINIMUM_CHAR_LENGTH,
463 ),
464 ),
465 # There are two possible states for new_email_token, new_email_token_created, and new_email_token_expiry
466 CheckConstraint(
467 "(new_email_token IS NOT NULL AND new_email_token_created IS NOT NULL AND new_email_token_expiry IS NOT NULL) OR \
468 (new_email_token IS NULL AND new_email_token_created IS NULL AND new_email_token_expiry IS NULL)",
469 name="check_new_email_token_state",
470 ),
471 # Whenever a phone number is set, it must either be pending verification or already verified.
472 # Exactly one of the following must always be true: not phone, token, verified.
473 CheckConstraint(
474 "(phone IS NULL)::int + (phone_verification_verified IS NOT NULL)::int + (phone_verification_token IS NOT NULL)::int = 1",
475 name="phone_verified_conditions",
476 ),
477 # Email must match our regex
478 CheckConstraint(
479 f"email ~ '{EMAIL_REGEX}'",
480 name="valid_email",
481 ),
482 # Undelete token + time are coupled: either both null or neither; and if they're not null then the account is deleted
483 CheckConstraint(
484 "((undelete_token IS NULL) = (undelete_until IS NULL)) AND ((undelete_token IS NULL) OR deleted_at IS NOT NULL)",
485 name="undelete_nullity",
486 ),
487 # If the user disabled all emails, then they can't host or meet up
488 CheckConstraint(
489 "(do_not_email IS FALSE) OR ((hosting_status = 'cant_host') AND (meetup_status = 'does_not_want_to_meetup'))",
490 name="do_not_email_inactive",
491 ),
492 # Superusers must be editors
493 CheckConstraint(
494 "(is_superuser IS FALSE) OR (is_editor IS TRUE)",
495 name="superuser_is_editor",
496 ),
497 )
499 @hybrid_property
500 def has_completed_my_home(self) -> bool:
501 # completed my profile means that:
502 # 1. has filled out max_guests
503 # 2. has filled out sleeping_arrangement (sleeping privacy)
504 # 3. has some text in at least one of the my home free text fields
505 return (
506 self.max_guests is not None
507 and self.sleeping_arrangement is not None
508 and (
509 self.about_place is not None
510 or self.other_host_info is not None
511 or self.sleeping_details is not None
512 or self.area is not None
513 or self.house_rules is not None
514 )
515 )
517 @has_completed_my_home.inplace.expression
518 @classmethod
519 def _has_completed_my_home_expression(cls) -> ColumnElement[bool]:
520 return and_(
521 cls.max_guests != None,
522 cls.sleeping_arrangement != None,
523 or_(
524 cls.about_place != None,
525 cls.other_host_info != None,
526 cls.sleeping_details != None,
527 cls.area != None,
528 cls.house_rules != None,
529 ),
530 )
532 @hybrid_property
533 def jailed_missing_tos(self) -> bool:
534 return self.accepted_tos < TOS_VERSION
536 @hybrid_property
537 def jailed_missing_community_guidelines(self) -> bool:
538 return self.accepted_community_guidelines < GUIDELINES_VERSION
540 @hybrid_property
541 def jailed_pending_mod_notes(self) -> Any:
542 # mod_notes come from a backref in ModNote
543 return self.mod_notes.where(ModNote.is_pending).count() > 0
545 @jailed_pending_mod_notes.inplace.expression
546 @classmethod
547 def _jailed_pending_mod_notes_expression(cls) -> ColumnElement[bool]:
548 return select(ModNote.id).where(ModNote.user_id == cls.id, ModNote.is_pending).exists()
550 @hybrid_property
551 def jailed_pending_activeness_probe(self) -> Any:
552 # search for User.pending_activeness_probe
553 return self.pending_activeness_probe != None
555 @jailed_pending_activeness_probe.inplace.expression
556 @classmethod
557 def _jailed_pending_activeness_probe_expression(cls) -> ColumnElement[bool]:
558 return select(ActivenessProbe.id).where(ActivenessProbe.user_id == cls.id, ActivenessProbe.is_pending).exists()
560 @hybrid_property
561 def is_jailed(self) -> Any:
562 return (
563 self.jailed_missing_tos
564 | self.jailed_missing_community_guidelines
565 | self.is_missing_location
566 | self.jailed_pending_mod_notes
567 | self.jailed_pending_activeness_probe
568 )
570 @is_jailed.inplace.expression
571 @classmethod
572 def _is_jailed_expression(cls) -> ColumnElement[bool]:
573 return (
574 cls.jailed_missing_tos
575 | cls.jailed_missing_community_guidelines
576 | cls.is_missing_location
577 | cls.jailed_pending_mod_notes
578 | cls.jailed_pending_activeness_probe
579 )
581 @hybrid_property
582 def is_missing_location(self) -> bool:
583 return self.needs_to_update_location
585 @hybrid_property
586 def is_visible(self) -> bool:
587 return self.banned_at is None and self.deleted_at is None
589 @is_visible.inplace.expression
590 @classmethod
591 def _is_visible_expression(cls) -> ColumnElement[bool]:
592 return and_(cls.banned_at.is_(None), cls.deleted_at.is_(None))
594 @hybrid_property
595 def is_shadowed(self) -> bool:
596 return self.shadowed_at is not None
598 @is_shadowed.inplace.expression
599 @classmethod
600 def _is_shadowed_expression(cls) -> ColumnElement[bool]:
601 return cls.shadowed_at.is_not(None)
603 @hybrid_property
604 def is_banned(self) -> bool:
605 return self.banned_at is not None
607 @is_banned.inplace.expression
608 @classmethod
609 def _is_banned_expression(cls) -> ColumnElement[bool]:
610 return cls.banned_at.is_not(None)
612 @hybrid_property
613 def reserves_email(self) -> bool:
614 return self.deleted_at is None or self.banned_at is not None
616 @reserves_email.inplace.expression
617 @classmethod
618 def _reserves_email_expression(cls) -> ColumnElement[bool]:
619 return or_(cls.deleted_at.is_(None), cls.banned_at.is_not(None))
621 @property
622 def coordinates(self) -> tuple[float, float]:
623 return get_coordinates(self.geom)
625 @property
626 def display_joined(self) -> datetime:
627 """
628 Returns the last active time rounded down to the nearest hour.
629 """
630 return self.joined.replace(minute=0, second=0, microsecond=0)
632 @property
633 def display_last_active(self) -> datetime:
634 """
635 Returns the last active time rounded down whatever is the "last active" coarsening.
636 """
637 return last_active_coarsen(self.last_active)
639 @hybrid_property
640 def phone_is_verified(self) -> bool:
641 return (
642 self.phone_verification_verified is not None
643 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME
644 )
646 @phone_is_verified.inplace.expression
647 @classmethod
648 def _phone_is_verified_expression(cls) -> ColumnElement[bool]:
649 return (cls.phone_verification_verified != None) & (
650 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME
651 )
653 @hybrid_property
654 def phone_code_expired(self) -> bool:
655 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME
657 def __repr__(self) -> str:
658 return f"User(id={self.id}, email={self.email}, username={self.username})"
661class LanguageFluency(enum.Enum):
662 # note that the numbering is important here, these are ordinal
663 beginner = 1
664 conversational = 2
665 fluent = 3
668class LanguageAbility(Base, kw_only=True):
669 __tablename__ = "language_abilities"
670 __table_args__ = (
671 # Users can only have one language ability per language
672 UniqueConstraint("user_id", "language_code"),
673 )
675 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
676 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
677 language_code: Mapped[str] = mapped_column(ForeignKey("languages.code", deferrable=True))
678 fluency: Mapped[LanguageFluency] = mapped_column(Enum(LanguageFluency))
680 user: Mapped[User] = relationship(init=False, back_populates="language_abilities")
681 language: Mapped[Language] = relationship(init=False)
684class RegionVisited(Base, kw_only=True):
685 __tablename__ = "regions_visited"
686 __table_args__ = (UniqueConstraint("user_id", "region_code"),)
688 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
689 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
690 region_code: Mapped[str] = mapped_column(ForeignKey("regions.code", deferrable=True))
693class RegionLived(Base, kw_only=True):
694 __tablename__ = "regions_lived"
695 __table_args__ = (UniqueConstraint("user_id", "region_code"),)
697 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
698 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
699 region_code: Mapped[str] = mapped_column(ForeignKey("regions.code", deferrable=True))
702class HostingMeetupStatusHistory(Base, kw_only=True):
703 """
704 Append-only snapshot log of users' hosting and meetup statuses. A row is written whenever either status changes, so
705 a user's statuses at any past time are those of their newest row at or before that time.
706 """
708 __tablename__ = "hosting_meetup_status_history"
710 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
711 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
712 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
713 source: Mapped[HostingMeetupStatusSource] = mapped_column(Enum(HostingMeetupStatusSource))
714 hosting_status: Mapped[HostingStatus] = mapped_column(Enum(HostingStatus))
715 meetup_status: Mapped[MeetupStatus] = mapped_column(Enum(MeetupStatus))
717 user: Mapped[User] = relationship(init=False)
719 __table_args__ = (Index("ix_hosting_meetup_status_history_user_id_time", user_id, time),)
722class UserEmailHistory(Base, kw_only=True):
723 """
724 Append-only log of users' email addresses. A row is written whenever a user's email is set, so a user's email at any
725 past time is that of their newest row at or before that time.
726 """
728 __tablename__ = "user_email_history"
730 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
731 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
732 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
733 email: Mapped[str] = mapped_column(String)
735 user: Mapped[User] = relationship(init=False)
737 __table_args__ = (
738 Index("ix_user_email_history_user_id_time", user_id, time),
739 Index("ix_user_email_history_email", email),
740 )
743class UserRankingOffset(Base, kw_only=True):
744 """
745 Offsets added to users' recommendation_score in user search for viewers on a given variant (picked by the
746 search_ranking_offsets_variant flag).
747 """
749 __tablename__ = "user_ranking_offsets"
751 variant: Mapped[str] = mapped_column(String, primary_key=True)
752 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
753 offset: Mapped[float] = mapped_column(Float)