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

642 statements  

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

1import contextlib 

2import io 

3import logging 

4import mimetypes 

5import os 

6import re as _re 

7import uuid 

8import zipfile 

9from datetime import UTC, datetime, timedelta 

10from datetime import date as _date 

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 flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

27from flask_babel import lazy_gettext as _l 

28from flask_babel import ngettext 

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

30 Aircraft, 

31 Component, 

32 DocCategory, 

33 DocType, 

34 Document, 

35 PendingReconcile, 

36 Role, 

37 Tenant, 

38 TenantUser, 

39 db, 

40) 

41from services.upload_layout import ( # pyright: ignore[reportMissingImports] 

42 is_app_managed, 

43) 

44from sqlalchemy.orm import joinedload # pyright: ignore[reportMissingImports] 

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

46 activity, 

47 login_required, 

48 require_role, 

49 user_can_access_aircraft, 

50) 

51from werkzeug.datastructures import FileStorage 

52from werkzeug.utils import secure_filename 

53 

54log = logging.getLogger(__name__) 

55 

56documents_bp = Blueprint("documents", __name__) 

57 

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

59 

60_ALLOWED_EXTS = { 

61 ".jpg", 

62 ".jpeg", 

63 ".png", 

64 ".gif", 

65 ".webp", 

66 ".heic", 

67 ".pdf", 

68 ".doc", 

69 ".docx", 

70 ".xls", 

71 ".xlsx", 

72 ".txt", 

73} 

74 

75_PILOT_DOC_TYPES = [ 

76 (DocType.LICENSE, _l("Licence")), 

77 (DocType.MEDICAL, _l("Medical certificate")), 

78] 

79 

80_AIRCRAFT_DOC_TYPES = [ 

81 (DocType.INSURANCE_CERT, _l("Insurance Certificate")), 

82 (DocType.ARC, _l("Airworthiness Review Certificate (ARC)")), 

83] 

84 

85# Sentinel accepted by the aircraft Documents list's type filter alongside the 

86# real DocType values above, to isolate documents with no type set -- e.g. an 

87# insurance/ARC certificate uploaded without picking "Document type", which 

88# silently falls outside the expiry-tracking/supersession machinery those two 

89# real types drive (see _EXPIRY_DRIVING_DOC_TYPES). 

90_NO_TYPE_FILTER = "none" 

91_NO_TYPE_LABEL = _l("No type set") 

92 

93# Aircraft documents list pagination -- same values/convention as the pilot 

94# logbook (pilots/routes.py _VALID_PER_PAGE/_DEFAULT_PER_PAGE). 

95_DOCS_VALID_PER_PAGE = (10, 20, 50, 100) 

96_DOCS_DEFAULT_PER_PAGE = 20 

97 

98# doc_type -> Aircraft attribute it drives. Aircraft.insurance_expiry/arc_expiry 

99# are synced caches, not directly user-editable — see the comment on those 

100# columns in models.py. Kept in step by _recompute_expiry_field() below 

101# whenever a Document of one of these types is uploaded, edited, or deleted. 

102_EXPIRY_DRIVING_DOC_TYPES: dict[str, str] = { 

103 DocType.INSURANCE_CERT: "insurance_expiry", 

104 DocType.ARC: "arc_expiry", 

105} 

106 

107# Human-readable labels for each DocCategory value 

108_CATEGORY_LABELS: dict[str, str] = { 

109 DocCategory.MAINTENANCE: "Maintenance", 

110 DocCategory.INSURANCE: "Insurance", 

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

112 DocCategory.AIRWORTHINESS: "Airworthiness", 

113 DocCategory.LOGBOOK: "Logbook", 

114 DocCategory.INVOICE: "Invoice", 

115 DocCategory.OTHER: "Other", 

116 DocCategory.UNCATEGORISED: "Uncategorised", 

117} 

118 

119 

120# ── Helpers ─────────────────────────────────────────────────────────────────── 

121 

122 

123def _tenant_id() -> int: 

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

125 if not tu: 

126 abort(403) 

127 return int(tu.tenant_id) 

128 

129 

130def _get_tenant() -> Tenant: 

131 tid = _tenant_id() 

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

133 if not t: 

134 abort(403) # pragma: no cover 

135 return t 

136 

137 

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

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

140 if ( 

141 not ac 

142 or ac.tenant_id != _tenant_id() 

143 or not user_can_access_aircraft(aircraft_id) 

144 ): 

145 abort(404) 

146 return ac 

147 

148 

149def active_document_for( 

150 aircraft_id: int, 

151 doc_type: str, 

152 component_id: int | None = None, 

153 as_of: _date | None = None, 

154) -> Document | None: 

