Coverage for app/backend/src/couchers/models/users.py: 99%
251 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +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.static import Language, Region, TimezoneArea
46from couchers.utils import get_coordinates, last_active_coarsen, now
48if TYPE_CHECKING:
49 from couchers.models import UserBadge
50 from couchers.models.admin import UserAdminTag
51 from couchers.models.public_trips import PublicTrip
52 from couchers.models.rest import InviteCode, ModerationUserList
53 from couchers.models.uploads import PhotoGallery
56class HostingStatus(enum.Enum):
57 can_host = enum.auto()
58 maybe = enum.auto()
59 cant_host = enum.auto()
62class MeetupStatus(enum.Enum):
63 wants_to_meetup = enum.auto()
64 open_to_meetup = enum.auto()
65 does_not_want_to_meetup = enum.auto()
68class SmokingLocation(enum.Enum):
69 yes = enum.auto()
70 window = enum.auto()
71 outside = enum.auto()
72 no = enum.auto()
75class SleepingArrangement(enum.Enum):
76 private = enum.auto()
77 common = enum.auto()
78 shared_room = enum.auto()
81class ParkingDetails(enum.Enum):
82 free_onsite = enum.auto()
83 free_offsite = enum.auto()
84 paid_onsite = enum.auto()
85 paid_offsite = enum.auto()
88class ProfilePublicVisibility(enum.Enum):
89 # no public info
90 nothing = enum.auto()
91 # only show on map, randomized, unclickable
92 map_only = enum.auto()
93 # name, gender, location, hosting/meetup status, badges, number of references, and signup time
94 limited = enum.auto()
95 # full about me except additional info (hide my home)
96 most = enum.auto()
97 # all but references
98 full = enum.auto()
101class User(Base, kw_only=True):
102 """
103 Basic user and profile details
104 """
106 __tablename__ = "users"
108 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
110 username: Mapped[str] = mapped_column(String, unique=True)
111 email: Mapped[str] = mapped_column(String, unique=True)
112 # stored in libsodium hash format, can be null for email login
113 hashed_password: Mapped[bytes] = mapped_column(Binary)
114 # phone number in E.164 format with leading +, for example "+46701740605"
115 phone: Mapped[str | None] = mapped_column(String, default=None, server_default=expression.null())
116 # language preference -- defaults to empty string
117 ui_language_preference: Mapped[str | None] = mapped_column(String, default=None, server_default="")
119 # timezones should always be UTC
120 ## location
121 # point describing their location. EPSG4326 is the SRS (spatial ref system, = way to describe a point on earth) used
122 # by GPS, it has the WGS84 geoid with lat/lon
123 geom: Mapped[Geom] = mapped_column(Geometry(geometry_type="POINT", srid=4326))
124 # randomized coordinates within a radius of 0.02-0.1 degrees, equates to about 2-10 km
125 randomized_geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
126 # their display location (displayed to other users), in meters
127 geom_radius: Mapped[float] = mapped_column(Float)
128 # the display address (text) shown on their profile
129 city: Mapped[str] = mapped_column(String)
130 # "Grew up in" on profile
131 hometown: Mapped[str | None] = mapped_column(String, default=None)
133 regions_visited: Mapped[list[Region]] = relationship(
134 init=False, secondary="regions_visited", order_by="Region.name"
135 )
136 regions_lived: Mapped[list[Region]] = relationship(init=False, secondary="regions_lived", order_by="Region.name")
138 timezone = column_property(
139 select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, geom)).limit(1).scalar_subquery(),
140 deferred=True,
141 )
143 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
144 last_active: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
145 profile_last_updated: Mapped[datetime] = mapped_column(
146 DateTime(timezone=True), server_default=func.now(), init=False
147 )
149 public_visibility: Mapped[ProfilePublicVisibility] = mapped_column(
150 Enum(ProfilePublicVisibility), server_default="map_only", init=False
151 )
152 has_modified_public_visibility: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
154 # id of the last message that they received a notification about
155 last_notified_message_id: Mapped[int] = mapped_column(BigInteger, default=0)
156 # same as above for host requests
157 last_notified_request_message_id: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False)
159 # display name
160 name: Mapped[str] = mapped_column(String)
161 gender: Mapped[str] = mapped_column(String)
162 pronouns: Mapped[str | None] = mapped_column(String, default=None)
163 birthdate: Mapped[date] = mapped_column(Date) # in the timezone of birthplace
165 # Profile photo gallery for this user (photos about themselves)
166 # The first photo in the gallery (by position) is used as the avatar
167 profile_gallery_id: Mapped[int | None] = mapped_column(ForeignKey("photo_galleries.id"), default=None)
169 hosting_status: Mapped[HostingStatus] = mapped_column(Enum(HostingStatus))
170 meetup_status: Mapped[MeetupStatus] = mapped_column(Enum(MeetupStatus), server_default="open_to_meetup", init=False)
172 # community standing score
173 community_standing: Mapped[float | None] = mapped_column(Float, default=None)
175 occupation: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
176 education: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
178 # "Who I am" under "About Me" tab
179 about_me: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
180 # kept in sync by the database so profile-completeness checks never detoast about_me; doing so was the dominant
181 # cost of both the lite_users refresh and the profile metrics, which scan every user several times a minute
182 about_me_length: Mapped[int] = mapped_column(
183 Integer, Computed("coalesce(character_length(about_me), 0)", persisted=True), init=False
184 )
185 # "What I do in my free time" under "About Me" tab
186 things_i_like: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
187 # "About my home" under "My Home" tab
188 about_place: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
189 # "Additional information" under "About Me" tab
190 additional_information: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
192 banned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
193 deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
194 shadowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
195 is_superuser: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
196 is_editor: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
198 # the undelete token allows a user to recover their account for a couple of days after deletion in case it was
199 # accidental or they changed their mind
200 # constraints make sure these are non-null only if deleted_at is set and that these are null in unison
201 undelete_token: Mapped[str | None] = mapped_column(String, default=None)
202 # validity of the undelete token
203 undelete_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
205 # hosting preferences
206 max_guests: Mapped[int | None] = mapped_column(Integer, default=None)
207 last_minute: Mapped[bool | None] = mapped_column(Boolean, default=None)
208 has_pets: Mapped[bool | None] = mapped_column(Boolean, default=None)
209 accepts_pets: Mapped[bool | None] = mapped_column(Boolean, default=None)
210 pet_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
211 has_kids: Mapped[bool | None] = mapped_column(Boolean, default=None)
212 accepts_kids: Mapped[bool | None] = mapped_column(Boolean, default=None)
213 kid_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
214 has_housemates: Mapped[bool | None] = mapped_column(Boolean, default=None)
215 housemate_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
216 wheelchair_accessible: Mapped[bool | None] = mapped_column(Boolean, default=None)
217 smoking_allowed: Mapped[SmokingLocation | None] = mapped_column(Enum(SmokingLocation), default=None)
218 smokes_at_home: Mapped[bool | None] = mapped_column(Boolean, default=None)
219 drinking_allowed: Mapped[bool | None] = mapped_column(Boolean, default=None)
220 drinks_at_home: Mapped[bool | None] = mapped_column(Boolean, default=None)
221 # "Additional information" under "My Home" tab
222 other_host_info: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
224 # "Sleeping privacy" (not long-form text)
225 sleeping_arrangement: Mapped[SleepingArrangement | None] = mapped_column(Enum(SleepingArrangement), default=None)
226 # "Sleeping arrangement" under "My Home" tab
227 sleeping_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
228 # "Local area information" under "My Home" tab
229 area: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
230 # "House rules" under "My Home" tab
231 house_rules: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images
232 parking: Mapped[bool | None] = mapped_column(Boolean, default=None)
233 parking_details: Mapped[ParkingDetails | None] = mapped_column(
234 Enum(ParkingDetails), default=None
235 ) # CommonMark without images
236 camping_ok: Mapped[bool | None] = mapped_column(Boolean, default=None)
238 accepted_tos: Mapped[int] = mapped_column(Integer, default=0)
239 accepted_community_guidelines: Mapped[int] = mapped_column(Integer, server_default="0", init=False)
240 # whether the user has filled in the contributor form
241 filled_contributor_form: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
243 # number of onboarding emails sent
244 onboarding_emails_sent: Mapped[int] = mapped_column(Integer, server_default="0", init=False)
245 last_onboarding_email_sent: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
247 # whether we need to sync the user's newsletter preferences with the newsletter server
248 in_sync_with_newsletter: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
249 # opted out of the newsletter
250 opt_out_of_newsletter: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
252 # set to null to receive no digests
253 digest_frequency: Mapped[timedelta | None] = mapped_column(Interval, default=None)
254 last_digest_sent: Mapped[datetime] = mapped_column(
255 DateTime(timezone=True), server_default=text("to_timestamp(0)"), init=False
256 )
258 # for changing their email
259 new_email: Mapped[str | None] = mapped_column(String, default=None)
261 new_email_token: Mapped[str | None] = mapped_column(String, default=None)
262 new_email_token_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
263 new_email_token_expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
265 recommendation_score: Mapped[float] = mapped_column(Float, server_default="0", init=False)
267 mod_score: Mapped[float] = mapped_column(Float, server_default="1", init=False)
269 # Columns for verifying their phone number. State chart:
270 # ,-------------------,
271 # | Start |
272 # | phone = None | someone else
273 # ,-----------------, | token = None | verifies ,-----------------------,
274 # | Code Expired | | sent = 1970 or zz | phone xx | Verification Expired |
275 # | phone = xx | time passes | verified = None | <------, | phone = xx |
276 # | token = yy | <------------, | attempts = 0 | | | token = None |
277 # | sent = zz (exp.)| | '-------------------' | | sent = zz |
278 # | verified = None | | V ^ +-----------< | verified = ww (exp.) |
279 # | attempts = 0..2 | >--, | | | ChangePhone("") | | attempts = 0 |
280 # '-----------------' +-------- | ------+----+--------------------+ '-----------------------'
281 # | | | | ChangePhone(xx) | ^ time passes
282 # | | ^ V | |
283 # ,-----------------, | | ,-------------------, | ,-----------------------,
284 # | Too Many | >--' '--< | Code sent | >------+ | Verified |
285 # | phone = xx | | phone = xx | | | phone = xx |
286 # | token = yy | VerifyPhone(wrong)| token = yy | '-----------< | token = None |
287 # | sent = zz | <------+--------< | sent = zz | | sent = zz |
288 # | verified = None | | | verified = None | VerifyPhone(correct) | verified = ww |
289 # | attempts = 3 | '--------> | attempts = 0..2 | >------------------> | attempts = 0 |
290 # '-----------------' '-------------------' '-----------------------'
292 # randomly generated Luhn 6-digit string
293 phone_verification_token: Mapped[str | None] = mapped_column(
294 String(6), default=None, server_default=expression.null(), init=False
295 )
297 phone_verification_sent: Mapped[datetime] = mapped_column(
298 DateTime(timezone=True), server_default=text("to_timestamp(0)"), init=False
299 )
300 phone_verification_verified: Mapped[datetime | None] = mapped_column(
301 DateTime(timezone=True), default=None, server_default=expression.null(), init=False
302 )
303 phone_verification_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"), init=False)
305 # the stripe customer identifier if the user has donated to Couchers
306 # e.g. cus_JjoXHttuZopv0t
307 # for new US entity
308 stripe_customer_id: Mapped[str | None] = mapped_column(String, default=None)
309 # for old AU entity
310 stripe_customer_id_old: Mapped[str | None] = mapped_column(String, default=None)
312 has_passport_sex_gender_exception: Mapped[bool] = mapped_column(
313 Boolean, server_default=expression.false(), init=False
314 )
316 # checking for phone verification
317 last_donated: Mapped[datetime | None] = mapped_column(
318 DateTime(timezone=True), default=None, server_default=expression.null()
319 )
321 # whether this user has all emails turned off
322 do_not_email: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
324 profile_gallery: Mapped[PhotoGallery | None] = relationship(init=False, foreign_keys="User.profile_gallery_id")
326 admin_note: Mapped[str] = mapped_column(String, server_default=text("''"), init=False)
328 # whether mods have marked this user has having to update their location
329 needs_to_update_location: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
331 last_antibot: Mapped[datetime] = mapped_column(
332 DateTime(timezone=True), server_default=text("to_timestamp(0)"), init=False
333 )
335 age = column_property(func.date_part("year", func.age(birthdate)))
337 # ID of the invite code used to sign up (if any)
338 invite_code_id: Mapped[str | None] = mapped_column(ForeignKey("invite_codes.id"), default=None)
339 invite_code: Mapped[InviteCode | None] = relationship(init=False, foreign_keys=[invite_code_id])
341 # Signup motivations - how they heard about us and what they want to do
342 heard_about_couchers: Mapped[str | None] = mapped_column(String, default=None)
343 signup_motivations: Mapped[list[str] | None] = mapped_column(ARRAY(String), default=None)
345 moderation_user_lists: Mapped[list[ModerationUserList]] = relationship(
346 init=False, secondary="moderation_user_list_members", back_populates="users"
347 )
348 language_abilities: Mapped[list[LanguageAbility]] = relationship(init=False, back_populates="user")
349 galleries: Mapped[list[PhotoGallery]] = relationship(
350 init=False, foreign_keys="PhotoGallery.owner_user_id", back_populates="owner_user"
351 )
352 mod_notes: DynamicMapped[ModNote] = relationship(
353 init=False, foreign_keys="ModNote.user_id", back_populates="user", lazy="dynamic"
354 )
356 badges: Mapped[list[UserBadge]] = relationship(init=False, back_populates="user")
358 admin_tags: Mapped[list[UserAdminTag]] = relationship(
359 init=False, foreign_keys="UserAdminTag.user_id", overlaps="user"
360 )
362 pending_activeness_probe: Mapped[ActivenessProbe | None] = relationship(
363 init=False,
364 primaryjoin="and_(ActivenessProbe.user_id == User.id, ActivenessProbe.is_pending)",
365 uselist=False,
366 back_populates="user",
367 )
369 public_trips: Mapped[list[PublicTrip]] = relationship(init=False, back_populates="user")
371 __table_args__ = (
372 # Verified phone numbers should be unique
373 Index(
374 "ix_users_unique_phone",
375 phone,
376 unique=True,
377 postgresql_where=phone_verification_verified != None,
378 ),
379 Index(
380 "ix_users_active",
381 id,
382 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
383 ),
384 Index(
385 "ix_users_geom_active",
386 geom,
387 id,
388 username,
389 postgresql_using="gist",
390 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
391 ),
392 Index(
393 "ix_users_by_id",
394 id,
395 postgresql_using="hash",
396 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
397 ),
398 Index(
399 "ix_users_by_username",
400 username,
401 postgresql_using="hash",
402 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)),
403 ),
404 Index(
405 "ix_users_visible_with_about_me",
406 id,
407 postgresql_where=and_(
408 banned_at.is_(None),
409 deleted_at.is_(None),
410 profile_gallery_id.isnot(None),
411 about_me_length >= COMPLETED_PROFILE_MINIMUM_CHAR_LENGTH,
412 ),
413 ),
414 # There are two possible states for new_email_token, new_email_token_created, and new_email_token_expiry
415 CheckConstraint(
416 "(new_email_token IS NOT NULL AND new_email_token_created IS NOT NULL AND new_email_token_expiry IS NOT NULL) OR \
417 (new_email_token IS NULL AND new_email_token_created IS NULL AND new_email_token_expiry IS NULL)",
418 name="check_new_email_token_state",
419 ),
420 # Whenever a phone number is set, it must either be pending verification or already verified.
421 # Exactly one of the following must always be true: not phone, token, verified.
422 CheckConstraint(
423 "(phone IS NULL)::int + (phone_verification_verified IS NOT NULL)::int + (phone_verification_token IS NOT NULL)::int = 1",
424 name="phone_verified_conditions",
425 ),
426 # Email must match our regex
427 CheckConstraint(
428 f"email ~ '{EMAIL_REGEX}'",
429 name="valid_email",
430 ),
431 # Undelete token + time are coupled: either both null or neither; and if they're not null then the account is deleted
432 CheckConstraint(
433 "((undelete_token IS NULL) = (undelete_until IS NULL)) AND ((undelete_token IS NULL) OR deleted_at IS NOT NULL)",
434 name="undelete_nullity",
435 ),
436 # If the user disabled all emails, then they can't host or meet up
437 CheckConstraint(
438 "(do_not_email IS FALSE) OR ((hosting_status = 'cant_host') AND (meetup_status = 'does_not_want_to_meetup'))",
439 name="do_not_email_inactive",
440 ),
441 # Superusers must be editors
442 CheckConstraint(
443 "(is_superuser IS FALSE) OR (is_editor IS TRUE)",
444 name="superuser_is_editor",
445 ),
446 )
448 @hybrid_property
449 def has_completed_my_home(self) -> bool:
450 # completed my profile means that:
451 # 1. has filled out max_guests
452 # 2. has filled out sleeping_arrangement (sleeping privacy)
453 # 3. has some text in at least one of the my home free text fields
454 return (
455 self.max_guests is not None
456 and self.sleeping_arrangement is not None
457 and (
458 self.about_place is not None
459 or self.other_host_info is not None
460 or self.sleeping_details is not None
461 or self.area is not None
462 or self.house_rules is not None
463 )
464 )
466 @has_completed_my_home.inplace.expression
467 @classmethod
468 def _has_completed_my_home_expression(cls) -> ColumnElement[bool]:
469 return and_(
470 cls.max_guests != None,
471 cls.sleeping_arrangement != None,
472 or_(
473 cls.about_place != None,
474 cls.other_host_info != None,
475 cls.sleeping_details != None,
476 cls.area != None,
477 cls.house_rules != None,
478 ),
479 )
481 @hybrid_property
482 def jailed_missing_tos(self) -> bool:
483 return self.accepted_tos < TOS_VERSION
485 @hybrid_property
486 def jailed_missing_community_guidelines(self) -> bool:
487 return self.accepted_community_guidelines < GUIDELINES_VERSION
489 @hybrid_property
490 def jailed_pending_mod_notes(self) -> Any:
491 # mod_notes come from a backref in ModNote
492 return self.mod_notes.where(ModNote.is_pending).count() > 0
494 @jailed_pending_mod_notes.inplace.expression
495 @classmethod
496 def _jailed_pending_mod_notes_expression(cls) -> ColumnElement[bool]:
497 return select(ModNote.id).where(ModNote.user_id == cls.id, ModNote.is_pending).exists()
499 @hybrid_property
500 def jailed_pending_activeness_probe(self) -> Any:
501 # search for User.pending_activeness_probe
502 return self.pending_activeness_probe != None
504 @jailed_pending_activeness_probe.inplace.expression
505 @classmethod
506 def _jailed_pending_activeness_probe_expression(cls) -> ColumnElement[bool]:
507 return select(ActivenessProbe.id).where(ActivenessProbe.user_id == cls.id, ActivenessProbe.is_pending).exists()
509 @hybrid_property
510 def is_jailed(self) -> Any:
511 return (
512 self.jailed_missing_tos
513 | self.jailed_missing_community_guidelines
514 | self.is_missing_location
515 | self.jailed_pending_mod_notes
516 | self.jailed_pending_activeness_probe
517 )
519 @is_jailed.inplace.expression
520 @classmethod
521 def _is_jailed_expression(cls) -> ColumnElement[bool]:
522 return (
523 cls.jailed_missing_tos
524 | cls.jailed_missing_community_guidelines
525 | cls.is_missing_location
526 | cls.jailed_pending_mod_notes
527 | cls.jailed_pending_activeness_probe
528 )
530 @hybrid_property
531 def is_missing_location(self) -> bool:
532 return self.needs_to_update_location
534 @hybrid_property
535 def is_visible(self) -> bool:
536 return self.banned_at is None and self.deleted_at is None
538 @is_visible.inplace.expression
539 @classmethod
540 def _is_visible_expression(cls) -> ColumnElement[bool]:
541 return and_(cls.banned_at.is_(None), cls.deleted_at.is_(None))
543 @hybrid_property
544 def is_shadowed(self) -> bool:
545 return self.shadowed_at is not None
547 @is_shadowed.inplace.expression
548 @classmethod
549 def _is_shadowed_expression(cls) -> ColumnElement[bool]:
550 return cls.shadowed_at.is_not(None)
552 @property
553 def coordinates(self) -> tuple[float, float]:
554 return get_coordinates(self.geom)
556 @property
557 def display_joined(self) -> datetime:
558 """
559 Returns the last active time rounded down to the nearest hour.
560 """
561 return self.joined.replace(minute=0, second=0, microsecond=0)
563 @property
564 def display_last_active(self) -> datetime:
565 """
566 Returns the last active time rounded down whatever is the "last active" coarsening.
567 """
568 return last_active_coarsen(self.last_active)
570 @hybrid_property
571 def phone_is_verified(self) -> bool:
572 return (
573 self.phone_verification_verified is not None
574 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME
575 )
577 @phone_is_verified.inplace.expression
578 @classmethod
579 def _phone_is_verified_expression(cls) -> ColumnElement[bool]:
580 return (cls.phone_verification_verified != None) & (
581 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME
582 )
584 @hybrid_property
585 def phone_code_expired(self) -> bool:
586 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME
588 def __repr__(self) -> str:
589 return f"User(id={self.id}, email={self.email}, username={self.username})"
592class LanguageFluency(enum.Enum):
593 # note that the numbering is important here, these are ordinal
594 beginner = 1
595 conversational = 2
596 fluent = 3
599class LanguageAbility(Base, kw_only=True):
600 __tablename__ = "language_abilities"
601 __table_args__ = (
602 # Users can only have one language ability per language
603 UniqueConstraint("user_id", "language_code"),
604 )
606 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
607 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
608 language_code: Mapped[str] = mapped_column(ForeignKey("languages.code", deferrable=True))
609 fluency: Mapped[LanguageFluency] = mapped_column(Enum(LanguageFluency))
611 user: Mapped[User] = relationship(init=False, back_populates="language_abilities")
612 language: Mapped[Language] = relationship(init=False)
615class RegionVisited(Base, kw_only=True):
616 __tablename__ = "regions_visited"
617 __table_args__ = (UniqueConstraint("user_id", "region_code"),)
619 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
620 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
621 region_code: Mapped[str] = mapped_column(ForeignKey("regions.code", deferrable=True))
624class RegionLived(Base, kw_only=True):
625 __tablename__ = "regions_lived"
626 __table_args__ = (UniqueConstraint("user_id", "region_code"),)
628 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
629 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
630 region_code: Mapped[str] = mapped_column(ForeignKey("regions.code", deferrable=True))