Coverage for app/models.py: 100%

1461 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 15:29 +0000

1import enum 

2import secrets 

3from datetime import UTC, date, datetime 

4from typing import Any, ClassVar 

5 

6from flask_sqlalchemy import SQLAlchemy # pyright: ignore[reportMissingImports] 

7from sqlalchemy import text 

8 

9db = SQLAlchemy() 

10 

11 

12def N_(s: str) -> str: 

13 """Marks a string for pybabel extraction without translating it here — 

14 this model layer has no i18n dependency by design (see 

15 MaintenanceTrigger.service_basis), so the actual gettext() lookup 

16 happens later, at the template/route layer that renders a *LABELS 

17 dict value. A no-op at runtime; its literal-string argument is what 

18 lets pybabel discover these msgids despite them only being selected 

19 dynamically (by dict key) rather than passed straight to gettext().""" 

20 return s 

21 

22 

23def reset_schema(db: SQLAlchemy) -> None: 

24 """Drop and recreate the demo database's public schema, then rebuild tables. 

25 

26 Demo mode never runs Alembic migrations (see docker-init-db.py) and its 

27 Postgres volume persists across image updates. db.drop_all() only drops 

28 tables declared in the *current* models — a renamed/removed model (e.g. 

29 a past FlightEntry -> Flight rename) leaves the old physical table behind, 

30 invisible to drop_all() but still enforcing its FK constraints, which can 

31 block dropping tables that are still declared. Nuking the whole schema 

32 sidesteps that: nothing survives for a stale constraint to reference. 

33 

34 On SQLite (dev/test), schemas don't work this way and each test already 

35 starts from a fresh in-memory database, so just create_all(). 

36 """ 

37 if db.engine.dialect.name == "postgresql": 

38 with db.engine.begin() as conn: 

39 conn.execute(text("DROP SCHEMA public CASCADE")) 

40 conn.execute(text("CREATE SCHEMA public")) 

41 db.create_all() 

42 

43 

44class Role(str, enum.Enum): 

45 ADMIN = "admin" 

46 OWNER = "owner" 

47 PILOT = "pilot" # Pilot/Renter: log flights + view own; no config/cost edits 

48 MAINTENANCE = ( 

49 "maintenance" # Maintenance: view+update maintenance; no flights/aircraft edits 

50 ) 

51 VIEWER = "viewer" # Read-only across tenant 

52 STUDENT = "student" # Student pilot — requires instructor sign-off on solo entries 

53 INSTRUCTOR = "instructor" # Flight instructor — can countersign student entries 

54 

55 

56class OperatingModel(str, enum.Enum): 

57 SOLE_PILOT = "sole_pilot" 

58 SOLE_OPERATOR = "sole_operator" 

59 SHARED_OWNERSHIP = "shared_ownership" 

60 FLIGHT_CLUB = "flight_club" 

61 FLIGHT_SCHOOL = "flight_school" 

62 

63 

64class PermissionBit: 

65 """Bitmask constants for UserAircraftAccess.permissions_mask.""" 

66 

67 VIEW_AIRCRAFT = 0x01 

68 EDIT_AIRCRAFT = 0x02 

69 READ_MAINT_FULL = 0x04 

70 READ_MAINT_LIMITED = 0x08 

71 WRITE_MAINTENANCE = 0x10 

72 EDIT_COMPONENTS = 0x20 

73 WRITE_LOGBOOK = 0x40 

74 RESERVE_AIRCRAFT = 0x80 

75 ALL = 0xFF 

76 

77 # Default masks per role (used when no explicit per-aircraft row exists) 

78 ROLE_DEFAULTS: ClassVar[dict[str, int]] = { 

79 "admin": ALL, 

80 "owner": ALL, 

81 "pilot": VIEW_AIRCRAFT | READ_MAINT_LIMITED | WRITE_LOGBOOK | RESERVE_AIRCRAFT, 

82 "student": VIEW_AIRCRAFT | READ_MAINT_LIMITED, 

83 "instructor": VIEW_AIRCRAFT 

84 | READ_MAINT_FULL 

85 | WRITE_LOGBOOK 

86 | RESERVE_AIRCRAFT, 

87 "maintenance": VIEW_AIRCRAFT 

88 | EDIT_AIRCRAFT 

89 | READ_MAINT_FULL 

90 | WRITE_MAINTENANCE 

91 | EDIT_COMPONENTS, 

92 "viewer": VIEW_AIRCRAFT | READ_MAINT_FULL, 

93 } 

94 

95 

96class Tenant(db.Model): 

97 __tablename__ = "tenants" 

98 

99 id = db.Column(db.Integer, primary_key=True) 

100 name = db.Column(db.String(128), nullable=False) 

101 slug = db.Column(db.String(64), nullable=True, unique=True) 

102 is_active = db.Column(db.Boolean, nullable=False, default=True) 

103 require_totp = db.Column(db.Boolean, nullable=False, default=False) 

104 created_at = db.Column( 

105 db.DateTime(timezone=True), 

106 nullable=False, 

107 default=lambda: datetime.now(UTC), 

108 ) 

109 

110 users = db.relationship( 

111 "TenantUser", back_populates="tenant", cascade="all, delete-orphan" 

112 ) 

113 aircraft = db.relationship( 

114 "Aircraft", back_populates="tenant", cascade="all, delete-orphan" 

115 ) 

116 

117 

118class User(db.Model): 

119 __tablename__ = "users" 

120 

121 id = db.Column(db.Integer, primary_key=True) 

122 email = db.Column(db.String(255), unique=True, nullable=False) 

123 password_hash = db.Column(db.String(255), nullable=False) 

124 totp_secret = db.Column(db.String(64), nullable=True, default=None) 

125 is_active = db.Column(db.Boolean, nullable=False, default=True) 

126 name = db.Column(db.String(128), nullable=True) 

127 language = db.Column(db.String(8), nullable=True, default="en") 

128 theme = db.Column(db.String(8), nullable=True, default=None) 

129 # Phase 23: capability flags — orthogonal to role; allow cross-role flows 

130 is_pilot = db.Column(db.Boolean, nullable=False, default=False) 

131 is_maintenance = db.Column(db.Boolean, nullable=False, default=False) 

132 view_only = db.Column(db.Boolean, nullable=False, default=False) 

133 # Phase 29: instance-level super admin — set on the very first user created 

134 is_instance_admin = db.Column(db.Boolean, nullable=False, default=False) 

135 created_at = db.Column( 

136 db.DateTime(timezone=True), 

137 nullable=False, 

138 default=lambda: datetime.now(UTC), 

139 ) 

140 

141 tenants = db.relationship( 

142 "TenantUser", back_populates="user", cascade="all, delete-orphan" 

143 ) 

144 

145 @property 

146 def display_name(self) -> str: 

147 return (self.name or "").strip() or self.email.split("@")[0] 

148 

149 

150class TenantUser(db.Model): 

151 __tablename__ = "tenant_users" 

152 

153 user_id = db.Column( 

154 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True 

155 ) 

156 tenant_id = db.Column( 

157 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True 

158 ) 

159 role = db.Column(db.Enum(Role), nullable=False, default=Role.OWNER) 

160 

161 user = db.relationship("User", back_populates="tenants") 

162 tenant = db.relationship("Tenant", back_populates="users") 

163 

164 

165class UserAircraftAccess(db.Model): 

166 """Grants a non-owner/admin user explicit access to a specific aircraft.""" 

167 

168 __tablename__ = "user_aircraft_access" 

169 

170 user_id = db.Column( 

171 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True 

172 ) 

173 aircraft_id = db.Column( 

174 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), primary_key=True 

175 ) 

176 # Phase 23: optional override mask; when NULL the role's default mask applies 

177 permissions_mask = db.Column(db.Integer, nullable=True) 

178 

179 

180class UserAllAircraftAccess(db.Model): 

181 """Grants a user access to every aircraft in a tenant (past and future). 

182 

183 Admin users bypass access checks entirely and never need this row. 

184 For non-admin users, this row grants access to every aircraft in the 

185 tenant using the supplied permissions_mask (or the role default when NULL). 

186 """ 

187 

188 __tablename__ = "user_all_aircraft_access" 

189 

190 user_id = db.Column( 

191 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True 

192 ) 

193 tenant_id = db.Column( 

194 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True 

195 ) 

196 permissions_mask = db.Column(db.Integer, nullable=True) 

197 

198 

199class UserInvitation(db.Model): 

200 """Time-limited invitation for a new user to join a tenant.""" 

201 

202 __tablename__ = "user_invitations" 

203 

204 id = db.Column(db.Integer, primary_key=True) 

205 token = db.Column( 

206 db.String(64), 

207 unique=True, 

208 nullable=False, 

209 default=lambda: secrets.token_urlsafe(32), 

210 ) 

211 tenant_id = db.Column( 

212 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

213 ) 

214 invited_by_user_id = db.Column( 

215 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

216 ) 

217 email = db.Column(db.String(255), nullable=True) 

218 display_name = db.Column(db.String(128), nullable=True) 

219 role = db.Column(db.Enum(Role), nullable=False, default=Role.PILOT) 

220 aircraft_ids = db.Column(db.JSON, nullable=True) 

221 expires_at = db.Column(db.DateTime(timezone=True), nullable=False) 

222 accepted_at = db.Column(db.DateTime(timezone=True), nullable=True) 

223 created_at = db.Column( 

224 db.DateTime(timezone=True), 

225 nullable=False, 

226 default=lambda: datetime.now(UTC), 

227 ) 

228 

229 tenant = db.relationship("Tenant") 

230 invited_by = db.relationship("User", foreign_keys=[invited_by_user_id]) 

231 

232 @property 

233 def is_expired(self) -> bool: 

234 exp = self.expires_at 

235 # SQLite returns naive datetimes; compare with naive UTC in that case 

236 if exp.tzinfo is None: 

237 return datetime.now(UTC).replace(tzinfo=None) > exp 

238 return datetime.now(UTC) > exp 

239 

240 @property 

241 def is_accepted(self) -> bool: 

242 return self.accepted_at is not None 

243 

244 

245# ── Phase 29: Password Reset Token ─────────────────────────────────────────── 

246 

247 

248class PasswordResetToken(db.Model): 

249 """One-time password reset token generated by the instance admin for a tenant owner.""" 

250 

251 __tablename__ = "password_reset_tokens" 

252 

253 id = db.Column(db.Integer, primary_key=True) 

254 token = db.Column( 

255 db.String(64), 

256 unique=True, 

257 nullable=False, 

258 default=lambda: secrets.token_urlsafe(32), 

259 ) 

260 user_id = db.Column( 

261 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

262 ) 

263 generated_by_user_id = db.Column( 

264 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

265 ) 

266 created_at = db.Column( 

267 db.DateTime(timezone=True), 

268 nullable=False, 

269 default=lambda: datetime.now(UTC), 

270 ) 

271 expires_at = db.Column(db.DateTime(timezone=True), nullable=False) 

272 used_at = db.Column(db.DateTime(timezone=True), nullable=True) 

273 

274 user = db.relationship("User", foreign_keys=[user_id]) 

275 generated_by = db.relationship("User", foreign_keys=[generated_by_user_id]) 

276 

277 @property 

278 def is_expired(self) -> bool: 

279 exp = self.expires_at 

280 if exp.tzinfo is None: 

281 return datetime.now(UTC).replace(tzinfo=None) > exp 

282 return datetime.now(UTC) > exp 

283 

284 @property 

285 def is_used(self) -> bool: 

286 return self.used_at is not None 

287 

288 

289# ── Phase 26: Tenant Profile ───────────────────────────────────────────────── 

290 

291 

292class TenantProfile(db.Model): 

293 """Instance-level profile collected during the onboarding wizard.""" 

294 

295 __tablename__ = "tenant_profiles" 

296 

297 id = db.Column(db.Integer, primary_key=True) 

298 tenant_id = db.Column( 

299 db.Integer, 

300 db.ForeignKey("tenants.id", ondelete="CASCADE"), 

301 nullable=False, 

302 unique=True, 

303 ) 

304 operating_model = db.Column(db.Enum(OperatingModel), nullable=True) 

305 # 0 → logbook-only (no aircraft UI) 

306 # 1 → single-aircraft (hides fleet-level widgets) 

307 # N → show "Add aircraft" CTA until N aircraft exist 

308 planned_aircraft_count = db.Column(db.Integer, nullable=True) 

309 allows_rental = db.Column(db.Boolean, nullable=False, default=False) 

310 club_name = db.Column(db.String(128), nullable=True) 

311 school_name = db.Column(db.String(128), nullable=True) 

312 organisation_name = db.Column(db.String(128), nullable=True) 

313 setup_complete = db.Column(db.Boolean, nullable=False, default=False) 

314 # Phase 34: optional email subject prefix, e.g. "[MyClub]" 

315 email_subject_prefix = db.Column(db.String(64), nullable=True) 

316 # Phase 37c: "off" | "warn" | "block" — enforcement level when a renter 

317 # (non is_owner user) books an aircraft without a valid RenterAuthorization. 

318 rental_authorization_policy = db.Column( 

319 db.String(8), nullable=False, default="warn" 

320 ) 

321 # Phase 37f: "warn" | "block" — enforcement level when creating/confirming a 

322 # reservation on an aircraft with an open grounding snag. Owners always get 

323 # warn-level at most (they may be booking the aircraft for the shop visit). 

324 grounded_reservation_policy = db.Column( 

325 db.String(8), nullable=False, default="warn" 

326 ) 

327 # Phase 39c: days a co-owner capital balance may stay negative before the 

328 # billing dashboard flags it. Only editable/read on a shared_ownership 

329 # tenant — see config.update_profile's gating. 

330 co_owner_overdue_days = db.Column(db.Integer, nullable=False, default=30) 

331 

332 # cascade="all, delete-orphan" on the backref: without it, deleting a 

333 # Tenant object through the ORM (e.g. the demo-seed wipe cycle) tries to 

334 # NULL this row's NOT NULL tenant_id instead of deleting it, since this 

335 # is the only Tenant-side relationship that previously relied solely on 

336 # the DB's ondelete=CASCADE rather than mirroring it at the ORM level 

337 # (see Tenant.users/Tenant.aircraft above). 

338 tenant = db.relationship( 

339 "Tenant", 

340 backref=db.backref("profile", uselist=False, cascade="all, delete-orphan"), 

341 ) 

342 

343 

344# ── Phase 1: Aircraft & Component Models ────────────────────────────────────── 

345 

346 

347# Application-level component type constants. 

348# Stored as plain strings in the DB so new types never require a migration. 

349class ComponentType: 

350 AIRFRAME = "airframe" 

351 ENGINE = "engine" 

352 PROPELLER = "propeller" 

353 AVIONICS = "avionics" 

354 OTHER = "other" 

355 

356 ALL: ClassVar[set[str]] = {AIRFRAME, ENGINE, PROPELLER, AVIONICS, OTHER} 

357 

358 

359class Aircraft(db.Model): 

360 __tablename__ = "aircraft" 

361 

362 id = db.Column(db.Integer, primary_key=True) 

363 tenant_id = db.Column( 

364 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

365 ) 

366 registration = db.Column(db.String(16), nullable=False) 

367 make = db.Column(db.String(64), nullable=False) 

368 model = db.Column(db.String(64), nullable=False) 

369 year = db.Column(db.Integer, nullable=True) 

370 has_flight_counter = db.Column(db.Boolean, nullable=False, default=True) 

371 flight_counter_offset = db.Column(db.Numeric(3, 1), nullable=False, default=0.3) 

372 fuel_flow = db.Column( 

373 db.Numeric(6, 2), nullable=True 

374 ) # typical fuel consumption in L/h 

375 fuel_type = db.Column( 

376 db.String(8), nullable=False, default="avgas" 

377 ) # "avgas" | "jet_a1" 

378 # Oil consumption warning threshold in L/h; null = no warning on the 

379 # cost dashboard. 

380 oil_warning_lph = db.Column(db.Numeric(4, 2), nullable=True) 

381 # Both insurance_expiry and arc_expiry are synced caches, kept in step with 

382 # the currently-active (non-superseded) Document of the matching doc_type 

383 # by app/documents/routes.py — not directly user-editable. 

384 insurance_expiry = db.Column(db.Date, nullable=True) 

385 arc_expiry = db.Column(db.Date, nullable=True) 

386 # Phase 30: GPS import time rounding preference 

387 logbook_time_precision = db.Column( 

388 db.String(16), nullable=False, default="tenth_hour" 

389 ) # "tenth_hour" | "minute" 

390 # Phase 36: optional engine-overhaul reserve accrual rate, surfaced as a 

391 # line item on the cost dashboard; null = not configured. 

392 reserve_hourly_rate = db.Column(db.Numeric(8, 2), nullable=True) 

393 # Phase 39: hourly rate charged to the flying co-owner, and the date from 

394 # which co-owner billing considers expenses/flights. Both null until the 

395 # owners form is first saved. 

396 co_owner_hourly_rate = db.Column(db.Numeric(8, 2), nullable=True) 

397 co_owner_billing_start = db.Column(db.Date, nullable=True) 

398 # Phase 39g: reserve/overhaul fund contribution — at most one of the two 

399 # may be set (mode exclusivity validated on the owners form, not here). 

400 reserve_contribution_hourly = db.Column(db.Numeric(8, 2), nullable=True) 

401 reserve_contribution_monthly = db.Column(db.Numeric(8, 2), nullable=True) 

402 # Archived (sold/retired) aircraft keep their full history but are hidden 

403 # from active-fleet views, reservations, and notification passes. 

404 archived_at = db.Column(db.DateTime(timezone=True), nullable=True) 

405 # Free-text "about this aircraft" blurb shown only on the public showcase 

406 # page (ShareToken.access_level == "showcase") — never on the owner-facing 

407 # aircraft pages or the operational summary/full share tiers. 

408 showcase_blurb = db.Column(db.Text, nullable=True) 

409 created_at = db.Column( 

410 db.DateTime(timezone=True), 

411 nullable=False, 

412 default=lambda: datetime.now(UTC), 

413 ) 

414 

415 tenant = db.relationship("Tenant", back_populates="aircraft") 

416 components = db.relationship( 

417 "Component", back_populates="aircraft", cascade="all, delete-orphan" 

418 ) 

419 flights = db.relationship( 

420 "Flight", back_populates="aircraft", cascade="all, delete-orphan" 

421 ) 

422 maintenance_triggers = db.relationship( 

423 "MaintenanceTrigger", back_populates="aircraft", cascade="all, delete-orphan" 

424 ) 

425 expenses = db.relationship( 

426 "Expense", back_populates="aircraft", cascade="all, delete-orphan" 

427 ) 

428 refuels = db.relationship( 

429 "Refuel", back_populates="aircraft", cascade="all, delete-orphan" 

430 ) 

431 fuel_tanks = db.relationship( 

432 "AircraftFuelTank", 

433 back_populates="aircraft", 

434 cascade="all, delete-orphan", 

435 order_by="AircraftFuelTank.sort_order", 

436 ) 

437 documents = db.relationship( 

438 "Document", 

439 back_populates="aircraft", 

440 cascade="all, delete-orphan", 

441 single_parent=True, 

442 foreign_keys="Document.aircraft_id", 

443 primaryjoin="Document.aircraft_id == Aircraft.id", 

444 ) 

445 share_tokens = db.relationship( 

446 "ShareToken", 

447 back_populates="aircraft", 

448 cascade="all, delete-orphan", 

449 ) 

450 snags = db.relationship( 

451 "Snag", 

452 back_populates="aircraft", 

453 cascade="all, delete-orphan", 

454 ) 

455 maintenance_downtimes = db.relationship( 

456 "MaintenanceDowntime", 

457 back_populates="aircraft", 

458 cascade="all, delete-orphan", 

459 ) 

460 wb_config = db.relationship( 

461 "WeightBalanceConfig", 

462 back_populates="aircraft", 

463 cascade="all, delete-orphan", 

464 uselist=False, 

465 ) 

466 reservations = db.relationship( 

467 "Reservation", 

468 back_populates="aircraft", 

469 cascade="all, delete-orphan", 

470 ) 

471 booking_settings = db.relationship( 

472 "AircraftBookingSettings", 

473 back_populates="aircraft", 

474 cascade="all, delete-orphan", 

475 uselist=False, 

476 ) 

477 amp_declaration = db.relationship( 

478 "AmpDeclaration", 

479 back_populates="aircraft", 

480 cascade="all, delete-orphan", 

481 uselist=False, 

482 ) 

483 amp_revisions = db.relationship( 

484 "AmpRevision", 

485 back_populates="aircraft", 

486 cascade="all, delete-orphan", 

487 order_by="AmpRevision.revision_date.asc().nullslast(), AmpRevision.id.asc()", 

488 ) 

489 photos = db.relationship( 

490 "AircraftPhoto", 

491 back_populates="aircraft", 

492 cascade="all, delete-orphan", 

493 order_by="AircraftPhoto.sort_order", 

494 ) 

