Coverage for src/couchers/models.py: 99%

1224 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-02 20:25 +0000

1import enum 

2 

3from geoalchemy2.types import Geometry 

4from google.protobuf import empty_pb2 

5from sqlalchemy import ( 

6 ARRAY, 

7 JSON, 

8 BigInteger, 

9 Boolean, 

10 CheckConstraint, 

11 Column, 

12 Date, 

13 DateTime, 

14 Enum, 

15 Float, 

16 ForeignKey, 

17 Index, 

18 Integer, 

19 Interval, 

20 MetaData, 

21 Sequence, 

22 String, 

23 UniqueConstraint, 

24) 

25from sqlalchemy import LargeBinary as Binary 

26from sqlalchemy.dialects.postgresql import INET, TSTZRANGE, ExcludeConstraint 

27from sqlalchemy.ext.associationproxy import association_proxy 

28from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property 

29from sqlalchemy.orm import backref, column_property, declarative_base, deferred, relationship 

30from sqlalchemy.sql import and_, expression, func, not_, or_, text 

31from sqlalchemy.sql import select as sa_select 

32 

33from couchers import urls 

34from couchers.config import config 

35from couchers.constants import ( 

36 DATETIME_INFINITY, 

37 DATETIME_MINUS_INFINITY, 

38 EMAIL_REGEX, 

39 GUIDELINES_VERSION, 

40 PHONE_VERIFICATION_LIFETIME, 

41 SMS_CODE_LIFETIME, 

42 TOS_VERSION, 

43) 

44from couchers.utils import ( 

45 date_in_timezone, 

46 get_coordinates, 

47 last_active_coarsen, 

48 now, 

49) 

50from proto import notification_data_pb2 

51 

52meta = MetaData( 

53 naming_convention={ 

54 "ix": "ix_%(column_0_label)s", 

55 "uq": "uq_%(table_name)s_%(column_0_name)s", 

56 "ck": "ck_%(table_name)s_%(constraint_name)s", 

57 "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 

58 "pk": "pk_%(table_name)s", 

59 } 

60) 

61 

62Base = declarative_base(metadata=meta) 

63 

64 

65class HostingStatus(enum.Enum): 

66 can_host = enum.auto() 

67 maybe = enum.auto() 

68 cant_host = enum.auto() 

69 

70 

71class MeetupStatus(enum.Enum): 

72 wants_to_meetup = enum.auto() 

73 open_to_meetup = enum.auto() 

74 does_not_want_to_meetup = enum.auto() 

75 

76 

77class SmokingLocation(enum.Enum): 

78 yes = enum.auto() 

79 window = enum.auto() 

80 outside = enum.auto() 

81 no = enum.auto() 

82 

83 

84class SleepingArrangement(enum.Enum): 

85 private = enum.auto() 

86 common = enum.auto() 

87 shared_room = enum.auto() 

88 

89 

90class ParkingDetails(enum.Enum): 

91 free_onsite = enum.auto() 

92 free_offsite = enum.auto() 

93 paid_onsite = enum.auto() 

94 paid_offsite = enum.auto() 

95 

96 

97class ProfilePublicVisibility(enum.Enum): 

98 # no public info 

99 nothing = enum.auto() 

100 # only show on map, randomized, unclickable 

101 map_only = enum.auto() 

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

103 limited = enum.auto() 

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

105 most = enum.auto() 

106 # all but references 

107 full = enum.auto() 

108 

109 

110class TimezoneArea(Base): 

111 __tablename__ = "timezone_areas" 

112 id = Column(BigInteger, primary_key=True) 

113 

114 tzid = Column(String) 

115 geom = Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False) 

116 

117 __table_args__ = ( 

118 Index( 

119 "ix_timezone_areas_geom_tzid", 

120 geom, 

121 tzid, 

122 postgresql_using="gist", 

123 ), 

124 ) 

125 

126 

127class User(Base): 

128 """ 

129 Basic user and profile details 

130 """ 

131 

132 __tablename__ = "users" 

133 

134 id = Column(BigInteger, primary_key=True) 

135 

136 username = Column(String, nullable=False, unique=True) 

137 email = Column(String, nullable=False, unique=True) 

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

139 hashed_password = Column(Binary, nullable=False) 

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

141 phone = Column(String, nullable=True, server_default=expression.null()) 

142 # language preference -- defaults to empty string 

143 ui_language_preference = Column(String, nullable=True, server_default="") 

144 

145 # timezones should always be UTC 

146 ## location 

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

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

149 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=False) 

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

151 randomized_geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True) 

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

153 geom_radius = Column(Float, nullable=False) 

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

155 city = Column(String, nullable=False) 

156 # "Grew up in" on profile 

157 hometown = Column(String, nullable=True) 

158 

159 regions_visited = relationship("Region", secondary="regions_visited", order_by="Region.name") 

160 regions_lived = relationship("Region", secondary="regions_lived", order_by="Region.name") 

161 

162 timezone = column_property( 

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

164 deferred=True, 

165 ) 

166 

167 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

168 last_active = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

169 

170 public_visibility = Column(Enum(ProfilePublicVisibility), nullable=False, server_default="map_only") 

171 has_modified_public_visibility = Column(Boolean, nullable=False, server_default=expression.false()) 

172 

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

174 last_notified_message_id = Column(BigInteger, nullable=False, default=0) 

175 # same as above for host requests 

176 last_notified_request_message_id = Column(BigInteger, nullable=False, server_default=text("0")) 

177 

178 # display name 

179 name = Column(String, nullable=False) 

180 gender = Column(String, nullable=False) 

181 pronouns = Column(String, nullable=True) 

182 birthdate = Column(Date, nullable=False) # in the timezone of birthplace 

183 

184 avatar_key = Column(ForeignKey("uploads.key"), nullable=True) 

185 

186 hosting_status = Column(Enum(HostingStatus), nullable=False) 

187 meetup_status = Column(Enum(MeetupStatus), nullable=False, server_default="open_to_meetup") 

188 

189 # community standing score 

190 community_standing = Column(Float, nullable=True) 

191 

192 occupation = Column(String, nullable=True) # CommonMark without images 

193 education = Column(String, nullable=True) # CommonMark without images 

194 

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

196 about_me = Column(String, nullable=True) # CommonMark without images 

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

198 things_i_like = Column(String, nullable=True) # CommonMark without images 

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

200 about_place = Column(String, nullable=True) # CommonMark without images 

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

202 additional_information = Column(String, nullable=True) # CommonMark without images 

203 

204 is_banned = Column(Boolean, nullable=False, server_default=expression.false()) 

205 is_deleted = Column(Boolean, nullable=False, server_default=expression.false()) 

206 is_superuser = Column(Boolean, nullable=False, server_default=expression.false()) 

207 

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

209 # accidental or they changed their mind 

210 # constraints make sure these are non-null only if is_deleted and that these are null in unison 

211 undelete_token = Column(String, nullable=True) 

212 # validity of the undelete token 

213 undelete_until = Column(DateTime(timezone=True), nullable=True) 

214 

215 # hosting preferences 

216 max_guests = Column(Integer, nullable=True) 

217 last_minute = Column(Boolean, nullable=True) 

218 has_pets = Column(Boolean, nullable=True) 

219 accepts_pets = Column(Boolean, nullable=True) 

220 pet_details = Column(String, nullable=True) # CommonMark without images 

221 has_kids = Column(Boolean, nullable=True) 

222 accepts_kids = Column(Boolean, nullable=True) 

223 kid_details = Column(String, nullable=True) # CommonMark without images 

224 has_housemates = Column(Boolean, nullable=True) 

225 housemate_details = Column(String, nullable=True) # CommonMark without images 

226 wheelchair_accessible = Column(Boolean, nullable=True) 

227 smoking_allowed = Column(Enum(SmokingLocation), nullable=True) 

228 smokes_at_home = Column(Boolean, nullable=True) 

229 drinking_allowed = Column(Boolean, nullable=True) 

230 drinks_at_home = Column(Boolean, nullable=True) 

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

232 other_host_info = Column(String, nullable=True) # CommonMark without images 

233 

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

235 sleeping_arrangement = Column(Enum(SleepingArrangement), nullable=True) 

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

237 sleeping_details = Column(String, nullable=True) # CommonMark without images 

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

239 area = Column(String, nullable=True) # CommonMark without images 

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

241 house_rules = Column(String, nullable=True) # CommonMark without images 

242 parking = Column(Boolean, nullable=True) 

243 parking_details = Column(Enum(ParkingDetails), nullable=True) # CommonMark without images 

244 camping_ok = Column(Boolean, nullable=True) 

245 

246 accepted_tos = Column(Integer, nullable=False, default=0) 

247 accepted_community_guidelines = Column(Integer, nullable=False, server_default="0") 

248 # whether the user has yet filled in the contributor form 

249 filled_contributor_form = Column(Boolean, nullable=False, server_default=expression.false()) 

250 

251 # number of onboarding emails sent 

252 onboarding_emails_sent = Column(Integer, nullable=False, server_default="0") 

253 last_onboarding_email_sent = Column(DateTime(timezone=True), nullable=True) 

254 

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

256 in_sync_with_newsletter = Column(Boolean, nullable=False, server_default=expression.false()) 

257 # opted out of the newsletter 

258 opt_out_of_newsletter = Column(Boolean, nullable=False, server_default=expression.false()) 

259 

260 # set to null to receive no digests 

261 digest_frequency = Column(Interval, nullable=True) 

262 last_digest_sent = Column(DateTime(timezone=True), nullable=False, server_default=text("to_timestamp(0)")) 

263 

264 # for changing their email 

265 new_email = Column(String, nullable=True) 

266 

267 new_email_token = Column(String, nullable=True) 

268 new_email_token_created = Column(DateTime(timezone=True), nullable=True) 

269 new_email_token_expiry = Column(DateTime(timezone=True), nullable=True) 

270 

271 recommendation_score = Column(Float, nullable=False, server_default="0") 

272 

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

274 # ,-------------------, 

275 # | Start | 

276 # | phone = None | someone else 

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

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

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

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

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

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

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

284 # '-----------------' +-------- | ------+----+--------------------+ '-----------------------' 

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

286 # | | ^ V | | 

287 # ,-----------------, | | ,-------------------, | ,-----------------------, 

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

289 # | phone = xx | | phone = xx | | | phone = xx | 

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

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

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

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

294 # '-----------------' '-------------------' '-----------------------' 

295 

296 # randomly generated Luhn 6-digit string 

297 phone_verification_token = Column(String(6), nullable=True, server_default=expression.null()) 

298 

299 phone_verification_sent = Column(DateTime(timezone=True), nullable=False, server_default=text("to_timestamp(0)")) 

300 phone_verification_verified = Column(DateTime(timezone=True), nullable=True, server_default=expression.null()) 

301 phone_verification_attempts = Column(Integer, nullable=False, server_default=text("0")) 

302 

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

304 # e.g. cus_JjoXHttuZopv0t 

305 # for new US entity 

306 stripe_customer_id = Column(String, nullable=True) 

307 # for old AU entity 

308 stripe_customer_id_old = Column(String, nullable=True) 

309 

310 has_passport_sex_gender_exception = Column(Boolean, nullable=False, server_default=expression.false()) 

311 

312 # checking for phone verification 

313 has_donated = Column(Boolean, nullable=False, server_default=expression.false()) 

314 

315 # whether this user has all emails turned off 

316 do_not_email = Column(Boolean, nullable=False, server_default=expression.false()) 

317 

318 avatar = relationship("Upload", foreign_keys="User.avatar_key") 

319 

320 admin_note = Column(String, nullable=False, server_default=text("''")) 

321 

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

323 needs_to_update_location = Column(Boolean, nullable=False, server_default=expression.false()) 

324 

325 last_antibot = Column(DateTime(timezone=True), nullable=False, server_default=text("to_timestamp(0)")) 

326 

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

328 

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

330 invite_code_id = Column(ForeignKey("invite_codes.id"), nullable=True) 

331 invite_code = relationship("InviteCode", foreign_keys=[invite_code_id]) 

332 

333 moderation_user_lists = relationship( 

334 "ModerationUserList", secondary="moderation_user_list_members", back_populates="users" 

335 ) 

336 

337 __table_args__ = ( 

338 # Verified phone numbers should be unique 

339 Index( 

340 "ix_users_unique_phone", 

341 phone, 

342 unique=True, 

343 postgresql_where=phone_verification_verified != None, 

344 ), 

345 Index( 

346 "ix_users_active", 

347 id, 

348 postgresql_where=and_(not_(is_banned), not_(is_deleted)), 

349 ), 

350 # create index on users(geom, id, username) where not is_banned and not is_deleted and geom is not null; 

351 Index( 

352 "ix_users_geom_active", 

353 geom, 

354 id, 

355 username, 

356 postgresql_using="gist", 

357 postgresql_where=and_(not_(is_banned), not_(is_deleted)), 

358 ), 

359 Index( 

360 "ix_users_by_id", 

361 id, 

362 postgresql_using="hash", 

363 postgresql_where=and_(not_(is_banned), not_(is_deleted)), 

364 ), 

365 Index( 

366 "ix_users_by_username", 

367 username, 

368 postgresql_using="hash", 

369 postgresql_where=and_(not_(is_banned), not_(is_deleted)), 

370 ), 

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

372 CheckConstraint( 

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

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

375 name="check_new_email_token_state", 

376 ), 

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

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

379 CheckConstraint( 

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

381 name="phone_verified_conditions", 

382 ), 

383 # Email must match our regex 

384 CheckConstraint( 

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

386 name="valid_email", 

387 ), 

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

389 CheckConstraint( 

390 "((undelete_token IS NULL) = (undelete_until IS NULL)) AND ((undelete_token IS NULL) OR is_deleted)", 

391 name="undelete_nullity", 

392 ), 

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

394 CheckConstraint( 

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

396 name="do_not_email_inactive", 

397 ), 

398 ) 

