Coverage for app/documents/routes.py: 17%

552 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-01 12:21 +0000

1import contextlib 

2import io 

3import logging 

4import mimetypes 

5import os 

6import re as _re 

7import uuid 

8import zipfile 

9from datetime import date as _date 

10from datetime import datetime, timezone 

11 

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

13 Blueprint, 

14 Response, 

15 abort, 

16 current_app, 

17 flash, 

18 jsonify, 

19 redirect, 

20 render_template, 

21 request, 

22 session, 

23 url_for, 

24) 

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

26from werkzeug.datastructures import FileStorage 

27from werkzeug.utils import secure_filename 

28 

29from flask_babel import gettext as _, ngettext # pyright: ignore[reportMissingImports] 

30 

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

32 Aircraft, 

33 Component, 

34 DocCategory, 

35 DocType, 

36 Document, 

37 PendingReconcile, 

38 Role, 

39 Tenant, 

40 TenantUser, 

41 db, 

42) 

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

44 

45log = logging.getLogger(__name__) 

46 

47documents_bp = Blueprint("documents", __name__) 

48 

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

50 

51_ALLOWED_EXTS = { 

52 ".jpg", 

53 ".jpeg", 

54 ".png", 

55 ".gif", 

56 ".webp", 

57 ".heic", 

58 ".pdf", 

59 ".doc", 

60 ".docx", 

61 ".xls", 

62 ".xlsx", 

63 ".txt", 

64} 

65 

66_PILOT_DOC_TYPES = [ 

67 (DocType.LICENSE, "Licence"), 

68 (DocType.MEDICAL, "Medical certificate"), 

69] 

70 

71# Human-readable labels for each DocCategory value 

72_CATEGORY_LABELS: dict[str, str] = { 

73 DocCategory.MAINTENANCE: "Maintenance", 

74 DocCategory.INSURANCE: "Insurance", 

75 DocCategory.POH: "POH / Flight Manual", 

76 DocCategory.AIRWORTHINESS: "Airworthiness", 

77 DocCategory.LOGBOOK: "Logbook", 

78 DocCategory.INVOICE: "Invoice", 

79 DocCategory.OTHER: "Other", 

80 DocCategory.UNCATEGORISED: "Uncategorised", 

81} 

82 

83 

84# ── Helpers ─────────────────────────────────────────────────────────────────── 

85 

86 

87def _tenant_id() -> int: 

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

89 if not tu: 

90 abort(403) 

91 return int(tu.tenant_id) 

92 

93 

94def _get_tenant() -> Tenant: 

95 tid = _tenant_id() 

96 t = db.session.get(Tenant, tid) 

97 if not t: 

98 abort(403) # pragma: no cover 

99 return t 

100 

101 

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

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

104 if ( 

105 not ac 

106 or ac.tenant_id != _tenant_id() 

107 or not user_can_access_aircraft(aircraft_id) 

108 ): 

109 abort(404) 

110 return ac 

111 

112 

113def _get_aircraft_document_or_404(aircraft: Aircraft, document_id: int) -> Document: 

114 doc = db.session.get(Document, document_id) 

115 if not doc or doc.aircraft_id != aircraft.id: 

116 abort(404) 

117 return doc 

118 

119 

120def _delete_file(filename: str | None) -> None: 

121 """Move file to _trash/ instead of hard-deleting. 

122 

123 Syncthing propagates the move to all peers; the file is recoverable. 

124 If the file is not found, log and continue silently. 

125 """ 

126 if not filename: 

127 return 

128 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

129 src = os.path.join(folder, filename) 

130 if not os.path.exists(src): 

131 current_app.logger.debug("File already absent, skipping trash: %s", filename) 

132 return 

133 try: 

134 trash_dir = os.path.join(folder, "_trash") 

135 os.makedirs(trash_dir, exist_ok=True) 

136 dest_name = os.path.basename(filename) 

137 dest = os.path.join(trash_dir, dest_name) 

138 if os.path.exists(dest): 

139 base, ext = os.path.splitext(dest_name) 

140 dest = os.path.join(trash_dir, f"{base}_{uuid.uuid4().hex[:8]}{ext}") 

141 os.rename(src, dest) 

142 except OSError: 

143 current_app.logger.debug("Could not move to trash: %s", filename) 

144 

145 

146def _resolve_component(ac: Aircraft) -> Component | None: 

147 raw = request.args.get("component_id") or request.form.get("component_id") 

148 if not raw: 

149 return None 

150 try: 

151 cid = int(raw) 

152 except (ValueError, TypeError): 

153 return None 

154 comp = db.session.get(Component, cid) 

155 return comp if (comp and comp.aircraft_id == ac.id) else None 

156 

157 

158def _save_upload(file: FileStorage, label: str) -> tuple[str, str, int]: 

159 """Save *file* flat to upload folder (legacy path, no canonical structure).""" 

160 original = secure_filename(file.filename or "") 

161 ext = os.path.splitext(original)[1].lower() 

162 stored = f"doc_{label}_{uuid.uuid4().hex[:12]}{ext}" 