495 airworthiness_statuses = db.relationship( 

496 "AirworthinessDocumentStatus", 

497 back_populates="aircraft", 

498 cascade="all, delete-orphan", 

499 ) 

500 installed_stcs = db.relationship( 

501 "InstalledSTC", 

502 back_populates="aircraft", 

503 cascade="all, delete-orphan", 

504 ) 

505 ad_sb_items = db.relationship( 

506 "AdSbItem", 

507 back_populates="aircraft", 

508 cascade="all, delete-orphan", 

509 order_by="AdSbItem.reference", 

510 ) 

511 avionics_units = db.relationship( 

512 "AvionicsUnit", 

513 back_populates="aircraft", 

514 cascade="all, delete-orphan", 

515 order_by="AvionicsUnit.role", 

516 ) 

517 equipment_wishlist_items = db.relationship( 

518 "EquipmentWishlistItem", 

519 back_populates="aircraft", 

520 cascade="all, delete-orphan", 

521 order_by="EquipmentWishlistItem.created_at", 

522 ) 

523 owners = db.relationship( 

524 "AircraftOwner", 

525 back_populates="aircraft", 

526 cascade="all, delete-orphan", 

527 order_by="AircraftOwner.share_pct.desc()", 

528 ) 

529 

530 @property 

531 def cover_photo(self) -> "AircraftPhoto | None": 

532 return self.photos[0] if self.photos else None 

533 

534 @property 

535 def total_engine_hours(self) -> "float | None": 

536 """Current engine hours — the highest engine_time_counter_end across all flight entries.""" 

537 val = db.session.execute( 

538 db.select(db.func.max(Flight.engine_time_counter_end)).where( 

539 Flight.aircraft_id == self.id 

540 ) 

541 ).scalar() 

542 return float(val) if val is not None else None 

543 

544 @property 

545 def total_flight_hours(self) -> "float | None": 

546 """Current flight hours — the highest flight_time_counter_end across all flight entries.""" 

547 val = db.session.execute( 

548 db.select(db.func.max(Flight.flight_time_counter_end)).where( 

549 Flight.aircraft_id == self.id 

550 ) 

551 ).scalar() 

552 return float(val) if val is not None else None 

553 

554 @staticmethod 

555 def engine_hours_by_id(aircraft_ids: "list[int]") -> "dict[int, float | None]": 

556 """Current engine hours for a whole fleet in one aggregate query. 

557 

558 Returns an entry for every requested id (None when the aircraft has no 

559 flight entries or no engine counter values).""" 

560 totals: dict[int, float | None] = {aid: None for aid in aircraft_ids} 

561 if not aircraft_ids: 

562 return totals 

563 rows = db.session.execute( 

564 db.select( 

565 Flight.aircraft_id, 

566 db.func.max(Flight.engine_time_counter_end), 

567 ) 

568 .where(Flight.aircraft_id.in_(aircraft_ids)) 

569 .group_by(Flight.aircraft_id) 

570 ).all() 

571 for aid, max_end in rows: 

572 totals[aid] = float(max_end) if max_end is not None else None 

573 return totals 

574 

575 @staticmethod 

576 def flight_hours_by_id(aircraft_ids: "list[int]") -> "dict[int, float | None]": 

577 """Current flight hours for a whole fleet in one aggregate query. 

578 

579 Mirrors ``engine_hours_by_id``, reading ``flight_time_counter_end`` 

580 instead — used for maintenance triggers whose ``hours_basis`` is 

581 ``HoursBasis.FLIGHT``.""" 

582 totals: dict[int, float | None] = {aid: None for aid in aircraft_ids} 

583 if not aircraft_ids: 

584 return totals 

585 rows = db.session.execute( 

586 db.select( 

587 Flight.aircraft_id, 

588 db.func.max(Flight.flight_time_counter_end), 

589 ) 

590 .where(Flight.aircraft_id.in_(aircraft_ids)) 

591 .group_by(Flight.aircraft_id) 

592 ).all() 

593 for aid, max_end in rows: 

594 totals[aid] = float(max_end) if max_end is not None else None 

595 return totals 

596 

597 @property 

598 def total_landings(self) -> "int | None": 

599 """Current cumulative landing count — sum of landing_count across all 

600 flight entries (unlike the engine/flight hour counters, landings 

601 aren't a running total already, so this sums rather than maxes).""" 

602 val = db.session.execute( 

603 db.select(db.func.sum(Flight.landing_count)).where( 

604 Flight.aircraft_id == self.id 

605 ) 

606 ).scalar() 

607 return int(val) if val is not None else None 

608 

609 @staticmethod 

610 def landings_by_id(aircraft_ids: "list[int]") -> "dict[int, int | None]": 

611 """Current cumulative landing count for a whole fleet in one aggregate 

612 query. Returns an entry for every requested id (None when the 

613 aircraft has no flight entries with a recorded landing count).""" 

614 totals: dict[int, int | None] = {aid: None for aid in aircraft_ids} 

615 if not aircraft_ids: 

616 return totals 

617 rows = db.session.execute( 

618 db.select( 

619 Flight.aircraft_id, 

620 db.func.sum(Flight.landing_count), 

621 ) 

622 .where(Flight.aircraft_id.in_(aircraft_ids)) 

623 .group_by(Flight.aircraft_id) 

624 ).all() 

625 for aid, total in rows: 

626 totals[aid] = int(total) if total is not None else None 

627 return totals 

628 

629 @property 

630 def is_archived(self) -> bool: 

631 return self.archived_at is not None 

632 

633 @property 

634 def is_grounded(self) -> bool: 

635 """True when any unresolved grounding snag exists, or insurance/the 

636 ARC has expired.""" 

637 from datetime import date as _date 

638 

639 today = _date.today() 

640 if self.insurance_expiry is not None and self.insurance_expiry < today: 

641 return True 

642 if self.arc_expiry is not None and self.arc_expiry < today: 

643 return True 

644 return any(s.is_grounding and s.is_open for s in self.snags) 

645 

646 @staticmethod 

647 def _expiry_status(expiry: date | None) -> str: 

648 """Return 'expired', 'expiring_soon' (≤30 days), or 'ok' for a given date.""" 

649 if expiry is None: 

650 return "ok" 

651 delta = (expiry - date.today()).days 

652 if delta < 0: 

653 return "expired" 

654 if delta <= 30: 

655 return "expiring_soon" 

656 return "ok" 

657 

658 @property 

659 def insurance_status(self) -> str: 

660 """Return 'expired', 'expiring_soon' (≤30 days), or 'ok'.""" 

661 return self._expiry_status(self.insurance_expiry) 

662 

663 @property 

664 def arc_status(self) -> str: 

665 """Return 'expired', 'expiring_soon' (≤30 days), or 'ok'.""" 

666 return self._expiry_status(self.arc_expiry) 

667 

668 

669class AircraftPhoto(db.Model): 

670 __tablename__ = "aircraft_photos" 

671 __table_args__ = (db.Index("ix_aircraft_photos_aircraft_id", "aircraft_id"),) 

672 

673 id = db.Column(db.Integer, primary_key=True) 

674 aircraft_id = db.Column( 

675 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

676 ) 

677 filename = db.Column(db.String(512), nullable=False) 

678 original_filename = db.Column(db.String(256), nullable=False) 

679 sort_order = db.Column(db.Integer, nullable=False, default=1) 

680 uploaded_at = db.Column( 

681 db.DateTime(timezone=True), 

682 nullable=False, 

683 default=lambda: datetime.now(UTC), 

684 ) 

685 uploaded_by_user_id = db.Column( 

686 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

687 ) 

688 

689 aircraft = db.relationship("Aircraft", back_populates="photos") 

690 

691 

692class Component(db.Model): 

693 """ 

694 A generic aircraft component (engine, propeller, avionics, …). 

695 

696 Common fields live as columns; type-specific attributes go in `extras` (JSON). 

697 `removed_at = NULL` means the component is currently installed. 

698 `position` disambiguates multiple components of the same type, e.g. "left" / "right" 

699 for a twin-engine aircraft. 

700 """ 

701 

702 __tablename__ = "components" 

703 

704 id = db.Column(db.Integer, primary_key=True) 

705 aircraft_id = db.Column( 

706 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

707 ) 

708 # Plain string, validated at application layer — no DB ENUM so new types 

709 # never require a schema migration. 

710 type = db.Column(db.String(32), nullable=False) 

711 # Optional slot label: "left", "right", "1", "2", "center", … 

712 position = db.Column(db.String(32), nullable=True) 

713 

714 make = db.Column(db.String(64), nullable=False) 

715 model = db.Column(db.String(64), nullable=False) 

716 serial_number = db.Column(db.String(64), nullable=True) 

717 # Hours on this component when it was installed on this aircraft 

718 time_at_install = db.Column(db.Numeric(8, 1), nullable=True) 

719 

720 installed_at = db.Column(db.Date, nullable=True) 

721 removed_at = db.Column(db.Date, nullable=True) # NULL = currently installed 

722 

723 # Life limits: hours between overhauls (TBO) and/or a calendar life limit 

724 # (e.g. 12-year rubber hoses). overhauled_at_hours records the component 

725 # hours at the last overhaul — it resets the TBO reference point without 

726 # touching history; overhauled_on is the matching date for the records. 

727 tbo_hours = db.Column(db.Numeric(8, 1), nullable=True) 

728 life_limit_date = db.Column(db.Date, nullable=True) 

729 overhauled_at_hours = db.Column(db.Numeric(8, 1), nullable=True) 

730 overhauled_on = db.Column(db.Date, nullable=True) 

731 

732 # Type-specific attributes (blade count, TBO, firmware version, …) 

733 extras = db.Column(db.JSON, nullable=True) 

734 

735 created_at = db.Column( 

736 db.DateTime(timezone=True), 

737 nullable=False, 

738 default=lambda: datetime.now(UTC), 

739 ) 

740 

741 aircraft = db.relationship("Aircraft", back_populates="components") 

742 documents = db.relationship( 

743 "Document", 

744 back_populates="component", 

745 cascade="all, delete-orphan", 

746 ) 

747 easa_source_nodes = db.relationship( 

748 "EASASourceNode", 

749 back_populates="component", 

750 cascade="all, delete-orphan", 

751 ) 

752 airworthiness_documents = db.relationship( 

753 "AirworthinessDocument", 

754 back_populates="component", 

755 cascade="all, delete-orphan", 

756 foreign_keys="AirworthinessDocument.component_id", 

757 ) 

758 # Phase 40: no cascade — deleting a component unscopes (SET NULL) rather 

759 # than deletes its maintenance triggers, per MaintenanceTrigger.component_id. 

760 maintenance_triggers = db.relationship( 

761 "MaintenanceTrigger", back_populates="component" 

762 ) 

763 

764 

765# ── Phase 3: Flight Logging ─────────────────────────────────────────────────── 

766 

767 

768class CrewRole: 

769 PIC = "PIC" 

770 IP = "IP" 

771 SP = "SP" 

772 COPILOT = "COPILOT" 

773 STUDENT = "STUDENT" 

774 ALL: ClassVar[list[str]] = [PIC, IP, SP, COPILOT, STUDENT] 

775 # N_()-marked: rendered through gettext() at the template layer, not here. 

776 LABELS: ClassVar[dict[str, str]] = { 

777 PIC: N_("PIC"), 

778 IP: N_("Instructor"), 

779 SP: N_("Safety Pilot"), 

780 COPILOT: N_("Co-Pilot"), 

781 STUDENT: N_("Student"), 

782 } 

783 

784 

785# ── GPS Track (standalone, linkable from Flight) ────────────────────────────── 

786 

787 

788class GpsTrack(db.Model): 

789 __tablename__ = "gps_tracks" 

790 

791 id = db.Column(db.Integer, primary_key=True) 

792 source_filename = db.Column(db.String(256), nullable=True) 

793 device_id = db.Column(db.String(64), nullable=True, index=True) 

794 block_off_utc = db.Column(db.DateTime(timezone=True), nullable=True) 

795 block_on_utc = db.Column(db.DateTime(timezone=True), nullable=True) 

796 departure_icao = db.Column(db.String(4), nullable=True) 

797 arrival_icao = db.Column(db.String(4), nullable=True) 

798 geojson = db.Column(db.JSON, nullable=True) 

799 created_at = db.Column( 

800 db.DateTime(timezone=True), 

801 nullable=False, 

802 default=lambda: datetime.now(UTC), 

803 ) 

804 # Render cache for the default (landscape, low-res) single-flight PNG/GIF — 

805 # geojson never changes once saved, so no invalidation is ever needed. 

806 cached_png = db.Column(db.LargeBinary, nullable=True) 

807 cached_gif = db.Column(db.LargeBinary, nullable=True) 

808 

809 

810class LogbookEntryType: 

811 FLIGHT = "flight" 

812 FSTD = "fstd" # synthetic training device / simulator session 

813 ALL: ClassVar[set[str]] = {FLIGHT, FSTD} 

814 

815 

816class FstdType: 

817 FFS = "FFS" 

818 FTD = "FTD" 

819 FNPT = "FNPT" 

820 BITD = "BITD" 

821 AATD = "AATD" 

822 ALL: ClassVar[list[str]] = [FFS, FTD, FNPT, BITD, AATD] 

823 

824 

825class Flight(db.Model): 

826 """Unified logbook record — one row per real-world flight (or FSTD 

827 session), covering both the airframe-log ("aircraft side") and the 

828 EASA FCL.050 pilot-log ("pilot side") views of it, replacing the old 

829 FlightEntry + FlightCrew + PilotLogbookEntry three-table split. 

830 

831 Covers all three real-world cases: (1) a managed aircraft flown by a 

832 pilot with no OpenHangar account (aircraft_id set, pic_user_id NULL, 

833 pic_name free text); (2) a pilot with an account flying an aircraft 

834 not managed here (aircraft_id NULL, other_aircraft_* free text, 

835 pic_user_id set); (3) both managed (aircraft_id and pic_user_id both 

836 set). At most one additional occupant is tracked via the 

837 second_crew_* identity fields (mirrors the pre-refactor FlightCrew's 

838 2-slot cap — every crew-creating code path already stopped at 2). 

839 

840 EASA figures (night/instrument/cross-country time, landings, 

841 single_pilot_se/me, multi_pilot, the function_* breakdown, entry_type/ 

842 fstd_*) live once on the row, not duplicated per pilot slot — they 

843 describe the flight/session itself (or, for function_*, are already 

844 unambiguously tied to a slot by column name), not a specific occupant. 

845 

846 Two independent engine/flight duration tracks live side by side: 

847 departure_time/arrival_time + engine_time_counter_*/engine_time (engine 

848 start/end, pilot-log-facing) vs. takeoff_time/landing_time + 

849 flight_time_counter_*/flight_time (flight start/end, airframe-log- 

850 facing). Neither pair is derived from the other. 

851 Letting PIC and a second crew member log different figures for the 

852 same real flight was a bug in the old two-table design, not a 

853 feature — this schema makes that impossible by construction. 

854 """ 

855 

856 __tablename__ = "flights" 

857 

858 id = db.Column(db.Integer, primary_key=True) 

859 aircraft_id = db.Column( 

860 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=True 

861 ) 

862 # Free-text aircraft descriptor for flights on an aircraft not managed 

863 # in this instance (aircraft_id NULL) — mirrors PilotLogbookEntry's old 

864 # aircraft_type/_icao/registration. When aircraft_id is set, the type/ 

865 # registration are derived live from the Aircraft join instead. 

866 other_aircraft_type = db.Column(db.String(64), nullable=True) 

867 other_aircraft_type_icao = db.Column(db.String(16), nullable=True) 

868 other_aircraft_registration = db.Column(db.String(16), nullable=True) 

869 

870 date = db.Column(db.Date, nullable=False) 

871 # Widened from the old FlightEntry's strict 4-char ICAO-only String — 

872 # a standalone entry (rental/FSTD) may log a free-text place instead. 

873 departure_icao = db.Column(db.String(64), nullable=True) 

874 arrival_icao = db.Column(db.String(64), nullable=True) 

875 # Engine start/end (block-off/block-on — engine assumed on at block-off). 

876 # Pairs with engine_time_counter_*/engine_time below and feeds the pilot 

877 # logbook. Distinct from takeoff_time/landing_time (the flight/airborne 

878 # segment, pairs with flight_time_counter_*/flight_time, feeds the 

879 # airframe logbook) — the two pairs are never defaulted from each other. 

880 departure_time = db.Column(db.Time, nullable=True) 

881 arrival_time = db.Column(db.Time, nullable=True) 

882 # Flight start/end (wheels-up/wheels-down) — see comment above. 

883 takeoff_time = db.Column(db.Time, nullable=True) 

884 landing_time = db.Column(db.Time, nullable=True) 

885 flight_time = db.Column(db.Numeric(4, 1), nullable=True) 

886 nature_of_flight = db.Column(db.String(100), nullable=True) 

887 passenger_count = db.Column(db.Integer, nullable=True) 

888 landing_count = db.Column(db.Integer, nullable=True) 

889 flight_time_counter_start = db.Column(db.Numeric(8, 1), nullable=True) 

890 flight_time_counter_end = db.Column(db.Numeric(8, 1), nullable=True) 

891 notes = db.Column(db.Text, nullable=True) 

892 engine_time_counter_start = db.Column(db.Numeric(8, 1), nullable=True) 

893 engine_time_counter_end = db.Column(db.Numeric(8, 1), nullable=True) 

894 # Raw engine-hours duration for this flight (engine counter end minus 

895 # start, no offset) — distinct from flight_time, which approximates the 

896 # airborne segment. Engine/propeller TBO tracking sums this, not 

897 # flight_time (see services/component_limits.py). 

898 engine_time = db.Column(db.Numeric(4, 1), nullable=True) 

899 flight_counter_photo = db.Column(db.String(255), nullable=True) 

900 engine_counter_photo = db.Column(db.String(255), nullable=True) 

901 # Pre-flight counterparts of the two columns above — taken before 

902 # departure (e.g. at block-off) rather than after shutdown. Independent 

903 # of the post-flight photos: a flight can have neither, either, or both. 

904 flight_counter_photo_preflight = db.Column(db.String(255), nullable=True) 

905 engine_counter_photo_preflight = db.Column(db.String(255), nullable=True) 

906 # Refueling before and after are independent events — a flight can have 

907 # neither, either, or both (e.g. topped off before departure, then 

908 # topped off again after landing). 

909 fuel_added_before_qty = db.Column(db.Numeric(8, 2), nullable=True) 

910 fuel_added_before_unit = db.Column(db.String(8), nullable=True) 

911 fuel_added_after_qty = db.Column(db.Numeric(8, 2), nullable=True) 

912 fuel_added_after_unit = db.Column(db.String(8), nullable=True) 

913 fuel_remaining_qty = db.Column(db.Numeric(8, 2), nullable=True) 

914 fuel_photo = db.Column(db.String(255), nullable=True) 

915 # Same independence as the fuel_added_before/after pair above — oil can 

916 # be topped off before departure, after landing, or both. 

917 oil_added_before_l = db.Column(db.Numeric(4, 2), nullable=True) 

918 oil_added_after_l = db.Column(db.Numeric(4, 2), nullable=True) 

919 created_at = db.Column( 

920 db.DateTime(timezone=True), 

921 nullable=False, 

922 default=lambda: datetime.now(UTC), 

923 ) 

924 

925 # ── EASA FCL.050 pilot-log figures (shared, once per row) ───────────────── 

926 night_time = db.Column(db.Numeric(4, 1), nullable=True) 

927 instrument_time = db.Column(db.Numeric(4, 1), nullable=True) 

928 cross_country = db.Column(db.Numeric(4, 1), nullable=True) 

929 landings_day = db.Column(db.Integer, nullable=True) 

930 landings_night = db.Column(db.Integer, nullable=True) 

931 single_pilot_se = db.Column(db.Numeric(4, 1), nullable=True) 

932 single_pilot_me = db.Column(db.Numeric(4, 1), nullable=True) 

933 multi_pilot = db.Column(db.Numeric(4, 1), nullable=True) 

934 # Each already unambiguously tied to a slot by name: function_pic is 

935 # always the pic_* slot's hours; whichever of the other three is 

936 # non-null belongs to the second_crew_* slot, per second_crew_role. 

937 function_pic = db.Column(db.Numeric(4, 1), nullable=True) 

938 function_copilot = db.Column(db.Numeric(4, 1), nullable=True) 

939 function_dual = db.Column(db.Numeric(4, 1), nullable=True) 

940 function_instructor = db.Column(db.Numeric(4, 1), nullable=True) 

941 

942 # EASA AMC1 FCL.050 column 10 — FSTD/simulator sessions (LogbookEntryType constant) 

943 entry_type = db.Column( 

944 db.String(16), 

945 nullable=False, 

946 default=LogbookEntryType.FLIGHT, 

947 server_default=LogbookEntryType.FLIGHT, 

948 ) 

949 fstd_type = db.Column(db.String(16), nullable=True) # FstdType constant 

