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

1import enum 

2from datetime import date, datetime, timedelta 

3from typing import TYPE_CHECKING, Any 

4 

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 

32 

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 

45 

46if TYPE_CHECKING: 

47 from couchers.models import UserBadge 

48 from couchers.models.rest import InviteCode, ModerationUserList 

49 from couchers.models.uploads import PhotoGallery 

50 

51 

52class HostingStatus(enum.Enum): 

53 can_host = enum.auto() 

54 maybe = enum.auto() 

55 cant_host = enum.auto() 

56 

57 

58class MeetupStatus(enum.Enum): 

59 wants_to_meetup = enum.auto() 

60 open_to_meetup = enum.auto() 

61 does_not_want_to_meetup = enum.auto() 

62 

63 

64class SmokingLocation(enum.Enum): 

65 yes = enum.auto() 

66 window = enum.auto() 

67 outside = enum.auto() 

68 no = enum.auto() 

69 

70 

71class SleepingArrangement(enum.Enum): 

72 private = enum.auto() 

73 common = enum.auto() 

74 shared_room = enum.auto() 

75 

76 

77class ParkingDetails(enum.Enum): 

78 free_onsite = enum.auto() 

79 free_offsite = enum.auto() 

80 paid_onsite = enum.auto() 

81 paid_offsite = enum.auto() 

82 

83 

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() 

95 

96 

97class User(Base, kw_only=True): 

98 """ 

99 Basic user and profile details 

100 """ 

101 

102 __tablename__ = "users" 

103 

104 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

105 

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="") 

114 

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) 

128 

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") 

133 

134 timezone = column_property( 

135 sa_select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, geom)).limit(1).scalar_subquery(), 

136 deferred=True, 

137 ) 

138 

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 ) 

144 

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) 

149 

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) 

154 

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 

160 

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) 

164 

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) 

167 

168 # community standing score 

169 community_standing: Mapped[float | None] = mapped_column(Float, default=None) 

170 

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 

173 

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 

182 

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) 

187 

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) 

194 

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 

213 

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) 

227 

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) 

232 

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) 

236 

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) 

241 

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 ) 

247 

248 # for changing their email 

249 new_email: Mapped[str | None] = mapped_column(String, default=None) 

250 

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) 

254 

255 recommendation_score: Mapped[float] = mapped_column(Float, server_default="0", init=False) 

256 

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 # '-----------------' '-------------------' '-----------------------' 

279 

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 ) 

284 

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) 

292 

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) 

299 

300 has_passport_sex_gender_exception: Mapped[bool] = mapped_column( 

301 Boolean, server_default=expression.false(), init=False 

302 ) 

303 

304 # checking for phone verification 

305 last_donated: Mapped[datetime | None] = mapped_column( 

306 DateTime(timezone=True), default=None, server_default=expression.null() 

307 ) 

308 

309 # whether this user has all emails turned off 

310 do_not_email: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False) 

311 

312 profile_gallery: Mapped[PhotoGallery | None] = relationship(init=False, foreign_keys="User.profile_gallery_id") 

313 

314 admin_note: Mapped[str] = mapped_column(String, server_default=text("''"), init=False) 

315 

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) 

318 

319 last_antibot: Mapped[datetime] = mapped_column( 

320 DateTime(timezone=True), server_default=text("to_timestamp(0)"), init=False 

321 ) 

322 

323 age = column_property(func.date_part("year", func.age(birthdate))) 

324 

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]) 

328 

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 ) 

339 

340 badges: Mapped[list[UserBadge]] = relationship(init=False, back_populates="user") 

341 

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 ) 

348 

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 ) 

416 

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 ) 

434 

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 ) 

449 

450 @hybrid_property 

451 def jailed_missing_tos(self) -> bool: 

452 return self.accepted_tos < TOS_VERSION 

453 

454 @hybrid_property 

455 def jailed_missing_community_guidelines(self) -> bool: 

456 return self.accepted_community_guidelines < GUIDELINES_VERSION 

457 

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 

462 

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 

467 

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 ) 

477 

478 @hybrid_property 

479 def is_missing_location(self) -> bool: 

480 return self.needs_to_update_location 

481 

482 @hybrid_property 

483 def is_visible(self) -> bool: 

484 return not self.is_banned and not self.is_deleted 

485 

486 @is_visible.inplace.expression 

487 @classmethod 

488 def _is_visible_expression(cls) -> ColumnElement[bool]: 

489 return ~(cls.is_banned | cls.is_deleted) 

490 

491 @property 

492 def coordinates(self) -> tuple[float, float]: 

493 return get_coordinates(self.geom) 

494 

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) 

501 

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) 

508 

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 ) 

515 

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 ) 

522 

523 @hybrid_property 

524 def phone_code_expired(self) -> bool: 

525 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME 

526 

527 def __repr__(self) -> str: 

528 return f"User(id={self.id}, email={self.email}, username={self.username})" 

529 

530 

531class LanguageFluency(enum.Enum): 

532 # note that the numbering is important here, these are ordinal 

533 beginner = 1 

534 conversational = 2 

535 fluent = 3 

536 

537 

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 ) 

544 

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)) 

549 

550 user: Mapped[User] = relationship(init=False, back_populates="language_abilities") 

551 language: Mapped[Language] = relationship(init=False) 

552 

553 

554class RegionVisited(Base, kw_only=True): 

555 __tablename__ = "regions_visited" 

556 __table_args__ = (UniqueConstraint("user_id", "region_code"),) 

557 

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)) 

561 

562 

563class RegionLived(Base, kw_only=True): 

564 __tablename__ = "regions_lived" 

565 __table_args__ = (UniqueConstraint("user_id", "region_code"),) 

566 

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))