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

625 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-23 09:59 +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 utils import ( # pyright: ignore[reportMissingImports] 

42 activity, 

43 login_required, 

44 require_role, 

45 user_can_access_aircraft, 

46) 

47from werkzeug.datastructures import FileStorage 

48from werkzeug.utils import secure_filename 

49 

50log = logging.getLogger(__name__) 

51 

52documents_bp = Blueprint("documents", __name__) 

53 

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

55 

56_ALLOWED_EXTS = { 

57 ".jpg", 

58 ".jpeg", 

59 ".png", 

60 ".gif", 

61 ".webp", 

62 ".heic", 

63 ".pdf", 

64 ".doc", 

65 ".docx", 

66 ".xls", 

67 ".xlsx", 

68 ".txt", 

69} 

70 

71_PILOT_DOC_TYPES = [ 

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

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

74] 

75 

76_AIRCRAFT_DOC_TYPES = [ 

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

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

79] 

80 

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

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

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

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

85# real types drive (see _EXPIRY_DRIVING_DOC_TYPES). 

86_NO_TYPE_FILTER = "none" 

87_NO_TYPE_LABEL = _l("No type set") 

88 

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

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

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

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

93_EXPIRY_DRIVING_DOC_TYPES: dict[str, str] = { 

94 DocType.INSURANCE_CERT: "insurance_expiry", 

95 DocType.ARC: "arc_expiry", 

96} 

97 

98# Human-readable labels for each DocCategory value 

99_CATEGORY_LABELS: dict[str, str] = { 

100 DocCategory.MAINTENANCE: "Maintenance", 

101 DocCategory.INSURANCE: "Insurance", 

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

103 DocCategory.AIRWORTHINESS: "Airworthiness", 

104 DocCategory.LOGBOOK: "Logbook", 

105 DocCategory.INVOICE: "Invoice", 

106 DocCategory.OTHER: "Other", 

107 DocCategory.UNCATEGORISED: "Uncategorised", 

108} 

109 

110 

111# ── Helpers ─────────────────────────────────────────────────────────────────── 

112 

113 

114def _tenant_id() -> int: 

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

116 if not tu: 

117 abort(403) 

118 return int(tu.tenant_id) 

119 

120 

121def _get_tenant() -> Tenant: 

122 tid = _tenant_id() 

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

124 if not t: 

125 abort(403) # pragma: no cover 

126 return t 

127 

128 

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

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

131 if ( 

132 not ac 

133 or ac.tenant_id != _tenant_id() 

134 or not user_can_access_aircraft(aircraft_id) 

135 ): 

136 abort(404) 

137 return ac 

138 

139 

140def active_document_for( 

141 aircraft_id: int, 

142 doc_type: str, 

143 component_id: int | None = None, 

144 as_of: _date | None = None, 

145) -> Document | None: 

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

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

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

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

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

151 documents that never set valid_from is unchanged from before this 

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

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

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

155 as_of = as_of or _date.today() 

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

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

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

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

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

161 result: Document | None = ( 

162 Document.query.filter( 

163 Document.aircraft_id == aircraft_id, 

164 Document.doc_type == doc_type, 

165 Document.component_id == component_id, 

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

167 ) 

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

169 .first() 

170 ) 

171 return result 

172 

173 

174def effective_coverage_until( 

175 aircraft_id: int, 

176 doc_type: str, 

177 component_id: int | None = None, 

178 as_of: _date | None = None, 

179) -> _date | None: 

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

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

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

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

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

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

186 

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

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

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

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

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

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

193 still be reported as such. 

194 """ 

195 as_of = as_of or _date.today() 

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

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

198 return None 

199 

200 frontier: _date = active.valid_until 

201 while True: 

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

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

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

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

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

207 # loop. 

208 candidates = Document.query.filter( 

209 Document.aircraft_id == aircraft_id, 

210 Document.doc_type == doc_type, 

211 Document.component_id == component_id, 

212 Document.valid_from > as_of, 

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

214 Document.valid_until.isnot(None), 

215 Document.valid_until > frontier, 

216 ).all() 

217 nxt: Document | None = None 

218 for c in candidates: 

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

220 nxt = c 

221 if nxt is None: 

222 return frontier 

223 frontier = nxt.valid_until 

224 

225 

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

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

228 Document of that doc_type is currently active (see 

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

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

231 services.notification_service._recompute_all_expiry_fields) so a 

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

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

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

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

236 if not field: 

237 return 

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

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

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

241 

242 

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

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

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

246 valid_until has always been handled here.""" 