950 fstd_duration = db.Column(db.Numeric(4, 1), nullable=True) 

951 

952 # ── Crew identity slots ──────────────────────────────────────────────────── 

953 pic_user_id = db.Column( 

954 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

955 ) 

956 pic_name = db.Column(db.String(128), nullable=True) 

957 second_crew_user_id = db.Column( 

958 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

959 ) 

960 second_crew_name = db.Column(db.String(128), nullable=True) 

961 second_crew_role = db.Column(db.String(16), nullable=True) # CrewRole constant 

962 # Personal remark per slot — only ever edited by the pilot in that slot, 

963 # unlike the shared `notes` (see flights/shared_flight.py). 

964 pic_remarks = db.Column(db.Text, nullable=True) 

965 second_crew_remarks = db.Column(db.Text, nullable=True) 

966 # Who logged the flight. While they're still linked to a slot, only they 

967 # (plus tenant owners/admins, for managed aircraft) edit the shared 

968 # fields; the other linked pilot suggests corrections instead. NULL for 

969 # rows created before this was tracked → every linked pilot may edit. 

970 created_by_user_id = db.Column( 

971 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

972 ) 

973 

974 # Phase 30: GPS import 

975 source = db.Column(db.String(32), nullable=True) 

976 # "gps_import" | "import" | "logbook_import" | None — see docs/backlog.md 

977 # "Step 2" for how this is used to flag rows needing the other side's data. 

978 gps_import_batch_id = db.Column( 

979 db.Integer, 

980 db.ForeignKey("aircraft_gps_import_batches.id", ondelete="SET NULL"), 

981 nullable=True, 

982 ) 

983 # Bulk airframe logbook import (CSV/Excel) 

984 airframe_import_batch_id = db.Column( 

985 db.Integer, 

986 db.ForeignKey("airframe_import_batches.id", ondelete="SET NULL"), 

987 nullable=True, 

988 ) 

989 airframe_import_batch = db.relationship( 

990 "AirframeImportBatch", foreign_keys=[airframe_import_batch_id] 

991 ) 

992 # Pilot logbook import (CSV/Excel) — independent of airframe_import_batch_id 

993 # above; a row can carry both once it's been touched from both sides. 

994 import_batch_id = db.Column( 

995 db.Integer, 

996 db.ForeignKey("logbook_import_batches.id", ondelete="SET NULL"), 

997 nullable=True, 

998 ) 

999 block_off_utc = db.Column(db.DateTime(timezone=True), nullable=True) 

1000 block_on_utc = db.Column(db.DateTime(timezone=True), nullable=True) 

1001 gps_track_id = db.Column( 

1002 db.Integer, 

1003 db.ForeignKey("gps_tracks.id", ondelete="SET NULL"), 

1004 nullable=True, 

1005 ) 

1006 # Phase 37d: auto-linked when this flight falls inside a confirmed 

1007 # reservation's dispatch window for the same pilot. 

1008 reservation_id = db.Column( 

1009 db.Integer, 

1010 db.ForeignKey("reservations.id", ondelete="SET NULL"), 

1011 nullable=True, 

1012 ) 

1013 

1014 aircraft = db.relationship("Aircraft", back_populates="flights") 

1015 reservation = db.relationship("Reservation", back_populates="flights") 

1016 gps_track = db.relationship("GpsTrack", foreign_keys=[gps_track_id]) 

1017 gps_import_batch = db.relationship( 

1018 "AircraftGpsImportBatch", foreign_keys=[gps_import_batch_id] 

1019 ) 

1020 import_batch = db.relationship("LogbookImportBatch", foreign_keys=[import_batch_id]) 

1021 pic_user = db.relationship("User", foreign_keys=[pic_user_id]) 

1022 second_crew_user = db.relationship("User", foreign_keys=[second_crew_user_id]) 

1023 expenses = db.relationship("Expense", back_populates="flight_entry") 

1024 documents = db.relationship( 

1025 "Document", 

1026 back_populates="flight_entry", 

1027 cascade="all, delete-orphan", 

1028 ) 

1029 

1030 __table_args__ = ( 

1031 # Matches the standard airframe-log ordering (date DESC, id DESC per aircraft). 

1032 db.Index( 

1033 "ix_flights_aircraft_id_date_id", 

1034 aircraft_id, 

1035 date.desc(), 

1036 id.desc(), 

1037 ), 

1038 # Match the old per-pilot logbook ordering (date DESC, id DESC per 

1039 # pilot) for each identity slot — "my logbook" is 

1040 # WHERE pic_user_id = :uid OR second_crew_user_id = :uid. 

1041 db.Index( 

1042 "ix_flights_pic_user_id_date_id", 

1043 pic_user_id, 

1044 date.desc(), 

1045 id.desc(), 

1046 ), 

1047 db.Index( 

1048 "ix_flights_second_crew_user_id_date_id", 

1049 second_crew_user_id, 

1050 date.desc(), 

1051 id.desc(), 

1052 ), 

1053 ) 

1054 

1055 @property 

1056 def total_flight_time(self): 

1057 parts = [self.single_pilot_se, self.single_pilot_me, self.multi_pilot] 

1058 vals = [float(p) for p in parts if p is not None] 

1059 return round(sum(vals), 1) if vals else None 

1060 

1061 # Aircraft descriptor, resolved live from the managed Aircraft when 

1062 # aircraft_id is set (Aircraft.make/model rarely changes) rather than 

1063 # denormalized onto this row — falls back to the free-text 

1064 # other_aircraft_* columns for a standalone (aircraft_id NULL) row. 

1065 @property 

1066 def display_aircraft_type(self) -> str | None: 

1067 if self.aircraft_id and self.aircraft: 

1068 return f"{self.aircraft.make} {self.aircraft.model}".strip() 

1069 return self.other_aircraft_type 

1070 

1071 @property 

1072 def display_aircraft_type_icao(self) -> str | None: 

1073 if self.aircraft_id and self.aircraft: 

1074 return getattr(self.aircraft, "aircraft_type_icao", None) 

1075 return self.other_aircraft_type_icao 

1076 

1077 @property 

1078 def display_registration(self) -> str | None: 

1079 if self.aircraft_id and self.aircraft: 

1080 return self.aircraft.registration 

1081 return self.other_aircraft_registration 

1082 

1083 def slot_for(self, user_id: int | None) -> str | None: 

1084 """The crew slot ("pic" / "second") *user_id* is linked to, if any.""" 

1085 if user_id is None: 

1086 return None 

1087 if self.pic_user_id == user_id: 

1088 return "pic" 

1089 if self.second_crew_user_id == user_id: 

1090 return "second" 

1091 return None 

1092 

1093 def visible_function_fields(self, user_id: int | None) -> set[str]: 

1094 """function_* columns to show *user_id* in their logbook: on a flight 

1095 shared with another linked pilot, only their own slot's hours.""" 

1096 if self.other_linked_user_ids(user_id): 

1097 if self.slot_for(user_id) == "pic": 

1098 return {"function_pic"} 

1099 if self.slot_for(user_id) == "second": 

1100 return {"function_copilot", "function_dual", "function_instructor"} 

1101 return { 

1102 "function_pic", 

1103 "function_copilot", 

1104 "function_dual", 

1105 "function_instructor", 

1106 } 

1107 

1108 def personal_remark_for(self, user_id: int | None) -> str | None: 

1109 slot = self.slot_for(user_id) 

1110 if slot == "pic": 

1111 remark: str | None = self.pic_remarks 

1112 return remark 

1113 if slot == "second": 

1114 remark = self.second_crew_remarks 

1115 return remark 

1116 return None 

1117 

1118 def other_linked_user_ids(self, user_id: int | None) -> set[int]: 

1119 """Accounts linked to either crew slot, other than *user_id* — the 

1120 pilots whose logbooks a delete by *user_id* must not touch (see 

1121 flights/crew_removal.py).""" 

1122 return { 

1123 uid 

1124 for uid in (self.pic_user_id, self.second_crew_user_id) 

1125 if uid is not None and uid != user_id 

1126 } 

1127 

1128 

1129# ── Crew invites (ask another pilot to confirm their slot on a flight) ──────── 

1130 

1131 

1132class CrewSlot: 

1133 """Which of a Flight row's two identity slots an invite targets.""" 

1134 

1135 PIC = "pic" # pic_user_id / pic_name 

1136 SECOND = "second" # second_crew_user_id / second_crew_name / second_crew_role 

1137 ALL: ClassVar[list[str]] = [PIC, SECOND] 

1138 

1139 

1140class CrewInviteStatus: 

1141 PENDING = "pending" 

1142 ACCEPTED = "accepted" 

1143 DECLINED = "declined" 

1144 # The inviter changed/removed the name, or the slot got filled another way. 

1145 CANCELLED = "cancelled" 

1146 ALL: ClassVar[list[str]] = [PENDING, ACCEPTED, DECLINED, CANCELLED] 

1147 

1148 

1149class CrewInviteKind: 

1150 # The logger named a pilot; that pilot confirms or declines. 

1151 INVITE = "invite" 

1152 # A pilot found the flight already logged (duplicate warning) and asks to 

1153 # be added; the pilot who owns the flight's shared fields approves. 

1154 CLAIM = "claim" 

1155 ALL: ClassVar[list[str]] = [INVITE, CLAIM] 

1156 

1157 

1158class FlightCrewInvite(db.Model): 

1159 """A request to link ``invited_user_id`` into one of a flight's crew slots. 

1160 

1161 ``kind`` says who answers it: for an invite (a pilot named another pilot 

1162 of the same tenant) the invited pilot confirms or declines; for a claim (a 

1163 pilot asks to be added to a flight someone else logged) the pilot who owns 

1164 the flight's shared fields approves or declines. Either way, on acceptance 

1165 the user id is written into the slot and the flight appears in that 

1166 pilot's logbook. 

1167 

1168 The slot's user id is deliberately *not* set while the request is 

1169 pending, so an unconfirmed flight never counts towards that pilot's totals 

1170 or currency. 

1171 """ 

1172 

1173 __tablename__ = "flight_crew_invites" 

1174 

1175 id = db.Column(db.Integer, primary_key=True) 

1176 flight_id = db.Column( 

1177 db.Integer, 

1178 db.ForeignKey("flights.id", ondelete="CASCADE"), 

1179 nullable=False, 

1180 index=True, 

1181 ) 

1182 slot = db.Column(db.String(16), nullable=False) # CrewSlot constant 

1183 invited_user_id = db.Column( 

1184 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

1185 ) 

1186 invited_by_user_id = db.Column( 

1187 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

1188 ) 

1189 status = db.Column( 

1190 db.String(16), 

1191 nullable=False, 

1192 default=CrewInviteStatus.PENDING, 

1193 server_default=CrewInviteStatus.PENDING, 

1194 ) 

1195 kind = db.Column( 

1196 db.String(16), 

1197 nullable=False, 

1198 default=CrewInviteKind.INVITE, 

1199 server_default=CrewInviteKind.INVITE, 

1200 ) 

1201 # Claims only: the second-crew role the claiming pilot says they flew as, 

1202 # applied on approval when the flight doesn't name one yet. 

1203 requested_role = db.Column(db.String(16), nullable=True) # CrewRole constant 

1204 created_at = db.Column( 

1205 db.DateTime(timezone=True), 

1206 nullable=False, 

1207 default=lambda: datetime.now(UTC), 

1208 ) 

1209 responded_at = db.Column(db.DateTime(timezone=True), nullable=True) 

1210 

1211 flight = db.relationship( 

1212 "Flight", 

1213 backref=db.backref( 

1214 "crew_invites", cascade="all, delete-orphan", passive_deletes=True 

1215 ), 

1216 ) 

1217 invited_user = db.relationship("User", foreign_keys=[invited_user_id]) 

1218 invited_by = db.relationship("User", foreign_keys=[invited_by_user_id]) 

1219 

1220 __table_args__ = ( 

1221 db.Index( 

1222 "ix_flight_crew_invites_invited_user_id_status", 

1223 invited_user_id, 

1224 status, 

1225 ), 

1226 ) 

1227 

1228 

1229class CorrectionStatus: 

1230 PENDING = "pending" 

1231 ACCEPTED = "accepted" 

1232 REJECTED = "rejected" 

1233 ALL: ClassVar[list[str]] = [PENDING, ACCEPTED, REJECTED] 

1234 

1235 

1236class FlightCorrectionSuggestion(db.Model): 

1237 """A linked pilot who may not edit a shared flight's common fields 

1238 proposes new values; the pilot who logged it accepts (applied for both) 

1239 or rejects. ``changes`` maps field name → [old, new] canonical strings 

1240 (flights/shared_flight.py).""" 

1241 

1242 __tablename__ = "flight_correction_suggestions" 

1243 

1244 id = db.Column(db.Integer, primary_key=True) 

1245 flight_id = db.Column( 

1246 db.Integer, 

1247 db.ForeignKey("flights.id", ondelete="CASCADE"), 

1248 nullable=False, 

1249 index=True, 

1250 ) 

1251 suggested_by_user_id = db.Column( 

1252 db.Integer, 

1253 db.ForeignKey("users.id", ondelete="CASCADE"), 

1254 nullable=False, 

1255 index=True, 

1256 ) 

1257 changes = db.Column(db.JSON, nullable=False) 

1258 status = db.Column( 

1259 db.String(16), 

1260 nullable=False, 

1261 default=CorrectionStatus.PENDING, 

1262 server_default=CorrectionStatus.PENDING, 

1263 ) 

1264 created_at = db.Column( 

1265 db.DateTime(timezone=True), 

1266 nullable=False, 

1267 default=lambda: datetime.now(UTC), 

1268 ) 

1269 responded_at = db.Column(db.DateTime(timezone=True), nullable=True) 

1270 

1271 flight = db.relationship( 

1272 "Flight", 

1273 backref=db.backref( 

1274 "correction_suggestions", cascade="all, delete-orphan", passive_deletes=True 

1275 ), 

1276 ) 

1277 suggested_by = db.relationship("User", foreign_keys=[suggested_by_user_id]) 

1278 

1279 

1280# ── Pilot Profile ────────────────────────────────────────────────────────────── 

1281 

1282 

1283class PilotProfile(db.Model): 

1284 __tablename__ = "pilot_profiles" 

1285 

1286 id = db.Column(db.Integer, primary_key=True) 

1287 user_id = db.Column( 

1288 db.Integer, 

1289 db.ForeignKey("users.id", ondelete="CASCADE"), 

1290 nullable=False, 

1291 unique=True, 

1292 ) 

1293 license_number = db.Column(db.String(64), nullable=True) 

1294 medical_expiry = db.Column(db.Date, nullable=True) 

1295 sep_expiry = db.Column(db.Date, nullable=True) 

1296 first_solo_date = db.Column(db.Date, nullable=True) 

1297 ppl_issue_date = db.Column(db.Date, nullable=True) 

1298 

1299 user = db.relationship("User") 

1300 

1301 

1302# ── Personal minimums ────────────────────────────────────────────────────────── 

1303 

1304 

1305class PersonalMinimumsStatus: 

1306 DRAFT = "draft" 

1307 ACTIVE = "active" 

1308 SUPERSEDED = "superseded" 

1309 ALL: ClassVar[set[str]] = {DRAFT, ACTIVE, SUPERSEDED} 

1310 

1311 

1312class PersonalMinimumsTag: 

1313 """Small semantic vocabulary — binding an item to one of these lets the 

1314 app compute a recency nudge for it (the two MAX_DAYS_* tags only; the 

1315 other two are display-only in v1, see docs/backlog.md). Stored as a 

1316 plain string so new tags never need a migration.""" 

1317 

1318 MAX_DAYS_SINCE_LAST_FLIGHT = "max_days_since_last_flight" 

1319 MAX_DAYS_SINCE_INSTRUCTOR_FLIGHT = "max_days_since_instructor_flight" 

1320 MANOEUVRES_PRACTICE_INTERVAL_MONTHS = "manoeuvres_practice_interval_months" 

1321 MIN_FUEL_RESERVE_MINUTES = "min_fuel_reserve_minutes" 

1322 

1323 ALL: ClassVar[set[str]] = { 

1324 MAX_DAYS_SINCE_LAST_FLIGHT, 

1325 MAX_DAYS_SINCE_INSTRUCTOR_FLIGHT, 

1326 MANOEUVRES_PRACTICE_INTERVAL_MONTHS, 

1327 MIN_FUEL_RESERVE_MINUTES, 

1328 } 

1329 # Tags with an automatic recency check in v1 (see backlog decisions log). 

1330 HAS_RECENCY_CHECK: ClassVar[set[str]] = { 

1331 MAX_DAYS_SINCE_LAST_FLIGHT, 

1332 MAX_DAYS_SINCE_INSTRUCTOR_FLIGHT, 

1333 } 

1334 

1335 

1336class PersonalMinimumsRevision(db.Model): 

1337 """A pilot's personal minimums document, versioned: draft (editable) -> 

1338 active (published, immutable) -> superseded (immutable, kept for 

1339 history). At most one draft and one active per pilot at a time 

1340 (enforced in route logic, not a DB constraint — see pilots/routes.py).""" 

1341 

1342 __tablename__ = "personal_minimums_revisions" 

1343 __table_args__ = ( 

1344 db.UniqueConstraint( 

1345 "user_id", "revision_number", name="uq_personal_minimums_revision" 

1346 ), 

1347 ) 

1348 

1349 id = db.Column(db.Integer, primary_key=True) 

1350 user_id = db.Column( 

1351 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

1352 ) 

1353 revision_number = db.Column(db.Integer, nullable=False) 

1354 status = db.Column( 

1355 db.String(16), nullable=False, default=PersonalMinimumsStatus.DRAFT 

1356 ) 

1357 published_on = db.Column(db.Date, nullable=True) 

1358 experience_hours = db.Column(db.Numeric(6, 1), nullable=True) 

1359 experience_note = db.Column(db.String(128), nullable=True) 

1360 created_at = db.Column( 

1361 db.DateTime(timezone=True), 

1362 nullable=False, 

1363 default=lambda: datetime.now(UTC), 

1364 ) 

1365 updated_at = db.Column( 

1366 db.DateTime(timezone=True), 

1367 nullable=False, 

1368 default=lambda: datetime.now(UTC), 

1369 onupdate=lambda: datetime.now(UTC), 

1370 ) 

1371 

1372 user = db.relationship("User") 

1373 sections = db.relationship( 

1374 "PersonalMinimumsSection", 

1375 back_populates="revision", 

1376 cascade="all, delete-orphan", 

1377 order_by="PersonalMinimumsSection.sort_order", 

1378 ) 

1379 

1380 

1381class PersonalMinimumsSection(db.Model): 

1382 __tablename__ = "personal_minimums_sections" 

1383 

1384 id = db.Column(db.Integer, primary_key=True) 

1385 revision_id = db.Column( 

1386 db.Integer, 

1387 db.ForeignKey("personal_minimums_revisions.id", ondelete="CASCADE"), 

1388 nullable=False, 

1389 ) 

1390 title = db.Column(db.String(128), nullable=False) 

1391 sort_order = db.Column(db.Integer, nullable=False, default=0) 

1392 

1393 revision = db.relationship("PersonalMinimumsRevision", back_populates="sections") 

1394 items = db.relationship( 

1395 "PersonalMinimumsItem", 

1396 back_populates="section", 

1397 cascade="all, delete-orphan", 

1398 order_by="PersonalMinimumsItem.sort_order", 

1399 ) 

1400 

1401 

1402class PersonalMinimumsItem(db.Model): 

1403 __tablename__ = "personal_minimums_items" 

1404 

1405 id = db.Column(db.Integer, primary_key=True) 

1406 section_id = db.Column( 

1407 db.Integer, 

1408 db.ForeignKey("personal_minimums_sections.id", ondelete="CASCADE"), 

1409 nullable=False, 

1410 ) 

1411 label = db.Column(db.String(128), nullable=False) 

1412 value = db.Column(db.Text, nullable=True) 

1413 semantic_tag = db.Column( 

1414 db.String(64), nullable=True 

1415 ) # PersonalMinimumsTag constant 

1416 numeric_value = db.Column(db.Numeric(8, 2), nullable=True) 

1417 sort_order = db.Column(db.Integer, nullable=False, default=0) 

1418 

1419 section = db.relationship("PersonalMinimumsSection", back_populates="items") 

1420 

1421 

1422# ── Phase 28: Pilot Logbook Import ─────────────────────────────────────────── 

1423 

1424 

1425class LogbookImportMapping(db.Model): 

1426 __tablename__ = "logbook_import_mappings" 

1427 

1428 id = db.Column(db.Integer, primary_key=True) 

1429 pilot_user_id = db.Column( 

1430 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

1431 ) 

1432 source_fingerprint = db.Column(db.String(64), nullable=False, index=True) 

1433 # JSON: {norm_col_key: target_field_or_"ignore"} 

1434 column_mapping = db.Column(db.Text, nullable=False) 

1435 # JSON list of norm_col_keys — stored for fuzzy matching future uploads 

