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

1221 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-09-14 15:31 +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 ~(self.is_banned | self.is_deleted) 

473 

474 @property 

475 def coordinates(self): 

476 return get_coordinates(self.geom) 

477 

478 @property 

479 def display_joined(self): 

480 """ 

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

482 """ 

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

484 

485 @property 

486 def display_last_active(self): 

487 """ 

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

489 """ 

490 return last_active_coarsen(self.last_active) 

491 

492 @hybrid_property 

493 def phone_is_verified(self): 

494 return ( 

495 self.phone_verification_verified is not None 

496 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

497 ) 

498 

499 @phone_is_verified.expression 

500 def phone_is_verified(cls): 

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

502 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

503 ) 

504 

505 @hybrid_property 

506 def phone_code_expired(self): 

507 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME 

508 

509 def __repr__(self): 

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

511 

512 

513class UserBadge(Base): 

514 """ 

515 A badge on a user's profile 

516 """ 

517 

518 __tablename__ = "user_badges" 

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

520 

521 id = Column(BigInteger, primary_key=True) 

522 

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

524 # corresponds to "id" in badges.json 

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

526 

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

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

529 

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

531 

532 

533class ActivenessProbeStatus(enum.Enum): 

534 # no response yet 

535 pending = enum.auto() 

536 

537 # didn't respond on time 

538 expired = enum.auto() 

539 

540 # responded that they're still active 

541 still_active = enum.auto() 

542 

543 # responded that they're no longer active 

544 no_longer_active = enum.auto() 

545 

546 

547class ActivenessProbe(Base): 

548 """ 

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

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

551 show up as such. 

552 """ 

553 

554 __tablename__ = "activeness_probes" 

555 

556 id = Column(BigInteger, primary_key=True) 

557 

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

559 # the time this probe was initiated 

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

561 # the number of reminders sent for this probe 

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

563 

564 # the time of response 

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

566 # the response value 

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

568 

569 @hybrid_property 

570 def is_pending(self): 

571 return self.responded == None 

572 

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

574 

575 __table_args__ = ( 

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

577 Index( 

578 "ix_activeness_probe_unique_pending_response", 

579 user_id, 

580 unique=True, 

581 postgresql_where=responded == None, 

582 ), 

583 # response time is none iff response is pending 

584 CheckConstraint( 

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

586 name="pending_has_no_responded", 

587 ), 

588 ) 

589 

590 

591User.pending_activeness_probe = relationship( 

592 ActivenessProbe, 

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

594 uselist=False, 

595 back_populates="user", 

596) 

597 

598 

599class StrongVerificationAttemptStatus(enum.Enum): 

600 ## full data states 

601 # completed, this now provides verification for a user 

602 succeeded = enum.auto() 

603 

604 ## no data states 

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

606 in_progress_waiting_on_user_to_open_app = enum.auto() 

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

608 in_progress_waiting_on_user_in_app = enum.auto() 

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

610 in_progress_waiting_on_backend = enum.auto() 

611 # failed, no data 

612 failed = enum.auto() 

613 

614 # duplicate, at our end, has data 

615 duplicate = enum.auto() 

616 

617 ## minimal data states 

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

619 deleted = enum.auto() 

620 

621 

622class PassportSex(enum.Enum): 

623 """ 

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

625 """ 

626 

627 male = enum.auto() 

628 female = enum.auto() 

629 unspecified = enum.auto() 

630 

631 

632class StrongVerificationAttempt(Base): 

633 """ 

634 An attempt to perform strong verification 

635 """ 

636 

637 __tablename__ = "strong_verification_attempts" 

638 

639 # our verification id 

640 id = Column(BigInteger, primary_key=True) 

641 

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

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

644 

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

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

647 

648 status = Column( 

649 Enum(StrongVerificationAttemptStatus), 

650 nullable=False, 

651 default=StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app, 

652 ) 

653 

654 ## full data 

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

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

657 passport_encrypted_data = Column(Binary, nullable=True) 

658 passport_date_of_birth = Column(Date, nullable=True) 

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

660 

661 ## minimal data: this will not be deleted 

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

663 passport_expiry_date = Column(Date, nullable=True) 

664 passport_nationality = Column(String, nullable=True) 

665 # last three characters of the passport number 

666 passport_last_three_document_chars = Column(String, nullable=True) 

667 

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

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

670 

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

672 

673 user = relationship("User") 

674 

675 @hybrid_property 

676 def is_valid(self): 

677 """ 

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

679 """ 

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

681 

682 @is_valid.expression 

683 def is_valid(cls): 

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

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

686 ) 

687 

688 @hybrid_property 

689 def is_visible(self): 

690 return self.status != StrongVerificationAttemptStatus.deleted 

691 

692 @hybrid_method 

693 def _raw_birthdate_match(self, user): 

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

695 return self.passport_date_of_birth == user.birthdate 

696 

697 @hybrid_method 

698 def matches_birthdate(self, user): 

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

700 

701 @hybrid_method 

702 def _raw_gender_match(self, user): 

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

704 return ( 

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

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

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

708 | (user.has_passport_sex_gender_exception == True) 

709 ) 

710 

711 @hybrid_method 

712 def matches_gender(self, user): 

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

714 

715 @hybrid_method 

716 def has_strong_verification(self, user): 

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

718 

719 __table_args__ = ( 

720 # used to look up verification status for a user 

721 Index( 

722 "ix_strong_verification_attempts_current", 

723 user_id, 

724 passport_expiry_date, 

725 postgresql_where=status == StrongVerificationAttemptStatus.succeeded, 

726 ), 

727 # each passport can be verified only once 

728 Index( 

729 "ix_strong_verification_attempts_unique_succeeded", 

730 passport_expiry_date, 

731 passport_nationality, 

732 passport_last_three_document_chars, 

733 unique=True, 

734 postgresql_where=( 

735 (status == StrongVerificationAttemptStatus.succeeded) 

736 | (status == StrongVerificationAttemptStatus.deleted) 

737 ), 

738 ), 

739 # full data check 

740 CheckConstraint( 

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

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

743 name="full_data_status", 

744 ), 

745 # minimal data check 

746 CheckConstraint( 

747 "(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 \ 

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

749 name="minimal_data_status", 

750 ), 

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

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

753 CheckConstraint( 

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

755 name="full_data_implies_minimal_data", 

756 ), 

757 # succeeded implies full data 

758 CheckConstraint( 

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

760 name="succeeded_implies_full_data", 

761 ), 

762 # in_progress/failed implies no_data 

763 CheckConstraint( 

764 "(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)", 

765 name="in_progress_failed_iris_implies_no_data", 

766 ), 

767 # deleted or duplicate implies minimal data 

768 CheckConstraint( 

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

770 name="deleted_duplicate_implies_minimal_data", 

771 ), 

772 ) 