247 raw = raw.strip() 

248 if not raw: 

249 return None 

250 try: 

251 return _date.fromisoformat(raw) 

252 except ValueError as exc: 

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

254 return None 

255 

256 

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

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

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

260 abort(404) 

261 return doc 

262 

263 

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

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

266 

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

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

269 """ 

270 if not filename: 

271 return 

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

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

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

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

276 return 

277 try: 

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

279 os.makedirs(trash_dir, exist_ok=True) 

280 dest_name = os.path.basename(filename) 

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

282 if os.path.exists(dest): 

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

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

285 os.rename(src, dest) 

286 except OSError: 

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

288 

289 

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

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

292 if not raw: 

293 return None 

294 try: 

295 cid = int(raw) 

296 except (ValueError, TypeError): 

297 return None 

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

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

300 

301 

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

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

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

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

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

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

308 os.makedirs(folder, exist_ok=True) 

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

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

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

312 return stored, mime, size 

313 

314 

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

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

317 if tenant.slug: 

318 return str(tenant.slug) 

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

320 slug = base 

321 n = 1 

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

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

324 n += 1 

325 tenant.slug = slug 

326 db.session.flush() 

327 return slug 

328 

329 

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

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

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

333 

334 

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

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

337 root = os.path.realpath(upload_folder) 

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

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

340 abort(400) 

341 return joined 

342 

343 

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

345 

346 

347def _parse_reconcile_filename( 

348 cat_str: str, name_part: str 

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

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

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

352 date_hint). Shared by scan_documents and rename_reconcile_folder's 

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

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

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

356 title_hint: str | None = None 

357 date_hint: _date | None = None 

358 m = _RECONCILE_FILENAME_RE.match(name_part) 

359 if m: 

360 with contextlib.suppress( 

361 ValueError 

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

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

364 title_hint = m.group(2) 

365 else: 

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

367 return category, title_hint, date_hint 

368 

369 

370def _save_upload_canonical( 

371 file: FileStorage, 

372 tenant: Tenant, 

373 aircraft: Aircraft, 

374 category: str, 

375 title: str | None, 

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

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

378 

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

380 UPLOAD_FOLDER and suitable for storage in Document.filename. 

381 """ 

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

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

384 today = _date.today().isoformat() 

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

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

387 

388 slug = _ensure_tenant_slug(tenant) 

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

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

391 

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

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

394 os.makedirs(full_dir, exist_ok=True) 

395 

396 dest = _safe_join(folder, relpath) 

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

398 if os.path.exists(dest): 

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

400 relpath = os.path.join( 

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

402 ) 

403 dest = _safe_join(folder, relpath) 

404 

405 file.save(dest) 

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

407 size = os.path.getsize(dest) 

408 return relpath, mime, size 

409 

410 

411def _current_role() -> Role | None: 

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

413 return tu.role if tu else None 

414 

415 

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

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

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

419 

420 

421# ── Title suggestions ───────────────────────────────────────────────────────── 

422 

423 

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

425@login_required 

426def title_suggestions() -> ResponseReturnValue: 

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

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

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

430 

431 if owner_type == "pilot": 

432 base = Document.query.filter( 

433 Document.pilot_user_id == uid, 

434 Document.title.isnot(None), 

435 ) 

436 else: 

437 tid = _tenant_id() 

438 aircraft_ids = [ 

439 row.id 

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

441 .with_entities(Aircraft.id) 

442 .all() 

443 ] 

444 base = Document.query.filter( 

445 Document.aircraft_id.in_(aircraft_ids), 

446 Document.title.isnot(None), 

447 ) 

448 if owner_type == "component": 

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

450 else: 

451 base = base.filter( 

452 Document.component_id.is_(None), 

453 Document.flight_entry_id.is_(None), 

454 ) 

