Coverage for app / backend / src / couchers / models / rest.py: 99%

254 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 09:44 +0000

1import enum 

2from datetime import date, datetime 

3from typing import TYPE_CHECKING, Any 

4 

5from geoalchemy2 import Geometry 

6from sqlalchemy import ( 

7 ARRAY, 

8 JSON, 

9 BigInteger, 

10 Boolean, 

11 CheckConstraint, 

12 Date, 

13 DateTime, 

14 Enum, 

15 Float, 

16 ForeignKey, 

17 Index, 

18 Integer, 

19 String, 

20 UniqueConstraint, 

21 func, 

22 text, 

23) 

24from sqlalchemy import LargeBinary as Binary 

25from sqlalchemy.dialects.postgresql import INET 

26from sqlalchemy.ext.hybrid import hybrid_property 

27from sqlalchemy.orm import Mapped, mapped_column, relationship 

28from sqlalchemy.sql import expression 

29 

30from couchers.constants import GUIDELINES_VERSION 

31from couchers.models.base import Base, Geom 

32from couchers.models.users import HostingStatus 

33from couchers.utils import now 

34 

35if TYPE_CHECKING: 

36 from couchers.models import HostRequest, User 

37 from couchers.models.moderation import ModerationState 

38 

39 

40class UserBadge(Base, kw_only=True): 

41 """ 

42 A badge on a user's profile 

43 """ 

44 

45 __tablename__ = "user_badges" 

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

47 

48 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

49 

50 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

51 # corresponds to "id" in badges.json 

52 badge_id: Mapped[str] = mapped_column(String, index=True) 

53 

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

55 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

56 

57 user: Mapped[User] = relationship(init=False, back_populates="badges") 

58 

59 

60class FriendStatus(enum.Enum): 

61 pending = enum.auto() 

62 accepted = enum.auto() 

63 rejected = enum.auto() 

64 cancelled = enum.auto() 

65 

66 

67class FriendRelationship(Base, kw_only=True): 

68 """ 

69 Friendship relations between users 

70 

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

72 """ 

73 

74 __tablename__ = "friend_relationships" 

75 __moderation_author_column__ = "from_user_id" 

76 

77 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

78 

79 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

80 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

81 

82 # Unified Moderation System 

83 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True) 

84 

85 status: Mapped[FriendStatus] = mapped_column(Enum(FriendStatus), default=FriendStatus.pending) 

86 

87 # timezones should always be UTC 

88 time_sent: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

89 time_responded: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

90 

91 from_user: Mapped[User] = relationship( 

92 init=False, backref="friends_from", foreign_keys="FriendRelationship.from_user_id" 

93 ) 

94 to_user: Mapped[User] = relationship(init=False, backref="friends_to", foreign_keys="FriendRelationship.to_user_id") 

95 moderation_state: Mapped[ModerationState] = relationship(init=False) 

96 

97 __table_args__ = ( 

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

99 Index( 

100 "ix_friend_relationships_status_to_from", 

101 status, 

102 to_user_id, 

103 from_user_id, 

104 ), 

105 # At most one active (pending or accepted) relationship per unordered user pair 

106 Index( 

107 "uq_friend_relationships_active_pair", 

108 func.least(from_user_id, to_user_id), 

109 func.greatest(from_user_id, to_user_id), 

110 unique=True, 

111 postgresql_where=status.in_([FriendStatus.pending, FriendStatus.accepted]), 

112 ), 

113 ) 

114 

115 

116class ContributeOption(enum.Enum): 

117 yes = enum.auto() 

118 maybe = enum.auto() 

119 no = enum.auto() 

120 

121 

122class ContributorForm(Base, kw_only=True): 

123 """ 

124 Someone filled in the contributor form 

125 """ 

126 

127 __tablename__ = "contributor_forms" 

128 

129 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

130 

131 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

132 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

133 

134 ideas: Mapped[str | None] = mapped_column(String, default=None) 

135 features: Mapped[str | None] = mapped_column(String, default=None) 

136 experience: Mapped[str | None] = mapped_column(String, default=None) 

137 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None) 

138 contribute_ways: Mapped[list[str]] = mapped_column(ARRAY(String)) 

139 expertise: Mapped[str | None] = mapped_column(String, default=None) 

140 

141 user: Mapped[User] = relationship(init=False, backref="contributor_forms") 

142 

143 @hybrid_property 

144 def is_filled(self) -> Any: 

145 """ 

146 Whether the form counts as having been filled 

147 """ 

148 return ( 

149 (self.ideas != None) 

150 | (self.features != None) 

151 | (self.experience != None) 

152 | (self.contribute != None) 

153 | (self.contribute_ways != []) 

154 | (self.expertise != None) 

155 ) 

156 

157 @property 

158 def should_notify(self) -> bool: 

