Coverage for src/couchers/models.py: 99%
1221 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-09-14 15:31 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-09-14 15:31 +0000
1import enum
3from geoalchemy2.types import Geometry
4from google.protobuf import empty_pb2
5from sqlalchemy import (
6 ARRAY,
7 JSON,
8 BigInteger,
9 Boolean,
10 CheckConstraint,
11 Column,
12 Date,
13 DateTime,
14 Enum,
15 Float,
16 ForeignKey,
17 Index,
18 Integer,
19 Interval,
20 MetaData,
21 Sequence,
22 String,
23 UniqueConstraint,
24)
25from sqlalchemy import LargeBinary as Binary
26from sqlalchemy.dialects.postgresql import INET, TSTZRANGE, ExcludeConstraint
27from sqlalchemy.ext.associationproxy import association_proxy
28from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property
29from sqlalchemy.orm import backref, column_property, declarative_base, deferred, relationship
30from sqlalchemy.sql import and_, expression, func, not_, or_, text
31from sqlalchemy.sql import select as sa_select
33from couchers import urls
34from couchers.config import config
35from couchers.constants import (
36 DATETIME_INFINITY,
37 DATETIME_MINUS_INFINITY,
38 EMAIL_REGEX,
39 GUIDELINES_VERSION,
40 PHONE_VERIFICATION_LIFETIME,
41 SMS_CODE_LIFETIME,
42 TOS_VERSION,
43)
44from couchers.utils import (
45 date_in_timezone,
46 get_coordinates,
47 last_active_coarsen,
48 now,
49)
50from proto import notification_data_pb2
52meta = MetaData(
53 naming_convention={
54 "ix": "ix_%(column_0_label)s",
55 "uq": "uq_%(table_name)s_%(column_0_name)s",
56 "ck": "ck_%(table_name)s_%(constraint_name)s",
57 "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
58 "pk": "pk_%(table_name)s",
59 }
60)
62Base = declarative_base(metadata=meta)
65class HostingStatus(enum.Enum):
66 can_host = enum.auto()
67 maybe = enum.auto()
68 cant_host = enum.auto()
71class MeetupStatus(enum.Enum):
72 wants_to_meetup = enum.auto()
73 open_to_meetup = enum.auto()
74 does_not_want_to_meetup = enum.auto()
77class SmokingLocation(enum.Enum):
78 yes = enum.auto()
79 window = enum.auto()
80 outside = enum.auto()
81 no = enum.auto()
84class SleepingArrangement(enum.Enum):
85 private = enum.auto()
86 common = enum.auto()
87 shared_room = enum.auto()
90class ParkingDetails(enum.Enum):
91 free_onsite = enum.auto()
92 free_offsite = enum.auto()
93 paid_onsite = enum.auto()
94 paid_offsite = enum.auto()
97class ProfilePublicVisibility(enum.Enum):
98 # no public info
99 nothing = enum.auto()
100 # only show on map, randomized, unclickable
101 map_only = enum.auto()
102 # name, gender, location, hosting/meetup status, badges, number of references, and signup time
103 limited = enum.auto()
104 # full about me except additional info (hide my home)
105 most = enum.auto()
106 # all but references
107 full = enum.auto()
110class TimezoneArea(Base):
111 __tablename__ = "timezone_areas"
112 id = Column(BigInteger, primary_key=True)
114 tzid = Column(String)
115 geom = Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False)
117 __table_args__ = (
118 Index(
119 "ix_timezone_areas_geom_tzid",
120 geom,
121 tzid,
122 postgresql_using="gist",
123 ),
124 )
127class User(Base):
128 """
129 Basic user and profile details
130 """
132 __tablename__ = "users"
134 id = Column(BigInteger, primary_key=True)
136 username = Column(String, nullable=False, unique=True)
137 email = Column(String, nullable=False, unique=True)
138 # stored in libsodium hash format, can be null for email login
139 hashed_password = Column(Binary, nullable=False)
140 # phone number in E.164 format with leading +, for example "+46701740605"
141 phone = Column(String, nullable=True, server_default=expression.null())
142 # language preference -- defaults to empty string
143 ui_language_preference = Column(String, nullable=True, server_default="")
145 # timezones should always be UTC
146 ## location
147 # point describing their location. EPSG4326 is the SRS (spatial ref system, = way to describe a point on earth) used
148 # by GPS, it has the WGS84 geoid with lat/lon
149 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=False)
150 # randomized coordinates within a radius of 0.02-0.1 degrees, equates to about 2-10 km
151 randomized_geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
152 # their display location (displayed to other users), in meters
153 geom_radius = Column(Float, nullable=False)
154 # the display address (text) shown on their profile
155 city = Column(String, nullable=False)
156 # "Grew up in" on profile
157 hometown = Column(String, nullable=True)
159 regions_visited = relationship("Region", secondary="regions_visited", order_by="Region.name")
160 regions_lived = relationship("Region", secondary="regions_lived", order_by="Region.name")
162 timezone = column_property(
163 sa_select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, geom)).limit(1).scalar_subquery(),
164 deferred=True,
165 )
167 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
168 last_active = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
170 public_visibility = Column(Enum(ProfilePublicVisibility), nullable=False, server_default="map_only")
171 has_modified_public_visibility = Column(Boolean, nullable=False, server_default=expression.false())
173 # id of the last message that they received a notification about
174 last_notified_message_id = Column(BigInteger, nullable=False, default=0)
175 # same as above for host requests
176 last_notified_request_message_id = Column(BigInteger, nullable=False, server_default=text("0"))
178 # display name
179 name = Column(String, nullable=False)
180 gender = Column(String, nullable=False)
181 pronouns = Column(String, nullable=True)
182 birthdate = Column(Date, nullable=False) # in the timezone of birthplace
184 avatar_key = Column(ForeignKey("uploads.key"), nullable=True)
186 hosting_status = Column(Enum(HostingStatus), nullable=False)
187 meetup_status = Column(Enum(MeetupStatus), nullable=False, server_default="open_to_meetup")
189 # community standing score
190 community_standing = Column(Float, nullable=True)
192 occupation = Column(String, nullable=True) # CommonMark without images
193 education = Column(String, nullable=True) # CommonMark without images
195 # "Who I am" under "About Me" tab
196 about_me = Column(String, nullable=True) # CommonMark without images
197 # "What I do in my free time" under "About Me" tab
198 things_i_like = Column(String, nullable=True) # CommonMark without images
199 # "About my home" under "My Home" tab
200 about_place = Column(String, nullable=True) # CommonMark without images
201 # "Additional information" under "About Me" tab
202 additional_information = Column(String, nullable=True) # CommonMark without images
204 is_banned = Column(Boolean, nullable=False, server_default=expression.false())
205 is_deleted = Column(Boolean, nullable=False, server_default=expression.false())
206 is_superuser = Column(Boolean, nullable=False, server_default=expression.false())
208 # the undelete token allows a user to recover their account for a couple of days after deletion in case it was
209 # accidental or they changed their mind
210 # constraints make sure these are non-null only if is_deleted and that these are null in unison
211 undelete_token = Column(String, nullable=True)
212 # validity of the undelete token
213 undelete_until = Column(DateTime(timezone=True), nullable=True)
215 # hosting preferences
216 max_guests = Column(Integer, nullable=True)
217 last_minute = Column(Boolean, nullable=True)
218 has_pets = Column(Boolean, nullable=True)
219 accepts_pets = Column(Boolean, nullable=True)
220 pet_details = Column(String, nullable=True) # CommonMark without images
221 has_kids = Column(Boolean, nullable=True)
222 accepts_kids = Column(Boolean, nullable=True)
223 kid_details = Column(String, nullable=True) # CommonMark without images
224 has_housemates = Column(Boolean, nullable=True)
225 housemate_details = Column(String, nullable=True) # CommonMark without images
226 wheelchair_accessible = Column(Boolean, nullable=True)
227 smoking_allowed = Column(Enum(SmokingLocation), nullable=True)
228 smokes_at_home = Column(Boolean, nullable=True)
229 drinking_allowed = Column(Boolean, nullable=True)
230 drinks_at_home = Column(Boolean, nullable=True)
231 # "Additional information" under "My Home" tab
232 other_host_info = Column(String, nullable=True) # CommonMark without images
234 # "Sleeping privacy" (not long-form text)
235 sleeping_arrangement = Column(Enum(SleepingArrangement), nullable=True)
236 # "Sleeping arrangement" under "My Home" tab
237 sleeping_details = Column(String, nullable=True) # CommonMark without images
238 # "Local area information" under "My Home" tab
239 area = Column(String, nullable=True) # CommonMark without images
240 # "House rules" under "My Home" tab
241 house_rules = Column(String, nullable=True) # CommonMark without images
242 parking = Column(Boolean, nullable=True)
243 parking_details = Column(Enum(ParkingDetails), nullable=True) # CommonMark without images
244 camping_ok = Column(Boolean, nullable=True)
246 accepted_tos = Column(Integer, nullable=False, default=0)
247 accepted_community_guidelines = Column(Integer, nullable=False, server_default="0")
248 # whether the user has yet filled in the contributor form
249 filled_contributor_form = Column(Boolean, nullable=False, server_default=expression.false())
251 # number of onboarding emails sent
252 onboarding_emails_sent = Column(Integer, nullable=False, server_default="0")
253 last_onboarding_email_sent = Column(DateTime(timezone=True), nullable=True)
255 # whether we need to sync the user's newsletter preferences with the newsletter server
256 in_sync_with_newsletter = Column(Boolean, nullable=False, server_default=expression.false())
257 # opted out of the newsletter
258 opt_out_of_newsletter = Column(Boolean, nullable=False, server_default=expression.false())
260 # set to null to receive no digests
261 digest_frequency = Column(Interval, nullable=True)
262 last_digest_sent = Column(DateTime(timezone=True), nullable=False, server_default=text("to_timestamp(0)"))
264 # for changing their email
265 new_email = Column(String, nullable=True)
267 new_email_token = Column(String, nullable=True)
268 new_email_token_created = Column(DateTime(timezone=True), nullable=True)
269 new_email_token_expiry = Column(DateTime(timezone=True), nullable=True)
271 recommendation_score = Column(Float, nullable=False, server_default="0")
273 # Columns for verifying their phone number. State chart:
274 # ,-------------------,
275 # | Start |
276 # | phone = None | someone else
277 # ,-----------------, | token = None | verifies ,-----------------------,
278 # | Code Expired | | sent = 1970 or zz | phone xx | Verification Expired |
279 # | phone = xx | time passes | verified = None | <------, | phone = xx |
280 # | token = yy | <------------, | attempts = 0 | | | token = None |
281 # | sent = zz (exp.)| | '-------------------' | | sent = zz |
282 # | verified = None | | V ^ +-----------< | verified = ww (exp.) |
283 # | attempts = 0..2 | >--, | | | ChangePhone("") | | attempts = 0 |
284 # '-----------------' +-------- | ------+----+--------------------+ '-----------------------'
285 # | | | | ChangePhone(xx) | ^ time passes
286 # | | ^ V | |
287 # ,-----------------, | | ,-------------------, | ,-----------------------,
288 # | Too Many | >--' '--< | Code sent | >------+ | Verified |
289 # | phone = xx | | phone = xx | | | phone = xx |
290 # | token = yy | VerifyPhone(wrong)| token = yy | '-----------< | token = None |
291 # | sent = zz | <------+--------< | sent = zz | | sent = zz |
292 # | verified = None | | | verified = None | VerifyPhone(correct) | verified = ww |
293 # | attempts = 3 | '--------> | attempts = 0..2 | >------------------> | attempts = 0 |
294 # '-----------------' '-------------------' '-----------------------'
296 # randomly generated Luhn 6-digit string
297 phone_verification_token = Column(String(6), nullable=True, server_default=expression.null())
299 phone_verification_sent = Column(DateTime(timezone=True), nullable=False, server_default=text("to_timestamp(0)"))
300 phone_verification_verified = Column(DateTime(timezone=True), nullable=True, server_default=expression.null())
301 phone_verification_attempts = Column(Integer, nullable=False, server_default=text("0"))
303 # the stripe customer identifier if the user has donated to Couchers
304 # e.g. cus_JjoXHttuZopv0t
305 # for new US entity
306 stripe_customer_id = Column(String, nullable=True)
307 # for old AU entity
308 stripe_customer_id_old = Column(String, nullable=True)
310 has_passport_sex_gender_exception = Column(Boolean, nullable=False, server_default=expression.false())
312 # checking for phone verification
313 has_donated = Column(Boolean, nullable=False, server_default=expression.false())
315 # whether this user has all emails turned off
316 do_not_email = Column(Boolean, nullable=False, server_default=expression.false())
318 avatar = relationship("Upload", foreign_keys="User.avatar_key")
320 admin_note = Column(String, nullable=False, server_default=text("''"))
322 # whether mods have marked this user has having to update their location
323 needs_to_update_location = Column(Boolean, nullable=False, server_default=expression.false())
325 last_antibot = Column(DateTime(timezone=True), nullable=False, server_default=text("to_timestamp(0)"))
327 age = column_property(func.date_part("year", func.age(birthdate)))
329 # ID of the invite code used to sign up (if any)
330 invite_code_id = Column(ForeignKey("invite_codes.id"), nullable=True)
331 invite_code = relationship("InviteCode", foreign_keys=[invite_code_id])
333 moderation_user_lists = relationship(
334 "ModerationUserList", secondary="moderation_user_list_members", back_populates="users"
335 )
337 __table_args__ = (
338 # Verified phone numbers should be unique
339 Index(
340 "ix_users_unique_phone",
341 phone,
342 unique=True,
343 postgresql_where=phone_verification_verified != None,
344 ),
345 Index(
346 "ix_users_active",
347 id,
348 postgresql_where=and_(not_(is_banned), not_(is_deleted)),
349 ),
350 # create index on users(geom, id, username) where not is_banned and not is_deleted and geom is not null;
351 Index(
352 "ix_users_geom_active",
353 geom,
354 id,
355 username,
356 postgresql_using="gist",
357 postgresql_where=and_(not_(is_banned), not_(is_deleted)),
358 ),
359 Index(
360 "ix_users_by_id",
361 id,
362 postgresql_using="hash",
363 postgresql_where=and_(not_(is_banned), not_(is_deleted)),
364 ),
365 Index(
366 "ix_users_by_username",
367 username,
368 postgresql_using="hash",
369 postgresql_where=and_(not_(is_banned), not_(is_deleted)),
370 ),
371 # There are two possible states for new_email_token, new_email_token_created, and new_email_token_expiry
372 CheckConstraint(
373 "(new_email_token IS NOT NULL AND new_email_token_created IS NOT NULL AND new_email_token_expiry IS NOT NULL) OR \
374 (new_email_token IS NULL AND new_email_token_created IS NULL AND new_email_token_expiry IS NULL)",
375 name="check_new_email_token_state",
376 ),
377 # Whenever a phone number is set, it must either be pending verification or already verified.
378 # Exactly one of the following must always be true: not phone, token, verified.
379 CheckConstraint(
380 "(phone IS NULL)::int + (phone_verification_verified IS NOT NULL)::int + (phone_verification_token IS NOT NULL)::int = 1",
381 name="phone_verified_conditions",
382 ),
383 # Email must match our regex
384 CheckConstraint(
385 f"email ~ '{EMAIL_REGEX}'",
386 name="valid_email",
387 ),
388 # Undelete token + time are coupled: either both null or neither; and if they're not null then the account is deleted
389 CheckConstraint(
390 "((undelete_token IS NULL) = (undelete_until IS NULL)) AND ((undelete_token IS NULL) OR is_deleted)",
391 name="undelete_nullity",
392 ),
393 # If the user disabled all emails, then they can't host or meet up
394 CheckConstraint(
395 "(do_not_email IS FALSE) OR ((hosting_status = 'cant_host') AND (meetup_status = 'does_not_want_to_meetup'))",
396 name="do_not_email_inactive",
397 ),
398 )
400 @hybrid_property
401 def has_completed_profile(self):
402 return self.avatar_key is not None and self.about_me is not None and len(self.about_me) >= 150
404 @has_completed_profile.expression
405 def has_completed_profile(cls):
406 return (cls.avatar_key != None) & (func.character_length(cls.about_me) >= 150)
408 @hybrid_property
409 def has_completed_my_home(self):
410 # completed my profile means that:
411 # 1. has filled out max_guests
412 # 2. has filled out sleeping_arrangement (sleeping privacy)
413 # 3. has some text in at least one of the my home free text fields
414 return (
415 self.max_guests is not None
416 and self.sleeping_arrangement is not None
417 and (
418 self.about_place is not None
419 or self.other_host_info is not None
420 or self.sleeping_details is not None
421 or self.area is not None
422 or self.house_rules is not None
423 )
424 )
426 @has_completed_my_home.expression
427 def has_completed_my_home(cls):
428 return and_(
429 cls.max_guests != None,
430 cls.sleeping_arrangement != None,
431 or_(
432 cls.about_place != None,
433 cls.other_host_info != None,
434 cls.sleeping_details != None,
435 cls.area != None,
436 cls.house_rules != None,
437 ),
438 )
440 @hybrid_property
441 def jailed_missing_tos(self):
442 return self.accepted_tos < TOS_VERSION
444 @hybrid_property
445 def jailed_missing_community_guidelines(self):
446 return self.accepted_community_guidelines < GUIDELINES_VERSION
448 @hybrid_property
449 def jailed_pending_mod_notes(self):
450 return self.mod_notes.where(ModNote.is_pending).count() > 0
452 @hybrid_property
453 def jailed_pending_activeness_probe(self):
454 return self.pending_activeness_probe != None
456 @hybrid_property
457 def is_jailed(self):
458 return (
459 self.jailed_missing_tos
460 | self.jailed_missing_community_guidelines
461 | self.is_missing_location
462 | self.jailed_pending_mod_notes
463 | self.jailed_pending_activeness_probe
464 )
466 @hybrid_property
467 def is_missing_location(self):
468 return self.needs_to_update_location
470 @hybrid_property
471 def is_visible(self):
472 return ~(self.is_banned | self.is_deleted)
474 @property
475 def coordinates(self):
476 return get_coordinates(self.geom)
478 @property
479 def display_joined(self):
480 """
481 Returns the last active time rounded down to the nearest hour.
482 """
483 return self.joined.replace(minute=0, second=0, microsecond=0)
485 @property
486 def display_last_active(self):
487 """
488 Returns the last active time rounded down whatever is the "last active" coarsening.
489 """
490 return last_active_coarsen(self.last_active)
492 @hybrid_property
493 def phone_is_verified(self):
494 return (
495 self.phone_verification_verified is not None
496 and now() - self.phone_verification_verified < PHONE_VERIFICATION_LIFETIME
497 )
499 @phone_is_verified.expression
500 def phone_is_verified(cls):
501 return (cls.phone_verification_verified != None) & (
502 now() - cls.phone_verification_verified < PHONE_VERIFICATION_LIFETIME
503 )
505 @hybrid_property
506 def phone_code_expired(self):
507 return now() - self.phone_verification_sent > SMS_CODE_LIFETIME
509 def __repr__(self):
510 return f"User(id={self.id}, email={self.email}, username={self.username})"
513class UserBadge(Base):
514 """
515 A badge on a user's profile
516 """
518 __tablename__ = "user_badges"
519 __table_args__ = (UniqueConstraint("user_id", "badge_id"),)
521 id = Column(BigInteger, primary_key=True)
523 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
524 # corresponds to "id" in badges.json
525 badge_id = Column(String, nullable=False, index=True)
527 # take this with a grain of salt, someone may get then lose a badge for whatever reason
528 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
530 user = relationship("User", backref="badges")
533class ActivenessProbeStatus(enum.Enum):
534 # no response yet
535 pending = enum.auto()
537 # didn't respond on time
538 expired = enum.auto()
540 # responded that they're still active
541 still_active = enum.auto()
543 # responded that they're no longer active
544 no_longer_active = enum.auto()
547class ActivenessProbe(Base):
548 """
549 Activeness probes are used to gauge if users are still active: we send them a notification and ask them to respond,
550 we use this data both to help indicate response rate, as well as to make sure only those who are actively hosting
551 show up as such.
552 """
554 __tablename__ = "activeness_probes"
556 id = Column(BigInteger, primary_key=True)
558 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
559 # the time this probe was initiated
560 probe_initiated = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
561 # the number of reminders sent for this probe
562 notifications_sent = Column(Integer, nullable=False, server_default="0")
564 # the time of response
565 responded = Column(DateTime(timezone=True), nullable=True, default=None)
566 # the response value
567 response = Column(Enum(ActivenessProbeStatus), nullable=False, default=ActivenessProbeStatus.pending)
569 @hybrid_property
570 def is_pending(self):
571 return self.responded == None
573 user = relationship("User", back_populates="pending_activeness_probe")
575 __table_args__ = (
576 # a user can have at most one pending activeness probe at a time
577 Index(
578 "ix_activeness_probe_unique_pending_response",
579 user_id,
580 unique=True,
581 postgresql_where=responded == None,
582 ),
583 # response time is none iff response is pending
584 CheckConstraint(
585 "(responded IS NULL AND response = 'pending') OR (responded IS NOT NULL AND response != 'pending')",
586 name="pending_has_no_responded",
587 ),
588 )
591User.pending_activeness_probe = relationship(
592 ActivenessProbe,
593 primaryjoin="and_(ActivenessProbe.user_id == User.id, ActivenessProbe.is_pending)",
594 uselist=False,
595 back_populates="user",
596)
599class StrongVerificationAttemptStatus(enum.Enum):
600 ## full data states
601 # completed, this now provides verification for a user
602 succeeded = enum.auto()
604 ## no data states
605 # in progress: waiting for the user to scan the Iris code or open the app
606 in_progress_waiting_on_user_to_open_app = enum.auto()
607 # in progress: waiting for the user to scan MRZ or NFC/chip
608 in_progress_waiting_on_user_in_app = enum.auto()
609 # in progress, waiting for backend to pull verification data
610 in_progress_waiting_on_backend = enum.auto()
611 # failed, no data
612 failed = enum.auto()
614 # duplicate, at our end, has data
615 duplicate = enum.auto()
617 ## minimal data states
618 # the data, except minimal deduplication data, was deleted
619 deleted = enum.auto()
622class PassportSex(enum.Enum):
623 """
624 We don't care about sex, we use gender on the platform. But passports apparently do.
625 """
627 male = enum.auto()
628 female = enum.auto()
629 unspecified = enum.auto()
632class StrongVerificationAttempt(Base):
633 """
634 An attempt to perform strong verification
635 """
637 __tablename__ = "strong_verification_attempts"
639 # our verification id
640 id = Column(BigInteger, primary_key=True)
642 # this is returned in the callback, and we look up the attempt via this
643 verification_attempt_token = Column(String, nullable=False, unique=True)
645 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
646 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
648 status = Column(
649 Enum(StrongVerificationAttemptStatus),
650 nullable=False,
651 default=StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app,
652 )
654 ## full data
655 has_full_data = Column(Boolean, nullable=False, default=False)
656 # the data returned from iris, encrypted with a public key whose private key is kept offline
657 passport_encrypted_data = Column(Binary, nullable=True)
658 passport_date_of_birth = Column(Date, nullable=True)
659 passport_sex = Column(Enum(PassportSex), nullable=True)
661 ## minimal data: this will not be deleted
662 has_minimal_data = Column(Boolean, nullable=False, default=False)
663 passport_expiry_date = Column(Date, nullable=True)
664 passport_nationality = Column(String, nullable=True)
665 # last three characters of the passport number
666 passport_last_three_document_chars = Column(String, nullable=True)
668 iris_token = Column(String, nullable=False, unique=True)
669 iris_session_id = Column(BigInteger, nullable=False, unique=True)
671 passport_expiry_datetime = column_property(date_in_timezone(passport_expiry_date, "Etc/UTC"))
673 user = relationship("User")
675 @hybrid_property
676 def is_valid(self):
677 """
678 This only checks whether the attempt is a success and the passport is not expired, use `has_strong_verification` for full check
679 """
680 return (self.status == StrongVerificationAttemptStatus.succeeded) and (self.passport_expiry_datetime >= now())
682 @is_valid.expression
683 def is_valid(cls):
684 return (cls.status == StrongVerificationAttemptStatus.succeeded) & (
685 func.coalesce(cls.passport_expiry_datetime >= func.now(), False)
686 )
688 @hybrid_property
689 def is_visible(self):
690 return self.status != StrongVerificationAttemptStatus.deleted
692 @hybrid_method
693 def _raw_birthdate_match(self, user):
694 """Does not check whether the SV attempt itself is not expired"""
695 return self.passport_date_of_birth == user.birthdate
697 @hybrid_method
698 def matches_birthdate(self, user):
699 return self.is_valid & self._raw_birthdate_match(user)
701 @hybrid_method
702 def _raw_gender_match(self, user):
703 """Does not check whether the SV attempt itself is not expired"""
704 return (
705 ((user.gender == "Woman") & (self.passport_sex == PassportSex.female))
706 | ((user.gender == "Man") & (self.passport_sex == PassportSex.male))
707 | (self.passport_sex == PassportSex.unspecified)
708 | (user.has_passport_sex_gender_exception == True)
709 )
711 @hybrid_method
712 def matches_gender(self, user):
713 return self.is_valid & self._raw_gender_match(user)
715 @hybrid_method
716 def has_strong_verification(self, user):
717 return self.is_valid & self._raw_birthdate_match(user) & self._raw_gender_match(user)
719 __table_args__ = (
720 # used to look up verification status for a user
721 Index(
722 "ix_strong_verification_attempts_current",
723 user_id,
724 passport_expiry_date,
725 postgresql_where=status == StrongVerificationAttemptStatus.succeeded,
726 ),
727 # each passport can be verified only once
728 Index(
729 "ix_strong_verification_attempts_unique_succeeded",
730 passport_expiry_date,
731 passport_nationality,
732 passport_last_three_document_chars,
733 unique=True,
734 postgresql_where=(
735 (status == StrongVerificationAttemptStatus.succeeded)
736 | (status == StrongVerificationAttemptStatus.deleted)
737 ),
738 ),
739 # full data check
740 CheckConstraint(
741 "(has_full_data IS TRUE AND passport_encrypted_data IS NOT NULL AND passport_date_of_birth IS NOT NULL) OR \
742 (has_full_data IS FALSE AND passport_encrypted_data IS NULL AND passport_date_of_birth IS NULL)",
743 name="full_data_status",
744 ),
745 # minimal data check
746 CheckConstraint(
747 "(has_minimal_data IS TRUE AND passport_expiry_date IS NOT NULL AND passport_nationality IS NOT NULL AND passport_last_three_document_chars IS NOT NULL) OR \
748 (has_minimal_data IS FALSE AND passport_expiry_date IS NULL AND passport_nationality IS NULL AND passport_last_three_document_chars IS NULL)",
749 name="minimal_data_status",
750 ),
751 # note on implications: p => q iff ~p OR q
752 # full data implies minimal data, has_minimal_data => has_full_data
753 CheckConstraint(
754 "(has_full_data IS FALSE) OR (has_minimal_data IS TRUE)",
755 name="full_data_implies_minimal_data",
756 ),
757 # succeeded implies full data
758 CheckConstraint(
759 "(NOT (status = 'succeeded')) OR (has_full_data IS TRUE)",
760 name="succeeded_implies_full_data",
761 ),
762 # in_progress/failed implies no_data
763 CheckConstraint(
764 "(NOT ((status = 'in_progress_waiting_on_user_to_open_app') OR (status = 'in_progress_waiting_on_user_in_app') OR (status = 'in_progress_waiting_on_backend') OR (status = 'failed'))) OR (has_minimal_data IS FALSE)",
765 name="in_progress_failed_iris_implies_no_data",
766 ),
767 # deleted or duplicate implies minimal data
768 CheckConstraint(
769 "(NOT ((status = 'deleted') OR (status = 'duplicate'))) OR (has_minimal_data IS TRUE)",
770 name="deleted_duplicate_implies_minimal_data",
771 ),
772 )
775class ModNote(Base):
776 """
777 A moderator note to a user. This could be a warning, just a note "hey, we did X", or any other similar message.
779 The user has to read and "acknowledge" the note before continuing onto the platform, and being un-jailed.
780 """
782 __tablename__ = "mod_notes"
783 id = Column(BigInteger, primary_key=True)
785 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
787 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
788 acknowledged = Column(DateTime(timezone=True), nullable=True)
790 # this is an internal ID to allow the mods to track different types of notes
791 internal_id = Column(String, nullable=False)
792 # the admin that left this note
793 creator_user_id = Column(ForeignKey("users.id"), nullable=False)
795 note_content = Column(String, nullable=False) # CommonMark without images
797 user = relationship("User", backref=backref("mod_notes", lazy="dynamic"), foreign_keys="ModNote.user_id")
799 def __repr__(self):
800 return f"ModeNote(id={self.id}, user={self.user}, created={self.created}, ack'd={self.acknowledged})"
802 @hybrid_property
803 def is_pending(self):
804 return self.acknowledged == None
806 __table_args__ = (
807 # used to look up pending notes
808 Index(
809 "ix_mod_notes_unacknowledged",
810 user_id,
811 postgresql_where=acknowledged == None,
812 ),
813 )
816class StrongVerificationCallbackEvent(Base):
817 __tablename__ = "strong_verification_callback_events"
819 id = Column(BigInteger, primary_key=True)
820 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
822 verification_attempt_id = Column(ForeignKey("strong_verification_attempts.id"), nullable=False, index=True)
824 iris_status = Column(String, nullable=False)
827class DonationType(enum.Enum):
828 one_time = enum.auto()
829 recurring = enum.auto()
832class DonationInitiation(Base):
833 """
834 Whenever someone initiaties a donation through the platform
835 """
837 __tablename__ = "donation_initiations"
838 id = Column(BigInteger, primary_key=True)
840 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
841 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
843 amount = Column(Integer, nullable=False)
844 stripe_checkout_session_id = Column(String, nullable=False)
846 donation_type = Column(Enum(DonationType), nullable=False)
847 source = Column(String, nullable=True)
849 user = relationship("User", backref="donation_initiations")
852class Invoice(Base):
853 """
854 Successful donations, both one off and recurring
856 Triggered by `payment_intent.succeeded` webhook
857 """
859 __tablename__ = "invoices"
861 id = Column(BigInteger, primary_key=True)
862 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
863 user_id = Column(ForeignKey("users.id"), nullable=False)
865 amount = Column(Float, nullable=False)
867 stripe_payment_intent_id = Column(String, nullable=False, unique=True)
868 stripe_receipt_url = Column(String, nullable=False)
870 user = relationship("User", backref="invoices")
873class LanguageFluency(enum.Enum):
874 # note that the numbering is important here, these are ordinal
875 beginner = 1
876 conversational = 2
877 fluent = 3
880class LanguageAbility(Base):
881 __tablename__ = "language_abilities"
882 __table_args__ = (
883 # Users can only have one language ability per language
884 UniqueConstraint("user_id", "language_code"),
885 )
887 id = Column(BigInteger, primary_key=True)
888 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
889 language_code = Column(ForeignKey("languages.code", deferrable=True), nullable=False)
890 fluency = Column(Enum(LanguageFluency), nullable=False)
892 user = relationship("User", backref="language_abilities")
893 language = relationship("Language")
896class RegionVisited(Base):
897 __tablename__ = "regions_visited"
898 __table_args__ = (UniqueConstraint("user_id", "region_code"),)
900 id = Column(BigInteger, primary_key=True)
901 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
902 region_code = Column(ForeignKey("regions.code", deferrable=True), nullable=False)
905class RegionLived(Base):
906 __tablename__ = "regions_lived"
907 __table_args__ = (UniqueConstraint("user_id", "region_code"),)
909 id = Column(BigInteger, primary_key=True)
910 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
911 region_code = Column(ForeignKey("regions.code", deferrable=True), nullable=False)
914class FriendStatus(enum.Enum):
915 pending = enum.auto()
916 accepted = enum.auto()
917 rejected = enum.auto()
918 cancelled = enum.auto()
921class FriendRelationship(Base):
922 """
923 Friendship relations between users
925 TODO: make this better with sqlalchemy self-referential stuff
926 TODO: constraint on only one row per user pair where accepted or pending
927 """
929 __tablename__ = "friend_relationships"
931 id = Column(BigInteger, primary_key=True)
933 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
934 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
936 status = Column(Enum(FriendStatus), nullable=False, default=FriendStatus.pending)
938 # timezones should always be UTC
939 time_sent = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
940 time_responded = Column(DateTime(timezone=True), nullable=True)
942 from_user = relationship("User", backref="friends_from", foreign_keys="FriendRelationship.from_user_id")
943 to_user = relationship("User", backref="friends_to", foreign_keys="FriendRelationship.to_user_id")
945 __table_args__ = (
946 # Ping looks up pending friend reqs, this speeds that up
947 Index(
948 "ix_friend_relationships_status_to_from",
949 status,
950 to_user_id,
951 from_user_id,
952 ),
953 )
956class ContributeOption(enum.Enum):
957 yes = enum.auto()
958 maybe = enum.auto()
959 no = enum.auto()
962class ContributorForm(Base):
963 """
964 Someone filled in the contributor form
965 """
967 __tablename__ = "contributor_forms"
969 id = Column(BigInteger, primary_key=True)
971 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
972 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
974 ideas = Column(String, nullable=True)
975 features = Column(String, nullable=True)
976 experience = Column(String, nullable=True)
977 contribute = Column(Enum(ContributeOption), nullable=True)
978 contribute_ways = Column(ARRAY(String), nullable=False)
979 expertise = Column(String, nullable=True)
981 user = relationship("User", backref="contributor_forms")
983 @hybrid_property
984 def is_filled(self):
985 """
986 Whether the form counts as having been filled
987 """
988 return (
989 (self.ideas != None)
990 | (self.features != None)
991 | (self.experience != None)
992 | (self.contribute != None)
993 | (self.contribute_ways != [])
994 | (self.expertise != None)
995 )
997 @property
998 def should_notify(self):
999 """
1000 If this evaluates to true, we send an email to the recruitment team.
1002 We currently send if expertise is listed, or if they list a way to help outside of a set list
1003 """
1004 return False
1007class SignupFlow(Base):
1008 """
1009 Signup flows/incomplete users
1011 Coinciding fields have the same meaning as in User
1012 """
1014 __tablename__ = "signup_flows"
1016 id = Column(BigInteger, primary_key=True)
1018 # housekeeping
1019 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1020 flow_token = Column(String, nullable=False, unique=True)
1021 email_verified = Column(Boolean, nullable=False, default=False)
1022 email_sent = Column(Boolean, nullable=False, default=False)
1023 email_token = Column(String, nullable=True)
1024 email_token_expiry = Column(DateTime(timezone=True), nullable=True)
1026 ## Basic
1027 name = Column(String, nullable=False)
1028 # TODO: unique across both tables
1029 email = Column(String, nullable=False, unique=True)
1030 # TODO: invitation, attribution
1032 ## Account
1033 # TODO: unique across both tables
1034 username = Column(String, nullable=True, unique=True)
1035 hashed_password = Column(Binary, nullable=True)
1036 birthdate = Column(Date, nullable=True) # in the timezone of birthplace
1037 gender = Column(String, nullable=True)
1038 hosting_status = Column(Enum(HostingStatus), nullable=True)
1039 city = Column(String, nullable=True)
1040 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
1041 geom_radius = Column(Float, nullable=True)
1043 accepted_tos = Column(Integer, nullable=True)
1044 accepted_community_guidelines = Column(Integer, nullable=False, server_default="0")
1046 opt_out_of_newsletter = Column(Boolean, nullable=True)
1048 ## Feedback (now unused)
1049 filled_feedback = Column(Boolean, nullable=False, default=False)
1050 ideas = Column(String, nullable=True)
1051 features = Column(String, nullable=True)
1052 experience = Column(String, nullable=True)
1053 contribute = Column(Enum(ContributeOption), nullable=True)
1054 contribute_ways = Column(ARRAY(String), nullable=True)
1055 expertise = Column(String, nullable=True)
1057 invite_code_id = Column(ForeignKey("invite_codes.id"), nullable=True)
1059 @hybrid_property
1060 def token_is_valid(self):
1061 return (self.email_token != None) & (self.email_token_expiry >= now())
1063 @hybrid_property
1064 def account_is_filled(self):
1065 return (
1066 (self.username != None)
1067 & (self.birthdate != None)
1068 & (self.gender != None)
1069 & (self.hosting_status != None)
1070 & (self.city != None)
1071 & (self.geom != None)
1072 & (self.geom_radius != None)
1073 & (self.accepted_tos != None)
1074 & (self.opt_out_of_newsletter != None)
1075 )
1077 @hybrid_property
1078 def is_completed(self):
1079 return self.email_verified & self.account_is_filled & (self.accepted_community_guidelines == GUIDELINES_VERSION)
1082class LoginToken(Base):
1083 """
1084 A login token sent in an email to a user, allows them to sign in between the times defined by created and expiry
1085 """
1087 __tablename__ = "login_tokens"
1088 token = Column(String, primary_key=True)
1090 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1092 # timezones should always be UTC
1093 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1094 expiry = Column(DateTime(timezone=True), nullable=False)
1096 user = relationship("User", backref="login_tokens")
1098 @hybrid_property
1099 def is_valid(self):
1100 return (self.created <= now()) & (self.expiry >= now())
1102 def __repr__(self):
1103 return f"LoginToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})"
1106class PasswordResetToken(Base):
1107 __tablename__ = "password_reset_tokens"
1108 token = Column(String, primary_key=True)
1110 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1112 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1113 expiry = Column(DateTime(timezone=True), nullable=False)
1115 user = relationship("User", backref="password_reset_tokens")
1117 @hybrid_property
1118 def is_valid(self):
1119 return (self.created <= now()) & (self.expiry >= now())
1121 def __repr__(self):
1122 return f"PasswordResetToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})"
1125class AccountDeletionToken(Base):
1126 __tablename__ = "account_deletion_tokens"
1128 token = Column(String, primary_key=True)
1130 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1132 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1133 expiry = Column(DateTime(timezone=True), nullable=False)
1135 user = relationship("User", backref="account_deletion_tokens")
1137 @hybrid_property
1138 def is_valid(self):
1139 return (self.created <= now()) & (self.expiry >= now())
1141 def __repr__(self):
1142 return f"AccountDeletionToken(token={self.token}, user_id={self.user_id}, created={self.created}, expiry={self.expiry})"
1145class UserActivity(Base):
1146 """
1147 User activity: for each unique (user_id, period, ip_address, user_agent) tuple, keep track of number of api calls
1149 Used for user "last active" as well as admin stuff
1150 """
1152 __tablename__ = "user_activity"
1154 id = Column(BigInteger, primary_key=True)
1156 user_id = Column(ForeignKey("users.id"), nullable=False)
1157 # the start of a period of time, e.g. 1 hour during which we bin activeness
1158 period = Column(DateTime(timezone=True), nullable=False)
1160 # details of the browser, if available
1161 ip_address = Column(INET, nullable=True)
1162 user_agent = Column(String, nullable=True)
1164 # count of api calls made with this ip, user_agent, and period
1165 api_calls = Column(Integer, nullable=False, default=0)
1167 __table_args__ = (
1168 # helps look up this tuple quickly
1169 Index(
1170 "ix_user_activity_user_id_period_ip_address_user_agent",
1171 user_id,
1172 period,
1173 ip_address,
1174 user_agent,
1175 unique=True,
1176 ),
1177 )
1180class UserSession(Base):
1181 """
1182 API keys/session cookies for the app
1184 There are two types of sessions: long-lived, and short-lived. Long-lived are
1185 like when you choose "remember this browser": they will be valid for a long
1186 time without the user interacting with the site. Short-lived sessions on the
1187 other hand get invalidated quickly if the user does not interact with the
1188 site.
1190 Long-lived tokens are valid from `created` until `expiry`.
1192 Short-lived tokens expire after 168 hours (7 days) if they are not used.
1193 """
1195 __tablename__ = "sessions"
1196 token = Column(String, primary_key=True)
1198 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1200 # sessions are either "api keys" or "session cookies", otherwise identical
1201 # an api key is put in the authorization header (e.g. "authorization: Bearer <token>")
1202 # a session cookie is set in the "couchers-sesh" cookie (e.g. "cookie: couchers-sesh=<token>")
1203 # when a session is created, it's fixed as one or the other for security reasons
1204 # for api keys to be useful, they should be long lived and have a long expiry
1205 is_api_key = Column(Boolean, nullable=False, server_default=expression.false())
1207 # whether it's a long-lived or short-lived session
1208 long_lived = Column(Boolean, nullable=False)
1210 # the time at which the session was created
1211 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1213 # the expiry of the session: the session *cannot* be refreshed past this
1214 expiry = Column(DateTime(timezone=True), nullable=False, server_default=func.now() + text("interval '730 days'"))
1216 # the time at which the token was invalidated, allows users to delete sessions
1217 deleted = Column(DateTime(timezone=True), nullable=True, default=None)
1219 # the last time this session was used
1220 last_seen = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1222 # count of api calls made with this token/session (if we're updating last_seen, might as well update this too)
1223 api_calls = Column(Integer, nullable=False, default=0)
1225 # details of the browser, if available
1226 # these are from the request creating the session, not used for anything else
1227 ip_address = Column(String, nullable=True)
1228 user_agent = Column(String, nullable=True)
1230 user = relationship("User", backref="sessions")
1232 @hybrid_property
1233 def is_valid(self):
1234 """
1235 It must have been created and not be expired or deleted.
1237 Also, if it's a short lived token, it must have been used in the last 168 hours.
1239 TODO: this probably won't run in python (instance level), only in sql (class level)
1240 """
1241 return (
1242 (self.created <= func.now())
1243 & (self.expiry >= func.now())
1244 & (self.deleted == None)
1245 & (self.long_lived | (func.now() - self.last_seen < text("interval '168 hours'")))
1246 )
1248 __table_args__ = (
1249 Index(
1250 "ix_sessions_by_token",
1251 "token",
1252 postgresql_using="hash",
1253 ),
1254 )
1257class Conversation(Base):
1258 """
1259 Conversation brings together the different types of message/conversation types
1260 """
1262 __tablename__ = "conversations"
1264 id = Column(BigInteger, primary_key=True)
1265 # timezone should always be UTC
1266 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1268 def __repr__(self):
1269 return f"Conversation(id={self.id}, created={self.created})"
1272class GroupChat(Base):
1273 """
1274 Group chat
1275 """
1277 __tablename__ = "group_chats"
1279 conversation_id = Column("id", ForeignKey("conversations.id"), nullable=False, primary_key=True)
1281 title = Column(String, nullable=True)
1282 only_admins_invite = Column(Boolean, nullable=False, default=True)
1283 creator_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1284 is_dm = Column(Boolean, nullable=False)
1286 conversation = relationship("Conversation", backref="group_chat")
1287 creator = relationship("User", backref="created_group_chats")
1289 def __repr__(self):
1290 return f"GroupChat(conversation={self.conversation}, title={self.title or 'None'}, only_admins_invite={self.only_admins_invite}, creator={self.creator}, is_dm={self.is_dm})"
1293class GroupChatRole(enum.Enum):
1294 admin = enum.auto()
1295 participant = enum.auto()
1298class GroupChatSubscription(Base):
1299 """
1300 The recipient of a thread and information about when they joined/left/etc.
1301 """
1303 __tablename__ = "group_chat_subscriptions"
1304 id = Column(BigInteger, primary_key=True)
1306 # TODO: DB constraint on only one user+group_chat combo at a given time
1307 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1308 group_chat_id = Column(ForeignKey("group_chats.id"), nullable=False, index=True)
1310 # timezones should always be UTC
1311 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1312 left = Column(DateTime(timezone=True), nullable=True)
1314 role = Column(Enum(GroupChatRole), nullable=False)
1316 last_seen_message_id = Column(BigInteger, nullable=False, default=0)
1318 # when this chat is muted until, DATETIME_INFINITY for "forever"
1319 muted_until = Column(DateTime(timezone=True), nullable=False, server_default=DATETIME_MINUS_INFINITY.isoformat())
1321 user = relationship("User", backref="group_chat_subscriptions")
1322 group_chat = relationship("GroupChat", backref=backref("subscriptions", lazy="dynamic"))
1324 def muted_display(self):
1325 """
1326 Returns (muted, muted_until) display values:
1327 1. If not muted, returns (False, None)
1328 2. If muted forever, returns (True, None)
1329 3. If muted until a given datetime returns (True, dt)
1330 """
1331 if self.muted_until < now():
1332 return (False, None)
1333 elif self.muted_until == DATETIME_INFINITY:
1334 return (True, None)
1335 else:
1336 return (True, self.muted_until)
1338 @hybrid_property
1339 def is_muted(self):
1340 return self.muted_until > func.now()
1342 def __repr__(self):
1343 return f"GroupChatSubscription(id={self.id}, user={self.user}, joined={self.joined}, left={self.left}, role={self.role}, group_chat={self.group_chat})"
1346class InviteCode(Base):
1347 __tablename__ = "invite_codes"
1349 id = Column(String, primary_key=True)
1350 creator_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
1351 created = Column(DateTime(timezone=True), nullable=False, default=func.now())
1352 disabled = Column(DateTime(timezone=True), nullable=True)
1354 creator = relationship("User", foreign_keys=[creator_user_id])
1357class MessageType(enum.Enum):
1358 text = enum.auto()
1359 # e.g.
1360 # image =
1361 # emoji =
1362 # ...
1363 chat_created = enum.auto()
1364 chat_edited = enum.auto()
1365 user_invited = enum.auto()
1366 user_left = enum.auto()
1367 user_made_admin = enum.auto()
1368 user_removed_admin = enum.auto() # RemoveGroupChatAdmin: remove admin permission from a user in group chat
1369 host_request_status_changed = enum.auto()
1370 user_removed = enum.auto() # user is removed from group chat by amdin RemoveGroupChatUser
1373class HostRequestStatus(enum.Enum):
1374 pending = enum.auto()
1375 accepted = enum.auto()
1376 rejected = enum.auto()
1377 confirmed = enum.auto()
1378 cancelled = enum.auto()
1381class Message(Base):
1382 """
1383 A message.
1385 If message_type = text, then the message is a normal text message, otherwise, it's a special control message.
1386 """
1388 __tablename__ = "messages"
1390 id = Column(BigInteger, primary_key=True)
1392 # which conversation the message belongs in
1393 conversation_id = Column(ForeignKey("conversations.id"), nullable=False, index=True)
1395 # the user that sent the message/command
1396 author_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1398 # the message type, "text" is a text message, otherwise a "control message"
1399 message_type = Column(Enum(MessageType), nullable=False)
1401 # the target if a control message and requires target, e.g. if inviting a user, the user invited is the target
1402 target_id = Column(ForeignKey("users.id"), nullable=True, index=True)
1404 # time sent, timezone should always be UTC
1405 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1407 # the plain-text message text if not control
1408 text = Column(String, nullable=True)
1410 # the new host request status if the message type is host_request_status_changed
1411 host_request_status_target = Column(Enum(HostRequestStatus), nullable=True)
1413 conversation = relationship("Conversation", backref="messages", order_by="Message.time.desc()")
1414 author = relationship("User", foreign_keys="Message.author_id")
1415 target = relationship("User", foreign_keys="Message.target_id")
1417 @property
1418 def is_normal_message(self):
1419 """
1420 There's only one normal type atm, text
1421 """
1422 return self.message_type == MessageType.text
1424 def __repr__(self):
1425 return f"Message(id={self.id}, time={self.time}, text={self.text}, author={self.author}, conversation={self.conversation})"
1428class ContentReport(Base):
1429 """
1430 A piece of content reported to admins
1431 """
1433 __tablename__ = "content_reports"
1435 id = Column(BigInteger, primary_key=True)
1437 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1439 # the user who reported or flagged the content
1440 reporting_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1442 # reason, e.g. spam, inappropriate, etc
1443 reason = Column(String, nullable=False)
1444 # a short description
1445 description = Column(String, nullable=False)
1447 # a reference to the content, see //docs/content_ref.md
1448 content_ref = Column(String, nullable=False)
1449 # the author of the content (e.g. the user who wrote the comment itself)
1450 author_user_id = Column(ForeignKey("users.id"), nullable=False)
1452 # details of the browser, if available
1453 user_agent = Column(String, nullable=False)
1454 # the URL the user was on when reporting the content
1455 page = Column(String, nullable=False)
1457 # see comments above for reporting vs author
1458 reporting_user = relationship("User", foreign_keys="ContentReport.reporting_user_id")
1459 author_user = relationship("User", foreign_keys="ContentReport.author_user_id")
1462class Email(Base):
1463 """
1464 Table of all dispatched emails for debugging purposes, etc.
1465 """
1467 __tablename__ = "emails"
1469 id = Column(String, primary_key=True)
1471 # timezone should always be UTC
1472 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1474 sender_name = Column(String, nullable=False)
1475 sender_email = Column(String, nullable=False)
1477 recipient = Column(String, nullable=False)
1478 subject = Column(String, nullable=False)
1480 plain = Column(String, nullable=False)
1481 html = Column(String, nullable=False)
1483 list_unsubscribe_header = Column(String, nullable=True)
1484 source_data = Column(String, nullable=True)
1487class SMS(Base):
1488 """
1489 Table of all sent SMSs for debugging purposes, etc.
1490 """
1492 __tablename__ = "smss"
1494 id = Column(BigInteger, primary_key=True)
1496 # timezone should always be UTC
1497 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1498 # AWS message id
1499 message_id = Column(String, nullable=False)
1501 # the SMS sender ID sent to AWS, name that the SMS appears to come from
1502 sms_sender_id = Column(String, nullable=False)
1503 number = Column(String, nullable=False)
1504 message = Column(String, nullable=False)
1507class HostRequest(Base):
1508 """
1509 A request to stay with a host
1510 """
1512 __tablename__ = "host_requests"
1514 conversation_id = Column("id", ForeignKey("conversations.id"), nullable=False, primary_key=True)
1515 surfer_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1516 host_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1518 hosting_city = Column(String, nullable=False)
1519 hosting_location = Column(Geometry("POINT", srid=4326), nullable=False)
1520 hosting_radius = Column(Float, nullable=False)
1522 # TODO: proper timezone handling
1523 timezone = "Etc/UTC"
1525 # dates in the timezone above
1526 from_date = Column(Date, nullable=False)
1527 to_date = Column(Date, nullable=False)
1529 # timezone aware start and end times of the request, can be compared to now()
1530 start_time = column_property(date_in_timezone(from_date, timezone))
1531 end_time = column_property(date_in_timezone(to_date, timezone) + text("interval '1 days'"))
1532 start_time_to_write_reference = column_property(date_in_timezone(to_date, timezone))
1533 # notice 1 day for midnight at the *end of the day*, then 14 days to write a ref
1534 end_time_to_write_reference = column_property(date_in_timezone(to_date, timezone) + text("interval '15 days'"))
1536 status = Column(Enum(HostRequestStatus), nullable=False)
1537 is_host_archived = Column(Boolean, nullable=False, default=False, server_default=expression.false())
1538 is_surfer_archived = Column(Boolean, nullable=False, default=False, server_default=expression.false())
1540 host_last_seen_message_id = Column(BigInteger, nullable=False, default=0)
1541 surfer_last_seen_message_id = Column(BigInteger, nullable=False, default=0)
1543 # number of reference reminders sent out
1544 host_sent_reference_reminders = Column(BigInteger, nullable=False, server_default=text("0"))
1545 surfer_sent_reference_reminders = Column(BigInteger, nullable=False, server_default=text("0"))
1546 host_sent_request_reminders = Column(BigInteger, nullable=False, server_default=text("0"))
1547 last_sent_request_reminder_time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1549 # reason why the host/surfer marked that they didn't meet up
1550 # if null then they haven't marked it such
1551 host_reason_didnt_meetup = Column(String, nullable=True)
1552 surfer_reason_didnt_meetup = Column(String, nullable=True)
1554 surfer = relationship("User", backref="host_requests_sent", foreign_keys="HostRequest.surfer_user_id")
1555 host = relationship("User", backref="host_requests_received", foreign_keys="HostRequest.host_user_id")
1556 conversation = relationship("Conversation")
1558 __table_args__ = (
1559 # allows fast lookup as to whether they didn't meet up
1560 Index(
1561 "ix_host_requests_host_didnt_meetup",
1562 host_reason_didnt_meetup != None,
1563 ),
1564 Index(
1565 "ix_host_requests_surfer_didnt_meetup",
1566 surfer_reason_didnt_meetup != None,
1567 ),
1568 # Used for figuring out who needs a reminder to respond
1569 Index(
1570 "ix_host_requests_status_reminder_counts",
1571 status,
1572 host_sent_request_reminders,
1573 last_sent_request_reminder_time,
1574 from_date,
1575 ),
1576 )
1578 @hybrid_property
1579 def can_write_reference(self):
1580 return (
1581 ((self.status == HostRequestStatus.confirmed) | (self.status == HostRequestStatus.accepted))
1582 & (now() >= self.start_time_to_write_reference)
1583 & (now() <= self.end_time_to_write_reference)
1584 )
1586 @can_write_reference.expression
1587 def can_write_reference(cls):
1588 return (
1589 ((cls.status == HostRequestStatus.confirmed) | (cls.status == HostRequestStatus.accepted))
1590 & (func.now() >= cls.start_time_to_write_reference)
1591 & (func.now() <= cls.end_time_to_write_reference)
1592 )
1594 def __repr__(self):
1595 return f"HostRequest(id={self.conversation_id}, surfer_user_id={self.surfer_user_id}, host_user_id={self.host_user_id}...)"
1598class HostRequestQuality(enum.Enum):
1599 high_quality = enum.auto()
1600 okay_quality = enum.auto()
1601 low_quality = enum.auto()
1604class HostRequestFeedback(Base):
1605 """
1606 Private feedback from host about a host request
1607 """
1609 __tablename__ = "host_request_feedbacks"
1611 id = Column(BigInteger, primary_key=True)
1612 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1613 host_request_id = Column(ForeignKey("host_requests.id"), nullable=False)
1615 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1616 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1618 request_quality = Column(Enum(HostRequestQuality), nullable=True)
1619 decline_reason = Column(String, nullable=True) # plain text
1621 host_request = relationship("HostRequest")
1623 __table_args__ = (
1624 # Each user can leave at most one friend reference to another user
1625 Index(
1626 "ix_unique_host_req_feedback",
1627 from_user_id,
1628 to_user_id,
1629 host_request_id,
1630 unique=True,
1631 ),
1632 )
1635class ReferenceType(enum.Enum):
1636 friend = enum.auto()
1637 surfed = enum.auto() # The "from" user surfed with the "to" user
1638 hosted = enum.auto() # The "from" user hosted the "to" user
1641class Reference(Base):
1642 """
1643 Reference from one user to another
1644 """
1646 __tablename__ = "references"
1648 id = Column(BigInteger, primary_key=True)
1649 # timezone should always be UTC
1650 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1652 from_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1653 to_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1655 reference_type = Column(Enum(ReferenceType), nullable=False)
1657 host_request_id = Column(ForeignKey("host_requests.id"), nullable=True)
1659 text = Column(String, nullable=False) # plain text
1660 # text that's only visible to mods
1661 private_text = Column(String, nullable=True) # plain text
1663 rating = Column(Float, nullable=False)
1664 was_appropriate = Column(Boolean, nullable=False)
1666 is_deleted = Column(Boolean, nullable=False, default=False, server_default=expression.false())
1668 from_user = relationship("User", backref="references_from", foreign_keys="Reference.from_user_id")
1669 to_user = relationship("User", backref="references_to", foreign_keys="Reference.to_user_id")
1671 host_request = relationship("HostRequest", backref="references")
1673 __table_args__ = (
1674 # Rating must be between 0 and 1, inclusive
1675 CheckConstraint(
1676 "rating BETWEEN 0 AND 1",
1677 name="rating_between_0_and_1",
1678 ),
1679 # Has host_request_id or it's a friend reference
1680 CheckConstraint(
1681 "(host_request_id IS NOT NULL) <> (reference_type = 'friend')",
1682 name="host_request_id_xor_friend_reference",
1683 ),
1684 # Each user can leave at most one friend reference to another user
1685 Index(
1686 "ix_references_unique_friend_reference",
1687 from_user_id,
1688 to_user_id,
1689 reference_type,
1690 unique=True,
1691 postgresql_where=(reference_type == ReferenceType.friend),
1692 ),
1693 # Each user can leave at most one reference to another user for each stay
1694 Index(
1695 "ix_references_unique_per_host_request",
1696 from_user_id,
1697 to_user_id,
1698 host_request_id,
1699 unique=True,
1700 postgresql_where=(host_request_id != None),
1701 ),
1702 )
1704 @property
1705 def should_report(self):
1706 """
1707 If this evaluates to true, we send a report to the moderation team.
1708 """
1709 return self.rating <= 0.4 or not self.was_appropriate or self.private_text
1712class InitiatedUpload(Base):
1713 """
1714 Started downloads, not necessarily complete yet.
1715 """
1717 __tablename__ = "initiated_uploads"
1719 key = Column(String, primary_key=True)
1721 # timezones should always be UTC
1722 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1723 expiry = Column(DateTime(timezone=True), nullable=False)
1725 initiator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1727 initiator_user = relationship("User")
1729 @hybrid_property
1730 def is_valid(self):
1731 return (self.created <= func.now()) & (self.expiry >= func.now())
1734class Upload(Base):
1735 """
1736 Completed uploads.
1737 """
1739 __tablename__ = "uploads"
1740 key = Column(String, primary_key=True)
1742 filename = Column(String, nullable=False)
1743 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1744 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1746 # photo credit, etc
1747 credit = Column(String, nullable=True)
1749 creator_user = relationship("User", backref="uploads", foreign_keys="Upload.creator_user_id")
1751 def _url(self, size):
1752 return urls.media_url(filename=self.filename, size=size)
1754 @property
1755 def thumbnail_url(self):
1756 return self._url("thumbnail")
1758 @property
1759 def full_url(self):
1760 return self._url("full")
1763communities_seq = Sequence("communities_seq")
1766class Node(Base):
1767 """
1768 Node, i.e. geographical subdivision of the world
1770 Administered by the official cluster
1771 """
1773 __tablename__ = "nodes"
1775 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
1777 # name and description come from official cluster
1778 parent_node_id = Column(ForeignKey("nodes.id"), nullable=True, index=True)
1779 geom = deferred(Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False))
1780 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1782 parent_node = relationship("Node", backref="child_nodes", remote_side="Node.id")
1784 contained_users = relationship(
1785 "User",
1786 primaryjoin="func.ST_Contains(foreign(Node.geom), User.geom).as_comparison(1, 2)",
1787 viewonly=True,
1788 uselist=True,
1789 )
1791 contained_user_ids = association_proxy("contained_users", "id")
1794class Cluster(Base):
1795 """
1796 Cluster, administered grouping of content
1797 """
1799 __tablename__ = "clusters"
1801 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
1802 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
1803 name = Column(String, nullable=False)
1804 # short description
1805 description = Column(String, nullable=False)
1806 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
1808 is_official_cluster = Column(Boolean, nullable=False, default=False)
1810 discussions_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
1811 events_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
1813 slug = column_property(func.slugify(name))
1815 official_cluster_for_node = relationship(
1816 "Node",
1817 primaryjoin="and_(Cluster.parent_node_id == Node.id, Cluster.is_official_cluster)",
1818 backref=backref("official_cluster", uselist=False),
1819 uselist=False,
1820 viewonly=True,
1821 )
1823 parent_node = relationship(
1824 "Node", backref="child_clusters", remote_side="Node.id", foreign_keys="Cluster.parent_node_id"
1825 )
1827 nodes = relationship("Cluster", backref="clusters", secondary="node_cluster_associations", viewonly=True)
1828 # all pages
1829 pages = relationship(
1830 "Page", backref="clusters", secondary="cluster_page_associations", lazy="dynamic", viewonly=True
1831 )
1832 events = relationship("Event", backref="clusters", secondary="cluster_event_associations", viewonly=True)
1833 discussions = relationship(
1834 "Discussion", backref="clusters", secondary="cluster_discussion_associations", viewonly=True
1835 )
1837 # includes also admins
1838 members = relationship(
1839 "User",
1840 lazy="dynamic",
1841 backref="cluster_memberships",
1842 secondary="cluster_subscriptions",
1843 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
1844 secondaryjoin="User.id == ClusterSubscription.user_id",
1845 viewonly=True,
1846 )
1848 admins = relationship(
1849 "User",
1850 lazy="dynamic",
1851 backref="cluster_adminships",
1852 secondary="cluster_subscriptions",
1853 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
1854 secondaryjoin="and_(User.id == ClusterSubscription.user_id, ClusterSubscription.role == 'admin')",
1855 viewonly=True,
1856 )
1858 main_page = relationship(
1859 "Page",
1860 primaryjoin="and_(Cluster.id == Page.owner_cluster_id, Page.type == 'main_page')",
1861 viewonly=True,
1862 uselist=False,
1863 )
1865 @property
1866 def is_leaf(self) -> bool:
1867 """Whether the cluster is a leaf node in the cluster hierarchy."""
1868 return len(self.parent_node.child_nodes) == 0
1870 __table_args__ = (
1871 # Each node can have at most one official cluster
1872 Index(
1873 "ix_clusters_owner_parent_node_id_is_official_cluster",
1874 parent_node_id,
1875 is_official_cluster,
1876 unique=True,
1877 postgresql_where=is_official_cluster,
1878 ),
1879 )
1882class NodeClusterAssociation(Base):
1883 """
1884 NodeClusterAssociation, grouping of nodes
1885 """
1887 __tablename__ = "node_cluster_associations"
1888 __table_args__ = (UniqueConstraint("node_id", "cluster_id"),)
1890 id = Column(BigInteger, primary_key=True)
1892 node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
1893 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
1895 node = relationship("Node", backref="node_cluster_associations")
1896 cluster = relationship("Cluster", backref="node_cluster_associations")
1899class ClusterRole(enum.Enum):
1900 member = enum.auto()
1901 admin = enum.auto()
1904class ClusterSubscription(Base):
1905 """
1906 ClusterSubscription of a user
1907 """
1909 __tablename__ = "cluster_subscriptions"
1911 id = Column(BigInteger, primary_key=True)
1913 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1914 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
1915 role = Column(Enum(ClusterRole), nullable=False)
1917 user = relationship("User", backref="cluster_subscriptions")
1918 cluster = relationship("Cluster", backref="cluster_subscriptions")
1920 __table_args__ = (
1921 UniqueConstraint("user_id", "cluster_id"),
1922 Index(
1923 "ix_cluster_subscriptions_members",
1924 cluster_id,
1925 user_id,
1926 ),
1927 # For fast lookup of nodes this user is an admin of
1928 Index(
1929 "ix_cluster_subscriptions_admins",
1930 user_id,
1931 cluster_id,
1932 postgresql_where=(role == ClusterRole.admin),
1933 ),
1934 )
1937class ClusterPageAssociation(Base):
1938 """
1939 pages related to clusters
1940 """
1942 __tablename__ = "cluster_page_associations"
1943 __table_args__ = (UniqueConstraint("page_id", "cluster_id"),)
1945 id = Column(BigInteger, primary_key=True)
1947 page_id = Column(ForeignKey("pages.id"), nullable=False, index=True)
1948 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
1950 page = relationship("Page", backref="cluster_page_associations")
1951 cluster = relationship("Cluster", backref="cluster_page_associations")
1954class PageType(enum.Enum):
1955 main_page = enum.auto()
1956 place = enum.auto()
1957 guide = enum.auto()
1960class Page(Base):
1961 """
1962 similar to a wiki page about a community, POI or guide
1963 """
1965 __tablename__ = "pages"
1967 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
1969 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
1970 type = Column(Enum(PageType), nullable=False)
1971 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
1972 owner_user_id = Column(ForeignKey("users.id"), nullable=True, index=True)
1973 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=True, index=True)
1975 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True)
1977 parent_node = relationship("Node", backref="child_pages", remote_side="Node.id", foreign_keys="Page.parent_node_id")
1979 thread = relationship("Thread", backref="page", uselist=False)
1980 creator_user = relationship("User", backref="created_pages", foreign_keys="Page.creator_user_id")
1981 owner_user = relationship("User", backref="owned_pages", foreign_keys="Page.owner_user_id")
1982 owner_cluster = relationship(
1983 "Cluster", backref=backref("owned_pages", lazy="dynamic"), uselist=False, foreign_keys="Page.owner_cluster_id"
1984 )
1986 editors = relationship("User", secondary="page_versions", viewonly=True)
1988 __table_args__ = (
1989 # Only one of owner_user and owner_cluster should be set
1990 CheckConstraint(
1991 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
1992 name="one_owner",
1993 ),
1994 # Only clusters can own main pages
1995 CheckConstraint(
1996 "NOT (owner_cluster_id IS NULL AND type = 'main_page')",
1997 name="main_page_owned_by_cluster",
1998 ),
1999 # Each cluster can have at most one main page
2000 Index(
2001 "ix_pages_owner_cluster_id_type",
2002 owner_cluster_id,
2003 type,
2004 unique=True,
2005 postgresql_where=(type == PageType.main_page),
2006 ),
2007 )
2009 def __repr__(self):
2010 return f"Page({self.id=})"
2013class PageVersion(Base):
2014 """
2015 version of page content
2016 """
2018 __tablename__ = "page_versions"
2020 id = Column(BigInteger, primary_key=True)
2022 page_id = Column(ForeignKey("pages.id"), nullable=False, index=True)
2023 editor_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2024 title = Column(String, nullable=False)
2025 content = Column(String, nullable=False) # CommonMark without images
2026 photo_key = Column(ForeignKey("uploads.key"), nullable=True)
2027 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
2028 # the human-readable address
2029 address = Column(String, nullable=True)
2030 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2032 slug = column_property(func.slugify(title))
2034 page = relationship("Page", backref="versions", order_by="PageVersion.id")
2035 editor_user = relationship("User", backref="edited_pages")
2036 photo = relationship("Upload")
2038 __table_args__ = (
2039 # Geom and address must either both be null or both be set
2040 CheckConstraint(
2041 "(geom IS NULL) = (address IS NULL)",
2042 name="geom_iff_address",
2043 ),
2044 )
2046 @property
2047 def coordinates(self):
2048 # returns (lat, lng) or None
2049 return get_coordinates(self.geom)
2051 def __repr__(self):
2052 return f"PageVersion({self.id=}, {self.page_id=})"
2055class ClusterEventAssociation(Base):
2056 """
2057 events related to clusters
2058 """
2060 __tablename__ = "cluster_event_associations"
2061 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),)
2063 id = Column(BigInteger, primary_key=True)
2065 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
2066 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
2068 event = relationship("Event", backref="cluster_event_associations")
2069 cluster = relationship("Cluster", backref="cluster_event_associations")
2072class Event(Base):
2073 """
2074 An event is compose of two parts:
2076 * An event template (Event)
2077 * An occurrence (EventOccurrence)
2079 One-off events will have one of each; repeating events will have one Event,
2080 multiple EventOccurrences, one for each time the event happens.
2081 """
2083 __tablename__ = "events"
2085 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
2086 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
2088 title = Column(String, nullable=False)
2090 slug = column_property(func.slugify(title))
2092 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2093 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2094 owner_user_id = Column(ForeignKey("users.id"), nullable=True, index=True)
2095 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=True, index=True)
2096 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True)
2098 parent_node = relationship(
2099 "Node", backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id"
2100 )
2101 thread = relationship("Thread", backref="event", uselist=False)
2102 subscribers = relationship(
2103 "User", backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True
2104 )
2105 organizers = relationship(
2106 "User", backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True
2107 )
2108 creator_user = relationship("User", backref="created_events", foreign_keys="Event.creator_user_id")
2109 owner_user = relationship("User", backref="owned_events", foreign_keys="Event.owner_user_id")
2110 owner_cluster = relationship(
2111 "Cluster",
2112 backref=backref("owned_events", lazy="dynamic"),
2113 uselist=False,
2114 foreign_keys="Event.owner_cluster_id",
2115 )
2117 __table_args__ = (
2118 # Only one of owner_user and owner_cluster should be set
2119 CheckConstraint(
2120 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
2121 name="one_owner",
2122 ),
2123 )
2126class EventOccurrence(Base):
2127 __tablename__ = "event_occurrences"
2129 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
2130 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
2132 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event)
2133 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2134 content = Column(String, nullable=False) # CommonMark without images
2135 photo_key = Column(ForeignKey("uploads.key"), nullable=True)
2137 is_cancelled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
2138 is_deleted = Column(Boolean, nullable=False, default=False, server_default=expression.false())
2140 # a null geom is an online-only event
2141 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
2142 # physical address, iff geom is not null
2143 address = Column(String, nullable=True)
2144 # videoconferencing link, etc, must be specified if no geom, otherwise optional
2145 link = Column(String, nullable=True)
2147 timezone = "Etc/UTC"
2149 # time during which the event takes place; this is a range type (instead of separate start+end times) which
2150 # simplifies database constraints, etc
2151 during = Column(TSTZRANGE, nullable=False)
2153 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2154 last_edited = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2156 creator_user = relationship(
2157 "User", backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id"
2158 )
2159 event = relationship(
2160 "Event",
2161 backref=backref("occurrences", lazy="dynamic"),
2162 remote_side="Event.id",
2163 foreign_keys="EventOccurrence.event_id",
2164 )
2166 photo = relationship("Upload")
2168 __table_args__ = (
2169 # Geom and address go together
2170 CheckConstraint(
2171 # geom and address are either both null or neither of them are null
2172 "(geom IS NULL) = (address IS NULL)",
2173 name="geom_iff_address",
2174 ),
2175 # Online-only events need a link, note that online events may also have a link
2176 CheckConstraint(
2177 # exactly oen of geom or link is non-null
2178 "(geom IS NULL) <> (link IS NULL)",
2179 name="link_or_geom",
2180 ),
2181 # Can't have overlapping occurrences in the same Event
2182 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"),
2183 )
2185 @property
2186 def coordinates(self):
2187 # returns (lat, lng) or None
2188 return get_coordinates(self.geom)
2190 @hybrid_property
2191 def start_time(self):
2192 return self.during.lower
2194 @start_time.expression
2195 def start_time(cls):
2196 return func.lower(cls.during)
2198 @hybrid_property
2199 def end_time(self):
2200 return self.during.upper
2202 @end_time.expression
2203 def end_time(cls):
2204 return func.upper(cls.during)
2207class EventSubscription(Base):
2208 """
2209 Users' subscriptions to events
2210 """
2212 __tablename__ = "event_subscriptions"
2213 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
2215 id = Column(BigInteger, primary_key=True)
2217 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2218 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
2219 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2221 user = relationship("User")
2222 event = relationship("Event")
2225class EventOrganizer(Base):
2226 """
2227 Organizers for events
2228 """
2230 __tablename__ = "event_organizers"
2231 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
2233 id = Column(BigInteger, primary_key=True)
2235 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2236 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
2237 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2239 user = relationship("User")
2240 event = relationship("Event")
2243class AttendeeStatus(enum.Enum):
2244 going = enum.auto()
2245 maybe = enum.auto()
2248class EventOccurrenceAttendee(Base):
2249 """
2250 Attendees for events
2251 """
2253 __tablename__ = "event_occurrence_attendees"
2254 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),)
2256 id = Column(BigInteger, primary_key=True)
2258 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2259 occurrence_id = Column(ForeignKey("event_occurrences.id"), nullable=False, index=True)
2260 responded = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2261 attendee_status = Column(Enum(AttendeeStatus), nullable=False)
2263 user = relationship("User")
2264 occurrence = relationship("EventOccurrence", backref=backref("attendances", lazy="dynamic"))
2266 reminder_sent = Column(Boolean, nullable=False, default=False, server_default=expression.false())
2269class EventCommunityInviteRequest(Base):
2270 """
2271 Requests to send out invitation notifications/emails to the community for a given event occurrence
2272 """
2274 __tablename__ = "event_community_invite_requests"
2276 id = Column(BigInteger, primary_key=True)
2278 occurrence_id = Column(ForeignKey("event_occurrences.id"), nullable=False, index=True)
2279 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2281 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2283 decided = Column(DateTime(timezone=True), nullable=True)
2284 decided_by_user_id = Column(ForeignKey("users.id"), nullable=True)
2285 approved = Column(Boolean, nullable=True)
2287 occurrence = relationship("EventOccurrence", backref=backref("community_invite_requests", lazy="dynamic"))
2288 user = relationship("User", foreign_keys="EventCommunityInviteRequest.user_id")
2290 __table_args__ = (
2291 # each user can only request once
2292 UniqueConstraint("occurrence_id", "user_id"),
2293 # each event can only have one notification sent out
2294 Index(
2295 "ix_event_community_invite_requests_unique",
2296 occurrence_id,
2297 unique=True,
2298 postgresql_where=and_(approved.is_not(None), approved == True),
2299 ),
2300 # decided and approved ought to be null simultaneously
2301 CheckConstraint(
2302 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \
2303 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))",
2304 name="decided_approved",
2305 ),
2306 )
2309class ClusterDiscussionAssociation(Base):
2310 """
2311 discussions related to clusters
2312 """
2314 __tablename__ = "cluster_discussion_associations"
2315 __table_args__ = (UniqueConstraint("discussion_id", "cluster_id"),)
2317 id = Column(BigInteger, primary_key=True)
2319 discussion_id = Column(ForeignKey("discussions.id"), nullable=False, index=True)
2320 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
2322 discussion = relationship("Discussion", backref="cluster_discussion_associations")
2323 cluster = relationship("Cluster", backref="cluster_discussion_associations")
2326class Discussion(Base):
2327 """
2328 forum board
2329 """
2331 __tablename__ = "discussions"
2333 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
2335 title = Column(String, nullable=False)
2336 content = Column(String, nullable=False)
2337 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True)
2338 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2340 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2341 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
2343 slug = column_property(func.slugify(title))
2345 thread = relationship("Thread", backref="discussion", uselist=False)
2347 subscribers = relationship("User", backref="discussions", secondary="discussion_subscriptions", viewonly=True)
2349 creator_user = relationship("User", backref="created_discussions", foreign_keys="Discussion.creator_user_id")
2350 owner_cluster = relationship("Cluster", backref=backref("owned_discussions", lazy="dynamic"), uselist=False)
2353class DiscussionSubscription(Base):
2354 """
2355 users subscriptions to discussions
2356 """
2358 __tablename__ = "discussion_subscriptions"
2359 __table_args__ = (UniqueConstraint("discussion_id", "user_id"),)
2361 id = Column(BigInteger, primary_key=True)
2363 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2364 discussion_id = Column(ForeignKey("discussions.id"), nullable=False, index=True)
2365 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2366 left = Column(DateTime(timezone=True), nullable=True)
2368 user = relationship("User", backref="discussion_subscriptions")
2369 discussion = relationship("Discussion", backref="discussion_subscriptions")
2372class Thread(Base):
2373 """
2374 Thread
2375 """
2377 __tablename__ = "threads"
2379 id = Column(BigInteger, primary_key=True)
2381 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2382 deleted = Column(DateTime(timezone=True), nullable=True)
2385class Comment(Base):
2386 """
2387 Comment
2388 """
2390 __tablename__ = "comments"
2392 id = Column(BigInteger, primary_key=True)
2394 thread_id = Column(ForeignKey("threads.id"), nullable=False, index=True)
2395 author_user_id = Column(ForeignKey("users.id"), nullable=False)
2396 content = Column(String, nullable=False) # CommonMark without images
2397 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2398 deleted = Column(DateTime(timezone=True), nullable=True)
2400 thread = relationship("Thread", backref="comments")
2403class Reply(Base):
2404 """
2405 Reply
2406 """
2408 __tablename__ = "replies"
2410 id = Column(BigInteger, primary_key=True)
2412 comment_id = Column(ForeignKey("comments.id"), nullable=False, index=True)
2413 author_user_id = Column(ForeignKey("users.id"), nullable=False)
2414 content = Column(String, nullable=False) # CommonMark without images
2415 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2416 deleted = Column(DateTime(timezone=True), nullable=True)
2418 comment = relationship("Comment", backref="replies")
2421class BackgroundJobState(enum.Enum):
2422 # job is fresh, waiting to be picked off the queue
2423 pending = enum.auto()
2424 # job complete
2425 completed = enum.auto()
2426 # error occured, will be retried
2427 error = enum.auto()
2428 # failed too many times, not retrying anymore
2429 failed = enum.auto()
2432class BackgroundJob(Base):
2433 """
2434 This table implements a queue of background jobs.
2435 """
2437 __tablename__ = "background_jobs"
2439 id = Column(BigInteger, primary_key=True)
2441 # used to discern which function should be triggered to service it
2442 job_type = Column(String, nullable=False)
2443 state = Column(Enum(BackgroundJobState), nullable=False, default=BackgroundJobState.pending)
2445 # time queued
2446 queued = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2448 # time at which we may next attempt it, for implementing exponential backoff
2449 next_attempt_after = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2451 # used to count number of retries for failed jobs
2452 try_count = Column(Integer, nullable=False, default=0)
2454 max_tries = Column(Integer, nullable=False, default=5)
2456 # higher is more important
2457 priority = Column(Integer, nullable=False, server_default=text("10"))
2459 # protobuf encoded job payload
2460 payload = Column(Binary, nullable=False)
2462 # if the job failed, we write that info here
2463 failure_info = Column(String, nullable=True)
2465 __table_args__ = (
2466 # used in looking up background jobs to attempt
2467 # create index on background_jobs(priority desc, next_attempt_after, (max_tries - try_count)) where state = 'pending' OR state = 'error';
2468 Index(
2469 "ix_background_jobs_lookup",
2470 priority.desc(),
2471 next_attempt_after,
2472 (max_tries - try_count),
2473 postgresql_where=((state == BackgroundJobState.pending) | (state == BackgroundJobState.error)),
2474 ),
2475 )
2477 @hybrid_property
2478 def ready_for_retry(self):
2479 return (
2480 (self.next_attempt_after <= func.now())
2481 & (self.try_count < self.max_tries)
2482 & ((self.state == BackgroundJobState.pending) | (self.state == BackgroundJobState.error))
2483 )
2485 def __repr__(self):
2486 return f"BackgroundJob(id={self.id}, job_type={self.job_type}, state={self.state}, next_attempt_after={self.next_attempt_after}, try_count={self.try_count}, failure_info={self.failure_info})"
2489class NotificationDeliveryType(enum.Enum):
2490 # send push notification to mobile/web
2491 push = enum.auto()
2492 # send individual email immediately
2493 email = enum.auto()
2494 # send in digest
2495 digest = enum.auto()
2498dt = NotificationDeliveryType
2499nd = notification_data_pb2
2501dt_sec = [dt.email, dt.push]
2502dt_all = [dt.email, dt.push, dt.digest]
2505class NotificationTopicAction(enum.Enum):
2506 def __init__(self, topic_action, defaults, user_editable, data_type):
2507 self.topic, self.action = topic_action.split(":")
2508 self.defaults = defaults
2509 # for now user editable == not a security notification
2510 self.user_editable = user_editable
2512 self.data_type = data_type
2514 def unpack(self):
2515 return self.topic, self.action
2517 @property
2518 def display(self):
2519 return f"{self.topic}:{self.action}"
2521 def __str__(self):
2522 return self.display
2524 # topic, action, default delivery types
2525 friend_request__create = ("friend_request:create", dt_all, True, nd.FriendRequestCreate)
2526 friend_request__accept = ("friend_request:accept", dt_all, True, nd.FriendRequestAccept)
2528 # host requests
2529 host_request__create = ("host_request:create", dt_all, True, nd.HostRequestCreate)
2530 host_request__accept = ("host_request:accept", dt_all, True, nd.HostRequestAccept)
2531 host_request__reject = ("host_request:reject", dt_all, True, nd.HostRequestReject)
2532 host_request__confirm = ("host_request:confirm", dt_all, True, nd.HostRequestConfirm)
2533 host_request__cancel = ("host_request:cancel", dt_all, True, nd.HostRequestCancel)
2534 host_request__message = ("host_request:message", [dt.push, dt.digest], True, nd.HostRequestMessage)
2535 host_request__missed_messages = ("host_request:missed_messages", [dt.email], True, nd.HostRequestMissedMessages)
2536 host_request__reminder = ("host_request:reminder", dt_all, True, nd.HostRequestReminder)
2538 activeness__probe = ("activeness:probe", dt_sec, False, nd.ActivenessProbe)
2540 # you receive a friend ref
2541 reference__receive_friend = ("reference:receive_friend", dt_all, True, nd.ReferenceReceiveFriend)
2542 # you receive a reference from ... the host
2543 reference__receive_hosted = ("reference:receive_hosted", dt_all, True, nd.ReferenceReceiveHostRequest)
2544 # ... the surfer
2545 reference__receive_surfed = ("reference:receive_surfed", dt_all, True, nd.ReferenceReceiveHostRequest)
2547 # you hosted
2548 reference__reminder_hosted = ("reference:reminder_hosted", dt_all, True, nd.ReferenceReminder)
2549 # you surfed
2550 reference__reminder_surfed = ("reference:reminder_surfed", dt_all, True, nd.ReferenceReminder)
2552 badge__add = ("badge:add", [dt.push, dt.digest], True, nd.BadgeAdd)
2553 badge__remove = ("badge:remove", [dt.push, dt.digest], True, nd.BadgeRemove)
2555 # group chats
2556 chat__message = ("chat:message", [dt.push, dt.digest], True, nd.ChatMessage)
2557 chat__missed_messages = ("chat:missed_messages", [dt.email], True, nd.ChatMissedMessages)
2559 # events
2560 # approved by mods
2561 event__create_approved = ("event:create_approved", dt_all, True, nd.EventCreate)
2562 # any user creates any event, default to no notifications
2563 event__create_any = ("event:create_any", [], True, nd.EventCreate)
2564 event__update = ("event:update", dt_all, True, nd.EventUpdate)
2565 event__cancel = ("event:cancel", dt_all, True, nd.EventCancel)
2566 event__delete = ("event:delete", dt_all, True, nd.EventDelete)
2567 event__invite_organizer = ("event:invite_organizer", dt_all, True, nd.EventInviteOrganizer)
2568 event__reminder = ("event:reminder", dt_all, True, nd.EventReminder)
2569 # toplevel comment on an event
2570 event__comment = ("event:comment", dt_all, True, nd.EventComment)
2572 # discussion created
2573 discussion__create = ("discussion:create", [dt.digest], True, nd.DiscussionCreate)
2574 # someone comments on your discussion
2575 discussion__comment = ("discussion:comment", dt_all, True, nd.DiscussionComment)
2577 # someone responds to any of your top-level comment across the platform
2578 thread__reply = ("thread:reply", dt_all, True, nd.ThreadReply)
2580 # account settings
2581 password__change = ("password:change", dt_sec, False, empty_pb2.Empty)
2582 email_address__change = ("email_address:change", dt_sec, False, nd.EmailAddressChange)
2583 email_address__verify = ("email_address:verify", dt_sec, False, empty_pb2.Empty)
2584 phone_number__change = ("phone_number:change", dt_sec, False, nd.PhoneNumberChange)
2585 phone_number__verify = ("phone_number:verify", dt_sec, False, nd.PhoneNumberVerify)
2586 # reset password
2587 password_reset__start = ("password_reset:start", dt_sec, False, nd.PasswordResetStart)
2588 password_reset__complete = ("password_reset:complete", dt_sec, False, empty_pb2.Empty)
2590 # account deletion
2591 account_deletion__start = ("account_deletion:start", dt_sec, False, nd.AccountDeletionStart)
2592 # no more pushing to do
2593 account_deletion__complete = ("account_deletion:complete", dt_sec, False, nd.AccountDeletionComplete)
2594 # undeleted
2595 account_deletion__recovered = ("account_deletion:recovered", dt_sec, False, empty_pb2.Empty)
2597 # admin actions
2598 gender__change = ("gender:change", dt_sec, False, nd.GenderChange)
2599 birthdate__change = ("birthdate:change", dt_sec, False, nd.BirthdateChange)
2600 api_key__create = ("api_key:create", dt_sec, False, nd.ApiKeyCreate)
2602 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived)
2604 onboarding__reminder = ("onboarding:reminder", dt_sec, True, empty_pb2.Empty)
2606 modnote__create = ("modnote:create", dt_sec, False, empty_pb2.Empty)
2608 verification__sv_fail = ("verification:sv_fail", dt_sec, False, nd.VerificationSVFail)
2609 verification__sv_success = ("verification:sv_success", dt_sec, False, empty_pb2.Empty)
2611 # general announcements
2612 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], True, nd.GeneralNewBlogPost)
2615class NotificationPreference(Base):
2616 __tablename__ = "notification_preferences"
2618 id = Column(BigInteger, primary_key=True)
2619 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2621 topic_action = Column(Enum(NotificationTopicAction), nullable=False)
2622 delivery_type = Column(Enum(NotificationDeliveryType), nullable=False)
2623 deliver = Column(Boolean, nullable=False)
2625 user = relationship("User", foreign_keys="NotificationPreference.user_id")
2627 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),)
2630class Notification(Base):
2631 """
2632 Table for accumulating notifications until it is time to send email digest
2633 """
2635 __tablename__ = "notifications"
2637 id = Column(BigInteger, primary_key=True)
2638 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2640 # recipient user id
2641 user_id = Column(ForeignKey("users.id"), nullable=False)
2643 topic_action = Column(Enum(NotificationTopicAction), nullable=False)
2644 key = Column(String, nullable=False)
2646 data = Column(Binary, nullable=False)
2648 # whether the user has marked this notification as seen or not
2649 is_seen = Column(Boolean, nullable=False, server_default=expression.false())
2651 user = relationship("User", foreign_keys="Notification.user_id")
2653 __table_args__ = (
2654 # used in looking up which notifications need delivery
2655 Index(
2656 "ix_notifications_created",
2657 created,
2658 ),
2659 # Fast lookup for unseen notification count
2660 Index(
2661 "ix_notifications_unseen",
2662 user_id,
2663 topic_action,
2664 postgresql_where=(is_seen == False),
2665 ),
2666 # Fast lookup for latest notifications
2667 Index(
2668 "ix_notifications_latest",
2669 user_id,
2670 id.desc(),
2671 topic_action,
2672 ),
2673 )
2675 @property
2676 def topic(self):
2677 return self.topic_action.topic
2679 @property
2680 def action(self):
2681 return self.topic_action.action
2684class NotificationDelivery(Base):
2685 __tablename__ = "notification_deliveries"
2687 id = Column(BigInteger, primary_key=True)
2688 notification_id = Column(ForeignKey("notifications.id"), nullable=False, index=True)
2689 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2690 delivered = Column(DateTime(timezone=True), nullable=True)
2691 read = Column(DateTime(timezone=True), nullable=True)
2692 # todo: enum of "phone, web, digest"
2693 delivery_type = Column(Enum(NotificationDeliveryType), nullable=False)
2694 # todo: device id
2695 # todo: receipt id, etc
2696 notification = relationship("Notification", foreign_keys="NotificationDelivery.notification_id")
2698 __table_args__ = (
2699 UniqueConstraint("notification_id", "delivery_type"),
2700 # used in looking up which notifications need delivery
2701 Index(
2702 "ix_notification_deliveries_delivery_type",
2703 delivery_type,
2704 postgresql_where=(delivered != None),
2705 ),
2706 Index(
2707 "ix_notification_deliveries_dt_ni_dnull",
2708 delivery_type,
2709 notification_id,
2710 delivered == None,
2711 ),
2712 )
2715class PushNotificationSubscription(Base):
2716 __tablename__ = "push_notification_subscriptions"
2718 id = Column(BigInteger, primary_key=True)
2719 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2721 # which user this is connected to
2722 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
2724 # these come from https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription
2725 # the endpoint
2726 endpoint = Column(String, nullable=False)
2727 # the "auth" key
2728 auth_key = Column(Binary, nullable=False)
2729 # the "p256dh" key
2730 p256dh_key = Column(Binary, nullable=False)
2732 full_subscription_info = Column(String, nullable=False)
2734 # the browse user-agent, so we can tell the user what browser notifications are going to
2735 user_agent = Column(String, nullable=True)
2737 # when it was disabled
2738 disabled_at = Column(DateTime(timezone=True), nullable=False, server_default=DATETIME_INFINITY.isoformat())
2740 user = relationship("User")
2743class PushNotificationDeliveryAttempt(Base):
2744 __tablename__ = "push_notification_delivery_attempt"
2746 id = Column(BigInteger, primary_key=True)
2747 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2749 push_notification_subscription_id = Column(
2750 ForeignKey("push_notification_subscriptions.id"), nullable=False, index=True
2751 )
2753 success = Column(Boolean, nullable=False)
2754 # the HTTP status code, 201 is success
2755 status_code = Column(Integer, nullable=False)
2757 # can be null if it was a success
2758 response = Column(String, nullable=True)
2760 push_notification_subscription = relationship("PushNotificationSubscription")
2763class Language(Base):
2764 """
2765 Table of allowed languages (a subset of ISO639-3)
2766 """
2768 __tablename__ = "languages"
2770 # ISO639-3 language code, in lowercase, e.g. fin, eng
2771 code = Column(String(3), primary_key=True)
2773 # the english name
2774 name = Column(String, nullable=False, unique=True)
2777class Region(Base):
2778 """
2779 Table of regions
2780 """
2782 __tablename__ = "regions"
2784 # iso 3166-1 alpha3 code in uppercase, e.g. FIN, USA
2785 code = Column(String(3), primary_key=True)
2787 # the name, e.g. Finland, United States
2788 # this is the display name in English, should be the "common name", not "Republic of Finland"
2789 name = Column(String, nullable=False, unique=True)
2792class UserBlock(Base):
2793 """
2794 Table of blocked users
2795 """
2797 __tablename__ = "user_blocks"
2799 id = Column(BigInteger, primary_key=True)
2801 blocking_user_id = Column(ForeignKey("users.id"), nullable=False)
2802 blocked_user_id = Column(ForeignKey("users.id"), nullable=False)
2803 time_blocked = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2805 blocking_user = relationship("User", foreign_keys="UserBlock.blocking_user_id")
2806 blocked_user = relationship("User", foreign_keys="UserBlock.blocked_user_id")
2808 __table_args__ = (
2809 UniqueConstraint("blocking_user_id", "blocked_user_id"),
2810 Index("ix_user_blocks_blocking_user_id", blocking_user_id, blocked_user_id),
2811 Index("ix_user_blocks_blocked_user_id", blocked_user_id, blocking_user_id),
2812 )
2815class APICall(Base):
2816 """
2817 API call logs
2818 """
2820 __tablename__ = "api_calls"
2821 __table_args__ = {"schema": "logging"}
2823 id = Column(BigInteger, primary_key=True)
2825 # whether the call was made using an api key or session cookies
2826 is_api_key = Column(Boolean, nullable=False, server_default=expression.false())
2828 # backend version (normally e.g. develop-31469e3), allows us to figure out which proto definitions were used
2829 # note that `default` is a python side default, not hardcoded into DB schema
2830 version = Column(String, nullable=False, default=config["VERSION"])
2832 # approximate time of the call
2833 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2835 # the method call name, e.g. "/org.couchers.api.core.API/ListFriends"
2836 method = Column(String, nullable=False)
2838 # gRPC status code name, e.g. FAILED_PRECONDITION, None if success
2839 status_code = Column(String, nullable=True)
2841 # handler duration (excluding serialization, etc)
2842 duration = Column(Float, nullable=False)
2844 # user_id of caller, None means not logged in
2845 user_id = Column(BigInteger, nullable=True)
2847 # sanitized request bytes
2848 request = Column(Binary, nullable=True)
2850 # sanitized response bytes
2851 response = Column(Binary, nullable=True)
2853 # whether response bytes have been truncated
2854 response_truncated = Column(Boolean, nullable=False, server_default=expression.false())
2856 # the exception traceback, if any
2857 traceback = Column(String, nullable=True)
2859 # human readable perf report
2860 perf_report = Column(String, nullable=True)
2862 # details of the browser, if available
2863 ip_address = Column(String, nullable=True)
2864 user_agent = Column(String, nullable=True)
2867class AccountDeletionReason(Base):
2868 __tablename__ = "account_deletion_reason"
2870 id = Column(BigInteger, primary_key=True)
2871 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2872 user_id = Column(ForeignKey("users.id"), nullable=False)
2873 reason = Column(String, nullable=True)
2875 user = relationship("User")
2878class ModerationUserList(Base):
2879 """
2880 Represents a list of users listed together by a moderator
2881 """
2883 __tablename__ = "moderation_user_lists"
2885 id = Column(BigInteger, primary_key=True)
2886 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2888 # Relationships
2889 users = relationship("User", secondary="moderation_user_list_members", back_populates="moderation_user_lists")
2892class ModerationUserListMember(Base):
2893 """
2894 Association table for many-to-many relationship between users and moderation_user_lists
2895 """
2897 __tablename__ = "moderation_user_list_members"
2899 user_id = Column(ForeignKey("users.id"), primary_key=True)
2900 moderation_list_id = Column(ForeignKey("moderation_user_lists.id"), primary_key=True)
2902 __table_args__ = (UniqueConstraint("user_id", "moderation_list_id"),)
2905class AntiBotLog(Base):
2906 __tablename__ = "antibot_logs"
2908 id = Column(BigInteger, primary_key=True)
2909 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2910 user_id = Column(ForeignKey("users.id"), nullable=True)
2912 ip_address = Column(String, nullable=True)
2913 user_agent = Column(String, nullable=True)
2915 action = Column(String, nullable=False)
2916 token = Column(String, nullable=False)
2918 score = Column(Float, nullable=False)
2919 provider_data = Column(JSON, nullable=False)
2922class RateLimitAction(enum.Enum):
2923 """Possible user actions which can be rate limited."""
2925 host_request = "host request"
2926 friend_request = "friend request"
2927 chat_initiation = "chat initiation"
2930class RateLimitViolation(Base):
2931 __tablename__ = "rate_limit_violations"
2933 id = Column(BigInteger, primary_key=True)
2934 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
2935 user_id = Column(ForeignKey("users.id"), nullable=False)
2936 action = Column(Enum(RateLimitAction), nullable=False)
2937 is_hard_limit = Column(Boolean, nullable=False)
2939 user = relationship("User")
2941 __table_args__ = (
2942 # Fast lookup for rate limits in interval
2943 Index("ix_rate_limits_by_user", user_id, action, is_hard_limit, created),
2944 )
2947class Volunteer(Base):
2948 __tablename__ = "volunteers"
2950 id = Column(BigInteger, primary_key=True)
2951 user_id = Column(ForeignKey("users.id"), nullable=False, unique=True)
2953 display_name = Column(String, nullable=True)
2954 display_location = Column(String, nullable=True)
2956 role = Column(String, nullable=False)
2958 # custom sort order on team page, sorted ascending
2959 sort_key = Column(Float, nullable=True)
2961 started_volunteering = Column(Date, nullable=False, server_default=text("CURRENT_DATE"))
2962 stopped_volunteering = Column(Date, nullable=True, default=None)
2964 link_type = Column(String, nullable=True)
2965 link_text = Column(String, nullable=True)
2966 link_url = Column(String, nullable=True)
2968 show_on_team_page = Column(Boolean, nullable=False, server_default=expression.true())
2970 __table_args__ = (
2971 # Link type, text, url should all be null or all not be null
2972 CheckConstraint(
2973 "(link_type IS NULL) = (link_text IS NULL) AND (link_type IS NULL) = (link_url IS NULL)",
2974 name="link_type_text",
2975 ),
2976 )