399 

400 @hybrid_property 

401 def has_completed_profile(self): 

402 return self.avatar_key is not None and self.about_me is not None and len(self.about_me) >= 150 

403 

404 @has_completed_profile.expression 

405 def has_completed_profile(cls): 

406 return (cls.avatar_key != None) & (func.character_length(cls.about_me) >= 150) 

407 

408 @hybrid_property 

409 def has_completed_my_home(self): 

410 # completed my profile means that: 

411 # 1. has filled out max_guests 

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

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

414 return ( 

415 self.max_guests is not None 

416 and self.sleeping_arrangement is not None 

417 and ( 

418 self.about_place is not None 

419 or self.other_host_info is not None 

420 or self.sleeping_details is not None 

421 or self.area is not None 

422 or self.house_rules is not None 

423 ) 

424 ) 

425 

426 @has_completed_my_home.expression 

427 def has_completed_my_home(cls): 

428 return and_( 

429 cls.max_guests != None, 

430 cls.sleeping_arrangement != None, 

431 or_( 

432 cls.about_place != None, 

433 cls.other_host_info != None, 

434 cls.sleeping_details != None, 

435 cls.area != None, 

436 cls.house_rules != None, 

437 ), 

438 ) 

439 

440 @hybrid_property 

441 def jailed_missing_tos(self): 

442 return self.accepted_tos < TOS_VERSION 

443 

444 @hybrid_property 

445 def jailed_missing_community_guidelines(self): 

446 return self.accepted_community_guidelines < GUIDELINES_VERSION 

447 

448 @hybrid_property 

449 def jailed_pending_mod_notes(self): 

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

451 

452 @hybrid_property 

453 def jailed_pending_activeness_probe(self): 

454 return self.pending_activeness_probe != None 

455 

456 @hybrid_property 

457 def is_jailed(self): 

458 return ( 

459 self.jailed_missing_tos 

460 | self.jailed_missing_community_guidelines 

461 | self.is_missing_location 

462 | self.jailed_pending_mod_notes 

463 | self.jailed_pending_activeness_probe 

464 ) 

465 

466 @hybrid_property 

467 def is_missing_location(self): 

468 return self.needs_to_update_location 

469 

470 @hybrid_property 

471 def is_visible(self): 

472 return not self.is_banned and not self.is_deleted 

473 

474 @is_visible.expression 

475 def is_visible(cls): 

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

477 

478 @property 

479 def coordinates(self): 

480 return get_coordinates(self.geom) 

481 

482 @property 

483 def display_joined(self): 

484 """ 

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

486 """ 

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

488 

489 @property 

490 def display_last_active(self): 

491 """ 

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

493 """ 

494 return last_active_coarsen(self.last_active) 

495 

496 @hybrid_property 

497 def phone_is_verified(self): 

498 return ( 

499 self.phone_verification_verified is not None 

500 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

501 ) 

502 

503 @phone_is_verified.expression 

504 def phone_is_verified(cls): 

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

506 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

507 ) 

508 

509 @hybrid_property 

510 def phone_code_expired(self): 

511 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME 

512 

513 def __repr__(self): 

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

515 

516 

517class UserBadge(Base): 

518 """ 

519 A badge on a user's profile 

520 """ 

521 

522 __tablename__ = "user_badges" 

523 __table_args__ = (UniqueConstraint("user_id", "badge_id"),) 

524 

525 id = Column(BigInteger, primary_key=True) 

526 

527 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

528 # corresponds to "id" in badges.json 

529 badge_id = Column(String, nullable=False, index=True) 

530 

531 # take this with a grain of salt, someone may get then lose a badge for whatever reason 

532 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

533 

534 user = relationship("User", backref="badges") 

535 

536 

537class ActivenessProbeStatus(enum.Enum): 

538 # no response yet 

539 pending = enum.auto() 

540 

541 # didn't respond on time 

542 expired = enum.auto() 

543 

544 # responded that they're still active 

545 still_active = enum.auto() 

546 

547 # responded that they're no longer active 

548 no_longer_active = enum.auto() 

549 

550 

551class ActivenessProbe(Base): 

552 """ 

553 Activeness probes are used to gauge if users are still active: we send them a notification and ask them to respond, 

554 we use this data both to help indicate response rate, as well as to make sure only those who are actively hosting 

555 show up as such. 

556 """ 

557 

558 __tablename__ = "activeness_probes" 

559 

560 id = Column(BigInteger, primary_key=True) 

561 

562 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

563 # the time this probe was initiated 

564 probe_initiated = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

565 # the number of reminders sent for this probe 

566 notifications_sent = Column(Integer, nullable=False, server_default="0") 

567 

568 # the time of response 

569 responded = Column(DateTime(timezone=True), nullable=True, default=None) 

570 # the response value 

571 response = Column(Enum(ActivenessProbeStatus), nullable=False, default=ActivenessProbeStatus.pending) 

572 

573 @hybrid_property 

574 def is_pending(self): 

575 return self.responded == None 

576 

577 user = relationship("User", back_populates="pending_activeness_probe") 

578 

579 __table_args__ = ( 

580 # a user can have at most one pending activeness probe at a time 

581 Index( 

582 "ix_activeness_probe_unique_pending_response", 

583 user_id, 

584 unique=True, 

585 postgresql_where=responded == None, 

586 ), 

587 # response time is none iff response is pending 

588 CheckConstraint( 

589 "(responded IS NULL AND response = 'pending') OR (responded IS NOT NULL AND response != 'pending')", 

590 name="pending_has_no_responded", 

591 ), 

592 ) 

593 

594 

595User.pending_activeness_probe = relationship( 

596 ActivenessProbe, 

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

598 uselist=False, 

599 back_populates="user", 

600) 

601 

602 

603class StrongVerificationAttemptStatus(enum.Enum): 

604 ## full data states 

605 # completed, this now provides verification for a user 

606 succeeded = enum.auto() 

607 

608 ## no data states 

609 # in progress: waiting for the user to scan the Iris code or open the app 

610 in_progress_waiting_on_user_to_open_app = enum.auto() 

611 # in progress: waiting for the user to scan MRZ or NFC/chip 

612 in_progress_waiting_on_user_in_app = enum.auto() 

613 # in progress, waiting for backend to pull verification data 

614 in_progress_waiting_on_backend = enum.auto() 

615 # failed, no data 

616 failed = enum.auto() 

617 

618 # duplicate, at our end, has data 

619 duplicate = enum.auto() 

620 

621 ## minimal data states 

622 # the data, except minimal deduplication data, was deleted 

623 deleted = enum.auto() 

624 

625 

626class PassportSex(enum.Enum): 

627 """ 

628 We don't care about sex, we use gender on the platform. But passports apparently do. 

629 """ 

630 

631 male = enum.auto() 

632 female = enum.auto() 

633 unspecified = enum.auto() 

634 

635 

636class StrongVerificationAttempt(Base): 

637 """ 

638 An attempt to perform strong verification 

639 """ 

640 

641 __tablename__ = "strong_verification_attempts" 

642 

643 # our verification id 

644 id = Column(BigInteger, primary_key=True) 

645 

646 # this is returned in the callback, and we look up the attempt via this 

647 verification_attempt_token = Column(String, nullable=False, unique=True) 

648 

649 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

650 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

651 

652 status = Column( 

653 Enum(StrongVerificationAttemptStatus), 

654 nullable=False, 

655 default=StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app, 

656 ) 

657 

658 ## full data 

659 has_full_data = Column(Boolean, nullable=False, default=False) 

660 # the data returned from iris, encrypted with a public key whose private key is kept offline 

661 passport_encrypted_data = Column(Binary, nullable=True) 

662 passport_date_of_birth = Column(Date, nullable=True) 

663 passport_sex = Column(Enum(PassportSex), nullable=True) 

664 

665 ## minimal data: this will not be deleted 

666 has_minimal_data = Column(Boolean, nullable=False, default=False) 

667 passport_expiry_date = Column(Date, nullable=True) 

668 passport_nationality = Column(String, nullable=True) 

669 # last three characters of the passport number 

670 passport_last_three_document_chars = Column(String, nullable=True) 

671 

672 iris_token = Column(String, nullable=False, unique=True) 

673 iris_session_id = Column(BigInteger, nullable=False, unique=True) 

674 

675 passport_expiry_datetime = column_property(date_in_timezone(passport_expiry_date, "Etc/UTC")) 

676 

677 user = relationship("User") 

678 

679 @hybrid_property 

680 def is_valid(self): 

681 """ 

682 This only checks whether the attempt is a success and the passport is not expired, use `has_strong_verification` for full check 

683 """ 

684 return (self.status == StrongVerificationAttemptStatus.succeeded) and (self.passport_expiry_datetime >= now()) 

685 

686 @is_valid.expression 

687 def is_valid(cls): 

688 return (cls.status == StrongVerificationAttemptStatus.succeeded) & ( 

689 func.coalesce(cls.passport_expiry_datetime >= func.now(), False) 

690 ) 

691 

692 @hybrid_property 

693 def is_visible(self): 

694 return self.status != StrongVerificationAttemptStatus.deleted 

695 

696 @hybrid_method 

697 def _raw_birthdate_match(self, user): 

698 """Does not check whether the SV attempt itself is not expired""" 

699 return self.passport_date_of_birth == user.birthdate 

700 

701 @hybrid_method 

702 def matches_birthdate(self, user): 

703 return self.is_valid & self._raw_birthdate_match(user) 

704 

705 @hybrid_method 

706 def _raw_gender_match(self, user): 

707 """Does not check whether the SV attempt itself is not expired""" 

708 return ( 

709 ((user.gender == "Woman") & (self.passport_sex == PassportSex.female)) 

710 | ((user.gender == "Man") & (self.passport_sex == PassportSex.male)) 

711 | (self.passport_sex == PassportSex.unspecified) 

712 | (user.has_passport_sex_gender_exception == True) 

713 ) 

714 

715 @hybrid_method 

716 def matches_gender(self, user): 

717 return self.is_valid & self._raw_gender_match(user) 

718 

719 @hybrid_method 

720 def has_strong_verification(self, user): 

721 return self.is_valid & self._raw_birthdate_match(user) & self._raw_gender_match(user) 

722 

723 __table_args__ = ( 

724 # used to look up verification status for a user 

725 Index( 

726 "ix_strong_verification_attempts_current", 

727 user_id, 

728 passport_expiry_date, 

729 postgresql_where=status == StrongVerificationAttemptStatus.succeeded, 

730 ), 

731 # each passport can be verified only once 

732 Index( 

733 "ix_strong_verification_attempts_unique_succeeded", 

734 passport_expiry_date, 

735 passport_nationality, 

736 passport_last_three_document_chars, 

737 unique=True, 

738 postgresql_where=( 

739 (status == StrongVerificationAttemptStatus.succeeded) 

740 | (status == StrongVerificationAttemptStatus.deleted) 

741 ), 

742 ), 

743 # full data check 

744 CheckConstraint( 

745 "(has_full_data IS TRUE AND passport_encrypted_data IS NOT NULL AND passport_date_of_birth IS NOT NULL) OR \ 

746 (has_full_data IS FALSE AND passport_encrypted_data IS NULL AND passport_date_of_birth IS NULL)", 

747 name="full_data_status", 

748 ), 

749 # minimal data check 

750 CheckConstraint( 

751 "(has_minimal_data IS TRUE AND passport_expiry_date IS NOT NULL AND passport_nationality IS NOT NULL AND passport_last_three_document_chars IS NOT NULL) OR \ 

752 (has_minimal_data IS FALSE AND passport_expiry_date IS NULL AND passport_nationality IS NULL AND passport_last_three_document_chars IS NULL)", 

753 name="minimal_data_status", 

754 ), 

755 # note on implications: p => q iff ~p OR q 

756 # full data implies minimal data, has_minimal_data => has_full_data 

757 CheckConstraint( 

758 "(has_full_data IS FALSE) OR (has_minimal_data IS TRUE)", 

759 name="full_data_implies_minimal_data", 

760 ), 

761 # succeeded implies full data 

762 CheckConstraint( 

763 "(NOT (status = 'succeeded')) OR (has_full_data IS TRUE)", 

764 name="succeeded_implies_full_data", 

765 ), 

766 # in_progress/failed implies no_data 

767 CheckConstraint( 

768 "(NOT ((status = 'in_progress_waiting_on_user_to_open_app') OR (status = 'in_progress_waiting_on_user_in_app') OR (status = 'in_progress_waiting_on_backend') OR (status = 'failed'))) OR (has_minimal_data IS FALSE)", 

769 name="in_progress_failed_iris_implies_no_data", 

770 ), 

771 # deleted or duplicate implies minimal data 

772 CheckConstraint( 

773 "(NOT ((status = 'deleted') OR (status = 'duplicate'))) OR (has_minimal_data IS TRUE)", 

774 name="deleted_duplicate_implies_minimal_data", 

775 ), 

776 ) 

