Coverage for app/airworthiness/routes.py: 100%

352 statements  

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

1import os 

2from datetime import date 

3 

4from flask import ( # pyright: ignore[reportMissingImports] 

5 Blueprint, 

6 abort, 

7 flash, 

8 redirect, 

9 render_template, 

10 request, 

11 session, 

12 url_for, 

13) 

14from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports] 

15from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

16from flask_babel import ngettext 

17from models import ( # pyright: ignore[reportMissingImports] 

18 AdSbItem, 

19 AdSbStatus, 

20 Aircraft, 

21 AirworthinessDocStatus, 

22 AirworthinessDocType, 

23 AirworthinessDocument, 

24 AirworthinessDocumentStatus, 

25 AvionicsUnit, 

26 Component, 

27 EASASourceNode, 

28 EquipmentStatus, 

29 EquipmentWishlistItem, 

30 InstalledSTC, 

31 Role, 

32 TenantUser, 

33 db, 

34) 

35from utils import ( # pyright: ignore[reportMissingImports] 

36 login_required, 

37 require_role, 

38 user_can_access_aircraft, 

39) 

40 

41airworthiness_bp = Blueprint("airworthiness", __name__) 

42 

43_OWNER_ROLES = (Role.ADMIN, Role.OWNER) 

44_CREW_ROLES = (Role.ADMIN, Role.OWNER, Role.PILOT, Role.MAINTENANCE) 

45 

46 

47def _tenant_id() -> int: 

48 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

49 if not tu: 

50 abort(403) 

51 return int(tu.tenant_id) 

52 

53 

54def _get_aircraft_or_404(aircraft_id: int) -> Aircraft: 

55 ac = db.session.get(Aircraft, aircraft_id) 

56 if ( 

57 not ac 

58 or ac.tenant_id != _tenant_id() 

59 or not user_can_access_aircraft(aircraft_id) 

60 ): 

61 abort(404) 

62 return ac 

63 

64 

65def _get_node_or_404(aircraft: Aircraft, node_id: int) -> EASASourceNode: 

66 node = db.session.get(EASASourceNode, node_id) 

67 if not node or node.component.aircraft_id != aircraft.id: 

68 abort(404) 

69 return node 

70 

71 

72def _get_doc_or_404(aircraft: Aircraft, doc_id: int) -> AirworthinessDocument: 

73 doc = db.session.get(AirworthinessDocument, doc_id) 

74 if not doc: 

75 abort(404) 

76 # Document belongs to this aircraft if its component belongs to it 

77 component = doc.component or ( 

78 doc.source_node.component if doc.source_node else None 

79 ) 

80 if not component or component.aircraft_id != aircraft.id: 

81 abort(404) 

82 return doc 

83 

84 

85def _get_stc_or_404(aircraft: Aircraft, stc_id: int) -> InstalledSTC: 

86 stc = db.session.get(InstalledSTC, stc_id) 

87 if not stc or stc.aircraft_id != aircraft.id: 

88 abort(404) 

89 return stc 

90 

91 

92def _get_ad_sb_item_or_404(aircraft: Aircraft, item_id: int) -> AdSbItem: 

93 item = db.session.get(AdSbItem, item_id) 

94 if not item or item.aircraft_id != aircraft.id: 

95 abort(404) 

96 return item 

97 

98 

99def _get_avionics_unit_or_404(aircraft: Aircraft, unit_id: int) -> AvionicsUnit: 

100 unit = db.session.get(AvionicsUnit, unit_id) 

101 if not unit or unit.aircraft_id != aircraft.id: 

102 abort(404) 

103 return unit 

104 

105 

106def _get_wishlist_item_or_404( 

107 aircraft: Aircraft, item_id: int 

108) -> EquipmentWishlistItem: 

109 item = db.session.get(EquipmentWishlistItem, item_id) 

110 if not item or item.aircraft_id != aircraft.id: 

111 abort(404) 

112 return item 

113 

114 

115def _status_for(aircraft_id: int, doc_id: int) -> AirworthinessDocumentStatus | None: 