1436 source_columns = db.Column(db.Text, nullable=False) 

1437 created_at = db.Column(db.DateTime(timezone=True), nullable=False) 

1438 

1439 pilot = db.relationship("User") 

1440 

1441 

1442class LogbookImportBatch(db.Model): 

1443 __tablename__ = "logbook_import_batches" 

1444 

1445 id = db.Column(db.Integer, primary_key=True) 

1446 pilot_user_id = db.Column( 

1447 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

1448 ) 

1449 mapping_id = db.Column( 

1450 db.Integer, 

1451 db.ForeignKey("logbook_import_mappings.id", ondelete="SET NULL"), 

1452 nullable=True, 

1453 ) 

1454 source_filename = db.Column(db.String(256), nullable=False) 

1455 imported_at = db.Column(db.DateTime(timezone=True), nullable=False) 

1456 row_count = db.Column(db.Integer, nullable=False, default=0) 

1457 subtotal_count = db.Column(db.Integer, nullable=False, default=0) 

1458 skipped_count = db.Column(db.Integer, nullable=False, default=0) 

1459 has_opening_balance = db.Column(db.Boolean, nullable=False, default=False) 

1460 

1461 pilot = db.relationship("User") 

1462 mapping = db.relationship("LogbookImportMapping") 

1463 entries = db.relationship( 

1464 "Flight", 

1465 foreign_keys="Flight.import_batch_id", 

1466 lazy="dynamic", 

1467 overlaps="import_batch", 

1468 ) 

1469 

1470 

1471class AirframeImportMapping(db.Model): 

1472 """Fingerprint-keyed column mapping memory for airframe logbook imports 

1473 (the aircraft-record twin of LogbookImportMapping).""" 

1474 

1475 __tablename__ = "airframe_import_mappings" 

1476 

1477 id = db.Column(db.Integer, primary_key=True) 

1478 tenant_id = db.Column( 

1479 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

1480 ) 

1481 source_fingerprint = db.Column(db.String(64), nullable=False, index=True) 

1482 # JSON: {norm_col_key: target_field_or_"ignore"} 

1483 column_mapping = db.Column(db.Text, nullable=False) 

1484 # JSON list of norm_col_keys 

1485 source_columns = db.Column(db.Text, nullable=False) 

1486 created_at = db.Column(db.DateTime(timezone=True), nullable=False) 

1487 

1488 

1489class AirframeImportBatch(db.Model): 

1490 """One executed airframe logbook import for one aircraft, undoable as a unit.""" 

1491 

1492 __tablename__ = "airframe_import_batches" 

1493 

1494 id = db.Column(db.Integer, primary_key=True) 

1495 aircraft_id = db.Column( 

1496 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

1497 ) 

1498 mapping_id = db.Column( 

1499 db.Integer, 

1500 db.ForeignKey("airframe_import_mappings.id", ondelete="SET NULL"), 

1501 nullable=True, 

1502 ) 

1503 source_filename = db.Column(db.String(256), nullable=False) 

1504 imported_at = db.Column(db.DateTime(timezone=True), nullable=False) 

1505 row_count = db.Column(db.Integer, nullable=False, default=0) 

1506 subtotal_count = db.Column(db.Integer, nullable=False, default=0) 

1507 skipped_count = db.Column(db.Integer, nullable=False, default=0) 

1508 warning_count = db.Column(db.Integer, nullable=False, default=0) 

1509 has_opening_counters = db.Column(db.Boolean, nullable=False, default=False) 

1510 # One-time digitization of pre-existing paper records: rows may contain 

1511 # imprecision (OCR misreads, decimal/hours.minutes ambiguity) that must 

1512 # still be imported as-is, and must not later block edits to that 

1513 # imported flight. Regular/incremental catch-up imports leave this False 

1514 # so they stay subject to the normal strict validation on edit. 

1515 is_historical = db.Column(db.Boolean, nullable=False, default=False) 

1516 

1517 aircraft = db.relationship("Aircraft") 

1518 

1519 

1520# ── Phase 30: Aircraft GPS Log Import ──────────────────────────────────────── 

1521 

1522 

1523class AircraftGpsImportBatch(db.Model): 

1524 """Metadata for one GPS-import session (1+ files → 1+ Flight records).""" 

1525 

1526 __tablename__ = "aircraft_gps_import_batches" 

1527 

1528 id = db.Column(db.Integer, primary_key=True) 

1529 aircraft_id = db.Column( 

1530 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

1531 ) 

1532 pilot_user_id = db.Column( 

1533 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

1534 ) 

1535 # JSON list of original filenames, e.g. ["log_260518_EBNM.csv", "track.gpx"] 

1536 source_filenames = db.Column(db.JSON, nullable=False, default=list) 

1537 imported_at = db.Column( 

1538 db.DateTime(timezone=True), 

1539 nullable=False, 

1540 default=lambda: datetime.now(UTC), 

1541 ) 

1542 format_detected = db.Column( 

1543 db.String(16), nullable=False 

1544 ) # "gpx"|"kml"|"garmin_csv"|"mixed" 

1545 segments_found = db.Column(db.Integer, nullable=False, default=0) 

1546 segments_imported = db.Column(db.Integer, nullable=False, default=0) 

1547 # IDs of pre-existing Flight rows that received a GPS track (not created). 

1548 linked_flight_entry_ids = db.Column(db.JSON, nullable=False, default=list) 

1549 # Pilot role selected during import: 'pic' | 'dual' | 'none' 

1550 pilot_role = db.Column(db.String(8), nullable=True) 

1551 # Set when the import is for an aircraft not in this instance (Phase 31). 

1552 other_aircraft_make_model = db.Column(db.String(128), nullable=True) 

1553 other_aircraft_registration = db.Column(db.String(16), nullable=True) 

1554 

1555 aircraft = db.relationship( 

1556 "Aircraft", 

1557 backref=db.backref("gps_import_batches", cascade="all, delete-orphan"), 

1558 ) 

1559 pilot = db.relationship("User", foreign_keys=[pilot_user_id]) 

1560 # Merges the old FlightEntry.gps_import_batch_id and 

1561 # PilotLogbookEntry.gps_batch_id (two separate FKs to this same table) 

1562 # into one — a unified Flight row only ever needs one link back here. 

1563 flight_entries = db.relationship( 

1564 "Flight", 

1565 foreign_keys="Flight.gps_import_batch_id", 

1566 lazy="dynamic", 

1567 overlaps="gps_import_batch", 

1568 ) 

1569 

1570 

1571# ── Phase 4: Maintenance Tracking ──────────────────────────────────────────── 

1572 

1573 

1574class TriggerType: 

1575 CALENDAR = "calendar" # due on a specific date 

1576 HOURS = "hours" # due at a specific hobbs reading 

1577 LANDINGS = "landings" # due at a specific cumulative landing count 

1578 ALL: ClassVar[set[str]] = {CALENDAR, HOURS, LANDINGS} 

1579 

1580 

1581class HoursBasis: 

1582 """Which running total an HOURS-type MaintenanceTrigger's 

1583 due_engine_hours/interval_hours are measured against — engine/propeller 

1584 TBO-style items are naturally engine-hours based, while airframe life 

1585 limits and other flight-hours-quoted items need the aircraft's flight 

1586 (airborne) hours instead.""" 

1587 

1588 ENGINE = "engine" 

1589 FLIGHT = "flight" 

1590 ALL: ClassVar[set[str]] = {ENGINE, FLIGHT} 

1591 LABELS: ClassVar[dict[str, str]] = {ENGINE: "Engine hours", FLIGHT: "Flight hours"} 

1592 

1593 

1594class AmpCategory: 

1595 """The 9 additional-maintenance-requirement categories from EASA Form 

1596 AMP block 4 / Appendix B (AMC2 ML.A.302), used verbatim as 

1597 ``MaintenanceTrigger.category`` values so that block 4's Yes/No table 

1598 and Appendix B can be computed from the trigger set at export time 

1599 rather than stored separately (Phase 40). ``ALL`` preserves the 

1600 official form's own row order for rendering.""" 

1601 

1602 EQUIPMENT_AND_MODIFICATIONS = ( 

1603 "Maintenance due to specific equipment and modifications" 

1604 ) 

1605 REPAIRS = "Maintenance due to repairs" 

1606 LIFE_LIMITED_COMPONENTS = "Maintenance due to life-limited components" 

1607 MANDATORY_CONTINUING_AIRWORTHINESS = ( 

1608 "Maintenance due to mandatory continuing airworthiness information " 

1609 "(ALIs, CMRs, TCDS)" 

1610 ) 

1611 TBO_RECOMMENDATIONS = "Maintenance recommendations (TBO via SB/SL, non-mandatory)" 

1612 REPETITIVE_ADS = "Maintenance due to repetitive ADs" 

1613 OPERATIONAL_AIRSPACE_DIRECTIVES = ( 

1614 "Maintenance due to specific operational/airspace directives/requirements" 

1615 ) 

1616 TYPE_OF_OPERATION = "Maintenance due to type of operation or operational approvals" 

1617 OTHER = "Other" 

1618 

1619 ALL: ClassVar[list[str]] = [ 

1620 EQUIPMENT_AND_MODIFICATIONS, 

1621 REPAIRS, 

1622 LIFE_LIMITED_COMPONENTS, 

1623 MANDATORY_CONTINUING_AIRWORTHINESS, 

1624 TBO_RECOMMENDATIONS, 

1625 REPETITIVE_ADS, 

1626 OPERATIONAL_AIRSPACE_DIRECTIVES, 

1627 TYPE_OF_OPERATION, 

1628 OTHER, 

1629 ] 

1630 

1631 

1632class MaintenanceTrigger(db.Model): 

1633 __tablename__ = "maintenance_triggers" 

1634 

1635 id = db.Column(db.Integer, primary_key=True) 

1636 aircraft_id = db.Column( 

1637 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

1638 ) 

1639 # Phase 40: optional component scoping (engine, propeller, …). NULL means 

1640 # airframe-general. SET NULL on component deletion — removing a component 

1641 # shouldn't delete its maintenance history, just unscope the trigger. 

1642 component_id = db.Column( 

1643 db.Integer, db.ForeignKey("components.id", ondelete="SET NULL"), nullable=True 

1644 ) 

1645 name = db.Column(db.String(128), nullable=False) 

1646 trigger_type = db.Column(db.String(16), nullable=False) # TriggerType constant 

1647 

1648 # Calendar trigger fields 

1649 due_date = db.Column(db.Date, nullable=True) 

1650 interval_days = db.Column(db.Integer, nullable=True) # advance due_date on service 

1651 # Days before due_date to flag 'due_soon'. NULL falls back to a flat 

1652 # 30-day default (status()) — lets an admin override per trigger without 

1653 # requiring every existing row to be re-saved. 

1654 warn_days = db.Column(db.Integer, nullable=True) 

1655 

1656 # Hours trigger fields — despite the column name, due_engine_hours is 

1657 # measured against engine or flight hours depending on hours_basis. 

1658 due_engine_hours = db.Column(db.Numeric(8, 1), nullable=True) 

1659 interval_hours = db.Column( 

1660 db.Numeric(8, 1), nullable=True 

1661 ) # advance due_engine_hours on service 

1662 hours_basis = db.Column( 

1663 db.String(16), 

1664 nullable=False, 

1665 default=HoursBasis.ENGINE, 

1666 server_default=HoursBasis.ENGINE, 

1667 ) 

1668 # Hours before due_engine_hours to flag 'due_soon'. NULL falls back to 

1669 # max(interval_hours * 10%, 5.0), or a flat 10.0 with no interval set. 

1670 warn_hours = db.Column(db.Numeric(6, 1), nullable=True) 

1671 

1672 # Landings trigger fields 

1673 due_landings = db.Column(db.Integer, nullable=True) 

1674 interval_landings = db.Column( 

1675 db.Integer, nullable=True 

1676 ) # advance due_landings on service 

1677 # Landings before due_landings to flag 'due_soon'. NULL falls back to 

1678 # max(interval_landings * 10%, 5), or a flat 10 with no interval set. 

1679 warn_landings = db.Column(db.Integer, nullable=True) 

1680 

1681 notes = db.Column(db.Text, nullable=True) 

1682 

1683 # Phase 40: AMP import/export provenance and classification fields — all 

1684 # nullable free text/flags, orthogonal to the calendar/hours/landings due 

1685 # fields above. See docs/maintenance_import.md once written. 

1686 category = db.Column(db.String(128), nullable=True) # AmpCategory value, or NULL 

1687 is_alternative_to_ica = db.Column( 

1688 db.Boolean, nullable=False, default=False, server_default=db.false() 

1689 ) 

1690 alternative_task_notes = db.Column(db.Text, nullable=True) 

1691 reference = db.Column(db.String(255), nullable=True) # AD/SB/manual doc reference 

1692 action = db.Column(db.String(32), nullable=True) # e.g. INSPECTION/REPLACE/TBO/SLL 

1693 part_number = db.Column(db.String(64), nullable=True) 

1694 serial_number = db.Column(db.String(64), nullable=True) 

1695 # Set on import for rows with an unresolved/unparseable interval, so they 

1696 # read as "not yet scheduled" rather than silently evaluating as 'ok'. 

1697 needs_review = db.Column( 

1698 db.Boolean, nullable=False, default=False, server_default=db.false() 

1699 ) 

1700 import_batch_id = db.Column( 

1701 db.Integer, 

1702 db.ForeignKey("maintenance_import_batches.id", ondelete="SET NULL"), 

1703 nullable=True, 

1704 ) 

1705 

1706 created_at = db.Column( 

1707 db.DateTime(timezone=True), 

1708 nullable=False, 

1709 default=lambda: datetime.now(UTC), 

1710 ) 

1711 

1712 aircraft = db.relationship("Aircraft", back_populates="maintenance_triggers") 

1713 component = db.relationship("Component", back_populates="maintenance_triggers") 

1714 import_batch = db.relationship("MaintenanceImportBatch", back_populates="triggers") 

1715 records = db.relationship( 

1716 "MaintenanceRecord", 

1717 back_populates="trigger", 

1718 cascade="all, delete-orphan", 

1719 order_by="MaintenanceRecord.performed_at.desc()", 

1720 ) 

1721 

1722 __table_args__ = ( 

1723 db.Index("ix_maintenance_triggers_aircraft_id", aircraft_id), 

1724 db.Index("ix_maintenance_triggers_component_id", component_id), 

1725 db.Index("ix_maintenance_triggers_import_batch_id", import_batch_id), 

1726 ) 

1727 

1728 def status( 

1729 self, 

1730 current_engine_hours: "float | None" = None, 

1731 current_landings: "int | None" = None, 

1732 current_flight_hours: "float | None" = None, 

1733 ) -> str: 

1734 """Return 'overdue', 'due_soon', or 'ok'. 

1735 

1736 Evaluates every populated field group (calendar ``due_date``, hours 

1737 ``due_engine_hours``, landings ``due_landings``) independently and 

1738 returns the worst result. This is what lets a trigger represent a 

1739 "due at whichever comes first" combined interval (e.g. an AMP task 

1740 quoted as "100FH / 12MO") simply by having more than one field group 

1741 populated at once, with no separate ``trigger_type`` value or 

1742 duplicate rows needed. A trigger with only one field group populated 

1743 (the common case) behaves exactly as before this method started 

1744 evaluating groups independently. For the hours group, ``hours_basis`` 

1745 picks which of ``current_engine_hours``/``current_flight_hours`` is 

1746 compared against ``due_engine_hours``.""" 

1747 from datetime import date as _date 

1748 

1749 statuses: list[str] = [] 

1750 

1751 if self.due_date is not None: 

1752 delta = (self.due_date - _date.today()).days 

1753 if delta < 0: 

1754 statuses.append("overdue") 

1755 else: 

1756 warn_days = self.warn_days if self.warn_days is not None else 30 

1757 if delta <= warn_days: 

1758 statuses.append("due_soon") 

1759 

1760 if self.due_engine_hours is not None: 

1761 current_hobbs = ( 

1762 current_flight_hours 

1763 if self.hours_basis == HoursBasis.FLIGHT 

1764 else current_engine_hours 

1765 ) 

1766 if current_hobbs is not None: 

1767 remaining = float(self.due_engine_hours) - float(current_hobbs) 

1768 if remaining <= 0: 

1769 statuses.append("overdue") 

1770 else: 

1771 if self.warn_hours is not None: 

1772 warn_h = float(self.warn_hours) 

1773 else: 

1774 warn_h = ( 

1775 max(float(self.interval_hours) * 0.1, 5.0) 

1776 if self.interval_hours 

1777 else 10.0 

1778 ) 

1779 if remaining <= warn_h: 

1780 statuses.append("due_soon") 

1781 

1782 if self.due_landings is not None and current_landings is not None: 

1783 remaining_l = self.due_landings - current_landings 

1784 if remaining_l <= 0: 

1785 statuses.append("overdue") 

1786 else: 

1787 if self.warn_landings is not None: 

1788 warn_l = self.warn_landings 

1789 else: 

1790 warn_l = ( 

1791 max(int(self.interval_landings * 0.1), 5) 

1792 if self.interval_landings 

1793 else 10 

1794 ) 

1795 if remaining_l <= warn_l: 

1796 statuses.append("due_soon") 

1797 

1798 if "overdue" in statuses: 

1799 return "overdue" 

1800 if "due_soon" in statuses: 

1801 return "due_soon" 

1802 return "ok" 

1803 

1804 @property 

1805 def last_record(self): 

1806 return self.records[0] if self.records else None 

1807 

1808 @property 

1809 def service_basis(self) -> "dict[str, Any] | None": 

1810 """Raw data behind "how was this due value computed": the last 

1811 service's date (+ reading, if logged) and this trigger's configured 

1812 interval(s) — so a template can show the basis for a due figure 

1813 without drilling into service history. Returns plain values (not a 

1814 pre-formatted string) so the template can apply unit words via 

1815 gettext — this model layer has no i18n dependency by design. 

1816 None when there's no service history yet (nothing to summarize: an 

1817 initial/imported due value isn't derived from a last-done reading + 

1818 interval).""" 

1819 lr = self.last_record 

1820 if lr is None: 

1821 return None 

1822 return { 

1823 "date": lr.performed_at, 

1824 "hobbs": float(lr.hobbs_at_service) 

1825 if lr.hobbs_at_service is not None 

1826 else None, 

1827 "landings": lr.landings_at_service, 

1828 "interval_days": self.interval_days, 

1829 "interval_hours": float(self.interval_hours) 

1830 if self.interval_hours 

1831 else None, 

1832 "interval_landings": self.interval_landings, 

1833 } 

1834 

1835 

1836# ── Phase 6: Demo Mode ──────────────────────────────────────────────────────── 

1837 

1838 

1839class DemoSlot(db.Model): 

1840 """One isolated visitor slot in demo mode. Each slot is its own tenant+user pair.""" 

1841 

1842 __tablename__ = "demo_slots" 

1843 

1844 id = db.Column(db.Integer, primary_key=True) # slot number 1..N 

1845 display_id = db.Column(db.Integer, nullable=True) # random 1000-9999, shown in UI 

1846 tenant_id = db.Column( 

1847 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

1848 ) 

1849 user_id = db.Column( 

1850 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

1851 ) 

1852 renter_user_id = db.Column( 

1853 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=True 

1854 ) 

1855 maintenance_user_id = db.Column( 

1856 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=True 

1857 ) 

1858 viewer_user_id = db.Column( 

1859 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=True 

1860 ) 

1861 sole_pilot_user_id = db.Column( 

1862 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=True 

1863 ) 

1864 sole_operator_user_id = db.Column( 

1865 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=True 

1866 ) 

1867 # Phase 39h: shared-ownership sub-tenant (3 co-owner users on one shared 

1868 # aircraft) — tracked by tenant id rather than a single user id, since 

1869 # this sub-tenant has 3 users, not 1. SET NULL (not CASCADE): the demo 

1870 # wipe cycle deletes this tenant explicitly (cascading its co-owners' 

1871 # AircraftOwner/BillingAccount/etc. rows), it doesn't rely on the FK to 

1872 # do it. 

1873 shared_ownership_tenant_id = db.Column( 

1874 db.Integer, db.ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True 

1875 ) 

1876 # One representative co-owner (of the 3 seeded on shared_ownership_tenant_id) 

1877 # for the "Shared ownership" demo-role login button — mirrors the direct 

1878 # *_user_id columns above rather than looking one up via the tenant at 

1879 # login time. 

1880 shared_ownership_user_id = db.Column( 

1881 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=True 

1882 ) 

1883 last_activity_at = db.Column(db.DateTime(timezone=True), nullable=True) 

1884 

1885 

1886class MaintenanceRecord(db.Model): 

1887 __tablename__ = "maintenance_records" 

1888 

1889 id = db.Column(db.Integer, primary_key=True) 

1890 trigger_id = db.Column( 

1891 db.Integer, 

1892 db.ForeignKey("maintenance_triggers.id", ondelete="CASCADE"), 

1893 nullable=False, 

1894 ) 