155 """The document of this (aircraft, doc_type[, component]) that is in 

156 force as of `as_of` (default today): valid_from is None (meaning "in 

157 force as soon as uploaded") or has already arrived. Among those, the 

158 one with the latest valid_from wins; ties -- including the common 

159 valid_from=None case -- break on uploaded_at, so behaviour for 

160 documents that never set valid_from is unchanged from before this 

161 concept existed. Deliberately ignores superseded_by_id: that's a 

162 structural "replaced by" pointer set at upload time, not a statement 

163 about which document is in force today.""" 

164 as_of = as_of or _date.today() 

165 # Ordering (not a Python-side max()) so the comparison stays in SQL -- 

166 # a freshly-flushed-but-uncommitted Document's tz-aware uploaded_at can 

167 # otherwise be compared against an already-committed sibling's 

168 # SQLite-naive one and raise. NULL valid_from sorts last (lowest 

169 # priority, same as treating it as `date.min`). 

170 result: Document | None = ( 

171 Document.query.filter( 

172 Document.aircraft_id == aircraft_id, 

173 Document.doc_type == doc_type, 

174 Document.component_id == component_id, 

175 db.or_(Document.valid_from.is_(None), Document.valid_from <= as_of), 

176 ) 

177 .order_by(Document.valid_from.desc().nullslast(), Document.uploaded_at.desc()) 

178 .first() 

179 ) 

180 return result 

181 

182 

183def effective_coverage_until( 

184 aircraft_id: int, 

185 doc_type: str, 

186 component_id: int | None = None, 

187 as_of: _date | None = None, 

188) -> _date | None: 

189 """How far into the future coverage is unbroken, following a chain of 

190 documents whose valid_from picks up on or the day after the previous 

191 one's valid_until (no gap) -- e.g. next quarter's insurance cert 

192 uploaded a month ahead of the current one's expiry. None if nothing is 

193 currently active (active_document_for finds no document in force); 

194 otherwise at least the active document's own valid_until. 

195 

196 Deliberately independent of the single-hop upcoming_*_cert query in 

197 aircraft/routes.py, which only looks one step ahead and doesn't check 

198 for a gap (it's a display hint: "here's the next thing on file", 

199 correct even when there IS a gap). This instead walks as many chained 

200 documents as exist and stops the moment a real gap appears -- a real 

201 gap still means genuine expiry risk between the two documents and must 

202 still be reported as such. 

203 """ 

204 as_of = as_of or _date.today() 

205 active = active_document_for(aircraft_id, doc_type, component_id, as_of=as_of) 

206 if active is None or active.valid_until is None: 

207 return None 

208 

209 frontier: _date = active.valid_until 

210 while True: 

211 # frontier only ever advances to a candidate's own valid_until, and 

212 # every candidate here must have valid_until > frontier -- so a 

213 # document picked in a prior iteration can never qualify again 

214 # (its valid_until now equals frontier, not exceeds it). No 

215 # "already visited" bookkeeping needed to avoid a repeat/infinite 

216 # loop. 

217 candidates = Document.query.filter( 

218 Document.aircraft_id == aircraft_id, 

219 Document.doc_type == doc_type, 

220 Document.component_id == component_id, 

221 Document.valid_from > as_of, 

222 Document.valid_from <= frontier + timedelta(days=1), 

223 Document.valid_until.isnot(None), 

224 Document.valid_until > frontier, 

225 ).all() 

226 nxt: Document | None = None 

227 for c in candidates: 

228 if nxt is None or c.valid_until > nxt.valid_until: 

229 nxt = c 

230 if nxt is None: 

231 return frontier 

232 frontier = nxt.valid_until 

233 

234 

235def _recompute_expiry_field(ac: Aircraft, doc_type: str | None) -> None: 

236 """Keep Aircraft.insurance_expiry/arc_expiry in step with whichever 

237 Document of that doc_type is currently active (see 

238 active_document_for). Called after any create/edit/delete that could 

239 change which document is active, and once daily (see 

240 services.notification_service._recompute_all_expiry_fields) so a 

241 future-dated document activates on its valid_from even without a new 

242 edit that day — safe to call for any doc_type, a no-op for ones that 

243 don't drive an Aircraft field.""" 

244 field = _EXPIRY_DRIVING_DOC_TYPES.get(doc_type or "") 

245 if not field: 

246 return 

247 assert doc_type is not None # implied by `field` being truthy above 

248 active = active_document_for(ac.id, doc_type, component_id=None) 

249 setattr(ac, field, active.valid_until if active else None) 

250 

251 

252def _parse_date_field(raw: str) -> _date | None: 

253 """Parse an optional YYYY-MM-DD form field; invalid/blank input is 

254 treated as "not set" rather than rejected outright, matching how 

255 valid_until has always been handled here.""" 

256 raw = raw.strip() 

257 if not raw: 

