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

1126 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-06-01 15:07 +0000

1import enum 

2 

3from geoalchemy2.types import Geometry 

4from google.protobuf import empty_pb2 

5from sqlalchemy import ( 

6 ARRAY, 

7 BigInteger, 

8 Boolean, 

9 CheckConstraint, 

10 Column, 

11 Date, 

12 DateTime, 

13 Enum, 

14 Float, 

15 ForeignKey, 

16 Index, 

17 Integer, 

18 Interval, 

19 MetaData, 

20 Sequence, 

21 String, 

22 UniqueConstraint, 

23) 

24from sqlalchemy import LargeBinary as Binary 

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

26from sqlalchemy.ext.associationproxy import association_proxy 

27from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property 

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

29from sqlalchemy.sql import and_, func, not_, text 

30from sqlalchemy.sql import select as sa_select 

31 

32from couchers import urls 

33from couchers.config import config 

34from couchers.constants import ( 

35 DATETIME_INFINITY, 

36 DATETIME_MINUS_INFINITY, 

37 EMAIL_REGEX, 

38 GUIDELINES_VERSION, 

39 PHONE_VERIFICATION_LIFETIME, 

40 SMS_CODE_LIFETIME, 

41 TOS_VERSION, 

42) 

43from couchers.utils import ( 

44 date_in_timezone, 

45 get_coordinates, 

46 last_active_coarsen, 

47 now, 

48) 

49from proto import notification_data_pb2 

50 

51meta = MetaData( 

52 naming_convention={ 

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

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

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

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

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

58 } 

59) 

60 

61Base = declarative_base(metadata=meta) 

62 

63 

64class HostingStatus(enum.Enum): 

65 can_host = enum.auto() 

66 maybe = enum.auto() 

67 cant_host = enum.auto() 

68 

69 

70class MeetupStatus(enum.Enum): 

71 wants_to_meetup = enum.auto() 

72 open_to_meetup = enum.auto() 

73 does_not_want_to_meetup = enum.auto() 

74 

75 

76class SmokingLocation(enum.Enum): 

77 yes = enum.auto() 

78 window = enum.auto() 

79 outside = enum.auto() 

80 no = enum.auto() 

81 

82 

83class SleepingArrangement(enum.Enum): 

84 private = enum.auto() 

85 common = enum.auto() 

86 shared_room = enum.auto() 

87 

88 

89class ParkingDetails(enum.Enum): 

90 free_onsite = enum.auto() 

91 free_offsite = enum.auto() 

92 paid_onsite = enum.auto() 

93 paid_offsite = enum.auto() 

94 

95 

96class ProfilePublicVisibility(enum.Enum): 

97 # no public info 

98 nothing = enum.auto() 

99 # only show on map, randomized, unclickable 

100 map_only = enum.auto() 

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

102 limited = enum.auto() 

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

104 most = enum.auto() 

105 # all but references 

106 full = enum.auto() 

107 

108 

109class TimezoneArea(Base): 

110 __tablename__ = "timezone_areas" 

111 id = Column(BigInteger, primary_key=True) 

112 

113 tzid = Column(String) 

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

115 

116 __table_args__ = ( 

117 Index( 

118 "ix_timezone_areas_geom_tzid", 

119 geom, 

120 tzid, 

121 postgresql_using="gist", 

122 ), 

123 ) 

124 

125 

126class User(Base): 

127 """ 

128 Basic user and profile details 

129 """ 

130 

131 __tablename__ = "users" 

132 

133 id = Column(BigInteger, primary_key=True) 

134 

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

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

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

138 hashed_password = Column(Binary, nullable=False) 

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

140 phone = Column(String, nullable=True, server_default=text("NULL")) 

141 # language preference -- defaults to empty string 

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

143 

144 # timezones should always be UTC 

145 ## location 

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

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

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

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

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

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

152 geom_radius = Column(Float, nullable=False) 

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

154 city = Column(String, nullable=False) 

155 # "Grew up in" on profile 

156 hometown = Column(String, nullable=True) 

157 

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

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

160 

161 timezone = column_property( 

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

163 deferred=True, 

164 ) 

165 

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

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

168 

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

170 has_modified_public_visibility = Column(Boolean, nullable=False, server_default=text("false")) 

171 

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

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

174 # same as above for host requests 

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

176 

177 # display name 

178 name = Column(String, nullable=False) 

179 gender = Column(String, nullable=False) 

180 pronouns = Column(String, nullable=True) 

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

182 

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

184 

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

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

187 

188 # community standing score 

189 community_standing = Column(Float, nullable=True) 

190 

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

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

193 

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

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

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

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

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

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

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

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

202 

203 is_banned = Column(Boolean, nullable=False, server_default=text("false")) 

204 is_deleted = Column(Boolean, nullable=False, server_default=text("false")) 

205 is_superuser = Column(Boolean, nullable=False, server_default=text("false")) 

206 

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

208 # accidental or they changed their mind 

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

210 undelete_token = Column(String, nullable=True) 

211 # validity of the undelete token 

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

213 

214 # hosting preferences 

215 max_guests = Column(Integer, nullable=True) 

216 last_minute = Column(Boolean, nullable=True) 

217 has_pets = Column(Boolean, nullable=True) 

218 accepts_pets = Column(Boolean, nullable=True) 

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

220 has_kids = Column(Boolean, nullable=True) 

221 accepts_kids = Column(Boolean, nullable=True) 

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

223 has_housemates = Column(Boolean, nullable=True) 

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

225 wheelchair_accessible = Column(Boolean, nullable=True) 

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

227 smokes_at_home = Column(Boolean, nullable=True) 

228 drinking_allowed = Column(Boolean, nullable=True) 

229 drinks_at_home = Column(Boolean, nullable=True) 

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

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

232 

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

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

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

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

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

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

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

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

241 parking = Column(Boolean, nullable=True) 

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

243 camping_ok = Column(Boolean, nullable=True) 

244 

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

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

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

248 filled_contributor_form = Column(Boolean, nullable=False, server_default="false") 

249 

250 # number of onboarding emails sent 

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

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

253 

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

255 in_sync_with_newsletter = Column(Boolean, nullable=False, server_default="false") 

256 # opted out of the newsletter 

257 opt_out_of_newsletter = Column(Boolean, nullable=False, server_default="false") 

258 

259 # set to null to receive no digests 

260 digest_frequency = Column(Interval, nullable=True) 

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

262 

263 # for changing their email 

264 new_email = Column(String, nullable=True) 

265 

266 new_email_token = Column(String, nullable=True) 

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

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

269 

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

271 

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

273 # ,-------------------, 

274 # | Start | 

275 # | phone = None | someone else 

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

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

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

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

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

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

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

283 # '-----------------' +-------- | ------+----+--------------------+ '-----------------------' 

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

285 # | | ^ V | | 

286 # ,-----------------, | | ,-------------------, | ,-----------------------, 

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

288 # | phone = xx | | phone = xx | | | phone = xx | 

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

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

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

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

293 # '-----------------' '-------------------' '-----------------------' 

294 

295 # randomly generated Luhn 6-digit string 

296 phone_verification_token = Column(String(6), nullable=True, server_default=text("NULL")) 

297 

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

299 phone_verification_verified = Column(DateTime(timezone=True), nullable=True, server_default=text("NULL")) 

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

301 

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

303 # e.g. cus_JjoXHttuZopv0t 

