Coverage for app/backend/src/couchers/models/users.py: 99%

251 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

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 Computed, 

12 Date, 

13 DateTime, 

14 Enum, 

15 Float, 

16 ForeignKey, 

17 Index, 

18 Integer, 

19 Interval, 

20 String, 

21 UniqueConstraint, 

22 and_, 

23 func, 

24 or_, 

25 select, 

26 text, 

27) 

28from sqlalchemy import LargeBinary as Binary 

29from sqlalchemy.ext.hybrid import hybrid_property 

30from sqlalchemy.orm import DynamicMapped, Mapped, column_property, mapped_column, relationship 

31from sqlalchemy.sql import expression 

32from sqlalchemy.sql.elements import ColumnElement 

33 

34from couchers.constants import ( 

35 COMPLETED_PROFILE_MINIMUM_CHAR_LENGTH, 

36 EMAIL_REGEX, 

37 GUIDELINES_VERSION, 

38 PHONE_VERIFICATION_LIFETIME, 

39 SMS_CODE_LIFETIME, 

40 TOS_VERSION, 

41) 

42from couchers.models.activeness_probe import ActivenessProbe 

43from couchers.models.base import Base, Geom 

44from couchers.models.mod_note import ModNote 

45from couchers.models.static import Language, Region, TimezoneArea 

46from couchers.utils import get_coordinates, last_active_coarsen, now 

47 

48if TYPE_CHECKING: 

49 from couchers.models import UserBadge 

50 from couchers.models.admin import UserAdminTag 

51 from couchers.models.public_trips import PublicTrip 

52 from couchers.models.rest import InviteCode, ModerationUserList 

53 from couchers.models.uploads import PhotoGallery 

54 

55 

56class HostingStatus(enum.Enum): 

57 can_host = enum.auto() 

58 maybe = enum.auto() 

59 cant_host = enum.auto() 

60 

61 

62class MeetupStatus(enum.Enum): 

63 wants_to_meetup = enum.auto() 

64 open_to_meetup = enum.auto() 

65 does_not_want_to_meetup = enum.auto() 

66 

67 

68class SmokingLocation(enum.Enum): 

69 yes = enum.auto() 

70 window = enum.auto() 

71 outside = enum.auto() 

72 no = enum.auto() 

73 

74 

75class SleepingArrangement(enum.Enum): 

76 private = enum.auto() 

77 common = enum.auto() 

78 shared_room = enum.auto() 

79 

80 

81class ParkingDetails(enum.Enum): 

82 free_onsite = enum.auto() 

83 free_offsite = enum.auto() 

84 paid_onsite = enum.auto() 

85 paid_offsite = enum.auto() 

86 

87 

88class ProfilePublicVisibility(enum.Enum): 

89 # no public info 

90 nothing = enum.auto() 

91 # only show on map, randomized, unclickable 

92 map_only = enum.auto() 

93 # name, gender, location, hosting/meetup status, badges, number of references, and signup time 

94 limited = enum.auto() 

95 # full about me except additional info (hide my home) 

96 most = enum.auto() 

97 # all but references 

98 full = enum.auto() 

99 

100 

101class User(Base, kw_only=True): 

102 """ 

103 Basic user and profile details 

104 """ 

105 

106 __tablename__ = "users" 

107 

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

109 

110 username: Mapped[str] = mapped_column(String, unique=True) 

111 email: Mapped[str] = mapped_column(String, unique=True) 

112 # stored in libsodium hash format, can be null for email login 

113 hashed_password: Mapped[bytes] = mapped_column(Binary) 

114 # phone number in E.164 format with leading +, for example "+46701740605" 

115 phone: Mapped[str | None] = mapped_column(String, default=None, server_default=expression.null()) 

116 # language preference -- defaults to empty string 

117 ui_language_preference: Mapped[str | None] = mapped_column(String, default=None, server_default="") 

118 

119 # timezones should always be UTC 

120 ## location 

121 # point describing their location. EPSG4326 is the SRS (spatial ref system, = way to describe a point on earth) used 

122 # by GPS, it has the WGS84 geoid with lat/lon 

