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

605 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-31 10:13 +0000

1import contextlib 

2import io 

3import logging 

4import mimetypes 

5import os 

6import re as _re 

7import uuid 

8import zipfile 

9from datetime import UTC, datetime 

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# doc_type -> Aircraft attribute it drives. Aircraft.insurance_expiry/arc_expiry 

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

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

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

85_EXPIRY_DRIVING_DOC_TYPES: dict[str, str] = { 

86 DocType.INSURANCE_CERT: "insurance_expiry", 

87 DocType.ARC: "arc_expiry", 

88} 

89 

90# Human-readable labels for each DocCategory value 

91_CATEGORY_LABELS: dict[str, str] = { 

92 DocCategory.MAINTENANCE: "Maintenance", 

93 DocCategory.INSURANCE: "Insurance", 

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

95 DocCategory.AIRWORTHINESS: "Airworthiness", 

96 DocCategory.LOGBOOK: "Logbook", 

97 DocCategory.INVOICE: "Invoice", 

98 DocCategory.OTHER: "Other", 

99 DocCategory.UNCATEGORISED: "Uncategorised", 

100} 

101 

102 

103# ── Helpers ─────────────────────────────────────────────────────────────────── 

104 

105 

106def _tenant_id() -> int: 

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

108 if not tu: 

109 abort(403) 

110 return int(tu.tenant_id) 

111 

112 

113def _get_tenant() -> Tenant: 

114 tid = _tenant_id() 

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

116 if not t: 

117 abort(403) # pragma: no cover 

118 return t 

119 

120 

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

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

123 if ( 

124 not ac 

125 or ac.tenant_id != _tenant_id() 

126 or not user_can_access_aircraft(aircraft_id) 

127 ): 

128 abort(404) 

129 return ac 

130 

131 

132def active_document_for( 

133 aircraft_id: int, 

134 doc_type: str, 

135 component_id: int | None = None, 

136 as_of: _date | None = None, 

137) -> Document | None: 

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

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

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

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

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

143 documents that never set valid_from is unchanged from before this 

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

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

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

147 as_of = as_of or _date.today() 

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

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

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

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

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

153 result: Document | None = ( 

154 Document.query.filter( 

155 Document.aircraft_id == aircraft_id, 

156 Document.doc_type == doc_type, 

157 Document.component_id == component_id, 

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

159 ) 

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

161 .first() 

162 ) 

163 return result 

164 

165 

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

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

168 Document of that doc_type is currently active (see 

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

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

171 services.notification_service._recompute_all_expiry_fields) so a 

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

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

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

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

176 if not field: 

177 return 

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

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

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

181 

182 

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

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

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

186 valid_until has always been handled here.""" 

187 raw = raw.strip() 

188 if not raw: 

189 return None 

190 try: 

191 return _date.fromisoformat(raw) 

192 except ValueError as exc: 

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

194 return None 

195 

196 

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

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

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

200 abort(404) 

201 return doc 

202 

203 

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

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

206 

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

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

209 """ 

210 if not filename: 

211 return 

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

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

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

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

216 return 

217 try: 

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

219 os.makedirs(trash_dir, exist_ok=True) 

220 dest_name = os.path.basename(filename) 

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

222 if os.path.exists(dest): 

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

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

225 os.rename(src, dest) 

226 except OSError: 

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

228 

229 

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

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

232 if not raw: 

233 return None 

234 try: 

235 cid = int(raw) 

236 except (ValueError, TypeError): 

237 return None 

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

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

240 

241 

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

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

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

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

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

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

248 os.makedirs(folder, exist_ok=True) 

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

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

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

252 return stored, mime, size 

253 

254 

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

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

257 if tenant.slug: 

258 return str(tenant.slug) 

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

260 slug = base 

261 n = 1 

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

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

264 n += 1 

265 tenant.slug = slug 

266 db.session.flush() 

267 return slug 