304 # for new US entity 

305 stripe_customer_id = Column(String, nullable=True) 

306 # for old AU entity 

307 stripe_customer_id_old = Column(String, nullable=True) 

308 

309 has_passport_sex_gender_exception = Column(Boolean, nullable=False, server_default=text("false")) 

310 

311 # checking for phone verification 

312 has_donated = Column(Boolean, nullable=False, server_default=text("false")) 

313 

314 # whether this user has all emails turned off 

315 do_not_email = Column(Boolean, nullable=False, server_default=text("false")) 

316 

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

318 

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

320 

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

322 needs_to_update_location = Column(Boolean, nullable=False, server_default=text("false")) 

323 

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

325 

326 __table_args__ = ( 

327 # Verified phone numbers should be unique 

328 Index( 

329 "ix_users_unique_phone", 

330 phone, 

331 unique=True, 

332 postgresql_where=phone_verification_verified != None, 

333 ), 

334 Index( 

335 "ix_users_active", 

336 id, 

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

338 ), 

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

340 Index( 

341 "ix_users_geom_active", 

342 geom, 

343 id, 

344 username, 

345 postgresql_using="gist", 

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

347 ), 

348 Index( 

349 "ix_users_by_id", 

350 id, 

351 postgresql_using="hash", 

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

353 ), 

354 Index( 

355 "ix_users_by_username", 

356 username, 

357 postgresql_using="hash", 

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

359 ), 

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

361 CheckConstraint( 

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

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

364 name="check_new_email_token_state", 

365 ), 

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

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

368 CheckConstraint( 

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

370 name="phone_verified_conditions", 

371 ), 

372 # Email must match our regex 

373 CheckConstraint( 

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

375 name="valid_email", 

376 ), 

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

378 CheckConstraint( 

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

380 name="undelete_nullity", 

381 ), 

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

383 CheckConstraint( 

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

385 name="do_not_email_inactive", 

386 ), 

387 ) 

388 

389 @hybrid_property 

390 def has_completed_profile(self): 

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

392 

393 @has_completed_profile.expression 

394 def has_completed_profile(cls): 

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

396 

397 @hybrid_property 

398 def jailed_missing_tos(self): 

399 return self.accepted_tos < TOS_VERSION 

400 

401 @hybrid_property 

402 def jailed_missing_community_guidelines(self): 

403 return self.accepted_community_guidelines < GUIDELINES_VERSION 

404 

405 @hybrid_property 

406 def jailed_pending_mod_notes(self): 

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

408 

409 @hybrid_property 

410 def jailed_pending_activeness_probe(self): 

411 return self.pending_activeness_probe != None 

412 

413 @hybrid_property 

414 def is_jailed(self): 

415 return ( 

416 self.jailed_missing_tos 

417 | self.jailed_missing_community_guidelines 

418 | self.is_missing_location 

419 | self.jailed_pending_mod_notes 

420 | self.jailed_pending_activeness_probe 

421 ) 

422 

423 @hybrid_property 

424 def is_missing_location(self): 

425 return self.needs_to_update_location 

426 

427 @hybrid_property 

428 def is_visible(self): 

429 return ~(self.is_banned | self.is_deleted) 

430 

431 @property 

432 def coordinates(self): 

433 return get_coordinates(self.geom) 

434 

435 @property 

436 def display_joined(self): 

437 """ 

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

439 """ 

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

441 

442 @property 

443 def display_last_active(self): 

444 """ 

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

446 """ 

447 return last_active_coarsen(self.last_active) 

448 

449 @hybrid_property 

450 def phone_is_verified(self): 

451 return ( 

452 self.phone_verification_verified is not None 

453 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

454 ) 

455 

456 @phone_is_verified.expression 

457 def phone_is_verified(cls): 

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

459 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

460 ) 

461 

462 @hybrid_property 

463 def phone_code_expired(self): 

464 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME 

465 

466 def __repr__(self): 

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

468 

469 

470class UserBadge(Base): 

471 """ 

472 A badge on a user's profile 

473 """ 

474 

475 __tablename__ = "user_badges" 

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

477 

478 id = Column(BigInteger, primary_key=True) 

479 

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

481 # corresponds to "id" in badges.json 

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

483 

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

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

486 

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

488 

489 

490class ActivenessProbeStatus(enum.Enum): 

491 # no response yet 

492 pending = enum.auto() 

493 

494 # didn't respond on time 

495 expired = enum.auto() 

496 

497 # responded that they're still active 

498 still_active = enum.auto() 

499 

500 # responded that they're no longer active 

501 no_longer_active = enum.auto() 

502 

503 

504class ActivenessProbe(Base): 

505 """ 

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

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

508 show up as such. 

509 """ 

510 

511 __tablename__ = "activeness_probes" 

512 

513 id = Column(BigInteger, primary_key=True) 

514 

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

516 # the time this probe was initiated 

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

518 # the number of reminders sent for this probe 

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

520 

521 # the time of response 

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

523 # the response value 

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

525 

526 @hybrid_property 

527 def is_pending(self): 

528 return self.responded == None 

529 

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

531 

532 __table_args__ = ( 

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

534 Index( 

535 "ix_activeness_probe_unique_pending_response", 

536 user_id, 

537 unique=True, 

538 postgresql_where=responded == None, 

539 ), 

540 # response time is none iff response is pending 

541 CheckConstraint( 

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

543 name="pending_has_no_responded", 

544 ), 

545 ) 

546 

547 

548User.pending_activeness_probe = relationship( 

549 ActivenessProbe, 

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

551 uselist=False, 

552 back_populates="user", 

553) 

554 

555 

556class StrongVerificationAttemptStatus(enum.Enum): 

557 ## full data states 

558 # completed, this now provides verification for a user 

559 succeeded = enum.auto() 

560 

561 ## no data states 

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

563 in_progress_waiting_on_user_to_open_app = enum.auto() 

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

565 in_progress_waiting_on_user_in_app = enum.auto() 

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

567 in_progress_waiting_on_backend = enum.auto() 

568 # failed, no data 

569 failed = enum.auto() 

570 

571 # duplicate, at our end, has data 

572 duplicate = enum.auto() 

573 

574 ## minimal data states 

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

576 deleted = enum.auto() 

577 

578 

579class PassportSex(enum.Enum): 

580 """ 

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

582 """ 

583 

584 male = enum.auto() 

585 female = enum.auto() 

586 unspecified = enum.auto() 

587 

588 

589class StrongVerificationAttempt(Base): 

590 """ 

591 An attempt to perform strong verification 

592 """ 

593 

594 __tablename__ = "strong_verification_attempts" 

595 

596 # our verification id 

597 id = Column(BigInteger, primary_key=True) 

598 

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

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

601 

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

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

604 

605 status = Column( 

606 Enum(StrongVerificationAttemptStatus), 

607 nullable=False, 

608 default=StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app, 

609 ) 

610 

611 ## full data 

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

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

614 passport_encrypted_data = Column(Binary, nullable=True) 

615 passport_date_of_birth = Column(Date, nullable=True) 

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

617 

618 ## minimal data: this will not be deleted 

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

620 passport_expiry_date = Column(Date, nullable=True) 

621 passport_nationality = Column(String, nullable=True) 

622 # last three characters of the passport number 

623 passport_last_three_document_chars = Column(String, nullable=True) 

624 

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

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