163 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

164 os.makedirs(folder, exist_ok=True) 

165 file.save(os.path.join(folder, stored)) 

166 mime = mimetypes.guess_type(original)[0] or "application/octet-stream" 

167 size = os.path.getsize(os.path.join(folder, stored)) 

168 return stored, mime, size 

169 

170 

171def _ensure_tenant_slug(tenant: Tenant) -> str: 

172 """Return tenant.slug, generating one from the name if not yet set.""" 

173 if tenant.slug: 

174 return str(tenant.slug) 

175 base = _re.sub(r"[^a-z0-9]+", "-", tenant.name.lower()).strip("-")[:64] 

176 slug = base 

177 n = 1 

178 while Tenant.query.filter(Tenant.slug == slug, Tenant.id != tenant.id).first(): 

179 slug = f"{base}-{n}" 

180 n += 1 

181 tenant.slug = slug 

182 db.session.flush() 

183 return slug 

184 

185 

186def _safe_path_component(s: str) -> str: 

187 """Strip characters that are unsafe in filesystem path segments.""" 

188 return _re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", s).strip() 

189 

190 

191def _safe_join(upload_folder: str, *parts: str) -> str: 

192 """Join parts under upload_folder; abort(400) if the result would escape it.""" 

193 root = os.path.realpath(upload_folder) 

194 joined = os.path.normpath(os.path.join(root, *parts)) 

195 if not (joined == root or joined.startswith(root + os.sep)): 

196 abort(400) 

197 return joined 

198 

199 

