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

193 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 23:33 +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 _, ngettext # pyright: ignore[reportMissingImports] 

16 

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

18 Aircraft, 

19 AirworthinessDocument, 

20 AirworthinessDocStatus, 

21 AirworthinessDocType, 

22 AirworthinessDocumentStatus, 

23 Component, 

24 EASASourceNode, 

25 InstalledSTC, 

26 Role, 

27 TenantUser, 

28 db, 

29) 

30from utils import login_required, require_role, user_can_access_aircraft # pyright: ignore[reportMissingImports] 

31 

32airworthiness_bp = Blueprint("airworthiness", __name__) 

33 

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

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

36 

37 

38def _tenant_id() -> int: 

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

40 if not tu: 

41 abort(403) 

42 return int(tu.tenant_id) 

43 

44 

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

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

47 if ( 

48 not ac 

49 or ac.tenant_id != _tenant_id() 

50 or not user_can_access_aircraft(aircraft_id) 

51 ): 

52 abort(404) 

53 return ac 

54 

55 

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

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

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

59 abort(404) 

60 return node 

61 

62 

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

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

65 if not doc: 

66 abort(404) 

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

68 component = doc.component or ( 

69 doc.source_node.component if doc.source_node else None 

70 ) 

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

72 abort(404) 

73 return doc 

74 

75 

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

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

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

79 abort(404) 

80 return stc 

81 

82 

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

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

85 aircraft_id=aircraft_id, document_id=doc_id 

86 ).first() 

87 

88 

89# ── Dashboard ───────────────────────────────────────────────────────────────── 

90 

91 

92@airworthiness_bp.route("/aircraft/<int:aircraft_id>/airworthiness/") 

93@login_required 

94@require_role(*_CREW_ROLES) 

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

96 ac = _get_aircraft_or_404(aircraft_id) 

97 

98 # Gather all documents for this aircraft through its components 

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

100 

101 # Documents via EASA source nodes 

102 synced_docs = ( 

103 AirworthinessDocument.query.join(EASASourceNode) 

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

105 .all() 

106 if component_ids 

107 else [] 

108 ) 

109 # Manual documents attached directly to components 

110 manual_docs = ( 

111 AirworthinessDocument.query.filter( 

112 AirworthinessDocument.component_id.in_(component_ids), 

113 AirworthinessDocument.source_node_id.is_(None), 

114 ).all() 

115 if component_ids 

116 else [] 

117 ) 

118 all_docs = synced_docs + manual_docs 

119 

120 # Build status map {doc_id: AirworthinessDocumentStatus} 

121 existing_statuses = { 

122 s.document_id: s 

123 for s in AirworthinessDocumentStatus.query.filter_by( 

124 aircraft_id=aircraft_id 

125 ).all() 

126 } 

127 

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

129 doc_rows = [] 

130 for doc in all_docs: 

131 st = existing_statuses.get(doc.id) 

132 component = doc.component or ( 

133 doc.source_node.component if doc.source_node else None 

134 ) 

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

136 

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

138 _STATUS_ORDER = { 

139 AirworthinessDocStatus.PENDING_REVIEW: 0, 

140 AirworthinessDocStatus.QUESTION: 1, 

141 AirworthinessDocStatus.DEFERRED: 2, 

142 AirworthinessDocStatus.COMPLIED: 3, 

143 AirworthinessDocStatus.NOT_APPLICABLE: 4, 

144 None: 0, 

145 } 

146 doc_rows.sort( 

147 key=lambda r: ( 

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

149 r["doc"].doc_type, 

150 r["doc"].reference, 

151 ) 

152 ) 

153 

154 # Summary counts 

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

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

157 for row in doc_rows: 

158 st_val = ( 

159 row["status"].status 

160 if row["status"] 

161 else AirworthinessDocStatus.PENDING_REVIEW 

162 ) 

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

164 

165 # Source nodes grouped by component 

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

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

168 if comp.easa_source_nodes: 

169 nodes_by_component[comp.id] = comp.easa_source_nodes 

170 

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

172 return render_template( 

173 "airworthiness/dashboard.html", 

174 aircraft=ac, 

175 doc_rows=doc_rows, 

176 counts=counts, 

177 nodes_by_component=nodes_by_component, 

178 doc_types=AirworthinessDocType, 

179 statuses=AirworthinessDocStatus, 

180 installed_stcs=ac.installed_stcs, 

181 is_production=is_production, 

182 ) 

183 

184 

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

186 

187 

188@airworthiness_bp.route( 

189 "/aircraft/<int:aircraft_id>/airworthiness/sync", methods=["POST"] 

190) 

191@login_required 

192@require_role(*_OWNER_ROLES) 

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

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

195 abort(403) 

196 ac = _get_aircraft_or_404(aircraft_id) 

197 from airworthiness_sync import sync_aircraft # pyright: ignore[reportMissingImports] 

198 

199 added, errors = sync_aircraft(ac) 

200 if errors: 

201 flash( 

202 ngettext( 

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

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

205 errors, 

206 n=errors, 

207 ), 

208 "warning", 

209 ) 

210 if added: 