123 geom: Mapped[Geom] = mapped_column(Geometry(geometry_type="POINT", srid=4326)) 

124 # randomized coordinates within a radius of 0.02-0.1 degrees, equates to about 2-10 km 

125 randomized_geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None) 

126 # their display location (displayed to other users), in meters 

127 geom_radius: Mapped[float] = mapped_column(Float) 

128 # the display address (text) shown on their profile 

129 city: Mapped[str] = mapped_column(String) 

130 # "Grew up in" on profile 

131 hometown: Mapped[str | None] = mapped_column(String, default=None) 

132 

133 regions_visited: Mapped[list[Region]] = relationship( 

134 init=False, secondary="regions_visited", order_by="Region.name" 

135 ) 

136 regions_lived: Mapped[list[Region]] = relationship(init=False, secondary="regions_lived", order_by="Region.name") 

137 

138 timezone = column_property( 

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

140 deferred=True, 

141 ) 

142 

143 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

144 last_active: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

145 profile_last_updated: Mapped[datetime] = mapped_column( 

146 DateTime(timezone=True), server_default=func.now(), init=False 

147 ) 

148 

149 public_visibility: Mapped[ProfilePublicVisibility] = mapped_column( 

150 Enum(ProfilePublicVisibility), server_default="map_only", init=False 

151 ) 

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

153 

154 # id of the last message that they received a notification about 

155 last_notified_message_id: Mapped[int] = mapped_column(BigInteger, default=0) 

156 # same as above for host requests 

157 last_notified_request_message_id: Mapped[int] = mapped_column(BigInteger, server_default=text("0"), init=False) 

158 

159 # display name 

160 name: Mapped[str] = mapped_column(String) 

161 gender: Mapped[str] = mapped_column(String) 

162 pronouns: Mapped[str | None] = mapped_column(String, default=None) 

163 birthdate: Mapped[date] = mapped_column(Date) # in the timezone of birthplace 

164 

165 # Profile photo gallery for this user (photos about themselves) 

166 # The first photo in the gallery (by position) is used as the avatar 

167 profile_gallery_id: Mapped[int | None] = mapped_column(ForeignKey("photo_galleries.id"), default=None) 

168 

169 hosting_status: Mapped[HostingStatus] = mapped_column(Enum(HostingStatus)) 

170 meetup_status: Mapped[MeetupStatus] = mapped_column(Enum(MeetupStatus), server_default="open_to_meetup", init=False) 

171 

172 # community standing score 

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

174 

175 occupation: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

176 education: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

177 

178 # "Who I am" under "About Me" tab 

179 about_me: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

180 # kept in sync by the database so profile-completeness checks never detoast about_me; doing so was the dominant 

181 # cost of both the lite_users refresh and the profile metrics, which scan every user several times a minute 

182 about_me_length: Mapped[int] = mapped_column( 

183 Integer, Computed("coalesce(character_length(about_me), 0)", persisted=True), init=False 

184 ) 

185 # "What I do in my free time" under "About Me" tab 

186 things_i_like: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

187 # "About my home" under "My Home" tab 

188 about_place: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

189 # "Additional information" under "About Me" tab 

190 additional_information: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

191 

192 banned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

193 deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

194 shadowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

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

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

197 

198 # the undelete token allows a user to recover their account for a couple of days after deletion in case it was 

199 # accidental or they changed their mind 

200 # constraints make sure these are non-null only if deleted_at is set and that these are null in unison 

201 undelete_token: Mapped[str | None] = mapped_column(String, default=None) 

202 # validity of the undelete token 

203 undelete_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

204 

205 # hosting preferences 

206 max_guests: Mapped[int | None] = mapped_column(Integer, default=None) 

207 last_minute: Mapped[bool | None] = mapped_column(Boolean, default=None) 

208 has_pets: Mapped[bool | None] = mapped_column(Boolean, default=None) 

209 accepts_pets: Mapped[bool | None] = mapped_column(Boolean, default=None) 

210 pet_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

211 has_kids: Mapped[bool | None] = mapped_column(Boolean, default=None) 