627 

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

629 

630 user = relationship("User") 

631 

632 @hybrid_property 

633 def is_valid(self): 

634 """ 

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

636 """ 

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

638 

639 @is_valid.expression 

640 def is_valid(cls): 

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

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

643 ) 

644 

645 @hybrid_property 

646 def is_visible(self): 

647 return self.status != StrongVerificationAttemptStatus.deleted 

648 

649 @hybrid_method 

650 def _raw_birthdate_match(self, user): 

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

652 return self.passport_date_of_birth == user.birthdate 

653 

654 @hybrid_method 

655 def matches_birthdate(self, user): 

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

657 

658 @hybrid_method 

659 def _raw_gender_match(self, user): 

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

661 return ( 

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

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

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

665 | (user.has_passport_sex_gender_exception == True) 

666 ) 

667 

668 @hybrid_method 

669 def matches_gender(self, user): 

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

671 

672 @hybrid_method 

673 def has_strong_verification(self, user): 

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

675 

676 __table_args__ = ( 

677 # used to look up verification status for a user 

678 Index( 

679 "ix_strong_verification_attempts_current", 

680 user_id, 

681 passport_expiry_date, 

682 postgresql_where=status == StrongVerificationAttemptStatus.succeeded, 

683 ), 

684 # each passport can be verified only once 

685 Index( 

686 "ix_strong_verification_attempts_unique_succeeded", 

687 passport_expiry_date, 

688 passport_nationality, 

689 passport_last_three_document_chars, 

690 unique=True, 

691 postgresql_where=( 

692 (status == StrongVerificationAttemptStatus.succeeded) 

693 | (status == StrongVerificationAttemptStatus.deleted) 

694 ), 

695 ), 

696 # full data check 

697 CheckConstraint( 

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

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

700 name="full_data_status", 

701 ), 

702 # minimal data check 

703 CheckConstraint( 

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

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

706 name="minimal_data_status", 

707 ), 

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

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

710 CheckConstraint( 

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

712 name="full_data_implies_minimal_data", 

713 ), 

714 # succeeded implies full data 

715 CheckConstraint( 

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

717 name="succeeded_implies_full_data", 

718 ), 

719 # in_progress/failed implies no_data 

720 CheckConstraint( 

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

722 name="in_progress_failed_iris_implies_no_data", 

723 ), 

724 # deleted or duplicate implies minimal data 

725 CheckConstraint( 

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

727 name="deleted_duplicate_implies_minimal_data", 

728 ), 

729 ) 

730 

731 

732class ModNote(Base): 

733 """ 

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

735 

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

737 """ 

738 

739 __tablename__ = "mod_notes" 

740 id = Column(BigInteger, primary_key=True) 

741 

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

743 

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

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

746 

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

748 internal_id = Column(String, nullable=False) 

749 # the admin that left this note 

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

751 

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

753 

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

755 

756 def __repr__(self): 

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

758 

759 @hybrid_property 

760 def is_pending(self): 

761 return self.acknowledged == None 

762 

763 __table_args__ = ( 

764 # used to look up pending notes 

765 Index( 

766 "ix_mod_notes_unacknowledged", 

767 user_id, 

768 postgresql_where=acknowledged == None, 

769 ), 

770 ) 

771 

772 

773class StrongVerificationCallbackEvent(Base): 

774 __tablename__ = "strong_verification_callback_events" 

775 

776 id = Column(BigInteger, primary_key=True) 

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

778 

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

780 

781 iris_status = Column(String, nullable=False) 

782 

783 

784class DonationType(enum.Enum): 

785 one_time = enum.auto() 

786 recurring = enum.auto() 

787 

788 

789class DonationInitiation(Base): 

790 """ 

791 Whenever someone initiaties a donation through the platform 

792 """ 

793 

794 __tablename__ = "donation_initiations" 

795 id = Column(BigInteger, primary_key=True) 

796 

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

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

799 

800 amount = Column(Integer, nullable=False) 

801 stripe_checkout_session_id = Column(String, nullable=False) 

802 

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

804 

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

806 

807 

808class Invoice(Base): 

809 """ 

810 Successful donations, both one off and recurring 

811 

812 Triggered by `payment_intent.succeeded` webhook 

813 """ 

814 

815 __tablename__ = "invoices" 

816 

817 id = Column(BigInteger, primary_key=True) 

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

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

820 

821 amount = Column(Float, nullable=False) 

822 

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

824 stripe_receipt_url = Column(String, nullable=False) 

825 

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

827 

828 

829class LanguageFluency(enum.Enum): 

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

831 beginner = 1 

832 conversational = 2 

833 fluent = 3 

834 

835 

836class LanguageAbility(Base): 

837 __tablename__ = "language_abilities" 

838 __table_args__ = ( 

839 # Users can only have one language ability per language 

840 UniqueConstraint("user_id", "language_code"), 

841 ) 

842 

843 id = Column(BigInteger, primary_key=True) 

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

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

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

847 

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

849 language = relationship("Language") 

850 

851 

852class RegionVisited(Base): 

853 __tablename__ = "regions_visited" 

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

855 

856 id = Column(BigInteger, primary_key=True) 

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

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

859 

860 

861class RegionLived(Base): 

862 __tablename__ = "regions_lived" 

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

864 

865 id = Column(BigInteger, primary_key=True) 

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

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

868 

869 

870class FriendStatus(enum.Enum): 

871 pending = enum.auto() 

872 accepted = enum.auto() 

873 rejected = enum.auto() 

874 cancelled = enum.auto() 

875 

876 

877class FriendRelationship(Base): 

878 """ 

879 Friendship relations between users 

880 

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

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

883 """ 

884 

885 __tablename__ = "friend_relationships" 

886 

887 id = Column(BigInteger, primary_key=True) 

888 

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

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

891 

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

893 

894 # timezones should always be UTC 

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

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

897 

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

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

900 

901 __table_args__ = ( 

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

903 Index( 

904 "ix_friend_relationships_status_to_from", 

905 status, 

906 to_user_id, 

907 from_user_id, 

908 ), 

909 ) 

910 

911 

912class ContributeOption(enum.Enum): 

913 yes = enum.auto() 

914 maybe = enum.auto() 

915 no = enum.auto() 

916 

917 

918class ContributorForm(Base): 

919 """ 

920 Someone filled in the contributor form 

921 """ 

922 

923 __tablename__ = "contributor_forms" 

924 

925 id = Column(BigInteger, primary_key=True) 

926 

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

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

929 

930 ideas = Column(String, nullable=True) 

931 features = Column(String, nullable=True) 

932 experience = Column(String, nullable=True) 

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

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

935 expertise = Column(String, nullable=True) 

936 

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

938 

939 @hybrid_property 

940 def is_filled(self): 

941 """ 

942 Whether the form counts as having been filled 

943 """ 

944 return ( 

945 (self.ideas != None) 

946 | (self.features != None) 

947 | (self.experience != None) 

948 | (self.contribute != None) 

949 | (self.contribute_ways != []) 

950 | (self.expertise != None) 

951 ) 

952 

953 @property 

954 def should_notify(self): 

955 """ 

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

957 

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

959 """ 

960 return False 

961 

962 

963class SignupFlow(Base): 

964 """ 

965 Signup flows/incomplete users 

966 

967 Coinciding fields have the same meaning as in User 

968 """ 

969 