258 return None 

259 try: 

260 return _date.fromisoformat(raw) 

261 except ValueError as exc: 

262 log.debug("Invalid date field: %s", exc) 

263 return None 

264 

265 

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

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

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

269 abort(404) 

270 return doc 

271 

272 

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

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

275 

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

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

278 """ 

279 if not filename: 

280 return 

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

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

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

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

285 return 

286 try: 

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

288 os.makedirs(trash_dir, exist_ok=True) 

289 dest_name = os.path.basename(filename) 

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

291 if os.path.exists(dest): 

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

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

294 os.rename(src, dest) 

295 except OSError: 

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

297 

298 

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

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

301 if not raw: 

302 return None 

303 try: 

304 cid = int(raw) 

305 except (ValueError, TypeError): 

306 return None 

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

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

309 

310 

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

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

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

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

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

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

317 os.makedirs(folder, exist_ok=True) 

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

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

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

321 return stored, mime, size 

322 

323 

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

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

326 if tenant.slug: 

327 return str(tenant.slug) 

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

329 slug = base 

330 n = 1 

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

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

333 n += 1 

334 tenant.slug = slug 

335 db.session.flush() 

336 return slug 

337 

338 

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

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

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

342 

343 

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

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

346 root = os.path.realpath(upload_folder) 

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

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

349 abort(400) 

350 return joined 

351 

352 

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

354 

355 

356def _parse_reconcile_filename( 

357 cat_str: str, name_part: str 

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

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

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

361 date_hint). Shared by scan_documents and rename_reconcile_folder's 

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

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

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

365 title_hint: str | None = None 

366 date_hint: _date | None = None 

367 m = _RECONCILE_FILENAME_RE.match(name_part) 

368 if m: 

369 with contextlib.suppress( 

370 ValueError 

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

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

373 title_hint = m.group(2) 

374 else: 

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

376 return category, title_hint, date_hint 

377 

378 

379def _save_upload_canonical( 

380 file: FileStorage, 

381 tenant: Tenant, 

382 aircraft: Aircraft, 

383 category: str, 

384 title: str | None, 

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

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

387 

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

389 UPLOAD_FOLDER and suitable for storage in Document.filename. 

390 """ 

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

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

393 today = _date.today().isoformat() 

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

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

396 

397 slug = _ensure_tenant_slug(tenant) 

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

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

400 

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

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

403 os.makedirs(full_dir, exist_ok=True) 

404 

405 dest = _safe_join(folder, relpath) 

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

407 if os.path.exists(dest): 

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

409 relpath = os.path.join( 

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

411 ) 

412 dest = _safe_join(folder, relpath) 

413 

414 file.save(dest) 

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

416 size = os.path.getsize(dest) 

417 return relpath, mime, size 

418 

419 

420def _current_role() -> Role | None: 

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

422 return tu.role if tu else None 

423 

424 

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

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

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

428 

429 

430# ── Title suggestions ───────────────────────────────────────────────────────── 

431 

432 

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

434@login_required 

435def title_suggestions() -> ResponseReturnValue: 

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

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

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

439 

440 if owner_type == "pilot": 

441 base = Document.query.filter( 

442 Document.pilot_user_id == uid, 

443 Document.title.isnot(None), 

444 ) 

445 else: 

446 tid = _tenant_id() 

447 aircraft_ids = [ 

448 row.id 

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

450 .with_entities(Aircraft.id) 

451 .all() 

452 ] 

453 base = Document.query.filter( 

454 Document.aircraft_id.in_(aircraft_ids), 

455 Document.title.isnot(None), 

456 ) 

457 if owner_type == "component": 

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

459 else: 

460 base = base.filter( 

461 Document.component_id.is_(None), 

462 Document.flight_entry_id.is_(None), 

463 ) 

464 

465 if q: 

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

467 

468 rows = ( 

469 base.with_entities(Document.title) 

470 .distinct() 

471 .order_by(Document.title) 

472 .limit(10) 

473 .all() 

474 ) 

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

476 

477 

478# ── Aircraft document list ──────────────────────────────────────────────────── 

479 

480 

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

482@login_required 

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

484 ac = _get_aircraft_or_404(aircraft_id) 

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

486 doc_type_labels = dict(_AIRCRAFT_DOC_TYPES) 

487 doc_type_labels[_NO_TYPE_FILTER] = _NO_TYPE_LABEL 

488 filter_doc_type = request.args.get("doc_type") or None 

489 if filter_doc_type not in doc_type_labels: 

490 filter_doc_type = None 

491 query = Document.query.filter_by(aircraft_id=ac.id).options( 

492 joinedload(Document.component), # type: ignore[arg-type] 

493 joinedload(Document.flight_entry), # type: ignore[arg-type] 

494 joinedload(Document.expense), # type: ignore[arg-type] 

495 ) 

