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

1import enum 

2from datetime import date, datetime, timedelta 

3from typing import TYPE_CHECKING, Any 

4 

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 

32 

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 

46 

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 

53 

54 

55class HostingStatus(enum.Enum): 

56 can_host = enum.auto() 

57 maybe = enum.auto() 

58 cant_host = enum.auto() 

59 

60 

61class MeetupStatus(enum.Enum): 

62 wants_to_meetup = enum.auto() 

63 open_to_meetup = enum.auto() 

64 does_not_want_to_meetup = enum.auto() 

65 

66 

67class SmokingLocation(enum.Enum): 

68 yes = enum.auto() 

69 window = enum.auto() 

70 outside = enum.auto() 

71 no = enum.auto() 

72 

73 

74class SleepingArrangement(enum.Enum): 

75 private = enum.auto() 

76 common = enum.auto() 

77 shared_room = enum.auto() 

78 

79 

80class ParkingDetails(enum.Enum): 

81 free_onsite = enum.auto() 

82 free_offsite = enum.auto() 

83 paid_onsite = enum.auto() 

84 paid_offsite = enum.auto() 

85 

86 

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

98 

99 

100class User(Base, kw_only=True): 

101 """ 

102 Basic user and profile details 

103 """ 

104 

105 __tablename__ = "users" 

106 

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

108 

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

117 

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) 

131 

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

136 

137 timezone = column_property( 

138 select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, geom)).limit(1).scalar_subquery(), 

139 deferred=True, 

140 ) 

141 

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 ) 

147 

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) 

152 

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) 

157 

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 

163 

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) 

167 

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) 

170 

171 # community standing score 

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

173 

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 

176 

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 

185 

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) 

191 

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) 

198 

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 

217 

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) 

231 

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) 

236 

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) 

240 

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) 

245 

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 ) 

251 

252 # for changing their email 

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

254 

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) 

258 

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

260 

261 mod_score: Mapped[float] = mapped_column(Float, server_default="1", init=False) 

262 

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

285 

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 ) 

290 

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) 

298 

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) 

305 

306 has_passport_sex_gender_exception: Mapped[bool] = mapped_column( 

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

308 ) 

309 

310 # checking for phone verification 

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

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

313 ) 

314 

315 # whether this user has all emails turned off 

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

317 

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

319 

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

321 

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) 

324 

325 last_antibot: Mapped[datetime] = mapped_column( 

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

327 ) 

328 

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

330 

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

334 

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) 

338 

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 ) 

349 

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

351 

352 admin_tags: Mapped[list[UserAdminTag]] = relationship( 

353 init=False, foreign_keys="UserAdminTag.user_id", overlaps="user" 

354 ) 

355 

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 ) 

362 

363 public_trips: Mapped[list[PublicTrip]] = relationship(init=False, back_populates="user") 

364 

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 ) 

441 

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 ) 

459 

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 ) 

474 

475 @hybrid_property 

476 def jailed_missing_tos(self) -> bool: 

477 return self.accepted_tos < TOS_VERSION 

478 

479 @hybrid_property 

480 def jailed_missing_community_guidelines(self) -> bool: 

481 return self.accepted_community_guidelines < GUIDELINES_VERSION 

482 

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 

487 

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

492 

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 

497 

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

502 

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 ) 

512 

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 ) 

523 

524 @hybrid_property 

525 def is_missing_location(self) -> bool: 

526 return self.needs_to_update_location 

527 

528 @hybrid_property 

529 def is_visible(self) -> bool: 

530 return self.banned_at is None and self.deleted_at is None 

531 

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

536 

537 @hybrid_property 

538 def is_shadowed(self) -> bool: 

539 return self.shadowed_at is not None 

540 

541 @is_shadowed.inplace.expression 

542 @classmethod 

543 def _is_shadowed_expression(cls) -> ColumnElement[bool]: 

544 return cls.shadowed_at.is_not(None) 

545 

546 @property 

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

548 return get_coordinates(self.geom) 

549 

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) 

556 

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) 

563 

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 ) 

570 

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 ) 

577 

578 @hybrid_property 

579 def phone_code_expired(self) -> bool: 

580 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME 

581 

582 def __repr__(self) -> str: 

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

584 

585 

586class LanguageFluency(enum.Enum): 

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

588 beginner = 1 

589 conversational = 2 

590 fluent = 3 

591 

592 

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 ) 

599 

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

604 

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

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

607 

608 

609class RegionVisited(Base, kw_only=True): 

610 __tablename__ = "regions_visited" 

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

612 

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

616 

617 

618class RegionLived(Base, kw_only=True): 

619 __tablename__ = "regions_lived" 

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

621 

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