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

254 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +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 TODO: constraint on only one row per user pair where accepted or pending 

73 """ 

74 

75 __tablename__ = "friend_relationships" 

76 __moderation_author_column__ = "from_user_id" 

77 

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

79 

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

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

82 

83 # Unified Moderation System 

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

85 

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

87 

88 # timezones should always be UTC 

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

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

91 

92 from_user: Mapped[User] = relationship( 

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

94 ) 

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

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

97 

98 __table_args__ = ( 

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

100 Index( 

101 "ix_friend_relationships_status_to_from", 

102 status, 

103 to_user_id, 

104 from_user_id, 

105 ), 

106 ) 

107 

108 

109class ContributeOption(enum.Enum): 

110 yes = enum.auto() 

111 maybe = enum.auto() 

112 no = enum.auto() 

113 

114 

115class ContributorForm(Base, kw_only=True): 

116 """ 

117 Someone filled in the contributor form 

118 """ 

119 

120 __tablename__ = "contributor_forms" 

121 

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

123 

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

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

126 

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

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

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

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

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

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

133 

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

135 

136 @hybrid_property 

137 def is_filled(self) -> Any: 

138 """ 

139 Whether the form counts as having been filled 

140 """ 

141 return ( 

142 (self.ideas != None) 

143 | (self.features != None) 

144 | (self.experience != None) 

145 | (self.contribute != None) 

146 | (self.contribute_ways != []) 

147 | (self.expertise != None) 

148 ) 

149 

150 @property 

151 def should_notify(self) -> bool: 

152 """ 

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

154 

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

156 """ 

157 return False 

158 

159 

160class SignupFlow(Base, kw_only=True): 

161 """ 

162 Signup flows/incomplete users 

163 

164 Coinciding fields have the same meaning as in User 

165 """ 

166 

167 __tablename__ = "signup_flows" 

168 

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

170 

171 # housekeeping 

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

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

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

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

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

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

178 

179 ## Basic 

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

181 # TODO: unique across both tables 

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

183 # TODO: invitation, attribution 

184 

185 ## Account 

186 # TODO: unique across both tables 

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

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

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

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

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

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

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

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

195 

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

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

198 

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

200 

201 ## Feedback (now unused) 

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

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

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

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

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

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

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

209 

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

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

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

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

214 

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

216 

217 @hybrid_property 

218 def token_is_valid(self) -> Any: 

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

220 

221 @hybrid_property 

222 def account_is_filled(self) -> Any: 

223 return ( 

224 (self.username != None) 

225 & (self.birthdate != None) 

226 & (self.gender != None) 

227 & (self.hosting_status != None) 

228 & (self.city != None) 

229 & (self.geom != None) 

230 & (self.geom_radius != None) 

231 & (self.accepted_tos != None) 

232 & (self.opt_out_of_newsletter != None) 

233 ) 

234 

235 @hybrid_property 

236 def is_completed(self) -> Any: 

237 return ( 

238 self.email_verified 

239 & self.account_is_filled 

240 & (self.accepted_community_guidelines == GUIDELINES_VERSION) 

241 & self.filled_motivations 

242 ) 

243 

244 

245class AccountDeletionToken(Base, kw_only=True): 

246 __tablename__ = "account_deletion_tokens" 

247 

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

249 

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

251 

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

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

254 

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

256 

257 @hybrid_property 

258 def is_valid(self) -> Any: 

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

260 

261 def __repr__(self) -> str: 

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

263 

264 

265class UserActivity(Base, kw_only=True): 

266 """ 

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

268 

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

270 """ 

271 

272 __tablename__ = "user_activity" 

273 

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

275 

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

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

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

279 

280 # details of the browser, if available 

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

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

283 

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

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

286 

287 __table_args__ = ( 

288 # helps look up this tuple quickly 

289 Index( 

290 "ix_user_activity_user_id_period_ip_address_user_agent", 

291 user_id, 

292 period, 

293 ip_address, 

294 user_agent, 

295 unique=True, 

296 ), 

297 ) 

298 

299 

300class InviteCode(Base, kw_only=True): 

301 __tablename__ = "invite_codes" 

302 

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

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

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

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

307 

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

309 

310 

311class ContentReport(Base, kw_only=True): 

312 """ 

313 A piece of content reported to admins 