777 

778 

779class ModNote(Base): 

780 """ 

781 A moderator note to a user. This could be a warning, just a note "hey, we did X", or any other similar message. 

782 

783 The user has to read and "acknowledge" the note before continuing onto the platform, and being un-jailed. 

784 """ 

785 

786 __tablename__ = "mod_notes" 

787 id = Column(BigInteger, primary_key=True) 

788 

789 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

790 

791 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

792 acknowledged = Column(DateTime(timezone=True), nullable=True) 

793 

794 # this is an internal ID to allow the mods to track different types of notes 

795 internal_id = Column(String, nullable=False) 

796 # the admin that left this note 

797 creator_user_id = Column(ForeignKey("users.id"), nullable=False) 

798 

799 note_content = Column(String, nullable=False) # CommonMark without images 

800 

801 user = relationship("User", backref=backref("mod_notes", lazy="dynamic"), foreign_keys="ModNote.user_id") 

802 

803 def __repr__(self): 

804 return f"ModeNote(id={self.id}, user={self.user}, created={self.created}, ack'd={self.acknowledged})" 

805 

806 @hybrid_property 

807 def is_pending(self): 

808 return self.acknowledged == None 

809 

810 __table_args__ = ( 

811 # used to look up pending notes 

812 Index( 

813 "ix_mod_notes_unacknowledged", 

814 user_id, 

815 postgresql_where=acknowledged == None, 

816 ), 

817 ) 

818 

819 

820class StrongVerificationCallbackEvent(Base): 

821 __tablename__ = "strong_verification_callback_events" 

822 

823 id = Column(BigInteger, primary_key=True) 

824 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

825 

826 verification_attempt_id = Column(ForeignKey("strong_verification_attempts.id"), nullable=False, index=True) 

827 

828 iris_status = Column(String, nullable=False) 

829 

830 

831class DonationType(enum.Enum): 

832 one_time = enum.auto() 

833 recurring = enum.auto() 

834 

835 

836class DonationInitiation(Base): 

837 """ 

838 Whenever someone initiaties a donation through the platform 

839 """ 

840 

841 __tablename__ = "donation_initiations" 

842 id = Column(BigInteger, primary_key=True) 

843 

844 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

845 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

846 

847 amount = Column(Integer, nullable=False) 

848 stripe_checkout_session_id = Column(String, nullable=False) 

849 

850 donation_type = Column(Enum(DonationType), nullable=False) 

851 source = Column(String, nullable=True) 

852 

853 user = relationship("User", backref="donation_initiations") 

854 

855 

856class Invoice(Base): 

857 """ 

858 Successful donations, both one off and recurring 

859 

860 Triggered by `payment_intent.succeeded` webhook 

861 """ 

862 

863 __tablename__ = "invoices" 

864 

865 id = Column(BigInteger, primary_key=True) 

866 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

867 user_id = Column(ForeignKey("users.id"), nullable=False) 

868 

869 amount = Column(Float, nullable=False) 

870 

871 stripe_payment_intent_id = Column(String, nullable=False, unique=True) 

872 stripe_receipt_url = Column(String, nullable=False) 

873 

874 user = relationship("User", backref="invoices") 

875 

876 

877class LanguageFluency(enum.Enum): 

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

879 beginner = 1 

880 conversational = 2 

881 fluent = 3 

882 

883 

884class LanguageAbility(Base): 

885 __tablename__ = "language_abilities" 

886 __table_args__ = ( 

887 # Users can only have one language ability per language 

888 UniqueConstraint("user_id", "language_code"), 

889 ) 

890 

891 id = Column(BigInteger, primary_key=True) 

892 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

893 language_code = Column(ForeignKey("languages.code", deferrable=True), nullable=False) 

894 fluency = Column(Enum(LanguageFluency), nullable=False) 

895 

896 user = relationship("User", backref="language_abilities") 

897 language = relationship("Language") 

898 

899 

900class RegionVisited(Base): 

901 __tablename__ = "regions_visited" 

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

903 

904 id = Column(BigInteger, primary_key=True) 

905 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

906 region_code = Column(ForeignKey("regions.code", deferrable=True), nullable=False) 

907 

908 

909class RegionLived(Base): 

910 __tablename__ = "regions_lived" 

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

912 

913 id = Column(BigInteger, primary_key=True) 

914 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

915 region_code = Column(ForeignKey("regions.code", deferrable=True), nullable=False) 

916 

917 

918class FriendStatus(enum.Enum): 

919 pending = enum.auto() 

920 accepted = enum.auto() 

921 rejected = enum.auto() 

922 cancelled = enum.auto() 

923 

924 

925class FriendRelationship(Base): 

926 """ 

927 Friendship relations between users 

928 

929 TODO: make this better with sqlalchemy self-referential stuff 

930 TODO: constraint on only one row per user pair where accepted or pending 

931 """ 

932 

933 __tablename__ = "friend_relationships" 

934 

935 id = Column(BigInteger, primary_key=True) 

936 

937 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

938 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

939 

940 status = Column(Enum(FriendStatus), nullable=False, default=FriendStatus.pending) 

941 

942 # timezones should always be UTC 

943 time_sent = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

944 time_responded = Column(DateTime(timezone=True), nullable=True) 

945 

946 from_user = relationship("User", backref="friends_from", foreign_keys="FriendRelationship.from_user_id") 

947 to_user = relationship("User", backref="friends_to", foreign_keys="FriendRelationship.to_user_id") 

948 

949 __table_args__ = ( 

950 # Ping looks up pending friend reqs, this speeds that up 

951 Index( 

952 "ix_friend_relationships_status_to_from", 

953 status, 

954 to_user_id, 

955 from_user_id, 

956 ), 

957 ) 

958 

959 

960class ContributeOption(enum.Enum): 

961 yes = enum.auto() 

962 maybe = enum.auto() 

963 no = enum.auto() 

964 

965 

966class ContributorForm(Base): 

967 """ 

968 Someone filled in the contributor form 

969 """ 

970 

971 __tablename__ = "contributor_forms" 

972 

973 id = Column(BigInteger, primary_key=True) 

974 

975 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

976 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

977 

978 ideas = Column(String, nullable=True) 

979 features = Column(String, nullable=True) 

980 experience = Column(String, nullable=True) 

981 contribute = Column(Enum(ContributeOption), nullable=True) 

982 contribute_ways = Column(ARRAY(String), nullable=False) 

983 expertise = Column(String, nullable=True) 

984 

985 user = relationship("User", backref="contributor_forms") 

986 

987 @hybrid_property 

988 def is_filled(self): 

989 """ 

990 Whether the form counts as having been filled 

991 """ 

992 return ( 

993 (self.ideas != None) 

994 | (self.features != None) 

995 | (self.experience != None) 

996 | (self.contribute != None) 

997 | (self.contribute_ways != []) 

998 | (self.expertise != None) 

999 ) 

1000 

1001 @property 

1002 def should_notify(self): 

1003 """ 

1004 If this evaluates to true, we send an email to the recruitment team. 

1005 

1006 We currently send if expertise is listed, or if they list a way to help outside of a set list 

1007 """ 

1008 return False 

1009 

1010 

1011class SignupFlow(Base): 

1012 """ 

1013 Signup flows/incomplete users 

1014 

1015 Coinciding fields have the same meaning as in User 

1016 """ 

1017 

1018 __tablename__ = "signup_flows" 

1019 

1020 id = Column(BigInteger, primary_key=True) 

1021 

1022 # housekeeping 

1023 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1024 flow_token = Column(String, nullable=False, unique=True) 

1025 email_verified = Column(Boolean, nullable=False, default=False) 

1026 email_sent = Column(Boolean, nullable=False, default=False) 

1027 email_token = Column(String, nullable=True) 

1028 email_token_expiry = Column(DateTime(timezone=True), nullable=True) 

1029 

1030 ## Basic 

1031 name = Column(String, nullable=False) 

1032 # TODO: unique across both tables 

1033 email = Column(String, nullable=False, unique=True) 

1034 # TODO: invitation, attribution 

1035 

1036 ## Account 

1037 # TODO: unique across both tables 

1038 username = Column(String, nullable=True, unique=True) 

1039 hashed_password = Column(Binary, nullable=True) 

1040 birthdate = Column(Date, nullable=True) # in the timezone of birthplace 

1041 gender = Column(String, nullable=True) 

1042 hosting_status = Column(Enum(HostingStatus), nullable=True) 

1043 city = Column(String, nullable=True) 

1044 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True) 

1045 geom_radius = Column(Float, nullable=True) 

1046 

1047 accepted_tos = Column(Integer, nullable=True) 

1048 accepted_community_guidelines = Column(Integer, nullable=False, server_default="0") 

1049 

1050 opt_out_of_newsletter = Column(Boolean, nullable=True) 

1051 

1052 ## Feedback (now unused) 

1053 filled_feedback = Column(Boolean, nullable=False, default=False) 

1054 ideas = Column(String, nullable=True) 

1055 features = Column(String, nullable=True) 

1056 experience = Column(String, nullable=True) 

1057 contribute = Column(Enum(ContributeOption), nullable=True) 

1058 contribute_ways = Column(ARRAY(String), nullable=True) 

1059 expertise = Column(String, nullable=True) 

1060 

1061 invite_code_id = Column(ForeignKey("invite_codes.id"), nullable=True) 

1062 

1063 @hybrid_property 

1064 def token_is_valid(self): 

1065 return (self.email_token != None) & (self.email_token_expiry >= now()) 

1066 

1067 @hybrid_property 

1068 def account_is_filled(self): 

1069 return ( 

1070 (self.username != None) 

1071 & (self.birthdate != None) 

1072 & (self.gender != None) 

1073 & (self.hosting_status != None) 

1074 & (self.city != None) 

1075 & (self.geom != None) 

1076 & (self.geom_radius != None) 

1077 & (self.accepted_tos != None) 

1078 & (self.opt_out_of_newsletter != None) 

1079 ) 

1080 

1081 @hybrid_property 

1082 def is_completed(self): 

1083 return self.email_verified & self.account_is_filled & (self.accepted_community_guidelines == GUIDELINES_VERSION) 

1084 

1085 

1086class LoginToken(Base): 

1087 """ 

1088 A login token sent in an email to a user, allows them to sign in between the times defined by created and expiry 

1089 """ 

1090 

1091 __tablename__ = "login_tokens" 

1092 token = Column(String, primary_key=True) 

1093 

1094 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1095 

1096 # timezones should always be UTC 

1097 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1098 expiry = Column(DateTime(timezone=True), nullable=False) 

1099 

1100 user = relationship("User", backref="login_tokens") 

1101 

1102 @hybrid_property 

1103 def is_valid(self): 

1104 return (self.created <= now()) & (self.expiry >= now()) 

1105 

1106 def __repr__(self): 

1107 return f"LoginToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})" 

1108 

1109 

1110class PasswordResetToken(Base): 

1111 __tablename__ = "password_reset_tokens" 

1112 token = Column(String, primary_key=True) 

1113 

1114 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1115 

1116 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1117 expiry = Column(DateTime(timezone=True), nullable=False) 

1118 

1119 user = relationship("User", backref="password_reset_tokens") 

1120 

1121 @hybrid_property 

1122 def is_valid(self): 

1123 return (self.created <= now()) & (self.expiry >= now()) 

1124 

1125 def __repr__(self): 

1126 return f"PasswordResetToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})" 

1127 

1128 

1129class AccountDeletionToken(Base): 

1130 __tablename__ = "account_deletion_tokens" 

1131 

1132 token = Column(String, primary_key=True) 

1133 

1134 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1135 

1136 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1137 expiry = Column(DateTime(timezone=True), nullable=False) 

1138 

1139 user = relationship("User", backref="account_deletion_tokens") 

1140 

1141 @hybrid_property 

1142 def is_valid(self): 

1143 return (self.created <= now()) & (self.expiry >= now()) 

1144 

1145 def __repr__(self): 

1146 return f"AccountDeletionToken(token={self.token}, user_id={self.user_id}, created={self.created}, expiry={self.expiry})" 

1147 

1148 

1149class UserActivity(Base): 

1150 """ 

1151 User activity: for each unique (user_id, period, ip_address, user_agent) tuple, keep track of number of api calls 

1152 

1153 Used for user "last active" as well as admin stuff 

1154 """ 

1155 

1156 __tablename__ = "user_activity" 

1157 

1158 id = Column(BigInteger, primary_key=True) 

1159 

1160 user_id = Column(ForeignKey("users.id"), nullable=False) 

1161 # the start of a period of time, e.g. 1 hour during which we bin activeness 

1162 period = Column(DateTime(timezone=True), nullable=False) 

1163 

1164 # details of the browser, if available 

1165 ip_address = Column(INET, nullable=True) 

1166 user_agent = Column(String, nullable=True) 

1167 

1168 # count of api calls made with this ip, user_agent, and period 

1169 api_calls = Column(Integer, nullable=False, default=0) 

1170 

1171 __table_args__ = ( 

1172 # helps look up this tuple quickly 

1173 Index( 

1174 "ix_user_activity_user_id_period_ip_address_user_agent", 

1175 user_id, 

1176 period, 

1177 ip_address, 

1178 user_agent, 

1179 unique=True, 

1180 ), 

1181 ) 