116 return AirworthinessDocumentStatus.query.filter_by( # type: ignore[no-any-return] 

117 aircraft_id=aircraft_id, document_id=doc_id 

118 ).first() 

119 

120 

121# ── Dashboard ───────────────────────────────────────────────────────────────── 

122 

123 

124@airworthiness_bp.route("/aircraft/<aircraft_ref:aircraft_id>/airworthiness/") 

125@login_required 

126@require_role(*_CREW_ROLES) 

127def dashboard(aircraft_id: int) -> ResponseReturnValue: 

128 ac = _get_aircraft_or_404(aircraft_id) 

129 

130 # Gather all documents for this aircraft through its components 

131 component_ids = [c.id for c in ac.components] # type: ignore[attr-defined] 

132 

133 # Documents via EASA source nodes 

134 synced_docs = ( 

135 AirworthinessDocument.query.join(EASASourceNode) 

136 .filter(EASASourceNode.component_id.in_(component_ids)) 

137 .all() 

138 if component_ids 

139 else [] 

140 ) 

141 # Manual documents attached directly to components 

142 manual_docs = ( 

143 AirworthinessDocument.query.filter( 

144 AirworthinessDocument.component_id.in_(component_ids), 

145 AirworthinessDocument.source_node_id.is_(None), 

146 ).all() 

147 if component_ids 

148 else [] 

149 ) 

150 all_docs = synced_docs + manual_docs 

151 

152 # Build status map {doc_id: AirworthinessDocumentStatus} 

153 existing_statuses = { 

154 s.document_id: s 

155 for s in AirworthinessDocumentStatus.query.filter_by( 

156 aircraft_id=aircraft_id 

157 ).all() 

158 } 

159 

160 # Attach status to each doc (or None if not yet created) 

161 doc_rows = [] 

162 for doc in all_docs: 

163 st = existing_statuses.get(doc.id) 

164 component = doc.component or ( 

165 doc.source_node.component if doc.source_node else None 

166 ) 

167 doc_rows.append({"doc": doc, "status": st, "component": component}) 

168 

169 # Sort: pending first, then by doc_type, then reference 

170 _STATUS_ORDER = { 

171 AirworthinessDocStatus.PENDING_REVIEW: 0, 

172 AirworthinessDocStatus.QUESTION: 1, 

173 AirworthinessDocStatus.DEFERRED: 2, 

174 AirworthinessDocStatus.COMPLIED: 3, 

175 AirworthinessDocStatus.NOT_APPLICABLE: 4, 

176 None: 0, 

177 } 

178 doc_rows.sort( 

179 key=lambda r: ( 

180 _STATUS_ORDER.get(r["status"].status if r["status"] else None, 0), 

181 r["doc"].doc_type, 

182 r["doc"].reference, 

183 ) 

184 ) 

185 

186 # Summary counts 

187 counts: dict[str, int] = {s: 0 for s in AirworthinessDocStatus.ALL} 

188 counts["total"] = len(all_docs) 

189 for row in doc_rows: 

190 st_val = ( 

191 row["status"].status 

192 if row["status"] 

193 else AirworthinessDocStatus.PENDING_REVIEW 

194 ) 

195 counts[st_val] = counts.get(st_val, 0) + 1 

196 

197 # Source nodes grouped by component 

198 nodes_by_component: dict[int, list[EASASourceNode]] = {} 

199 for comp in ac.components: # type: ignore[attr-defined] 

200 if comp.easa_source_nodes: 

201 nodes_by_component[comp.id] = comp.easa_source_nodes 

202 

203 is_production = os.environ.get("OPENHANGAR_ENV", "production") == "production" 

204 return render_template( 

205 "airworthiness/dashboard.html", 

206 aircraft=ac, 

207 doc_rows=doc_rows, 

208 counts=counts, 

209 nodes_by_component=nodes_by_component, 

210 doc_types=AirworthinessDocType, 

211 statuses=AirworthinessDocStatus, 

212 installed_stcs=ac.installed_stcs, 

213 is_production=is_production, 

214 ) 

215 

216 

217# ── EASA sync (manual trigger) ──────────────────────────────────────────────── 

218 

219 

220@airworthiness_bp.route( 

221 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/sync", methods=["POST"] 

222) 