970 __tablename__ = "signup_flows" 

971 

972 id = Column(BigInteger, primary_key=True) 

973 

974 # housekeeping 

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

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

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

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

979 email_token = Column(String, nullable=True) 

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

981 

982 ## Basic 

983 name = Column(String, nullable=False) 

984 # TODO: unique across both tables 

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

986 # TODO: invitation, attribution 

987 

988 ## Account 

989 # TODO: unique across both tables 

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

991 hashed_password = Column(Binary, nullable=True) 

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

993 gender = Column(String, nullable=True) 

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

995 city = Column(String, nullable=True) 

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

997 geom_radius = Column(Float, nullable=True) 

998 

999 accepted_tos = Column(Integer, nullable=True) 

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

1001 

1002 opt_out_of_newsletter = Column(Boolean, nullable=True) 

1003 

1004 ## Feedback (now unused) 

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

1006 ideas = Column(String, nullable=True) 

1007 features = Column(String, nullable=True) 

1008 experience = Column(String, nullable=True) 

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

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

1011 expertise = Column(String, nullable=True) 

1012 

1013 @hybrid_property 

1014 def token_is_valid(self): 

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

1016 

1017 @hybrid_property 

1018 def account_is_filled(self): 

1019 return ( 

1020 (self.username != None) 

1021 & (self.birthdate != None) 

1022 & (self.gender != None) 

1023 & (self.hosting_status != None) 

1024 & (self.city != None) 

1025 & (self.geom != None) 

1026 & (self.geom_radius != None) 

1027 & (self.accepted_tos != None) 

1028 & (self.opt_out_of_newsletter != None) 

1029 ) 

1030 

1031 @hybrid_property 

1032 def is_completed(self): 

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

1034 

1035 

1036class LoginToken(Base): 

1037 """ 

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

1039 """ 

1040 

1041 __tablename__ = "login_tokens" 

1042 token = Column(String, primary_key=True) 

1043 

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

1045 

1046 # timezones should always be UTC 

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

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

1049 

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

1051 

1052 @hybrid_property 

1053 def is_valid(self): 

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

1055 

1056 def __repr__(self): 

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

1058 

1059 

1060class PasswordResetToken(Base): 

1061 __tablename__ = "password_reset_tokens" 

1062 token = Column(String, primary_key=True) 

1063 

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

1065 

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

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

1068 

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

1070 

1071 @hybrid_property 

1072 def is_valid(self): 

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

1074 

1075 def __repr__(self): 

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

1077 

1078 

1079class AccountDeletionToken(Base): 

1080 __tablename__ = "account_deletion_tokens" 

1081 

1082 token = Column(String, primary_key=True) 

1083 

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

1085 

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

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

1088 

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

1090 

1091 @hybrid_property 

1092 def is_valid(self): 

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

1094 

1095 def __repr__(self): 

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

1097 

1098 

1099class UserActivity(Base): 

1100 """ 

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

1102 

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

1104 """ 

1105 

1106 __tablename__ = "user_activity" 

1107 

1108 id = Column(BigInteger, primary_key=True) 

1109 

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

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

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

1113 

1114 # details of the browser, if available 

1115 ip_address = Column(INET, nullable=True) 

1116 user_agent = Column(String, nullable=True) 

1117 

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

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

1120 

1121 __table_args__ = ( 

1122 # helps look up this tuple quickly 

1123 Index( 

1124 "ix_user_activity_user_id_period_ip_address_user_agent", 

1125 user_id, 

1126 period, 

1127 ip_address, 

1128 user_agent, 

1129 unique=True, 

1130 ), 

1131 ) 

1132 

1133 

1134class UserSession(Base): 

1135 """ 

1136 API keys/session cookies for the app 

1137 

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

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

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

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

1142 site. 

1143 

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

1145 

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

1147 """ 

1148 

1149 __tablename__ = "sessions" 

1150 token = Column(String, primary_key=True) 

1151 

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

1153 

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

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

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

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

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

1159 is_api_key = Column(Boolean, nullable=False, server_default=text("false")) 

1160 

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

1162 long_lived = Column(Boolean, nullable=False) 

1163 

1164 # the time at which the session was created 

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

1166 

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

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

1169 

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

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

1172 

1173 # the last time this session was used 

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

1175 

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

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

1178 

1179 # details of the browser, if available 

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

1181 ip_address = Column(String, nullable=True) 

1182 user_agent = Column(String, nullable=True) 

1183 

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

1185 

1186 @hybrid_property 

1187 def is_valid(self): 

1188 """ 

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

1190 

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

1192 

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

1194 """ 

1195 return ( 

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

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

1198 & (self.deleted == None) 

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

1200 ) 

1201 

1202 __table_args__ = ( 

1203 Index( 

1204 "ix_sessions_by_token", 

1205 "token", 

1206 postgresql_using="hash", 

1207 ), 

1208 ) 

1209 

1210 

1211class Conversation(Base): 

1212 """ 

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

1214 """ 

1215 

1216 __tablename__ = "conversations" 

1217 

1218 id = Column(BigInteger, primary_key=True) 

1219 # timezone should always be UTC 

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

1221 

1222 def __repr__(self): 

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

1224 

1225 

1226class GroupChat(Base): 

1227 """ 

1228 Group chat 

1229 """ 

1230 

1231 __tablename__ = "group_chats" 

1232 

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

1234 

1235 title = Column(String, nullable=True) 

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

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

1238 is_dm = Column(Boolean, nullable=False) 

1239 

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

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

1242 

1243 def __repr__(self): 

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

1245 

1246 

1247class GroupChatRole(enum.Enum): 

1248 admin = enum.auto() 

1249 participant = enum.auto() 

1250 

1251 

1252class GroupChatSubscription(Base): 

1253 """ 

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

1255 """ 

1256 

1257 __tablename__ = "group_chat_subscriptions" 

1258 id = Column(BigInteger, primary_key=True) 

1259 

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

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

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

1263 

1264 # timezones should always be UTC 

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

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

1267 

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

1269 

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

1271 

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

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

1274 

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

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

1277 

1278 def muted_display(self): 

1279 """ 

1280 Returns (muted, muted_until) display values: 

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

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

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

1284 """ 

1285 if self.muted_until < now(): 

1286 return (False, None) 

1287 elif self.muted_until == DATETIME_INFINITY: 

1288 return (True, None) 

1289 else: 

1290 return (True, self.muted_until) 

1291 

1292 @hybrid_property 

1293 def is_muted(self): 

1294 return self.muted_until > func.now() 

1295 

1296 def __repr__(self): 

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

1298 

1299 

1300class MessageType(enum.Enum): 

1301 text = enum.auto() 

1302 # e.g. 

1303 # image = 

1304 # emoji = 

1305 # ... 

1306 chat_created = enum.auto() 

1307 chat_edited = enum.auto() 

1308 user_invited = enum.auto() 

1309 user_left = enum.auto() 

1310 user_made_admin = enum.auto() 

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

1312 host_request_status_changed = enum.auto() 

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

1314 

1315 

1316class HostRequestStatus(enum.Enum): 

1317 pending = enum.auto() 

1318 accepted = enum.auto() 

1319 rejected = enum.auto() 

1320 confirmed = enum.auto() 

1321 cancelled = enum.auto() 

1322 

1323 

1324class Message(Base): 

1325 """ 

1326 A message. 

1327 

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

1329 """ 

