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

1078 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-03-11 15:27 +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, 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 date_in_timezone, get_coordinates, last_active_coarsen, now 

44from proto import notification_data_pb2 

45 

46meta = MetaData( 

47 naming_convention={ 

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

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

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

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

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

53 } 

54) 

55 

56Base = declarative_base(metadata=meta) 

57 

58 

59class HostingStatus(enum.Enum): 

60 can_host = enum.auto() 

61 maybe = enum.auto() 

62 cant_host = enum.auto() 

63 

64 

65class MeetupStatus(enum.Enum): 

66 wants_to_meetup = enum.auto() 

67 open_to_meetup = enum.auto() 

68 does_not_want_to_meetup = enum.auto() 

69 

70 

71class SmokingLocation(enum.Enum): 

72 yes = enum.auto() 

73 window = enum.auto() 

74 outside = enum.auto() 

75 no = enum.auto() 

76 

77 

78class SleepingArrangement(enum.Enum): 

79 private = enum.auto() 

80 common = enum.auto() 

81 shared_room = enum.auto() 

82 

83 

84class ParkingDetails(enum.Enum): 

85 free_onsite = enum.auto() 

86 free_offsite = enum.auto() 

87 paid_onsite = enum.auto() 

88 paid_offsite = enum.auto() 

89 

90 

91class TimezoneArea(Base): 

92 __tablename__ = "timezone_areas" 

93 id = Column(BigInteger, primary_key=True) 

94 

95 tzid = Column(String) 

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

97 

98 __table_args__ = ( 

99 Index( 

100 "ix_timezone_areas_geom_tzid", 

101 geom, 

102 tzid, 

103 postgresql_using="gist", 

104 ), 

105 ) 

106 

107 

108class User(Base): 

109 """ 

110 Basic user and profile details 

111 """ 

112 

113 __tablename__ = "users" 

114 

115 id = Column(BigInteger, primary_key=True) 

116 

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

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

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

120 hashed_password = Column(Binary, nullable=False) 

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

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

123 # language preference -- defaults to empty string 

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

125 

126 # timezones should always be UTC 

127 ## location 

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

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

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

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

132 geom_radius = Column(Float, nullable=True) 

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

134 city = Column(String, nullable=False) 

135 # "Grew up in" on profile 

136 hometown = Column(String, nullable=True) 

137 

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

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

140 

141 timezone = column_property( 

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

143 deferred=True, 

144 ) 

145 

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

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

148 

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

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

151 # same as above for host requests 

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

153 

154 # display name 

155 name = Column(String, nullable=False) 

156 gender = Column(String, nullable=False) 

157 pronouns = Column(String, nullable=True) 

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

159 

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

161 

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

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

164 

165 # community standing score 

166 community_standing = Column(Float, nullable=True) 

167 

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

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

170 

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

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

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

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

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

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

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

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

179 

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

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

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

183 

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

185 # accidental or they changed their mind 

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

187 undelete_token = Column(String, nullable=True) 

188 # validity of the undelete token 

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

190 

191 # hosting preferences 

192 max_guests = Column(Integer, nullable=True) 

193 last_minute = Column(Boolean, nullable=True) 

194 has_pets = Column(Boolean, nullable=True) 

195 accepts_pets = Column(Boolean, nullable=True) 

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

197 has_kids = Column(Boolean, nullable=True) 

198 accepts_kids = Column(Boolean, nullable=True) 

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

200 has_housemates = Column(Boolean, nullable=True) 

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

202 wheelchair_accessible = Column(Boolean, nullable=True) 

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

204 smokes_at_home = Column(Boolean, nullable=True) 

205 drinking_allowed = Column(Boolean, nullable=True) 

206 drinks_at_home = Column(Boolean, nullable=True) 

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

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

209 

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

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

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

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

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

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

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

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

218 parking = Column(Boolean, nullable=True) 

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

220 camping_ok = Column(Boolean, nullable=True) 

221 

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

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

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

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

226 

227 # number of onboarding emails sent 

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

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

230 

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

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

233 # opted out of the newsletter 

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

235 

236 # set to null to receive no digests 

237 digest_frequency = Column(Interval, nullable=True) 

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

239 

240 # for changing their email 

241 new_email = Column(String, nullable=True) 

242 

243 new_email_token = Column(String, nullable=True) 

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

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

246 

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

248 

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

250 # ,-------------------, 

251 # | Start | 

252 # | phone = None | someone else 

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

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

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

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

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

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

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

260 # '-----------------' +-------- | ------+----+--------------------+ '-----------------------' 

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

262 # | | ^ V | | 

263 # ,-----------------, | | ,-------------------, | ,-----------------------, 

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

265 # | phone = xx | | phone = xx | | | phone = xx | 

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

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

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

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

270 # '-----------------' '-------------------' '-----------------------' 

271 

272 # randomly generated Luhn 6-digit string 

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

274 

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

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

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

278 

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

280 # e.g. cus_JjoXHttuZopv0t 

281 # for new US entity 

282 stripe_customer_id = Column(String, nullable=True) 

283 # for old AU entity 

284 stripe_customer_id_old = Column(String, nullable=True) 

285 

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

287 

288 # checking for phone verification 

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

290 

291 # whether this user has all emails turned off 

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

293 

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

295 

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

297 

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

299 

300 __table_args__ = ( 

301 # Verified phone numbers should be unique 

302 Index( 

303 "ix_users_unique_phone", 

304 phone, 

305 unique=True, 

306 postgresql_where=phone_verification_verified != None, 

307 ), 

308 Index( 

309 "ix_users_active", 

310 id, 

311 postgresql_where=~is_banned & ~is_deleted, 

312 ), 

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

314 Index( 

315 "ix_users_geom_active", 

316 geom, 

317 id, 

318 username, 

319 postgresql_where=~is_banned & ~is_deleted & (geom != None), 

320 ), 

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

322 CheckConstraint( 

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

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

325 name="check_new_email_token_state", 

326 ), 

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

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

329 CheckConstraint( 

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

331 name="phone_verified_conditions", 

332 ), 

333 # Email must match our regex 

334 CheckConstraint( 

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

336 name="valid_email", 

337 ), 

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

339 CheckConstraint( 

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

341 name="undelete_nullity", 

342 ), 

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

344 CheckConstraint( 

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

346 name="do_not_email_inactive", 

347 ), 

348 ) 

349 

350 @hybrid_property 

351 def has_completed_profile(self): 

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

353 

354 @has_completed_profile.expression 

355 def has_completed_profile(cls): 

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

357 