200_RECONCILE_FILENAME_RE = _re.compile(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$") 

201 

202 

203def _parse_reconcile_filename( 

204 cat_str: str, name_part: str 

205) -> tuple[str | None, str | None, _date | None]: 

206 """Parse a Syncthing-mounted path's category segment and its 

207 "YYYY-MM-DD - title.ext" filename segment into (category, title_hint, 

208 date_hint). Shared by scan_documents and rename_reconcile_folder's 

209 inline rescan so a scan and a post-rename rescan can never disagree on 

210 how to interpret the same on-disk layout.""" 

211 category = cat_str.lower() if cat_str.lower() in DocCategory.ALL else None 

212 title_hint: str | None = None 

213 date_hint: _date | None = None 

214 m = _RECONCILE_FILENAME_RE.match(name_part) 

215 if m: 

216 with contextlib.suppress( 

217 ValueError 

218 ): # regex matched date-like string but it's invalid; treat as no date 

219 date_hint = _date.fromisoformat(m.group(1)) 

220 title_hint = m.group(2) 

221 else: 

222 title_hint = os.path.splitext(name_part)[0] 

223 return category, title_hint, date_hint 

224 

225 

226def _save_upload_canonical( 

227 file: FileStorage, 

228 tenant: Tenant, 

229 aircraft: Aircraft, 

230 category: str, 

231 title: str | None, 

232) -> tuple[str, str, int]: 

233 """Save *file* to the canonical Syncthing-compatible path structure. 

234 

235 Returns (relpath, mime_type, size_bytes) where relpath is relative to 

236 UPLOAD_FOLDER and suitable for storage in Document.filename. 

237 """ 

238 original = secure_filename(file.filename or "unnamed") 

239 ext = os.path.splitext(original)[1].lower() 

240 today = _date.today().isoformat() 

241 safe_title = _safe_path_component(title or os.path.splitext(original)[0])[:100] 

242 fname = f"{today} - {safe_title}{ext}" 

243 

244 slug = _ensure_tenant_slug(tenant) 

245 safe_reg = aircraft.registration.replace("/", "-").replace(" ", "-").upper() 

246 relpath = os.path.join(slug, safe_reg, category, fname) 

247 

248 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

249 full_dir = _safe_join(folder, slug, safe_reg, category) 

250 os.makedirs(full_dir, exist_ok=True) 

251 

252 dest = _safe_join(folder, relpath) 

253 # If a file with this name already exists (e.g. same title + date), add a short suffix 

254 if os.path.exists(dest): 

255 base, ext2 = os.path.splitext(fname) 

256 relpath = os.path.join( 

257 slug, safe_reg, category, f"{base}_{uuid.uuid4().hex[:6]}{ext2}" 

258 ) 

259 dest = _safe_join(folder, relpath) 

260 

261 file.save(dest) 

262 mime = mimetypes.guess_type(original)[0] or "application/octet-stream" 

263 size = os.path.getsize(dest) 

264 return relpath, mime, size 

265 

266 

267def _current_role() -> Role | None: 

268 tu = TenantUser.query.filter_by(user_id=session.get("user_id")).first() 

269 return tu.role if tu else None 

270 

271 

272def _doc_broken(doc: Document) -> bool: 

273 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

274 return not os.path.exists(os.path.join(folder, doc.filename)) 

275 

276 

277# ── Title suggestions ───────────────────────────────────────────────────────── 

278 

279 

280@documents_bp.route("/documents/title-suggestions") 

281@login_required 

282def title_suggestions() -> ResponseReturnValue: 

283 q = request.args.get("q", "").strip() 

284 owner_type = request.args.get("owner_type", "aircraft") 

285 uid = int(session["user_id"]) 

286 

287 if owner_type == "pilot": 

288 base = Document.query.filter( 

289 Document.pilot_user_id == uid, 

290 Document.title.isnot(None), 

291 ) 

292 else: 

293 tid = _tenant_id() 

294 aircraft_ids = [ 

295 row.id 

296 for row in Aircraft.query.filter_by(tenant_id=tid) 

297 .with_entities(Aircraft.id) 

298 .all() 

299 ] 

300 base = Document.query.filter( 

301 Document.aircraft_id.in_(aircraft_ids), 

302 Document.title.isnot(None), 

303 ) 

304 if owner_type == "component": 

305 base = base.filter(Document.component_id.isnot(None)) 

306 else: 

307 base = base.filter( 

308 Document.component_id.is_(None), 

309 Document.flight_entry_id.is_(None), 

310 ) 

311 

312 if q: 

313 base = base.filter(Document.title.ilike(f"{q}%")) 

314 

315 rows = ( 

316 base.with_entities(Document.title) 

317 .distinct() 

318 .order_by(Document.title) 

319 .limit(10) 

320 .all() 

321 ) 

322 return jsonify([r.title for r in rows]) 

323 

324 

325# ── Aircraft document list ──────────────────────────────────────────────────── 

326 

327 

328@documents_bp.route("/aircraft/<aircraft_ref:aircraft_id>/documents") 

329@login_required 

330def list_documents(aircraft_id: int) -> ResponseReturnValue: 

331 ac = _get_aircraft_or_404(aircraft_id) 

332 show_sensitive = request.args.get("sensitive") == "1" 

333 query = Document.query.filter_by(aircraft_id=ac.id) 

334 if not show_sensitive: 

335 query = query.filter_by(is_sensitive=False) 

336 docs = query.order_by(Document.uploaded_at.desc()).all() 

337 sensitive_count = Document.query.filter_by( 

338 aircraft_id=ac.id, is_sensitive=True 

339 ).count() 

340 role = _current_role() 

341 is_owner = role in _OWNER_ROLES 

342 broken_ids = {doc.id for doc in docs if _doc_broken(doc)} 

343 return render_template( 

344 "documents/list.html", 

345 aircraft=ac, 

346 docs=docs, 

347 show_sensitive=show_sensitive, 

348 sensitive_count=sensitive_count, 

349 is_owner=is_owner, 

350 broken_ids=broken_ids, 

351 category_labels=_CATEGORY_LABELS, 

352 ) 

353 

354 

355# ── Upload aircraft document ────────────────────────────────────────────────── 

356 

357 

358@documents_bp.route( 

359 "/aircraft/<aircraft_ref:aircraft_id>/documents/upload", methods=["GET", "POST"] 

360) 

361@login_required 

362@require_role(*_OWNER_ROLES) 

363def upload_document(aircraft_id: int) -> ResponseReturnValue: 

364 ac = _get_aircraft_or_404(aircraft_id) 

365 component = _resolve_component(ac) 

366 

367 if request.method == "POST": 

368 file = request.files.get("file") 

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

370 is_sensitive = bool(request.form.get("is_sensitive")) 

371 doc_type = request.form.get("doc_type") or None 

372 category = request.form.get("category") or None 

373 if category and category not in DocCategory.ALL: 

374 category = None 

375 valid_until_str = request.form.get("valid_until", "").strip() 

376 valid_until = None 

377 if valid_until_str: 

378 try: 

379 valid_until = _date.fromisoformat(valid_until_str) 

380 except ValueError as exc: 

381 log.debug("Invalid valid_until date: %s", exc) 

382 

383 def _re_render(msg: str | None = None) -> str: 

384 if msg: 

385 flash(msg, "danger") 

386 return render_template( 

387 "documents/upload_form.html", 

388 aircraft=ac, 

389 component=component, 

390 doc_types=_PILOT_DOC_TYPES, 

391 categories=list(_CATEGORY_LABELS.items()), 

392 ) 

393 

394 if not file or not file.filename: 

395 return _re_render(_("Please select a file to upload.")) 

396 

397 original = secure_filename(file.filename) 

398 ext = os.path.splitext(original)[1].lower() 

399 if ext not in _ALLOWED_EXTS: 

400 return _re_render( 

401 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown") 

402 ) 

403 

404 # Use canonical path when a category is set and this is an aircraft doc 

405 if category and not component: 

406 tenant = _get_tenant() 

407 stored, mime, size = _save_upload_canonical( 

408 file, tenant, ac, category, title 

409 ) 

410 else: 

411 label = f"comp{component.id}" if component else f"ac{ac.id}" 

412 stored, mime, size = _save_upload(file, label) 

413 

414 doc = Document( 

415 aircraft_id=ac.id, 

416 component_id=component.id if component else None, 

417 filename=stored, 

418 original_filename=original, 

419 mime_type=mime, 

420 size_bytes=size, 

421 title=title, 

422 doc_type=doc_type, 

423 category=category, 

424 valid_until=valid_until, 

425 is_sensitive=is_sensitive, 

426 ) 

427 db.session.add(doc) 

428 

429 # Insurance document with an expiry date: auto-update the aircraft's 

430 # insurance_expiry (if the new date is later) and make this document 

431 # the active certificate so the "View certificate" link appears. 

432 if category == DocCategory.INSURANCE and valid_until and not component: 

433 doc.doc_type = DocType.INSURANCE_CERT 

434 if ac.insurance_expiry is None or valid_until > ac.insurance_expiry: 

435 ac.insurance_expiry = valid_until 

436 db.session.flush() 

437 prev_cert = Document.query.filter( 

438 Document.aircraft_id == ac.id, 

439 Document.doc_type == DocType.INSURANCE_CERT, 

440 Document.superseded_by_id.is_(None), 

441 Document.id != doc.id, 

442 ).first() 

443 if prev_cert: 

444 prev_cert.superseded_by_id = doc.id 

445 

446 db.session.commit() 

447 activity( 

448 "document.uploaded", 

449 document_id=doc.id, 

450 aircraft_id=ac.id, 

451 title=doc.title or "", 

452 ) 

453 

454 flash(_("Document uploaded."), "success") 

455 return redirect(url_for("documents.list_documents", aircraft_id=ac.id)) 

456 

457 return render_template( 

458 "documents/upload_form.html", 

459 aircraft=ac, 

460 component=component, 

461 doc_types=_PILOT_DOC_TYPES, 

462 categories=list(_CATEGORY_LABELS.items()), 

463 ) 

464 

465 

466# ── Edit aircraft document ──────────────────────────────────────────────────── 

467 

468 

469@documents_bp.route( 

470 "/aircraft/<aircraft_ref:aircraft_id>/documents/<int:document_id>/edit", 

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

472) 

473@login_required 

474@require_role(*_OWNER_ROLES) 

475def edit_document(aircraft_id: int, document_id: int) -> ResponseReturnValue: 

476 ac = _get_aircraft_or_404(aircraft_id) 

477 doc = _get_aircraft_document_or_404(ac, document_id) 

478 

479 if request.method == "POST": 

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

481 doc.is_sensitive = bool(request.form.get("is_sensitive")) 

482 category = request.form.get("category") or None 

483 if category and category not in DocCategory.ALL: 

484 category = None 

485 

486 old_category = doc.category 

487 doc.category = category 

488 

489 # If the category changed and the file lives in the canonical path, move it 

490 if category and old_category and category != old_category and doc.filename: 

491 parts = doc.filename.replace("\\", "/").split("/") 

492 if len(parts) >= 4 and parts[2] == old_category: 

493 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

494 old_full = _safe_join(folder, doc.filename) 

495 if os.path.exists(old_full): 

496 new_relpath = "/".join(parts[:2] + [category] + parts[3:]) 

497 new_full = _safe_join(folder, new_relpath) 

498 os.makedirs(os.path.dirname(new_full), exist_ok=True) 

499 try: 

500 os.rename(old_full, new_full) 

501 doc.filename = new_relpath 

502 except OSError as exc: 

503 log.warning("Could not move document file: %s", exc) 

504 

505 valid_until_str = request.form.get("valid_until", "").strip() 

506 doc.valid_until = None 

507 if valid_until_str: 

508 try: 

509 doc.valid_until = _date.fromisoformat(valid_until_str) 

510 except ValueError as exc: 

511 log.debug("Invalid valid_until date: %s", exc) 

512 db.session.commit() 

513 flash(_("Document updated."), "success") 

514 return redirect(url_for("documents.list_documents", aircraft_id=ac.id)) 

515 

516 return render_template( 

517 "documents/edit_form.html", 

518 aircraft=ac, 

519 doc=doc, 

520 categories=list(_CATEGORY_LABELS.items()), 

521 ) 

522 

523 

524# ── Delete aircraft document ────────────────────────────────────────────────── 

525 

526 

527@documents_bp.route( 

528 "/aircraft/<aircraft_ref:aircraft_id>/documents/<int:document_id>/delete", 

529 methods=["POST"], 

530) 

531@login_required 

532@require_role(*_OWNER_ROLES) 

533def delete_document(aircraft_id: int, document_id: int) -> ResponseReturnValue: 

534 ac = _get_aircraft_or_404(aircraft_id) 

535 doc = _get_aircraft_document_or_404(ac, document_id) 

536 activity("document.deleted", document_id=document_id, aircraft_id=aircraft_id) 

537 _delete_file(doc.filename) 

538 db.session.delete(doc) 

539 db.session.commit() 

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

541 return redirect(url_for("documents.list_documents", aircraft_id=ac.id)) 

542 

543 

544# ── Download all aircraft documents as ZIP ──────────────────────────────────── 

545 

546 

547@documents_bp.route("/aircraft/<aircraft_ref:aircraft_id>/documents/download-all") 

548@login_required 

549def download_all_documents(aircraft_id: int) -> ResponseReturnValue: 

550 ac = _get_aircraft_or_404(aircraft_id) 

551 role = _current_role() 

552 include_sensitive = role in _OWNER_ROLES 

553 

554 query = Document.query.filter_by(aircraft_id=ac.id) 

555 if not include_sensitive: 

556 query = query.filter_by(is_sensitive=False) 

557 docs = query.order_by(Document.uploaded_at.asc()).all() 

558 

559 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

560 buf = io.BytesIO() 

561 manifest_lines = ["filename\ttitle\tcategory\ttype\tuploaded\n"] 

562 

563 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: 

564 for doc in docs: 

565 path = os.path.join(folder, doc.filename) 

566 arcname = doc.original_filename 

567 if os.path.exists(path): 

568 zf.write(path, arcname=arcname) 

569 doc_type_label = doc.doc_type or "" 

570 category_label = _CATEGORY_LABELS.get( 

571 doc.category or "", doc.category or "" 

572 ) 

573 uploaded = doc.uploaded_at.strftime("%Y-%m-%d") if doc.uploaded_at else "" 

574 manifest_lines.append( 

575 f"{arcname}\t{doc.title or ''}\t{category_label}\t{doc_type_label}\t{uploaded}\n" 

576 ) 

577 zf.writestr("manifest.txt", "".join(manifest_lines)) 

578 

579 buf.seek(0) 

580 reg = ac.registration.replace("/", "-") 

581 zip_name = f"aircraft-{reg}-documents.zip" 

582 return Response( 

583 buf.read(), 

584 mimetype="application/zip", 

585 headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, 

586 ) 

587 

588 

589# ── Insurance certificate upload ────────────────────────────────────────────── 

590 

591 

592@documents_bp.route( 

593 "/aircraft/<aircraft_ref:aircraft_id>/insurance-cert/upload", methods=["POST"] 

594) 

595@login_required 

596@require_role(*_OWNER_ROLES) 

597def upload_insurance_cert(aircraft_id: int) -> ResponseReturnValue: 

598 ac = _get_aircraft_or_404(aircraft_id) 

599 file = request.files.get("file") 

600 

601 if not file or not file.filename: 

602 flash(_("Please select a file to upload."), "danger") 

603 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

604 

605 original = secure_filename(file.filename) 

606 ext = os.path.splitext(original)[1].lower() 

607 if ext not in _ALLOWED_EXTS: 

608 flash(_("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger") 

609 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

610 

611 # Insurance certificates go into the canonical insurance folder 

612 tenant = _get_tenant() 

613 stored, mime, size = _save_upload_canonical( 

614 file, tenant, ac, DocCategory.INSURANCE, _("Insurance Certificate") 

615 ) 

616 

617 # Mark previous insurance certificate as superseded 

618 prev = ( 

619 Document.query.filter_by( 

620 aircraft_id=ac.id, 

621 doc_type=DocType.INSURANCE_CERT, 

622 ) 

623 .filter(Document.superseded_by_id.is_(None)) 

624 .first() 

625 ) 

626 

627 new_cert = Document( 

628 aircraft_id=ac.id, 

629 filename=stored, 

630 original_filename=original, 

631 mime_type=mime, 

632 size_bytes=size, 

633 title=_("Insurance Certificate"), 

634 doc_type=DocType.INSURANCE_CERT, 

635 category=DocCategory.INSURANCE, 

636 valid_until=ac.insurance_expiry, 

637 is_sensitive=True, 

638 ) 

639 db.session.add(new_cert) 

640 db.session.flush() 

641 

642 if prev: 

643 prev.superseded_by_id = new_cert.id 

644 

645 db.session.commit() 

646 flash(_("Insurance certificate uploaded."), "success") 

647 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

648 

649 

650# ── Pilot document upload ───────────────────────────────────────────────────── 

651 

652 

653@documents_bp.route("/pilot/documents/upload", methods=["GET", "POST"]) 

654@login_required 

655def upload_pilot_document() -> ResponseReturnValue: 

656 uid = int(session["user_id"]) 

657 

658 if request.method == "POST": 

659 file = request.files.get("file") 

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

661 doc_type = request.form.get("doc_type") or None 

662 valid_until_str = request.form.get("valid_until", "").strip() 

663 valid_until = None 

664 if valid_until_str: 

665 try: 

666 valid_until = _date.fromisoformat(valid_until_str) 

667 except ValueError as exc: 

668 log.debug("Invalid valid_until date: %s", exc) 

669 

670 if not file or not file.filename: 

671 flash(_("Please select a file to upload."), "danger") 

672 return render_template( 

673 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES 

674 ) 

675 

676 original = secure_filename(file.filename) 

677 ext = os.path.splitext(original)[1].lower() 

678 if ext not in _ALLOWED_EXTS: 

679 flash( 

680 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger" 

681 ) 

682 return render_template( 

683 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES 

684 ) 

685 

686 stored, mime, size = _save_upload(file, f"pilot{uid}") 

687 

688 doc = Document( 

689 pilot_user_id=uid, 

690 filename=stored, 

691 original_filename=original, 

692 mime_type=mime, 

693 size_bytes=size, 

694 title=title, 

695 doc_type=doc_type, 

696 valid_until=valid_until, 

697 is_sensitive=True, 

698 ) 

699 db.session.add(doc) 

700 db.session.commit() 

701 

702 flash(_("Document uploaded."), "success") 

703 return redirect(url_for("pilots.profile")) 

704 

705 return render_template( 

706 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES 

707 ) 

708 

709 

710# ── Pilot document delete ───────────────────────────────────────────────────── 

711 

712 

713@documents_bp.route("/pilot/documents/<int:document_id>/delete", methods=["POST"]) 

714@login_required 

715def delete_pilot_document(document_id: int) -> ResponseReturnValue: 

716 uid = int(session["user_id"]) 

717 doc = db.session.get(Document, document_id) 

718 if not doc or doc.pilot_user_id != uid: 

719 abort(404) 

720 _delete_file(doc.filename) 

721 db.session.delete(doc) 

722 db.session.commit() 

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

724 return redirect(url_for("pilots.profile")) 

725 

726 

727# ── Reconcile: scan + list + import + ignore ────────────────────────────────── 

728 

729 

730@documents_bp.route("/documents/reconcile") 

731@login_required 

732@require_role(*_OWNER_ROLES) 

733def list_reconcile() -> ResponseReturnValue: 

734 import difflib 

735 

736 tenant = _get_tenant() 

737 pending = ( 

738 PendingReconcile.query.filter_by( 

739 tenant_id=tenant.id, reconciled_at=None, ignored=False 

740 ) 

741 .order_by(PendingReconcile.detected_at.desc()) 

742 .all() 

743 ) 

744 aircraft_list = ( 

745 Aircraft.query.filter_by(tenant_id=tenant.id) 

746 .order_by(Aircraft.registration) 

747 .all() 

748 ) 

749 

750 # For entries with an unrecognised category folder, suggest the closest match 

751 category_suggestions: dict[ 

752 int, tuple[str, str] 

753 ] = {} # pr.id → (raw_folder, suggestion) 

754 for pr in pending: 

755 parts = pr.filepath.replace("\\", "/").split("/") 

756 if len(parts) >= 4: 

757 raw = parts[2] 

758 if raw.lower() not in DocCategory.ALL: 

759 close = difflib.get_close_matches( 

760 raw.lower(), DocCategory.ALL, n=1, cutoff=0.6 

761 ) 

762 if close: 

763 # folder path up to and including the bad category dir 

764 bad_folder = "/".join(parts[:3]) 

765 category_suggestions[pr.id] = (bad_folder, close[0]) 

766 

767 return render_template( 

768 "documents/reconcile.html", 

769 tenant=tenant, 

770 pending=pending, 

771 aircraft_list=aircraft_list, 

772 categories=list(_CATEGORY_LABELS.items()), 

773 category_suggestions=category_suggestions, 

774 ) 

775 

776 

777@documents_bp.route("/documents/reconcile/scan", methods=["POST"]) 

778@login_required 

779@require_role(*_OWNER_ROLES) 

780def scan_documents() -> ResponseReturnValue: 

781 tenant = _get_tenant() 

782 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

783 

784 if not tenant.slug: 

785 flash(_("Set a Hangar ID in Settings before scanning for files."), "warning") 

786 return redirect(url_for("documents.list_reconcile")) 

787 

788 slug_dir = os.path.join(folder, tenant.slug) 

789 if not os.path.isdir(slug_dir): 

790 flash( 

791 _( 

792 "No files found in '%(slug)s/'. Mount your Syncthing folder and try again.", 

793 slug=tenant.slug, 

794 ), 

795 "info", 

796 ) 

797 return redirect(url_for("documents.list_reconcile")) 

798 

799 # Build set of filenames already tracked in the documents table 

800 tid = tenant.id 

801 known: set[str] = { 

802 doc.filename 

803 for doc in Document.query.filter( 

804 Document.aircraft_id.in_( 

805 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id) 

806 ) 

807 ).all() 

808 } 

809 # Prune stale pending entries whose file no longer exists on disk 

810 stale_removed = 0 

811 for pr in PendingReconcile.query.filter_by( 

812 tenant_id=tid, reconciled_at=None, ignored=False 

813 ).all(): 

814 if not os.path.exists(os.path.join(folder, pr.filepath)): 

815 db.session.delete(pr) 

816 stale_removed += 1 

817 if stale_removed: 

818 db.session.flush() 

819 

820 existing_pending: set[str] = { 

821 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all() 

822 } 

823 

824 aircraft_by_reg: dict[str, Aircraft] = { 

825 ac.registration.upper().replace("-", "").replace(" ", ""): ac 

826 for ac in Aircraft.query.filter_by(tenant_id=tid).all() 

827 } 

828 

829 new_count = 0 

830 for dirpath, _dirs, filenames in os.walk(slug_dir): 

831 for fname in filenames: 

832 if fname.startswith(".") or fname.startswith("_"): 

833 continue 

834 full = os.path.join(dirpath, fname) 

835 relpath = os.path.relpath(full, folder) 

836 # Normalise to forward slashes for DB consistency 

837 relpath = relpath.replace("\\", "/") 

838 

839 if relpath in known or relpath in existing_pending: 

840 continue 

841 

842 # Parse canonical path: slug/reg/category/YYYY-MM-DD - title.ext 

843 parts = relpath.split("/") 

844 aircraft_obj: Aircraft | None = None 

845 category: str | None = None 

846 title_hint: str | None = None 

847 date_hint: _date | None = None 

848 

849 if len(parts) >= 4: 

850 reg_raw = parts[1].upper().replace("-", "").replace(" ", "") 

851 aircraft_obj = aircraft_by_reg.get(reg_raw) 

852 category, title_hint, date_hint = _parse_reconcile_filename( 

853 parts[2], parts[3] 

854 ) 

855 

856 pr = PendingReconcile( 

857 tenant_id=tid, 

858 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

859 filepath=relpath, 

860 category=category, 

861 title_hint=title_hint, 

862 date_hint=date_hint, 

863 ) 

864 db.session.add(pr) 

865 new_count += 1 

866 

867 db.session.commit() 

868 parts_msg = [] 

869 if new_count: 

870 parts_msg.append( 

871 ngettext( 

872 "one new file queued for review", 

873 "%(n)s new files queued for review", 

874 new_count, 

875 n=new_count, 

876 ) 

877 ) 

878 if stale_removed: 

879 parts_msg.append( 

880 ngettext( 

881 "one missing file removed from queue", 

882 "%(n)s missing files removed from queue", 

883 stale_removed, 

884 n=stale_removed, 

885 ) 

886 ) 

887 if parts_msg: 

888 flash( 

889 _("Scan complete — %(details)s.", details=", ".join(parts_msg)), "success" 

890 ) 

891 else: 

892 flash(_("Scan complete — no new files found."), "info") 

893 return redirect(url_for("documents.list_reconcile")) 

894 

895 

896@documents_bp.route("/documents/reconcile/rename-folder", methods=["POST"]) 

897@login_required 

898@require_role(*_OWNER_ROLES) 

899def rename_reconcile_folder() -> ResponseReturnValue: 

900 """Rename a misnamed category folder on disk (e.g. 'Maintenance' → 'maintenance', 

901 or a typo like 'maintenence' → 'maintenance'), prune stale pending entries for 

902 the old path, then run a fresh scan so the corrected files are picked up immediately. 

903 """ 

904 import shutil 

905 

906 tenant = _get_tenant() 

907 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

908 

909 bad_folder = request.form.get("bad_folder", "").strip().replace("\\", "/") 

910 new_category = request.form.get("new_category", "").strip() 

911 

912 if new_category not in DocCategory.ALL: 

913 flash(_("Invalid category."), "danger") 

914 return redirect(url_for("documents.list_reconcile")) 

915 

916 if not tenant.slug or not bad_folder.startswith(tenant.slug + "/"): 

917 abort(403) 

918 

919 old_dir = _safe_join(folder, bad_folder) 

920 parent_rel = "/".join(bad_folder.split("/")[:2]) # slug/reg 

921 new_rel = parent_rel + "/" + new_category 

922 new_dir = _safe_join(folder, new_rel) 

923 

924 if os.path.isdir(old_dir): 

925 if os.path.isdir(new_dir): 

926 for dirpath, _dirs, filenames in os.walk(old_dir): 

927 rel = os.path.relpath(dirpath, old_dir) 

928 dest_d = os.path.join(new_dir, rel) 

929 os.makedirs(dest_d, exist_ok=True) 

930 for fname in filenames: 

931 shutil.move( 

932 os.path.join(dirpath, fname), os.path.join(dest_d, fname) 

933 ) 

934 shutil.rmtree(old_dir, ignore_errors=True) 

935 else: 

936 os.rename(old_dir, new_dir) 

937 

938 # Prune stale pending entries for the old folder path 

939 tid = tenant.id 

940 for pr in PendingReconcile.query.filter( 

941 PendingReconcile.tenant_id == tid, 

942 PendingReconcile.filepath.like(bad_folder + "/%"), 

943 ).all(): 

944 db.session.delete(pr) 

945 db.session.flush() 

946 

947 # Inline scan: pick up the files now in the correct folder 

948 known: set[str] = { 

949 doc.filename 

950 for doc in Document.query.filter( 

951 Document.aircraft_id.in_( 

952 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id) 

953 ) 

954 ).all() 

955 } 

