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

554 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 23:33 +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 

200def _save_upload_canonical( 

201 file: FileStorage, 

202 tenant: Tenant, 

203 aircraft: Aircraft, 

204 category: str, 

205 title: str | None, 

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

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

208 

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

210 UPLOAD_FOLDER and suitable for storage in Document.filename. 

211 """ 

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

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

214 today = _date.today().isoformat() 

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

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

217 

218 slug = _ensure_tenant_slug(tenant) 

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

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

221 

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

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

224 os.makedirs(full_dir, exist_ok=True) 

225 

226 dest = _safe_join(folder, relpath) 

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

228 if os.path.exists(dest): 

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

230 relpath = os.path.join( 

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

232 ) 

233 dest = _safe_join(folder, relpath) 

234 

235 file.save(dest) 

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

237 size = os.path.getsize(dest) 

238 return relpath, mime, size 

239 

240 

241def _current_role() -> Role | None: 

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

243 return tu.role if tu else None 

244 

245 

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

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

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

249 

250 

251# ── Title suggestions ───────────────────────────────────────────────────────── 

252 

253 

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

255@login_required 

256def title_suggestions() -> ResponseReturnValue: 

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

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

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

260 

261 if owner_type == "pilot": 

262 base = Document.query.filter( 

263 Document.pilot_user_id == uid, 

264 Document.title.isnot(None), 

265 ) 

266 else: 

267 tid = _tenant_id() 

268 aircraft_ids = [ 

269 row.id 

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

271 .with_entities(Aircraft.id) 

272 .all() 

273 ] 

274 base = Document.query.filter( 

275 Document.aircraft_id.in_(aircraft_ids), 

276 Document.title.isnot(None), 

277 ) 

278 if owner_type == "component": 

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

280 else: 

281 base = base.filter( 

282 Document.component_id.is_(None), 

283 Document.flight_entry_id.is_(None), 

284 ) 

285 

286 if q: 

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

288 

289 rows = ( 

290 base.with_entities(Document.title) 

291 .distinct() 

292 .order_by(Document.title) 

293 .limit(10) 

294 .all() 

295 ) 

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

297 

298 

299# ── Aircraft document list ──────────────────────────────────────────────────── 

300 

301 

302@documents_bp.route("/aircraft/<int:aircraft_id>/documents") 

303@login_required 

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

305 ac = _get_aircraft_or_404(aircraft_id) 

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

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

308 if not show_sensitive: 

309 query = query.filter_by(is_sensitive=False) 

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

311 sensitive_count = Document.query.filter_by( 

312 aircraft_id=ac.id, is_sensitive=True 

313 ).count() 

314 role = _current_role() 

315 is_owner = role in _OWNER_ROLES 

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

317 return render_template( 

318 "documents/list.html", 

319 aircraft=ac, 

320 docs=docs, 

321 show_sensitive=show_sensitive, 

322 sensitive_count=sensitive_count, 

323 is_owner=is_owner, 

324 broken_ids=broken_ids, 

325 category_labels=_CATEGORY_LABELS, 

326 ) 

327 

328 

329# ── Upload aircraft document ────────────────────────────────────────────────── 

330 

331 

332@documents_bp.route( 

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

334) 

335@login_required 

336@require_role(*_OWNER_ROLES) 

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

338 ac = _get_aircraft_or_404(aircraft_id) 

339 component = _resolve_component(ac) 

340 

341 if request.method == "POST": 

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

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

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

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

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

347 if category and category not in DocCategory.ALL: 

348 category = None 

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

350 valid_until = None 

351 if valid_until_str: 

352 try: 

353 valid_until = _date.fromisoformat(valid_until_str) 

354 except ValueError as exc: 

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

356 

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

358 if msg: 

359 flash(msg, "danger") 

360 return render_template( 

361 "documents/upload_form.html", 

362 aircraft=ac, 

363 component=component, 

364 doc_types=_PILOT_DOC_TYPES, 

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

366 ) 

367 

368 if not file or not file.filename: 

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

370 

371 original = secure_filename(file.filename) 

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

373 if ext not in _ALLOWED_EXTS: 

374 return _re_render( 

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

376 ) 

377 

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

379 if category and not component: 

380 tenant = _get_tenant() 

381 stored, mime, size = _save_upload_canonical( 

382 file, tenant, ac, category, title 

383 ) 

384 else: 

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

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

387 

388 doc = Document( 

389 aircraft_id=ac.id, 

390 component_id=component.id if component else None, 

391 filename=stored, 

392 original_filename=original, 

393 mime_type=mime, 

394 size_bytes=size, 

395 title=title, 

396 doc_type=doc_type, 

397 category=category, 

398 valid_until=valid_until, 

399 is_sensitive=is_sensitive, 

400 ) 

401 db.session.add(doc) 

402 

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

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

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

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

407 doc.doc_type = DocType.INSURANCE_CERT 

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

409 ac.insurance_expiry = valid_until 

410 db.session.flush() 

411 prev_cert = Document.query.filter( 

412 Document.aircraft_id == ac.id, 

413 Document.doc_type == DocType.INSURANCE_CERT, 

414 Document.superseded_by_id.is_(None), 

415 Document.id != doc.id, 

416 ).first() 

417 if prev_cert: 

418 prev_cert.superseded_by_id = doc.id 

419 

420 db.session.commit() 

421 activity( 

422 "document.uploaded", 

423 document_id=doc.id, 

424 aircraft_id=ac.id, 

425 title=doc.title or "", 

426 ) 

427 

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

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

430 

431 return render_template( 

432 "documents/upload_form.html", 

433 aircraft=ac, 

434 component=component, 

435 doc_types=_PILOT_DOC_TYPES, 

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

437 ) 

438 

439 

440# ── Edit aircraft document ──────────────────────────────────────────────────── 

441 

442 

443@documents_bp.route( 

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

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

446) 

447@login_required 

448@require_role(*_OWNER_ROLES) 

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

450 ac = _get_aircraft_or_404(aircraft_id) 

451 doc = _get_aircraft_document_or_404(ac, document_id) 

452 

453 if request.method == "POST": 

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

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

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

457 if category and category not in DocCategory.ALL: 

458 category = None 

459 

460 old_category = doc.category 

461 doc.category = category 

462 

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

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

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

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

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

468 old_full = _safe_join(folder, doc.filename) 

469 if os.path.exists(old_full): 

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

471 new_full = _safe_join(folder, new_relpath) 

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

473 try: 

474 os.rename(old_full, new_full) 

475 doc.filename = new_relpath 

476 except OSError as exc: 

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

478 

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

480 doc.valid_until = None 

481 if valid_until_str: 

482 try: 

483 doc.valid_until = _date.fromisoformat(valid_until_str) 

484 except ValueError as exc: 

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

486 db.session.commit() 

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

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

489 

490 return render_template( 

491 "documents/edit_form.html", 

492 aircraft=ac, 

493 doc=doc, 

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

495 ) 

496 

497 

498# ── Delete aircraft document ────────────────────────────────────────────────── 

499 

500 

501@documents_bp.route( 

502 "/aircraft/<int:aircraft_id>/documents/<int:document_id>/delete", methods=["POST"] 

503) 

504@login_required 

505@require_role(*_OWNER_ROLES) 

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

507 ac = _get_aircraft_or_404(aircraft_id) 

508 doc = _get_aircraft_document_or_404(ac, document_id) 

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

510 _delete_file(doc.filename) 

511 db.session.delete(doc) 

512 db.session.commit() 

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

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

515 

516 

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

518 

519 

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

521@login_required 

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

523 ac = _get_aircraft_or_404(aircraft_id) 

524 role = _current_role() 

525 include_sensitive = role in _OWNER_ROLES 

526 

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

528 if not include_sensitive: 

529 query = query.filter_by(is_sensitive=False) 

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

531 

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

533 buf = io.BytesIO() 

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

535 

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

537 for doc in docs: 

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

539 arcname = doc.original_filename 

540 if os.path.exists(path): 

541 zf.write(path, arcname=arcname) 

542 doc_type_label = doc.doc_type or "" 

543 category_label = _CATEGORY_LABELS.get( 

544 doc.category or "", doc.category or "" 

545 ) 

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

547 manifest_lines.append( 

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

549 ) 

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

551 

552 buf.seek(0) 

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

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

555 return Response( 

556 buf.read(), 

557 mimetype="application/zip", 

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

559 ) 

560 

561 

562# ── Insurance certificate upload ────────────────────────────────────────────── 

563 

564 

565@documents_bp.route( 

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

567) 

568@login_required 

569@require_role(*_OWNER_ROLES) 

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

571 ac = _get_aircraft_or_404(aircraft_id) 

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

573 

574 if not file or not file.filename: 

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

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

577 

578 original = secure_filename(file.filename) 

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

580 if ext not in _ALLOWED_EXTS: 

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

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

583 

584 # Insurance certificates go into the canonical insurance folder 

585 tenant = _get_tenant() 

586 stored, mime, size = _save_upload_canonical( 

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

588 ) 

589 

590 # Mark previous insurance certificate as superseded 

591 prev = ( 

592 Document.query.filter_by( 

593 aircraft_id=ac.id, 

594 doc_type=DocType.INSURANCE_CERT, 

595 ) 

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

597 .first() 

598 ) 

599 

600 new_cert = Document( 

601 aircraft_id=ac.id, 

602 filename=stored, 

603 original_filename=original, 

604 mime_type=mime, 

605 size_bytes=size, 

606 title=_("Insurance Certificate"), 

607 doc_type=DocType.INSURANCE_CERT, 

608 category=DocCategory.INSURANCE, 

609 valid_until=ac.insurance_expiry, 

610 is_sensitive=True, 

611 ) 

612 db.session.add(new_cert) 

613 db.session.flush() 

614 

615 if prev: 

616 prev.superseded_by_id = new_cert.id 

617 

618 db.session.commit() 

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

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

621 

622 

623# ── Pilot document upload ───────────────────────────────────────────────────── 

624 

625 

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

627@login_required 

628def upload_pilot_document() -> ResponseReturnValue: 

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

630 

631 if request.method == "POST": 

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

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

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

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

636 valid_until = None 

637 if valid_until_str: 

638 try: 

639 valid_until = _date.fromisoformat(valid_until_str) 

640 except ValueError as exc: 

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

642 

643 if not file or not file.filename: 

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

645 return render_template( 

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

647 ) 

648 

649 original = secure_filename(file.filename) 

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

651 if ext not in _ALLOWED_EXTS: 

652 flash( 

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

654 ) 

655 return render_template( 

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

657 ) 

658 

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

660 

661 doc = Document( 

662 pilot_user_id=uid, 

663 filename=stored, 

664 original_filename=original, 

665 mime_type=mime, 

666 size_bytes=size, 

667 title=title, 

668 doc_type=doc_type, 

669 valid_until=valid_until, 

670 is_sensitive=True, 

671 ) 

672 db.session.add(doc) 

673 db.session.commit() 

674 

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

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

677 

678 return render_template( 

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

680 ) 

681 

682 

683# ── Pilot document delete ───────────────────────────────────────────────────── 

684 

685 

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

687@login_required 

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

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

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

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

692 abort(404) 

693 _delete_file(doc.filename) 

694 db.session.delete(doc) 

695 db.session.commit() 

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

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

698 

699 

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

701 

702 

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

704@login_required 

705@require_role(*_OWNER_ROLES) 

706def list_reconcile() -> ResponseReturnValue: 

707 import difflib 

708 

709 tenant = _get_tenant() 

710 pending = ( 

711 PendingReconcile.query.filter_by( 

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

713 ) 

714 .order_by(PendingReconcile.detected_at.desc()) 

715 .all() 

716 ) 

717 aircraft_list = ( 

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

719 .order_by(Aircraft.registration) 

720 .all() 

721 ) 

722 

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

724 category_suggestions: dict[ 

725 int, tuple[str, str] 

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

727 for pr in pending: 

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

729 if len(parts) >= 4: 

730 raw = parts[2] 

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

732 close = difflib.get_close_matches( 

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

734 ) 

735 if close: 

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

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

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

739 

740 return render_template( 

741 "documents/reconcile.html", 

742 tenant=tenant, 

743 pending=pending, 

744 aircraft_list=aircraft_list, 

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

746 category_suggestions=category_suggestions, 

747 ) 

748 

749 

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

751@login_required 

752@require_role(*_OWNER_ROLES) 

753def scan_documents() -> ResponseReturnValue: 

754 tenant = _get_tenant() 

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

756 

757 if not tenant.slug: 

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

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

760 

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

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

763 flash( 

764 _( 

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

766 slug=tenant.slug, 

767 ), 

768 "info", 

769 ) 

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

771 

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

773 tid = tenant.id 

774 known: set[str] = { 

775 doc.filename 

776 for doc in Document.query.filter( 

777 Document.aircraft_id.in_( 

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

779 ) 

780 ).all() 

781 } 

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

783 stale_removed = 0 

784 for pr in PendingReconcile.query.filter_by( 

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

786 ).all(): 

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

788 db.session.delete(pr) 

789 stale_removed += 1 

790 if stale_removed: 

791 db.session.flush() 

792 

793 existing_pending: set[str] = { 

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

795 } 

796 

797 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

800 } 

801 

802 new_count = 0 

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

804 for fname in filenames: 

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

806 continue 

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

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

809 # Normalise to forward slashes for DB consistency 

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

811 

812 if relpath in known or relpath in existing_pending: 

813 continue 

814 

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

816 parts = relpath.split("/") 

817 aircraft_obj: Aircraft | None = None 

818 category: str | None = None 

819 title_hint: str | None = None 

820 date_hint: _date | None = None 

821 

822 if len(parts) >= 4: 

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

824 aircraft_obj = aircraft_by_reg.get(reg_raw) 

825 cat_str = parts[2] 

826 if cat_str.lower() in DocCategory.ALL: 

827 category = cat_str.lower() 

828 # Parse "YYYY-MM-DD - title.ext" 

829 m = _re.match(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$", parts[3]) 

830 if m: 

831 with contextlib.suppress( 

832 ValueError 

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

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

835 title_hint = m.group(2) 

836 else: 

837 title_hint = os.path.splitext(parts[3])[0] 

838 

839 pr = PendingReconcile( 

840 tenant_id=tid, 

841 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

842 filepath=relpath, 

843 category=category, 

844 title_hint=title_hint, 

845 date_hint=date_hint, 

846 ) 

847 db.session.add(pr) 

848 new_count += 1 

849 

850 db.session.commit() 

851 parts_msg = [] 

852 if new_count: 

853 parts_msg.append( 

854 ngettext( 

855 "one new file queued for review", 

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

857 new_count, 

858 n=new_count, 

859 ) 

860 ) 

861 if stale_removed: 

862 parts_msg.append( 

863 ngettext( 

864 "one missing file removed from queue", 

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

866 stale_removed, 

867 n=stale_removed, 

868 ) 

869 ) 

870 if parts_msg: 

871 flash( 

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

873 ) 

874 else: 

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

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

877 

878 

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

880@login_required 

881@require_role(*_OWNER_ROLES) 

882def rename_reconcile_folder() -> ResponseReturnValue: 

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

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

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

886 """ 

887 import shutil 

888 

889 tenant = _get_tenant() 

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

891 

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

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

894 

895 if new_category not in DocCategory.ALL: 

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

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

898 

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

900 abort(403) 

901 

902 old_dir = _safe_join(folder, bad_folder) 

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

904 new_rel = parent_rel + "/" + new_category 

905 new_dir = _safe_join(folder, new_rel) 

906 

907 if os.path.isdir(old_dir): 

908 if os.path.isdir(new_dir): 

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

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

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

912 os.makedirs(dest_d, exist_ok=True) 

913 for fname in filenames: 

914 shutil.move( 

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

916 ) 

917 shutil.rmtree(old_dir, ignore_errors=True) 

918 else: 

919 os.rename(old_dir, new_dir) 

920 

921 # Prune stale pending entries for the old folder path 

922 tid = tenant.id 

923 for pr in PendingReconcile.query.filter( 

924 PendingReconcile.tenant_id == tid, 

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

926 ).all(): 

927 db.session.delete(pr) 

928 db.session.flush() 

929 

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

931 known: set[str] = { 

932 doc.filename 

933 for doc in Document.query.filter( 

934 Document.aircraft_id.in_( 

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

936 ) 

937 ).all() 

938 } 

939 existing_pending: set[str] = { 

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

941 } 

942 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

945 } 