212 accepts_kids: Mapped[bool | None] = mapped_column(Boolean, default=None) 

213 kid_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

214 has_housemates: Mapped[bool | None] = mapped_column(Boolean, default=None) 

215 housemate_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

216 wheelchair_accessible: Mapped[bool | None] = mapped_column(Boolean, default=None) 

217 smoking_allowed: Mapped[SmokingLocation | None] = mapped_column(Enum(SmokingLocation), default=None) 

218 smokes_at_home: Mapped[bool | None] = mapped_column(Boolean, default=None) 

219 drinking_allowed: Mapped[bool | None] = mapped_column(Boolean, default=None) 

220 drinks_at_home: Mapped[bool | None] = mapped_column(Boolean, default=None) 

221 # "Additional information" under "My Home" tab 

222 other_host_info: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

223 

224 # "Sleeping privacy" (not long-form text) 

225 sleeping_arrangement: Mapped[SleepingArrangement | None] = mapped_column(Enum(SleepingArrangement), default=None) 

226 # "Sleeping arrangement" under "My Home" tab 

227 sleeping_details: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

228 # "Local area information" under "My Home" tab 

229 area: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

230 # "House rules" under "My Home" tab 

231 house_rules: Mapped[str | None] = mapped_column(String, default=None) # CommonMark without images 

232 parking: Mapped[bool | None] = mapped_column(Boolean, default=None) 

233 parking_details: Mapped[ParkingDetails | None] = mapped_column( 

234 Enum(ParkingDetails), default=None 

235 ) # CommonMark without images 

236 camping_ok: Mapped[bool | None] = mapped_column(Boolean, default=None) 

237 

238 accepted_tos: Mapped[int] = mapped_column(Integer, default=0) 

239 accepted_community_guidelines: Mapped[int] = mapped_column(Integer, server_default="0", init=False) 

240 # whether the user has filled in the contributor form 

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

242 

243 # number of onboarding emails sent 

244 onboarding_emails_sent: Mapped[int] = mapped_column(Integer, server_default="0", init=False) 

245 last_onboarding_email_sent: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

246 

247 # whether we need to sync the user's newsletter preferences with the newsletter server 

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

249 # opted out of the newsletter 

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

251 

252 # set to null to receive no digests 

253 digest_frequency: Mapped[timedelta | None] = mapped_column(Interval, default=None) 

254 last_digest_sent: Mapped[datetime] = mapped_column( 

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

256 ) 

257 

258 # for changing their email 

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

260 

261 new_email_token: Mapped[str | None] = mapped_column(String, default=None) 

262 new_email_token_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

263 new_email_token_expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

264 

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

266 

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

268 

269 # Columns for verifying their phone number. State chart: 

270 # ,-------------------, 

271 # | Start | 

272 # | phone = None | someone else 

273 # ,-----------------, | token = None | verifies ,-----------------------, 

274 # | Code Expired | | sent = 1970 or zz | phone xx | Verification Expired | 

275 # | phone = xx | time passes | verified = None | <------, | phone = xx | 

276 # | token = yy | <------------, | attempts = 0 | | | token = None | 

277 # | sent = zz (exp.)| | '-------------------' | | sent = zz | 

278 # | verified = None | | V ^ +-----------< | verified = ww (exp.) | 

279 # | attempts = 0..2 | >--, | | | ChangePhone("") | | attempts = 0 | 

280 # '-----------------' +-------- | ------+----+--------------------+ '-----------------------' 

281 # | | | | ChangePhone(xx) | ^ time passes 

282 # | | ^ V | | 

283 # ,-----------------, | | ,-------------------, | ,-----------------------, 

284 # | Too Many | >--' '--< | Code sent | >------+ | Verified | 

285 # | phone = xx | | phone = xx | | | phone = xx | 

286 # | token = yy | VerifyPhone(wrong)| token = yy | '-----------< | token = None | 

287 # | sent = zz | <------+--------< | sent = zz | | sent = zz | 

288 # | verified = None | | | verified = None | VerifyPhone(correct) | verified = ww | 