358 @hybrid_property 

359 def is_jailed(self): 

360 return ( 

361 (self.accepted_tos < TOS_VERSION) 

362 | (self.accepted_community_guidelines < GUIDELINES_VERSION) 

363 | self.is_missing_location 

364 | (self.mod_notes.where(ModNote.is_pending).count() > 0) 

365 ) 

366 

367 @hybrid_property 

368 def is_missing_location(self): 

369 return (self.geom == None) | (self.geom_radius == None) 

370 

371 @hybrid_property 

372 def is_visible(self): 

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

374 

375 @property 

376 def coordinates(self): 

377 return get_coordinates(self.geom) 

378 

379 @property 

380 def display_joined(self): 

381 """ 

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

383 """ 

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

385 

386 @property 

387 def display_last_active(self): 

388 """ 

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

390 """ 

391 return last_active_coarsen(self.last_active) 

392 

393 @hybrid_property 

394 def phone_is_verified(self): 

395 return ( 

396 self.phone_verification_verified is not None 

397 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

398 ) 

399 

400 @phone_is_verified.expression 

401 def phone_is_verified(cls): 

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

403 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME 

404 ) 

405 

406 @hybrid_property 

407 def phone_code_expired(self): 

408 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME 

409 

410 def __repr__(self): 

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

412 

413 

414class UserBadge(Base): 

415 """ 

416 A badge on a user's profile 

417 """ 

418 

419 __tablename__ = "user_badges" 

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

421 

422 id = Column(BigInteger, primary_key=True) 

423 

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

425 # corresponds to "id" in badges.json 

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

427 

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

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

430 

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

432 

433 

434class StrongVerificationAttemptStatus(enum.Enum): 

435 ## full data states 

436 # completed, this now provides verification for a user 

437 succeeded = enum.auto() 

438 

439 ## no data states 

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

441 in_progress_waiting_on_user_to_open_app = enum.auto() 

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

443 in_progress_waiting_on_user_in_app = enum.auto() 

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

445 in_progress_waiting_on_backend = enum.auto() 

446 # failed, no data 

447 failed = enum.auto() 

448 

449 # duplicate, at our end, has data 

450 duplicate = enum.auto() 

451 

452 ## minimal data states 

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

454 deleted = enum.auto() 

455 

456 

457class PassportSex(enum.Enum): 

458 """ 

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

460 """ 

461 

462 male = enum.auto() 

463 female = enum.auto() 

464 unspecified = enum.auto() 

465 

466 

467class StrongVerificationAttempt(Base): 

468 """ 

469 An attempt to perform strong verification 

470 """ 

471 

472 __tablename__ = "strong_verification_attempts" 

473 

474 # our verification id 

475 id = Column(BigInteger, primary_key=True) 

476 

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

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

479 

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

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

482 

483 status = Column( 

484 Enum(StrongVerificationAttemptStatus), 

485 nullable=False, 

486 default=StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app, 

487 ) 

488 

489 ## full data 

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

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

492 passport_encrypted_data = Column(Binary, nullable=True) 

493 passport_date_of_birth = Column(Date, nullable=True) 

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

495 

496 ## minimal data: this will not be deleted 

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

498 passport_expiry_date = Column(Date, nullable=True) 

499 passport_nationality = Column(String, nullable=True) 

500 # last three characters of the passport number 

501 passport_last_three_document_chars = Column(String, nullable=True) 

502 

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

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

505 

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

507 

508 user = relationship("User") 

509 

510 @hybrid_property 

511 def is_valid(self): 

512 """ 

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

514 """ 

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

516 

517 @is_valid.expression 

518 def is_valid(cls): 

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

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

521 ) 

522 

523 @hybrid_property 

524 def is_visible(self): 

525 return self.status != StrongVerificationAttemptStatus.deleted 

526 

527 @hybrid_method 

528 def _raw_birthdate_match(self, user): 

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

530 return self.passport_date_of_birth == user.birthdate 

531 

532 @hybrid_method 

533 def matches_birthdate(self, user): 

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

535 

536 @hybrid_method 

537 def _raw_gender_match(self, user): 

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

539 return ( 

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

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

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

543 | (user.has_passport_sex_gender_exception == True) 

544 ) 

545 

546 @hybrid_method 

547 def matches_gender(self, user): 

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

549 

550 @hybrid_method 

551 def has_strong_verification(self, user): 

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

553 

554 __table_args__ = ( 

555 # used to look up verification status for a user 

556 Index( 

557 "ix_strong_verification_attempts_current", 

558 user_id, 

559 passport_expiry_date, 

560 postgresql_where=status == StrongVerificationAttemptStatus.succeeded, 

561 ), 

562 # each passport can be verified only once 

563 Index( 

564 "ix_strong_verification_attempts_unique_succeeded", 

565 passport_expiry_date, 

566 passport_nationality, 

567 passport_last_three_document_chars, 

568 unique=True, 

569 postgresql_where=( 

570 (status == StrongVerificationAttemptStatus.succeeded) 

571 | (status == StrongVerificationAttemptStatus.deleted) 

572 ), 

573 ), 

574 # full data check 

575 CheckConstraint( 

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

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

578 name="full_data_status", 

579 ), 

580 # minimal data check 

581 CheckConstraint( 

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

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

584 name="minimal_data_status", 

585 ), 

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

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

588 CheckConstraint( 

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

590 name="full_data_implies_minimal_data", 

591 ), 

592 # succeeded implies full data 

593 CheckConstraint( 

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

595 name="succeeded_implies_full_data", 

596 ), 

597 # in_progress/failed implies no_data 

598 CheckConstraint( 

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

600 name="in_progress_failed_iris_implies_no_data", 

601 ), 

602 # deleted or duplicate implies minimal data 

603 CheckConstraint( 

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

605 name="deleted_duplicate_implies_minimal_data", 

606 ), 

607 ) 

608 

609 

610class ModNote(Base): 

611 """ 

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

613 

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

615 """ 

616 

617 __tablename__ = "mod_notes" 

618 id = Column(BigInteger, primary_key=True) 

619 

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

621 

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

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

624 

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

626 internal_id = Column(String, nullable=False) 

627 # the admin that left this note 

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

629 

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

631 

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

633 

634 def __repr__(self): 

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

636 

637 @hybrid_property 

638 def is_pending(self): 

639 return self.acknowledged == None 

640 

641 __table_args__ = ( 

642 # used to look up pending notes 

643 Index( 

644 "ix_mod_notes_unacknowledged", 

645 user_id, 

646 postgresql_where=acknowledged == None, 

647 ), 

648 ) 