946 new_count = 0 

947 if os.path.isdir(new_dir): 

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

949 for fname in filenames: 

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

951 continue 

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

953 "\\", "/" 

954 ) 

955 full = _safe_join(folder, relpath) 

956 if relpath in known or relpath in existing_pending: 

957 continue 

958 parts = relpath.split("/") 

959 aircraft_obj: Aircraft | None = None 

960 category: str | None = None 

961 title_hint: str | None = None 

962 date_hint: _date | None = None 

963 if len(parts) >= 4: 

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

965 aircraft_obj = aircraft_by_reg.get(reg_raw) 

966 cat_str = parts[2] 

967 if cat_str.lower() in DocCategory.ALL: 

968 category = cat_str.lower() 

969 m = _re.match(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$", parts[3]) 

970 if m: 

971 with contextlib.suppress( 

972 ValueError 

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

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

975 title_hint = m.group(2) 

976 else: 

977 title_hint = os.path.splitext(parts[3])[0] 

978 if aircraft_obj and category: 

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

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

981 doc = Document( 

982 aircraft_id=aircraft_obj.id, 

983 filename=relpath, 

984 original_filename=fname, 

985 mime_type=mime, 

986 size_bytes=size, 

987 title=title_hint, 

988 category=category, 

989 ) 

990 db.session.add(doc) 

991 else: 

992 pr = PendingReconcile( 

993 tenant_id=tid, 

994 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

995 filepath=relpath, 

996 category=category, 

997 title_hint=title_hint, 

998 date_hint=date_hint, 

999 ) 

1000 db.session.add(pr) 

1001 new_count += 1 

1002 

1003 db.session.commit() 

1004 flash( 

1005 ngettext( 

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

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

1008 new_count, 

1009 cat=new_category, 

1010 n=new_count, 

1011 ), 

1012 "success", 

1013 ) 

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

1015 

1016 

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

1018@login_required 

1019@require_role(*_OWNER_ROLES) 

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

1021 tenant = _get_tenant() 

1022 pr = PendingReconcile.query.filter_by( 

1023 id=pending_id, tenant_id=tenant.id 

1024 ).first_or_404() 

1025 

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

1027 try: 

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

1029 except (ValueError, TypeError): 

1030 aircraft_id = None 

1031 

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

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

1034 if category and category not in DocCategory.ALL: 

1035 category = None 

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

1037 valid_until: _date | None = None 

1038 if valid_until_str: 

1039 with contextlib.suppress( 

1040 ValueError 

1041 ): # malformed date submitted; valid_until stays None 

1042 valid_until = _date.fromisoformat(valid_until_str) 

1043 

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

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

1046 mime = ( 

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

1048 or "application/octet-stream" 

1049 ) 

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

1051 

1052 doc = Document( 

1053 aircraft_id=aircraft_id, 

1054 filename=pr.filepath, 

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

1056 mime_type=mime, 

1057 size_bytes=size, 

1058 title=title, 

1059 category=category, 

1060 valid_until=valid_until, 

1061 is_sensitive=False, 

1062 ) 

1063 db.session.add(doc) 

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

1065 db.session.commit() 

1066 

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

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

1069 

1070 

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

1072@login_required 

1073@require_role(*_OWNER_ROLES) 

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

1075 tenant = _get_tenant() 

1076 pr = PendingReconcile.query.filter_by( 

1077 id=pending_id, tenant_id=tenant.id 

1078 ).first_or_404() 

1079 pr.ignored = True 

1080 db.session.commit() 

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

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