289 # | attempts = 3 | '--------> | attempts = 0..2 | >------------------> | attempts = 0 | 

290 # '-----------------' '-------------------' '-----------------------' 

291 

292 # randomly generated Luhn 6-digit string 

293 phone_verification_token: Mapped[str | None] = mapped_column( 

294 String(6), default=None, server_default=expression.null(), init=False 

295 ) 

296 

297 phone_verification_sent: Mapped[datetime] = mapped_column( 

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

299 ) 

300 phone_verification_verified: Mapped[datetime | None] = mapped_column( 

301 DateTime(timezone=True), default=None, server_default=expression.null(), init=False 

302 ) 

303 phone_verification_attempts: Mapped[int] = mapped_column(Integer, server_default=text("0"), init=False) 

304 

305 # the stripe customer identifier if the user has donated to Couchers 

306 # e.g. cus_JjoXHttuZopv0t 

307 # for new US entity 

308 stripe_customer_id: Mapped[str | None] = mapped_column(String, default=None) 

309 # for old AU entity 

310 stripe_customer_id_old: Mapped[str | None] = mapped_column(String, default=None) 

311 

312 has_passport_sex_gender_exception: Mapped[bool] = mapped_column( 

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

314 ) 

315 

316 # checking for phone verification 

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

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

319 ) 

320 

321 # whether this user has all emails turned off 

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

323 

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

325 

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

327 

328 # whether mods have marked this user has having to update their location 

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

330 

331 last_antibot: Mapped[datetime] = mapped_column( 

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

333 ) 

334 

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

336 

337 # ID of the invite code used to sign up (if any) 

338 invite_code_id: Mapped[str | None] = mapped_column(ForeignKey("invite_codes.id"), default=None) 

339 invite_code: Mapped[InviteCode | None] = relationship(init=False, foreign_keys=[invite_code_id]) 

340 

341 # Signup motivations - how they heard about us and what they want to do 

342 heard_about_couchers: Mapped[str | None] = mapped_column(String, default=None) 

343 signup_motivations: Mapped[list[str] | None] = mapped_column(ARRAY(String), default=None) 

344 

345 moderation_user_lists: Mapped[list[ModerationUserList]] = relationship( 

346 init=False, secondary="moderation_user_list_members", back_populates="users" 

347 ) 

348 language_abilities: Mapped[list[LanguageAbility]] = relationship(init=False, back_populates="user") 

349 galleries: Mapped[list[PhotoGallery]] = relationship( 

350 init=False, foreign_keys="PhotoGallery.owner_user_id", back_populates="owner_user" 

351 ) 

352 mod_notes: DynamicMapped[ModNote] = relationship( 

353 init=False, foreign_keys="ModNote.user_id", back_populates="user", lazy="dynamic" 

354 ) 

355 

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

357 

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

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

360 ) 

361 

362 pending_activeness_probe: Mapped[ActivenessProbe | None] = relationship( 

363 init=False, 

364 primaryjoin="and_(ActivenessProbe.user_id == User.id, ActivenessProbe.is_pending)", 

365 uselist=False, 

366 back_populates="user", 

367 ) 

368 

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

370 

371 __table_args__ = ( 

372 # Verified phone numbers should be unique 

373 Index( 

374 "ix_users_unique_phone", 

375 phone, 

376 unique=True, 

377 postgresql_where=phone_verification_verified != None, 

378 ), 

379 Index( 

380 "ix_users_active", 

381 id, 

382 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)), 

383 ), 

384 Index( 

385 "ix_users_geom_active", 

386 geom, 

387 id, 

388 username, 

389 postgresql_using="gist", 

390 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)), 

391 ), 

392 Index( 

393 "ix_users_by_id", 

394 id, 

395 postgresql_using="hash", 

396 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)), 

397 ), 

398 Index( 

399 "ix_users_by_username", 

400 username, 

401 postgresql_using="hash", 

402 postgresql_where=and_(banned_at.is_(None), deleted_at.is_(None)), 

403 ), 