223@login_required 

224@require_role(*_OWNER_ROLES) 

225def trigger_sync(aircraft_id: int) -> ResponseReturnValue: 

226 if os.environ.get("OPENHANGAR_ENV", "production") != "production": 

227 abort(403) 

228 ac = _get_aircraft_or_404(aircraft_id) 

229 from airworthiness_sync import ( 

230 sync_aircraft, # pyright: ignore[reportMissingImports] 

231 ) 

232 

233 added, errors = sync_aircraft(ac) 

234 if errors: 

235 flash( 

236 ngettext( 

237 "Sync completed with errors on one node. Check logs.", 

238 "Sync completed with errors on %(n)s nodes. Check logs.", 

239 errors, 

240 n=errors, 

241 ), 

242 "warning", 

243 ) 

244 if added: 

245 flash( 

246 ngettext( 

247 "One new document discovered.", 

248 "%(n)s new documents discovered.", 

249 added, 

250 n=added, 

251 ), 

252 "success", 

253 ) 

254 else: 

255 flash(_("No new documents found."), "info") 

256 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

257 

258 

259# ── EASA source nodes ───────────────────────────────────────────────────────── 

260 

261 

262@airworthiness_bp.route( 

263 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/nodes/new", 

264 methods=["GET", "POST"], 

265) 

266@login_required 

267@require_role(*_OWNER_ROLES) 

268def add_node(aircraft_id: int) -> ResponseReturnValue: 

269 ac = _get_aircraft_or_404(aircraft_id) 

270 components = [c for c in ac.components if not c.removed_at] # type: ignore[attr-defined] 

271 

272 if request.method == "POST": 

273 component_id = request.form.get("component_id", type=int) 

274 comp = db.session.get(Component, component_id) 

275 if not comp or comp.aircraft_id != aircraft_id: 

276 abort(400) 

277 

278 node = EASASourceNode( 

279 component_id=component_id, 

280 tc_holder_node_id=request.form["tc_holder_node_id"].strip(), 

281 tc_holder_name=request.form["tc_holder_name"].strip(), 

282 type_node_id=request.form["type_node_id"].strip(), 

283 type_name=request.form["type_name"].strip(), 

284 model_node_id=request.form["model_node_id"].strip(), 

285 model_name=request.form["model_name"].strip(), 

286 ) 

287 db.session.add(node) 

288 db.session.commit() 

289 flash(_("EASA source node added."), "success") 

290 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

291 

292 preselect_component_id = request.args.get("component_id", type=int) 

293 return render_template( 

294 "airworthiness/node_form.html", 

295 aircraft=ac, 

296 components=components, 

297 preselect_component_id=preselect_component_id, 

298 ) 

299 

300 

301@airworthiness_bp.route( 

302 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/nodes/<int:node_id>/delete", 

303 methods=["POST"], 

304) 

305@login_required 

306@require_role(*_OWNER_ROLES) 

307def delete_node(aircraft_id: int, node_id: int) -> ResponseReturnValue: 

308 ac = _get_aircraft_or_404(aircraft_id) 

309 node = _get_node_or_404(ac, node_id) 

310 db.session.delete(node) 

311 db.session.commit() 

312 flash(_("EASA source node removed."), "success") 

313 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

314 

315 

316# ── Documents ───────────────────────────────────────────────────────────────── 

317 

318 

319@airworthiness_bp.route( 

320 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/documents/new", 

321 methods=["GET", "POST"], 

322) 

323@login_required 

324@require_role(*_OWNER_ROLES) 

325def add_document(aircraft_id: int) -> ResponseReturnValue: 

326 ac = _get_aircraft_or_404(aircraft_id) 

327 components = [c for c in ac.components if not c.removed_at] # type: ignore[attr-defined] 

328 

329 if request.method == "POST": 

330 component_id = request.form.get("component_id", type=int) 

331 comp = db.session.get(Component, component_id) 

332 if not comp or comp.aircraft_id != aircraft_id: 

333 abort(400) 

334 

335 expiry_raw = request.form.get("expiry_date", "").strip() 

336 expiry = date.fromisoformat(expiry_raw) if expiry_raw else None 