1895 performed_at = db.Column(db.Date, nullable=False) 

1896 hobbs_at_service = db.Column(db.Numeric(8, 1), nullable=True) 

1897 landings_at_service = db.Column(db.Integer, nullable=True) 

1898 notes = db.Column(db.Text, nullable=True) 

1899 created_at = db.Column( 

1900 db.DateTime(timezone=True), 

1901 nullable=False, 

1902 default=lambda: datetime.now(UTC), 

1903 ) 

1904 

1905 trigger = db.relationship("MaintenanceTrigger", back_populates="records") 

1906 

1907 

1908# ── Phase 40: AMP Declaration Profile ──────────────────────────────────────── 

1909 

1910 

1911class AmpBasis: 

1912 """EASA Form AMP block 2 — what the programme is based on.""" 

1913 

1914 DAH_ICA = "dah_ica" 

1915 MIP = "mip" 

1916 ALL: ClassVar[set[str]] = {DAH_ICA, MIP} 

1917 

1918 

1919class AmpDeclarationType: 

1920 """EASA Form AMP block 7 — declaration by the owner, or approval by a 

1921 contracted CAMO/CAO.""" 

1922 

1923 OWNER = "owner" 

1924 CAMO_CAO = "camo_cao" 

1925 ALL: ClassVar[set[str]] = {OWNER, CAMO_CAO} 

1926 

1927 

1928class AmpCertifyingPartyKind: 

1929 """EASA Form AMP block 8 — who signs the certification statement.""" 

1930 

1931 OWNER_LESSEE_OPERATOR = "owner_lessee_operator" 

1932 CAMO_CAO = "camo_cao" 

1933 ALL: ClassVar[set[str]] = {OWNER_LESSEE_OPERATOR, CAMO_CAO} 

1934 

1935 

1936class AmpDeclaration(db.Model): 

1937 """One-to-one with Aircraft: the EASA Form AMP (AMC2 ML.A.302) fields 

1938 that aren't derivable from Aircraft/Component/MaintenanceTrigger. Block 

1939 4/5's Yes/No tables and Appendix B/C are computed from MaintenanceTrigger 

1940 at export time, not stored here — see docs/maintenance_import.md.""" 

1941 

1942 __tablename__ = "amp_declarations" 

1943 

1944 aircraft_id = db.Column( 

1945 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), primary_key=True 

1946 ) 

1947 

1948 # Block 1 — aircraft owner. Distinct from block 8's certifying party 

1949 # below: real AMPs can have a contracted CAMO/CAO (block 8) acting on 

1950 # behalf of a different aircraft owner (block 1) — e.g. a declaration 

1951 # by the owner themselves has these match, but a CAMO/CAO approval 

1952 # doesn't. Optional: when unset, export falls back to the certifying 

1953 # party (the common case where they're the same person/entity). 

1954 owner_name = db.Column(db.String(128), nullable=True) 

1955 owner_address = db.Column(db.Text, nullable=True) 

1956 

1957 # Block 2 — basis for the maintenance programme 

1958 basis = db.Column( 

1959 db.String(16), 

1960 nullable=False, 

1961 default=AmpBasis.DAH_ICA, 

1962 server_default=AmpBasis.DAH_ICA, 

1963 ) 

1964 mip_details = db.Column(db.Text, nullable=True) # Appendix A content, if basis=MIP 

1965 

1966 # Block 3a-3c — DAH ICA reference per equipment (manufacturer/type/model 

1967 # is already on Aircraft/Component; only the reference is stored here). 

1968 # Balloon fields 3d-3g are out of scope — no balloon support elsewhere. 

1969 dah_ica_airframe_ref = db.Column(db.String(255), nullable=True) 

1970 dah_ica_engine_ref = db.Column(db.String(255), nullable=True) 

1971 dah_ica_propeller_ref = db.Column(db.String(255), nullable=True) 

1972 

1973 # Block 6 — pilot-owner maintenance (ML.A.803) 

1974 pilot_owner_maintenance = db.Column( 

1975 db.Boolean, nullable=False, default=False, server_default=db.false() 

1976 ) 

1977 pilot_owner_name = db.Column(db.String(128), nullable=True) 

1978 pilot_owner_licence_number = db.Column(db.String(64), nullable=True) 

1979 

1980 # Block 7 — declaration/approval of the maintenance programme 

1981 declaration_type = db.Column( 

1982 db.String(16), 

1983 nullable=False, 

1984 default=AmpDeclarationType.OWNER, 

1985 server_default=AmpDeclarationType.OWNER, 

1986 ) 

1987 camo_cao_approval_reference = db.Column(db.String(128), nullable=True) 

1988 

1989 # Block 8 — certification statement 

1990 certifying_party_kind = db.Column( 

1991 db.String(24), 

1992 nullable=False, 

1993 default=AmpCertifyingPartyKind.OWNER_LESSEE_OPERATOR, 

1994 server_default=AmpCertifyingPartyKind.OWNER_LESSEE_OPERATOR, 

1995 ) 

1996 certifying_party_name = db.Column(db.String(128), nullable=True) 

1997 certifying_party_address = db.Column(db.Text, nullable=True) 

1998 certifying_party_phone = db.Column(db.String(32), nullable=True) 

1999 certifying_party_email = db.Column(db.String(128), nullable=True) 

2000 

2001 # Appendix D — optional free text. Block 10 (revision history) is a 

2002 # separate one-to-many AmpRevision table, not stored here — see below. 

2003 appendix_d_notes = db.Column(db.Text, nullable=True) 

2004 

2005 updated_at = db.Column( 

2006 db.DateTime(timezone=True), 

2007 nullable=False, 

2008 default=lambda: datetime.now(UTC), 

2009 onupdate=lambda: datetime.now(UTC), 

2010 ) 

2011 

2012 aircraft = db.relationship("Aircraft", back_populates="amp_declaration") 

2013 

2014 

2015class AmpRevision(db.Model): 

2016 """Block 10 — Revision control & periodic reviews of the Aircraft 

2017 Maintenance Programme. One-to-many with Aircraft (not AmpDeclaration) 

2018 so revision history survives even if the declaration profile is ever 

2019 deleted and recreated. Real shop-produced AMPs consistently carry 

2020 multiple rows here (e.g. "R00 initial release", "R01 <what changed>", 

2021 ...) — mirrors MaintenanceRecord's shape/relationship style.""" 

2022 

2023 __tablename__ = "amp_revisions" 

2024 

2025 id = db.Column(db.Integer, primary_key=True) 

2026 aircraft_id = db.Column( 

2027 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2028 ) 

2029 revision_number = db.Column(db.String(16), nullable=False) 

2030 revision_content = db.Column(db.Text, nullable=True) 

2031 revision_date = db.Column(db.Date, nullable=True) 

2032 # SHA-256 of the export HTML rendered at the moment this revision was 

2033 # added — the "what did the AMP look like when this revision was 

2034 # declared" snapshot. A later export whose freshly-rendered HTML hashes 

2035 # to something else means the AMP's data has drifted since this 

2036 # revision, so a download at that point is a draft, not this revision. 

2037 content_hash = db.Column(db.String(64), nullable=True) 

2038 # Path (relative to UPLOAD_FOLDER, mirrors Document.filename) of the 

2039 # canonical PDF for this revision — set the first time a download 

2040 # happens while content_hash still matches the live data, so this 

2041 # revision's exact bytes stay re-downloadable even after later edits. 

2042 # Never set at all if this revision was superseded before anyone ever 

2043 # downloaded it while current — there is no way to reconstruct that. 

2044 pdf_path = db.Column(db.String(512), nullable=True) 

2045 created_at = db.Column( 

2046 db.DateTime(timezone=True), 

2047 nullable=False, 

2048 default=lambda: datetime.now(UTC), 

2049 ) 

2050 

2051 aircraft = db.relationship("Aircraft", back_populates="amp_revisions") 

2052 

2053 __table_args__ = (db.Index("ix_amp_revisions_aircraft_id", "aircraft_id"),) 

2054 

2055 

2056class MaintenanceImportBatch(db.Model): 

2057 """One AMP task-list spreadsheet import — mirrors LogbookImportBatch 

2058 (Phase 28): links every MaintenanceTrigger it created so the whole 

2059 import can be reviewed or rolled back as a unit.""" 

2060 

2061 __tablename__ = "maintenance_import_batches" 

2062 

2063 id = db.Column(db.Integer, primary_key=True) 

2064 aircraft_id = db.Column( 

2065 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2066 ) 

2067 source_filename = db.Column(db.String(256), nullable=False) 

2068 imported_at = db.Column( 

2069 db.DateTime(timezone=True), 

2070 nullable=False, 

2071 default=lambda: datetime.now(UTC), 

2072 ) 

2073 row_count = db.Column(db.Integer, nullable=False, default=0) 

2074 needs_review_count = db.Column(db.Integer, nullable=False, default=0) 

2075 

2076 aircraft = db.relationship("Aircraft") 

2077 triggers = db.relationship( 

2078 "MaintenanceTrigger", 

2079 back_populates="import_batch", 

2080 foreign_keys="MaintenanceTrigger.import_batch_id", 

2081 lazy="dynamic", 

2082 ) 

2083 

2084 __table_args__ = ( 

2085 db.Index("ix_maintenance_import_batches_aircraft_id", aircraft_id), 

2086 ) 

2087 

2088 

2089# ── Phase 8: Cost Tracking ──────────────────────────────────────────────────── 

2090 

2091 

2092class ExpenseType: 

2093 FUEL = "fuel" 

2094 PARTS = "parts" 

2095 INSURANCE = "insurance" 

2096 OTHER = "other" 

2097 

2098 ALL: ClassVar[set[str]] = {FUEL, PARTS, INSURANCE, OTHER} 

2099 # N_()-marked: rendered through gettext() at the template/route layer, not here. 

2100 LABELS: ClassVar[dict[str, str]] = { 

2101 FUEL: N_("Fuel"), 

2102 PARTS: N_("Parts & Maintenance"), 

2103 INSURANCE: N_("Insurance"), 

2104 OTHER: N_("Other"), 

2105 } 

2106 

2107 

2108class ExpenseCategory: 

2109 """Phase 36: fixed costs (pro-rated by time) vs. operating costs (usage-based).""" 

2110 

2111 FIXED = "fixed" 

2112 OPERATING = "operating" 

2113 

2114 ALL: ClassVar[set[str]] = {FIXED, OPERATING} 

2115 LABELS: ClassVar[dict[str, str]] = { 

2116 FIXED: "Fixed", 

2117 OPERATING: "Operating", 

2118 } 

2119 # Default category per expense type; user may override on a per-entry basis. 

2120 DEFAULTS: ClassVar[dict[str, str]] = { 

2121 ExpenseType.FUEL: OPERATING, 

2122 ExpenseType.PARTS: OPERATING, 

2123 ExpenseType.INSURANCE: FIXED, 

2124 ExpenseType.OTHER: OPERATING, 

2125 } 

2126 

2127 

2128class ExpenseRecurrence: 

2129 """Recurring fixed costs: how often a template expense repeats.""" 

2130 

2131 MONTHLY = "monthly" 

2132 QUARTERLY = "quarterly" 

2133 YEARLY = "yearly" 

2134 

2135 MONTHS: ClassVar[dict[str, int]] = {MONTHLY: 1, QUARTERLY: 3, YEARLY: 12} 

2136 ALL: ClassVar[set[str]] = set(MONTHS) 

2137 

2138 

2139class Expense(db.Model): 

2140 __tablename__ = "expenses" 

2141 

2142 id = db.Column(db.Integer, primary_key=True) 

2143 aircraft_id = db.Column( 

2144 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2145 ) 

2146 flight_entry_id = db.Column( 

2147 db.Integer, 

2148 db.ForeignKey("flights.id", ondelete="SET NULL"), 

2149 nullable=True, 

2150 ) 

2151 date = db.Column(db.Date, nullable=False) 

2152 expense_type = db.Column(db.String(32), nullable=False, default=ExpenseType.OTHER) 

2153 expense_category = db.Column( 

2154 db.String(16), nullable=False, default=ExpenseCategory.OPERATING 

2155 ) 

2156 description = db.Column(db.String(255), nullable=True) 

2157 amount = db.Column(db.Numeric(10, 2), nullable=False) 

2158 currency = db.Column(db.String(4), nullable=False, default="EUR") 

2159 quantity = db.Column(db.Numeric(8, 2), nullable=True) # litres or gallons of fuel 

2160 unit = db.Column(db.String(8), nullable=True) # L, gal 

2161 # Phase 36: optional coverage span for fixed costs (e.g. an annual insurance 

2162 # premium), used to pro-rate the amount across a report period shorter than 

2163 # the coverage span. Left null, the expense counts in full on its `date`. 

2164 coverage_start = db.Column(db.Date, nullable=True) 

2165 coverage_end = db.Column(db.Date, nullable=True) 

2166 # Recurring fixed costs: a template expense carries `recurrence` 

2167 # (ExpenseRecurrence value, optionally bounded by recurrence_end); the 

2168 # daily pass materialises ordinary Expense rows linked back through 

2169 # recurring_template_id. recurrence_last_date is the materialiser's 

2170 # cursor — deleting a generated row must not resurrect it next run. 

2171 recurrence = db.Column(db.String(16), nullable=True) 

2172 recurrence_end = db.Column(db.Date, nullable=True) 

2173 recurrence_last_date = db.Column(db.Date, nullable=True) 

2174 recurring_template_id = db.Column( 

2175 db.Integer, db.ForeignKey("expenses.id", ondelete="SET NULL"), nullable=True 

2176 ) 

2177 created_at = db.Column( 

2178 db.DateTime(timezone=True), 

2179 nullable=False, 

2180 default=lambda: datetime.now(UTC), 

2181 ) 

2182 # Phase 37e: who recorded this expense — used to attribute a renter's own 

2183 # fuel-purchase expenses on a rental's linked flights for the fuel credit. 

2184 created_by_id = db.Column( 

2185 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2186 ) 

2187 

2188 aircraft = db.relationship("Aircraft", back_populates="expenses") 

2189 flight_entry = db.relationship("Flight", back_populates="expenses") 

2190 created_by = db.relationship("User", foreign_keys=[created_by_id]) 

2191 receipts = db.relationship( 

2192 "Document", 

2193 back_populates="expense", 

2194 cascade="all, delete-orphan", 

2195 ) 

2196 recurring_template = db.relationship( 

2197 "Expense", 

2198 foreign_keys=[recurring_template_id], 

2199 remote_side="Expense.id", 

2200 uselist=False, 

2201 ) 

2202 

2203 __table_args__ = (db.Index("ix_expenses_aircraft_id", aircraft_id),) 

2204 

2205 

2206# ── Fuel: standalone refuel record (backlog) ───────────────────────────────── 

2207 

2208 

2209class Refuel(db.Model): 

2210 """A refuel not bracketed by a flight (e.g. topping off while at the 

2211 airfield for maintenance). Mirrors Flight.fuel_added_*_qty/_unit but 

2212 carries no flight linkage — findable from the aircraft page. Can be 

2213 turned into a costed Expense the same way a flight's fuel-added figure 

2214 can (see expenses._fuel_expense_prefill): only the date and aircraft 

2215 matter for cost tracking, not whether the volume was logged here or 

2216 on a Flight row.""" 

2217 

2218 __tablename__ = "refuels" 

2219 

2220 id = db.Column(db.Integer, primary_key=True) 

2221 aircraft_id = db.Column( 

2222 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2223 ) 

2224 date = db.Column(db.Date, nullable=False) 

2225 quantity = db.Column(db.Numeric(8, 2), nullable=False) 

2226 unit = db.Column(db.String(8), nullable=False, default="L") # L, gal 

2227 note = db.Column(db.String(255), nullable=True) 

2228 created_at = db.Column( 

2229 db.DateTime(timezone=True), 

2230 nullable=False, 

2231 default=lambda: datetime.now(UTC), 

2232 ) 

2233 created_by_id = db.Column( 

2234 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2235 ) 

2236 

2237 aircraft = db.relationship("Aircraft", back_populates="refuels") 

2238 created_by = db.relationship("User", foreign_keys=[created_by_id]) 

2239 

2240 __table_args__ = (db.Index("ix_refuels_aircraft_id", aircraft_id),) 

2241 

2242 

2243# ── Fuel: per-tank capacity tracking (backlog) ─────────────────────────────── 

2244 

2245 

2246class AircraftFuelTank(db.Model): 

2247 """One independently-tracked fuel tank on an aircraft (e.g. left/right 

2248 wing, or a main + aux tank). An aircraft with a single combined tank 

2249 still needs exactly one row here (name it e.g. "Main") — there is no 

2250 separate scalar-capacity fallback.""" 

2251 

2252 __tablename__ = "aircraft_fuel_tanks" 

2253 

2254 id = db.Column(db.Integer, primary_key=True) 

2255 aircraft_id = db.Column( 

2256 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2257 ) 

2258 name = db.Column(db.String(64), nullable=False) 

2259 capacity_liters = db.Column(db.Numeric(6, 1), nullable=False) 

2260 sort_order = db.Column(db.Integer, nullable=False, default=0) 

2261 

2262 aircraft = db.relationship("Aircraft", back_populates="fuel_tanks") 

2263 

2264 __table_args__ = (db.Index("ix_aircraft_fuel_tanks_aircraft_id", aircraft_id),) 

2265 

2266 

2267# ── Phase 9 / 27: Document & Photo Uploads ─────────────────────────────────── 

2268 

2269 

2270class DocType: 

2271 LICENSE = "license" 

2272 MEDICAL = "medical" 

2273 INSURANCE_CERT = "insurance_certificate" 

2274 ARC = "arc_certificate" 

2275 

2276 

2277class DocCategory: 

2278 """Broad document categories that map 1-to-1 to on-disk folder names. 

2279 

2280 Used by the Syncthing-compatible canonical path layout: 

2281 {tenant_slug}/{aircraft_reg}/{category}/{YYYY-MM-DD} - {title}.{ext} 

2282 """ 

2283 

2284 MAINTENANCE = "maintenance" 

2285 INSURANCE = "insurance" 

2286 POH = "poh" 

2287 AIRWORTHINESS = "airworthiness" 

2288 LOGBOOK = "logbook" 

2289 INVOICE = "invoice" 

2290 OTHER = "other" 

2291 UNCATEGORISED = "uncategorised" 

2292 

2293 ALL: ClassVar[list[str]] = [ 

2294 MAINTENANCE, 

2295 INSURANCE, 

2296 POH, 

2297 AIRWORTHINESS, 

2298 LOGBOOK, 

2299 INVOICE, 

2300 OTHER, 

2301 UNCATEGORISED, 

2302 ] 

2303 

2304 

2305class Document(db.Model): 

2306 """ 

2307 A document or photo attached to an aircraft, component, flight entry, or 

2308 pilot profile. aircraft_id or pilot_user_id must be set (not both). 

2309 """ 

2310 

2311 __tablename__ = "documents" 

2312 

2313 id = db.Column(db.Integer, primary_key=True) 

2314 aircraft_id = db.Column( 

2315 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=True 

2316 ) 

2317 pilot_user_id = db.Column( 

2318 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=True 

2319 ) 

2320 component_id = db.Column( 

2321 db.Integer, db.ForeignKey("components.id", ondelete="CASCADE"), nullable=True 

2322 ) 

2323 flight_entry_id = db.Column( 

2324 db.Integer, 

2325 db.ForeignKey("flights.id", ondelete="CASCADE"), 

2326 nullable=True, 

2327 ) 

2328 # Receipt/invoice attached to an expense; such documents also carry 

2329 # aircraft_id so access control and the aircraft document list apply. 

2330 expense_id = db.Column( 

2331 db.Integer, db.ForeignKey("expenses.id", ondelete="CASCADE"), nullable=True 

2332 ) 

2333 # Signed rental agreement attached to a RenterAuthorization (Phase 37c). 

2334 # Carries no aircraft_id — visible to the renter concerned and is_owner 

2335 # users, not to the aircraft document list. 

2336 renter_authorization_id = db.Column( 

2337 db.Integer, 

2338 db.ForeignKey("renter_authorizations.id", ondelete="CASCADE"), 

2339 nullable=True, 

2340 ) 

2341 filename = db.Column( 

2342 db.String(512), nullable=False 

2343 ) # stored path on disk (may include subdirectories) 

2344 original_filename = db.Column(db.String(255), nullable=False) # as uploaded 

2345 mime_type = db.Column(db.String(128), nullable=True) 

2346 size_bytes = db.Column(db.Integer, nullable=True) 

2347 title = db.Column(db.String(128), nullable=True) # optional display name 

2348 doc_type = db.Column(db.String(32), nullable=True) # DocType constant 

2349 category = db.Column( 

2350 db.String(32), nullable=True 

2351 ) # DocCategory value; drives on-disk folder 

2352 # NULL means "in force as soon as uploaded" (the original behaviour, 