404 Index( 

405 "ix_users_visible_with_about_me", 

406 id, 

407 postgresql_where=and_( 

408 banned_at.is_(None), 

409 deleted_at.is_(None), 

410 profile_gallery_id.isnot(None), 

411 about_me_length >= COMPLETED_PROFILE_MINIMUM_CHAR_LENGTH, 

412 ), 

413 ), 

414 # There are two possible states for new_email_token, new_email_token_created, and new_email_token_expiry 

415 CheckConstraint( 

416 "(new_email_token IS NOT NULL AND new_email_token_created IS NOT NULL AND new_email_token_expiry IS NOT NULL) OR \ 

417 (new_email_token IS NULL AND new_email_token_created IS NULL AND new_email_token_expiry IS NULL)", 

418 name="check_new_email_token_state", 

419 ), 

420 # Whenever a phone number is set, it must either be pending verification or already verified. 

421 # Exactly one of the following must always be true: not phone, token, verified. 

422 CheckConstraint( 

423 "(phone IS NULL)::int + (phone_verification_verified IS NOT NULL)::int + (phone_verification_token IS NOT NULL)::int = 1", 

424 name="phone_verified_conditions", 

425 ), 

426 # Email must match our regex 

427 CheckConstraint( 

428 f"email ~ '{EMAIL_REGEX}'", 

429 name="valid_email", 

430 ), 

431 # Undelete token + time are coupled: either both null or neither; and if they're not null then the account is deleted 

432 CheckConstraint( 

433 "((undelete_token IS NULL) = (undelete_until IS NULL)) AND ((undelete_token IS NULL) OR deleted_at IS NOT NULL)", 

434 name="undelete_nullity", 

435 ), 

436 # If the user disabled all emails, then they can't host or meet up 

437 CheckConstraint( 

438 "(do_not_email IS FALSE) OR ((hosting_status = 'cant_host') AND (meetup_status = 'does_not_want_to_meetup'))", 

439 name="do_not_email_inactive", 

440 ), 

441 # Superusers must be editors 

442 CheckConstraint( 

443 "(is_superuser IS FALSE) OR (is_editor IS TRUE)", 

444 name="superuser_is_editor", 

445 ), 

446 ) 

447 

448 @hybrid_property 

449 def has_completed_my_home(self) -> bool: 

450 # completed my profile means that: 

451 # 1. has filled out max_guests 

452 # 2. has filled out sleeping_arrangement (sleeping privacy) 

453 # 3. has some text in at least one of the my home free text fields 

454 return ( 

455 self.max_guests is not None 

456 and self.sleeping_arrangement is not None 

457 and ( 

458 self.about_place is not None 

459 or self.other_host_info is not None 

460 or self.sleeping_details is not None 

461 or self.area is not None 

462 or self.house_rules is not None 

463 ) 

464 ) 

465 

466 @has_completed_my_home.inplace.expression 

467 @classmethod 

468 def _has_completed_my_home_expression(cls) -> ColumnElement[bool]: 

469 return and_( 

470 cls.max_guests != None, 

471 cls.sleeping_arrangement != None, 

472 or_( 

473 cls.about_place != None, 

474 cls.other_host_info != None, 

475 cls.sleeping_details != None, 

476 cls.area != None, 

477 cls.house_rules != None, 

478 ), 

479 ) 

480 

481 @hybrid_property 

482 def jailed_missing_tos(self) -> bool: 

483 return self.accepted_tos < TOS_VERSION 

484 

485 @hybrid_property 

486 def jailed_missing_community_guidelines(self) -> bool: 

487 return self.accepted_community_guidelines < GUIDELINES_VERSION 

488 

489 @hybrid_property 

490 def jailed_pending_mod_notes(self) -> Any: 

491 # mod_notes come from a backref in ModNote 

492 return self.mod_notes.where(ModNote.is_pending).count() > 0 

493 

494 @jailed_pending_mod_notes.inplace.expression 

495 @classmethod 

496 def _jailed_pending_mod_notes_expression(cls) -> ColumnElement[bool]: 

497 return select(ModNote.id).where(ModNote.user_id == cls.id, ModNote.is_pending).exists() 

498 

499 @hybrid_property 