1182 

1183 

1184class UserSession(Base): 

1185 """ 

1186 API keys/session cookies for the app 

1187 

1188 There are two types of sessions: long-lived, and short-lived. Long-lived are 

1189 like when you choose "remember this browser": they will be valid for a long 

1190 time without the user interacting with the site. Short-lived sessions on the 

1191 other hand get invalidated quickly if the user does not interact with the 

1192 site. 

1193 

1194 Long-lived tokens are valid from `created` until `expiry`. 

1195 

1196 Short-lived tokens expire after 168 hours (7 days) if they are not used. 

1197 """ 

1198 

1199 __tablename__ = "sessions" 

1200 token = Column(String, primary_key=True) 

1201 

1202 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1203 

1204 # sessions are either "api keys" or "session cookies", otherwise identical 

1205 # an api key is put in the authorization header (e.g. "authorization: Bearer <token>") 

1206 # a session cookie is set in the "couchers-sesh" cookie (e.g. "cookie: couchers-sesh=<token>") 

1207 # when a session is created, it's fixed as one or the other for security reasons 

1208 # for api keys to be useful, they should be long lived and have a long expiry 

1209 is_api_key = Column(Boolean, nullable=False, server_default=expression.false()) 

1210 

1211 # whether it's a long-lived or short-lived session 

1212 long_lived = Column(Boolean, nullable=False) 

1213 

1214 # the time at which the session was created 

1215 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1216 

1217 # the expiry of the session: the session *cannot* be refreshed past this 

1218 expiry = Column(DateTime(timezone=True), nullable=False, server_default=func.now() + text("interval '730 days'")) 

1219 

1220 # the time at which the token was invalidated, allows users to delete sessions 

1221 deleted = Column(DateTime(timezone=True), nullable=True, default=None) 

1222 

1223 # the last time this session was used 

1224 last_seen = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1225 

1226 # count of api calls made with this token/session (if we're updating last_seen, might as well update this too) 

1227 api_calls = Column(Integer, nullable=False, default=0) 

1228 

1229 # details of the browser, if available 

1230 # these are from the request creating the session, not used for anything else 

1231 ip_address = Column(String, nullable=True) 

1232 user_agent = Column(String, nullable=True) 

1233 

1234 user = relationship("User", backref="sessions") 

1235 

1236 @hybrid_property 

1237 def is_valid(self): 

1238 """ 

1239 It must have been created and not be expired or deleted. 

1240 

1241 Also, if it's a short lived token, it must have been used in the last 168 hours. 

1242 

1243 TODO: this probably won't run in python (instance level), only in sql (class level) 

1244 """ 

1245 return ( 

1246 (self.created <= func.now()) 

1247 & (self.expiry >= func.now()) 

1248 & (self.deleted == None) 

1249 & (self.long_lived | (func.now() - self.last_seen < text("interval '168 hours'"))) 

1250 ) 

1251 

1252 __table_args__ = ( 

1253 Index( 

1254 "ix_sessions_by_token", 

1255 "token", 

1256 postgresql_using="hash", 

1257 ), 

1258 ) 

1259 

1260 

1261class Conversation(Base): 

1262 """ 

1263 Conversation brings together the different types of message/conversation types 

1264 """ 

1265 

1266 __tablename__ = "conversations" 

1267 

1268 id = Column(BigInteger, primary_key=True) 

1269 # timezone should always be UTC 

1270 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1271 

1272 def __repr__(self): 

1273 return f"Conversation(id={self.id}, created={self.created})" 

1274 

1275 

1276class GroupChat(Base): 

1277 """ 

1278 Group chat 

1279 """ 

1280 

1281 __tablename__ = "group_chats" 

1282 

1283 conversation_id = Column("id", ForeignKey("conversations.id"), nullable=False, primary_key=True) 

1284 

1285 title = Column(String, nullable=True) 

1286 only_admins_invite = Column(Boolean, nullable=False, default=True) 

1287 creator_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1288 is_dm = Column(Boolean, nullable=False) 

1289 

1290 conversation = relationship("Conversation", backref="group_chat") 

1291 creator = relationship("User", backref="created_group_chats") 

1292 

1293 def __repr__(self): 

1294 return f"GroupChat(conversation={self.conversation}, title={self.title or 'None'}, only_admins_invite={self.only_admins_invite}, creator={self.creator}, is_dm={self.is_dm})" 

1295 

1296 

1297class GroupChatRole(enum.Enum): 

1298 admin = enum.auto() 

1299 participant = enum.auto() 

1300 

1301 

1302class GroupChatSubscription(Base): 

1303 """ 

1304 The recipient of a thread and information about when they joined/left/etc. 

1305 """ 

1306 

1307 __tablename__ = "group_chat_subscriptions" 

1308 id = Column(BigInteger, primary_key=True) 

1309 

1310 # TODO: DB constraint on only one user+group_chat combo at a given time 

1311 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1312 group_chat_id = Column(ForeignKey("group_chats.id"), nullable=False, index=True) 

1313 

1314 # timezones should always be UTC 

1315 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1316 left = Column(DateTime(timezone=True), nullable=True) 

1317 

1318 role = Column(Enum(GroupChatRole), nullable=False) 

1319 

1320 last_seen_message_id = Column(BigInteger, nullable=False, default=0) 

1321 

1322 # when this chat is muted until, DATETIME_INFINITY for "forever" 

1323 muted_until = Column(DateTime(timezone=True), nullable=False, server_default=DATETIME_MINUS_INFINITY.isoformat()) 

1324 

1325 user = relationship("User", backref="group_chat_subscriptions") 

1326 group_chat = relationship("GroupChat", backref=backref("subscriptions", lazy="dynamic")) 

1327 

1328 def muted_display(self): 

1329 """ 

1330 Returns (muted, muted_until) display values: 

1331 1. If not muted, returns (False, None) 

1332 2. If muted forever, returns (True, None) 

1333 3. If muted until a given datetime returns (True, dt) 

1334 """ 

1335 if self.muted_until < now(): 

1336 return (False, None) 

1337 elif self.muted_until == DATETIME_INFINITY: 

1338 return (True, None) 

1339 else: 

1340 return (True, self.muted_until) 

1341 

1342 @hybrid_property 

1343 def is_muted(self): 

1344 return self.muted_until > func.now() 

1345 

1346 def __repr__(self): 

1347 return f"GroupChatSubscription(id={self.id}, user={self.user}, joined={self.joined}, left={self.left}, role={self.role}, group_chat={self.group_chat})" 

1348 

1349 

1350class InviteCode(Base): 

1351 __tablename__ = "invite_codes" 

1352 

1353 id = Column(String, primary_key=True) 

1354 creator_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) 

1355 created = Column(DateTime(timezone=True), nullable=False, default=func.now()) 

1356 disabled = Column(DateTime(timezone=True), nullable=True) 

1357 

1358 creator = relationship("User", foreign_keys=[creator_user_id]) 

1359 

1360 

1361class MessageType(enum.Enum): 

1362 text = enum.auto() 

1363 # e.g. 

1364 # image = 

1365 # emoji = 

1366 # ... 

1367 chat_created = enum.auto() 

1368 chat_edited = enum.auto() 

1369 user_invited = enum.auto() 

1370 user_left = enum.auto() 

1371 user_made_admin = enum.auto() 

1372 user_removed_admin = enum.auto() # RemoveGroupChatAdmin: remove admin permission from a user in group chat 

1373 host_request_status_changed = enum.auto() 

1374 user_removed = enum.auto() # user is removed from group chat by amdin RemoveGroupChatUser 

1375 

1376 

1377class HostRequestStatus(enum.Enum): 

1378 pending = enum.auto() 

1379 accepted = enum.auto() 

1380 rejected = enum.auto() 

1381 confirmed = enum.auto() 

1382 cancelled = enum.auto() 

1383 

1384 

1385class Message(Base): 

1386 """ 

1387 A message. 

1388 

1389 If message_type = text, then the message is a normal text message, otherwise, it's a special control message. 

1390 """ 

1391 

1392 __tablename__ = "messages" 

1393 

1394 id = Column(BigInteger, primary_key=True) 

1395 

1396 # which conversation the message belongs in 

1397 conversation_id = Column(ForeignKey("conversations.id"), nullable=False, index=True) 

1398 

1399 # the user that sent the message/command 

1400 author_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1401 

1402 # the message type, "text" is a text message, otherwise a "control message" 

1403 message_type = Column(Enum(MessageType), nullable=False) 

1404 

1405 # the target if a control message and requires target, e.g. if inviting a user, the user invited is the target 

1406 target_id = Column(ForeignKey("users.id"), nullable=True, index=True) 

1407 

1408 # time sent, timezone should always be UTC 

1409 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1410 

1411 # the plain-text message text if not control 

1412 text = Column(String, nullable=True) 

1413 

1414 # the new host request status if the message type is host_request_status_changed 

1415 host_request_status_target = Column(Enum(HostRequestStatus), nullable=True) 

1416 

1417 conversation = relationship("Conversation", backref="messages", order_by="Message.time.desc()") 

1418 author = relationship("User", foreign_keys="Message.author_id") 

1419 target = relationship("User", foreign_keys="Message.target_id") 

1420 

1421 @property 

1422 def is_normal_message(self): 

1423 """ 

1424 There's only one normal type atm, text 

1425 """ 

1426 return self.message_type == MessageType.text 

1427 

1428 def __repr__(self): 

1429 return f"Message(id={self.id}, time={self.time}, text={self.text}, author={self.author}, conversation={self.conversation})" 

1430 

1431 

1432class ContentReport(Base): 

1433 """ 

1434 A piece of content reported to admins 

1435 """ 

1436 

1437 __tablename__ = "content_reports" 

1438 

1439 id = Column(BigInteger, primary_key=True) 

1440 

1441 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1442 

1443 # the user who reported or flagged the content 

1444 reporting_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1445 

1446 # reason, e.g. spam, inappropriate, etc 

1447 reason = Column(String, nullable=False) 

1448 # a short description 

1449 description = Column(String, nullable=False) 

1450 

1451 # a reference to the content, see //docs/content_ref.md 

1452 content_ref = Column(String, nullable=False) 

1453 # the author of the content (e.g. the user who wrote the comment itself) 

1454 author_user_id = Column(ForeignKey("users.id"), nullable=False) 

1455 

1456 # details of the browser, if available 

1457 user_agent = Column(String, nullable=False) 

1458 # the URL the user was on when reporting the content 

1459 page = Column(String, nullable=False) 

1460 

1461 # see comments above for reporting vs author 

1462 reporting_user = relationship("User", foreign_keys="ContentReport.reporting_user_id") 

1463 author_user = relationship("User", foreign_keys="ContentReport.author_user_id") 

1464 

1465 

1466class Email(Base): 

1467 """ 

1468 Table of all dispatched emails for debugging purposes, etc. 

1469 """ 

1470 

1471 __tablename__ = "emails" 

1472 

1473 id = Column(String, primary_key=True) 

1474 

1475 # timezone should always be UTC 

1476 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1477 

1478 sender_name = Column(String, nullable=False) 

1479 sender_email = Column(String, nullable=False) 

1480 

1481 recipient = Column(String, nullable=False) 

1482 subject = Column(String, nullable=False) 

1483 

1484 plain = Column(String, nullable=False) 

1485 html = Column(String, nullable=False) 

1486 

1487 list_unsubscribe_header = Column(String, nullable=True) 

1488 source_data = Column(String, nullable=True) 

1489 

1490 

1491class SMS(Base): 

1492 """ 

1493 Table of all sent SMSs for debugging purposes, etc. 

1494 """ 

1495 

1496 __tablename__ = "smss" 

1497 

1498 id = Column(BigInteger, primary_key=True) 

1499 

1500 # timezone should always be UTC 

1501 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1502 # AWS message id 

1503 message_id = Column(String, nullable=False) 

1504 

1505 # the SMS sender ID sent to AWS, name that the SMS appears to come from 

1506 sms_sender_id = Column(String, nullable=False) 

1507 number = Column(String, nullable=False) 

1508 message = Column(String, nullable=False) 

1509 

1510 

1511class HostRequest(Base): 

1512 """ 

1513 A request to stay with a host 

1514 """ 

1515 

1516 __tablename__ = "host_requests" 

1517 

1518 conversation_id = Column("id", ForeignKey("conversations.id"), nullable=False, primary_key=True) 

1519 surfer_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1520 host_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1521 

1522 hosting_city = Column(String, nullable=False) 

1523 hosting_location = Column(Geometry("POINT", srid=4326), nullable=False) 

1524 hosting_radius = Column(Float, nullable=False) 

1525 

1526 # TODO: proper timezone handling 

1527 timezone = "Etc/UTC" 

1528 

1529 # dates in the timezone above 

1530 from_date = Column(Date, nullable=False) 

1531 to_date = Column(Date, nullable=False) 

1532 

1533 # timezone aware start and end times of the request, can be compared to now() 

1534 start_time = column_property(date_in_timezone(from_date, timezone)) 

1535 end_time = column_property(date_in_timezone(to_date, timezone) + text("interval '1 days'")) 

1536 start_time_to_write_reference = column_property(date_in_timezone(to_date, timezone)) 

1537 # notice 1 day for midnight at the *end of the day*, then 14 days to write a ref 