2353 # before valid_from existed) -- only set it to model a document that 

2354 # shouldn't become the active one until a future date, e.g. next 

2355 # quarter's insurance cert uploaded ahead of time. See 

2356 # documents/routes.py active_document_for(). 

2357 valid_from = db.Column(db.Date, nullable=True) 

2358 valid_until = db.Column(db.Date, nullable=True) 

2359 superseded_by_id = db.Column( 

2360 db.Integer, db.ForeignKey("documents.id", ondelete="SET NULL"), nullable=True 

2361 ) 

2362 is_sensitive = db.Column(db.Boolean, nullable=False, default=False) 

2363 uploaded_at = db.Column( 

2364 db.DateTime(timezone=True), 

2365 nullable=False, 

2366 default=lambda: datetime.now(UTC), 

2367 ) 

2368 

2369 aircraft = db.relationship( 

2370 "Aircraft", 

2371 back_populates="documents", 

2372 foreign_keys=[aircraft_id], 

2373 ) 

2374 pilot_user = db.relationship("User", foreign_keys=[pilot_user_id]) 

2375 component = db.relationship("Component", back_populates="documents") 

2376 flight_entry = db.relationship("Flight", back_populates="documents") 

2377 expense = db.relationship("Expense", back_populates="receipts") 

2378 renter_authorization = db.relationship( 

2379 "RenterAuthorization", back_populates="agreement_documents" 

2380 ) 

2381 superseded_by = db.relationship( 

2382 "Document", 

2383 foreign_keys=[superseded_by_id], 

2384 remote_side="Document.id", 

2385 uselist=False, 

2386 ) 

2387 

2388 __table_args__ = ( 

2389 db.Index("ix_documents_aircraft_id", aircraft_id), 

2390 db.Index("ix_documents_pilot_user_id", pilot_user_id), 

2391 db.Index("ix_documents_component_id", component_id), 

2392 db.Index("ix_documents_flight_entry_id", flight_entry_id), 

2393 db.Index("ix_documents_expense_id", expense_id), 

2394 ) 

2395 

2396 @property 

2397 def owner_type(self) -> str: 

2398 if self.pilot_user_id: 

2399 return "pilot" 

2400 if self.component_id: 

2401 return "component" 

2402 if self.flight_entry_id: 

2403 return "entry" 

2404 if self.expense_id: 

2405 return "expense" 

2406 return "aircraft" 

2407 

2408 @property 

2409 def is_image(self) -> bool: 

2410 return bool(self.mime_type and self.mime_type.startswith("image/")) 

2411 

2412 @property 

2413 def is_pdf(self) -> bool: 

2414 return self.mime_type == "application/pdf" 

2415 

2416 @property 

2417 def is_expiring_soon(self) -> bool: 

2418 """True when valid_until is set and within 90 days from today.""" 

2419 from datetime import date as _date 

2420 

2421 if self.valid_until is None: 

2422 return False 

2423 return (self.valid_until - _date.today()).days <= 90 

2424 

2425 @property 

2426 def is_upcoming(self) -> bool: 

2427 """True when valid_from is set and still in the future -- this 

2428 document is on file but not yet in force.""" 

2429 from datetime import date as _date 

2430 

2431 if self.valid_from is None: 

2432 return False 

2433 return self.valid_from > _date.today() 

2434 

2435 

2436# ── Syncthing reconcile queue ───────────────────────────────────────────────── 

2437 

2438 

2439class PendingReconcile(db.Model): 

2440 """Files found on disk (via Syncthing or manual copy) that are not yet 

2441 tracked in the documents table. The reconcile screen lets owners review 

2442 these files and import them as Document rows with a single click. 

2443 

2444 filepath is relative to UPLOAD_FOLDER (e.g. 'my-hangar/OO-PNH/maintenance/ 

2445 2024-03-15 - Annual inspection.pdf'). The unique constraint prevents the 

2446 same file from appearing twice in the queue. 

2447 """ 

2448 

2449 __tablename__ = "pending_reconcile" 

2450 

2451 id = db.Column(db.Integer, primary_key=True) 

2452 tenant_id = db.Column( 

2453 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

2454 ) 

2455 aircraft_id = db.Column( 

2456 db.Integer, db.ForeignKey("aircraft.id", ondelete="SET NULL"), nullable=True 

2457 ) 

2458 filepath = db.Column(db.String(512), nullable=False, unique=True) 

2459 category = db.Column(db.String(32), nullable=True) 

2460 title_hint = db.Column(db.String(255), nullable=True) 

2461 date_hint = db.Column(db.Date, nullable=True) 

2462 detected_at = db.Column( 

2463 db.DateTime(timezone=True), 

2464 nullable=False, 

2465 default=lambda: datetime.now(UTC), 

2466 ) 

2467 reconciled_at = db.Column(db.DateTime(timezone=True), nullable=True) 

2468 ignored = db.Column(db.Boolean, nullable=False, default=False) 

2469 

2470 tenant = db.relationship("Tenant") 

2471 aircraft = db.relationship("Aircraft") 

2472 

2473 

2474# ── Phase 10: Backup & Restore ──────────────────────────────────────────────── 

2475 

2476 

2477class BackupRecord(db.Model): 

2478 __tablename__ = "backup_records" 

2479 

2480 id = db.Column(db.Integer, primary_key=True) 

2481 filename = db.Column(db.String(255), nullable=False) 

2482 path = db.Column(db.String(512), nullable=False) 

2483 size_bytes = db.Column(db.Integer, nullable=True) 

2484 sha256 = db.Column(db.String(64), nullable=True) 

2485 created_at = db.Column( 

2486 db.DateTime(timezone=True), 

2487 nullable=False, 

2488 default=lambda: datetime.now(UTC), 

2489 ) 

2490 status = db.Column(db.String(32), nullable=False, default="ok") # ok / failed 

2491 app_version = db.Column(db.String(64), nullable=True) 

2492 alembic_head = db.Column(db.String(64), nullable=True) 

2493 

2494 

2495# ── Phase 11: Read-only Share Links ────────────────────────────────────────── 

2496 

2497 

2498class ShareToken(db.Model): 

2499 __tablename__ = "share_tokens" 

2500 

2501 id = db.Column(db.Integer, primary_key=True) 

2502 aircraft_id = db.Column( 

2503 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2504 ) 

2505 token = db.Column(db.String(16), unique=True, nullable=False, index=True) 

2506 access_level = db.Column( 

2507 db.String(16), nullable=False, default="summary" 

2508 ) # summary / full 

2509 created_at = db.Column( 

2510 db.DateTime(timezone=True), 

2511 nullable=False, 

2512 default=lambda: datetime.now(UTC), 

2513 ) 

2514 revoked_at = db.Column(db.DateTime(timezone=True), nullable=True, default=None) 

2515 

2516 aircraft = db.relationship("Aircraft", back_populates="share_tokens") 

2517 

2518 @property 

2519 def is_active(self) -> bool: 

2520 return self.revoked_at is None 

2521 

2522 

2523# ── Phase 12: Snag List ─────────────────────────────────────────────────────── 

2524 

2525 

2526class Snag(db.Model): 

2527 __tablename__ = "snags" 

2528 

2529 id = db.Column(db.Integer, primary_key=True) 

2530 aircraft_id = db.Column( 

2531 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2532 ) 

2533 title = db.Column(db.String(128), nullable=False) 

2534 description = db.Column(db.Text, nullable=True) 

2535 reporter = db.Column(db.String(128), nullable=True) 

2536 is_grounding = db.Column(db.Boolean, nullable=False, default=False) 

2537 reported_at = db.Column( 

2538 db.DateTime(timezone=True), 

2539 nullable=False, 

2540 default=lambda: datetime.now(UTC), 

2541 ) 

2542 resolved_at = db.Column(db.DateTime(timezone=True), nullable=True, default=None) 

2543 resolution_note = db.Column(db.Text, nullable=True) 

2544 

2545 aircraft = db.relationship("Aircraft", back_populates="snags") 

2546 

2547 __table_args__ = (db.Index("ix_snags_aircraft_id", aircraft_id),) 

2548 

2549 @property 

2550 def is_open(self) -> bool: 

2551 return self.resolved_at is None 

2552 

2553 

2554# ── Phase 22: Reservations ─────────────────────────────────────────────────── 

2555 

2556 

2557class ReservationStatus(str, enum.Enum): 

2558 PENDING = "pending" 

2559 CONFIRMED = "confirmed" 

2560 CANCELLED = "cancelled" 

2561 

2562 

2563class Reservation(db.Model): 

2564 __tablename__ = "reservations" 

2565 

2566 id = db.Column(db.Integer, primary_key=True) 

2567 aircraft_id = db.Column( 

2568 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2569 ) 

2570 pilot_user_id = db.Column( 

2571 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2572 ) 

2573 start_dt = db.Column(db.DateTime(timezone=True), nullable=False) 

2574 end_dt = db.Column(db.DateTime(timezone=True), nullable=False) 

2575 status = db.Column( 

2576 db.Enum(ReservationStatus), nullable=False, default=ReservationStatus.PENDING 

2577 ) 

2578 notes = db.Column(db.Text, nullable=True) 

2579 hourly_rate = db.Column(db.Numeric(8, 2), nullable=True) # EUR/h snapshot 

2580 estimated_cost = db.Column(db.Numeric(10, 2), nullable=True) 

2581 created_at = db.Column( 

2582 db.DateTime(timezone=True), 

2583 nullable=False, 

2584 default=lambda: datetime.now(UTC), 

2585 ) 

2586 

2587 aircraft = db.relationship("Aircraft", back_populates="reservations") 

2588 pilot = db.relationship("User", foreign_keys=[pilot_user_id]) 

2589 flights = db.relationship("Flight", back_populates="reservation") 

2590 dispatch = db.relationship( 

2591 "DispatchRecord", back_populates="reservation", uselist=False 

2592 ) 

2593 rental_charge = db.relationship( 

2594 "RentalCharge", back_populates="reservation", uselist=False 

2595 ) 

2596 

2597 __table_args__ = (db.Index("ix_reservations_aircraft_id", aircraft_id),) 

2598 

2599 @property 

2600 def duration_hours(self) -> float: 

2601 delta = self.end_dt - self.start_dt 

2602 return round(delta.total_seconds() / 3600, 2) 

2603 

2604 

2605class DispatchRecord(db.Model): 

2606 """Phase 37d: check-out / check-in record for a rental reservation. One 

2607 row per reservation (unique constraint) — created at check-out, updated 

2608 at check-in.""" 

2609 

2610 __tablename__ = "dispatch_records" 

2611 

2612 id = db.Column(db.Integer, primary_key=True) 

2613 reservation_id = db.Column( 

2614 db.Integer, 

2615 db.ForeignKey("reservations.id", ondelete="CASCADE"), 

2616 nullable=False, 

2617 unique=True, 

2618 ) 

2619 # Check-out 

2620 out_at = db.Column(db.DateTime(timezone=True), nullable=True) 

2621 out_by_id = db.Column( 

2622 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2623 ) 

2624 out_engine_counter = db.Column(db.Numeric(8, 1), nullable=True) 

2625 out_flight_counter = db.Column(db.Numeric(8, 1), nullable=True) 

2626 out_fuel_state = db.Column(db.String(64), nullable=True) 

2627 out_walkaround_ok = db.Column(db.Boolean, nullable=False, default=False) 

2628 out_snags_acknowledged = db.Column(db.Boolean, nullable=False, default=False) 

2629 # is_owner explicitly dispatched despite a grounding snag — auditability. 

2630 out_grounded_override = db.Column(db.Boolean, nullable=False, default=False) 

2631 # Check-in 

2632 in_at = db.Column(db.DateTime(timezone=True), nullable=True) 

2633 in_by_id = db.Column( 

2634 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2635 ) 

2636 in_engine_counter = db.Column(db.Numeric(8, 1), nullable=True) 

2637 in_flight_counter = db.Column(db.Numeric(8, 1), nullable=True) 

2638 in_fuel_state = db.Column(db.String(64), nullable=True) 

2639 in_notes = db.Column(db.Text, nullable=True) 

2640 

2641 reservation = db.relationship("Reservation", back_populates="dispatch") 

2642 out_by = db.relationship("User", foreign_keys=[out_by_id]) 

2643 in_by = db.relationship("User", foreign_keys=[in_by_id]) 

2644 

2645 @property 

2646 def is_checked_out(self) -> bool: 

2647 return self.out_at is not None 

2648 

2649 @property 

2650 def is_checked_in(self) -> bool: 

2651 return self.in_at is not None 

2652 

2653 

2654class RentalChargeStatus: 

2655 DRAFT = "draft" 

2656 FINAL = "final" 

2657 

2658 ALL: ClassVar[set[str]] = {DRAFT, FINAL} 

2659 

2660 

2661class RentalCharge(db.Model): 

2662 """Phase 37e: rental charge for one reservation. Drafted automatically at 

2663 check-in (see reservations.routes.checkin); the owner reviews and 

2664 finalizes it, which posts one CHARGE to the billing ledger (Phase 37a). 

2665 A finalized charge is immutable — corrections go through 

2666 BillingService.reverse plus a new adjustment, never an edit.""" 

2667 

2668 __tablename__ = "rental_charges" 

2669 

2670 id = db.Column(db.Integer, primary_key=True) 

2671 reservation_id = db.Column( 

2672 db.Integer, 

2673 db.ForeignKey("reservations.id", ondelete="CASCADE"), 

2674 nullable=False, 

2675 unique=True, 

2676 ) 

2677 renter_user_id = db.Column( 

2678 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

2679 ) 

2680 status = db.Column(db.String(12), nullable=False, default=RentalChargeStatus.DRAFT) 

2681 billable_hours = db.Column(db.Numeric(6, 1), nullable=False) 

2682 hourly_rate = db.Column(db.Numeric(8, 2), nullable=False) # snapshot 

2683 rate_type = db.Column(db.String(8), nullable=False) # snapshot 

2684 fuel_credit = db.Column( 

2685 db.Numeric(10, 2), nullable=False, default=0 

2686 ) # positive, subtracted 

2687 adjustment = db.Column(db.Numeric(10, 2), nullable=False, default=0) # signed 

2688 adjustment_note = db.Column(db.String(255), nullable=True) 

2689 # True when the drafting logic fell back to the non-preferred counter 

2690 # (the rate_basis one was left blank at dispatch) — shown as a note on 

2691 # the draft review, not a data-quality error. 

2692 fallback_counter_used = db.Column(db.Boolean, nullable=False, default=False) 

2693 total = db.Column( 

2694 db.Numeric(10, 2), nullable=False 

2695 ) # hours*rate - credit + adjustment 

2696 finalized_at = db.Column(db.DateTime(timezone=True), nullable=True) 

2697 finalized_by_id = db.Column( 

2698 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2699 ) 

2700 created_at = db.Column( 

2701 db.DateTime(timezone=True), 

2702 nullable=False, 

2703 default=lambda: datetime.now(UTC), 

2704 ) 

2705 

2706 reservation = db.relationship("Reservation", back_populates="rental_charge") 

2707 renter_user = db.relationship("User", foreign_keys=[renter_user_id]) 

2708 finalized_by = db.relationship("User", foreign_keys=[finalized_by_id]) 

2709 

2710 @property 

2711 def is_final(self) -> bool: 

2712 return self.status == RentalChargeStatus.FINAL 

2713 

2714 

2715class MaintenanceDowntime(db.Model): 

2716 """Phase 37f: owner-entered planned unavailability window (e.g. a shop 

2717 appointment). Behaves like a confirmed reservation in conflict 

2718 detection and is rendered on the booking calendar in a distinct style. 

2719 Downtime is scheduling; a grounding snag is airworthiness — related but 

2720 separate records.""" 

2721 

2722 __tablename__ = "maintenance_downtimes" 

2723 

2724 id = db.Column(db.Integer, primary_key=True) 

2725 aircraft_id = db.Column( 

2726 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

2727 ) 

2728 start_dt = db.Column(db.DateTime(timezone=True), nullable=False) 

2729 end_dt = db.Column(db.DateTime(timezone=True), nullable=False) 

2730 reason = db.Column(db.String(255), nullable=True) 

2731 created_by_id = db.Column( 

2732 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2733 ) 

2734 created_at = db.Column( 

2735 db.DateTime(timezone=True), 

2736 nullable=False, 

2737 default=lambda: datetime.now(UTC), 

2738 ) 

2739 

2740 aircraft = db.relationship("Aircraft", back_populates="maintenance_downtimes") 

2741 created_by = db.relationship("User", foreign_keys=[created_by_id]) 

2742 

2743 

2744class RateBasis: 

2745 """Phase 37b: which counter delta a rental charge is billed against.""" 

2746 

2747 ENGINE_TIME = "engine_time" 

2748 FLIGHT_TIME = "flight_time" 

2749 

2750 ALL: ClassVar[set[str]] = {ENGINE_TIME, FLIGHT_TIME} 

2751 LABELS: ClassVar[dict[str, str]] = { 

2752 ENGINE_TIME: "Engine time", 

2753 FLIGHT_TIME: "Flight time", 

2754 } 

2755 

2756 

2757class RateType: 

2758 """Phase 37b: wet (fuel included) vs. dry (fuel billed separately).""" 

2759 

2760 WET = "wet" 

2761 DRY = "dry" 

2762 

2763 ALL: ClassVar[set[str]] = {WET, DRY} 

2764 LABELS: ClassVar[dict[str, str]] = { 

2765 WET: "Wet", 

2766 DRY: "Dry", 

2767 } 

2768 

2769 

2770class AircraftBookingSettings(db.Model): 

2771 """Per-aircraft booking rules and hourly rate for cost estimation.""" 

2772 

2773 __tablename__ = "aircraft_booking_settings" 

2774 

2775 aircraft_id = db.Column( 

2776 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), primary_key=True 

2777 ) 

2778 min_booking_hours = db.Column(db.Numeric(4, 1), nullable=True) 

2779 max_booking_hours = db.Column(db.Numeric(4, 1), nullable=True) 

2780 hourly_rate = db.Column(db.Numeric(8, 2), nullable=True) # EUR/h 

2781 rate_basis = db.Column(db.String(16), nullable=False, default=RateBasis.ENGINE_TIME) 

2782 rate_type = db.Column(db.String(8), nullable=False, default=RateType.WET) 

2783 min_hours_per_day = db.Column(db.Numeric(4, 1), nullable=True) 

2784 

2785 aircraft = db.relationship("Aircraft", back_populates="booking_settings") 

2786 

2787 

2788class RenterAuthorization(db.Model): 

2789 """Phase 37c: owner-verified rental qualification for one renter, scoped 

2790 to one aircraft or the whole fleet (aircraft_id is NULL). 

2791 

2792 These are owner-entered verification facts — deliberately NOT automatic 

2793 reads of the renter's private PilotProfile. 

2794 """ 

2795 

2796 __tablename__ = "renter_authorizations" 

2797 __table_args__ = ( 

2798 db.Index("ix_renter_authorizations_tenant_id", "tenant_id"), 

2799 db.Index("ix_renter_authorizations_renter_user_id", "renter_user_id"), 

2800 ) 

2801 

2802 id = db.Column(db.Integer, primary_key=True) 

2803 tenant_id = db.Column( 

2804 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

2805 ) 

2806 renter_user_id = db.Column( 

2807 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

2808 ) 

2809 aircraft_id = db.Column( 

2810 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=True 

2811 ) # NULL = whole fleet 

2812 authorized_by_id = db.Column( 

2813 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

2814 ) 

2815 granted_on = db.Column(db.Date, nullable=False) 

2816 expires_on = db.Column(db.Date, nullable=True) # NULL = does not expire 

2817 checkout_flight_on = db.Column(db.Date, nullable=True) 

2818 licence_seen_on = db.Column(db.Date, nullable=True) 

2819 medical_valid_until = db.Column(db.Date, nullable=True) # owner-entered 

2820 notes = db.Column(db.Text, nullable=True) 

2821 revoked_at = db.Column(db.DateTime(timezone=True), nullable=True) 

2822 created_at = db.Column( 

2823 db.DateTime(timezone=True), 

2824 nullable=False, 

2825 default=lambda: datetime.now(UTC), 

2826 ) 

2827 

2828 tenant = db.relationship("Tenant") 

2829 renter_user = db.relationship("User", foreign_keys=[renter_user_id]) 

2830 authorized_by = db.relationship("User", foreign_keys=[authorized_by_id]) 

2831 aircraft = db.relationship("Aircraft") 

2832 agreement_documents = db.relationship( 

2833 "Document", back_populates="renter_authorization" 

2834 ) 

2835 

2836 @property 

2837 def is_valid(self) -> bool: 

2838 from datetime import date as _date 

2839 

2840 if self.revoked_at is not None: 

2841 return False 

2842 today = _date.today() 

2843 if self.expires_on is not None and self.expires_on < today: 

2844 return False 

2845 return not ( 

2846 self.medical_valid_until is not None and self.medical_valid_until < today 

2847 ) 

2848 