337 

338 doc = AirworthinessDocument( 

339 doc_type=request.form["doc_type"].strip(), 

340 reference=request.form["reference"].strip(), 

341 title=request.form.get("title", "").strip() or None, 

342 component_id=component_id, 

343 doc_url=request.form.get("doc_url", "").strip() or None, 

344 expiry_date=expiry, 

345 ) 

346 db.session.add(doc) 

347 db.session.flush() 

348 

349 status = AirworthinessDocumentStatus( 

350 aircraft_id=aircraft_id, 

351 document_id=doc.id, 

352 status=AirworthinessDocStatus.PENDING_REVIEW, 

353 ) 

354 db.session.add(status) 

355 db.session.commit() 

356 flash(_("Document added."), "success") 

357 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

358 

359 return render_template( 

360 "airworthiness/document_form.html", 

361 aircraft=ac, 

362 components=components, 

363 doc_types=AirworthinessDocType, 

364 ) 

365 

366 

367@airworthiness_bp.route( 

368 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/documents/<int:doc_id>/delete", 

369 methods=["POST"], 

370) 

371@login_required 

372@require_role(*_OWNER_ROLES) 

373def delete_document(aircraft_id: int, doc_id: int) -> ResponseReturnValue: 

374 ac = _get_aircraft_or_404(aircraft_id) 

375 doc = _get_doc_or_404(ac, doc_id) 

376 if not doc.is_manual: 

377 flash(_("Only manually added documents can be deleted."), "danger") 

378 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

379 db.session.delete(doc) 

380 db.session.commit() 

381 flash(_("Document deleted."), "success") 

382 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

383 

384 

385# ── Status updates ──────────────────────────────────────────────────────────── 

386 

387 

388@airworthiness_bp.route( 

389 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/documents/<int:doc_id>/status", 

390 methods=["GET", "POST"], 

391) 

392@login_required 

393@require_role(*_CREW_ROLES) 

394def update_status(aircraft_id: int, doc_id: int) -> ResponseReturnValue: 

395 ac = _get_aircraft_or_404(aircraft_id) 

396 doc = _get_doc_or_404(ac, doc_id) 

397 st = _status_for(aircraft_id, doc_id) 

398 

399 if request.method == "POST": 

400 new_status = request.form.get("status", "").strip() 

401 if new_status not in AirworthinessDocStatus.ALL: 

402 abort(400) 

403 

404 compliance_raw = request.form.get("compliance_date", "").strip() 

405 review_raw = request.form.get("next_review_date", "").strip() 

406 

407 if st is None: 

408 st = AirworthinessDocumentStatus( 

409 aircraft_id=aircraft_id, document_id=doc_id 

410 ) 

411 db.session.add(st) 

412 

413 st.status = new_status 

414 st.notes = request.form.get("notes", "").strip() or None 

415 st.compliance_date = ( 

416 date.fromisoformat(compliance_raw) if compliance_raw else None 

417 ) 

418 st.next_review_date = date.fromisoformat(review_raw) if review_raw else None 

419 db.session.commit() 

420 flash(_("Status updated."), "success") 

421 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

422 

423 return render_template( 

424 "airworthiness/status_form.html", 

425 aircraft=ac, 

426 doc=doc, 

427 current_status=st, 

428 statuses=AirworthinessDocStatus, 

429 ) 

430 

431 

432# ── Installed STCs ──────────────────────────────────────────────────────────── 

433 

434 

435@airworthiness_bp.route( 

436 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/stcs/new", 

437 methods=["GET", "POST"], 

438) 

439@login_required 

440@require_role(*_OWNER_ROLES) 

441def add_stc(aircraft_id: int) -> ResponseReturnValue: 

442 ac = _get_aircraft_or_404(aircraft_id) 

443 

444 if request.method == "POST": 

445 install_raw = request.form.get("installation_date", "").strip() 

446 stc = InstalledSTC( 

447 aircraft_id=aircraft_id, 

448 stc_number=request.form["stc_number"].strip(), 

449 title=request.form.get("title", "").strip() or None, 

450 tc_holder=request.form.get("tc_holder", "").strip() or None, 

451 installation_date=date.fromisoformat(install_raw) if install_raw else None, 

452 notes=request.form.get("notes", "").strip() or None, 

453 ) 