159 """ 

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

161 

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

163 """ 

164 return False 

165 

166 

167class SignupFlow(Base, kw_only=True): 

168 """ 

169 Signup flows/incomplete users 

170 

171 Coinciding fields have the same meaning as in User 

172 """ 

173 

174 __tablename__ = "signup_flows" 

175 

176 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

177 

178 # housekeeping 

179 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

180 flow_token: Mapped[str] = mapped_column(String, unique=True) 

181 email_verified: Mapped[bool] = mapped_column(Boolean, default=False) 

182 email_sent: Mapped[bool] = mapped_column(Boolean, default=False) 

183 email_token: Mapped[str | None] = mapped_column(String, default=None) 

184 email_token_expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

185 

186 ## Basic 

187 name: Mapped[str] = mapped_column(String) 

188 # TODO: unique across both tables 

189 email: Mapped[str] = mapped_column(String, unique=True) 

190 # TODO: invitation, attribution 

191 

192 ## Account 

193 # TODO: unique across both tables 

194 username: Mapped[str | None] = mapped_column(String, unique=True, default=None) 

195 hashed_password: Mapped[bytes | None] = mapped_column(Binary, default=None) 

196 birthdate: Mapped[date | None] = mapped_column(Date, default=None) # in the timezone of birthplace 

197 gender: Mapped[str | None] = mapped_column(String, default=None) 

198 hosting_status: Mapped[HostingStatus | None] = mapped_column(Enum(HostingStatus), default=None) 

199 city: Mapped[str | None] = mapped_column(String, default=None) 

200 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None) 

201 geom_radius: Mapped[float | None] = mapped_column(Float, default=None) 

202 

203 accepted_tos: Mapped[int | None] = mapped_column(Integer, default=None) 

204 accepted_community_guidelines: Mapped[int] = mapped_column(Integer, server_default="0", init=False) 

205 

206 opt_out_of_newsletter: Mapped[bool | None] = mapped_column(Boolean, default=None) 

207 

208 ## Feedback (now unused) 

209 filled_feedback: Mapped[bool] = mapped_column(Boolean, default=False) 

210 ideas: Mapped[str | None] = mapped_column(String, default=None) 

211 features: Mapped[str | None] = mapped_column(String, default=None) 

212 experience: Mapped[str | None] = mapped_column(String, default=None) 

213 contribute: Mapped[ContributeOption | None] = mapped_column(Enum(ContributeOption), default=None) 

214 contribute_ways: Mapped[list[str] | None] = mapped_column(ARRAY(String), default=None) 

215 expertise: Mapped[str | None] = mapped_column(String, default=None) 

216 

217 ## Motivations (how they heard about us and what they want to do) 

218 filled_motivations: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), default=False) 

219 heard_about_couchers: Mapped[str | None] = mapped_column(String, default=None) 

220 signup_motivations: Mapped[list[str]] = mapped_column(ARRAY(String), server_default="{}", default_factory=list) 

221 

222 invite_code_id: Mapped[str | None] = mapped_column(ForeignKey("invite_codes.id"), default=None) 

223 

224 @hybrid_property 

225 def token_is_valid(self) -> Any: 

226 return (self.email_token != None) & (self.email_token_expiry >= now()) # type: ignore[operator] 

227 

228 @hybrid_property 

229 def account_is_filled(self) -> Any: 

230 return ( 

231 (self.username != None) 

232 & (self.birthdate != None) 

233 & (self.gender != None) 

234 & (self.hosting_status != None) 

235 & (self.city != None) 

236 & (self.geom != None) 

237 & (self.geom_radius != None) 

238 & (self.accepted_tos != None) 

239 & (self.opt_out_of_newsletter != None) 

240 ) 

241 

242 @hybrid_property 

243 def is_completed(self) -> Any: 

244 return ( 

245 self.email_verified 

246 & self.account_is_filled 

247 & (self.accepted_community_guidelines == GUIDELINES_VERSION) 

248 & self.filled_motivations 

249 ) 

250 

251 

252class AccountDeletionToken(Base, kw_only=True): 

253 __tablename__ = "account_deletion_tokens" 

254 

255 token: Mapped[str] = mapped_column(String, primary_key=True) 

256 

257 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

258 

259 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

260 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True)) 

261 

262 user: Mapped[User] = relationship(init=False, backref="account_deletion_tokens") 

263 

264 @hybrid_property 

265 def is_valid(self) -> Any: 

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

267 

268 def __repr__(self) -> str: 

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

270 

271 

272class UserActivity(Base, kw_only=True): 

273 """ 

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

275 

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

277 """ 

278 

279 __tablename__ = "user_activity" 

280 

281 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

282 

283 user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

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

285 period: Mapped[datetime] = mapped_column(DateTime(timezone=True)) 

286 

287 # details of the browser, if available 