773 

774 

775class ModNote(Base): 

776 """ 

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

778 

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

780 """ 

781 

782 __tablename__ = "mod_notes" 

783 id = Column(BigInteger, primary_key=True) 

784 

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

786 

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

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

789 

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

791 internal_id = Column(String, nullable=False) 

792 # the admin that left this note 

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

794 

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

796 

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

798 

799 def __repr__(self): 

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

801 

802 @hybrid_property 

803 def is_pending(self): 

804 return self.acknowledged == None 

805 

806 __table_args__ = ( 

807 # used to look up pending notes 

808 Index( 

809 "ix_mod_notes_unacknowledged", 

810 user_id, 

811 postgresql_where=acknowledged == None, 

812 ), 

813 ) 

814 

815 

816class StrongVerificationCallbackEvent(Base): 

817 __tablename__ = "strong_verification_callback_events" 

818 

819 id = Column(BigInteger, primary_key=True) 

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

821 

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

823 

824 iris_status = Column(String, nullable=False) 

825 

826 

827class DonationType(enum.Enum): 

828 one_time = enum.auto() 

829 recurring = enum.auto() 

830 

831 

832class DonationInitiation(Base): 

833 """ 

834 Whenever someone initiaties a donation through the platform 

835 """ 

836 

837 __tablename__ = "donation_initiations" 

838 id = Column(BigInteger, primary_key=True) 

839 

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

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

842 

843 amount = Column(Integer, nullable=False) 

844 stripe_checkout_session_id = Column(String, nullable=False) 

845 

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

847 source = Column(String, nullable=True) 

848 

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

850 

851 

852class Invoice(Base): 

853 """ 

854 Successful donations, both one off and recurring 

855 

856 Triggered by `payment_intent.succeeded` webhook 

857 """ 

858 

859 __tablename__ = "invoices" 

860 

861 id = Column(BigInteger, primary_key=True) 

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

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

864 

865 amount = Column(Float, nullable=False) 

866 

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

868 stripe_receipt_url = Column(String, nullable=False) 

869 

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

871 

872 

873class LanguageFluency(enum.Enum): 

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

875 beginner = 1 

876 conversational = 2 

877 fluent = 3 

878 

879 

880class LanguageAbility(Base): 

881 __tablename__ = "language_abilities" 

882 __table_args__ = ( 

883 # Users can only have one language ability per language 

884 UniqueConstraint("user_id", "language_code"), 

885 ) 

886 

887 id = Column(BigInteger, primary_key=True) 

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

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

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

891 

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

893 language = relationship("Language") 

894 

895 

896class RegionVisited(Base): 

897 __tablename__ = "regions_visited" 

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

899 

900 id = Column(BigInteger, primary_key=True) 

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

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

903 

904 

905class RegionLived(Base): 

906 __tablename__ = "regions_lived" 

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

908 

909 id = Column(BigInteger, primary_key=True) 

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

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

912 

913 

914class FriendStatus(enum.Enum): 

915 pending = enum.auto() 

916 accepted = enum.auto() 

917 rejected = enum.auto() 

918 cancelled = enum.auto() 

919 

920 

921class FriendRelationship(Base): 

922 """ 

923 Friendship relations between users 

924 

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

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

927 """ 

928 

929 __tablename__ = "friend_relationships" 

930 

931 id = Column(BigInteger, primary_key=True) 

932 

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

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

935 

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

937 

938 # timezones should always be UTC 

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

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

941 

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

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

944 

945 __table_args__ = ( 

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

947 Index( 

948 "ix_friend_relationships_status_to_from", 

949 status, 

950 to_user_id, 

951 from_user_id, 

952 ), 

953 ) 

954 

955 

956class ContributeOption(enum.Enum): 

957 yes = enum.auto() 

958 maybe = enum.auto() 

959 no = enum.auto() 

960 

961 

962class ContributorForm(Base): 

963 """ 

964 Someone filled in the contributor form 

965 """ 

966 

967 __tablename__ = "contributor_forms" 

968 

969 id = Column(BigInteger, primary_key=True) 

970 

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

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

973 

974 ideas = Column(String, nullable=True) 

975 features = Column(String, nullable=True) 

976 experience = Column(String, nullable=True) 

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

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

979 expertise = Column(String, nullable=True) 

980 

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

982 

983 @hybrid_property 

984 def is_filled(self): 

985 """ 

986 Whether the form counts as having been filled 

987 """ 

988 return ( 

989 (self.ideas != None) 

990 | (self.features != None) 

991 | (self.experience != None) 

992 | (self.contribute != None) 

993 | (self.contribute_ways != []) 

994 | (self.expertise != None) 

995 ) 

996 

997 @property 

998 def should_notify(self): 

999 """ 

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

1001 

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

1003 """ 

1004 return False 

1005 

1006 

1007class SignupFlow(Base): 

1008 """ 

1009 Signup flows/incomplete users 

1010 

1011 Coinciding fields have the same meaning as in User 

1012 """ 

1013 

1014 __tablename__ = "signup_flows" 

1015 

1016 id = Column(BigInteger, primary_key=True) 

1017 

1018 # housekeeping 

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

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

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

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

1023 email_token = Column(String, nullable=True) 

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

1025 

1026 ## Basic 

1027 name = Column(String, nullable=False) 

1028 # TODO: unique across both tables 

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

1030 # TODO: invitation, attribution 

1031 

1032 ## Account 

1033 # TODO: unique across both tables 

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

1035 hashed_password = Column(Binary, nullable=True) 

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

1037 gender = Column(String, nullable=True) 

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

1039 city = Column(String, nullable=True) 

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

1041 geom_radius = Column(Float, nullable=True) 

1042 

1043 accepted_tos = Column(Integer, nullable=True) 

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

1045 

1046 opt_out_of_newsletter = Column(Boolean, nullable=True) 

1047 

1048 ## Feedback (now unused) 

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

1050 ideas = Column(String, nullable=True) 

1051 features = Column(String, nullable=True) 

1052 experience = Column(String, nullable=True) 

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

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

1055 expertise = Column(String, nullable=True) 

1056 

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

1058 

1059 @hybrid_property 

1060 def token_is_valid(self): 

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

1062 

1063 @hybrid_property 