649 

650 

651class StrongVerificationCallbackEvent(Base): 

652 __tablename__ = "strong_verification_callback_events" 

653 

654 id = Column(BigInteger, primary_key=True) 

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

656 

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

658 

659 iris_status = Column(String, nullable=False) 

660 

661 

662class DonationType(enum.Enum): 

663 one_time = enum.auto() 

664 recurring = enum.auto() 

665 

666 

667class DonationInitiation(Base): 

668 """ 

669 Whenever someone initiaties a donation through the platform 

670 """ 

671 

672 __tablename__ = "donation_initiations" 

673 id = Column(BigInteger, primary_key=True) 

674 

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

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

677 

678 amount = Column(Integer, nullable=False) 

679 stripe_checkout_session_id = Column(String, nullable=False) 

680 

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

682 

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

684 

685 

686class Invoice(Base): 

687 """ 

688 Successful donations, both one off and recurring 

689 

690 Triggered by `payment_intent.succeeded` webhook 

691 """ 

692 

693 __tablename__ = "invoices" 

694 

695 id = Column(BigInteger, primary_key=True) 

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

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

698 

699 amount = Column(Float, nullable=False) 

700 

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

702 stripe_receipt_url = Column(String, nullable=False) 

703 

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

705 

706 

707class LanguageFluency(enum.Enum): 

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

709 beginner = 1 

710 conversational = 2 

711 fluent = 3 

712 

713 

714class LanguageAbility(Base): 

715 __tablename__ = "language_abilities" 

716 __table_args__ = ( 

717 # Users can only have one language ability per language 

718 UniqueConstraint("user_id", "language_code"), 

719 ) 

720 

721 id = Column(BigInteger, primary_key=True) 

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

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

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

725 

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

727 language = relationship("Language") 

728 

729 

730class RegionVisited(Base): 

731 __tablename__ = "regions_visited" 

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

733 

734 id = Column(BigInteger, primary_key=True) 

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

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

737 

738 

739class RegionLived(Base): 

740 __tablename__ = "regions_lived" 

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

742 

743 id = Column(BigInteger, primary_key=True) 

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

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

746 

747 

748class FriendStatus(enum.Enum): 

749 pending = enum.auto() 

750 accepted = enum.auto() 

751 rejected = enum.auto() 

752 cancelled = enum.auto() 

753 

754 

755class FriendRelationship(Base): 

756 """ 

757 Friendship relations between users 

758 

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

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

761 """ 

762 

763 __tablename__ = "friend_relationships" 

764 

765 id = Column(BigInteger, primary_key=True) 

766 

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

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

769 

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

771 

772 # timezones should always be UTC 

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

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

775 

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

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

778 

779 

780class ContributeOption(enum.Enum): 

781 yes = enum.auto() 

782 maybe = enum.auto() 

783 no = enum.auto() 

784 

785 

786class ContributorForm(Base): 

787 """ 

788 Someone filled in the contributor form 

789 """ 

790 

791 __tablename__ = "contributor_forms" 

792 

793 id = Column(BigInteger, primary_key=True) 

794 

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

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

797 

798 ideas = Column(String, nullable=True) 

799 features = Column(String, nullable=True) 

800 experience = Column(String, nullable=True) 

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

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

803 expertise = Column(String, nullable=True) 

804 

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

806 

807 @hybrid_property 

808 def is_filled(self): 

809 """ 

810 Whether the form counts as having been filled 

811 """ 

812 return ( 

813 (self.ideas != None) 

814 | (self.features != None) 

815 | (self.experience != None) 

816 | (self.contribute != None) 

817 | (self.contribute_ways != []) 

818 | (self.expertise != None) 

819 ) 

820 

821 @property 

822 def should_notify(self): 

823 """ 

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

825 

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

827 """ 

828 return (self.expertise != None) | (not set(self.contribute_ways).issubset({"community", "blog", "other"})) 

829 

830 

831class SignupFlow(Base): 

832 """ 

833 Signup flows/incomplete users 

834 

835 Coinciding fields have the same meaning as in User 

836 """ 

837 

838 __tablename__ = "signup_flows" 

839 

840 id = Column(BigInteger, primary_key=True) 

841 

842 # housekeeping 

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

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

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

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

847 email_token = Column(String, nullable=True) 

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

849 

850 ## Basic 

851 name = Column(String, nullable=False) 

852 # TODO: unique across both tables 

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

854 # TODO: invitation, attribution 

855 

856 ## Account 

857 # TODO: unique across both tables 

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

859 hashed_password = Column(Binary, nullable=True) 

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

861 gender = Column(String, nullable=True) 

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

863 city = Column(String, nullable=True) 

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

865 geom_radius = Column(Float, nullable=True) 

866 

867 accepted_tos = Column(Integer, nullable=True) 

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

869 

870 opt_out_of_newsletter = Column(Boolean, nullable=True) 

871 

872 ## Feedback 

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

874 ideas = Column(String, nullable=True) 

875 features = Column(String, nullable=True) 

876 experience = Column(String, nullable=True) 

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

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

879 expertise = Column(String, nullable=True) 

880 

881 @hybrid_property 

882 def token_is_valid(self): 

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

884 

885 @hybrid_property 

886 def account_is_filled(self): 

887 return ( 

888 (self.username != None) 

889 & (self.birthdate != None) 

890 & (self.gender != None) 

891 & (self.hosting_status != None) 

892 & (self.city != None) 

893 & (self.geom != None) 

894 & (self.geom_radius != None) 

895 & (self.accepted_tos != None) 

896 & (self.opt_out_of_newsletter != None) 

897 ) 

898 

899 @hybrid_property 

900 def is_completed(self): 

901 return ( 

902 self.email_verified 

903 & self.account_is_filled 

904 & self.filled_feedback 

905 & (self.accepted_community_guidelines == GUIDELINES_VERSION) 

906 ) 

907 

908 

909class LoginToken(Base): 

910 """ 

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

912 """ 

913 

914 __tablename__ = "login_tokens" 

915 token = Column(String, primary_key=True) 

916 

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

918 

919 # timezones should always be UTC 

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

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

922 

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

924 

925 @hybrid_property 

926 def is_valid(self): 

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

928 

929 def __repr__(self): 

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

931 

932 

933class PasswordResetToken(Base): 

934 __tablename__ = "password_reset_tokens" 

935 token = Column(String, primary_key=True) 

936 

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

938 

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

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

941 

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

943 

944 @hybrid_property 

945 def is_valid(self): 

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

