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