500 def jailed_pending_activeness_probe(self) -> Any: 

501 # search for User.pending_activeness_probe 

502 return self.pending_activeness_probe != None 

503 

504 @jailed_pending_activeness_probe.inplace.expression 

505 @classmethod 

506 def _jailed_pending_activeness_probe_expression(cls) -> ColumnElement[bool]: 

507 return select(ActivenessProbe.id).where(ActivenessProbe.user_id == cls.id, ActivenessProbe.is_pending).exists() 

508 

509 @hybrid_property 

510 def is_jailed(self) -> Any: 

511 return ( 

512 self.jailed_missing_tos 

513 | self.jailed_missing_community_guidelines 

514 | self.is_missing_location 

515 | self.jailed_pending_mod_notes 

516 | self.jailed_pending_activeness_probe 

517 ) 

518 

519 @is_jailed.inplace.expression 

520 @classmethod 

521 def _is_jailed_expression(cls) -> ColumnElement[bool]: 

522 return ( 

523 cls.jailed_missing_tos 

524 | cls.jailed_missing_community_guidelines 

525 | cls.is_missing_location 

526 | cls.jailed_pending_mod_notes 

527 | cls.jailed_pending_activeness_probe 

528 ) 

529 

530 @hybrid_property 

531 def is_missing_location(self) -> bool: 

532 return self.needs_to_update_location 

533 

534 @hybrid_property 

535 def is_visible(self) -> bool: 

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

537 

538 @is_visible.inplace.expression 

539 @classmethod 

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

541 return and_(cls.banned_at.is_(None), cls.deleted_at.is_(None)) 

542 

543 @hybrid_property 

544 def is_shadowed(self) -> bool: 

545 return self.shadowed_at is not None 

546 

547 @is_shadowed.inplace.expression 

548 @classmethod 

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

550 return cls.shadowed_at.is_not(None) 

551 

552 @property 

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

554 return get_coordinates(self.geom) 

555 

556 @property 

557 def display_joined(self) -> datetime: 

558 """ 

559 Returns the last active time rounded down to the nearest hour. 

560 """ 

561 return self.joined.replace(minute=0, second=0, microsecond=0) 

562 

563 @property 

564 def display_last_active(self) -> datetime: 

565 """ 

566 Returns the last active time rounded down whatever is the "last active" coarsening. 

567 """ 

568 return last_active_coarsen(self.last_active) 

569 

570 @hybrid_property 

571 def phone_is_verified(self) -> bool: 

572 return ( 

573 self.phone_verification_verified is not None 

574 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

575 ) 

576 

577 @phone_is_verified.inplace.expression 

578 @classmethod 

579 def _phone_is_verified_expression(cls) -> ColumnElement[bool]: 

580 return (cls.phone_verification_verified != None) & ( 

581 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

582 ) 

583 

584 @hybrid_property 

585 def phone_code_expired(self) -> bool: 

586 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME 

587 

588 def __repr__(self) -> str: 

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

590 

591 

592class LanguageFluency(enum.Enum): 

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

594 beginner = 1 

595 conversational = 2 

596 fluent = 3 

597 

598 

599class LanguageAbility(Base, kw_only=True): 

600 __tablename__ = "language_abilities" 

601 __table_args__ = ( 

602 # Users can only have one language ability per language 

603 UniqueConstraint("user_id", "language_code"), 

604 ) 

605 

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

607 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

608 language_code: Mapped[str] = mapped_column(ForeignKey("languages.code", deferrable=True)) 

609 fluency: Mapped[LanguageFluency] = mapped_column(Enum(LanguageFluency)) 

610 

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

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

613 

614 

615class RegionVisited(Base, kw_only=True): 

616 __tablename__ = "regions_visited" 

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

618 

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

620 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

621 region_code: Mapped[str] = mapped_column(ForeignKey("regions.code", deferrable=True)) 

622 

623 

624class RegionLived(Base, kw_only=True): 

625 __tablename__ = "regions_lived" 

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

627 

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

629 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

630 region_code: Mapped[str] = mapped_column(ForeignKey("regions.code", deferrable=True))