947 

948 def __repr__(self): 

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

950 

951 

952class AccountDeletionToken(Base): 

953 __tablename__ = "account_deletion_tokens" 

954 

955 token = Column(String, primary_key=True) 

956 

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

958 

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

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

961 

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

963 

964 @hybrid_property 

965 def is_valid(self): 

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

967 

968 def __repr__(self): 

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

970 

971 

972class UserActivity(Base): 

973 """ 

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

975 

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

977 """ 

978 

979 __tablename__ = "user_activity" 

980 

981 id = Column(BigInteger, primary_key=True) 

982 

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

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

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

986 

987 # details of the browser, if available 

988 ip_address = Column(INET, nullable=True) 

989 user_agent = Column(String, nullable=True) 

990 

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

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

993 

994 __table_args__ = ( 

995 # helps look up this tuple quickly 

996 Index( 

997 "ix_user_activity_user_id_period_ip_address_user_agent", 

998 user_id, 

999 period, 

1000 ip_address, 

1001 user_agent, 

1002 unique=True, 

1003 ), 

1004 ) 

1005 

1006 

1007class UserSession(Base): 

1008 """ 

1009 API keys/session cookies for the app 

1010 

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

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

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

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

1015 site. 

1016 

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

1018 

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

1020 """ 

1021 

1022 __tablename__ = "sessions" 

1023 token = Column(String, primary_key=True) 

1024 

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

1026 

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

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

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

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

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

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

1033 

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

1035 long_lived = Column(Boolean, nullable=False) 

1036 

1037 # the time at which the session was created 

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

1039 

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

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

1042 

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

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

1045 

1046 # the last time this session was used 

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

1048 

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

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

1051 

1052 # details of the browser, if available 

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

1054 ip_address = Column(String, nullable=True) 

1055 user_agent = Column(String, nullable=True) 

1056 

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

1058 

1059 @hybrid_property 

1060 def is_valid(self): 

1061 """ 

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

1063 

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

1065 

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

1067 """ 

1068 return ( 

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

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

1071 & (self.deleted == None) 

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

1073 ) 

1074 

1075 

1076class Conversation(Base): 

1077 """ 

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

1079 """ 

1080 

1081 __tablename__ = "conversations" 

1082 

1083 id = Column(BigInteger, primary_key=True) 

1084 # timezone should always be UTC 

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

1086 

1087 def __repr__(self): 

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

1089 

1090 

1091class GroupChat(Base): 

1092 """ 

1093 Group chat 

1094 """ 

1095 

1096 __tablename__ = "group_chats" 

1097 

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

1099 

1100 title = Column(String, nullable=True) 

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

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

1103 is_dm = Column(Boolean, nullable=False) 

1104 

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

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

1107 

1108 def __repr__(self): 

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

1110 

1111 

1112class GroupChatRole(enum.Enum): 

1113 admin = enum.auto() 

1114 participant = enum.auto() 

1115 

1116 

1117class GroupChatSubscription(Base): 

1118 """ 

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

1120 """ 

1121 

1122 __tablename__ = "group_chat_subscriptions" 

1123 id = Column(BigInteger, primary_key=True) 

1124 

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

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

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

1128 

1129 # timezones should always be UTC 

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

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

1132 

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

1134 

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

1136 

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

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

1139 

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

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

1142 

1143 def muted_display(self): 

1144 """ 

1145 Returns (muted, muted_until) display values: 

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

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

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

1149 """ 

1150 if self.muted_until < now(): 

1151 return (False, None) 

1152 elif self.muted_until == DATETIME_INFINITY: 

1153 return (True, None) 

1154 else: 

1155 return (True, self.muted_until) 

1156 

1157 @hybrid_property 

1158 def is_muted(self): 

1159 return self.muted_until > func.now() 

1160 

1161 def __repr__(self): 

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

1163 

1164 

1165class MessageType(enum.Enum): 

1166 text = enum.auto() 

1167 # e.g. 

1168 # image = 

1169 # emoji = 

1170 # ... 

1171 chat_created = enum.auto() 

1172 chat_edited = enum.auto() 

1173 user_invited = enum.auto() 

1174 user_left = enum.auto() 

1175 user_made_admin = enum.auto() 

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

1177 host_request_status_changed = enum.auto() 

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

1179 

1180 

1181class HostRequestStatus(enum.Enum): 

1182 pending = enum.auto() 

1183 accepted = enum.auto() 

1184 rejected = enum.auto() 

1185 confirmed = enum.auto() 

1186 cancelled = enum.auto() 

1187 

1188 

1189class Message(Base): 

1190 """ 

1191 A message. 

1192 

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

1194 """ 

1195 

1196 __tablename__ = "messages" 

1197 

1198 id = Column(BigInteger, primary_key=True) 

1199 

1200 # which conversation the message belongs in 

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

1202 

1203 # the user that sent the message/command 

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

1205 

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

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

1208 

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

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

1211 

1212 # time sent, timezone should always be UTC 

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

1214 

1215 # the plain-text message text if not control 

1216 text = Column(String, nullable=True) 

1217 

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

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

1220 

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

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

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

1224 

1225 @property 

1226 def is_normal_message(self): 

1227 """ 

1228 There's only one normal type atm, text 

1229 """ 

1230 return self.message_type == MessageType.text 

1231 

1232 def __repr__(self): 

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

1234 

1235 

1236class ContentReport(Base): 

1237 """ 

1238 A piece of content reported to admins 

1239 """ 

1240 

1241 __tablename__ = "content_reports" 

1242 

1243 id = Column(BigInteger, primary_key=True) 

1244 

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

1246 

1247 # the user who reported or flagged the content 

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

1249 

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

1251 reason = Column(String, nullable=False) 

1252 # a short description 

1253 description = Column(String, nullable=False) 

1254 

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

1256 content_ref = Column(String, nullable=False) 

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

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

1259 

1260 # details of the browser, if available 

1261 user_agent = Column(String, nullable=False) 

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

1263 page = Column(String, nullable=False) 

1264 

1265 # see comments above for reporting vs author 

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

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

1268 

1269 

1270class Email(Base): 

1271 """ 

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

1273 """ 

1274 

1275 __tablename__ = "emails" 

1276 

1277 id = Column(String, primary_key=True) 

1278 

1279 # timezone should always be UTC 

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

1281 

1282 sender_name = Column(String, nullable=False) 

1283 sender_email = Column(String, nullable=False) 

1284 

1285 recipient = Column(String, nullable=False) 

1286 subject = Column(String, nullable=False) 