956 existing_pending: set[str] = { 

957 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all() 

958 } 

959 aircraft_by_reg: dict[str, Aircraft] = { 

960 ac.registration.upper().replace("-", "").replace(" ", ""): ac 

961 for ac in Aircraft.query.filter_by(tenant_id=tid).all() 

962 } 

963 new_count = 0 

964 if os.path.isdir(new_dir): 

965 for dirpath, _dirs, filenames in os.walk(new_dir): 

966 for fname in filenames: 

967 if fname.startswith(".") or fname.startswith("_"): 

968 continue 

969 relpath = os.path.relpath(os.path.join(dirpath, fname), folder).replace( 

970 "\\", "/" 

971 ) 

972 full = _safe_join(folder, relpath) 

973 if relpath in known or relpath in existing_pending: 

974 continue 

975 parts = relpath.split("/") 

976 aircraft_obj: Aircraft | None = None 

977 category: str | None = None 

978 title_hint: str | None = None 

979 date_hint: _date | None = None 

980 if len(parts) >= 4: 

981 reg_raw = parts[1].upper().replace("-", "").replace(" ", "") 

982 aircraft_obj = aircraft_by_reg.get(reg_raw) 

983 category, title_hint, date_hint = _parse_reconcile_filename( 

984 parts[2], parts[3] 

985 ) 