1064 def account_is_filled(self): 

1065 return ( 

1066 (self.username != None) 

1067 & (self.birthdate != None) 

1068 & (self.gender != None) 

1069 & (self.hosting_status != None) 

1070 & (self.city != None) 

1071 & (self.geom != None) 

1072 & (self.geom_radius != None) 

1073 & (self.accepted_tos != None) 

1074 & (self.opt_out_of_newsletter != None) 

1075 ) 

1076 

1077 @hybrid_property 

1078 def is_completed(self): 

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

1080 

1081 

1082class LoginToken(Base): 

1083 """ 

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

1085 """ 

1086 

1087 __tablename__ = "login_tokens" 

1088 token = Column(String, primary_key=True) 

1089 

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

1091 

1092 # timezones should always be UTC 

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

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

1095 

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

1097 

1098 @hybrid_property 

1099 def is_valid(self): 

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

1101 

1102 def __repr__(self): 

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

1104 

1105 

1106class PasswordResetToken(Base): 

1107 __tablename__ = "password_reset_tokens" 

1108 token = Column(String, primary_key=True) 

1109 

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

1111 

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

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

1114 

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

1116 

1117 @hybrid_property 

1118 def is_valid(self): 

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

1120 

1121 def __repr__(self): 

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

1123 

1124 

1125class AccountDeletionToken(Base): 

1126 __tablename__ = "account_deletion_tokens" 

1127 

1128 token = Column(String, primary_key=True) 

1129 

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

1131 

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

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

1134 

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

1136 

1137 @hybrid_property 

1138 def is_valid(self): 

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

1140 

1141 def __repr__(self): 

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

1143 

1144 

1145class UserActivity(Base): 

1146 """ 

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

1148 

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

1150 """ 

1151 

1152 __tablename__ = "user_activity" 

1153 

1154 id = Column(BigInteger, primary_key=True) 

1155 

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

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

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

1159 

1160 # details of the browser, if available 

1161 ip_address = Column(INET, nullable=True) 

1162 user_agent = Column(String, nullable=True) 

1163 

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

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

1166 

1167 __table_args__ = ( 

1168 # helps look up this tuple quickly 

1169 Index( 

1170 "ix_user_activity_user_id_period_ip_address_user_agent", 

1171 user_id, 

1172 period, 

1173 ip_address, 

1174 user_agent, 

1175 unique=True, 

1176 ), 

1177 ) 

1178 

1179 

1180class UserSession(Base): 

1181 """ 

1182 API keys/session cookies for the app 

1183 

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

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

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

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

1188 site. 

1189 

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

1191 

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

1193 """ 

1194 

1195 __tablename__ = "sessions" 

1196 token = Column(String, primary_key=True) 

1197 

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

1199 

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

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

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

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

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

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

1206 

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

1208 long_lived = Column(Boolean, nullable=False) 

1209 

1210 # the time at which the session was created 

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

1212 

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

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

1215 

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

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

1218 

1219 # the last time this session was used 

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

1221 

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

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

1224 

1225 # details of the browser, if available 

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

1227 ip_address = Column(String, nullable=True) 

1228 user_agent = Column(String, nullable=True) 

1229 

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

1231 

1232 @hybrid_property 

1233 def is_valid(self): 

1234 """ 

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

1236 

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

1238 

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

1240 """ 

1241 return ( 

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

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

1244 & (self.deleted == None) 

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

1246 ) 

1247 

1248 __table_args__ = ( 

1249 Index( 

1250 "ix_sessions_by_token", 

1251 "token", 

1252 postgresql_using="hash", 

1253 ), 

1254 ) 

1255 

1256 

1257class Conversation(Base): 

1258 """ 

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

1260 """ 

1261 

1262 __tablename__ = "conversations" 

1263 

1264 id = Column(BigInteger, primary_key=True) 

1265 # timezone should always be UTC 

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

1267 

1268 def __repr__(self): 

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

1270 

1271 

1272class GroupChat(Base): 

1273 """ 

1274 Group chat 

1275 """ 

1276 

1277 __tablename__ = "group_chats" 

1278 

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

1280 

1281 title = Column(String, nullable=True) 

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

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

1284 is_dm = Column(Boolean, nullable=False) 

1285 

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

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

1288 

1289 def __repr__(self): 

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

1291 

1292 

1293class GroupChatRole(enum.Enum): 

1294 admin = enum.auto() 

1295 participant = enum.auto() 

1296 

1297 

1298class GroupChatSubscription(Base): 

1299 """ 

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

1301 """ 

1302 

1303 __tablename__ = "group_chat_subscriptions" 

1304 id = Column(BigInteger, primary_key=True) 

1305 

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

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

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

1309 

1310 # timezones should always be UTC 

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

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

1313 

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

1315 

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

1317 

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

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

1320 

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

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

1323 

1324 def muted_display(self): 

1325 """ 

1326 Returns (muted, muted_until) display values: 

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

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

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

1330 """ 

1331 if self.muted_until < now(): 

1332 return (False, None) 

1333 elif self.muted_until == DATETIME_INFINITY: 

1334 return (True, None) 

1335 else: 

1336 return (True, self.muted_until) 

1337 

1338 @hybrid_property 

1339 def is_muted(self): 

1340 return self.muted_until > func.now() 

1341 

1342 def __repr__(self): 

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

1344 

1345 

1346class InviteCode(Base): 

1347 __tablename__ = "invite_codes" 

1348 

1349 id = Column(String, primary_key=True) 

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

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

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

1353 

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

1355 

1356 

1357class MessageType(enum.Enum): 

1358 text = enum.auto() 

1359 # e.g. 

1360 # image = 

1361 # emoji = 

1362 # ... 

1363 chat_created = enum.auto() 

1364 chat_edited = enum.auto() 

1365 user_invited = enum.auto() 

1366 user_left = enum.auto() 

1367 user_made_admin = enum.auto() 

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

1369 host_request_status_changed = enum.auto() 

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

1371 

1372 

1373class HostRequestStatus(enum.Enum): 

1374 pending = enum.auto() 

1375 accepted = enum.auto() 

1376 rejected = enum.auto() 

1377 confirmed = enum.auto() 

1378 cancelled = enum.auto() 

1379 

1380 

1381class Message(Base): 

1382 """ 

1383 A message. 

1384 

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

1386 """ 

1387 

1388 __tablename__ = "messages" 

1389 

1390 id = Column(BigInteger, primary_key=True) 

1391 

1392 # which conversation the message belongs in 

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

1394 