496 if not show_sensitive: 

497 query = query.filter_by(is_sensitive=False) 

498 if filter_doc_type == _NO_TYPE_FILTER: 

499 query = query.filter(Document.doc_type.is_(None)) 

500 elif filter_doc_type: 

501 query = query.filter_by(doc_type=filter_doc_type) 

502 query = query.order_by(Document.uploaded_at.desc()) 

503 

504 page = request.args.get("page", 1, type=int) 

505 pp_raw = request.args.get("per_page", str(_DOCS_DEFAULT_PER_PAGE)) 

506 show_all = pp_raw == "all" 

507 per_page = ( 

508 None 

509 if show_all 

510 else ( 

511 int(pp_raw) 

512 if pp_raw.isdigit() and int(pp_raw) in _DOCS_VALID_PER_PAGE 

513 else _DOCS_DEFAULT_PER_PAGE 

514 ) 

515 ) 

516 if show_all: 

517 docs = query.all() 

518 pagination = None 

519 total_count = len(docs) 

520 else: 

521 pagination = query.paginate(page=page, per_page=per_page, error_out=False) 

522 docs = pagination.items 

523 total_count = pagination.total 

524 

525 sensitive_count = Document.query.filter_by( 

526 aircraft_id=ac.id, is_sensitive=True 

527 ).count() 

528 role = _current_role() 

529 is_owner = role in _OWNER_ROLES 

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

531 # One active document per doc_type actually present in this list -- 

532 # cheap even unfiltered, since an aircraft only ever has the two 

533 # expiry-driving types (ARC/insurance) to look up. 

534 doc_types_shown = { 

535 d.doc_type for d in docs if d.doc_type in _EXPIRY_DRIVING_DOC_TYPES 

536 } 

537 active_doc_ids = { 

538 active.id 

539 for dt in doc_types_shown 

540 for active in [active_document_for(ac.id, dt, component_id=None)] 

541 if active is not None 

542 } 

543 return render_template( 

544 "documents/list.html", 

545 aircraft=ac, 

546 docs=docs, 

547 pagination=pagination, 

548 per_page=pp_raw, 

549 valid_per_page=_DOCS_VALID_PER_PAGE, 

550 total_count=total_count, 

551 show_sensitive=show_sensitive, 

552 sensitive_count=sensitive_count, 

553 is_owner=is_owner, 

554 broken_ids=broken_ids, 

555 category_labels=_CATEGORY_LABELS, 

556 filter_doc_type=filter_doc_type, 

557 doc_type_filter_options=list(doc_type_labels.items()), 

558 active_doc_ids=active_doc_ids, 

559 expiry_driving_doc_types=_EXPIRY_DRIVING_DOC_TYPES, 

560 ) 

561 

562 

563# ── Upload aircraft document ────────────────────────────────────────────────── 

564 

565 

566@documents_bp.route( 

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

568) 

569@login_required 

570@require_role(*_OWNER_ROLES) 

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

572 ac = _get_aircraft_or_404(aircraft_id) 

573 component = _resolve_component(ac) 

574 

575 if request.method == "POST": 

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

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

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

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

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

581 if category and category not in DocCategory.ALL: 

582 category = None 

583 valid_from = _parse_date_field(request.form.get("valid_from", "")) 

584 valid_until = _parse_date_field(request.form.get("valid_until", "")) 

585 

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

587 if msg: 

588 flash(msg, "danger") 

589 return render_template( 

590 "documents/upload_form.html", 

591 aircraft=ac, 

592 component=component, 

593 doc_types=_AIRCRAFT_DOC_TYPES, 

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

595 ) 

596 

597 if not file or not file.filename: 

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

599 

600 original = secure_filename(file.filename) 

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

602 if ext not in _ALLOWED_EXTS: 

603 return _re_render( 

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

605 ) 

606 

607 if valid_from and valid_until and valid_from > valid_until: 

608 return _re_render(_("'Valid from' must not be after 'Valid until'.")) 

609 

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

611 if category and not component: 

612 tenant = _get_tenant() 

613 stored, mime, size = _save_upload_canonical( 

614 file, tenant, ac, category, title 

615 ) 

616 else: 

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

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

619 

620 doc = Document( 

621 aircraft_id=ac.id, 

622 component_id=component.id if component else None, 

623 filename=stored, 

624 original_filename=original, 

625 mime_type=mime, 

626 size_bytes=size, 

627 title=title, 

628 doc_type=doc_type, 

629 category=category, 

630 valid_from=valid_from, 

631 valid_until=valid_until, 

632 is_sensitive=is_sensitive, 

633 ) 

634 db.session.add(doc) 

635 

636 # Insurance document without an explicit type: infer it from the 