2849 @staticmethod 

2850 def valid_for( 

2851 renter_user_id: int, aircraft_id: int 

2852 ) -> "RenterAuthorization | None": 

2853 """Return a valid authorization covering this renter+aircraft — a 

2854 fleet-wide row (aircraft_id IS NULL) or a per-aircraft row — or None.""" 

2855 candidates = RenterAuthorization.query.filter( 

2856 RenterAuthorization.renter_user_id == renter_user_id, 

2857 RenterAuthorization.revoked_at.is_(None), 

2858 db.or_( 

2859 RenterAuthorization.aircraft_id.is_(None), 

2860 RenterAuthorization.aircraft_id == aircraft_id, 

2861 ), 

2862 ).all() 

2863 return next((c for c in candidates if c.is_valid), None) 

2864 

2865 

2866# ── Phase 20: Mass & Balance ────────────────────────────────────────────────── 

2867 

2868FUEL_DENSITY = { 

2869 "avgas": 0.72, # Avgas 100LL 

2870 "ul91": 0.72, # UL91 — unleaded avgas replacement 

2871 "mogas": 0.74, # Automotive gasoline (Mogas) 

2872 "jet_a1": 0.81, # Jet-A1 (kerosene) 

2873} # kg/L 

2874GAL_TO_L = 3.78541 # US gallons to litres 

2875 

2876 

2877class WeightBalanceConfig(db.Model): 

2878 __tablename__ = "wb_configs" 

2879 

2880 id = db.Column(db.Integer, primary_key=True) 

2881 aircraft_id = db.Column( 

2882 db.Integer, 

2883 db.ForeignKey("aircraft.id", ondelete="CASCADE"), 

2884 nullable=False, 

2885 unique=True, 

2886 ) 

2887 empty_weight = db.Column(db.Numeric(7, 2), nullable=False) # kg 

2888 empty_cg_arm = db.Column(db.Numeric(7, 2), nullable=False) # m from datum 

2889 max_takeoff_weight = db.Column(db.Numeric(7, 2), nullable=False) # kg 

2890 forward_cg_limit = db.Column(db.Numeric(7, 2), nullable=False) # m 

2891 aft_cg_limit = db.Column(db.Numeric(7, 2), nullable=False) # m 

2892 fuel_unit = db.Column(db.String(3), nullable=False, default="L") # "L" or "gal" 

2893 # Optional non-rectangular envelope: list of [arm_m, weight_kg] pairs in polygon order. 

2894 # When ≥ 3 points are present they override forward_cg_limit/aft_cg_limit/max_takeoff_weight 

2895 # for the in-envelope check. 

2896 envelope_points = db.Column(db.JSON, nullable=True) 

2897 datum_note = db.Column(db.String(200), nullable=True) 

2898 

2899 aircraft = db.relationship("Aircraft", back_populates="wb_config") 

2900 stations = db.relationship( 

2901 "WeightBalanceStation", 

2902 back_populates="config", 

2903 cascade="all, delete-orphan", 

2904 order_by="WeightBalanceStation.position", 

2905 ) 

2906 entries = db.relationship( 

2907 "WeightBalanceEntry", 

2908 back_populates="config", 

2909 cascade="all, delete-orphan", 

2910 ) 

2911 

2912 

2913class WeightBalanceStation(db.Model): 

2914 __tablename__ = "wb_stations" 

2915 

2916 id = db.Column(db.Integer, primary_key=True) 

2917 config_id = db.Column( 

2918 db.Integer, db.ForeignKey("wb_configs.id", ondelete="CASCADE"), nullable=False 

2919 ) 

2920 label = db.Column(db.String(64), nullable=False) 

2921 arm = db.Column(db.Numeric(7, 2), nullable=False) # m from datum 

2922 max_weight = db.Column(db.Numeric(6, 2), nullable=True) # kg limit (non-fuel only) 

2923 capacity = db.Column(db.Float, nullable=True) # L or gal (fuel stations) 

2924 is_fuel = db.Column(db.Boolean, nullable=False, default=False) 

2925 position = db.Column(db.Integer, nullable=False, default=0) # display order 

2926 

2927 config = db.relationship("WeightBalanceConfig", back_populates="stations") 

2928 

2929 

2930class WeightBalanceEntry(db.Model): 

2931 __tablename__ = "wb_entries" 

2932 

2933 id = db.Column(db.Integer, primary_key=True) 

2934 config_id = db.Column( 

2935 db.Integer, db.ForeignKey("wb_configs.id", ondelete="CASCADE"), nullable=False 

2936 ) 

2937 date = db.Column(db.Date, nullable=False) 

2938 label = db.Column(db.String(100), nullable=True) 

2939 total_weight = db.Column(db.Numeric(7, 2), nullable=False) # kg 

2940 loaded_cg = db.Column(db.Numeric(7, 2), nullable=False) # mm 

2941 is_in_envelope = db.Column(db.Boolean, nullable=False) 

2942 # {station_id_str: value} — fuel stations store volume (L or gal), non-fuel store kg 

2943 station_weights = db.Column(db.JSON, nullable=False, default=dict) 

2944 created_at = db.Column( 

2945 db.DateTime(timezone=True), 

2946 nullable=False, 

2947 default=lambda: datetime.now(UTC), 

2948 ) 

2949 

2950 config = db.relationship("WeightBalanceConfig", back_populates="entries") 

2951 

2952 

2953class AppSetting(db.Model): 

2954 __tablename__ = "app_settings" 

2955 

2956 key = db.Column(db.String(64), primary_key=True) 

2957 value = db.Column(db.Text, nullable=True) 

2958 

2959 

2960# ── Phase 33: Airworthiness Requirements Tracker ────────────────────────────── 

2961 

2962 

2963class EASASourceNode(db.Model): 

2964 """ 

2965 Maps a Component to one leaf node in the EASA Safety Publications Tool 

2966 taxonomy tree (TC holder → type → model). One component may have multiple 

2967 nodes (e.g. base TC plus an installed STC that also carries ADs). 

2968 """ 

2969 

2970 __tablename__ = "easa_source_nodes" 

2971 

2972 id = db.Column(db.Integer, primary_key=True) 

2973 component_id = db.Column( 

2974 db.Integer, db.ForeignKey("components.id", ondelete="CASCADE"), nullable=False 

2975 ) 

2976 tc_holder_node_id = db.Column(db.String(16), nullable=False) 

2977 tc_holder_name = db.Column(db.String(128), nullable=False) 

2978 type_node_id = db.Column(db.String(16), nullable=False) 

2979 type_name = db.Column(db.String(128), nullable=False) 

2980 model_node_id = db.Column(db.String(16), nullable=False) 

2981 model_name = db.Column(db.String(128), nullable=False) 

2982 last_synced_at = db.Column(db.DateTime(timezone=True), nullable=True) 

2983 consecutive_errors = db.Column(db.Integer, nullable=False, default=0) 

2984 

2985 component = db.relationship("Component", back_populates="easa_source_nodes") 

2986 documents = db.relationship( 

2987 "AirworthinessDocument", 

2988 back_populates="source_node", 

2989 cascade="all, delete-orphan", 

2990 ) 

2991 

2992 @property 

2993 def display_path(self) -> str: 

2994 return f"{self.tc_holder_name} / {self.type_name} / {self.model_name}" 

2995 

2996 

2997class AirworthinessDocType: 

2998 AD = "ad" 

2999 MANDATORY_SB = "mandatory_sb" 

3000 SB = "sb" 

3001 SIB = "sib" 

3002 ARC = "arc" 

3003 MANUAL = "manual" 

3004 

3005 ALL = (AD, MANDATORY_SB, SB, SIB, ARC, MANUAL) 

3006 SYNCED = (AD, SIB) # types populated by EASA sync 

3007 # N_()-marked: rendered through gettext() at the template layer, not here. 

3008 LABELS: ClassVar[dict[str, str]] = { 

3009 AD: N_("AD"), 

3010 MANDATORY_SB: N_("Mandatory SB"), 

3011 SB: N_("SB"), 

3012 SIB: N_("SIB"), 

3013 ARC: N_("ARC"), 

3014 MANUAL: N_("Manual"), 

3015 } 

3016 

3017 

3018class AirworthinessDocStatus: 

3019 PENDING_REVIEW = "pending_review" 

3020 COMPLIED = "complied" 

3021 NOT_APPLICABLE = "not_applicable" 

3022 DEFERRED = "deferred" 

3023 QUESTION = "question" 

3024 

3025 ALL = (PENDING_REVIEW, COMPLIED, NOT_APPLICABLE, DEFERRED, QUESTION) 

3026 

3027 

3028class AirworthinessDocument(db.Model): 

3029 """ 

3030 One airworthiness-related document (AD, SB, SIB, ARC, …) applicable to a 

3031 component. Synced documents reference a source_node; manually entered 

3032 documents have source_node_id = NULL. 

3033 """ 

3034 

3035 __tablename__ = "airworthiness_documents" 

3036 

3037 id = db.Column(db.Integer, primary_key=True) 

3038 doc_type = db.Column(db.String(16), nullable=False) 

3039 reference = db.Column(db.String(64), nullable=False) 

3040 title = db.Column(db.String(256), nullable=True) 

3041 source_node_id = db.Column( 

3042 db.Integer, 

3043 db.ForeignKey("easa_source_nodes.id", ondelete="CASCADE"), 

3044 nullable=True, 

3045 ) 

3046 # For manual entries without a source node, store the component directly 

3047 component_id = db.Column( 

3048 db.Integer, db.ForeignKey("components.id", ondelete="CASCADE"), nullable=True 

3049 ) 

3050 doc_url = db.Column(db.String(512), nullable=True) 

3051 # For ARC: date the certificate expires 

3052 expiry_date = db.Column(db.Date, nullable=True) 

3053 first_seen_at = db.Column( 

3054 db.DateTime(timezone=True), 

3055 nullable=False, 

3056 default=lambda: datetime.now(UTC), 

3057 ) 

3058 

3059 source_node = db.relationship("EASASourceNode", back_populates="documents") 

3060 component = db.relationship("Component", back_populates="airworthiness_documents") 

3061 statuses = db.relationship( 

3062 "AirworthinessDocumentStatus", 

3063 back_populates="document", 

3064 cascade="all, delete-orphan", 

3065 ) 

3066 

3067 @property 

3068 def is_manual(self) -> bool: 

3069 return self.source_node_id is None 

3070 

3071 

3072class AirworthinessDocumentStatus(db.Model): 

3073 """ 

3074 Compliance state of one AirworthinessDocument for one aircraft. 

3075 Unique per (aircraft_id, document_id). 

3076 """ 

3077 

3078 __tablename__ = "airworthiness_document_statuses" 

3079 __table_args__ = ( 

3080 db.UniqueConstraint("aircraft_id", "document_id", name="uq_aw_status"), 

3081 ) 

3082 

3083 id = db.Column(db.Integer, primary_key=True) 

3084 aircraft_id = db.Column( 

3085 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

3086 ) 

3087 document_id = db.Column( 

3088 db.Integer, 

3089 db.ForeignKey("airworthiness_documents.id", ondelete="CASCADE"), 

3090 nullable=False, 

3091 ) 

3092 status = db.Column( 

3093 db.String(24), nullable=False, default=AirworthinessDocStatus.PENDING_REVIEW 

3094 ) 

3095 notes = db.Column(db.Text, nullable=True) 

3096 compliance_date = db.Column(db.Date, nullable=True) 

3097 next_review_date = db.Column(db.Date, nullable=True) 

3098 updated_at = db.Column( 

3099 db.DateTime(timezone=True), 

3100 nullable=False, 

3101 default=lambda: datetime.now(UTC), 

3102 onupdate=lambda: datetime.now(UTC), 

3103 ) 

3104 

3105 aircraft = db.relationship("Aircraft", back_populates="airworthiness_statuses") 

3106 document = db.relationship("AirworthinessDocument", back_populates="statuses") 

3107 

3108 

3109class InstalledSTC(db.Model): 

3110 """ 

3111 Registry of Supplemental Type Certificates physically installed on an 

3112 aircraft. No compliance workflow — presence/absence is the record. 

3113 """ 

3114 

3115 __tablename__ = "installed_stcs" 

3116 

3117 id = db.Column(db.Integer, primary_key=True) 

3118 aircraft_id = db.Column( 

3119 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

3120 ) 

3121 stc_number = db.Column(db.String(64), nullable=False) 

3122 title = db.Column(db.String(256), nullable=True) 

3123 tc_holder = db.Column(db.String(128), nullable=True) 

3124 installation_date = db.Column(db.Date, nullable=True) 

3125 notes = db.Column(db.Text, nullable=True) 

3126 

3127 aircraft = db.relationship("Aircraft", back_populates="installed_stcs") 

3128 

3129 

3130class AdSbStatus: 

3131 """Status tags for an AD/SB compliance-board item (backlog: AD/SB 

3132 compliance board — an owner's working summary, not the authoritative 

3133 FAA/EASA record).""" 

3134 

3135 RECURRING = "recurring" 

3136 CONDITIONAL = "conditional" 

3137 VERIFY_PART_NUMBER = "verify_part_number" 

3138 CLOSED = "closed" 

3139 NOT_APPLICABLE = "not_applicable" 

3140 ALL: ClassVar[set[str]] = { 

3141 RECURRING, 

3142 CONDITIONAL, 

3143 VERIFY_PART_NUMBER, 

3144 CLOSED, 

3145 NOT_APPLICABLE, 

3146 } 

3147 LABELS: ClassVar[dict[str, str]] = { 

3148 RECURRING: "Recurring", 

3149 CONDITIONAL: "Conditional", 

3150 VERIFY_PART_NUMBER: "Verify part number", 

3151 CLOSED: "Closed", 

3152 NOT_APPLICABLE: "N/A", 

3153 } 

3154 # Statuses that still need attention at the next annual — drives both 

3155 # the board's "open" filter and the print/checklist item list. 

3156 OPEN: ClassVar[set[str]] = {RECURRING, CONDITIONAL, VERIFY_PART_NUMBER} 

3157 

3158 

3159class AdSbItem(db.Model): 

3160 """An owner's working AD/SB (Airworthiness Directive / Service 

3161 Bulletin) compliance-tracking entry for one aircraft. Explicitly not 

3162 the authoritative FAA/EASA record — see the disclaimer shown on the 

3163 board itself.""" 

3164 

3165 __tablename__ = "ad_sb_items" 

3166 

3167 id = db.Column(db.Integer, primary_key=True) 

3168 aircraft_id = db.Column( 

3169 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

3170 ) 

3171 reference = db.Column(db.String(64), nullable=False) # e.g. "AD 2023-0048" 

3172 title = db.Column(db.String(256), nullable=True) 

3173 status = db.Column( 

3174 db.String(32), 

3175 nullable=False, 

3176 default=AdSbStatus.CONDITIONAL, 

3177 server_default=AdSbStatus.CONDITIONAL, 

3178 ) 

3179 notes = db.Column(db.Text, nullable=True) 

3180 created_at = db.Column( 

3181 db.DateTime(timezone=True), 

3182 nullable=False, 

3183 default=lambda: datetime.now(UTC), 

3184 ) 

3185 

3186 aircraft = db.relationship("Aircraft", back_populates="ad_sb_items") 

3187 

3188 __table_args__ = (db.Index("ix_ad_sb_items_aircraft_id", aircraft_id),) 

3189 

3190 

3191class EquipmentStatus: 

3192 """Per-unit status for an avionics/equipment inventory item (backlog: 

3193 avionics inventory with per-unit status and an upgrade wish list).""" 

3194 

3195 SERVICEABLE = "serviceable" 

3196 OPEN_SQUAWK = "open_squawk" 

3197 PLACARDED_INOPERATIVE = "placarded_inoperative" 

3198 ALL: ClassVar[set[str]] = {SERVICEABLE, OPEN_SQUAWK, PLACARDED_INOPERATIVE} 

3199 LABELS: ClassVar[dict[str, str]] = { 

3200 SERVICEABLE: "Serviceable", 

3201 OPEN_SQUAWK: "Open squawk", 

3202 PLACARDED_INOPERATIVE: "Placarded inoperative", 

3203 } 

3204 

3205 

3206class AvionicsUnit(db.Model): 

3207 """An installed avionics/equipment unit tracked separately from the 

3208 generic Component/maintenance-trigger model — this is a simple 

3209 inventory entry (role, status, certification notes), not a TBO/life- 

3210 limit-tracked part.""" 

3211 

3212 __tablename__ = "avionics_units" 

3213 

3214 id = db.Column(db.Integer, primary_key=True) 

3215 aircraft_id = db.Column( 

3216 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

3217 ) 

3218 role = db.Column(db.String(64), nullable=False) # e.g. "COM1", "Transponder" 

3219 make = db.Column(db.String(64), nullable=True) 

3220 model = db.Column(db.String(64), nullable=True) 

3221 serial_number = db.Column(db.String(64), nullable=True) 

3222 status = db.Column( 

3223 db.String(32), 

3224 nullable=False, 

3225 default=EquipmentStatus.SERVICEABLE, 

3226 server_default=EquipmentStatus.SERVICEABLE, 

3227 ) 

3228 # Certification history and STC/AFMS approvals — free text rather than a 

3229 # separate sub-model, matching this table's own "simple inventory" scope. 

3230 certification_notes = db.Column(db.Text, nullable=True) 

3231 notes = db.Column(db.Text, nullable=True) 

3232 created_at = db.Column( 

3233 db.DateTime(timezone=True), 

3234 nullable=False, 

3235 default=lambda: datetime.now(UTC), 

3236 ) 

3237 

3238 aircraft = db.relationship("Aircraft", back_populates="avionics_units") 

3239 

3240 __table_args__ = (db.Index("ix_avionics_units_aircraft_id", aircraft_id),) 

3241 

3242 

3243class EquipmentWishlistItem(db.Model): 

3244 """A planned equipment upgrade an owner is considering — separate from 

3245 what's actually installed (AvionicsUnit).""" 

3246 

3247 __tablename__ = "equipment_wishlist_items" 

3248 

3249 id = db.Column(db.Integer, primary_key=True) 

3250 aircraft_id = db.Column( 

3251 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

3252 ) 

3253 title = db.Column(db.String(256), nullable=False) 

3254 rough_cost = db.Column(db.Numeric(10, 2), nullable=True) 

3255 # What installing it would require — downtime, STC, panel space, etc. 

3256 requirements = db.Column(db.Text, nullable=True) 

3257 notes = db.Column(db.Text, nullable=True) 

3258 created_at = db.Column( 

3259 db.DateTime(timezone=True), 

3260 nullable=False, 

3261 default=lambda: datetime.now(UTC), 

3262 ) 

3263 

3264 aircraft = db.relationship("Aircraft", back_populates="equipment_wishlist_items") 

3265 

3266 __table_args__ = (db.Index("ix_equipment_wishlist_items_aircraft_id", aircraft_id),) 

3267 

3268 

3269# ── Phase 34: Email Notifications ───────────────────────────────────────────── 

3270 

3271 

3272class NotificationType: 

3273 """String constants for all supported notification types. 

3274 

3275 Stored as plain strings in the DB — no enum migration needed to add types. 

3276 """ 

3277 

3278 GROUNDING_SNAG_OPENED = "grounding_snag_opened" 

3279 SNAG_REPORTED = "snag_reported" 

3280 RESERVATION_CONFIRMED = "reservation_confirmed" 

3281 RESERVATION_CANCELLED = "reservation_cancelled" 

3282 RESERVATION_REQUEST = "reservation_request" 

3283 MAINTENANCE_DUE_SOON = "maintenance_due_soon" 

3284 MAINTENANCE_OVERDUE = "maintenance_overdue" 

3285 INSURANCE_EXPIRING = "insurance_expiring" 

3286 ARC_EXPIRY = "arc_expiry" 

3287 MEDICAL_EXPIRING = "medical_expiring" 

3288 SEP_RATING_EXPIRING = "sep_rating_expiring" 

3289 DOCUMENT_EXPIRING = "document_expiring" 

3290 NEW_MEMBER_JOINED = "new_member_joined" 

3291 AIRWORTHINESS_REVIEW_DUE = "airworthiness_review_due" 

3292 EASA_SYNC_NEW_AD = "easa_sync_new_ad" 

3293 RENTER_AUTHORIZATION_EXPIRY = "renter_authorization_expiry" 

3294 RESERVATION_AIRCRAFT_GROUNDED = "reservation_aircraft_grounded" 

3295 PERSONAL_MINIMUMS_RECENCY = "personal_minimums_recency" 

3296 CREW_INVITE = "crew_invite" 

3297 CREW_INVITE_ANSWERED = "crew_invite_answered" 

3298 SHARED_FLIGHT_CHANGED = "shared_flight_changed" 

3299 FLIGHT_CORRECTION = "flight_correction" 

3300 CREW_CLAIM = "crew_claim" 

3301 