1330 

1331 __tablename__ = "messages" 

1332 

1333 id = Column(BigInteger, primary_key=True) 

1334 

1335 # which conversation the message belongs in 

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

1337 

1338 # the user that sent the message/command 

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

1340 

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

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

1343 

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

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

1346 

1347 # time sent, timezone should always be UTC 

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

1349 

1350 # the plain-text message text if not control 

1351 text = Column(String, nullable=True) 

1352 

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

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

1355 

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

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

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

1359 

1360 @property 

1361 def is_normal_message(self): 

1362 """ 

1363 There's only one normal type atm, text 

1364 """ 

1365 return self.message_type == MessageType.text 

1366 

1367 def __repr__(self): 

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

1369 

1370 

1371class ContentReport(Base): 

1372 """ 

1373 A piece of content reported to admins 

1374 """ 

1375 

1376 __tablename__ = "content_reports" 

1377 

1378 id = Column(BigInteger, primary_key=True) 

1379 

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

1381 

1382 # the user who reported or flagged the content 

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

1384 

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

1386 reason = Column(String, nullable=False) 

1387 # a short description 

1388 description = Column(String, nullable=False) 

1389 

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

1391 content_ref = Column(String, nullable=False) 

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

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

1394 

1395 # details of the browser, if available 

1396 user_agent = Column(String, nullable=False) 

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

1398 page = Column(String, nullable=False) 

1399 

1400 # see comments above for reporting vs author 

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

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

1403 

1404 

1405class Email(Base): 

1406 """ 

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

1408 """ 

1409 

1410 __tablename__ = "emails" 

1411 

1412 id = Column(String, primary_key=True) 

1413 

1414 # timezone should always be UTC 

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

1416 

1417 sender_name = Column(String, nullable=False) 

1418 sender_email = Column(String, nullable=False) 

1419 

1420 recipient = Column(String, nullable=False) 

1421 subject = Column(String, nullable=False) 

1422 

1423 plain = Column(String, nullable=False) 

1424 html = Column(String, nullable=False) 

1425 

1426 list_unsubscribe_header = Column(String, nullable=True) 

1427 source_data = Column(String, nullable=True) 

1428 

1429 

1430class SMS(Base): 

1431 """ 

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

1433 """ 

1434 

1435 __tablename__ = "smss" 

1436 

1437 id = Column(BigInteger, primary_key=True) 

1438 

1439 # timezone should always be UTC 

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

1441 # AWS message id 

1442 message_id = Column(String, nullable=False) 

1443 

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

1445 sms_sender_id = Column(String, nullable=False) 

1446 number = Column(String, nullable=False) 

1447 message = Column(String, nullable=False) 

1448 

1449 

1450class HostRequest(Base): 

1451 """ 

1452 A request to stay with a host 

1453 """ 

1454 

1455 __tablename__ = "host_requests" 

1456 

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

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

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

1460 

1461 # TODO: proper timezone handling 

1462 timezone = "Etc/UTC" 

1463 

1464 # dates in the timezone above 

1465 from_date = Column(Date, nullable=False) 

1466 to_date = Column(Date, nullable=False) 

1467 

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

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

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

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

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

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

1474 

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

1476 

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

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

1479 

1480 # number of reference reminders sent out 

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

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

1483 

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

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

1486 host_reason_didnt_meetup = Column(String, nullable=True) 

1487 surfer_reason_didnt_meetup = Column(String, nullable=True) 

1488 

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

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

1491 conversation = relationship("Conversation") 

1492 

1493 __table_args__ = ( 

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

1495 Index( 

1496 "ix_host_requests_host_didnt_meetup", 

1497 host_reason_didnt_meetup != None, 

1498 ), 

1499 Index( 

1500 "ix_host_requests_surfer_didnt_meetup", 

1501 surfer_reason_didnt_meetup != None, 

1502 ), 

1503 ) 

1504 

1505 @hybrid_property 

1506 def can_write_reference(self): 

1507 return ( 

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

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

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

1511 ) 

1512 

1513 @can_write_reference.expression 

1514 def can_write_reference(cls): 

1515 return ( 

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

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

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

1519 ) 

1520 

1521 def __repr__(self): 

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

1523 

1524 

1525class ReferenceType(enum.Enum): 

1526 friend = enum.auto() 

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

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

1529 

1530 

1531class Reference(Base): 

1532 """ 

1533 Reference from one user to another 

1534 """ 

1535 

1536 __tablename__ = "references" 

1537 

1538 id = Column(BigInteger, primary_key=True) 

1539 # timezone should always be UTC 

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

1541 

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

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

1544 

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

1546 

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

1548 

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

1550 # text that's only visible to mods 

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

1552 

1553 rating = Column(Float, nullable=False) 

1554 was_appropriate = Column(Boolean, nullable=False) 

1555 

1556 is_deleted = Column(Boolean, nullable=False, default=False, server_default="false") 

1557 

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

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

1560 

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

1562 

1563 __table_args__ = ( 

1564 # Rating must be between 0 and 1, inclusive 

1565 CheckConstraint( 

1566 "rating BETWEEN 0 AND 1", 

1567 name="rating_between_0_and_1", 

1568 ), 

1569 # Has host_request_id or it's a friend reference 

1570 CheckConstraint( 

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

1572 name="host_request_id_xor_friend_reference", 

1573 ), 

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

1575 Index( 

1576 "ix_references_unique_friend_reference", 

1577 from_user_id, 

1578 to_user_id, 

1579 reference_type, 

1580 unique=True, 

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

1582 ), 

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

1584 Index( 

1585 "ix_references_unique_per_host_request", 

1586 from_user_id, 

1587 to_user_id, 

1588 host_request_id, 

1589 unique=True, 

1590 postgresql_where=(host_request_id != None), 

1591 ), 

1592 ) 

1593 

1594 @property 

1595 def should_report(self): 

1596 """ 

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

1598 """ 

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

1600 

1601 

1602class InitiatedUpload(Base): 

1603 """ 

1604 Started downloads, not necessarily complete yet. 

1605 """ 

1606 

1607 __tablename__ = "initiated_uploads" 

1608 

1609 key = Column(String, primary_key=True) 

1610 

1611 # timezones should always be UTC 

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

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

1614 

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

1616 

1617 initiator_user = relationship("User") 

1618 

1619 @hybrid_property 

1620 def is_valid(self): 

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

1622 

1623 

1624class Upload(Base): 

1625 """ 

1626 Completed uploads. 

1627 """ 

1628 

1629 __tablename__ = "uploads" 

1630 key = Column(String, primary_key=True) 

1631 

1632 filename = Column(String, nullable=False) 

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

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

1635 

1636 # photo credit, etc 

1637 credit = Column(String, nullable=True) 

1638 

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

1640 

1641 def _url(self, size): 

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

1643 

1644 @property 

1645 def thumbnail_url(self): 

1646 return self._url("thumbnail") 

1647 

1648 @property 

1649 def full_url(self): 

1650 return self._url("full") 

1651 

1652 

1653communities_seq = Sequence("communities_seq") 

1654 

1655 

1656class Node(Base): 

1657 """ 

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

1659 

1660 Administered by the official cluster 

1661 """ 

1662 

1663 __tablename__ = "nodes" 

1664 

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

1666 

1667 # name and description come from official cluster 

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

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

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

1671 

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

1673 