637 # category for backward compatibility with the category-only flow. 

638 if doc.doc_type is None and category == DocCategory.INSURANCE and not component: 

639 doc.doc_type = DocType.INSURANCE_CERT 

640 

641 # A new insurance/ARC document becomes the active certificate for its 

642 # type — supersede whichever one was active before it, then recompute 

643 # the aircraft's synced insurance_expiry/arc_expiry from what's now 

644 # active (see _recompute_expiry_field). 

645 if doc.doc_type in _EXPIRY_DRIVING_DOC_TYPES and not component: 

646 db.session.flush() 

647 prev_cert = Document.query.filter( 

648 Document.aircraft_id == ac.id, 

649 Document.doc_type == doc.doc_type, 

650 Document.superseded_by_id.is_(None), 

651 Document.id != doc.id, 

652 ).first() 

653 if prev_cert: 

654 prev_cert.superseded_by_id = doc.id 

655 _recompute_expiry_field(ac, doc.doc_type) 

656 

657 db.session.commit() 

658 activity( 

659 "document.uploaded", 

660 document_id=doc.id, 

661 aircraft_id=ac.id, 

662 title=doc.title or "", 

663 ) 

664 

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

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

667 

668 return render_template( 

669 "documents/upload_form.html", 

670 aircraft=ac, 

671 component=component, 

672 doc_types=_AIRCRAFT_DOC_TYPES, 

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

674 ) 

675 

676 

677# ── Edit aircraft document ──────────────────────────────────────────────────── 

678 

679 

680@documents_bp.route( 

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

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

683) 

684@login_required 

685@require_role(*_OWNER_ROLES) 

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

687 ac = _get_aircraft_or_404(aircraft_id) 

688 doc = _get_aircraft_document_or_404(ac, document_id) 

689 

690 if request.method == "POST": 

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

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

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

694 if category and category not in DocCategory.ALL: 

695 category = None 

696 

697 old_doc_type = doc.doc_type 

698 if not doc.component_id: 

699 new_doc_type = request.form.get("doc_type") or None 

700 if new_doc_type in dict(_AIRCRAFT_DOC_TYPES) or new_doc_type is None: 

701 doc.doc_type = new_doc_type 

702 

703 old_category = doc.category 

704 doc.category = category 

705 

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

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

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

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

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

711 old_full = _safe_join(folder, doc.filename) 

712 if os.path.exists(old_full): 

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

714 new_full = _safe_join(folder, new_relpath) 

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

716 try: 

717 os.rename(old_full, new_full) 

718 doc.filename = new_relpath 

719 except OSError as exc: 

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

721 

722 new_valid_from = _parse_date_field(request.form.get("valid_from", "")) 

723 new_valid_until = _parse_date_field(request.form.get("valid_until", "")) 

724 if new_valid_from and new_valid_until and new_valid_from > new_valid_until: 

725 flash(_("'Valid from' must not be after 'Valid until'."), "danger") 

726 return render_template( 

727 "documents/edit_form.html", 

728 aircraft=ac, 

729 doc=doc, 

730 doc_types=_AIRCRAFT_DOC_TYPES, 

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

732 ) 

733 doc.valid_from = new_valid_from 

734 doc.valid_until = new_valid_until 

735 

736 # Recompute whichever Aircraft field(s) this document could affect — 

737 # both its old and new doc_type, in case the type itself changed. 

738 _recompute_expiry_field(ac, old_doc_type) 

739 _recompute_expiry_field(ac, doc.doc_type) 

740 

741 db.session.commit() 

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

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

744 

745 return render_template( 

746 "documents/edit_form.html", 

747 aircraft=ac, 

748 doc=doc, 

749 doc_types=_AIRCRAFT_DOC_TYPES, 

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

751 ) 

752 

753 

754# ── Delete aircraft document ────────────────────────────────────────────────── 

755 

756 

757@documents_bp.route( 

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

759 methods=["POST"], 

760) 

761@login_required 

762@require_role(*_OWNER_ROLES) 

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

764 ac = _get_aircraft_or_404(aircraft_id) 

765 doc = _get_aircraft_document_or_404(ac, document_id) 

766 doc_type = doc.doc_type 

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

768 _delete_file(doc.filename) 

769 db.session.delete(doc) 

770 db.session.commit() 

771 

772 # If the deleted document drove an Aircraft expiry field, recompute it — 

773 # deleting the active cert reactivates whatever it had superseded (its 

774 # own superseded_by_id is cleared by the FK's ON DELETE SET NULL) or 

775 # clears the field if nothing is left. 

776 if doc_type in _EXPIRY_DRIVING_DOC_TYPES: 

777 _recompute_expiry_field(ac, doc_type) 

778 db.session.commit() 

779 

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

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

782 

783 

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

785 