1287 

1288 plain = Column(String, nullable=False) 

1289 html = Column(String, nullable=False) 

1290 

1291 list_unsubscribe_header = Column(String, nullable=True) 

1292 source_data = Column(String, nullable=True) 

1293 

1294 

1295class SMS(Base): 

1296 """ 

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

1298 """ 

1299 

1300 __tablename__ = "smss" 

1301 

1302 id = Column(BigInteger, primary_key=True) 

1303 

1304 # timezone should always be UTC 

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

1306 # AWS message id 

1307 message_id = Column(String, nullable=False) 

1308 

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

1310 sms_sender_id = Column(String, nullable=False) 

1311 number = Column(String, nullable=False) 

1312 message = Column(String, nullable=False) 

1313 

1314 

1315class HostRequest(Base): 

1316 """ 

1317 A request to stay with a host 

1318 """ 

1319 

1320 __tablename__ = "host_requests" 

1321 

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

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

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

1325 

1326 # TODO: proper timezone handling 

1327 timezone = "Etc/UTC" 

1328 

1329 # dates in the timezone above 

1330 from_date = Column(Date, nullable=False) 

1331 to_date = Column(Date, nullable=False) 

1332 

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

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

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

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

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

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

1339 

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

1341 

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

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

1344 

1345 # number of reference reminders sent out 

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

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

1348 

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

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

1351 host_reason_didnt_meetup = Column(String, nullable=True) 

1352 surfer_reason_didnt_meetup = Column(String, nullable=True) 

1353 

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

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

1356 conversation = relationship("Conversation") 

1357 

1358 __table_args__ = ( 

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

1360 Index( 

1361 "ix_host_requests_host_didnt_meetup", 

1362 host_reason_didnt_meetup != None, 

1363 ), 

1364 Index( 

1365 "ix_host_requests_surfer_didnt_meetup", 

1366 surfer_reason_didnt_meetup != None, 

1367 ), 

1368 ) 

1369 

1370 @hybrid_property 

1371 def can_write_reference(self): 

1372 return ( 

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

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

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

1376 ) 

1377 

1378 @can_write_reference.expression 

1379 def can_write_reference(cls): 

1380 return ( 

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

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

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

1384 ) 

1385 

1386 def __repr__(self): 

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

1388 

1389 

1390class ReferenceType(enum.Enum): 

1391 friend = enum.auto() 

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

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

1394 

1395 

1396class Reference(Base): 

1397 """ 

1398 Reference from one user to another 

1399 """ 

1400 

1401 __tablename__ = "references" 

1402 

1403 id = Column(BigInteger, primary_key=True) 

1404 # timezone should always be UTC 

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

1406 

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

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

1409 

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

1411 

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

1413 

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

1415 # text that's only visible to mods 

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

1417 

1418 rating = Column(Float, nullable=False) 

1419 was_appropriate = Column(Boolean, nullable=False) 

1420 

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

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

1423 

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

1425 

1426 __table_args__ = ( 

1427 # Rating must be between 0 and 1, inclusive 

1428 CheckConstraint( 

1429 "rating BETWEEN 0 AND 1", 

1430 name="rating_between_0_and_1", 

1431 ), 

1432 # Has host_request_id or it's a friend reference 

1433 CheckConstraint( 

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

1435 name="host_request_id_xor_friend_reference", 

1436 ), 

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

1438 Index( 

1439 "ix_references_unique_friend_reference", 

1440 from_user_id, 

1441 to_user_id, 

1442 reference_type, 

1443 unique=True, 

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

1445 ), 

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

1447 Index( 

1448 "ix_references_unique_per_host_request", 

1449 from_user_id, 

1450 to_user_id, 

1451 host_request_id, 

1452 unique=True, 

1453 postgresql_where=(host_request_id != None), 

1454 ), 

1455 ) 

1456 

1457 @property 

1458 def should_report(self): 

1459 """ 

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

1461 """ 

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

1463 

1464 

1465class InitiatedUpload(Base): 

1466 """ 

1467 Started downloads, not necessarily complete yet. 

1468 """ 

1469 

1470 __tablename__ = "initiated_uploads" 

1471 

1472 key = Column(String, primary_key=True) 

1473 

1474 # timezones should always be UTC 

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

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

1477 

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

1479 

1480 initiator_user = relationship("User") 

1481 

1482 @hybrid_property 

1483 def is_valid(self): 

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

1485 

1486 

1487class Upload(Base): 

1488 """ 

1489 Completed uploads. 

1490 """ 

1491 

1492 __tablename__ = "uploads" 

1493 key = Column(String, primary_key=True) 

1494 

1495 filename = Column(String, nullable=False) 

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

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

1498 

1499 # photo credit, etc 

1500 credit = Column(String, nullable=True) 

1501 

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

1503 

1504 def _url(self, size): 

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

1506 

1507 @property 

1508 def thumbnail_url(self): 

1509 return self._url("thumbnail") 

1510 

1511 @property 

1512 def full_url(self): 

1513 return self._url("full") 

1514 

1515 

1516communities_seq = Sequence("communities_seq") 

1517 

1518 

1519class Node(Base): 

1520 """ 

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

1522 

1523 Administered by the official cluster 

1524 """ 

1525 

1526 __tablename__ = "nodes" 

1527 

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

1529 

1530 # name and description come from official cluster 

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

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

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

1534 

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

1536 

1537 contained_users = relationship( 

1538 "User", 

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

1540 viewonly=True, 

1541 uselist=True, 

1542 ) 

1543 

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

1545 

1546 

1547class Cluster(Base): 

1548 """ 

1549 Cluster, administered grouping of content 

1550 """ 

1551 

1552 __tablename__ = "clusters" 

1553 

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

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

1556 name = Column(String, nullable=False) 

1557 # short description 

1558 description = Column(String, nullable=False) 

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

1560 

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

1562 

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

1564 

1565 official_cluster_for_node = relationship( 

1566 "Node", 

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

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

1569 uselist=False, 

1570 viewonly=True, 

1571 ) 

1572 

1573 parent_node = relationship( 

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

1575 ) 

1576 

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

1578 # all pages 

1579 pages = relationship( 

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

1581 ) 

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

1583 discussions = relationship( 

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

1585 ) 

1586 

1587 # includes also admins 

1588 members = relationship( 

1589 "User", 

1590 lazy="dynamic", 

1591 backref="cluster_memberships", 

1592 secondary="cluster_subscriptions", 

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

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

1595 viewonly=True, 

1596 ) 

1597 