454 db.session.add(stc) 

455 db.session.commit() 

456 flash(_("Installed STC added."), "success") 

457 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

458 

459 return render_template("airworthiness/stc_form.html", aircraft=ac) 

460 

461 

462@airworthiness_bp.route( 

463 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/stcs/<int:stc_id>/delete", 

464 methods=["POST"], 

465) 

466@login_required 

467@require_role(*_OWNER_ROLES) 

468def delete_stc(aircraft_id: int, stc_id: int) -> ResponseReturnValue: 

469 ac = _get_aircraft_or_404(aircraft_id) 

470 stc = _get_stc_or_404(ac, stc_id) 

471 db.session.delete(stc) 

472 db.session.commit() 

473 flash(_("Installed STC removed."), "success") 

474 return redirect(url_for("airworthiness.dashboard", aircraft_id=aircraft_id)) 

475 

476 

477# ── AD/SB compliance board ──────────────────────────────────────────────────── 

478# Backlog item: an owner's working AD/SB summary — explicitly not the 

479# authoritative FAA/EASA record (see the disclaimer on the board itself). 

480 

481 

482@airworthiness_bp.route("/aircraft/<aircraft_ref:aircraft_id>/airworthiness/adsb/") 

483@login_required 

484@require_role(*_CREW_ROLES) 

485def adsb_board(aircraft_id: int) -> ResponseReturnValue: 

486 ac = _get_aircraft_or_404(aircraft_id) 

487 items = list(ac.ad_sb_items) 

488 checklist_items = [i for i in items if i.status in AdSbStatus.OPEN] 

489 return render_template( 

490 "airworthiness/adsb_board.html", 

491 aircraft=ac, 

492 items=items, 

493 checklist_items=checklist_items, 

494 statuses=AdSbStatus, 

495 ) 

496 

497 

498@airworthiness_bp.route( 

499 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/adsb/new", 

500 methods=["GET", "POST"], 

501) 

502@login_required 

503@require_role(*_OWNER_ROLES) 

504def add_ad_sb_item(aircraft_id: int) -> ResponseReturnValue: 

505 ac = _get_aircraft_or_404(aircraft_id) 

506 

507 if request.method == "POST": 

508 status = request.form.get("status", "").strip() 

509 if status not in AdSbStatus.ALL: 

510 abort(400) 

511 item = AdSbItem( 

512 aircraft_id=aircraft_id, 

513 reference=request.form["reference"].strip(), 

514 title=request.form.get("title", "").strip() or None, 

515 status=status, 

516 notes=request.form.get("notes", "").strip() or None, 

517 ) 

518 db.session.add(item) 

519 db.session.commit() 

520 flash(_("AD/SB item added."), "success") 

521 return redirect(url_for("airworthiness.adsb_board", aircraft_id=aircraft_id)) 

522 

523 return render_template( 

524 "airworthiness/adsb_form.html", aircraft=ac, item=None, statuses=AdSbStatus 

525 ) 

526 

527 

528@airworthiness_bp.route( 

529 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/adsb/<int:item_id>/edit", 

530 methods=["GET", "POST"], 

531) 

532@login_required 

533@require_role(*_OWNER_ROLES) 

534def edit_ad_sb_item(aircraft_id: int, item_id: int) -> ResponseReturnValue: 

535 ac = _get_aircraft_or_404(aircraft_id) 

536 item = _get_ad_sb_item_or_404(ac, item_id) 

537 

538 if request.method == "POST": 

539 status = request.form.get("status", "").strip() 

540 if status not in AdSbStatus.ALL: 

541 abort(400) 

542 item.reference = request.form["reference"].strip() 

543 item.title = request.form.get("title", "").strip() or None 

544 item.status = status 

545 item.notes = request.form.get("notes", "").strip() or None 

546 db.session.commit() 

547 flash(_("AD/SB item updated."), "success") 

548 return redirect(url_for("airworthiness.adsb_board", aircraft_id=aircraft_id)) 

549 