1674 contained_users = relationship( 

1675 "User", 

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

1677 viewonly=True, 

1678 uselist=True, 

1679 ) 

1680 

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

1682 

1683 

1684class Cluster(Base): 

1685 """ 

1686 Cluster, administered grouping of content 

1687 """ 

1688 

1689 __tablename__ = "clusters" 

1690 

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

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

1693 name = Column(String, nullable=False) 

1694 # short description 

1695 description = Column(String, nullable=False) 

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

1697 

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

1699 

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

1701 

1702 official_cluster_for_node = relationship( 

1703 "Node", 

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

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

1706 uselist=False, 

1707 viewonly=True, 

1708 ) 

1709 

1710 parent_node = relationship( 

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

1712 ) 

1713 

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

1715 # all pages 

1716 pages = relationship( 

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

1718 ) 

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

1720 discussions = relationship( 

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

1722 ) 

1723 

1724 # includes also admins 

1725 members = relationship( 

1726 "User", 

1727 lazy="dynamic", 

1728 backref="cluster_memberships", 

1729 secondary="cluster_subscriptions", 

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

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

1732 viewonly=True, 

1733 ) 

1734 

1735 admins = relationship( 

1736 "User", 

1737 lazy="dynamic", 

1738 backref="cluster_adminships", 

1739 secondary="cluster_subscriptions", 

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

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

1742 viewonly=True, 

1743 ) 

1744 

1745 main_page = relationship( 

1746 "Page", 

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

1748 viewonly=True, 

1749 uselist=False, 

1750 ) 

1751 

1752 @property 

1753 def is_leaf(self) -> bool: 

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

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

1756 

1757 __table_args__ = ( 

1758 # Each node can have at most one official cluster 

1759 Index( 

1760 "ix_clusters_owner_parent_node_id_is_official_cluster", 

1761 parent_node_id, 

1762 is_official_cluster, 

1763 unique=True, 

1764 postgresql_where=is_official_cluster, 

1765 ), 

1766 ) 

1767 

1768 

1769class NodeClusterAssociation(Base): 

1770 """ 

1771 NodeClusterAssociation, grouping of nodes 

1772 """ 

1773 

1774 __tablename__ = "node_cluster_associations" 

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

1776 

1777 id = Column(BigInteger, primary_key=True) 

1778 

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

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

1781 

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

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

1784 

1785 

1786class ClusterRole(enum.Enum): 

1787 member = enum.auto() 

1788 admin = enum.auto() 

1789 

1790 

1791class ClusterSubscription(Base): 

1792 """ 

1793 ClusterSubscription of a user 

1794 """ 

1795 

1796 __tablename__ = "cluster_subscriptions" 

1797 

1798 id = Column(BigInteger, primary_key=True) 

1799 

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

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

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

1803 

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

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

1806 

1807 __table_args__ = ( 

1808 UniqueConstraint("user_id", "cluster_id"), 

1809 Index( 

1810 "ix_cluster_subscriptions_members", 

1811 cluster_id, 

1812 user_id, 

1813 ), 

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

1815 Index( 

1816 "ix_cluster_subscriptions_admins", 

1817 user_id, 

1818 cluster_id, 

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

1820 ), 

1821 ) 

1822 

1823 

1824class ClusterPageAssociation(Base): 

1825 """ 

1826 pages related to clusters 

1827 """ 

1828 

1829 __tablename__ = "cluster_page_associations" 

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

1831 

1832 id = Column(BigInteger, primary_key=True) 

1833 

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

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

1836 

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

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

1839 

1840 

1841class PageType(enum.Enum): 

1842 main_page = enum.auto() 

1843 place = enum.auto() 

1844 guide = enum.auto() 

1845 

1846 

1847class Page(Base): 

1848 """ 

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

1850 """ 

1851 

1852 __tablename__ = "pages" 

1853 

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

1855 

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

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

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

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

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

1861 

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

1863 

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

1865 

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

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

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

1869 owner_cluster = relationship( 

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

1871 ) 

1872 

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

1874 

1875 __table_args__ = ( 

1876 # Only one of owner_user and owner_cluster should be set 

1877 CheckConstraint( 

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

1879 name="one_owner", 

1880 ), 

1881 # Only clusters can own main pages 

1882 CheckConstraint( 

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

1884 name="main_page_owned_by_cluster", 

1885 ), 

1886 # Each cluster can have at most one main page 

1887 Index( 

1888 "ix_pages_owner_cluster_id_type", 

1889 owner_cluster_id, 

1890 type, 

1891 unique=True, 

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

1893 ), 

1894 ) 

1895 

1896 def __repr__(self): 

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

1898 

1899 

1900class PageVersion(Base): 

1901 """ 

1902 version of page content 

1903 """ 

1904 

1905 __tablename__ = "page_versions" 

1906 

1907 id = Column(BigInteger, primary_key=True) 

1908 

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

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

1911 title = Column(String, nullable=False) 

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

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

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

1915 # the human-readable address 

1916 address = Column(String, nullable=True) 

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

1918 

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

1920 

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

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

1923 photo = relationship("Upload") 

1924 

1925 __table_args__ = ( 

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

1927 CheckConstraint( 

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

1929 name="geom_iff_address", 

1930 ), 

1931 ) 

1932 

1933 @property 

1934 def coordinates(self): 

1935 # returns (lat, lng) or None 

1936 return get_coordinates(self.geom) 

1937 

1938 def __repr__(self): 

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

1940 

1941 

1942class ClusterEventAssociation(Base): 

1943 """ 

1944 events related to clusters 

1945 """ 

1946 

1947 __tablename__ = "cluster_event_associations" 

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

1949 

1950 id = Column(BigInteger, primary_key=True) 

1951 

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

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

1954 

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

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

1957 

1958 

1959class Event(Base): 

1960 """ 

1961 An event is compose of two parts: 

1962 

1963 * An event template (Event) 

1964 * An occurrence (EventOccurrence) 

1965 

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

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

1968 """ 

1969 

1970 __tablename__ = "events" 

1971 

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

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

1974 

1975 title = Column(String, nullable=False) 

1976 

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

1978 

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

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

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

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

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

1984 

1985 parent_node = relationship( 

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

1987 ) 

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

1989 subscribers = relationship( 

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

1991 ) 

1992 organizers = relationship( 

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

1994 ) 

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

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

1997 owner_cluster = relationship( 

1998 "Cluster", 

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

2000 uselist=False, 

2001 foreign_keys="Event.owner_cluster_id", 

2002 ) 

2003 

2004 __table_args__ = ( 

2005 # Only one of owner_user and owner_cluster should be set 

2006 CheckConstraint( 

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

2008 name="one_owner", 

2009 ), 

2010 ) 

2011 

2012 

2013class EventOccurrence(Base): 

2014 __tablename__ = "event_occurrences" 

2015 

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

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

2018 

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

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

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

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

2023 

2024 is_cancelled = Column(Boolean, nullable=False, default=False, server_default=text("false")) 

2025 is_deleted = Column(Boolean, nullable=False, default=False, server_default=text("false")) 

2026 

2027 # a null geom is an online-only event 

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

2029 # physical address, iff geom is not null 

2030 address = Column(String, nullable=True) 

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

2032 link = Column(String, nullable=True) 

2033 

2034 timezone = "Etc/UTC" 

2035 

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

2037 # simplifies database constraints, etc 