455 

456 if q: 

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

458 

459 rows = ( 

460 base.with_entities(Document.title) 

461 .distinct() 

462 .order_by(Document.title) 

463 .limit(10) 

464 .all() 

465 ) 

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

467 

468 

469# ── Aircraft document list ──────────────────────────────────────────────────── 

470 

471 

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

473@login_required 

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

475 ac = _get_aircraft_or_404(aircraft_id) 

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

477 doc_type_labels = dict(_AIRCRAFT_DOC_TYPES) 

478 doc_type_labels[_NO_TYPE_FILTER] = _NO_TYPE_LABEL 

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

480 if filter_doc_type not in doc_type_labels: 

481 filter_doc_type = None 

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

483 if not show_sensitive: 

484 query = query.filter_by(is_sensitive=False) 

485 if filter_doc_type == _NO_TYPE_FILTER: 

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

487 elif filter_doc_type: 

488 query = query.filter_by(doc_type=filter_doc_type) 

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

490 sensitive_count = Document.query.filter_by( 

491 aircraft_id=ac.id, is_sensitive=True 

492 ).count() 

493 role = _current_role() 

494 is_owner = role in _OWNER_ROLES 

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

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

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

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

499 doc_types_shown = { 

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

501 } 

502 active_doc_ids = { 

503 active.id 

504 for dt in doc_types_shown 

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

506 if active is not None 

507 } 

508 return render_template( 

509 "documents/list.html", 

510 aircraft=ac, 

511 docs=docs, 

512 show_sensitive=show_sensitive, 

513 sensitive_count=sensitive_count, 

514 is_owner=is_owner, 

515 broken_ids=broken_ids, 

516 category_labels=_CATEGORY_LABELS, 

517 filter_doc_type=filter_doc_type, 

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

519 active_doc_ids=active_doc_ids, 

520 expiry_driving_doc_types=_EXPIRY_DRIVING_DOC_TYPES, 

521 ) 

522 

523 

524# ── Upload aircraft document ────────────────────────────────────────────────── 

525 

526 

527@documents_bp.route( 

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

529) 

530@login_required 

531@require_role(*_OWNER_ROLES) 

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

533 ac = _get_aircraft_or_404(aircraft_id) 

534 component = _resolve_component(ac) 

535 

536 if request.method == "POST": 

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

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

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

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

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

542 if category and category not in DocCategory.ALL: 

543 category = None 

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

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

546 

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

548 if msg: 

549 flash(msg, "danger") 

550 return render_template( 

551 "documents/upload_form.html", 

552 aircraft=ac, 

553 component=component, 

554 doc_types=_AIRCRAFT_DOC_TYPES, 

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

556 ) 

557 

558 if not file or not file.filename: 

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

560 

561 original = secure_filename(file.filename) 

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

563 if ext not in _ALLOWED_EXTS: 

564 return _re_render( 

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

566 ) 

567 

568 if valid_from and valid_until and valid_from > valid_until: 

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

570 

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

572 if category and not component: 

573 tenant = _get_tenant() 

574 stored, mime, size = _save_upload_canonical( 

575 file, tenant, ac, category, title 

576 ) 

577 else: 

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

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

580 

581 doc = Document( 

582 aircraft_id=ac.id, 

583 component_id=component.id if component else None, 

584 filename=stored, 

585 original_filename=original, 

586 mime_type=mime, 

587 size_bytes=size, 

588 title=title, 

589 doc_type=doc_type, 

590 category=category, 

591 valid_from=valid_from, 

592 valid_until=valid_until, 

593 is_sensitive=is_sensitive, 

594 ) 

595 db.session.add(doc) 

596 

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

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

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

600 doc.doc_type = DocType.INSURANCE_CERT 

601 

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

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

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

605 # active (see _recompute_expiry_field). 

606 if doc.doc_type in _EXPIRY_DRIVING_DOC_TYPES and not component: 

607 db.session.flush() 

608 prev_cert = Document.query.filter( 

609 Document.aircraft_id == ac.id, 

610 Document.doc_type == doc.doc_type, 

611 Document.superseded_by_id.is_(None), 

612 Document.id != doc.id, 

613 ).first() 