1395 # the user that sent the message/command 

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

1397 

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

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

1400 

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

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

1403 

1404 # time sent, timezone should always be UTC 

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

1406 

1407 # the plain-text message text if not control 

1408 text = Column(String, nullable=True) 

1409 

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

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

1412 

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

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

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

1416 

1417 @property 

1418 def is_normal_message(self): 

1419 """ 

1420 There's only one normal type atm, text 

1421 """ 

1422 return self.message_type == MessageType.text 

1423 

1424 def __repr__(self): 

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

1426 

1427 

1428class ContentReport(Base): 

1429 """ 

1430 A piece of content reported to admins 

1431 """ 

1432 

1433 __tablename__ = "content_reports" 

1434 

1435 id = Column(BigInteger, primary_key=True) 

1436 

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

1438 

1439 # the user who reported or flagged the content 

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

1441 

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

1443 reason = Column(String, nullable=False) 

1444 # a short description 

1445 description = Column(String, nullable=False) 

1446 

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

1448 content_ref = Column(String, nullable=False) 

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

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

1451 

1452 # details of the browser, if available 

1453 user_agent = Column(String, nullable=False) 

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

1455 page = Column(String, nullable=False) 

1456 

1457 # see comments above for reporting vs author 

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

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

1460 

1461 

1462class Email(Base): 

1463 """ 

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

1465 """ 

1466 

1467 __tablename__ = "emails" 

1468 

1469 id = Column(String, primary_key=True) 

1470 

1471 # timezone should always be UTC 

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

1473 

1474 sender_name = Column(String, nullable=False) 

1475 sender_email = Column(String, nullable=False) 

1476 

1477 recipient = Column(String, nullable=False) 

1478 subject = Column(String, nullable=False) 

1479 

1480 plain = Column(String, nullable=False) 

1481 html = Column(String, nullable=False) 

1482 

1483 list_unsubscribe_header = Column(String, nullable=True) 

1484 source_data = Column(String, nullable=True) 

1485 

1486 

1487class SMS(Base): 

1488 """ 

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

1490 """ 

1491 

1492 __tablename__ = "smss" 

1493 

1494 id = Column(BigInteger, primary_key=True) 

1495 

1496 # timezone should always be UTC 

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

1498 # AWS message id 

1499 message_id = Column(String, nullable=False) 

1500 

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

1502 sms_sender_id = Column(String, nullable=False) 

1503 number = Column(String, nullable=False) 

1504 message = Column(String, nullable=False) 

1505 

1506 

1507class HostRequest(Base): 

1508 """ 

1509 A request to stay with a host 

1510 """ 

1511 

1512 __tablename__ = "host_requests" 

1513 

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

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

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

1517 

1518 hosting_city = Column(String, nullable=False) 

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

1520 hosting_radius = Column(Float, nullable=False) 

1521 

1522 # TODO: proper timezone handling 

1523 timezone = "Etc/UTC" 

1524 

1525 # dates in the timezone above 

1526 from_date = Column(Date, nullable=False) 

1527 to_date = Column(Date, nullable=False) 

1528 

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

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

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

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

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

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

1535 

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

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

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

1539 

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

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

1542 

1543 # number of reference reminders sent out 

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

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

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

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

1548 

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

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

1551 host_reason_didnt_meetup = Column(String, nullable=True) 

1552 surfer_reason_didnt_meetup = Column(String, nullable=True) 

1553 

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

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

1556 conversation = relationship("Conversation") 

1557 

1558 __table_args__ = ( 

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

1560 Index( 

1561 "ix_host_requests_host_didnt_meetup", 

1562 host_reason_didnt_meetup != None, 

1563 ), 

1564 Index( 

1565 "ix_host_requests_surfer_didnt_meetup", 

1566 surfer_reason_didnt_meetup != None, 

1567 ), 

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

1569 Index( 

1570 "ix_host_requests_status_reminder_counts", 

1571 status, 

1572 host_sent_request_reminders, 

1573 last_sent_request_reminder_time, 

1574 from_date, 

1575 ), 

1576 ) 

1577 

1578 @hybrid_property 

1579 def can_write_reference(self): 

1580 return ( 

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

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

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

1584 ) 

1585 

1586 @can_write_reference.expression 

1587 def can_write_reference(cls): 

1588 return ( 

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

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

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

1592 ) 

1593 

1594 def __repr__(self): 

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

1596 

1597 

1598class HostRequestQuality(enum.Enum): 

1599 high_quality = enum.auto() 

1600 okay_quality = enum.auto() 

1601 low_quality = enum.auto() 

1602 

1603 

1604class HostRequestFeedback(Base): 

1605 """ 

1606 Private feedback from host about a host request 

1607 """ 

1608 

1609 __tablename__ = "host_request_feedbacks" 

1610 

1611 id = Column(BigInteger, primary_key=True) 

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

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

1614 

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

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

1617 

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

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

1620 

1621 host_request = relationship("HostRequest") 

1622 

1623 __table_args__ = ( 

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

1625 Index( 

1626 "ix_unique_host_req_feedback", 

1627 from_user_id, 

1628 to_user_id, 

1629 host_request_id, 

1630 unique=True, 

1631 ), 

1632 ) 

1633 

1634 

1635class ReferenceType(enum.Enum): 

1636 friend = enum.auto() 

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

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

1639 

1640 

1641class Reference(Base): 

1642 """ 

1643 Reference from one user to another 

1644 """ 

1645 

1646 __tablename__ = "references" 

1647 

1648 id = Column(BigInteger, primary_key=True) 

1649 # timezone should always be UTC 

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

1651 

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

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

1654 

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

1656 

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

1658 

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

1660 # text that's only visible to mods 

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

1662 

1663 rating = Column(Float, nullable=False) 

1664 was_appropriate = Column(Boolean, nullable=False) 

1665 

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

1667 

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

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

1670 

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

1672 

1673 __table_args__ = ( 

1674 # Rating must be between 0 and 1, inclusive 

1675 CheckConstraint( 

1676 "rating BETWEEN 0 AND 1", 

1677 name="rating_between_0_and_1", 

1678 ), 

1679 # Has host_request_id or it's a friend reference 

1680 CheckConstraint( 

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

1682 name="host_request_id_xor_friend_reference", 

1683 ), 

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

1685 Index( 

1686 "ix_references_unique_friend_reference", 

1687 from_user_id, 

1688 to_user_id, 

1689 reference_type, 

1690 unique=True, 

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

1692 ), 

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

1694 Index( 

1695 "ix_references_unique_per_host_request", 

1696 from_user_id, 

1697 to_user_id, 

1698 host_request_id, 

1699 unique=True, 

1700 postgresql_where=(host_request_id != None), 

1701 ), 

1702 ) 

