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