614 if prev_cert: 

615 prev_cert.superseded_by_id = doc.id 

616 _recompute_expiry_field(ac, doc.doc_type) 

617 

618 db.session.commit() 

619 activity( 

620 "document.uploaded", 

621 document_id=doc.id, 

622 aircraft_id=ac.id, 

623 title=doc.title or "", 

624 ) 

625 

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

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

628 

629 return render_template( 

630 "documents/upload_form.html", 

631 aircraft=ac, 

632 component=component, 

633 doc_types=_AIRCRAFT_DOC_TYPES, 

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

635 ) 

636 

637 

638# ── Edit aircraft document ──────────────────────────────────────────────────── 

639 

640 

641@documents_bp.route( 

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

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

644) 

645@login_required 

646@require_role(*_OWNER_ROLES) 

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

648 ac = _get_aircraft_or_404(aircraft_id) 

649 doc = _get_aircraft_document_or_404(ac, document_id) 

650 

651 if request.method == "POST": 

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

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

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

655 if category and category not in DocCategory.ALL: 

656 category = None 

657 

658 old_doc_type = doc.doc_type 

659 if not doc.component_id: 

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

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

662 doc.doc_type = new_doc_type 

663 

664 old_category = doc.category 

665 doc.category = category 

666 

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

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

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

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

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

672 old_full = _safe_join(folder, doc.filename) 

673 if os.path.exists(old_full): 

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

675 new_full = _safe_join(folder, new_relpath) 

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

677 try: 

678 os.rename(old_full, new_full) 

679 doc.filename = new_relpath 

680 except OSError as exc: 

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

682 

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

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

685 if new_valid_from and new_valid_until and new_valid_from > new_valid_until: 

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

687 return render_template( 

688 "documents/edit_form.html", 

689 aircraft=ac, 

690 doc=doc, 

691 doc_types=_AIRCRAFT_DOC_TYPES, 

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

693 ) 

694 doc.valid_from = new_valid_from 

695 doc.valid_until = new_valid_until 

696 

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

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

699 _recompute_expiry_field(ac, old_doc_type) 

700 _recompute_expiry_field(ac, doc.doc_type) 

701 

702 db.session.commit() 

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

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

705 

706 return render_template( 

707 "documents/edit_form.html", 

708 aircraft=ac, 

709 doc=doc, 

710 doc_types=_AIRCRAFT_DOC_TYPES, 

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

712 ) 

713 

714 

715# ── Delete aircraft document ────────────────────────────────────────────────── 

716 

717 

718@documents_bp.route( 

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

720 methods=["POST"], 

721) 

722@login_required 

723@require_role(*_OWNER_ROLES) 

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

725 ac = _get_aircraft_or_404(aircraft_id) 

726 doc = _get_aircraft_document_or_404(ac, document_id) 

727 doc_type = doc.doc_type 

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

729 _delete_file(doc.filename) 

730 db.session.delete(doc) 

731 db.session.commit() 

732 

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

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

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

736 # clears the field if nothing is left. 

737 if doc_type in _EXPIRY_DRIVING_DOC_TYPES: 

738 _recompute_expiry_field(ac, doc_type) 

739 db.session.commit() 

740 

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

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

743 

744 

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

746 

747 

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

749@login_required 

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

751 ac = _get_aircraft_or_404(aircraft_id) 

752 role = _current_role() 

753 include_sensitive = role in _OWNER_ROLES 

754 

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

756 if not include_sensitive: 

757 query = query.filter_by(is_sensitive=False) 

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

759 

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

761 buf = io.BytesIO() 

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

763 

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

765 for doc in docs: 

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

767 arcname = doc.original_filename 

768 if os.path.exists(path): 

769 zf.write(path, arcname=arcname) 

770 doc_type_label = doc.doc_type or "" 

771 category_label = _CATEGORY_LABELS.get( 

772 doc.category or "", doc.category or "" 

773 ) 

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

775 manifest_lines.append( 

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

777 ) 

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

779 

780 buf.seek(0) 

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

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

783 return Response( 

784 buf.read(), 

785 mimetype="application/zip", 

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

787 ) 