1538 end_time_to_write_reference = column_property(date_in_timezone(to_date, timezone) + text("interval '15 days'")) 

1539 

1540 status = Column(Enum(HostRequestStatus), nullable=False) 

1541 is_host_archived = Column(Boolean, nullable=False, default=False, server_default=expression.false()) 

1542 is_surfer_archived = Column(Boolean, nullable=False, default=False, server_default=expression.false()) 

1543 

1544 host_last_seen_message_id = Column(BigInteger, nullable=False, default=0) 

1545 surfer_last_seen_message_id = Column(BigInteger, nullable=False, default=0) 

1546 

1547 # number of reference reminders sent out 

1548 host_sent_reference_reminders = Column(BigInteger, nullable=False, server_default=text("0")) 

1549 surfer_sent_reference_reminders = Column(BigInteger, nullable=False, server_default=text("0")) 

1550 host_sent_request_reminders = Column(BigInteger, nullable=False, server_default=text("0")) 

1551 last_sent_request_reminder_time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1552 

1553 # reason why the host/surfer marked that they didn't meet up 

1554 # if null then they haven't marked it such 

1555 host_reason_didnt_meetup = Column(String, nullable=True) 

1556 surfer_reason_didnt_meetup = Column(String, nullable=True) 

1557 

1558 surfer = relationship("User", backref="host_requests_sent", foreign_keys="HostRequest.surfer_user_id") 

1559 host = relationship("User", backref="host_requests_received", foreign_keys="HostRequest.host_user_id") 

1560 conversation = relationship("Conversation") 

1561 

1562 __table_args__ = ( 

1563 # allows fast lookup as to whether they didn't meet up 

1564 Index( 

1565 "ix_host_requests_host_didnt_meetup", 

1566 host_reason_didnt_meetup != None, 

1567 ), 

1568 Index( 

1569 "ix_host_requests_surfer_didnt_meetup", 

1570 surfer_reason_didnt_meetup != None, 

1571 ), 

1572 # Used for figuring out who needs a reminder to respond 

1573 Index( 

1574 "ix_host_requests_status_reminder_counts", 

1575 status, 

1576 host_sent_request_reminders, 

1577 last_sent_request_reminder_time, 

1578 from_date, 

1579 ), 

1580 ) 

1581 

1582 @hybrid_property 

1583 def can_write_reference(self): 

1584 return ( 

1585 ((self.status == HostRequestStatus.confirmed) | (self.status == HostRequestStatus.accepted)) 

1586 & (now() >= self.start_time_to_write_reference) 

1587 & (now() <= self.end_time_to_write_reference) 

1588 ) 

1589 

1590 @can_write_reference.expression 

1591 def can_write_reference(cls): 

1592 return ( 

1593 ((cls.status == HostRequestStatus.confirmed) | (cls.status == HostRequestStatus.accepted)) 

1594 & (func.now() >= cls.start_time_to_write_reference) 

1595 & (func.now() <= cls.end_time_to_write_reference) 

1596 ) 

1597 

1598 def __repr__(self): 

1599 return f"HostRequest(id={self.conversation_id}, surfer_user_id={self.surfer_user_id}, host_user_id={self.host_user_id}...)" 

1600 

1601 

1602class HostRequestQuality(enum.Enum): 

1603 high_quality = enum.auto() 

1604 okay_quality = enum.auto() 

1605 low_quality = enum.auto() 

1606 

1607 

1608class HostRequestFeedback(Base): 

1609 """ 

1610 Private feedback from host about a host request 

1611 """ 

1612 

1613 __tablename__ = "host_request_feedbacks" 

1614 

1615 id = Column(BigInteger, primary_key=True) 

1616 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1617 host_request_id = Column(ForeignKey("host_requests.id"), nullable=False) 

1618 

1619 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1620 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1621 

1622 request_quality = Column(Enum(HostRequestQuality), nullable=True) 

1623 decline_reason = Column(String, nullable=True) # plain text 

1624 

1625 host_request = relationship("HostRequest") 

1626 

1627 __table_args__ = ( 

1628 # Each user can leave at most one friend reference to another user 

1629 Index( 

1630 "ix_unique_host_req_feedback", 

1631 from_user_id, 

1632 to_user_id, 

1633 host_request_id, 

1634 unique=True, 

1635 ), 

1636 ) 

1637 

1638 

1639class ReferenceType(enum.Enum): 

1640 friend = enum.auto() 

1641 surfed = enum.auto() # The "from" user surfed with the "to" user 

1642 hosted = enum.auto() # The "from" user hosted the "to" user 

1643 

1644 

1645class Reference(Base): 

1646 """ 

1647 Reference from one user to another 

1648 """ 

1649 

1650 __tablename__ = "references" 

1651 

1652 id = Column(BigInteger, primary_key=True) 

1653 # timezone should always be UTC 

1654 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1655 

1656 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1657 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1658 

1659 reference_type = Column(Enum(ReferenceType), nullable=False) 

1660 

1661 host_request_id = Column(ForeignKey("host_requests.id"), nullable=True) 

1662 

1663 text = Column(String, nullable=False) # plain text 

1664 # text that's only visible to mods 

1665 private_text = Column(String, nullable=True) # plain text 

1666 

1667 rating = Column(Float, nullable=False) 

1668 was_appropriate = Column(Boolean, nullable=False) 

1669 

1670 is_deleted = Column(Boolean, nullable=False, default=False, server_default=expression.false()) 

1671 

1672 from_user = relationship("User", backref="references_from", foreign_keys="Reference.from_user_id") 

1673 to_user = relationship("User", backref="references_to", foreign_keys="Reference.to_user_id") 

1674 

1675 host_request = relationship("HostRequest", backref="references") 

1676 

1677 __table_args__ = ( 

1678 # Rating must be between 0 and 1, inclusive 

1679 CheckConstraint( 

1680 "rating BETWEEN 0 AND 1", 

1681 name="rating_between_0_and_1", 

1682 ), 

1683 # Has host_request_id or it's a friend reference 

1684 CheckConstraint( 

1685 "(host_request_id IS NOT NULL) <> (reference_type = 'friend')", 

1686 name="host_request_id_xor_friend_reference", 

1687 ), 

1688 # Each user can leave at most one friend reference to another user 

1689 Index( 

1690 "ix_references_unique_friend_reference", 

1691 from_user_id, 

1692 to_user_id, 

1693 reference_type, 

1694 unique=True, 

1695 postgresql_where=(reference_type == ReferenceType.friend), 

1696 ), 

1697 # Each user can leave at most one reference to another user for each stay 

1698 Index( 

1699 "ix_references_unique_per_host_request", 

1700 from_user_id, 

1701 to_user_id, 

1702 host_request_id, 

1703 unique=True, 

1704 postgresql_where=(host_request_id != None), 

1705 ), 

1706 ) 

1707 

1708 @property 

1709 def should_report(self): 

1710 """ 

1711 If this evaluates to true, we send a report to the moderation team. 

1712 """ 

1713 return self.rating <= 0.4 or not self.was_appropriate or self.private_text 

1714 

1715 

1716class InitiatedUpload(Base): 

1717 """ 

1718 Started downloads, not necessarily complete yet. 

1719 """ 

1720 

1721 __tablename__ = "initiated_uploads" 

1722 

1723 key = Column(String, primary_key=True) 

1724 

1725 # timezones should always be UTC 

1726 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1727 expiry = Column(DateTime(timezone=True), nullable=False) 

1728 

1729 initiator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1730 

1731 initiator_user = relationship("User") 

1732 

1733 @hybrid_property 

1734 def is_valid(self): 

1735 return (self.created <= func.now()) & (self.expiry >= func.now()) 

1736 

1737 

1738class Upload(Base): 

1739 """ 

1740 Completed uploads. 

1741 """ 

1742 

1743 __tablename__ = "uploads" 

1744 key = Column(String, primary_key=True) 

1745 

1746 filename = Column(String, nullable=False) 

1747 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1748 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1749 

1750 # photo credit, etc 

1751 credit = Column(String, nullable=True) 

1752 

1753 creator_user = relationship("User", backref="uploads", foreign_keys="Upload.creator_user_id") 

1754 

1755 def _url(self, size): 

1756 return urls.media_url(filename=self.filename, size=size) 

1757 

1758 @property 

1759 def thumbnail_url(self): 

1760 return self._url("thumbnail") 

1761 

1762 @property 

1763 def full_url(self): 

1764 return self._url("full") 

1765 

1766 

1767communities_seq = Sequence("communities_seq") 

1768 

1769 

1770class Node(Base): 

1771 """ 

1772 Node, i.e. geographical subdivision of the world 

1773 

1774 Administered by the official cluster 

1775 """ 

1776 

1777 __tablename__ = "nodes" 

1778 

1779 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()) 

1780 

1781 # name and description come from official cluster 

1782 parent_node_id = Column(ForeignKey("nodes.id"), nullable=True, index=True) 

1783 geom = deferred(Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False)) 

1784 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1785 

1786 parent_node = relationship("Node", backref="child_nodes", remote_side="Node.id") 

1787 

1788 contained_users = relationship( 

1789 "User", 

1790 primaryjoin="func.ST_Contains(foreign(Node.geom), User.geom).as_comparison(1, 2)", 

1791 viewonly=True, 

1792 uselist=True, 

1793 ) 

1794 

1795 contained_user_ids = association_proxy("contained_users", "id") 

1796 

1797 

1798class Cluster(Base): 

1799 """ 

1800 Cluster, administered grouping of content 

1801 """ 

1802 

1803 __tablename__ = "clusters" 

1804 

1805 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()) 

1806 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True) 

1807 name = Column(String, nullable=False) 

1808 # short description 

1809 description = Column(String, nullable=False) 

1810 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

1811 

1812 is_official_cluster = Column(Boolean, nullable=False, default=False) 

1813 

1814 discussions_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true()) 

1815 events_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true()) 

1816 

1817 slug = column_property(func.slugify(name)) 

1818 

1819 official_cluster_for_node = relationship( 

1820 "Node", 

1821 primaryjoin="and_(Cluster.parent_node_id == Node.id, Cluster.is_official_cluster)", 

1822 backref=backref("official_cluster", uselist=False), 

1823 uselist=False, 

1824 viewonly=True, 

1825 ) 

1826 

1827 parent_node = relationship( 

1828 "Node", backref="child_clusters", remote_side="Node.id", foreign_keys="Cluster.parent_node_id" 

1829 ) 

1830 

1831 nodes = relationship("Cluster", backref="clusters", secondary="node_cluster_associations", viewonly=True) 

1832 # all pages 

1833 pages = relationship( 

1834 "Page", backref="clusters", secondary="cluster_page_associations", lazy="dynamic", viewonly=True 

1835 ) 

1836 events = relationship("Event", backref="clusters", secondary="cluster_event_associations", viewonly=True) 

1837 discussions = relationship( 

1838 "Discussion", backref="clusters", secondary="cluster_discussion_associations", viewonly=True 

1839 ) 

1840 

1841 # includes also admins 

1842 members = relationship( 

1843 "User", 

1844 lazy="dynamic", 

1845 backref="cluster_memberships", 

1846 secondary="cluster_subscriptions", 

1847 primaryjoin="Cluster.id == ClusterSubscription.cluster_id", 

1848 secondaryjoin="User.id == ClusterSubscription.user_id", 

1849 viewonly=True, 

1850 ) 

1851 

1852 admins = relationship( 

1853 "User", 

1854 lazy="dynamic", 

1855 backref="cluster_adminships", 

1856 secondary="cluster_subscriptions", 

1857 primaryjoin="Cluster.id == ClusterSubscription.cluster_id", 

1858 secondaryjoin="and_(User.id == ClusterSubscription.user_id, ClusterSubscription.role == 'admin')", 

1859 viewonly=True, 

1860 ) 

1861 

1862 main_page = relationship( 

1863 "Page", 

1864 primaryjoin="and_(Cluster.id == Page.owner_cluster_id, Page.type == 'main_page')", 

1865 viewonly=True, 

1866 uselist=False, 

1867 ) 

1868 

1869 @property 

1870 def is_leaf(self) -> bool: 

1871 """Whether the cluster is a leaf node in the cluster hierarchy.""" 

1872 return len(self.parent_node.child_nodes) == 0 

1873 

1874 __table_args__ = ( 

1875 # Each node can have at most one official cluster 

1876 Index( 

1877 "ix_clusters_owner_parent_node_id_is_official_cluster", 

1878 parent_node_id, 

1879 is_official_cluster, 

1880 unique=True, 

1881 postgresql_where=is_official_cluster, 

1882 ), 

1883 # trigram index on unaccented name 

1884 # note that the function `unaccent` is not immutable so cannot be used in an index, that's why we wrap it 

1885 Index( 

1886 "idx_clusters_name_unaccented_trgm", 

1887 text("immutable_unaccent(name) gin_trgm_ops"), 

1888 postgresql_using="gin", 

1889 ), 

1890 ) 

1891 

1892 

1893class NodeClusterAssociation(Base): 

1894 """ 

1895 NodeClusterAssociation, grouping of nodes 

1896 """ 

1897 

1898 __tablename__ = "node_cluster_associations" 

1899 __table_args__ = (UniqueConstraint("node_id", "cluster_id"),) 

1900 

1901 id = Column(BigInteger, primary_key=True) 

1902 

1903 node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True) 

1904 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True) 