2038 during = Column(TSTZRANGE, nullable=False) 

2039 

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

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

2042 

2043 creator_user = relationship( 

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

2045 ) 

2046 event = relationship( 

2047 "Event", 

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

2049 remote_side="Event.id", 

2050 foreign_keys="EventOccurrence.event_id", 

2051 ) 

2052 

2053 photo = relationship("Upload") 

2054 

2055 __table_args__ = ( 

2056 # Geom and address go together 

2057 CheckConstraint( 

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

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

2060 name="geom_iff_address", 

2061 ), 

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

2063 CheckConstraint( 

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

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

2066 name="link_or_geom", 

2067 ), 

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

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

2070 ) 

2071 

2072 @property 

2073 def coordinates(self): 

2074 # returns (lat, lng) or None 

2075 return get_coordinates(self.geom) 

2076 

2077 @hybrid_property 

2078 def start_time(self): 

2079 return self.during.lower 

2080 

2081 @start_time.expression 

2082 def start_time(cls): 

2083 return func.lower(cls.during) 

2084 

2085 @hybrid_property 

2086 def end_time(self): 

2087 return self.during.upper 

2088 

2089 @end_time.expression 

2090 def end_time(cls): 

2091 return func.upper(cls.during) 

2092 

2093 

2094class EventSubscription(Base): 

2095 """ 

2096 Users' subscriptions to events 

2097 """ 

2098 

2099 __tablename__ = "event_subscriptions" 

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

2101 

2102 id = Column(BigInteger, primary_key=True) 

2103 

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

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

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

2107 

2108 user = relationship("User") 

2109 event = relationship("Event") 

2110 

2111 

2112class EventOrganizer(Base): 

2113 """ 

2114 Organizers for events 

2115 """ 

2116 

2117 __tablename__ = "event_organizers" 

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

2119 

2120 id = Column(BigInteger, primary_key=True) 

2121 

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

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

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

2125 

2126 user = relationship("User") 

2127 event = relationship("Event") 

2128 

2129 

2130class AttendeeStatus(enum.Enum): 

2131 going = enum.auto() 

2132 maybe = enum.auto() 

2133 

2134 

2135class EventOccurrenceAttendee(Base): 

2136 """ 

2137 Attendees for events 

2138 """ 

2139 

2140 __tablename__ = "event_occurrence_attendees" 

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

2142 

2143 id = Column(BigInteger, primary_key=True) 

2144 

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

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

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

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

2149 

2150 user = relationship("User") 

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

2152 

2153 

2154class EventCommunityInviteRequest(Base): 

2155 """ 

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

2157 """ 

2158 

2159 __tablename__ = "event_community_invite_requests" 

2160 

2161 id = Column(BigInteger, primary_key=True) 

2162 

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

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

2165 

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

2167 

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

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

2170 approved = Column(Boolean, nullable=True) 

2171 

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

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

2174 

2175 __table_args__ = ( 

2176 # each user can only request once 

2177 UniqueConstraint("occurrence_id", "user_id"), 

2178 # each event can only have one notification sent out 

2179 Index( 

2180 "ix_event_community_invite_requests_unique", 

2181 occurrence_id, 

2182 unique=True, 

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

2184 ), 

2185 # decided and approved ought to be null simultaneously 

2186 CheckConstraint( 

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

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

2189 name="decided_approved", 

2190 ), 

2191 ) 

2192 

2193 

2194class ClusterDiscussionAssociation(Base): 

2195 """ 

2196 discussions related to clusters 

2197 """ 

2198 

2199 __tablename__ = "cluster_discussion_associations" 

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

2201 

2202 id = Column(BigInteger, primary_key=True) 

2203 

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

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

2206 

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

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

2209 

2210 

2211class Discussion(Base): 

2212 """ 

2213 forum board 

2214 """ 

2215 

2216 __tablename__ = "discussions" 

2217 

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

2219 

2220 title = Column(String, nullable=False) 

2221 content = Column(String, nullable=False) 

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

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

2224 

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

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

2227 

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

2229 

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

2231 

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

2233 

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

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

2236 

2237 

2238class DiscussionSubscription(Base): 

2239 """ 

2240 users subscriptions to discussions 

2241 """ 

2242 

2243 __tablename__ = "discussion_subscriptions" 

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

2245 

2246 id = Column(BigInteger, primary_key=True) 

2247 

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

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

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

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

2252 

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

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

2255 

2256 

2257class Thread(Base): 

2258 """ 

2259 Thread 

2260 """ 

2261 

2262 __tablename__ = "threads" 

2263 

2264 id = Column(BigInteger, primary_key=True) 

2265 

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

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

2268 

2269 

2270class Comment(Base): 

2271 """ 

2272 Comment 

2273 """ 

2274 

2275 __tablename__ = "comments" 

2276 

2277 id = Column(BigInteger, primary_key=True) 

2278 

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

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

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

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

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

2284 

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

2286 

2287 

2288class Reply(Base): 

2289 """ 

2290 Reply 

2291 """ 

2292 

2293 __tablename__ = "replies" 

2294 

2295 id = Column(BigInteger, primary_key=True) 

2296 

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

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

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

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

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

2302 

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

2304 

2305 

2306class BackgroundJobState(enum.Enum): 

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

2308 pending = enum.auto() 

2309 # job complete 

2310 completed = enum.auto() 

2311 # error occured, will be retried 

2312 error = enum.auto() 

2313 # failed too many times, not retrying anymore 

2314 failed = enum.auto() 

2315 

2316 

2317class BackgroundJob(Base): 

2318 """ 

2319 This table implements a queue of background jobs. 

2320 """ 

2321 

2322 __tablename__ = "background_jobs" 

2323 

2324 id = Column(BigInteger, primary_key=True) 

2325 

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

2327 job_type = Column(String, nullable=False) 

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

2329 

2330 # time queued 

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

2332 

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

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

2335 

2336 # used to count number of retries for failed jobs 

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

2338 

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

2340 

2341 # higher is more important 

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

2343 

2344 # protobuf encoded job payload 

2345 payload = Column(Binary, nullable=False) 

2346 

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

2348 failure_info = Column(String, nullable=True) 

2349 

2350 __table_args__ = ( 

2351 # used in looking up background jobs to attempt 

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

2353 Index( 

2354 "ix_background_jobs_lookup", 

2355 priority.desc(), 

2356 next_attempt_after, 

2357 (max_tries - try_count), 

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

2359 ), 

2360 ) 

2361 

2362 @hybrid_property 

2363 def ready_for_retry(self): 

2364 return ( 

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

2366 & (self.try_count < self.max_tries) 

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

2368 ) 

2369 

2370 def __repr__(self): 

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

2372 

2373 

2374class NotificationDeliveryType(enum.Enum): 

2375 # send push notification to mobile/web 

2376 push = enum.auto() 

2377 # send individual email immediately 

2378 email = enum.auto() 

2379 # send in digest 

2380 digest = enum.auto() 

2381 

2382 

2383dt = NotificationDeliveryType 

2384nd = notification_data_pb2 

2385 

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

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

2388 

2389 

2390class NotificationTopicAction(enum.Enum): 

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

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

2393 self.defaults = defaults 

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

2395 self.user_editable = user_editable 

2396 

2397 self.data_type = data_type 

2398 

2399 def unpack(self): 

2400 return self.topic, self.action 

2401 

2402 @property 