788 

789 

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

791 

792 

793def _upload_expiry_cert( 

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

795) -> ResponseReturnValue: 

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

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

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

799 ac = _get_aircraft_or_404(aircraft_id) 

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

801 

802 if not file or not file.filename: 

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

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

805 

806 original = secure_filename(file.filename) 

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

808 if ext not in _ALLOWED_EXTS: 

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

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

811 

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

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

814 if valid_from and valid_until and valid_from > valid_until: 

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

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

817 

818 tenant = _get_tenant() 

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

820 

821 prev = Document.query.filter_by( 

822 aircraft_id=ac.id, 

823 doc_type=doc_type, 

824 superseded_by_id=None, 

825 component_id=None, 

826 ).first() 

827 

828 new_cert = Document( 

829 aircraft_id=ac.id, 

830 filename=stored, 

831 original_filename=original, 

832 mime_type=mime, 

833 size_bytes=size, 

834 title=title, 

835 doc_type=doc_type, 

836 category=category, 

837 valid_from=valid_from, 

838 valid_until=valid_until, 

839 is_sensitive=True, 

840 ) 

841 db.session.add(new_cert) 

842 db.session.flush() 

843 

844 if prev: 

845 prev.superseded_by_id = new_cert.id 

846 

847 _recompute_expiry_field(ac, doc_type) 

848 db.session.commit() 

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

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

851 

852 

853@documents_bp.route( 

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

855) 

856@login_required 

857@require_role(*_OWNER_ROLES) 

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

859 return _upload_expiry_cert( 

860 aircraft_id, 

861 DocType.INSURANCE_CERT, 

862 DocCategory.INSURANCE, 

863 _("Insurance Certificate"), 

864 ) 

865 

866 

867@documents_bp.route( 

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

869) 

870@login_required 

871@require_role(*_OWNER_ROLES) 

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

873 return _upload_expiry_cert( 

874 aircraft_id, 

875 DocType.ARC, 

876 DocCategory.AIRWORTHINESS, 

877 _("Airworthiness Review Certificate"), 

878 ) 

879 

880 

881# ── Pilot document upload ───────────────────────────────────────────────────── 

882 

883 

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

885@login_required 

886def upload_pilot_document() -> ResponseReturnValue: 

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

888 

889 if request.method == "POST": 

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

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

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

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

894 valid_until = None 

895 if valid_until_str: 

896 try: 

897 valid_until = _date.fromisoformat(valid_until_str) 

898 except ValueError as exc: 

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

900 

901 if not file or not file.filename: 

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

903 return render_template( 

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

905 ) 

906 

907 original = secure_filename(file.filename) 

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

909 if ext not in _ALLOWED_EXTS: 

910 flash( 

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

912 ) 

913 return render_template( 

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

915 ) 

916 

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

918 

919 doc = Document( 

920 pilot_user_id=uid, 

921 filename=stored, 

922 original_filename=original, 

923 mime_type=mime, 

924 size_bytes=size, 

925 title=title, 

926 doc_type=doc_type, 

927 valid_until=valid_until, 

928 is_sensitive=True, 

929 ) 

930 db.session.add(doc) 

931 db.session.commit() 

932 

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

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

935 

936 return render_template( 

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

938 ) 

939 

940 

941# ── Pilot document delete ───────────────────────────────────────────────────── 

942 

943 

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

945@login_required 

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

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

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

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

950 abort(404) 

951 _delete_file(doc.filename) 

952 db.session.delete(doc) 

953 db.session.commit() 

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

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

956 

957 

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

959 

960 

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

962@login_required 

963@require_role(*_OWNER_ROLES) 

964def list_reconcile() -> ResponseReturnValue: 

965 import difflib 

966 

967 tenant = _get_tenant() 

968 pending = ( 

969 PendingReconcile.query.filter_by( 

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

971 ) 

972 .order_by(PendingReconcile.detected_at.desc()) 

973 .all() 

974 ) 

975 aircraft_list = ( 

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

977 .order_by(Aircraft.registration) 

978 .all() 

979 ) 

980 

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