1905 

1906 node = relationship("Node", backref="node_cluster_associations") 

1907 cluster = relationship("Cluster", backref="node_cluster_associations") 

1908 

1909 

1910class ClusterRole(enum.Enum): 

1911 member = enum.auto() 

1912 admin = enum.auto() 

1913 

1914 

1915class ClusterSubscription(Base): 

1916 """ 

1917 ClusterSubscription of a user 

1918 """ 

1919 

1920 __tablename__ = "cluster_subscriptions" 

1921 

1922 id = Column(BigInteger, primary_key=True) 

1923 

1924 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1925 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True) 

1926 role = Column(Enum(ClusterRole), nullable=False) 

1927 

1928 user = relationship("User", backref="cluster_subscriptions") 

1929 cluster = relationship("Cluster", backref="cluster_subscriptions") 

1930 

1931 __table_args__ = ( 

1932 UniqueConstraint("user_id", "cluster_id"), 

1933 Index( 

1934 "ix_cluster_subscriptions_members", 

1935 cluster_id, 

1936 user_id, 

1937 ), 

1938 # For fast lookup of nodes this user is an admin of 

1939 Index( 

1940 "ix_cluster_subscriptions_admins", 

1941 user_id, 

1942 cluster_id, 

1943 postgresql_where=(role == ClusterRole.admin), 

1944 ), 

1945 ) 

1946 

1947 

1948class ClusterPageAssociation(Base): 

1949 """ 

1950 pages related to clusters 

1951 """ 

1952 

1953 __tablename__ = "cluster_page_associations" 

1954 __table_args__ = (UniqueConstraint("page_id", "cluster_id"),) 

1955 

1956 id = Column(BigInteger, primary_key=True) 

1957 

1958 page_id = Column(ForeignKey("pages.id"), nullable=False, index=True) 

1959 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True) 

1960 

1961 page = relationship("Page", backref="cluster_page_associations") 

1962 cluster = relationship("Cluster", backref="cluster_page_associations") 

1963 

1964 

1965class PageType(enum.Enum): 

1966 main_page = enum.auto() 

1967 place = enum.auto() 

1968 guide = enum.auto() 

1969 

1970 

1971class Page(Base): 

1972 """ 

1973 similar to a wiki page about a community, POI or guide 

1974 """ 

1975 

1976 __tablename__ = "pages" 

1977 

1978 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()) 

1979 

1980 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True) 

1981 type = Column(Enum(PageType), nullable=False) 

1982 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

1983 owner_user_id = Column(ForeignKey("users.id"), nullable=True, index=True) 

1984 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=True, index=True) 

1985 

1986 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True) 

1987 

1988 parent_node = relationship("Node", backref="child_pages", remote_side="Node.id", foreign_keys="Page.parent_node_id") 

1989 

1990 thread = relationship("Thread", backref="page", uselist=False) 

1991 creator_user = relationship("User", backref="created_pages", foreign_keys="Page.creator_user_id") 

1992 owner_user = relationship("User", backref="owned_pages", foreign_keys="Page.owner_user_id") 

1993 owner_cluster = relationship( 

1994 "Cluster", backref=backref("owned_pages", lazy="dynamic"), uselist=False, foreign_keys="Page.owner_cluster_id" 

1995 ) 

1996 

1997 editors = relationship("User", secondary="page_versions", viewonly=True) 

1998 

1999 __table_args__ = ( 

2000 # Only one of owner_user and owner_cluster should be set 

2001 CheckConstraint( 

2002 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)", 

2003 name="one_owner", 

2004 ), 

2005 # Only clusters can own main pages 

2006 CheckConstraint( 

2007 "NOT (owner_cluster_id IS NULL AND type = 'main_page')", 

2008 name="main_page_owned_by_cluster", 

2009 ), 

2010 # Each cluster can have at most one main page 

2011 Index( 

2012 "ix_pages_owner_cluster_id_type", 

2013 owner_cluster_id, 

2014 type, 

2015 unique=True, 

2016 postgresql_where=(type == PageType.main_page), 

2017 ), 

2018 ) 

2019 

2020 def __repr__(self): 

2021 return f"Page({self.id=})" 

2022 

2023 

2024class PageVersion(Base): 

2025 """ 

2026 version of page content 

2027 """ 

2028 

2029 __tablename__ = "page_versions" 

2030 

2031 id = Column(BigInteger, primary_key=True) 

2032 

2033 page_id = Column(ForeignKey("pages.id"), nullable=False, index=True) 

2034 editor_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2035 title = Column(String, nullable=False) 

2036 content = Column(String, nullable=False) # CommonMark without images 

2037 photo_key = Column(ForeignKey("uploads.key"), nullable=True) 

2038 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True) 

2039 # the human-readable address 

2040 address = Column(String, nullable=True) 

2041 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2042 

2043 slug = column_property(func.slugify(title)) 

2044 

2045 page = relationship("Page", backref="versions", order_by="PageVersion.id") 

2046 editor_user = relationship("User", backref="edited_pages") 

2047 photo = relationship("Upload") 

2048 

2049 __table_args__ = ( 

2050 # Geom and address must either both be null or both be set 

2051 CheckConstraint( 

2052 "(geom IS NULL) = (address IS NULL)", 

2053 name="geom_iff_address", 

2054 ), 

2055 ) 

2056 

2057 @property 

2058 def coordinates(self): 

2059 # returns (lat, lng) or None 

2060 return get_coordinates(self.geom) 

2061 

2062 def __repr__(self): 

2063 return f"PageVersion({self.id=}, {self.page_id=})" 

2064 

2065 

2066class ClusterEventAssociation(Base): 

2067 """ 

2068 events related to clusters 

2069 """ 

2070 

2071 __tablename__ = "cluster_event_associations" 

2072 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),) 

2073 

2074 id = Column(BigInteger, primary_key=True) 

2075 

2076 event_id = Column(ForeignKey("events.id"), nullable=False, index=True) 

2077 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True) 

2078 

2079 event = relationship("Event", backref="cluster_event_associations") 

2080 cluster = relationship("Cluster", backref="cluster_event_associations") 

2081 

2082 

2083class Event(Base): 

2084 """ 

2085 An event is compose of two parts: 

2086 

2087 * An event template (Event) 

2088 * An occurrence (EventOccurrence) 

2089 

2090 One-off events will have one of each; repeating events will have one Event, 

2091 multiple EventOccurrences, one for each time the event happens. 

2092 """ 

2093 

2094 __tablename__ = "events" 

2095 

2096 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()) 

2097 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True) 

2098 

2099 title = Column(String, nullable=False) 

2100 

2101 slug = column_property(func.slugify(title)) 

2102 

2103 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2104 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2105 owner_user_id = Column(ForeignKey("users.id"), nullable=True, index=True) 

2106 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=True, index=True) 

2107 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True) 

2108 

2109 parent_node = relationship( 

2110 "Node", backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id" 

2111 ) 

2112 thread = relationship("Thread", backref="event", uselist=False) 

2113 subscribers = relationship( 

2114 "User", backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True 

2115 ) 

2116 organizers = relationship( 

2117 "User", backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True 

2118 ) 

2119 creator_user = relationship("User", backref="created_events", foreign_keys="Event.creator_user_id") 

2120 owner_user = relationship("User", backref="owned_events", foreign_keys="Event.owner_user_id") 

2121 owner_cluster = relationship( 

2122 "Cluster", 

2123 backref=backref("owned_events", lazy="dynamic"), 

2124 uselist=False, 

2125 foreign_keys="Event.owner_cluster_id", 

2126 ) 

2127 

2128 __table_args__ = ( 

2129 # Only one of owner_user and owner_cluster should be set 

2130 CheckConstraint( 

2131 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)", 

2132 name="one_owner", 

2133 ), 

2134 ) 

2135 

2136 

2137class EventOccurrence(Base): 

2138 __tablename__ = "event_occurrences" 

2139 

2140 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()) 

2141 event_id = Column(ForeignKey("events.id"), nullable=False, index=True) 

2142 

2143 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event) 

2144 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2145 content = Column(String, nullable=False) # CommonMark without images 

2146 photo_key = Column(ForeignKey("uploads.key"), nullable=True) 

2147 

2148 is_cancelled = Column(Boolean, nullable=False, default=False, server_default=expression.false()) 

2149 is_deleted = Column(Boolean, nullable=False, default=False, server_default=expression.false()) 

2150 

2151 # a null geom is an online-only event 

2152 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True) 

2153 # physical address, iff geom is not null 

2154 address = Column(String, nullable=True) 

2155 # videoconferencing link, etc, must be specified if no geom, otherwise optional 

2156 link = Column(String, nullable=True) 

2157 

2158 timezone = "Etc/UTC" 

2159 

2160 # time during which the event takes place; this is a range type (instead of separate start+end times) which 

2161 # simplifies database constraints, etc 

2162 during = Column(TSTZRANGE, nullable=False) 

2163 

2164 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2165 last_edited = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2166 

2167 creator_user = relationship( 

2168 "User", backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id" 

2169 ) 

2170 event = relationship( 

2171 "Event", 

2172 backref=backref("occurrences", lazy="dynamic"), 

2173 remote_side="Event.id", 

2174 foreign_keys="EventOccurrence.event_id", 

2175 ) 

2176 

2177 photo = relationship("Upload") 

2178 

2179 __table_args__ = ( 

2180 # Geom and address go together 

2181 CheckConstraint( 

2182 # geom and address are either both null or neither of them are null 

2183 "(geom IS NULL) = (address IS NULL)", 

2184 name="geom_iff_address", 

2185 ), 

2186 # Online-only events need a link, note that online events may also have a link 

2187 CheckConstraint( 

2188 # exactly oen of geom or link is non-null 

2189 "(geom IS NULL) <> (link IS NULL)", 

2190 name="link_or_geom", 

2191 ), 

2192 # Can't have overlapping occurrences in the same Event 

2193 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"), 

2194 ) 

2195 

2196 @property 

2197 def coordinates(self): 

2198 # returns (lat, lng) or None 

2199 return get_coordinates(self.geom) 

2200 

2201 @hybrid_property 

2202 def start_time(self): 

2203 return self.during.lower 

2204 

2205 @start_time.expression 

2206 def start_time(cls): 

2207 return func.lower(cls.during) 

2208 

2209 @hybrid_property 

2210 def end_time(self): 

2211 return self.during.upper 

2212 

2213 @end_time.expression 

2214 def end_time(cls): 

2215 return func.upper(cls.during) 

2216 

2217 

2218class EventSubscription(Base): 

2219 """ 

2220 Users' subscriptions to events 

2221 """ 

2222 

2223 __tablename__ = "event_subscriptions" 

2224 __table_args__ = (UniqueConstraint("event_id", "user_id"),) 

2225 

2226 id = Column(BigInteger, primary_key=True) 

2227 

2228 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2229 event_id = Column(ForeignKey("events.id"), nullable=False, index=True) 

2230 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2231 

2232 user = relationship("User") 

2233 event = relationship("Event") 

2234 

2235 

2236class EventOrganizer(Base): 

2237 """ 

2238 Organizers for events 

2239 """ 

2240 

2241 __tablename__ = "event_organizers" 

2242 __table_args__ = (UniqueConstraint("event_id", "user_id"),) 

2243 

2244 id = Column(BigInteger, primary_key=True) 

2245 

2246 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2247 event_id = Column(ForeignKey("events.id"), nullable=False, index=True) 

2248 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2249 

2250 user = relationship("User") 

2251 event = relationship("Event") 

2252 

2253 

2254class AttendeeStatus(enum.Enum): 

2255 going = enum.auto() 

2256 maybe = enum.auto() 

2257 

2258 

2259class EventOccurrenceAttendee(Base): 

2260 """ 

2261 Attendees for events 

2262 """ 

2263 

2264 __tablename__ = "event_occurrence_attendees" 

2265 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),) 

2266 

2267 id = Column(BigInteger, primary_key=True) 

2268 

2269 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2270 occurrence_id = Column(ForeignKey("event_occurrences.id"), nullable=False, index=True) 

2271 responded = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2272 attendee_status = Column(Enum(AttendeeStatus), nullable=False) 

2273 

2274 user = relationship("User") 

2275 occurrence = relationship("EventOccurrence", backref=backref("attendances", lazy="dynamic")) 

2276 

2277 reminder_sent = Column(Boolean, nullable=False, default=False, server_default=expression.false()) 

2278 

2279 

2280class EventCommunityInviteRequest(Base): 

2281 """ 

2282 Requests to send out invitation notifications/emails to the community for a given event occurrence 

2283 """ 

2284 

2285 __tablename__ = "event_community_invite_requests" 

2286 

2287 id = Column(BigInteger, primary_key=True) 

2288 

2289 occurrence_id = Column(ForeignKey("event_occurrences.id"), nullable=False, index=True) 

2290 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2291 

2292 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2293 

2294 decided = Column(DateTime(timezone=True), nullable=True) 

2295 decided_by_user_id = Column(ForeignKey("users.id"), nullable=True) 

2296 approved = Column(Boolean, nullable=True) 

2297 

2298 occurrence = relationship("EventOccurrence", backref=backref("community_invite_requests", lazy="dynamic")) 

2299 user = relationship("User", foreign_keys="EventCommunityInviteRequest.user_id") 

2300 