1598 admins = relationship( 

1599 "User", 

1600 lazy="dynamic", 

1601 backref="cluster_adminships", 

1602 secondary="cluster_subscriptions", 

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

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

1605 viewonly=True, 

1606 ) 

1607 

1608 main_page = relationship( 

1609 "Page", 

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

1611 viewonly=True, 

1612 uselist=False, 

1613 ) 

1614 

1615 @property 

1616 def is_leaf(self) -> bool: 

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

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

1619 

1620 __table_args__ = ( 

1621 # Each node can have at most one official cluster 

1622 Index( 

1623 "ix_clusters_owner_parent_node_id_is_official_cluster", 

1624 parent_node_id, 

1625 is_official_cluster, 

1626 unique=True, 

1627 postgresql_where=is_official_cluster, 

1628 ), 

1629 ) 

1630 

1631 

1632class NodeClusterAssociation(Base): 

1633 """ 

1634 NodeClusterAssociation, grouping of nodes 

1635 """ 

1636 

1637 __tablename__ = "node_cluster_associations" 

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

1639 

1640 id = Column(BigInteger, primary_key=True) 

1641 

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

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

1644 

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

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

1647 

1648 

1649class ClusterRole(enum.Enum): 

1650 member = enum.auto() 

1651 admin = enum.auto() 

1652 

1653 

1654class ClusterSubscription(Base): 

1655 """ 

1656 ClusterSubscription of a user 

1657 """ 

1658 

1659 __tablename__ = "cluster_subscriptions" 

1660 __table_args__ = (UniqueConstraint("user_id", "cluster_id"),) 

1661 

1662 id = Column(BigInteger, primary_key=True) 

1663 

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

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

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

1667 

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

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

1670 

1671 

1672class ClusterPageAssociation(Base): 

1673 """ 

1674 pages related to clusters 

1675 """ 

1676 

1677 __tablename__ = "cluster_page_associations" 

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

1679 

1680 id = Column(BigInteger, primary_key=True) 

1681 

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

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

1684 

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

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

1687 

1688 

1689class PageType(enum.Enum): 

1690 main_page = enum.auto() 

1691 place = enum.auto() 

1692 guide = enum.auto() 

1693 

1694 

1695class Page(Base): 

1696 """ 

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

1698 """ 

1699 

1700 __tablename__ = "pages" 

1701 

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

1703 

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

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

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

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

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

1709 

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

1711 

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

1713 

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

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

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

1717 owner_cluster = relationship( 

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

1719 ) 

1720 

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

1722 

1723 __table_args__ = ( 

1724 # Only one of owner_user and owner_cluster should be set 

1725 CheckConstraint( 

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

1727 name="one_owner", 

1728 ), 

1729 # Only clusters can own main pages 

1730 CheckConstraint( 

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

1732 name="main_page_owned_by_cluster", 

1733 ), 

1734 # Each cluster can have at most one main page 

1735 Index( 

1736 "ix_pages_owner_cluster_id_type", 

1737 owner_cluster_id, 

1738 type, 

1739 unique=True, 

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

1741 ), 

1742 ) 

1743 

1744 def __repr__(self): 

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

1746 

1747 

1748class PageVersion(Base): 

1749 """ 

1750 version of page content 

1751 """ 

1752 

1753 __tablename__ = "page_versions" 

1754 

1755 id = Column(BigInteger, primary_key=True) 

1756 

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

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

1759 title = Column(String, nullable=False) 

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

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

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

1763 # the human-readable address 

1764 address = Column(String, nullable=True) 

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

1766 

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

1768 

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

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

1771 photo = relationship("Upload") 

1772 

1773 __table_args__ = ( 

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

1775 CheckConstraint( 

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

1777 name="geom_iff_address", 

1778 ), 

1779 ) 

1780 

1781 @property 

1782 def coordinates(self): 

1783 # returns (lat, lng) or None 

1784 return get_coordinates(self.geom) 

1785 

1786 def __repr__(self): 

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

1788 

1789 

1790class ClusterEventAssociation(Base): 

1791 """ 

1792 events related to clusters 

1793 """ 

1794 

1795 __tablename__ = "cluster_event_associations" 

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

1797 

1798 id = Column(BigInteger, primary_key=True) 

1799 

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

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

1802 

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

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

1805 

1806 

1807class Event(Base): 

1808 """ 

1809 An event is compose of two parts: 

1810 

1811 * An event template (Event) 

1812 * An occurrence (EventOccurrence) 

1813 

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

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

1816 """ 

1817 

1818 __tablename__ = "events" 

1819 

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

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

1822 

1823 title = Column(String, nullable=False) 

1824 

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

1826 

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

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

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

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

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

1832 

1833 parent_node = relationship( 

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

1835 ) 

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

1837 subscribers = relationship( 

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

1839 ) 

1840 organizers = relationship( 

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

1842 ) 

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

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

1845 owner_cluster = relationship( 

1846 "Cluster", 

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

1848 uselist=False, 

1849 foreign_keys="Event.owner_cluster_id", 

1850 ) 

1851 

1852 __table_args__ = ( 

1853 # Only one of owner_user and owner_cluster should be set 

1854 CheckConstraint( 

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

1856 name="one_owner", 

1857 ), 

1858 ) 

1859 

1860 

1861class EventOccurrence(Base): 

1862 __tablename__ = "event_occurrences" 

1863 

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

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

1866 

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

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

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

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

1871 

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

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

1874 

1875 # a null geom is an online-only event 

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

1877 # physical address, iff geom is not null 

1878 address = Column(String, nullable=True) 

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

1880 link = Column(String, nullable=True) 

1881 

1882 timezone = "Etc/UTC" 

1883 

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

1885 # simplifies database constraints, etc 

1886 during = Column(TSTZRANGE, nullable=False) 

1887 

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

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

1890 

1891 creator_user = relationship( 

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

1893 ) 

1894 event = relationship( 

1895 "Event", 

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

1897 remote_side="Event.id", 

1898 foreign_keys="EventOccurrence.event_id", 

1899 ) 

1900 

1901 photo = relationship("Upload") 

1902 

1903 __table_args__ = ( 

1904 # Geom and address go together 

1905 CheckConstraint( 

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

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

1908 name="geom_iff_address", 

1909 ), 

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

1911 CheckConstraint( 

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

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

1914 name="link_or_geom", 

1915 ), 

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

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

1918 ) 

1919 

1920 @property 

1921 def coordinates(self): 

1922 # returns (lat, lng) or None 

1923 return get_coordinates(self.geom) 

1924 

1925 @hybrid_property 