314 """ 

315 

316 __tablename__ = "content_reports" 

317 

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

319 

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

321 

322 # the user who reported or flagged the content 

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

324 

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

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

327 # a short description 

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

329 

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

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

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

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

334 

335 # details of the browser, if available 

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

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

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

339 

340 # see comments above for reporting vs author 

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

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

343 

344 

345class Email(Base, kw_only=True): 

346 """ 

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

348 """ 

349 

350 __tablename__ = "emails" 

351 

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

353 

354 # timezone should always be UTC 

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

356 

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

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

359 

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

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

362 

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

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

365 

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

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

368 

369 

370class SMS(Base, kw_only=True): 

371 """ 

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

373 """ 

374 

375 __tablename__ = "smss" 

376 

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

378 

379 # timezone should always be UTC 

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

381 # AWS message id 

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

383 

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

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

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

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

388 

389 

390class ReferenceType(enum.Enum): 

391 friend = enum.auto() 

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

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

394 

395 

396class Reference(Base, kw_only=True): 

397 """ 

398 Reference from one user to another 

399 """ 

400 

401 __tablename__ = "references" 

402 

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

404 # timezone should always be UTC 

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

406 

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

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

409 

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

411 

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

413 

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

415 # text that's only visible to mods 

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

417 

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

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

420 

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

422 

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

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

425 

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

427 

428 __table_args__ = ( 

429 # Rating must be between 0 and 1, inclusive 

430 CheckConstraint( 

431 "rating BETWEEN 0 AND 1", 

432 name="rating_between_0_and_1", 

433 ), 

434 # Has host_request_id or it's a friend reference 

435 CheckConstraint( 

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

437 name="host_request_id_xor_friend_reference", 

438 ), 

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

440 Index( 

441 "ix_references_unique_friend_reference", 

442 from_user_id, 

443 to_user_id, 

444 reference_type, 

445 unique=True, 

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

447 ), 

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

449 Index( 

450 "ix_references_unique_per_host_request", 

451 from_user_id, 

452 to_user_id, 

453 host_request_id, 

454 unique=True, 

455 postgresql_where=(host_request_id != None), 

456 ), 

457 ) 

458 

459 @property 

460 def should_report(self) -> bool: 

461 """ 

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

463 """ 

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

465 

466 

467class UserBlock(Base, kw_only=True): 

468 """ 

469 Table of blocked users 

470 """ 

471 

472 __tablename__ = "user_blocks" 

473 

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

475 

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

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

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

479 

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

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

482 

483 __table_args__ = ( 

484 UniqueConstraint("blocking_user_id", "blocked_user_id"), 

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

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

487 ) 

488 

489 

490class AccountDeletionReason(Base, kw_only=True): 

491 __tablename__ = "account_deletion_reason" 

492 

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

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

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

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

497 

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

499 

500 

501class ModerationUserList(Base, kw_only=True): 

502 """ 

503 Represents a list of users listed together by a moderator 

504 """ 

505 

506 __tablename__ = "moderation_user_lists" 

507 

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

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

510 

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

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

513 ) 

514 

515 

516class ModerationUserListMember(Base, kw_only=True): 

517 """ 

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

519 """ 

520 

521 __tablename__ = "moderation_user_list_members" 

522 

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

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

525 

526 

527class AntiBotLog(Base, kw_only=True): 

528 __tablename__ = "antibot_logs" 

529 

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

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

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

533 

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

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

536 

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

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

539 

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

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

542 

543 

544class RateLimitAction(enum.Enum): 

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

546 

547 host_request = "host request" 

548 friend_request = "friend request" 

549 chat_initiation = "chat initiation" 

550 

551 

552class RateLimitViolation(Base, kw_only=True): 

553 __tablename__ = "rate_limit_violations" 

554 

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

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

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

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

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

560 

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

562 

563 __table_args__ = ( 

564 # Fast lookup for rate limits in interval 

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

566 ) 

567 

568 

569class Volunteer(Base, kw_only=True): 

570 __tablename__ = "volunteers" 

571 

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

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

574 

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

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

577 

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

579 

580 # custom sort order on team page, sorted ascending 

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

582 

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

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

585 

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

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

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

589 

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

591 

592 __table_args__ = ( 

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

594 CheckConstraint( 

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

596 name="link_type_text", 

597 ), 

598 )