268 

269 

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

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

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

273 

274 

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

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

277 root = os.path.realpath(upload_folder) 

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

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

280 abort(400) 

281 return joined 

282 

283 

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

285 

286 

287def _parse_reconcile_filename( 

288 cat_str: str, name_part: str 

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

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

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

292 date_hint). Shared by scan_documents and rename_reconcile_folder's 

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

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

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

296 title_hint: str | None = None 

297 date_hint: _date | None = None 

298 m = _RECONCILE_FILENAME_RE.match(name_part) 

299 if m: 

300 with contextlib.suppress( 

301 ValueError 

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

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

304 title_hint = m.group(2) 

305 else: 

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

307 return category, title_hint, date_hint 

308 

309 

310def _save_upload_canonical( 

311 file: FileStorage, 

312 tenant: Tenant, 

313 aircraft: Aircraft, 

314 category: str, 

315 title: str | None, 

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

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

318 

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

320 UPLOAD_FOLDER and suitable for storage in Document.filename. 

321 """ 

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

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

324 today = _date.today().isoformat() 

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

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

327 

328 slug = _ensure_tenant_slug(tenant) 

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

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

331 

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

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

334 os.makedirs(full_dir, exist_ok=True) 

335 

336 dest = _safe_join(folder, relpath) 

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

338 if os.path.exists(dest): 

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

340 relpath = os.path.join( 

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

342 ) 

343 dest = _safe_join(folder, relpath) 

344 

345 file.save(dest) 

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

347 size = os.path.getsize(dest) 

348 return relpath, mime, size 

349 

350 

351def _current_role() -> Role | None: 

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

353 return tu.role if tu else None 

354 

355 

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

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

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

359 

360 

361# ── Title suggestions ───────────────────────────────────────────────────────── 

362 

363 

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

365@login_required 

366def title_suggestions() -> ResponseReturnValue: 

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

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

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

370 

371 if owner_type == "pilot": 

372 base = Document.query.filter( 

373 Document.pilot_user_id == uid, 

374 Document.title.isnot(None), 

375 ) 

376 else: 

377 tid = _tenant_id() 

378 aircraft_ids = [ 

379 row.id 

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

381 .with_entities(Aircraft.id) 

382 .all() 

383 ] 

384 base = Document.query.filter( 

385 Document.aircraft_id.in_(aircraft_ids), 

386 Document.title.isnot(None), 

387 ) 

388 if owner_type == "component": 

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

390 else: 

391 base = base.filter( 

392 Document.component_id.is_(None), 

393 Document.flight_entry_id.is_(None), 

394 ) 

395 

396 if q: 

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

398 

399 rows = ( 

400 base.with_entities(Document.title) 

401 .distinct() 

402 .order_by(Document.title) 

403 .limit(10) 

404 .all() 

405 ) 

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

407 

408 

409# ── Aircraft document list ──────────────────────────────────────────────────── 

410 

411 

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

413@login_required 

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

415 ac = _get_aircraft_or_404(aircraft_id) 

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

417 doc_type_labels = dict(_AIRCRAFT_DOC_TYPES) 

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

419 if filter_doc_type not in doc_type_labels: 

420 filter_doc_type = None 

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

422 if not show_sensitive: 

423 query = query.filter_by(is_sensitive=False) 

424 if filter_doc_type: 

425 query = query.filter_by(doc_type=filter_doc_type) 

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

427 sensitive_count = Document.query.filter_by( 

428 aircraft_id=ac.id, is_sensitive=True 

429 ).count() 

430 role = _current_role() 

431 is_owner = role in _OWNER_ROLES 

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

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

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

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

436 doc_types_shown = { 

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

438 } 

439 active_doc_ids = { 

440 active.id 

441 for dt in doc_types_shown 

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

443 if active is not None 

444 } 

445 return render_template( 

446 "documents/list.html", 

447 aircraft=ac, 

448 docs=docs, 

449 show_sensitive=show_sensitive, 

450 sensitive_count=sensitive_count, 

451 is_owner=is_owner, 

452 broken_ids=broken_ids, 

453 category_labels=_CATEGORY_LABELS, 

454 filter_doc_type=filter_doc_type, 

455 filter_doc_type_label=doc_type_labels.get(filter_doc_type or ""), 

456 active_doc_ids=active_doc_ids, 

457 expiry_driving_doc_types=_EXPIRY_DRIVING_DOC_TYPES, 

458 ) 

459 

460 

461# ── Upload aircraft document ────────────────────────────────────────────────── 

462 

463 

464@documents_bp.route( 

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

466) 

467@login_required 

468@require_role(*_OWNER_ROLES) 

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

470 ac = _get_aircraft_or_404(aircraft_id) 

471 component = _resolve_component(ac) 

472 

473 if request.method == "POST": 

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

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

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

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

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

479 if category and category not in DocCategory.ALL: 

480 category = None 

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

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

483 

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

485 if msg: 

486 flash(msg, "danger") 

487 return render_template( 

488 "documents/upload_form.html", 

489 aircraft=ac, 

490 component=component, 

491 doc_types=_AIRCRAFT_DOC_TYPES, 

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

493 ) 

494 

495 if not file or not file.filename: 

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

497 

498 original = secure_filename(file.filename) 

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

500 if ext not in _ALLOWED_EXTS: 

501 return _re_render( 

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

503 ) 

504 

505 if valid_from and valid_until and valid_from > valid_until: 

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

507 

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

509 if category and not component: 

510 tenant = _get_tenant() 

511 stored, mime, size = _save_upload_canonical( 

512 file, tenant, ac, category, title 

513 ) 

514 else: 

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

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

517 

518 doc = Document( 

519 aircraft_id=ac.id, 

520 component_id=component.id if component else None, 

521 filename=stored, 

522 original_filename=original, 

523 mime_type=mime, 

524 size_bytes=size, 

525 title=title, 

526 doc_type=doc_type, 

527 category=category, 

528 valid_from=valid_from, 

529 valid_until=valid_until, 

530 is_sensitive=is_sensitive, 

531 ) 

532 db.session.add(doc) 

533 

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

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

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

537 doc.doc_type = DocType.INSURANCE_CERT 

538 

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

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

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

542 # active (see _recompute_expiry_field). 

543 if doc.doc_type in _EXPIRY_DRIVING_DOC_TYPES and not component: 

544 db.session.flush() 

545 prev_cert = Document.query.filter( 

546 Document.aircraft_id == ac.id, 

547 Document.doc_type == doc.doc_type, 

548 Document.superseded_by_id.is_(None), 

549 Document.id != doc.id, 

550 ).first() 

551 if prev_cert: 

552 prev_cert.superseded_by_id = doc.id 

553 _recompute_expiry_field(ac, doc.doc_type) 

554 

555 db.session.commit() 

556 activity( 

557 "document.uploaded", 

558 document_id=doc.id, 

559 aircraft_id=ac.id, 

560 title=doc.title or "", 

561 ) 

562 

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

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

565 

566 return render_template( 

567 "documents/upload_form.html", 

568 aircraft=ac, 

569 component=component, 

570 doc_types=_AIRCRAFT_DOC_TYPES, 

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

572 ) 

573 

574 

575# ── Edit aircraft document ──────────────────────────────────────────────────── 

576 

577 

578@documents_bp.route( 

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

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

581) 

582@login_required 

583@require_role(*_OWNER_ROLES) 

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

585 ac = _get_aircraft_or_404(aircraft_id) 

586 doc = _get_aircraft_document_or_404(ac, document_id) 

587 

588 if request.method == "POST": 

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

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

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

592 if category and category not in DocCategory.ALL: 

593 category = None 

594 

595 old_doc_type = doc.doc_type 

596 if not doc.component_id: 

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

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

599 doc.doc_type = new_doc_type 

600 

601 old_category = doc.category 

602 doc.category = category 

603 

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

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

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

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

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

609 old_full = _safe_join(folder, doc.filename) 

610 if os.path.exists(old_full): 

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

612 new_full = _safe_join(folder, new_relpath) 

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

614 try: 

615 os.rename(old_full, new_full) 

616 doc.filename = new_relpath 

617 except OSError as exc: 

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

619 

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

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

622 if new_valid_from and new_valid_until and new_valid_from > new_valid_until: 

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

624 return render_template( 

625 "documents/edit_form.html", 

626 aircraft=ac, 

627 doc=doc, 

628 doc_types=_AIRCRAFT_DOC_TYPES, 

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

630 ) 

631 doc.valid_from = new_valid_from 

632 doc.valid_until = new_valid_until 

633 

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

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

636 _recompute_expiry_field(ac, old_doc_type) 

637 _recompute_expiry_field(ac, doc.doc_type) 

638 

639 db.session.commit() 

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

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

642 

643 return render_template( 

644 "documents/edit_form.html", 

645 aircraft=ac, 

646 doc=doc, 

647 doc_types=_AIRCRAFT_DOC_TYPES, 

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

649 ) 

650 

651 

652# ── Delete aircraft document ────────────────────────────────────────────────── 

653 

654 

655@documents_bp.route( 

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

657 methods=["POST"], 

658) 

659@login_required 

660@require_role(*_OWNER_ROLES) 

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

662 ac = _get_aircraft_or_404(aircraft_id) 

663 doc = _get_aircraft_document_or_404(ac, document_id) 

664 doc_type = doc.doc_type 

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

666 _delete_file(doc.filename) 

667 db.session.delete(doc) 

668 db.session.commit() 

669 

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

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

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

673 # clears the field if nothing is left. 

674 if doc_type in _EXPIRY_DRIVING_DOC_TYPES: 

675 _recompute_expiry_field(ac, doc_type) 

676 db.session.commit() 

677 

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

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

680 

681 

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

683 

684 

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

686@login_required 

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

688 ac = _get_aircraft_or_404(aircraft_id) 

689 role = _current_role() 

690 include_sensitive = role in _OWNER_ROLES 

691 

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

693 if not include_sensitive: 

694 query = query.filter_by(is_sensitive=False) 

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

696 

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

698 buf = io.BytesIO() 

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

700 

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

702 for doc in docs: 

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

704 arcname = doc.original_filename 

705 if os.path.exists(path): 

706 zf.write(path, arcname=arcname) 

707 doc_type_label = doc.doc_type or "" 

708 category_label = _CATEGORY_LABELS.get( 

709 doc.category or "", doc.category or "" 

710 ) 

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

712 manifest_lines.append( 

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

714 ) 

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

716 

717 buf.seek(0) 

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

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

720 return Response( 

721 buf.read(), 

722 mimetype="application/zip", 

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

724 ) 

725 

726 

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

728 

729 

730def _upload_expiry_cert( 

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

732) -> ResponseReturnValue: 

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

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

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

736 ac = _get_aircraft_or_404(aircraft_id) 

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

738 

739 if not file or not file.filename: 

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

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

742 

743 original = secure_filename(file.filename) 

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

745 if ext not in _ALLOWED_EXTS: 

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

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

748 

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

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

751 if valid_from and valid_until and valid_from > valid_until: 

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

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

754 

755 tenant = _get_tenant() 

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

757 

758 prev = Document.query.filter_by( 

759 aircraft_id=ac.id, 

760 doc_type=doc_type, 

761 superseded_by_id=None, 

762 component_id=None, 

763 ).first() 

764 

765 new_cert = Document( 

766 aircraft_id=ac.id, 

767 filename=stored, 

768 original_filename=original, 

769 mime_type=mime, 

770 size_bytes=size, 

771 title=title, 

772 doc_type=doc_type, 

773 category=category, 

774 valid_from=valid_from, 

775 valid_until=valid_until, 

776 is_sensitive=True, 

777 ) 

778 db.session.add(new_cert) 

779 db.session.flush() 

780 

781 if prev: 

782 prev.superseded_by_id = new_cert.id 

783 

784 _recompute_expiry_field(ac, doc_type) 

785 db.session.commit() 

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

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

788 

789 

790@documents_bp.route( 

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

792) 

793@login_required 

794@require_role(*_OWNER_ROLES) 

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

796 return _upload_expiry_cert( 

797 aircraft_id, 

798 DocType.INSURANCE_CERT, 

799 DocCategory.INSURANCE, 

800 _("Insurance Certificate"), 

801 ) 

802 

803 

804@documents_bp.route( 

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

806) 

807@login_required 

808@require_role(*_OWNER_ROLES) 

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

810 return _upload_expiry_cert( 

811 aircraft_id, 

812 DocType.ARC, 

813 DocCategory.AIRWORTHINESS, 

814 _("Airworthiness Review Certificate"), 

815 ) 

816 

817 

818# ── Pilot document upload ───────────────────────────────────────────────────── 

819 

820 

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

822@login_required 

823def upload_pilot_document() -> ResponseReturnValue: 

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

825 

826 if request.method == "POST": 

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

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

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

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

831 valid_until = None 

832 if valid_until_str: 

833 try: 

834 valid_until = _date.fromisoformat(valid_until_str) 

835 except ValueError as exc: 

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

837 

838 if not file or not file.filename: 

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

840 return render_template( 

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

842 ) 

843 

844 original = secure_filename(file.filename) 

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

846 if ext not in _ALLOWED_EXTS: 

847 flash( 

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

849 ) 

850 return render_template( 

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

852 ) 

853 

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

855 

856 doc = Document( 

857 pilot_user_id=uid, 

858 filename=stored, 

859 original_filename=original, 

860 mime_type=mime, 

861 size_bytes=size, 

862 title=title, 

863 doc_type=doc_type, 

864 valid_until=valid_until, 

865 is_sensitive=True, 

866 ) 

867 db.session.add(doc) 

868 db.session.commit() 

869 

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

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

872 

873 return render_template( 

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

875 ) 

876 

877 

878# ── Pilot document delete ───────────────────────────────────────────────────── 

879 

880 

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

882@login_required 

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

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

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

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

887 abort(404) 

888 _delete_file(doc.filename) 

889 db.session.delete(doc) 

890 db.session.commit() 

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

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

893 

894 

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

896 

897 

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

899@login_required 

900@require_role(*_OWNER_ROLES) 

901def list_reconcile() -> ResponseReturnValue: 

902 import difflib 

903 

904 tenant = _get_tenant() 

905 pending = ( 

906 PendingReconcile.query.filter_by( 

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

908 ) 

909 .order_by(PendingReconcile.detected_at.desc()) 

910 .all() 

911 ) 

912 aircraft_list = ( 

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

914 .order_by(Aircraft.registration) 

915 .all() 

916 ) 

917 

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

919 category_suggestions: dict[ 

920 int, tuple[str, str] 

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

922 for pr in pending: 

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

924 if len(parts) >= 4: 

925 raw = parts[2] 

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

927 close = difflib.get_close_matches( 

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

929 ) 

930 if close: 

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

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

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

934 

935 return render_template( 

936 "documents/reconcile.html", 

937 tenant=tenant, 

938 pending=pending, 

939 aircraft_list=aircraft_list, 

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

941 category_suggestions=category_suggestions, 

942 ) 

943 

944 

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

946@login_required 

947@require_role(*_OWNER_ROLES) 

948def scan_documents() -> ResponseReturnValue: 

949 tenant = _get_tenant() 

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

951 

952 if not tenant.slug: 

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

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

955 

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

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

958 flash( 

959 _( 

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

961 slug=tenant.slug, 

962 ), 

963 "info", 

964 ) 

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

966 

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

968 tid = tenant.id 

969 known: set[str] = { 

970 doc.filename 

971 for doc in Document.query.filter( 

972 Document.aircraft_id.in_( 

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

974 ) 

975 ).all() 

976 } 

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

978 stale_removed = 0 

979 for pr in PendingReconcile.query.filter_by( 

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

981 ).all(): 

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

983 db.session.delete(pr) 

984 stale_removed += 1 

985 if stale_removed: 

986 db.session.flush() 

987 

988 existing_pending: set[str] = { 

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

990 } 

991 

992 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

995 } 

996 

997 new_count = 0 

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

999 for fname in filenames: 

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

1001 continue 

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

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

1004 # Normalise to forward slashes for DB consistency 

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

1006 

1007 if relpath in known or relpath in existing_pending: 

1008 continue 

1009 

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

1011 parts = relpath.split("/") 

1012 aircraft_obj: Aircraft | None = None 

1013 category: str | None = None 

1014 title_hint: str | None = None 

1015 date_hint: _date | None = None 

1016 

1017 if len(parts) >= 4: 

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

1019 aircraft_obj = aircraft_by_reg.get(reg_raw) 

1020 category, title_hint, date_hint = _parse_reconcile_filename( 

1021 parts[2], parts[3] 

1022 ) 

1023 

1024 pr = PendingReconcile( 

1025 tenant_id=tid, 

1026 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

1027 filepath=relpath, 

1028 category=category, 

1029 title_hint=title_hint, 

1030 date_hint=date_hint, 

1031 ) 

1032 db.session.add(pr) 

1033 new_count += 1 

1034 

1035 db.session.commit() 

1036 parts_msg = [] 

1037 if new_count: 

1038 parts_msg.append( 

1039 ngettext( 

1040 "one new file queued for review", 

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

1042 new_count, 

1043 n=new_count, 

1044 ) 

1045 ) 

1046 if stale_removed: 

1047 parts_msg.append( 

1048 ngettext( 

1049 "one missing file removed from queue", 

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

1051 stale_removed, 

1052 n=stale_removed, 

1053 ) 

1054 ) 

1055 if parts_msg: 

1056 flash( 

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

1058 ) 

1059 else: 

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

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

1062 

1063 

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

1065@login_required 

1066@require_role(*_OWNER_ROLES) 

1067def rename_reconcile_folder() -> ResponseReturnValue: 

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

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

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

1071 """ 

1072 import shutil 

1073 

1074 tenant = _get_tenant() 

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

1076 

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

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

1079 

1080 if new_category not in DocCategory.ALL: 

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

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

1083 

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

1085 abort(403) 

1086 

1087 old_dir = _safe_join(folder, bad_folder) 

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

1089 new_rel = parent_rel + "/" + new_category 

1090 new_dir = _safe_join(folder, new_rel) 

1091 

1092 if os.path.isdir(old_dir): 

1093 if os.path.isdir(new_dir): 

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

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

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

1097 os.makedirs(dest_d, exist_ok=True) 

1098 for fname in filenames: 

1099 shutil.move( 

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

1101 ) 

1102 shutil.rmtree(old_dir, ignore_errors=True) 

1103 else: 

1104 os.rename(old_dir, new_dir) 

1105 

1106 # Prune stale pending entries for the old folder path 

1107 tid = tenant.id 

1108 for pr in PendingReconcile.query.filter( 

1109 PendingReconcile.tenant_id == tid, 

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

1111 ).all(): 

1112 db.session.delete(pr) 

1113 db.session.flush() 

1114 

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

1116 known: set[str] = { 

1117 doc.filename 

1118 for doc in Document.query.filter( 

1119 Document.aircraft_id.in_( 

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

1121 ) 

1122 ).all() 

1123 } 

1124 existing_pending: set[str] = { 

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

1126 } 

1127 aircraft_by_reg: dict[str, Aircraft] = { 

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

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

1130 } 

1131 new_count = 0 

1132 if os.path.isdir(new_dir): 

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

1134 for fname in filenames: 

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

1136 continue 

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

1138 "\\", "/" 

1139 ) 

1140 full = _safe_join(folder, relpath) 

1141 if relpath in known or relpath in existing_pending: 

1142 continue 

1143 parts = relpath.split("/") 

1144 aircraft_obj: Aircraft | None = None 

1145 category: str | None = None 

1146 title_hint: str | None = None 

1147 date_hint: _date | None = None 

1148 if len(parts) >= 4: 

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

1150 aircraft_obj = aircraft_by_reg.get(reg_raw) 

1151 category, title_hint, date_hint = _parse_reconcile_filename( 

1152 parts[2], parts[3] 

1153 ) 

1154 if aircraft_obj and category: 

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

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

1157 doc = Document( 

1158 aircraft_id=aircraft_obj.id, 

1159 filename=relpath, 

1160 original_filename=fname, 

1161 mime_type=mime, 

1162 size_bytes=size, 

1163 title=title_hint, 

1164 category=category, 

1165 ) 

1166 db.session.add(doc) 

1167 else: 

1168 pr = PendingReconcile( 

1169 tenant_id=tid, 

1170 aircraft_id=aircraft_obj.id if aircraft_obj else None, 

1171 filepath=relpath, 

1172 category=category, 

1173 title_hint=title_hint, 

1174 date_hint=date_hint, 

1175 ) 

1176 db.session.add(pr) 

1177 new_count += 1 

1178 

1179 db.session.commit() 

1180 flash( 

1181 ngettext( 

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

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

1184 new_count, 

1185 cat=new_category, 

1186 n=new_count, 

1187 ), 

1188 "success", 

1189 ) 

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

1191 

1192 

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

1194@login_required 

1195@require_role(*_OWNER_ROLES) 

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

1197 tenant = _get_tenant() 

1198 pr = PendingReconcile.query.filter_by( 

1199 id=pending_id, tenant_id=tenant.id 

1200 ).first_or_404() 

1201 

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

1203 try: 

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

1205 except (ValueError, TypeError): 

1206 aircraft_id = None 

1207 if aircraft_id is not None: 

1208 _get_aircraft_or_404(aircraft_id) 

1209 

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

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

1212 if category and category not in DocCategory.ALL: 

1213 category = None 

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

1215 valid_until: _date | None = None 

1216 if valid_until_str: 

1217 with contextlib.suppress( 

1218 ValueError 

1219 ): # malformed date submitted; valid_until stays None 

1220 valid_until = _date.fromisoformat(valid_until_str) 

1221 

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

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

1224 mime = ( 

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

1226 or "application/octet-stream" 

1227 ) 

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

1229 

1230 doc = Document( 

1231 aircraft_id=aircraft_id, 

1232 filename=pr.filepath, 

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

1234 mime_type=mime, 

1235 size_bytes=size, 

1236 title=title, 

1237 category=category, 

1238 valid_until=valid_until, 

1239 is_sensitive=False, 

1240 ) 

1241 db.session.add(doc) 

1242 pr.reconciled_at = datetime.now(UTC) 

1243 db.session.commit() 

1244 

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

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

1247 

1248 

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

1250@login_required 

1251@require_role(*_OWNER_ROLES) 

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

1253 tenant = _get_tenant() 

1254 pr = PendingReconcile.query.filter_by( 

1255 id=pending_id, tenant_id=tenant.id 

1256 ).first_or_404() 

1257 pr.ignored = True 

1258 db.session.commit() 

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

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