1703 

1704 @property 

1705 def should_report(self): 

1706 """ 

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

1708 """ 

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

1710 

1711 

1712class InitiatedUpload(Base): 

1713 """ 

1714 Started downloads, not necessarily complete yet. 

1715 """ 

1716 

1717 __tablename__ = "initiated_uploads" 

1718 

1719 key = Column(String, primary_key=True) 

1720 

1721 # timezones should always be UTC 

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

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

1724 

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

1726 

1727 initiator_user = relationship("User") 

1728 

1729 @hybrid_property 

1730 def is_valid(self): 

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

1732 

1733 

1734class Upload(Base): 

1735 """ 

1736 Completed uploads. 

1737 """ 

1738 

1739 __tablename__ = "uploads" 

1740 key = Column(String, primary_key=True) 

1741 

1742 filename = Column(String, nullable=False) 

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

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

1745 

1746 # photo credit, etc 

1747 credit = Column(String, nullable=True) 

1748 

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

1750 

1751 def _url(self, size): 

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

1753 

1754 @property 

1755 def thumbnail_url(self): 

1756 return self._url("thumbnail") 

1757 

1758 @property 

1759 def full_url(self): 

1760 return self._url("full") 

1761 

1762 

1763communities_seq = Sequence("communities_seq") 

1764 

1765 

1766class Node(Base): 

1767 """ 

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

1769 

1770 Administered by the official cluster 

1771 """ 

1772 

1773 __tablename__ = "nodes" 

1774 

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

1776 

1777 # name and description come from official cluster 

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

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

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

1781 

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

1783 

1784 contained_users = relationship( 

1785 "User", 

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

1787 viewonly=True, 

1788 uselist=True, 

1789 ) 

1790 

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

1792 

1793 

1794class Cluster(Base): 

1795 """ 

1796 Cluster, administered grouping of content 

1797 """ 

1798 

1799 __tablename__ = "clusters" 

1800 

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

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

1803 name = Column(String, nullable=False) 

1804 # short description 

1805 description = Column(String, nullable=False) 

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

1807 

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

1809 

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

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

1812 

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

1814 

1815 official_cluster_for_node = relationship( 

1816 "Node", 

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

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

1819 uselist=False, 

1820 viewonly=True, 

1821 ) 

1822 

1823 parent_node = relationship( 

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

1825 ) 

1826 

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

1828 # all pages 

1829 pages = relationship( 

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

1831 ) 

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

1833 discussions = relationship( 

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

1835 ) 

1836 

1837 # includes also admins 

1838 members = relationship( 

1839 "User", 

1840 lazy="dynamic", 

1841 backref="cluster_memberships", 

1842 secondary="cluster_subscriptions", 

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

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

1845 viewonly=True, 

1846 ) 

1847 

1848 admins = relationship( 

1849 "User", 

1850 lazy="dynamic", 

1851 backref="cluster_adminships", 

1852 secondary="cluster_subscriptions", 

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

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

1855 viewonly=True, 

1856 ) 

1857 

1858 main_page = relationship( 

1859 "Page", 

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

1861 viewonly=True, 

1862 uselist=False, 

1863 ) 

1864 

1865 @property 

1866 def is_leaf(self) -> bool: 

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

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

1869 

1870 __table_args__ = ( 

1871 # Each node can have at most one official cluster 

1872 Index( 

1873 "ix_clusters_owner_parent_node_id_is_official_cluster", 

1874 parent_node_id, 

1875 is_official_cluster, 

1876 unique=True, 

1877 postgresql_where=is_official_cluster, 

1878 ), 

1879 ) 

1880 

1881 

1882class NodeClusterAssociation(Base): 

1883 """ 

1884 NodeClusterAssociation, grouping of nodes 

1885 """ 

1886 

1887 __tablename__ = "node_cluster_associations" 

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

1889 

1890 id = Column(BigInteger, primary_key=True) 

1891 

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

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

1894 

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

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

1897 

1898 

1899class ClusterRole(enum.Enum): 

1900 member = enum.auto() 

1901 admin = enum.auto() 

1902 

1903 

1904class ClusterSubscription(Base): 

1905 """ 

1906 ClusterSubscription of a user 

1907 """ 

1908 

1909 __tablename__ = "cluster_subscriptions" 

1910 

1911 id = Column(BigInteger, primary_key=True) 

1912 

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

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

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

1916 

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

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

1919 

1920 __table_args__ = ( 

1921 UniqueConstraint("user_id", "cluster_id"), 

1922 Index( 

1923 "ix_cluster_subscriptions_members", 

1924 cluster_id, 

1925 user_id, 

1926 ), 

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

1928 Index( 

1929 "ix_cluster_subscriptions_admins", 

1930 user_id, 

1931 cluster_id, 

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

1933 ), 

1934 ) 

1935 

1936 

1937class ClusterPageAssociation(Base): 

1938 """ 

1939 pages related to clusters 

1940 """ 

1941 

1942 __tablename__ = "cluster_page_associations" 

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

1944 

1945 id = Column(BigInteger, primary_key=True) 

1946 

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

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

1949 

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

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

1952 

1953 

1954class PageType(enum.Enum): 

1955 main_page = enum.auto() 

1956 place = enum.auto() 

1957 guide = enum.auto() 

1958 

1959 

1960class Page(Base): 

1961 """ 

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

1963 """ 

1964 

1965 __tablename__ = "pages" 

1966 

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

1968 

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

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

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

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

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

1974 

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

1976 

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

1978 

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

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

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

1982 owner_cluster = relationship( 

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

1984 ) 

1985 

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

1987 

1988 __table_args__ = ( 

1989 # Only one of owner_user and owner_cluster should be set 

1990 CheckConstraint( 

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

1992 name="one_owner", 

1993 ), 

1994 # Only clusters can own main pages 

1995 CheckConstraint( 

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

1997 name="main_page_owned_by_cluster", 

1998 ), 

1999 # Each cluster can have at most one main page 

2000 Index( 

2001 "ix_pages_owner_cluster_id_type", 

2002 owner_cluster_id, 

2003 type, 

2004 unique=True, 

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

2006 ), 

2007 ) 

2008 

2009 def __repr__(self): 

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

2011 

2012 

2013class PageVersion(Base): 

2014 """ 

2015 version of page content 

2016 """ 