786 

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

788@login_required 

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

790 ac = _get_aircraft_or_404(aircraft_id) 

791 role = _current_role() 

792 include_sensitive = role in _OWNER_ROLES 

793 

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

795 if not include_sensitive: 

796 query = query.filter_by(is_sensitive=False) 

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

798 

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

800 buf = io.BytesIO() 

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

802 

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

804 for doc in docs: 

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

806 arcname = doc.original_filename 

807 if os.path.exists(path): 

808 zf.write(path, arcname=arcname) 

809 doc_type_label = doc.doc_type or "" 

810 category_label = _CATEGORY_LABELS.get( 

811 doc.category or "", doc.category or "" 

812 ) 

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

814 manifest_lines.append( 

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

816 ) 

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

818 

819 buf.seek(0) 

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

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

822 return Response( 

823 buf.read(), 

824 mimetype="application/zip", 

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

826 ) 

827 

828 

829# ── Insurance / ARC certificate quick upload ────────────────────────────────── 

830 

831 

832def _upload_expiry_cert( 

833 aircraft_id: int, doc_type: str, category: str, title: str 

834) -> ResponseReturnValue: 

835 """Shared handler for the small "Upload cert" forms on the aircraft 

836 detail page's Insurance and ARC sections — a file plus its valid-until 

837 date, which becomes the new active certificate for that doc_type.""" 

838 ac = _get_aircraft_or_404(aircraft_id) 

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

840 

841 if not file or not file.filename: 

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

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

844 

845 original = secure_filename(file.filename) 

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

847 if ext not in _ALLOWED_EXTS: 

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

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

850 

851 valid_from = _parse_date_field(request.form.get("valid_from", "")) 

852 valid_until = _parse_date_field(request.form.get("valid_until", "")) 

853 if valid_from and valid_until and valid_from > valid_until: 

854 flash(_("'Valid from' must not be after 'Valid until'."), "danger") 

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

856 

857 tenant = _get_tenant() 

858 stored, mime, size = _save_upload_canonical(file, tenant, ac, category, title) 

859 

860 prev = Document.query.filter_by( 

861 aircraft_id=ac.id, 

862 doc_type=doc_type, 

863 superseded_by_id=None, 

864 component_id=None, 

865 ).first() 

866 

867 new_cert = Document( 

868 aircraft_id=ac.id, 

869 filename=stored, 

870 original_filename=original, 

871 mime_type=mime, 

872 size_bytes=size, 

873 title=title, 

874 doc_type=doc_type, 

875 category=category, 

876 valid_from=valid_from, 

877 valid_until=valid_until, 

878 is_sensitive=True, 

879 ) 

880 db.session.add(new_cert) 

881 db.session.flush() 

882 

883 if prev: 

884 prev.superseded_by_id = new_cert.id 

885 

886 _recompute_expiry_field(ac, doc_type) 

887 db.session.commit() 

888 flash(_("Certificate uploaded."), "success") 

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

890 

891 

892@documents_bp.route( 

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

894) 

895@login_required 

896@require_role(*_OWNER_ROLES) 

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

898 return _upload_expiry_cert( 

899 aircraft_id, 

900 DocType.INSURANCE_CERT, 

901 DocCategory.INSURANCE, 

902 _("Insurance Certificate"), 

903 ) 

904 

905 

906@documents_bp.route( 

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

908) 

909@login_required 

910@require_role(*_OWNER_ROLES) 

911def upload_arc_cert(aircraft_id: int) -> ResponseReturnValue: 

912 return _upload_expiry_cert( 

913 aircraft_id, 

914 DocType.ARC, 

915 DocCategory.AIRWORTHINESS, 

916 _("Airworthiness Review Certificate"), 

917 ) 

918 

919 

920# ── Pilot document upload ───────────────────────────────────────────────────── 

921 

922 

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

924@login_required 

925def upload_pilot_document() -> ResponseReturnValue: 

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

927 

928 if request.method == "POST": 

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

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

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

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

933 valid_until = None 

934 if valid_until_str: 

935 try: 

936 valid_until = _date.fromisoformat(valid_until_str) 

937 except ValueError as exc: 

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

939 

940 if not file or not file.filename: 

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

942 return render_template( 

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

944 ) 

945 

946 original = secure_filename(file.filename) 

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

948 if ext not in _ALLOWED_EXTS: 

949 flash( 

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

951 ) 

952 return render_template( 

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

954 ) 

955 

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

957 

958 doc = Document( 

959 pilot_user_id=uid, 

960 filename=stored, 

961 original_filename=original, 

962 mime_type=mime, 

963 size_bytes=size, 

964 title=title, 

965 doc_type=doc_type, 

966 valid_until=valid_until, 

967 is_sensitive=True, 

968 ) 