982 category_suggestions: dict[ 

983 int, tuple[str, str] 

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

985 for pr in pending: 

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

987 if len(parts) >= 4: 

988 raw = parts[2] 

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

990 close = difflib.get_close_matches( 

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

992 ) 

993 if close: 

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

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

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

997 

998 return render_template( 

999 "documents/reconcile.html", 

1000 tenant=tenant, 

1001 pending=pending, 

1002 aircraft_list=aircraft_list, 

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

1004 category_suggestions=category_suggestions, 

1005 ) 

1006 

1007 

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

1009@login_required 

1010@require_role(*_OWNER_ROLES) 

1011def scan_documents() -> ResponseReturnValue: 

1012 tenant = _get_tenant() 

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

1014 

1015 if not tenant.slug: 

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

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

1018 

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

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

1021 flash( 

1022 _( 

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

1024 slug=tenant.slug, 

1025 ), 

1026 "info", 

1027 ) 

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

1029 

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

1031 tid = tenant.id 

1032 known: set[str] = { 

1033 doc.filename 

1034 for doc in Document.query.filter( 

1035 Document.aircraft_id.in_( 

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

1037 ) 

1038 ).all() 

1039 } 

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

1041 stale_removed = 0 

1042 for pr in PendingReconcile.query.filter_by( 

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

1044 ).all(): 

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

1046 db.session.delete(pr) 

1047 stale_removed += 1 

1048 if stale_removed: 

1049 db.session.flush() 

1050 

1051 existing_pending: set[str] = { 

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

1053 } 

1054 

1055 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

1058 } 

1059 

1060 new_count = 0 

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

1062 for fname in filenames: 

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

1064 continue 

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

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

1067 # Normalise to forward slashes for DB consistency 

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

1069 

1070 if relpath in known or relpath in existing_pending: 

1071 continue 

1072 

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

1074 parts = relpath.split("/") 

1075 aircraft_obj: Aircraft | None = None 

1076 category: str | None = None 

1077 title_hint: str | None = None 

1078 date_hint: _date | None = None 

1079 

1080 if len(parts) >= 4: 

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

1082 aircraft_obj = aircraft_by_reg.get(reg_raw) 

1083 category, title_hint, date_hint = _parse_reconcile_filename( 

1084 parts[2], parts[3] 

1085 ) 

1086 

1087 pr = PendingReconcile( 

1088 tenant_id=tid, 

1089 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

1090 filepath=relpath, 

1091 category=category, 

1092 title_hint=title_hint, 

1093 date_hint=date_hint, 

1094 ) 

1095 db.session.add(pr) 

1096 new_count += 1 

1097 

1098 db.session.commit() 

1099 parts_msg = [] 

1100 if new_count: 

1101 parts_msg.append( 

1102 ngettext( 

1103 "one new file queued for review", 

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

1105 new_count, 

1106 n=new_count, 

1107 ) 

1108 ) 

1109 if stale_removed: 

1110 parts_msg.append( 

1111 ngettext( 

1112 "one missing file removed from queue", 

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

1114 stale_removed, 

1115 n=stale_removed, 

1116 ) 

1117 ) 

1118 if parts_msg: 

1119 flash( 

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

1121 ) 

1122 else: 

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

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

1125 

1126 

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

1128@login_required 

1129@require_role(*_OWNER_ROLES) 

1130def rename_reconcile_folder() -> ResponseReturnValue: 

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

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

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