986 if aircraft_obj and category: 

987 mime = mimetypes.guess_type(fname)[0] or "application/octet-stream" 

988 size = os.path.getsize(full) if os.path.exists(full) else None 

989 doc = Document( 

990 aircraft_id=aircraft_obj.id, 

991 filename=relpath, 

992 original_filename=fname, 

993 mime_type=mime, 

994 size_bytes=size, 

995 title=title_hint, 

996 category=category, 

997 ) 

998 db.session.add(doc) 

999 else: 

1000 pr = PendingReconcile( 

1001 tenant_id=tid, 

1002 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

1003 filepath=relpath, 

1004 category=category, 

1005 title_hint=title_hint, 

1006 date_hint=date_hint, 

1007 ) 

1008 db.session.add(pr) 

1009 new_count += 1 

1010 

1011 db.session.commit() 

1012 flash( 

1013 ngettext( 

1014 "Folder renamed to '%(cat)s' — one file processed.", 

1015 "Folder renamed to '%(cat)s' — %(n)s files processed.", 

1016 new_count, 

1017 cat=new_category, 

1018 n=new_count, 

1019 ), 

1020 "success", 

1021 ) 

1022 return redirect(url_for("documents.list_reconcile")) 

1023 

1024 

1025@documents_bp.route("/documents/reconcile/<int:pending_id>/import", methods=["POST"]) 