969 db.session.add(doc) 

970 db.session.commit() 

971 

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

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

974 

975 return render_template( 

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

977 ) 

978 

979 

980# ── Pilot document delete ───────────────────────────────────────────────────── 

981 

982 

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

984@login_required 

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

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

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

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

989 abort(404) 

990 _delete_file(doc.filename) 

991 db.session.delete(doc) 

992 db.session.commit() 

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

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

995 

996 

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

998 

999 

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

1001@login_required 

1002@require_role(*_OWNER_ROLES) 

1003def list_reconcile() -> ResponseReturnValue: 

1004 import difflib 

1005 

1006 tenant = _get_tenant() 

1007 pending = ( 

1008 PendingReconcile.query.filter_by( 

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

1010 ) 

1011 .order_by(PendingReconcile.detected_at.desc()) 

1012 .all() 

1013 ) 

1014 aircraft_list = ( 

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

1016 .order_by(Aircraft.registration) 

1017 .all() 

1018 ) 

1019 

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

1021 category_suggestions: dict[ 

1022 int, tuple[str, str] 

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

1024 for pr in pending: 

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

1026 if len(parts) >= 4: 

1027 raw = parts[2] 

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

1029 close = difflib.get_close_matches( 

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

1031 ) 

1032 if close: 

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

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

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

1036 

1037 return render_template( 

1038 "documents/reconcile.html", 

1039 tenant=tenant, 

1040 pending=pending, 

1041 aircraft_list=aircraft_list, 

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

1043 category_suggestions=category_suggestions, 

1044 ) 

1045 

1046 

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

1048@login_required 

1049@require_role(*_OWNER_ROLES) 

1050def scan_documents() -> ResponseReturnValue: 

1051 tenant = _get_tenant() 

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

1053 

1054 if not tenant.slug: 

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

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

1057 

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

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

1060 flash( 

1061 _( 

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

1063 slug=tenant.slug, 

1064 ), 

1065 "info", 

1066 ) 

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

1068 

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

1070 tid = tenant.id 

1071 known: set[str] = { 

1072 doc.filename 

1073 for doc in Document.query.filter( 

1074 Document.aircraft_id.in_( 

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

1076 ) 

1077 ).all() 

1078 } 

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

1080 stale_removed = 0 

1081 for pr in PendingReconcile.query.filter_by( 

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

1083 ).all(): 

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

1085 db.session.delete(pr) 

1086 stale_removed += 1 

1087 if stale_removed: 

1088 db.session.flush() 

1089 

1090 existing_pending: set[str] = { 

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

1092 } 

1093 

1094 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

1097 } 

1098 

1099 new_count = 0 

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

1101 for fname in filenames: 

1102 if fname.startswith((".", "_")): 

1103 continue 

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

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

1106 # Normalise to forward slashes for DB consistency 

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

1108 

1109 if relpath in known or relpath in existing_pending: 

1110 continue 

1111 

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

1113 parts = relpath.split("/") 

1114 if is_app_managed(relpath): 

1115 continue 

1116 aircraft_obj: Aircraft | None = None 

1117 category: str | None = None 

1118 title_hint: str | None = None 

1119 date_hint: _date | None = None 

1120 

1121 if len(parts) >= 4: 

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

1123 aircraft_obj = aircraft_by_reg.get(reg_raw) 

1124 category, title_hint, date_hint = _parse_reconcile_filename( 

1125 parts[2], parts[3] 

1126 ) 

1127 

1128 pr = PendingReconcile( 

1129 tenant_id=tid, 

1130 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

1131 filepath=relpath, 

1132 category=category, 

1133 title_hint=title_hint, 

1134 date_hint=date_hint, 

1135 ) 

1136 db.session.add(pr) 

1137 new_count += 1 

1138 

1139 db.session.commit() 

1140 parts_msg = [] 

1141 if new_count: 

1142 parts_msg.append( 

1143 ngettext( 

1144 "one new file queued for review", 

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

1146 new_count, 

1147 n=new_count, 

1148 ) 

1149 ) 

1150 if stale_removed: 

1151 parts_msg.append( 

1152 ngettext( 

1153 "one missing file removed from queue", 

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

1155 stale_removed, 

1156 n=stale_removed, 

1157 ) 

1158 ) 

1159 if parts_msg: 

1160 flash( 

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

1162 ) 

1163 else: 

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

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

1166 

1167 

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

1169@login_required 

1170@require_role(*_OWNER_ROLES) 

1171def rename_reconcile_folder() -> ResponseReturnValue: 

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

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

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