1926 def start_time(self): 

1927 return self.during.lower 

1928 

1929 @start_time.expression 

1930 def start_time(cls): 

1931 return func.lower(cls.during) 

1932 

1933 @hybrid_property 

1934 def end_time(self): 

1935 return self.during.upper 

1936 

1937 @end_time.expression 

1938 def end_time(cls): 

1939 return func.upper(cls.during) 

1940 

1941 

1942class EventSubscription(Base): 

1943 """ 

1944 Users' subscriptions to events 

1945 """ 

1946 

1947 __tablename__ = "event_subscriptions" 

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

1949 

1950 id = Column(BigInteger, primary_key=True) 

1951 

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

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

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

1955 

1956 user = relationship("User") 

1957 event = relationship("Event") 

1958 

1959 

1960class EventOrganizer(Base): 

1961 """ 

1962 Organizers for events 

1963 """ 

1964 

1965 __tablename__ = "event_organizers" 

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

1967 

1968 id = Column(BigInteger, primary_key=True) 

1969 

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

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

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

1973 

1974 user = relationship("User") 

1975 event = relationship("Event") 

1976 

1977 

1978class AttendeeStatus(enum.Enum): 

1979 going = enum.auto() 

1980 maybe = enum.auto() 

1981 

1982 

1983class EventOccurrenceAttendee(Base): 

1984 """ 

1985 Attendees for events 

1986 """ 

1987 

1988 __tablename__ = "event_occurrence_attendees" 

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

1990 

1991 id = Column(BigInteger, primary_key=True) 

1992 

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

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

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

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

1997 

1998 user = relationship("User") 

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

2000 

2001 

2002class EventCommunityInviteRequest(Base): 

2003 """ 

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

2005 """ 

2006 

2007 __tablename__ = "event_community_invite_requests" 

2008 

2009 id = Column(BigInteger, primary_key=True) 

2010 

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

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

2013 

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

2015 

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

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

2018 approved = Column(Boolean, nullable=True) 

2019 

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

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

2022 

2023 __table_args__ = ( 

2024 # each user can only request once 

2025 UniqueConstraint("occurrence_id", "user_id"), 

2026 # each event can only have one notification sent out 

2027 Index( 

2028 "ix_event_community_invite_requests_unique", 

2029 occurrence_id, 

2030 unique=True, 

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

2032 ), 

2033 # decided and approved ought to be null simultaneously 

2034 CheckConstraint( 

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

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

2037 name="decided_approved", 

2038 ), 

2039 ) 

2040 

2041 

2042class ClusterDiscussionAssociation(Base): 

2043 """ 

2044 discussions related to clusters 

2045 """ 

2046 

2047 __tablename__ = "cluster_discussion_associations" 

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

2049 

2050 id = Column(BigInteger, primary_key=True) 

2051 

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

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

2054 

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

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

2057 

2058 

2059class Discussion(Base): 

2060 """ 

2061 forum board 

2062 """ 

2063 

2064 __tablename__ = "discussions" 

2065 

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

2067 

2068 title = Column(String, nullable=False) 

2069 content = Column(String, nullable=False) 

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

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

2072 

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

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

2075 

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

2077 

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

2079 

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

2081 

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

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

2084 

2085 

2086class DiscussionSubscription(Base): 

2087 """ 

2088 users subscriptions to discussions 

2089 """ 

2090 

2091 __tablename__ = "discussion_subscriptions" 

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

2093 

2094 id = Column(BigInteger, primary_key=True) 

2095 

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

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

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

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

2100 

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

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

2103 

2104 

2105class Thread(Base): 

2106 """ 

2107 Thread 

2108 """ 

2109 

2110 __tablename__ = "threads" 

2111 

2112 id = Column(BigInteger, primary_key=True) 

2113 

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

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

2116 

2117 

2118class Comment(Base): 

2119 """ 

2120 Comment 

2121 """ 

2122 

2123 __tablename__ = "comments" 

2124 

2125 id = Column(BigInteger, primary_key=True) 

2126 

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

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

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

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

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

2132 

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

2134 

2135 

2136class Reply(Base): 

2137 """ 

2138 Reply 

2139 """ 

2140 

2141 __tablename__ = "replies" 

2142 

2143 id = Column(BigInteger, primary_key=True) 

2144 

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

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

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

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

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

2150 

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

2152 

2153 

2154class BackgroundJobState(enum.Enum): 

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

2156 pending = enum.auto() 

2157 # job complete 

2158 completed = enum.auto() 

2159 # error occured, will be retried 

2160 error = enum.auto() 

2161 # failed too many times, not retrying anymore 

2162 failed = enum.auto() 

2163 

2164 

2165class BackgroundJob(Base): 

2166 """ 

2167 This table implements a queue of background jobs. 

2168 """ 

2169 

2170 __tablename__ = "background_jobs" 

2171 

2172 id = Column(BigInteger, primary_key=True) 

2173 

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

2175 job_type = Column(String, nullable=False) 

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

2177 

2178 # time queued 

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

2180 

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

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

2183 

2184 # used to count number of retries for failed jobs 

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

2186 

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

2188 

2189 # protobuf encoded job payload 

2190 payload = Column(Binary, nullable=False) 

2191 

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

2193 failure_info = Column(String, nullable=True) 

2194 

2195 __table_args__ = ( 

2196 # used in looking up background jobs to attempt 

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

2198 Index( 

2199 "ix_background_jobs_lookup", 

2200 next_attempt_after, 

2201 (max_tries - try_count), 

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

2203 ), 

2204 ) 

2205 

2206 @hybrid_property 

2207 def ready_for_retry(self): 

2208 return ( 

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

2210 & (self.try_count < self.max_tries) 

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

2212 ) 

2213 

2214 def __repr__(self): 

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

2216 

2217 

2218class NotificationDeliveryType(enum.Enum): 

2219 # send push notification to mobile/web 

2220 push = enum.auto() 

2221 # send individual email immediately 

2222 email = enum.auto() 

2223 # send in digest 

2224 digest = enum.auto() 

2225 

2226 

2227dt = NotificationDeliveryType 

2228nd = notification_data_pb2 

2229 

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

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

2232 

2233 

2234class NotificationTopicAction(enum.Enum): 

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

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

2237 self.defaults = defaults 

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

2239 self.user_editable = user_editable 

2240 

2241 self.data_type = data_type 

2242 

2243 def unpack(self): 

2244 return self.topic, self.action 

2245 

2246 @property 

2247 def display(self): 

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

2249 

2250 def __str__(self): 