2403 def display(self): 

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

2405 

2406 def __str__(self): 

2407 return self.display 

2408 

2409 # topic, action, default delivery types 

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

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

2412 

2413 # host requests 

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

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

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

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

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

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

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

2421 

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

2423 

2424 # you receive a friend ref 

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

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

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

2428 # ... the surfer 

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

2430 

2431 # you hosted 

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

2433 # you surfed 

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

2435 

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

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

2438 

2439 # group chats 

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

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

2442 

2443 # events 

2444 # approved by mods 

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

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

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

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

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

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

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

2452 # toplevel comment on an event 

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

2454 

2455 # discussion created 

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

2457 # someone comments on your discussion 

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

2459 

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

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

2462 

2463 # account settings 

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

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

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

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

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

2469 # reset password 

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

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

2472 

2473 # account deletion 

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

2475 # no more pushing to do 

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

2477 # undeleted 

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

2479 

2480 # admin actions 

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

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

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

2484 

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

2486 

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

2488 

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

2490 

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

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

2493 

2494 # general announcements 

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

2496 

2497 

2498class NotificationPreference(Base): 

2499 __tablename__ = "notification_preferences" 

2500 

2501 id = Column(BigInteger, primary_key=True) 

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

2503 

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

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

2506 deliver = Column(Boolean, nullable=False) 

2507 

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

2509 

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

2511 

2512 

2513class Notification(Base): 

2514 """ 

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

2516 """ 

2517 

2518 __tablename__ = "notifications" 

2519 

2520 id = Column(BigInteger, primary_key=True) 

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

2522 

2523 # recipient user id 

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

2525 

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

2527 key = Column(String, nullable=False) 

2528 

2529 data = Column(Binary, nullable=False) 

2530 

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

2532 is_seen = Column(Boolean, nullable=False, server_default=text("false")) 

2533 

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

2535 

2536 __table_args__ = ( 

2537 # used in looking up which notifications need delivery 

2538 Index( 

2539 "ix_notifications_created", 

2540 created, 

2541 ), 

2542 # Fast lookup for unseen notification count 

2543 Index( 

2544 "ix_notifications_unseen", 

2545 user_id, 

2546 topic_action, 

2547 postgresql_where=(is_seen == False), 

2548 ), 

2549 # Fast lookup for latest notifications 

2550 Index( 

2551 "ix_notifications_latest", 

2552 user_id, 

2553 id.desc(), 

2554 topic_action, 

2555 ), 

2556 ) 

2557 

2558 @property 

2559 def topic(self): 

2560 return self.topic_action.topic 

2561 

2562 @property 

2563 def action(self): 

2564 return self.topic_action.action 

2565 

2566 

2567class NotificationDelivery(Base): 

2568 __tablename__ = "notification_deliveries" 

2569 

2570 id = Column(BigInteger, primary_key=True) 

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

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

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

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

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

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

2577 # todo: device id 

2578 # todo: receipt id, etc 

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

2580 

2581 __table_args__ = ( 

2582 UniqueConstraint("notification_id", "delivery_type"), 

2583 # used in looking up which notifications need delivery 

2584 Index( 

2585 "ix_notification_deliveries_delivery_type", 

2586 delivery_type, 

2587 postgresql_where=(delivered != None), 

2588 ), 

2589 Index( 

2590 "ix_notification_deliveries_dt_ni_dnull", 

2591 delivery_type, 

2592 notification_id, 

2593 delivered == None, 

2594 ), 

2595 ) 

2596 

2597 

2598class PushNotificationSubscription(Base): 

2599 __tablename__ = "push_notification_subscriptions" 

2600 

2601 id = Column(BigInteger, primary_key=True) 

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

2603 

2604 # which user this is connected to 

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

2606 

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

2608 # the endpoint 

2609 endpoint = Column(String, nullable=False) 

2610 # the "auth" key 

2611 auth_key = Column(Binary, nullable=False) 

2612 # the "p256dh" key 

2613 p256dh_key = Column(Binary, nullable=False) 

2614 

2615 full_subscription_info = Column(String, nullable=False) 

2616 

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

2618 user_agent = Column(String, nullable=True) 

2619 

2620 # when it was disabled 

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

2622 

2623 user = relationship("User") 

2624 

2625 

2626class PushNotificationDeliveryAttempt(Base): 

2627 __tablename__ = "push_notification_delivery_attempt" 

2628 

2629 id = Column(BigInteger, primary_key=True) 

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

2631 

2632 push_notification_subscription_id = Column( 

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

2634 ) 

2635 

2636 success = Column(Boolean, nullable=False) 

2637 # the HTTP status code, 201 is success 

2638 status_code = Column(Integer, nullable=False) 

2639 

2640 # can be null if it was a success 

2641 response = Column(String, nullable=True) 

2642 

2643 push_notification_subscription = relationship("PushNotificationSubscription") 

2644 

2645 

2646class Language(Base): 

2647 """ 

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

2649 """ 

2650 

2651 __tablename__ = "languages" 

2652 

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

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

2655 

2656 # the english name 

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

2658 

2659 

2660class Region(Base): 

2661 """ 

2662 Table of regions 

2663 """ 

2664 

2665 __tablename__ = "regions" 

2666 

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

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

2669 

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

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

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

2673 

2674 

2675class UserBlock(Base): 

2676 """ 

2677 Table of blocked users 

2678 """ 

2679 

2680 __tablename__ = "user_blocks" 

2681 

2682 id = Column(BigInteger, primary_key=True) 

2683 

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

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

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

2687 

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

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

2690 

2691 __table_args__ = ( 

2692 UniqueConstraint("blocking_user_id", "blocked_user_id"), 

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

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

2695 ) 

2696 

2697 

2698class APICall(Base): 

2699 """ 

2700 API call logs 

2701 """ 

2702 

2703 __tablename__ = "api_calls" 

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

2705 

2706 id = Column(BigInteger, primary_key=True) 

2707 

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

2709 is_api_key = Column(Boolean, nullable=False, server_default=text("false")) 

2710 

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

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

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

2714 

2715 # approximate time of the call 

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

2717 

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

2719 method = Column(String, nullable=False) 

2720 

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

2722 status_code = Column(String, nullable=True) 

2723 

2724 # handler duration (excluding serialization, etc) 

2725 duration = Column(Float, nullable=False) 

2726 

2727 # user_id of caller, None means not logged in 

2728 user_id = Column(BigInteger, nullable=True) 

2729 

2730 # sanitized request bytes 

2731 request = Column(Binary, nullable=True) 

2732 

2733 # sanitized response bytes 

2734 response = Column(Binary, nullable=True) 

2735 

2736 # whether response bytes have been truncated 

2737 response_truncated = Column(Boolean, nullable=False, server_default=text("false")) 

2738 

2739 # the exception traceback, if any 

2740 traceback = Column(String, nullable=True) 

2741 

2742 # human readable perf report 

2743 perf_report = Column(String, nullable=True) 

2744 

2745 # details of the browser, if available 

2746 ip_address = Column(String, nullable=True) 

2747 user_agent = Column(String, nullable=True) 

2748 

2749 

2750class AccountDeletionReason(Base): 

2751 __tablename__ = "account_deletion_reason" 

2752 

2753 id = Column(BigInteger, primary_key=True) 

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

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

2756 reason = Column(String, nullable=True) 

2757 

2758 user = relationship("User")