2017 

2018 __tablename__ = "page_versions" 

2019 

2020 id = Column(BigInteger, primary_key=True) 

2021 

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

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

2024 title = Column(String, nullable=False) 

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

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

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

2028 # the human-readable address 

2029 address = Column(String, nullable=True) 

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

2031 

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

2033 

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

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

2036 photo = relationship("Upload") 

2037 

2038 __table_args__ = ( 

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

2040 CheckConstraint( 

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

2042 name="geom_iff_address", 

2043 ), 

2044 ) 

2045 

2046 @property 

2047 def coordinates(self): 

2048 # returns (lat, lng) or None 

2049 return get_coordinates(self.geom) 

2050 

2051 def __repr__(self): 

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

2053 

2054 

2055class ClusterEventAssociation(Base): 

2056 """ 

2057 events related to clusters 

2058 """ 

2059 

2060 __tablename__ = "cluster_event_associations" 

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

2062 

2063 id = Column(BigInteger, primary_key=True) 

2064 

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

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

2067 

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

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

2070 

2071 

2072class Event(Base): 

2073 """ 

2074 An event is compose of two parts: 

2075 

2076 * An event template (Event) 

2077 * An occurrence (EventOccurrence) 

2078 

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

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

2081 """ 

2082 

2083 __tablename__ = "events" 

2084 

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

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

2087 

2088 title = Column(String, nullable=False) 

2089 

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

2091 

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

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

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

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

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

2097 

2098 parent_node = relationship( 

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

2100 ) 

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

2102 subscribers = relationship( 

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

2104 ) 

2105 organizers = relationship( 

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

2107 ) 

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

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

2110 owner_cluster = relationship( 

2111 "Cluster", 

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

2113 uselist=False, 

2114 foreign_keys="Event.owner_cluster_id", 

2115 ) 

2116 

2117 __table_args__ = ( 

2118 # Only one of owner_user and owner_cluster should be set 

2119 CheckConstraint( 

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

2121 name="one_owner", 

2122 ), 

2123 ) 

2124 

2125 

2126class EventOccurrence(Base): 

2127 __tablename__ = "event_occurrences" 

2128 

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

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

2131 

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

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

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

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

2136 

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

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

2139 

2140 # a null geom is an online-only event 

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

2142 # physical address, iff geom is not null 

2143 address = Column(String, nullable=True) 

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

2145 link = Column(String, nullable=True) 

2146 

2147 timezone = "Etc/UTC" 

2148 

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

2150 # simplifies database constraints, etc 

2151 during = Column(TSTZRANGE, nullable=False) 

2152 

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

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

2155 

2156 creator_user = relationship( 

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

2158 ) 

2159 event = relationship( 

2160 "Event", 

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

2162 remote_side="Event.id", 

2163 foreign_keys="EventOccurrence.event_id", 

2164 ) 

2165 

2166 photo = relationship("Upload") 

2167 

2168 __table_args__ = ( 

2169 # Geom and address go together 

2170 CheckConstraint( 

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

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

2173 name="geom_iff_address", 

2174 ), 

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

2176 CheckConstraint( 

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

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

2179 name="link_or_geom", 

2180 ), 

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

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

2183 ) 

2184 

2185 @property 

2186 def coordinates(self): 

2187 # returns (lat, lng) or None 

2188 return get_coordinates(self.geom) 

2189 

2190 @hybrid_property 

2191 def start_time(self): 

2192 return self.during.lower 

2193 

2194 @start_time.expression 

2195 def start_time(cls): 

2196 return func.lower(cls.during) 

2197 

2198 @hybrid_property 

2199 def end_time(self): 

2200 return self.during.upper 

2201 

2202 @end_time.expression 

2203 def end_time(cls): 

2204 return func.upper(cls.during) 

2205 

2206 

2207class EventSubscription(Base): 

2208 """ 

2209 Users' subscriptions to events 

2210 """ 

2211 

2212 __tablename__ = "event_subscriptions" 

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

2214 

2215 id = Column(BigInteger, primary_key=True) 

2216 

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

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

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

2220 

2221 user = relationship("User") 

2222 event = relationship("Event") 

2223 

2224 

2225class EventOrganizer(Base): 

2226 """ 

2227 Organizers for events 

2228 """ 

2229 

2230 __tablename__ = "event_organizers" 

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

2232 

2233 id = Column(BigInteger, primary_key=True) 

2234 

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

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

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

2238 

2239 user = relationship("User") 

2240 event = relationship("Event") 

2241 

2242 

2243class AttendeeStatus(enum.Enum): 

2244 going = enum.auto() 

2245 maybe = enum.auto() 

2246 

2247 

2248class EventOccurrenceAttendee(Base): 

2249 """ 

2250 Attendees for events 

2251 """ 

2252 

2253 __tablename__ = "event_occurrence_attendees" 

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

2255 

2256 id = Column(BigInteger, primary_key=True) 

2257 

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

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

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

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

2262 

2263 user = relationship("User") 

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

2265 

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

2267 

2268 

2269class EventCommunityInviteRequest(Base): 

2270 """ 

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

2272 """ 

2273 

2274 __tablename__ = "event_community_invite_requests" 

2275 

2276 id = Column(BigInteger, primary_key=True) 

2277 

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

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

2280 

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

2282 

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

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

2285 approved = Column(Boolean, nullable=True) 

2286 

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

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

2289 

2290 __table_args__ = ( 

2291 # each user can only request once 

2292 UniqueConstraint("occurrence_id", "user_id"), 

2293 # each event can only have one notification sent out 

2294 Index( 

2295 "ix_event_community_invite_requests_unique", 

2296 occurrence_id, 

2297 unique=True, 

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

2299 ), 

2300 # decided and approved ought to be null simultaneously 

2301 CheckConstraint( 

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

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

2304 name="decided_approved", 

2305 ), 

2306 ) 

2307 

2308 

2309class ClusterDiscussionAssociation(Base): 

2310 """ 

2311 discussions related to clusters 

2312 """ 

2313 

2314 __tablename__ = "cluster_discussion_associations" 

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

2316 

2317 id = Column(BigInteger, primary_key=True) 

2318 

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

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

2321 

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

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

2324 

2325 

2326class Discussion(Base): 

2327 """ 

2328 forum board 

2329 """ 

2330 

2331 __tablename__ = "discussions" 

2332 

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

2334 

2335 title = Column(String, nullable=False) 

2336 content = Column(String, nullable=False) 

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

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

2339 

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

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

2342 

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

2344 

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