211 flash( 

212 ngettext( 

213 "One new document discovered.", 

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

215 added, 

216 n=added, 

217 ), 

218 "success", 

219 ) 

220 else: 

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

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

223 

224 

225# ── EASA source nodes ───────────────────────────────────────────────────────── 

226 

227 

228@airworthiness_bp.route( 

229 "/aircraft/<int:aircraft_id>/airworthiness/nodes/new", 

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

231) 

232@login_required 

233@require_role(*_OWNER_ROLES) 

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

235 ac = _get_aircraft_or_404(aircraft_id) 

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

237 

238 if request.method == "POST": 

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

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

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

242 abort(400) 

243 

244 node = EASASourceNode( 

245 component_id=component_id, 

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

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

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

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

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

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

252 ) 

253 db.session.add(node) 

254 db.session.commit() 

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

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

257 

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

259 return render_template( 

260 "airworthiness/node_form.html", 

261 aircraft=ac, 

262 components=components, 

263 preselect_component_id=preselect_component_id, 

264 ) 

265 

266 

267@airworthiness_bp.route( 

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

269 methods=["POST"], 

270) 

271@login_required 

272@require_role(*_OWNER_ROLES) 

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

274 ac = _get_aircraft_or_404(aircraft_id) 

275 node = _get_node_or_404(ac, node_id) 

276 db.session.delete(node) 

277 db.session.commit() 

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

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

280 

281 

282# ── Documents ───────────────────────────────────────────────────────────────── 

283 

284 

285@airworthiness_bp.route( 

286 "/aircraft/<int:aircraft_id>/airworthiness/documents/new", 

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

288) 

289@login_required 

290@require_role(*_OWNER_ROLES) 

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

292 ac = _get_aircraft_or_404(aircraft_id) 

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

294 

295 if request.method == "POST": 

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

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

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

299 abort(400) 

300 

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

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

303 

304 doc = AirworthinessDocument( 

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

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

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

308 component_id=component_id, 

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

310 expiry_date=expiry, 

311 ) 

312 db.session.add(doc) 

313 db.session.flush() 

314 

315 status = AirworthinessDocumentStatus( 

316 aircraft_id=aircraft_id, 

317 document_id=doc.id, 

318 status=AirworthinessDocStatus.PENDING_REVIEW, 

319 ) 

320 db.session.add(status) 

321 db.session.commit() 

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

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

324 

325 return render_template( 

326 "airworthiness/document_form.html", 

327 aircraft=ac, 

328 components=components, 

329 doc_types=AirworthinessDocType, 

330 ) 

331 

332 

333@airworthiness_bp.route( 

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

335 methods=["POST"], 

336) 

337@login_required 

338@require_role(*_OWNER_ROLES) 

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

340 ac = _get_aircraft_or_404(aircraft_id) 

341 doc = _get_doc_or_404(ac, doc_id) 

342 if not doc.is_manual: 

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

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

345 db.session.delete(doc) 

346 db.session.commit() 

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

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

349 

350 

351# ── Status updates ──────────────────────────────────────────────────────────── 

352 

353 

354@airworthiness_bp.route( 

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

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

357) 

358@login_required 

359@require_role(*_CREW_ROLES) 

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

361 ac = _get_aircraft_or_404(aircraft_id) 

362 doc = _get_doc_or_404(ac, doc_id) 

363 st = _status_for(aircraft_id, doc_id) 

364 

365 if request.method == "POST": 

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

367 if new_status not in AirworthinessDocStatus.ALL: 

368 abort(400) 

369 

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

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

372 

373 if st is None: 

374 st = AirworthinessDocumentStatus( 

375 aircraft_id=aircraft_id, document_id=doc_id 

376 ) 

377 db.session.add(st) 

378 

379 st.status = new_status 

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

381 st.compliance_date = ( 

382 date.fromisoformat(compliance_raw) if compliance_raw else None 

383 ) 

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

385 db.session.commit() 

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

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

388 

389 return render_template( 

390 "airworthiness/status_form.html", 

391 aircraft=ac, 

392 doc=doc, 

393 current_status=st, 

394 statuses=AirworthinessDocStatus, 

395 ) 

396 

397 

398# ── Installed STCs ──────────────────────────────────────────────────────────── 

399 

400 

401@airworthiness_bp.route( 

402 "/aircraft/<int:aircraft_id>/airworthiness/stcs/new", 

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

404) 

405@login_required 

406@require_role(*_OWNER_ROLES) 

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

408 ac = _get_aircraft_or_404(aircraft_id) 

409 

410 if request.method == "POST": 

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

412 stc = InstalledSTC( 

413 aircraft_id=aircraft_id, 

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

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

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

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

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

419 ) 

420 db.session.add(stc) 

421 db.session.commit() 

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

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

424 

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

426 

427 

428@airworthiness_bp.route( 

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

430 methods=["POST"], 

431) 

432@login_required 

433@require_role(*_OWNER_ROLES) 

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

435 ac = _get_aircraft_or_404(aircraft_id) 

436 stc = _get_stc_or_404(ac, stc_id) 

437 db.session.delete(stc) 

438 db.session.commit() 

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

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