288 ip_address: Mapped[str | None] = mapped_column(INET, default=None) 

289 user_agent: Mapped[str | None] = mapped_column(String, default=None) 

290 

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

292 api_calls: Mapped[int] = mapped_column(Integer, default=0) 

293 

294 __table_args__ = ( 

295 # helps look up this tuple quickly 

296 Index( 

297 "ix_user_activity_user_id_period_ip_address_user_agent", 

298 user_id, 

299 period, 

300 ip_address, 

301 user_agent, 

302 unique=True, 

303 ), 

304 ) 

305 

306 

307class InviteCode(Base, kw_only=True): 

308 __tablename__ = "invite_codes" 

309 

310 id: Mapped[str] = mapped_column(String, primary_key=True) 

311 creator_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id")) 

312 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), init=False) 

313 disabled: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

314 

315 creator: Mapped[User] = relationship(init=False, foreign_keys=[creator_user_id]) 

316 

317 

318class ContentReport(Base, kw_only=True): 

319 """ 

320 A piece of content reported to admins 

321 """ 

322 

323 __tablename__ = "content_reports" 

324 

325 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

326 

327 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

328 

329 # the user who reported or flagged the content 

330 reporting_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

331 

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

333 reason: Mapped[str] = mapped_column(String) 

334 # a short description 

335 description: Mapped[str] = mapped_column(String) 

336 

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

338 content_ref: Mapped[str] = mapped_column(String) 

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

340 author_user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

341 

342 # details of the browser, if available 

343 user_agent: Mapped[str] = mapped_column(String) 

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

345 page: Mapped[str] = mapped_column(String) 

346 

347 # see comments above for reporting vs author 

348 reporting_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.reporting_user_id") 

349 author_user: Mapped[User] = relationship(init=False, foreign_keys="ContentReport.author_user_id") 

350 

351 

352class Email(Base, kw_only=True): 

353 """ 

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

355 """ 

356 

357 __tablename__ = "emails" 

358 

359 id: Mapped[str] = mapped_column(String, primary_key=True) 

360 

361 # timezone should always be UTC 

362 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

363 

364 sender_name: Mapped[str] = mapped_column(String) 

365 sender_email: Mapped[str] = mapped_column(String) 

366 

367 recipient: Mapped[str] = mapped_column(String) 

368 subject: Mapped[str] = mapped_column(String) 

369 

370 plain: Mapped[str] = mapped_column(String) 

371 html: Mapped[str] = mapped_column(String) 

372 

373 list_unsubscribe_header: Mapped[str | None] = mapped_column(String, default=None) 

374 source_data: Mapped[str | None] = mapped_column(String, default=None) 

375 

376 

377class SMS(Base, kw_only=True): 

378 """ 

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

380 """ 

381 

382 __tablename__ = "smss" 

383 

384 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

385 

386 # timezone should always be UTC 

387 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

388 # AWS message id 

389 message_id: Mapped[str] = mapped_column(String) 

390 

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

392 sms_sender_id: Mapped[str] = mapped_column(String) 

393 number: Mapped[str] = mapped_column(String) 

394 message: Mapped[str] = mapped_column(String) 

395 

396 

397class ReferenceType(enum.Enum): 

398 friend = enum.auto() 

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

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

401 

402 

403class Reference(Base, kw_only=True): 

404 """ 

405 Reference from one user to another 

406 """ 

407 

408 __tablename__ = "references" 

409 

410 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

411 # timezone should always be UTC 

412 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

413 

414 from_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

415 to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

416 

417 reference_type: Mapped[ReferenceType] = mapped_column(Enum(ReferenceType)) 

418 

419 host_request_id: Mapped[int | None] = mapped_column(ForeignKey("host_requests.id"), default=None) 

420 

421 text: Mapped[str] = mapped_column(String) # plain text 

422 # text that's only visible to mods 

423 private_text: Mapped[str | None] = mapped_column(String, default=None) # plain text 

424 

425 rating: Mapped[float] = mapped_column(Float) 

426 was_appropriate: Mapped[bool] = mapped_column(Boolean) 

427 

428 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false()) 

429 

430 from_user: Mapped[User] = relationship(init=False, backref="references_from", foreign_keys="Reference.from_user_id") 

431 to_user: Mapped[User] = relationship(init=False, backref="references_to", foreign_keys="Reference.to_user_id") 

432 

433 host_request: Mapped[HostRequest | None] = relationship(init=False, backref="references") 

434 