2301 __table_args__ = ( 

2302 # each user can only request once 

2303 UniqueConstraint("occurrence_id", "user_id"), 

2304 # each event can only have one notification sent out 

2305 Index( 

2306 "ix_event_community_invite_requests_unique", 

2307 occurrence_id, 

2308 unique=True, 

2309 postgresql_where=and_(approved.is_not(None), approved == True), 

2310 ), 

2311 # decided and approved ought to be null simultaneously 

2312 CheckConstraint( 

2313 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \ 

2314 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))", 

2315 name="decided_approved", 

2316 ), 

2317 ) 

2318 

2319 

2320class ClusterDiscussionAssociation(Base): 

2321 """ 

2322 discussions related to clusters 

2323 """ 

2324 

2325 __tablename__ = "cluster_discussion_associations" 

2326 __table_args__ = (UniqueConstraint("discussion_id", "cluster_id"),) 

2327 

2328 id = Column(BigInteger, primary_key=True) 

2329 

2330 discussion_id = Column(ForeignKey("discussions.id"), nullable=False, index=True) 

2331 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True) 

2332 

2333 discussion = relationship("Discussion", backref="cluster_discussion_associations") 

2334 cluster = relationship("Cluster", backref="cluster_discussion_associations") 

2335 

2336 

2337class Discussion(Base): 

2338 """ 

2339 forum board 

2340 """ 

2341 

2342 __tablename__ = "discussions" 

2343 

2344 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()) 

2345 

2346 title = Column(String, nullable=False) 

2347 content = Column(String, nullable=False) 

2348 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True) 

2349 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2350 

2351 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2352 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True) 

2353 

2354 slug = column_property(func.slugify(title)) 

2355 

2356 thread = relationship("Thread", backref="discussion", uselist=False) 

2357 

2358 subscribers = relationship("User", backref="discussions", secondary="discussion_subscriptions", viewonly=True) 

2359 

2360 creator_user = relationship("User", backref="created_discussions", foreign_keys="Discussion.creator_user_id") 

2361 owner_cluster = relationship("Cluster", backref=backref("owned_discussions", lazy="dynamic"), uselist=False) 

2362 

2363 

2364class DiscussionSubscription(Base): 

2365 """ 

2366 users subscriptions to discussions 

2367 """ 

2368 

2369 __tablename__ = "discussion_subscriptions" 

2370 __table_args__ = (UniqueConstraint("discussion_id", "user_id"),) 

2371 

2372 id = Column(BigInteger, primary_key=True) 

2373 

2374 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2375 discussion_id = Column(ForeignKey("discussions.id"), nullable=False, index=True) 

2376 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2377 left = Column(DateTime(timezone=True), nullable=True) 

2378 

2379 user = relationship("User", backref="discussion_subscriptions") 

2380 discussion = relationship("Discussion", backref="discussion_subscriptions") 

2381 

2382 

2383class Thread(Base): 

2384 """ 

2385 Thread 

2386 """ 

2387 

2388 __tablename__ = "threads" 

2389 

2390 id = Column(BigInteger, primary_key=True) 

2391 

2392 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2393 deleted = Column(DateTime(timezone=True), nullable=True) 

2394 

2395 

2396class Comment(Base): 

2397 """ 

2398 Comment 

2399 """ 

2400 

2401 __tablename__ = "comments" 

2402 

2403 id = Column(BigInteger, primary_key=True) 

2404 

2405 thread_id = Column(ForeignKey("threads.id"), nullable=False, index=True) 

2406 author_user_id = Column(ForeignKey("users.id"), nullable=False) 

2407 content = Column(String, nullable=False) # CommonMark without images 

2408 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2409 deleted = Column(DateTime(timezone=True), nullable=True) 

2410 

2411 thread = relationship("Thread", backref="comments") 

2412 

2413 

2414class Reply(Base): 

2415 """ 

2416 Reply 

2417 """ 

2418 

2419 __tablename__ = "replies" 

2420 

2421 id = Column(BigInteger, primary_key=True) 

2422 

2423 comment_id = Column(ForeignKey("comments.id"), nullable=False, index=True) 

2424 author_user_id = Column(ForeignKey("users.id"), nullable=False) 

2425 content = Column(String, nullable=False) # CommonMark without images 

2426 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2427 deleted = Column(DateTime(timezone=True), nullable=True) 

2428 

2429 comment = relationship("Comment", backref="replies") 

2430 

2431 

2432class BackgroundJobState(enum.Enum): 

2433 # job is fresh, waiting to be picked off the queue 

2434 pending = enum.auto() 

2435 # job complete 

2436 completed = enum.auto() 

2437 # error occured, will be retried 

2438 error = enum.auto() 

2439 # failed too many times, not retrying anymore 

2440 failed = enum.auto() 

2441 

2442 

2443class BackgroundJob(Base): 

2444 """ 

2445 This table implements a queue of background jobs. 

2446 """ 

2447 

2448 __tablename__ = "background_jobs" 

2449 

2450 id = Column(BigInteger, primary_key=True) 

2451 

2452 # used to discern which function should be triggered to service it 

2453 job_type = Column(String, nullable=False) 

2454 state = Column(Enum(BackgroundJobState), nullable=False, default=BackgroundJobState.pending) 

2455 

2456 # time queued 

2457 queued = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2458 

2459 # time at which we may next attempt it, for implementing exponential backoff 

2460 next_attempt_after = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2461 

2462 # used to count number of retries for failed jobs 

2463 try_count = Column(Integer, nullable=False, default=0) 

2464 

2465 max_tries = Column(Integer, nullable=False, default=5) 

2466 

2467 # higher is more important 

2468 priority = Column(Integer, nullable=False, server_default=text("10")) 

2469 

2470 # protobuf encoded job payload 

2471 payload = Column(Binary, nullable=False) 

2472 

2473 # if the job failed, we write that info here 

2474 failure_info = Column(String, nullable=True) 

2475 

2476 __table_args__ = ( 

2477 # used in looking up background jobs to attempt 

2478 # create index on background_jobs(priority desc, next_attempt_after, (max_tries - try_count)) where state = 'pending' OR state = 'error'; 

2479 Index( 

2480 "ix_background_jobs_lookup", 

2481 priority.desc(), 

2482 next_attempt_after, 

2483 (max_tries - try_count), 

2484 postgresql_where=((state == BackgroundJobState.pending) | (state == BackgroundJobState.error)), 

2485 ), 

2486 ) 

2487 

2488 @hybrid_property 

2489 def ready_for_retry(self): 

2490 return ( 

2491 (self.next_attempt_after <= func.now()) 

2492 & (self.try_count < self.max_tries) 

2493 & ((self.state == BackgroundJobState.pending) | (self.state == BackgroundJobState.error)) 

2494 ) 

2495 

2496 def __repr__(self): 

2497 return f"BackgroundJob(id={self.id}, job_type={self.job_type}, state={self.state}, next_attempt_after={self.next_attempt_after}, try_count={self.try_count}, failure_info={self.failure_info})" 

2498 

2499 

2500class NotificationDeliveryType(enum.Enum): 

2501 # send push notification to mobile/web 

2502 push = enum.auto() 

2503 # send individual email immediately 

2504 email = enum.auto() 

2505 # send in digest 

2506 digest = enum.auto() 

2507 

2508 

2509dt = NotificationDeliveryType 

2510nd = notification_data_pb2 

2511 

2512dt_sec = [dt.email, dt.push] 

2513dt_all = [dt.email, dt.push, dt.digest] 

2514 

2515 

2516class NotificationTopicAction(enum.Enum): 

2517 def __init__(self, topic_action, defaults, user_editable, data_type): 

2518 self.topic, self.action = topic_action.split(":") 

2519 self.defaults = defaults 

2520 # for now user editable == not a security notification 

2521 self.user_editable = user_editable 

2522 

2523 self.data_type = data_type 

2524 

2525 def unpack(self): 

2526 return self.topic, self.action 

2527 

2528 @property 

2529 def display(self): 

2530 return f"{self.topic}:{self.action}" 

2531 

2532 def __str__(self): 

2533 return self.display 

2534 

2535 # topic, action, default delivery types 

2536 friend_request__create = ("friend_request:create", dt_all, True, nd.FriendRequestCreate) 

2537 friend_request__accept = ("friend_request:accept", dt_all, True, nd.FriendRequestAccept) 

2538 

2539 # host requests 

2540 host_request__create = ("host_request:create", dt_all, True, nd.HostRequestCreate) 

2541 host_request__accept = ("host_request:accept", dt_all, True, nd.HostRequestAccept) 

2542 host_request__reject = ("host_request:reject", dt_all, True, nd.HostRequestReject) 

2543 host_request__confirm = ("host_request:confirm", dt_all, True, nd.HostRequestConfirm) 

2544 host_request__cancel = ("host_request:cancel", dt_all, True, nd.HostRequestCancel) 

2545 host_request__message = ("host_request:message", [dt.push, dt.digest], True, nd.HostRequestMessage) 

2546 host_request__missed_messages = ("host_request:missed_messages", [dt.email], True, nd.HostRequestMissedMessages) 

2547 host_request__reminder = ("host_request:reminder", dt_all, True, nd.HostRequestReminder) 

2548 

2549 activeness__probe = ("activeness:probe", dt_sec, False, nd.ActivenessProbe) 

2550 

2551 # you receive a friend ref 

2552 reference__receive_friend = ("reference:receive_friend", dt_all, True, nd.ReferenceReceiveFriend) 

2553 # you receive a reference from ... the host 

2554 reference__receive_hosted = ("reference:receive_hosted", dt_all, True, nd.ReferenceReceiveHostRequest) 

2555 # ... the surfer 

2556 reference__receive_surfed = ("reference:receive_surfed", dt_all, True, nd.ReferenceReceiveHostRequest) 

2557 

2558 # you hosted 

2559 reference__reminder_hosted = ("reference:reminder_hosted", dt_all, True, nd.ReferenceReminder) 

2560 # you surfed 

2561 reference__reminder_surfed = ("reference:reminder_surfed", dt_all, True, nd.ReferenceReminder) 

2562 

2563 badge__add = ("badge:add", [dt.push, dt.digest], True, nd.BadgeAdd) 

2564 badge__remove = ("badge:remove", [dt.push, dt.digest], True, nd.BadgeRemove) 

2565 

2566 # group chats 

2567 chat__message = ("chat:message", [dt.push, dt.digest], True, nd.ChatMessage) 

2568 chat__missed_messages = ("chat:missed_messages", [dt.email], True, nd.ChatMissedMessages) 

2569 

2570 # events 

2571 # approved by mods 

2572 event__create_approved = ("event:create_approved", dt_all, True, nd.EventCreate) 

2573 # any user creates any event, default to no notifications 

2574 event__create_any = ("event:create_any", [], True, nd.EventCreate) 

2575 event__update = ("event:update", dt_all, True, nd.EventUpdate) 

2576 event__cancel = ("event:cancel", dt_all, True, nd.EventCancel) 

2577 event__delete = ("event:delete", dt_all, True, nd.EventDelete) 

2578 event__invite_organizer = ("event:invite_organizer", dt_all, True, nd.EventInviteOrganizer) 

2579 event__reminder = ("event:reminder", dt_all, True, nd.EventReminder) 

2580 # toplevel comment on an event 

2581 event__comment = ("event:comment", dt_all, True, nd.EventComment) 

2582 

2583 # discussion created 

2584 discussion__create = ("discussion:create", [dt.digest], True, nd.DiscussionCreate) 

2585 # someone comments on your discussion 

2586 discussion__comment = ("discussion:comment", dt_all, True, nd.DiscussionComment) 

2587 

2588 # someone responds to any of your top-level comment across the platform 

2589 thread__reply = ("thread:reply", dt_all, True, nd.ThreadReply) 

2590 

2591 # account settings 

2592 password__change = ("password:change", dt_sec, False, empty_pb2.Empty) 

2593 email_address__change = ("email_address:change", dt_sec, False, nd.EmailAddressChange) 

2594 email_address__verify = ("email_address:verify", dt_sec, False, empty_pb2.Empty) 

2595 phone_number__change = ("phone_number:change", dt_sec, False, nd.PhoneNumberChange) 

2596 phone_number__verify = ("phone_number:verify", dt_sec, False, nd.PhoneNumberVerify) 

2597 # reset password 

2598 password_reset__start = ("password_reset:start", dt_sec, False, nd.PasswordResetStart) 

2599 password_reset__complete = ("password_reset:complete", dt_sec, False, empty_pb2.Empty) 

2600 

2601 # account deletion 

2602 account_deletion__start = ("account_deletion:start", dt_sec, False, nd.AccountDeletionStart) 

2603 # no more pushing to do 

2604 account_deletion__complete = ("account_deletion:complete", dt_sec, False, nd.AccountDeletionComplete) 

2605 # undeleted 

2606 account_deletion__recovered = ("account_deletion:recovered", dt_sec, False, empty_pb2.Empty) 

2607 

2608 # admin actions 

2609 gender__change = ("gender:change", dt_sec, False, nd.GenderChange) 

2610 birthdate__change = ("birthdate:change", dt_sec, False, nd.BirthdateChange) 

2611 api_key__create = ("api_key:create", dt_sec, False, nd.ApiKeyCreate) 

2612 

2613 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived) 

2614 

2615 onboarding__reminder = ("onboarding:reminder", dt_sec, True, empty_pb2.Empty) 