1026@login_required 

1027@require_role(*_OWNER_ROLES) 

1028def import_reconcile(pending_id: int) -> ResponseReturnValue: 

1029 tenant = _get_tenant() 

1030 pr = PendingReconcile.query.filter_by( 

1031 id=pending_id, tenant_id=tenant.id 

1032 ).first_or_404() 

1033 

1034 aircraft_id_raw = request.form.get("aircraft_id") 

1035 try: 

1036 aircraft_id: int | None = int(aircraft_id_raw) if aircraft_id_raw else None 

1037 except (ValueError, TypeError): 

1038 aircraft_id = None 

1039 if aircraft_id is not None: 

1040 _get_aircraft_or_404(aircraft_id) 

1041 

1042 title = request.form.get("title", "").strip() or pr.title_hint 

1043 category = request.form.get("category") or pr.category 

1044 if category and category not in DocCategory.ALL: 

1045 category = None 

1046 valid_until_str = request.form.get("valid_until", "").strip() 

1047 valid_until: _date | None = None 

1048 if valid_until_str: 

1049 with contextlib.suppress( 

1050 ValueError 

1051 ): # malformed date submitted; valid_until stays None 

1052 valid_until = _date.fromisoformat(valid_until_str) 

1053 

1054 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

1055 full_path = os.path.join(folder, pr.filepath) 