550 return render_template( 

551 "airworthiness/adsb_form.html", aircraft=ac, item=item, statuses=AdSbStatus 

552 ) 

553 

554 

555@airworthiness_bp.route( 

556 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/adsb/<int:item_id>/delete", 

557 methods=["POST"], 

558) 

559@login_required 

560@require_role(*_OWNER_ROLES) 

561def delete_ad_sb_item(aircraft_id: int, item_id: int) -> ResponseReturnValue: 

562 ac = _get_aircraft_or_404(aircraft_id) 

563 item = _get_ad_sb_item_or_404(ac, item_id) 

564 db.session.delete(item) 

565 db.session.commit() 

566 flash(_("AD/SB item removed."), "success") 

567 return redirect(url_for("airworthiness.adsb_board", aircraft_id=aircraft_id)) 

568 

569 

570@airworthiness_bp.route("/aircraft/<aircraft_ref:aircraft_id>/airworthiness/adsb/print") 

571@login_required 

572@require_role(*_CREW_ROLES) 

573def adsb_print(aircraft_id: int) -> ResponseReturnValue: 

574 ac = _get_aircraft_or_404(aircraft_id) 

575 items = list(ac.ad_sb_items) 

576 checklist_items = [i for i in items if i.status in AdSbStatus.OPEN] 

577 return render_template( 

578 "airworthiness/adsb_print.html", 

579 aircraft=ac, 

580 items=items, 

581 checklist_items=checklist_items, 

582 statuses=AdSbStatus, 

583 today=date.today(), 

584 ) 

585 

586 

587# ── Avionics/equipment inventory ───────────────────────────────────────────── 

588# Backlog item: per-unit status + a separate upgrade wish list, kept distinct 

589# from the generic Component/maintenance-trigger model (no TBO/life-limit 

590# tracking here — this is a simple inventory). 

591 

592 

593@airworthiness_bp.route("/aircraft/<aircraft_ref:aircraft_id>/airworthiness/equipment/") 

594@login_required 

595@require_role(*_CREW_ROLES) 

596def equipment_board(aircraft_id: int) -> ResponseReturnValue: 

597 ac = _get_aircraft_or_404(aircraft_id) 

598 return render_template( 

599 "airworthiness/equipment_board.html", 

600 aircraft=ac, 

601 units=list(ac.avionics_units), 

602 wishlist_items=list(ac.equipment_wishlist_items), 

603 statuses=EquipmentStatus, 

604 ) 

605 

606 

607@airworthiness_bp.route( 

608 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/equipment/new", 

609 methods=["GET", "POST"], 

610) 

611@login_required 

612@require_role(*_OWNER_ROLES) 

613def add_avionics_unit(aircraft_id: int) -> ResponseReturnValue: 

614 ac = _get_aircraft_or_404(aircraft_id) 

615 

616 if request.method == "POST": 

617 status = request.form.get("status", "").strip() 

618 if status not in EquipmentStatus.ALL: 

619 abort(400) 

620 unit = AvionicsUnit( 

621 aircraft_id=aircraft_id, 

622 role=request.form["role"].strip(), 

623 make=request.form.get("make", "").strip() or None, 

624 model=request.form.get("model", "").strip() or None, 

625 serial_number=request.form.get("serial_number", "").strip() or None, 

626 status=status, 

627 certification_notes=request.form.get("certification_notes", "").strip() 

628 or None, 

629 notes=request.form.get("notes", "").strip() or None, 

630 ) 

631 db.session.add(unit) 

632 db.session.commit() 

633 flash(_("Equipment unit added."), "success") 

634 return redirect( 

635 url_for("airworthiness.equipment_board", aircraft_id=aircraft_id) 

636 ) 

637 

638 return render_template( 

639 "airworthiness/avionics_unit_form.html", 

640 aircraft=ac, 

641 unit=None, 

642 statuses=EquipmentStatus, 

643 ) 

644 

645 

646@airworthiness_bp.route( 

647 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/equipment/<int:unit_id>/edit", 

648 methods=["GET", "POST"], 

649) 

650@login_required 

651@require_role(*_OWNER_ROLES) 