2616 

2617 modnote__create = ("modnote:create", dt_sec, False, empty_pb2.Empty) 

2618 

2619 verification__sv_fail = ("verification:sv_fail", dt_sec, False, nd.VerificationSVFail) 

2620 verification__sv_success = ("verification:sv_success", dt_sec, False, empty_pb2.Empty) 

2621 

2622 # general announcements 

2623 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], True, nd.GeneralNewBlogPost) 

2624 

2625 

2626class NotificationPreference(Base): 

2627 __tablename__ = "notification_preferences" 

2628 

2629 id = Column(BigInteger, primary_key=True) 

2630 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2631 

2632 topic_action = Column(Enum(NotificationTopicAction), nullable=False) 

2633 delivery_type = Column(Enum(NotificationDeliveryType), nullable=False) 

2634 deliver = Column(Boolean, nullable=False) 

2635 

2636 user = relationship("User", foreign_keys="NotificationPreference.user_id") 

2637 

2638 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),) 

2639 

2640 

2641class Notification(Base): 

2642 """ 

2643 Table for accumulating notifications until it is time to send email digest 

2644 """ 

2645 

2646 __tablename__ = "notifications" 

2647 

2648 id = Column(BigInteger, primary_key=True) 

2649 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2650 

2651 # recipient user id 

2652 user_id = Column(ForeignKey("users.id"), nullable=False) 

2653 

2654 topic_action = Column(Enum(NotificationTopicAction), nullable=False) 

2655 key = Column(String, nullable=False) 

2656 

2657 data = Column(Binary, nullable=False) 

2658 

2659 # whether the user has marked this notification as seen or not 

2660 is_seen = Column(Boolean, nullable=False, server_default=expression.false()) 

2661 

2662 user = relationship("User", foreign_keys="Notification.user_id") 

2663 

2664 __table_args__ = ( 

2665 # used in looking up which notifications need delivery 

2666 Index( 

2667 "ix_notifications_created", 

2668 created, 

2669 ), 

2670 # Fast lookup for unseen notification count 

2671 Index( 

2672 "ix_notifications_unseen", 

2673 user_id, 

2674 topic_action, 

2675 postgresql_where=(is_seen == False), 

2676 ), 

2677 # Fast lookup for latest notifications 

2678 Index( 

2679 "ix_notifications_latest", 

2680 user_id, 

2681 id.desc(), 

2682 topic_action, 

2683 ), 

2684 ) 

2685 

2686 @property 

2687 def topic(self): 

2688 return self.topic_action.topic 

2689 

2690 @property 

2691 def action(self): 

2692 return self.topic_action.action 

2693 

2694 

2695class NotificationDelivery(Base): 

2696 __tablename__ = "notification_deliveries" 

2697 

2698 id = Column(BigInteger, primary_key=True) 

2699 notification_id = Column(ForeignKey("notifications.id"), nullable=False, index=True) 

2700 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2701 delivered = Column(DateTime(timezone=True), nullable=True) 

2702 read = Column(DateTime(timezone=True), nullable=True) 

2703 # todo: enum of "phone, web, digest" 

2704 delivery_type = Column(Enum(NotificationDeliveryType), nullable=False) 

2705 # todo: device id 

2706 # todo: receipt id, etc 

2707 notification = relationship("Notification", foreign_keys="NotificationDelivery.notification_id") 

2708 

2709 __table_args__ = ( 

2710 UniqueConstraint("notification_id", "delivery_type"), 

2711 # used in looking up which notifications need delivery 

2712 Index( 

2713 "ix_notification_deliveries_delivery_type", 

2714 delivery_type, 

2715 postgresql_where=(delivered != None), 

2716 ), 

2717 Index( 

2718 "ix_notification_deliveries_dt_ni_dnull", 

2719 delivery_type, 

2720 notification_id, 

2721 delivered == None, 

2722 ), 

2723 ) 

2724 

2725 

2726class PushNotificationSubscription(Base): 

2727 __tablename__ = "push_notification_subscriptions" 

2728 

2729 id = Column(BigInteger, primary_key=True) 

2730 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2731 

2732 # which user this is connected to 

2733 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

2734 

2735 # these come from https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription 

2736 # the endpoint 

2737 endpoint = Column(String, nullable=False) 

2738 # the "auth" key 

2739 auth_key = Column(Binary, nullable=False) 

2740 # the "p256dh" key 

2741 p256dh_key = Column(Binary, nullable=False) 

2742 

2743 full_subscription_info = Column(String, nullable=False) 

2744 

2745 # the browse user-agent, so we can tell the user what browser notifications are going to 

2746 user_agent = Column(String, nullable=True) 

2747 

2748 # when it was disabled 

2749 disabled_at = Column(DateTime(timezone=True), nullable=False, server_default=DATETIME_INFINITY.isoformat()) 

2750 

2751 user = relationship("User") 

2752 

2753 

2754class PushNotificationDeliveryAttempt(Base): 

2755 __tablename__ = "push_notification_delivery_attempt" 

2756 

2757 id = Column(BigInteger, primary_key=True) 

2758 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2759 

2760 push_notification_subscription_id = Column( 

2761 ForeignKey("push_notification_subscriptions.id"), nullable=False, index=True 

2762 ) 

2763 

2764 success = Column(Boolean, nullable=False) 

2765 # the HTTP status code, 201 is success 

2766 status_code = Column(Integer, nullable=False) 

2767 

2768 # can be null if it was a success 

2769 response = Column(String, nullable=True) 

2770 

2771 push_notification_subscription = relationship("PushNotificationSubscription") 

2772 

2773 

2774class Language(Base): 

2775 """ 

2776 Table of allowed languages (a subset of ISO639-3) 

2777 """ 

2778 

2779 __tablename__ = "languages" 

2780 

2781 # ISO639-3 language code, in lowercase, e.g. fin, eng 

2782 code = Column(String(3), primary_key=True) 

2783 

2784 # the english name 

2785 name = Column(String, nullable=False, unique=True) 

2786 

2787 

2788class Region(Base): 

2789 """ 

2790 Table of regions 

2791 """ 

2792 

2793 __tablename__ = "regions" 

2794 

2795 # iso 3166-1 alpha3 code in uppercase, e.g. FIN, USA 

2796 code = Column(String(3), primary_key=True) 

2797 

2798 # the name, e.g. Finland, United States 

2799 # this is the display name in English, should be the "common name", not "Republic of Finland" 

2800 name = Column(String, nullable=False, unique=True) 

2801 

2802 

2803class UserBlock(Base): 

2804 """ 

2805 Table of blocked users 

2806 """ 

2807 

2808 __tablename__ = "user_blocks" 

2809 

2810 id = Column(BigInteger, primary_key=True) 

2811 

2812 blocking_user_id = Column(ForeignKey("users.id"), nullable=False) 

2813 blocked_user_id = Column(ForeignKey("users.id"), nullable=False) 

2814 time_blocked = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2815 

2816 blocking_user = relationship("User", foreign_keys="UserBlock.blocking_user_id") 

2817 blocked_user = relationship("User", foreign_keys="UserBlock.blocked_user_id") 

2818 

2819 __table_args__ = ( 

2820 UniqueConstraint("blocking_user_id", "blocked_user_id"), 

2821 Index("ix_user_blocks_blocking_user_id", blocking_user_id, blocked_user_id), 

2822 Index("ix_user_blocks_blocked_user_id", blocked_user_id, blocking_user_id), 

2823 ) 

2824 

2825 

2826class APICall(Base): 

2827 """ 

2828 API call logs 

2829 """ 

2830 

2831 __tablename__ = "api_calls" 

2832 __table_args__ = {"schema": "logging"} 

2833 

2834 id = Column(BigInteger, primary_key=True) 

2835 

2836 # whether the call was made using an api key or session cookies 

2837 is_api_key = Column(Boolean, nullable=False, server_default=expression.false()) 

2838 

2839 # backend version (normally e.g. develop-31469e3), allows us to figure out which proto definitions were used 

2840 # note that `default` is a python side default, not hardcoded into DB schema 

2841 version = Column(String, nullable=False, default=config["VERSION"]) 

2842 

2843 # approximate time of the call 

2844 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2845 

2846 # the method call name, e.g. "/org.couchers.api.core.API/ListFriends" 

2847 method = Column(String, nullable=False) 

2848 

2849 # gRPC status code name, e.g. FAILED_PRECONDITION, None if success 

2850 status_code = Column(String, nullable=True) 

2851 

2852 # handler duration (excluding serialization, etc) 

2853 duration = Column(Float, nullable=False) 

2854 

2855 # user_id of caller, None means not logged in 

2856 user_id = Column(BigInteger, nullable=True) 

2857 

2858 # sanitized request bytes 

2859 request = Column(Binary, nullable=True) 

2860 

2861 # sanitized response bytes 

2862 response = Column(Binary, nullable=True) 

2863 

2864 # whether response bytes have been truncated 

2865 response_truncated = Column(Boolean, nullable=False, server_default=expression.false()) 

2866 

2867 # the exception traceback, if any 

2868 traceback = Column(String, nullable=True) 

2869 

2870 # human readable perf report 

2871 perf_report = Column(String, nullable=True) 

2872 

2873 # details of the browser, if available 

2874 ip_address = Column(String, nullable=True) 

2875 user_agent = Column(String, nullable=True) 

2876 

2877 

2878class AccountDeletionReason(Base): 

2879 __tablename__ = "account_deletion_reason" 

2880 

2881 id = Column(BigInteger, primary_key=True) 

2882 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2883 user_id = Column(ForeignKey("users.id"), nullable=False) 

2884 reason = Column(String, nullable=True) 

2885 

2886 user = relationship("User") 

2887 

2888 

2889class ModerationUserList(Base): 

2890 """ 

2891 Represents a list of users listed together by a moderator 

2892 """ 

2893 

2894 __tablename__ = "moderation_user_lists" 

2895 

2896 id = Column(BigInteger, primary_key=True) 

2897 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2898 

2899 # Relationships 

2900 users = relationship("User", secondary="moderation_user_list_members", back_populates="moderation_user_lists") 

2901 

2902 

2903class ModerationUserListMember(Base): 

2904 """ 

2905 Association table for many-to-many relationship between users and moderation_user_lists 

2906 """ 

2907 

2908 __tablename__ = "moderation_user_list_members" 

2909 

2910 user_id = Column(ForeignKey("users.id"), primary_key=True) 

2911 moderation_list_id = Column(ForeignKey("moderation_user_lists.id"), primary_key=True) 

2912 

2913 __table_args__ = (UniqueConstraint("user_id", "moderation_list_id"),) 

2914 

2915 

2916class AntiBotLog(Base): 

2917 __tablename__ = "antibot_logs" 

2918 

2919 id = Column(BigInteger, primary_key=True) 

2920 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2921 user_id = Column(ForeignKey("users.id"), nullable=True) 

2922 

2923 ip_address = Column(String, nullable=True) 

2924 user_agent = Column(String, nullable=True) 

2925 

2926 action = Column(String, nullable=False) 

2927 token = Column(String, nullable=False) 

2928 

2929 score = Column(Float, nullable=False) 

2930 provider_data = Column(JSON, nullable=False) 

2931 

2932 

2933class RateLimitAction(enum.Enum): 

2934 """Possible user actions which can be rate limited.""" 

2935 

2936 host_request = "host request" 

2937 friend_request = "friend request" 

2938 chat_initiation = "chat initiation" 

2939 

2940 

2941class RateLimitViolation(Base): 

2942 __tablename__ = "rate_limit_violations" 

2943 

2944 id = Column(BigInteger, primary_key=True) 

2945 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

2946 user_id = Column(ForeignKey("users.id"), nullable=False) 

2947 action = Column(Enum(RateLimitAction), nullable=False) 

2948 is_hard_limit = Column(Boolean, nullable=False) 

2949 

2950 user = relationship("User") 

2951 

2952 __table_args__ = ( 

2953 # Fast lookup for rate limits in interval 

2954 Index("ix_rate_limits_by_user", user_id, action, is_hard_limit, created), 

2955 ) 

2956 

2957 

2958class Volunteer(Base): 

2959 __tablename__ = "volunteers" 

2960 

2961 id = Column(BigInteger, primary_key=True) 

2962 user_id = Column(ForeignKey("users.id"), nullable=False, unique=True) 

2963 

2964 display_name = Column(String, nullable=True) 

2965 display_location = Column(String, nullable=True) 

2966 

2967 role = Column(String, nullable=False) 

2968 

2969 # custom sort order on team page, sorted ascending 

2970 sort_key = Column(Float, nullable=True) 

2971 

2972 started_volunteering = Column(Date, nullable=False, server_default=text("CURRENT_DATE")) 

2973 stopped_volunteering = Column(Date, nullable=True, default=None) 

2974 

2975 link_type = Column(String, nullable=True) 

2976 link_text = Column(String, nullable=True) 

2977 link_url = Column(String, nullable=True) 

2978 

2979 show_on_team_page = Column(Boolean, nullable=False, server_default=expression.true()) 

2980 

2981 __table_args__ = ( 

2982 # Link type, text, url should all be null or all not be null 

2983 CheckConstraint( 

2984 "(link_type IS NULL) = (link_text IS NULL) AND (link_type IS NULL) = (link_url IS NULL)", 

2985 name="link_type_text", 

2986 ), 

2987 )