1056 mime = ( 

1057 mimetypes.guess_type(os.path.basename(pr.filepath))[0] 

1058 or "application/octet-stream" 

1059 ) 

1060 size = os.path.getsize(full_path) if os.path.exists(full_path) else None 

1061 

1062 doc = Document( 

1063 aircraft_id=aircraft_id, 

1064 filename=pr.filepath, 

1065 original_filename=os.path.basename(pr.filepath), 

1066 mime_type=mime, 

1067 size_bytes=size, 

1068 title=title, 

1069 category=category, 

1070 valid_until=valid_until, 

1071 is_sensitive=False, 

1072 ) 

1073 db.session.add(doc) 

1074 pr.reconciled_at = datetime.now(timezone.utc) 

1075 db.session.commit() 

1076 

1077 flash(_("Document imported."), "success") 

1078 return redirect(url_for("documents.list_reconcile")) 

1079 

1080 

1081@documents_bp.route("/documents/reconcile/<int:pending_id>/ignore", methods=["POST"]) 

1082@login_required 

1083@require_role(*_OWNER_ROLES) 

1084def ignore_reconcile(pending_id: int) -> ResponseReturnValue: 

1085 tenant = _get_tenant() 

1086 pr = PendingReconcile.query.filter_by( 

1087 id=pending_id, tenant_id=tenant.id 

1088 ).first_or_404() 

1089 pr.ignored = True 

1090 db.session.commit() 

1091 flash(_("File ignored."), "info") 

1092 return redirect(url_for("documents.list_reconcile"))