3302 ALL: ClassVar[list[str]] = [ 

3303 GROUNDING_SNAG_OPENED, 

3304 SNAG_REPORTED, 

3305 RESERVATION_CONFIRMED, 

3306 RESERVATION_CANCELLED, 

3307 RESERVATION_REQUEST, 

3308 MAINTENANCE_DUE_SOON, 

3309 MAINTENANCE_OVERDUE, 

3310 INSURANCE_EXPIRING, 

3311 ARC_EXPIRY, 

3312 MEDICAL_EXPIRING, 

3313 SEP_RATING_EXPIRING, 

3314 DOCUMENT_EXPIRING, 

3315 NEW_MEMBER_JOINED, 

3316 AIRWORTHINESS_REVIEW_DUE, 

3317 EASA_SYNC_NEW_AD, 

3318 RENTER_AUTHORIZATION_EXPIRY, 

3319 RESERVATION_AIRCRAFT_GROUNDED, 

3320 PERSONAL_MINIMUMS_RECENCY, 

3321 CREW_INVITE, 

3322 CREW_INVITE_ANSWERED, 

3323 SHARED_FLIGHT_CHANGED, 

3324 FLIGHT_CORRECTION, 

3325 CREW_CLAIM, 

3326 ] 

3327 

3328 # System defaults — coded constants; DB only stores per-user or per-tenant overrides 

3329 SYSTEM_DEFAULTS: ClassVar[dict[str, dict]] = { 

3330 GROUNDING_SNAG_OPENED: {"enabled": True, "threshold_days": None}, 

3331 SNAG_REPORTED: {"enabled": False, "threshold_days": None}, 

3332 RESERVATION_CONFIRMED: {"enabled": True, "threshold_days": None}, 

3333 RESERVATION_CANCELLED: {"enabled": True, "threshold_days": None}, 

3334 RESERVATION_REQUEST: {"enabled": True, "threshold_days": None}, 

3335 MAINTENANCE_DUE_SOON: {"enabled": True, "threshold_days": 30}, 

3336 MAINTENANCE_OVERDUE: {"enabled": True, "threshold_days": None}, 

3337 INSURANCE_EXPIRING: {"enabled": True, "threshold_days": 30}, 

3338 ARC_EXPIRY: {"enabled": True, "threshold_days": 60}, 

3339 MEDICAL_EXPIRING: {"enabled": True, "threshold_days": 60}, 

3340 SEP_RATING_EXPIRING: {"enabled": True, "threshold_days": 60}, 

3341 DOCUMENT_EXPIRING: {"enabled": True, "threshold_days": 30}, 

3342 NEW_MEMBER_JOINED: {"enabled": False, "threshold_days": None}, 

3343 AIRWORTHINESS_REVIEW_DUE: {"enabled": True, "threshold_days": 30}, 

3344 EASA_SYNC_NEW_AD: {"enabled": True, "threshold_days": None}, 

3345 RENTER_AUTHORIZATION_EXPIRY: {"enabled": True, "threshold_days": 30}, 

3346 RESERVATION_AIRCRAFT_GROUNDED: {"enabled": True, "threshold_days": None}, 

3347 PERSONAL_MINIMUMS_RECENCY: {"enabled": True, "threshold_days": None}, 

3348 CREW_INVITE: {"enabled": True, "threshold_days": None}, 

3349 CREW_INVITE_ANSWERED: {"enabled": True, "threshold_days": None}, 

3350 SHARED_FLIGHT_CHANGED: {"enabled": True, "threshold_days": None}, 

3351 FLIGHT_CORRECTION: {"enabled": True, "threshold_days": None}, 

3352 CREW_CLAIM: {"enabled": True, "threshold_days": None}, 

3353 } 

3354 

3355 # Capability flags required — user sees this type in their prefs if they have >= 1 

3356 # "is_owner" | "is_pilot" | "is_maint" match init.py context processor naming 

3357 REQUIRED_CAPS: ClassVar[dict[str, list[str]]] = { 

3358 GROUNDING_SNAG_OPENED: ["is_owner", "is_maint"], 

3359 SNAG_REPORTED: ["is_owner"], 

3360 RESERVATION_CONFIRMED: ["is_pilot"], 

3361 RESERVATION_CANCELLED: ["is_pilot"], 

3362 RESERVATION_REQUEST: ["is_owner"], 

3363 MAINTENANCE_DUE_SOON: ["is_owner", "is_maint"], 

3364 MAINTENANCE_OVERDUE: ["is_owner", "is_maint"], 

3365 INSURANCE_EXPIRING: ["is_owner"], 

3366 ARC_EXPIRY: ["is_owner"], 

3367 MEDICAL_EXPIRING: ["is_pilot"], 

3368 SEP_RATING_EXPIRING: ["is_pilot"], 

3369 DOCUMENT_EXPIRING: ["is_owner", "is_maint"], 

3370 NEW_MEMBER_JOINED: ["is_owner"], 

3371 AIRWORTHINESS_REVIEW_DUE: ["is_owner", "is_maint"], 

3372 EASA_SYNC_NEW_AD: ["is_owner", "is_maint"], 

3373 RENTER_AUTHORIZATION_EXPIRY: ["is_owner"], 

3374 # Any authenticated role that could hold a reservation. 

3375 RESERVATION_AIRCRAFT_GROUNDED: ["is_owner", "is_pilot", "is_maint"], 

3376 PERSONAL_MINIMUMS_RECENCY: ["is_pilot"], 

3377 CREW_INVITE: ["is_pilot"], 

3378 CREW_INVITE_ANSWERED: ["is_pilot"], 

3379 SHARED_FLIGHT_CHANGED: ["is_pilot"], 

3380 FLIGHT_CORRECTION: ["is_pilot"], 

3381 CREW_CLAIM: ["is_pilot"], 

3382 } 

3383 

3384 # Types that have a configurable days-ahead threshold 

3385 HAS_THRESHOLD: ClassVar[set[str]] = { 

3386 MAINTENANCE_DUE_SOON, 

3387 INSURANCE_EXPIRING, 

3388 ARC_EXPIRY, 

3389 MEDICAL_EXPIRING, 

3390 SEP_RATING_EXPIRING, 

3391 DOCUMENT_EXPIRING, 

3392 AIRWORTHINESS_REVIEW_DUE, 

3393 RENTER_AUTHORIZATION_EXPIRY, 

3394 } 

3395 

3396 

3397class NotificationPreference(db.Model): 

3398 """Per-user notification preference override within a tenant (level 1 of 3).""" 

3399 

3400 __tablename__ = "notification_preferences" 

3401 __table_args__ = ( 

3402 db.UniqueConstraint( 

3403 "user_id", "tenant_id", "notification_type", name="uq_notif_pref" 

3404 ), 

3405 ) 

3406 

3407 id = db.Column(db.Integer, primary_key=True) 

3408 user_id = db.Column( 

3409 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

3410 ) 

3411 tenant_id = db.Column( 

3412 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

3413 ) 

3414 notification_type = db.Column(db.String(64), nullable=False) 

3415 enabled = db.Column(db.Boolean, nullable=False) 

3416 threshold_days = db.Column(db.Integer, nullable=True) 

3417 updated_at = db.Column( 

3418 db.DateTime(timezone=True), 

3419 nullable=False, 

3420 default=lambda: datetime.now(UTC), 

3421 onupdate=lambda: datetime.now(UTC), 

3422 ) 

3423 

3424 user = db.relationship("User") 

3425 tenant = db.relationship("Tenant") 

3426 

3427 

3428class TenantNotificationDefault(db.Model): 

3429 """Per-tenant override of system notification defaults (level 2 of 3).""" 

3430 

3431 __tablename__ = "tenant_notification_defaults" 

3432 __table_args__ = ( 

3433 db.UniqueConstraint( 

3434 "tenant_id", "notification_type", name="uq_tenant_notif_default" 

3435 ), 

3436 ) 

3437 

3438 id = db.Column(db.Integer, primary_key=True) 

3439 tenant_id = db.Column( 

3440 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

3441 ) 

3442 notification_type = db.Column(db.String(64), nullable=False) 

3443 enabled = db.Column(db.Boolean, nullable=False) 

3444 threshold_days = db.Column(db.Integer, nullable=True) 

3445 updated_at = db.Column( 

3446 db.DateTime(timezone=True), 

3447 nullable=False, 

3448 default=lambda: datetime.now(UTC), 

3449 onupdate=lambda: datetime.now(UTC), 

3450 ) 

3451 

3452 tenant = db.relationship("Tenant") 

3453 

3454 

3455class NotificationSendLog(db.Model): 

3456 """Records that a given (user, notification instance) was emailed on a 

3457 given calendar day -- makes run_daily_checks() idempotent within a day, 

3458 so a server restart mid-day (or a second manual run) does not resend 

3459 everything already sent earlier. subject_ref identifies the specific 

3460 instance a notification is about (e.g. "aircraft:12", "document:34"), 

3461 distinct from notification_type which identifies the kind of alert.""" 

3462 

3463 __tablename__ = "notification_send_log" 

3464 __table_args__ = ( 

3465 db.UniqueConstraint( 

3466 "user_id", 

3467 "tenant_id", 

3468 "notification_type", 

3469 "subject_ref", 

3470 "sent_date", 

3471 name="uq_notif_send_log", 

3472 ), 

3473 ) 

3474 

3475 id = db.Column(db.Integer, primary_key=True) 

3476 user_id = db.Column( 

3477 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

3478 ) 

3479 tenant_id = db.Column( 

3480 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

3481 ) 

3482 notification_type = db.Column(db.String(64), nullable=False) 

3483 subject_ref = db.Column(db.String(128), nullable=False) 

3484 sent_date = db.Column(db.Date, nullable=False) 

3485 created_at = db.Column( 

3486 db.DateTime(timezone=True), 

3487 nullable=False, 

3488 default=lambda: datetime.now(UTC), 

3489 ) 

3490 

3491 user = db.relationship("User") 

3492 tenant = db.relationship("Tenant") 

3493 

3494 

3495class NotificationSnooze(db.Model): 

3496 """Per-recipient, per-instance reminder snooze, keyed the same way as 

3497 NotificationSendLog (notification_type + subject_ref). snoozed_value is 

3498 NULL until the user follows the one-click email link and confirms; 

3499 once set, it is compared against the live deadline value on every 

3500 subsequent check -- a match suppresses the email, a mismatch means the 

3501 underlying deadline changed (e.g. a renewed document was uploaded) and 

3502 the snooze is treated as stale/cleared automatically.""" 

3503 

3504 __tablename__ = "notification_snoozes" 

3505 __table_args__ = ( 

3506 db.UniqueConstraint( 

3507 "user_id", 

3508 "tenant_id", 

3509 "notification_type", 

3510 "subject_ref", 

3511 name="uq_notif_snooze", 

3512 ), 

3513 ) 

3514 

3515 id = db.Column(db.Integer, primary_key=True) 

3516 user_id = db.Column( 

3517 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

3518 ) 

3519 tenant_id = db.Column( 

3520 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

3521 ) 

3522 notification_type = db.Column(db.String(64), nullable=False) 

3523 subject_ref = db.Column(db.String(128), nullable=False) 

3524 token = db.Column( 

3525 db.String(64), 

3526 unique=True, 

3527 nullable=False, 

3528 default=lambda: secrets.token_urlsafe(32), 

3529 ) 

3530 label = db.Column(db.String(256), nullable=False) 

3531 current_value = db.Column(db.String(64), nullable=False) 

3532 snoozed_value = db.Column(db.String(64), nullable=True) 

3533 snoozed_at = db.Column(db.DateTime(timezone=True), nullable=True) 

3534 created_at = db.Column( 

3535 db.DateTime(timezone=True), 

3536 nullable=False, 

3537 default=lambda: datetime.now(UTC), 

3538 ) 

3539 

3540 user = db.relationship("User") 

3541 tenant = db.relationship("Tenant") 

3542 

3543 @property 

3544 def is_active(self) -> bool: 

3545 return ( 

3546 self.snoozed_value is not None and self.snoozed_value == self.current_value 

3547 ) 

3548 

3549 

3550class BillingAccountKind: 

3551 """Shared billing core (Phases 37/39/41) — see docs/billing_service_design.md.""" 

3552 

3553 RENTER = "renter" # Phase 37 — scoped to the tenant (all aircraft) 

3554 CO_OWNER = "co_owner" # Phase 39 — scoped to one aircraft 

3555 MEMBER = "member" # Phase 41 — scoped to the tenant 

3556 

3557 ALL: ClassVar[set[str]] = {RENTER, CO_OWNER, MEMBER} 

3558 

3559 

3560class BillingAccount(db.Model): 

3561 """One row per (tenant, user, scope). Created lazily by BillingService — 

3562 there is no UI to create an account directly.""" 

3563 

3564 __tablename__ = "billing_accounts" 

3565 __table_args__ = ( 

3566 # A plain UniqueConstraint on (tenant_id, user_id, kind, aircraft_id) 

3567 # would NOT prevent duplicate renter/member accounts: aircraft_id is 

3568 # NULL for those (tenant-scoped, not aircraft-scoped) kinds, and SQL 

3569 # unique constraints treat NULL as distinct from every other NULL. 

3570 # Two partial unique indexes close that gap: one for aircraft-scoped 

3571 # (co_owner) rows, one for tenant-scoped (renter/member) rows. 

3572 db.Index( 

3573 "uq_billing_account_scope_aircraft", 

3574 "tenant_id", 

3575 "user_id", 

3576 "kind", 

3577 "aircraft_id", 

3578 unique=True, 

3579 sqlite_where=db.text("aircraft_id IS NOT NULL"), 

3580 postgresql_where=db.text("aircraft_id IS NOT NULL"), 

3581 ), 

3582 db.Index( 

3583 "uq_billing_account_scope_fleet", 

3584 "tenant_id", 

3585 "user_id", 

3586 "kind", 

3587 unique=True, 

3588 sqlite_where=db.text("aircraft_id IS NULL"), 

3589 postgresql_where=db.text("aircraft_id IS NULL"), 

3590 ), 

3591 db.Index("ix_billing_accounts_tenant_id", "tenant_id"), 

3592 ) 

3593 

3594 id = db.Column(db.Integer, primary_key=True) 

3595 tenant_id = db.Column( 

3596 db.Integer, db.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False 

3597 ) 

3598 user_id = db.Column( 

3599 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

3600 ) 

3601 aircraft_id = db.Column( 

3602 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=True 

3603 ) # co_owner only 

3604 kind = db.Column(db.String(16), nullable=False) # BillingAccountKind 

3605 currency = db.Column(db.String(4), nullable=False, default="EUR") 

3606 created_at = db.Column( 

3607 db.DateTime(timezone=True), 

3608 nullable=False, 

3609 default=lambda: datetime.now(UTC), 

3610 ) 

3611 

3612 tenant = db.relationship("Tenant") 

3613 user = db.relationship("User", foreign_keys=[user_id]) 

3614 aircraft = db.relationship("Aircraft") 

3615 entries = db.relationship( 

3616 "LedgerEntry", back_populates="account", cascade="all, delete-orphan" 

3617 ) 

3618 

3619 

3620class LedgerEntryType: 

3621 """Append-only ledger entry types. No update/delete route may ever exist 

3622 for LedgerEntry — corrections are posted as reversal entries.""" 

3623 

3624 CHARGE = "charge" # money the account holder owes (positive amount) 

3625 PAYMENT = "payment" # money received from the holder (negative amount) 

3626 CREDIT = "credit" # reduction of debt, e.g. fuel reimbursement (negative) 

3627 ADJUSTMENT = "adjustment" # manual correction, either sign, requires note 

3628 OPENING = "opening" # opening balance / co-owner buy-in 

3629 

3630 ALL: ClassVar[set[str]] = {CHARGE, PAYMENT, CREDIT, ADJUSTMENT, OPENING} 

3631 

3632 

3633class LedgerEntry(db.Model): 

3634 """Append-only. Sign convention: positive amount = the holder owes more; 

3635 negative = the holder owes less. balance = sum(amount).""" 

3636 

3637 __tablename__ = "ledger_entries" 

3638 __table_args__ = (db.Index("ix_ledger_entries_account_id", "account_id"),) 

3639 

3640 id = db.Column(db.Integer, primary_key=True) 

3641 account_id = db.Column( 

3642 db.Integer, 

3643 db.ForeignKey("billing_accounts.id", ondelete="CASCADE"), 

3644 nullable=False, 

3645 ) 

3646 entry_type = db.Column(db.String(16), nullable=False) # LedgerEntryType 

3647 amount = db.Column(db.Numeric(10, 2), nullable=False) # signed 

3648 description = db.Column(db.String(255), nullable=False) 

3649 entry_date = db.Column(db.Date, nullable=False) # business date, not created_at 

3650 # Link back to the domain object that produced the entry, for drill-down: 

3651 source_type = db.Column(db.String(32), nullable=True) # e.g. "rental_charge" 

3652 source_id = db.Column(db.Integer, nullable=True) 

3653 # SET NULL, not RESTRICT: entries are never deleted in the app (append-only, 

3654 # no delete route) so this only matters for admin-level DB surgery / test 

3655 # cleanup — a self-referential RESTRICT here blocks a bulk DELETE FROM 

3656 # ledger_entries entirely, since SQLite can't order same-table FK checks. 

3657 reverses_id = db.Column( 

3658 db.Integer, 

3659 db.ForeignKey("ledger_entries.id", ondelete="SET NULL"), 

3660 nullable=True, 

3661 ) 

3662 created_by_id = db.Column( 

3663 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

3664 ) 

3665 created_at = db.Column( 

3666 db.DateTime(timezone=True), 

3667 nullable=False, 

3668 default=lambda: datetime.now(UTC), 

3669 ) 

3670 

3671 account = db.relationship("BillingAccount", back_populates="entries") 

3672 reverses = db.relationship( 

3673 "LedgerEntry", foreign_keys=[reverses_id], remote_side="LedgerEntry.id" 

3674 ) 

3675 created_by = db.relationship("User", foreign_keys=[created_by_id]) 

3676 

3677 

3678# ── Phase 39: Shared Ownership ──────────────────────────────────────────────── 

3679 

3680 

3681class AircraftOwner(db.Model): 

3682 """A co-owner of one aircraft. share_pct values for one aircraft always 

3683 sum to exactly 100.00 (enforced in the manage-owners route — rows are 

3684 only ever written through that form, which replaces the full owner set 

3685 for an aircraft atomically). Share percentage is financial only — each 

3686 co-owner always has exactly one vote regardless of share size, so no 

3687 voting-weight column exists.""" 

3688 

3689 __tablename__ = "aircraft_owners" 

3690 __table_args__ = ( 

3691 db.UniqueConstraint("aircraft_id", "user_id", name="uq_aircraft_owner"), 

3692 db.Index("ix_aircraft_owners_aircraft_id", "aircraft_id"), 

3693 ) 

3694 

3695 id = db.Column(db.Integer, primary_key=True) 

3696 aircraft_id = db.Column( 

3697 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

3698 ) 

3699 user_id = db.Column( 

3700 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

3701 ) 

3702 share_pct = db.Column(db.Numeric(5, 2), nullable=False) # 0.01 - 100.00 

3703 buy_in_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) 

3704 created_at = db.Column( 

3705 db.DateTime(timezone=True), 

3706 nullable=False, 

3707 default=lambda: datetime.now(UTC), 

3708 ) 

3709 

3710 aircraft = db.relationship("Aircraft", back_populates="owners") 

3711 user = db.relationship("User") 

3712 

3713 

3714class CoOwnerValuationSnapshot(db.Model): 

3715 """Phase 39e: immutable point-in-time capital value per co-owner. No 

3716 update or delete route may ever exist for this table — reproducible by 

3717 construction, since the ledger it summarises is itself append-only.""" 

3718 

3719 __tablename__ = "co_owner_valuation_snapshots" 

3720 __table_args__ = (db.Index("ix_covs_aircraft_id", "aircraft_id"),) 

3721 

3722 id = db.Column(db.Integer, primary_key=True) 

3723 aircraft_id = db.Column( 

3724 db.Integer, db.ForeignKey("aircraft.id", ondelete="CASCADE"), nullable=False 

3725 ) 

3726 user_id = db.Column( 

3727 db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False 

3728 ) 

3729 valuation_date = db.Column(db.Date, nullable=False) 

3730 share_pct = db.Column(db.Numeric(5, 2), nullable=False) # copied at snapshot time 

3731 capital_balance = db.Column( 

3732 db.Numeric(10, 2), nullable=False 

3733 ) # -balance(account, as_of=valuation_date) 

3734 note = db.Column(db.String(255), nullable=True) 

3735 created_by_id = db.Column( 

3736 db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

3737 ) 

3738 created_at = db.Column( 

3739 db.DateTime(timezone=True), 

3740 nullable=False, 

3741 default=lambda: datetime.now(UTC), 

3742 ) 

3743 

3744 aircraft = db.relationship("Aircraft") 

3745 user = db.relationship("User", foreign_keys=[user_id]) 

3746 created_by = db.relationship("User", foreign_keys=[created_by_id])