652def edit_avionics_unit(aircraft_id: int, unit_id: int) -> ResponseReturnValue: 

653 ac = _get_aircraft_or_404(aircraft_id) 

654 unit = _get_avionics_unit_or_404(ac, unit_id) 

655 

656 if request.method == "POST": 

657 status = request.form.get("status", "").strip() 

658 if status not in EquipmentStatus.ALL: 

659 abort(400) 

660 unit.role = request.form["role"].strip() 

661 unit.make = request.form.get("make", "").strip() or None 

662 unit.model = request.form.get("model", "").strip() or None 

663 unit.serial_number = request.form.get("serial_number", "").strip() or None 

664 unit.status = status 

665 unit.certification_notes = ( 

666 request.form.get("certification_notes", "").strip() or None 

667 ) 

668 unit.notes = request.form.get("notes", "").strip() or None 

669 db.session.commit() 

670 flash(_("Equipment unit updated."), "success") 

671 return redirect( 

672 url_for("airworthiness.equipment_board", aircraft_id=aircraft_id) 

673 ) 

674 

675 return render_template( 

676 "airworthiness/avionics_unit_form.html", 

677 aircraft=ac, 

678 unit=unit, 

679 statuses=EquipmentStatus, 

680 ) 

681 

682 

683@airworthiness_bp.route( 

684 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/equipment/<int:unit_id>/delete", 

685 methods=["POST"], 

686) 

687@login_required 

688@require_role(*_OWNER_ROLES) 

689def delete_avionics_unit(aircraft_id: int, unit_id: int) -> ResponseReturnValue: 

690 ac = _get_aircraft_or_404(aircraft_id) 

691 unit = _get_avionics_unit_or_404(ac, unit_id) 

692 db.session.delete(unit) 

693 db.session.commit() 

694 flash(_("Equipment unit removed."), "success") 

695 return redirect(url_for("airworthiness.equipment_board", aircraft_id=aircraft_id)) 

696 

697 

698@airworthiness_bp.route( 

699 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/equipment/wishlist/new", 

700 methods=["GET", "POST"], 

701) 

702@login_required 

703@require_role(*_OWNER_ROLES) 

704def add_wishlist_item(aircraft_id: int) -> ResponseReturnValue: 

705 ac = _get_aircraft_or_404(aircraft_id) 

706 

707 if request.method == "POST": 

708 cost_raw = request.form.get("rough_cost", "").strip() 

709 rough_cost = None 

710 if cost_raw: 

711 try: 

712 rough_cost = float(cost_raw) 

713 if rough_cost < 0: 

714 raise ValueError 

715 except ValueError: 

716 flash(_("Estimated cost must be a non-negative number."), "danger") 

717 return render_template("airworthiness/wishlist_form.html", aircraft=ac) 

718 item = EquipmentWishlistItem( 

719 aircraft_id=aircraft_id, 

720 title=request.form["title"].strip(), 

721 rough_cost=rough_cost, 

722 requirements=request.form.get("requirements", "").strip() or None, 

723 notes=request.form.get("notes", "").strip() or None, 

724 ) 

725 db.session.add(item) 

726 db.session.commit() 

727 flash(_("Wish-list item added."), "success") 

728 return redirect( 

729 url_for("airworthiness.equipment_board", aircraft_id=aircraft_id) 

730 ) 

731 

732 return render_template("airworthiness/wishlist_form.html", aircraft=ac) 

733 

734 

735@airworthiness_bp.route( 

736 "/aircraft/<aircraft_ref:aircraft_id>/airworthiness/equipment/wishlist/<int:item_id>/delete", 

737 methods=["POST"], 

738) 

739@login_required 

740@require_role(*_OWNER_ROLES) 

741def delete_wishlist_item(aircraft_id: int, item_id: int) -> ResponseReturnValue: 

742 ac = _get_aircraft_or_404(aircraft_id) 

743 item = _get_wishlist_item_or_404(ac, item_id) 

744 db.session.delete(item) 

745 db.session.commit() 

746 flash(_("Wish-list item removed."), "success") 

747 return redirect(url_for("airworthiness.equipment_board", aircraft_id=aircraft_id))