2251 return self.display 

2252 

2253 # topic, action, default delivery types 

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

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

2256 

2257 # host requests 

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

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

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

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

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

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

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

2265 

2266 # you receive a friend ref 

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

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

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

2270 # ... the surfer 

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

2272 

2273 # you hosted 

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

2275 # you surfed 

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

2277 

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

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

2280 

2281 # group chats 

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

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

2284 

2285 # events 

2286 # approved by mods 

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

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

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

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

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

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

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

2294 # toplevel comment on an event 

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

2296 

2297 # discussion created 

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

2299 # someone comments on your discussion 

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

2301 

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

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

2304 

2305 # account settings 

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

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

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

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

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

2311 # reset password 

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

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

2314 

2315 # account deletion 

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

2317 # no more pushing to do 

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

2319 # undeleted 

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

2321 

2322 # admin actions 

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

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

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

2326 

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

2328 

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

2330 

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

2332 

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

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

2335 

2336 

2337class NotificationPreference(Base): 

2338 __tablename__ = "notification_preferences" 

2339 

2340 id = Column(BigInteger, primary_key=True) 

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

2342 

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

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

2345 deliver = Column(Boolean, nullable=False) 

2346 

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

2348 

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

2350 

2351 

2352class Notification(Base): 

2353 """ 

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

2355 """ 

2356 

2357 __tablename__ = "notifications" 

2358 

2359 id = Column(BigInteger, primary_key=True) 

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

2361 

2362 # recipient user id 

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

2364 

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

2366 key = Column(String, nullable=False) 

2367 

2368 data = Column(Binary, nullable=False) 

2369 

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

2371 

2372 __table_args__ = ( 

2373 # used in looking up which notifications need delivery 

2374 Index( 

2375 "ix_notifications_created", 

2376 created, 

2377 ), 

2378 ) 

2379 

2380 @property 

2381 def topic(self): 

2382 return self.topic_action.topic 

2383 

2384 @property 

2385 def action(self): 

2386 return self.topic_action.action 

2387 

2388 

2389class NotificationDelivery(Base): 

2390 __tablename__ = "notification_deliveries" 

2391 

2392 id = Column(BigInteger, primary_key=True) 

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

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

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

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

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

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

2399 # todo: device id 

2400 # todo: receipt id, etc 

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

2402 

2403 __table_args__ = ( 

2404 UniqueConstraint("notification_id", "delivery_type"), 

2405 # used in looking up which notifications need delivery 

2406 Index( 

2407 "ix_notification_deliveries_delivery_type", 

2408 delivery_type, 

2409 postgresql_where=(delivered != None), 

2410 ), 

2411 Index( 

2412 "ix_notification_deliveries_dt_ni_dnull", 

2413 delivery_type, 

2414 notification_id, 

2415 delivered == None, 

2416 ), 

2417 ) 

2418 

2419 

2420class PushNotificationSubscription(Base): 

2421 __tablename__ = "push_notification_subscriptions" 

2422 

2423 id = Column(BigInteger, primary_key=True) 

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

2425 

2426 # which user this is connected to 

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

2428 

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

2430 # the endpoint 

2431 endpoint = Column(String, nullable=False) 

2432 # the "auth" key 

2433 auth_key = Column(Binary, nullable=False) 

2434 # the "p256dh" key 

2435 p256dh_key = Column(Binary, nullable=False) 

2436 

2437 full_subscription_info = Column(String, nullable=False) 

2438 

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

2440 user_agent = Column(String, nullable=True) 

2441 

2442 # when it was disabled 

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

2444 

2445 user = relationship("User") 

2446 

2447 

2448class PushNotificationDeliveryAttempt(Base): 

2449 __tablename__ = "push_notification_delivery_attempt" 

2450 

2451 id = Column(BigInteger, primary_key=True) 

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

2453 

2454 push_notification_subscription_id = Column( 

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

2456 ) 

2457 

2458 success = Column(Boolean, nullable=False) 

2459 # the HTTP status code, 201 is success 

2460 status_code = Column(Integer, nullable=False) 

2461 

2462 # can be null if it was a success 

2463 response = Column(String, nullable=True) 

2464 

2465 push_notification_subscription = relationship("PushNotificationSubscription") 

2466 

2467 

2468class Language(Base): 

2469 """ 

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

2471 """ 

2472 

2473 __tablename__ = "languages" 

2474 

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

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

2477 

2478 # the english name 

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

2480 

2481 

2482class Region(Base): 

2483 """ 

2484 Table of regions 

2485 """ 

2486 

2487 __tablename__ = "regions" 

2488 

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

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

2491 

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

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

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

2495 

2496 

2497class UserBlock(Base): 

2498 """ 

2499 Table of blocked users 

2500 """ 

2501 

2502 __tablename__ = "user_blocks" 

2503 __table_args__ = (UniqueConstraint("blocking_user_id", "blocked_user_id"),) 

2504 

2505 id = Column(BigInteger, primary_key=True) 

2506 

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

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

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

2510 

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

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

2513 

2514 

2515class APICall(Base): 

2516 """ 

2517 API call logs 

2518 """ 

2519 

2520 __tablename__ = "api_calls" 

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

2522 

2523 id = Column(BigInteger, primary_key=True) 

2524 

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

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

2527 

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

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

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

2531 

2532 # approximate time of the call 

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

2534 

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

2536 method = Column(String, nullable=False) 

2537 

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

2539 status_code = Column(String, nullable=True) 

2540 

2541 # handler duration (excluding serialization, etc) 

2542 duration = Column(Float, nullable=False) 

2543 

2544 # user_id of caller, None means not logged in 

2545 user_id = Column(BigInteger, nullable=True) 

2546 

2547 # sanitized request bytes 

2548 request = Column(Binary, nullable=True) 

2549 

2550 # sanitized response bytes 

2551 response = Column(Binary, nullable=True) 

2552 

2553 # whether response bytes have been truncated 

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

2555 

2556 # the exception traceback, if any 

2557 traceback = Column(String, nullable=True) 

2558 

2559 # human readable perf report 

2560 perf_report = Column(String, nullable=True) 

2561 

2562 # details of the browser, if available 

2563 ip_address = Column(String, nullable=True) 

2564 user_agent = Column(String, nullable=True) 

2565 

2566 

2567class AccountDeletionReason(Base): 

2568 __tablename__ = "account_deletion_reason" 

2569 

2570 id = Column(BigInteger, primary_key=True) 

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

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

2573 reason = Column(String, nullable=True) 

2574 

2575 user = relationship("User")