1134 """ 

1135 import shutil 

1136 

1137 tenant = _get_tenant() 

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

1139 

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

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

1142 

1143 if new_category not in DocCategory.ALL: 

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

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

1146 

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

1148 abort(403) 

1149 

1150 old_dir = _safe_join(folder, bad_folder) 

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

1152 new_rel = parent_rel + "/" + new_category 

1153 new_dir = _safe_join(folder, new_rel) 

1154 

1155 if os.path.isdir(old_dir): 

1156 if os.path.isdir(new_dir): 

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

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

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

1160 os.makedirs(dest_d, exist_ok=True) 

1161 for fname in filenames: 

1162 shutil.move( 

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

1164 ) 

1165 shutil.rmtree(old_dir, ignore_errors=True) 

1166 else: 

1167 os.rename(old_dir, new_dir) 

1168 

1169 # Prune stale pending entries for the old folder path 

1170 tid = tenant.id 

1171 for pr in PendingReconcile.query.filter( 

1172 PendingReconcile.tenant_id == tid, 

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

1174 ).all(): 

1175 db.session.delete(pr) 

1176 db.session.flush() 

1177 

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

1179 known: set[str] = { 

1180 doc.filename 

1181 for doc in Document.query.filter( 

1182 Document.aircraft_id.in_( 

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

1184 ) 

1185 ).all() 

1186 } 

1187 existing_pending: set[str] = { 

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

1189 } 

1190 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

1193 } 

1194 new_count = 0 

1195 if os.path.isdir(new_dir): 

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

1197 for fname in filenames: 

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

1199 continue 

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

1201 "\\", "/" 

1202 ) 

1203 full = _safe_join(folder, relpath) 

1204 if relpath in known or relpath in existing_pending: 

1205 continue 

1206 parts = relpath.split("/") 

1207 aircraft_obj: Aircraft | None = None 

1208 category: str | None = None 

1209 title_hint: str | None = None 

1210 date_hint: _date | None = None 

1211 if len(parts) >= 4: 

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

1213 aircraft_obj = aircraft_by_reg.get(reg_raw) 

1214 category, title_hint, date_hint = _parse_reconcile_filename( 

1215 parts[2], parts[3] 

1216 ) 

1217 if aircraft_obj and category: 

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

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

1220 doc = Document( 

1221 aircraft_id=aircraft_obj.id, 

1222 filename=relpath, 

1223 original_filename=fname, 

1224 mime_type=mime, 

1225 size_bytes=size, 

1226 title=title_hint, 

1227 category=category, 

1228 ) 

1229 db.session.add(doc) 

1230 else: 

1231 pr = PendingReconcile( 

1232 tenant_id=tid, 

1233 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

1234 filepath=relpath, 

1235 category=category, 

1236 title_hint=title_hint, 

1237 date_hint=date_hint, 

1238 ) 

1239 db.session.add(pr) 

1240 new_count += 1 

1241 

1242 db.session.commit() 

1243 flash( 

1244 ngettext( 

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

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

1247 new_count, 

1248 cat=new_category, 

1249 n=new_count, 

1250 ), 

1251 "success", 

1252 ) 

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

1254 

1255 

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

1257@login_required 

1258@require_role(*_OWNER_ROLES) 

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

1260 tenant = _get_tenant() 

1261 pr = PendingReconcile.query.filter_by( 

1262 id=pending_id, tenant_id=tenant.id 

1263 ).first_or_404() 

1264 

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

1266 try: 

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

1268 except (ValueError, TypeError): 

1269 aircraft_id = None 

1270 if aircraft_id is not None: 

1271 _get_aircraft_or_404(aircraft_id) 

1272 

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

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

1275 if category and category not in DocCategory.ALL: 

1276 category = None 

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

1278 valid_until: _date | None = None 

1279 if valid_until_str: 

1280 with contextlib.suppress( 

1281 ValueError 

1282 ): # malformed date submitted; valid_until stays None 

1283 valid_until = _date.fromisoformat(valid_until_str) 

1284 

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

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

1287 mime = ( 

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

1289 or "application/octet-stream" 

1290 ) 

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

1292 

1293 doc = Document( 

1294 aircraft_id=aircraft_id, 

1295 filename=pr.filepath, 

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

1297 mime_type=mime, 

1298 size_bytes=size, 

1299 title=title, 

1300 category=category, 

1301 valid_until=valid_until, 

1302 is_sensitive=False, 

1303 ) 

1304 db.session.add(doc) 

1305 pr.reconciled_at = datetime.now(UTC) 

1306 db.session.commit() 

1307 

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

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

1310 

1311 

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

1313@login_required 

1314@require_role(*_OWNER_ROLES) 

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

1316 tenant = _get_tenant() 

1317 pr = PendingReconcile.query.filter_by( 

1318 id=pending_id, tenant_id=tenant.id 

1319 ).first_or_404() 

1320 pr.ignored = True 

1321 db.session.commit() 

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

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