2346 

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

2348 

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

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

2351 

2352 

2353class DiscussionSubscription(Base): 

2354 """ 

2355 users subscriptions to discussions 

2356 """ 

2357 

2358 __tablename__ = "discussion_subscriptions" 

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

2360 

2361 id = Column(BigInteger, primary_key=True) 

2362 

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

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

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

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

2367 

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

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

2370 

2371 

2372class Thread(Base): 

2373 """ 

2374 Thread 

2375 """ 

2376 

2377 __tablename__ = "threads" 

2378 

2379 id = Column(BigInteger, primary_key=True) 

2380 

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

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

2383 

2384 

2385class Comment(Base): 

2386 """ 

2387 Comment 

2388 """ 

2389 

2390 __tablename__ = "comments" 

2391 

2392 id = Column(BigInteger, primary_key=True) 

2393 

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

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

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

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

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

2399 

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

2401 

2402 

2403class Reply(Base): 

2404 """ 

2405 Reply 

2406 """ 

2407 

2408 __tablename__ = "replies" 

2409 

2410 id = Column(BigInteger, primary_key=True) 

2411 

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

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

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

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

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

2417 

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

2419 

2420 

2421class BackgroundJobState(enum.Enum): 

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

2423 pending = enum.auto() 

2424 # job complete 

2425 completed = enum.auto() 

2426 # error occured, will be retried 

2427 error = enum.auto() 

2428 # failed too many times, not retrying anymore 

2429 failed = enum.auto() 

2430 

2431 

2432class BackgroundJob(Base): 

2433 """ 

2434 This table implements a queue of background jobs. 

2435 """ 

2436 

2437 __tablename__ = "background_jobs" 

2438 

2439 id = Column(BigInteger, primary_key=True) 

2440 

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

2442 job_type = Column(String, nullable=False) 

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

2444 

2445 # time queued 

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

2447 

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

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

2450 

2451 # used to count number of retries for failed jobs 

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

2453 

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

2455 

2456 # higher is more important 

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

2458 

2459 # protobuf encoded job payload 

2460 payload = Column(Binary, nullable=False) 

2461 

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

2463 failure_info = Column(String, nullable=True) 

2464 

2465 __table_args__ = ( 

2466 # used in looking up background jobs to attempt 

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

2468 Index( 

2469 "ix_background_jobs_lookup", 

2470 priority.desc(), 

2471 next_attempt_after, 

2472 (max_tries - try_count), 

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

2474 ), 

2475 ) 

2476 

2477 @hybrid_property 

2478 def ready_for_retry(self): 

2479 return ( 

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

2481 & (self.try_count < self.max_tries) 

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

2483 ) 

2484 

2485 def __repr__(self): 

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

2487 

2488 

2489class NotificationDeliveryType(enum.Enum): 

2490 # send push notification to mobile/web 

2491 push = enum.auto() 

2492 # send individual email immediately 

2493 email = enum.auto() 

2494 # send in digest 

2495 digest = enum.auto() 

2496 

2497 

2498dt = NotificationDeliveryType 

2499nd = notification_data_pb2 

2500 

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

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

2503 

2504 

2505class NotificationTopicAction(enum.Enum): 

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

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

2508 self.defaults = defaults 

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

2510 self.user_editable = user_editable 

2511 

2512 self.data_type = data_type 

2513 

2514 def unpack(self): 

2515 return self.topic, self.action 

2516 

2517 @property 

2518 def display(self): 

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

2520 

2521 def __str__(self): 

2522 return self.display 

2523 

2524 # topic, action, default delivery types 

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

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

2527 

2528 # host requests 

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

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

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

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

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

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

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

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

2537 

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

2539 

2540 # you receive a friend ref 

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

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

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

2544 # ... the surfer 

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

2546 

2547 # you hosted 

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

2549 # you surfed 

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

2551 

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

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

2554 

2555 # group chats 

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

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

2558 

2559 # events 

2560 # approved by mods 

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

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

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

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

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

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

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

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

2569 # toplevel comment on an event 

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

2571 

2572 # discussion created 

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

2574 # someone comments on your discussion 

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

2576 

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

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

2579 

2580 # account settings 

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

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

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

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

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

2586 # reset password 

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

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

2589 

2590 # account deletion 

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

2592 # no more pushing to do 

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

2594 # undeleted 

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

2596 

2597 # admin actions 

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

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

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

2601 

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

2603 

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

2605 

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

2607 

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

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

2610 

2611 # general announcements 

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

2613 

2614 

2615class NotificationPreference(Base): 

2616 __tablename__ = "notification_preferences" 

2617 

2618 id = Column(BigInteger, primary_key=True) 

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

2620 

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

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

2623 deliver = Column(Boolean, nullable=False) 

2624 

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

2626 

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

2628 

2629 

2630class Notification(Base): 

2631 """ 

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

2633 """ 

2634 

2635 __tablename__ = "notifications" 

2636 

2637 id = Column(BigInteger, primary_key=True) 

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

2639 

2640 # recipient user id 

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

2642 

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

2644 key = Column(String, nullable=False) 

2645 

2646 data = Column(Binary, nullable=False) 

2647 

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

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

2650 

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

2652 

2653 __table_args__ = ( 

2654 # used in looking up which notifications need delivery 

2655 Index( 

2656 "ix_notifications_created", 

2657 created, 

2658 ), 

2659 # Fast lookup for unseen notification count 

2660 Index( 

2661 "ix_notifications_unseen", 

2662 user_id, 

2663 topic_action, 

2664 postgresql_where=(is_seen == False), 

2665 ), 

2666 # Fast lookup for latest notifications 

2667 Index( 

2668 "ix_notifications_latest", 

2669 user_id, 

2670 id.desc(), 

2671 topic_action, 

2672 ), 

2673 ) 

2674 

2675 @property 

2676 def topic(self): 

2677 return self.topic_action.topic 

2678 

2679 @property 

2680 def action(self): 

2681 return self.topic_action.action 

2682 

2683 

2684class NotificationDelivery(Base): 

2685 __tablename__ = "notification_deliveries" 

2686 

2687 id = Column(BigInteger, primary_key=True) 

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

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

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

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

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

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

2694 # todo: device id 

2695 # todo: receipt id, etc 

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

2697 

2698 __table_args__ = ( 

2699 UniqueConstraint("notification_id", "delivery_type"), 

2700 # used in looking up which notifications need delivery 

2701 Index( 

2702 "ix_notification_deliveries_delivery_type", 

2703 delivery_type, 

2704 postgresql_where=(delivered != None), 

2705 ), 

2706 Index( 

2707 "ix_notification_deliveries_dt_ni_dnull", 

2708 delivery_type, 

2709 notification_id, 

2710 delivered == None, 

2711 ), 

2712 ) 