435 __table_args__ = ( 

436 # Rating must be between 0 and 1, inclusive 

437 CheckConstraint( 

438 "rating BETWEEN 0 AND 1", 

439 name="rating_between_0_and_1", 

440 ), 

441 # Has host_request_id or it's a friend reference 

442 CheckConstraint( 

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

444 name="host_request_id_xor_friend_reference", 

445 ), 

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

447 Index( 

448 "ix_references_unique_friend_reference", 

449 from_user_id, 

450 to_user_id, 

451 reference_type, 

452 unique=True, 

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

454 ), 

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

456 Index( 

457 "ix_references_unique_per_host_request", 

458 from_user_id, 

459 to_user_id, 

460 host_request_id, 

461 unique=True, 

462 postgresql_where=(host_request_id != None), 

463 ), 

464 ) 

465 

466 @property 

467 def should_report(self) -> bool: 

468 """ 

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

470 """ 

471 return bool(self.rating <= 0.4 or not self.was_appropriate or self.private_text) 

472 

473 

474class UserBlock(Base, kw_only=True): 

475 """ 

476 Table of blocked users 

477 """ 

478 

479 __tablename__ = "user_blocks" 

480 

481 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

482 

483 blocking_user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

484 blocked_user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

485 time_blocked: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

486 

487 blocking_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocking_user_id") 

488 blocked_user: Mapped[User] = relationship(init=False, foreign_keys="UserBlock.blocked_user_id") 

489 

490 __table_args__ = ( 

491 UniqueConstraint("blocking_user_id", "blocked_user_id"), 

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

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

494 ) 

495 

496 

497class AccountDeletionReason(Base, kw_only=True): 

498 __tablename__ = "account_deletion_reason" 

499 

500 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

501 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

502 user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

503 reason: Mapped[str | None] = mapped_column(String, default=None) 

504 

505 user: Mapped[User] = relationship(init=False) 

506 

507 

508class ModerationUserList(Base, kw_only=True): 

509 """ 

510 Represents a list of users listed together by a moderator 

511 """ 

512 

513 __tablename__ = "moderation_user_lists" 

514 

515 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

516 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

517 

518 users: Mapped[list[User]] = relationship( 

519 init=False, secondary="moderation_user_list_members", back_populates="moderation_user_lists" 

520 ) 

521 

522 

523class ModerationUserListMember(Base, kw_only=True): 

524 """ 

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

526 """ 

527 

528 __tablename__ = "moderation_user_list_members" 

529 

530 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) 

531 moderation_list_id: Mapped[int] = mapped_column(ForeignKey("moderation_user_lists.id"), primary_key=True) 

532 

533 

534class AntiBotLog(Base, kw_only=True): 

535 __tablename__ = "antibot_logs" 

536 

537 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

538 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

539 user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None) 

540 

541 ip_address: Mapped[str | None] = mapped_column(String, default=None) 

542 user_agent: Mapped[str | None] = mapped_column(String, default=None) 

543 

544 action: Mapped[str] = mapped_column(String) 

545 token: Mapped[str] = mapped_column(String) 

546 

547 score: Mapped[float] = mapped_column(Float) 

548 provider_data: Mapped[dict[str, Any]] = mapped_column(JSON) 

549 

550 

551class RateLimitAction(enum.Enum): 

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

553 

554 host_request = "host request" 

555 friend_request = "friend request" 

556 chat_initiation = "chat initiation" 

557 

558 

559class RateLimitViolation(Base, kw_only=True): 

560 __tablename__ = "rate_limit_violations" 

561 

562 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

563 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

564 user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

565 action: Mapped[RateLimitAction] = mapped_column(Enum(RateLimitAction)) 

566 is_hard_limit: Mapped[bool] = mapped_column(Boolean) 

567 

568 user: Mapped[User] = relationship(init=False) 

569 

570 __table_args__ = ( 

571 # Fast lookup for rate limits in interval 

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

573 ) 

574 

575 

576class Volunteer(Base, kw_only=True): 

577 __tablename__ = "volunteers" 

578 

579 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

580 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True) 

581 

582 display_name: Mapped[str | None] = mapped_column(String, default=None) 

583 display_location: Mapped[str | None] = mapped_column(String, default=None) 

584 

585 role: Mapped[str] = mapped_column(String) 

586 

587 # custom sort order on team page, sorted ascending 

588 sort_key: Mapped[float | None] = mapped_column(Float, default=None) 

589 

590 started_volunteering: Mapped[date] = mapped_column(Date, server_default=text("CURRENT_DATE"), init=False) 

591 stopped_volunteering: Mapped[date | None] = mapped_column(Date, default=None) 

592 

593 link_type: Mapped[str | None] = mapped_column(String, default=None) 

594 link_text: Mapped[str | None] = mapped_column(String, default=None) 

595 link_url: Mapped[str | None] = mapped_column(String, default=None) 

596 

597 show_on_team_page: Mapped[bool] = mapped_column(Boolean, server_default=expression.true()) 

598 

599 __table_args__ = ( 

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

601 CheckConstraint( 

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

603 name="link_type_text", 

604 ), 

605 )