1175 """ 

1176 import shutil 

1177 

1178 tenant = _get_tenant() 

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

1180 

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

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

1183 

1184 if new_category not in DocCategory.ALL: 

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

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

1187 

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

1189 abort(403) 

1190 

1191 old_dir = _safe_join(folder, bad_folder) 

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

1193 new_rel = parent_rel + "/" + new_category 

1194 new_dir = _safe_join(folder, new_rel) 

1195 

1196 if os.path.isdir(old_dir): 

1197 if os.path.isdir(new_dir): 

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

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

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

1201 os.makedirs(dest_d, exist_ok=True) 

1202 for fname in filenames: 

1203 shutil.move( 

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

1205 ) 

1206 shutil.rmtree(old_dir, ignore_errors=True) 

1207 else: 

1208 os.rename(old_dir, new_dir) 

1209 

1210 # Prune stale pending entries for the old folder path 

1211 tid = tenant.id 

1212 for pr in PendingReconcile.query.filter( 

1213 PendingReconcile.tenant_id == tid, 

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

1215 ).all(): 

1216 db.session.delete(pr) 

1217 db.session.flush() 

1218 

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

1220 known: set[str] = { 

1221 doc.filename 

1222 for doc in Document.query.filter( 

1223 Document.aircraft_id.in_( 

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

1225 ) 

1226 ).all() 

1227 } 

1228 existing_pending: set[str] = { 

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

1230 } 

1231 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

1234 } 

1235 new_count = 0 

1236 if os.path.isdir(new_dir): 

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

1238 for fname in filenames: 

1239 if fname.startswith((".", "_")): 

1240 continue 

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

1242 "\\", "/" 

1243 ) 

1244 full = _safe_join(folder, relpath) 

1245 if relpath in known or relpath in existing_pending: 

1246 continue 

1247 parts = relpath.split("/") 

1248 aircraft_obj: Aircraft | None = None 

1249 category: str | None = None 

1250 title_hint: str | None = None 

1251 date_hint: _date | None = None 

1252 if len(parts) >= 4: 

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

1254 aircraft_obj = aircraft_by_reg.get(reg_raw) 

1255 category, title_hint, date_hint = _parse_reconcile_filename( 

1256 parts[2], parts[3] 

1257 ) 

1258 if aircraft_obj and category: 

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

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

1261 doc = Document( 

1262 aircraft_id=aircraft_obj.id, 

1263 filename=relpath, 

1264 original_filename=fname, 

1265 mime_type=mime, 

1266 size_bytes=size, 

1267 title=title_hint, 

1268 category=category, 

1269 ) 

1270 db.session.add(doc) 

1271 else: 

1272 pr = PendingReconcile( 

1273 tenant_id=tid, 

1274 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

1275 filepath=relpath, 

1276 category=category, 

1277 title_hint=title_hint, 

1278 date_hint=date_hint, 

1279 ) 

1280 db.session.add(pr) 

1281 new_count += 1 

1282 

1283 db.session.commit() 

1284 flash( 

1285 ngettext( 

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

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

1288 new_count, 

1289 cat=new_category, 

1290 n=new_count, 

1291 ), 

1292 "success", 

1293 ) 

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

1295 

1296 

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

1298@login_required 

1299@require_role(*_OWNER_ROLES) 

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

1301 tenant = _get_tenant() 

1302 pr = PendingReconcile.query.filter_by( 

1303 id=pending_id, tenant_id=tenant.id 

1304 ).first_or_404() 

1305 

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

1307 try: 

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

1309 except (ValueError, TypeError): 

1310 aircraft_id = None 

1311 if aircraft_id is not None: 

1312 _get_aircraft_or_404(aircraft_id) 

1313 

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

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

1316 if category and category not in DocCategory.ALL: 

1317 category = None 

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

1319 valid_until: _date | None = None 

1320 if valid_until_str: 

1321 with contextlib.suppress( 

1322 ValueError 

1323 ): # malformed date submitted; valid_until stays None 

1324 valid_until = _date.fromisoformat(valid_until_str) 

1325 

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

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

1328 mime = ( 

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

1330 or "application/octet-stream" 

1331 ) 

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

1333 

1334 doc = Document( 

1335 aircraft_id=aircraft_id, 

1336 filename=pr.filepath, 

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

1338 mime_type=mime, 

1339 size_bytes=size, 

1340 title=title, 

1341 category=category, 

1342 valid_until=valid_until, 

1343 is_sensitive=False, 

1344 ) 

1345 db.session.add(doc) 

1346 pr.reconciled_at = datetime.now(UTC) 

1347 db.session.commit() 

1348 

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

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

1351 

1352 

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

1354@login_required 

1355@require_role(*_OWNER_ROLES) 

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

1357 tenant = _get_tenant() 

1358 pr = PendingReconcile.query.filter_by( 

1359 id=pending_id, tenant_id=tenant.id 

1360 ).first_or_404() 

1361 pr.ignored = True 

1362 db.session.commit() 

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

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