2713 

2714 

2715class PushNotificationSubscription(Base): 

2716 __tablename__ = "push_notification_subscriptions" 

2717 

2718 id = Column(BigInteger, primary_key=True) 

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

2720 

2721 # which user this is connected to 

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

2723 

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

2725 # the endpoint 

2726 endpoint = Column(String, nullable=False) 

2727 # the "auth" key 

2728 auth_key = Column(Binary, nullable=False) 

2729 # the "p256dh" key 

2730 p256dh_key = Column(Binary, nullable=False) 

2731 

2732 full_subscription_info = Column(String, nullable=False) 

2733 

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

2735 user_agent = Column(String, nullable=True) 

2736 

2737 # when it was disabled 

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

2739 

2740 user = relationship("User") 

2741 

2742 

2743class PushNotificationDeliveryAttempt(Base): 

2744 __tablename__ = "push_notification_delivery_attempt" 

2745 

2746 id = Column(BigInteger, primary_key=True) 

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

2748 

2749 push_notification_subscription_id = Column( 

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

2751 ) 

2752 

2753 success = Column(Boolean, nullable=False) 

2754 # the HTTP status code, 201 is success 

2755 status_code = Column(Integer, nullable=False) 

2756 

2757 # can be null if it was a success 

2758 response = Column(String, nullable=True) 

2759 

2760 push_notification_subscription = relationship("PushNotificationSubscription") 

2761 

2762 

2763class Language(Base): 

2764 """ 

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

2766 """ 

2767 

2768 __tablename__ = "languages" 

2769 

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

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

2772 

2773 # the english name 

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

2775 

2776 

2777class Region(Base): 

2778 """ 

2779 Table of regions 

2780 """ 

2781 

2782 __tablename__ = "regions" 

2783 

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

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

2786 

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

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

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

2790 

2791 

2792class UserBlock(Base): 

2793 """ 

2794 Table of blocked users 

2795 """ 

2796 

2797 __tablename__ = "user_blocks" 

2798 

2799 id = Column(BigInteger, primary_key=True) 

2800 

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

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

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

2804 

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

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

2807 

2808 __table_args__ = ( 

2809 UniqueConstraint("blocking_user_id", "blocked_user_id"), 

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

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

2812 ) 

2813 

2814 

2815class APICall(Base): 

2816 """ 

2817 API call logs 

2818 """ 

2819 

2820 __tablename__ = "api_calls" 

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

2822 

2823 id = Column(BigInteger, primary_key=True) 

2824 

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

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

2827 

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

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

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

2831 

2832 # approximate time of the call 

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

2834 

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

2836 method = Column(String, nullable=False) 

2837 

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

2839 status_code = Column(String, nullable=True) 

2840 

2841 # handler duration (excluding serialization, etc) 

2842 duration = Column(Float, nullable=False) 

2843 

2844 # user_id of caller, None means not logged in 

2845 user_id = Column(BigInteger, nullable=True) 

2846 

2847 # sanitized request bytes 

2848 request = Column(Binary, nullable=True) 

2849 

2850 # sanitized response bytes 

2851 response = Column(Binary, nullable=True) 

2852 

2853 # whether response bytes have been truncated 

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

2855 

2856 # the exception traceback, if any 

2857 traceback = Column(String, nullable=True) 

2858 

2859 # human readable perf report 

2860 perf_report = Column(String, nullable=True) 

2861 

2862 # details of the browser, if available 

2863 ip_address = Column(String, nullable=True) 

2864 user_agent = Column(String, nullable=True) 

2865 

2866 

2867class AccountDeletionReason(Base): 

2868 __tablename__ = "account_deletion_reason" 

2869 

2870 id = Column(BigInteger, primary_key=True) 

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

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

2873 reason = Column(String, nullable=True) 

2874 

2875 user = relationship("User") 

2876 

2877 

2878class ModerationUserList(Base): 

2879 """ 

2880 Represents a list of users listed together by a moderator 

2881 """ 

2882 

2883 __tablename__ = "moderation_user_lists" 

2884 

2885 id = Column(BigInteger, primary_key=True) 

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

2887 

2888 # Relationships 

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

2890 

2891 

2892class ModerationUserListMember(Base): 

2893 """ 

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

2895 """ 

2896 

2897 __tablename__ = "moderation_user_list_members" 

2898 

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

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

2901 

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

2903 

2904 

2905class AntiBotLog(Base): 

2906 __tablename__ = "antibot_logs" 

2907 

2908 id = Column(BigInteger, primary_key=True) 

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

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

2911 

2912 ip_address = Column(String, nullable=True) 

2913 user_agent = Column(String, nullable=True) 

2914 

2915 action = Column(String, nullable=False) 

2916 token = Column(String, nullable=False) 

2917 

2918 score = Column(Float, nullable=False) 

2919 provider_data = Column(JSON, nullable=False) 

2920 

2921 

2922class RateLimitAction(enum.Enum): 

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

2924 

2925 host_request = "host request" 

2926 friend_request = "friend request" 

2927 chat_initiation = "chat initiation" 

2928 

2929 

2930class RateLimitViolation(Base): 

2931 __tablename__ = "rate_limit_violations" 

2932 

2933 id = Column(BigInteger, primary_key=True) 

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

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

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

2937 is_hard_limit = Column(Boolean, nullable=False) 

2938 

2939 user = relationship("User") 

2940 

2941 __table_args__ = ( 

2942 # Fast lookup for rate limits in interval 

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

2944 ) 

2945 

2946 

2947class Volunteer(Base): 

2948 __tablename__ = "volunteers" 

2949 

2950 id = Column(BigInteger, primary_key=True) 

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

2952 

2953 display_name = Column(String, nullable=True) 

2954 display_location = Column(String, nullable=True) 

2955 

2956 role = Column(String, nullable=False) 

2957 

2958 # custom sort order on team page, sorted ascending 

2959 sort_key = Column(Float, nullable=True) 

2960 

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

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

2963 

2964 link_type = Column(String, nullable=True) 

2965 link_text = Column(String, nullable=True) 

2966 link_url = Column(String, nullable=True) 

2967 

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

2969 

2970 __table_args__ = ( 

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

2972 CheckConstraint( 

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

2974 name="link_type_text", 

2975 ), 

2976 )