Coverage for app/documents/routes.py: 100%
642 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
1import contextlib
2import io
3import logging
4import mimetypes
5import os
6import re as _re
7import uuid
8import zipfile
9from datetime import UTC, datetime, timedelta
10from datetime import date as _date
12from flask import ( # pyright: ignore[reportMissingImports]
13 Blueprint,
14 Response,
15 abort,
16 current_app,
17 flash,
18 jsonify,
19 redirect,
20 render_template,
21 request,
22 session,
23 url_for,
24)
25from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
26from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
27from flask_babel import lazy_gettext as _l
28from flask_babel import ngettext
29from models import ( # pyright: ignore[reportMissingImports]
30 Aircraft,
31 Component,
32 DocCategory,
33 DocType,
34 Document,
35 PendingReconcile,
36 Role,
37 Tenant,
38 TenantUser,
39 db,
40)
41from services.upload_layout import ( # pyright: ignore[reportMissingImports]
42 is_app_managed,
43)
44from sqlalchemy.orm import joinedload # pyright: ignore[reportMissingImports]
45from utils import ( # pyright: ignore[reportMissingImports]
46 activity,
47 login_required,
48 require_role,
49 user_can_access_aircraft,
50)
51from werkzeug.datastructures import FileStorage
52from werkzeug.utils import secure_filename
54log = logging.getLogger(__name__)
56documents_bp = Blueprint("documents", __name__)
58_OWNER_ROLES = (Role.ADMIN, Role.OWNER)
60_ALLOWED_EXTS = {
61 ".jpg",
62 ".jpeg",
63 ".png",
64 ".gif",
65 ".webp",
66 ".heic",
67 ".pdf",
68 ".doc",
69 ".docx",
70 ".xls",
71 ".xlsx",
72 ".txt",
73}
75_PILOT_DOC_TYPES = [
76 (DocType.LICENSE, _l("Licence")),
77 (DocType.MEDICAL, _l("Medical certificate")),
78]
80_AIRCRAFT_DOC_TYPES = [
81 (DocType.INSURANCE_CERT, _l("Insurance Certificate")),
82 (DocType.ARC, _l("Airworthiness Review Certificate (ARC)")),
83]
85# Sentinel accepted by the aircraft Documents list's type filter alongside the
86# real DocType values above, to isolate documents with no type set -- e.g. an
87# insurance/ARC certificate uploaded without picking "Document type", which
88# silently falls outside the expiry-tracking/supersession machinery those two
89# real types drive (see _EXPIRY_DRIVING_DOC_TYPES).
90_NO_TYPE_FILTER = "none"
91_NO_TYPE_LABEL = _l("No type set")
93# Aircraft documents list pagination -- same values/convention as the pilot
94# logbook (pilots/routes.py _VALID_PER_PAGE/_DEFAULT_PER_PAGE).
95_DOCS_VALID_PER_PAGE = (10, 20, 50, 100)
96_DOCS_DEFAULT_PER_PAGE = 20
98# doc_type -> Aircraft attribute it drives. Aircraft.insurance_expiry/arc_expiry
99# are synced caches, not directly user-editable — see the comment on those
100# columns in models.py. Kept in step by _recompute_expiry_field() below
101# whenever a Document of one of these types is uploaded, edited, or deleted.
102_EXPIRY_DRIVING_DOC_TYPES: dict[str, str] = {
103 DocType.INSURANCE_CERT: "insurance_expiry",
104 DocType.ARC: "arc_expiry",
105}
107# Human-readable labels for each DocCategory value
108_CATEGORY_LABELS: dict[str, str] = {
109 DocCategory.MAINTENANCE: "Maintenance",
110 DocCategory.INSURANCE: "Insurance",
111 DocCategory.POH: "POH / Flight Manual",
112 DocCategory.AIRWORTHINESS: "Airworthiness",
113 DocCategory.LOGBOOK: "Logbook",
114 DocCategory.INVOICE: "Invoice",
115 DocCategory.OTHER: "Other",
116 DocCategory.UNCATEGORISED: "Uncategorised",
117}
120# ── Helpers ───────────────────────────────────────────────────────────────────
123def _tenant_id() -> int:
124 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
125 if not tu:
126 abort(403)
127 return int(tu.tenant_id)
130def _get_tenant() -> Tenant:
131 tid = _tenant_id()
132 t = db.session.get(Tenant, tid)
133 if not t:
134 abort(403) # pragma: no cover
135 return t
138def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
139 ac = db.session.get(Aircraft, aircraft_id)
140 if (
141 not ac
142 or ac.tenant_id != _tenant_id()
143 or not user_can_access_aircraft(aircraft_id)
144 ):
145 abort(404)
146 return ac
149def active_document_for(
150 aircraft_id: int,
151 doc_type: str,
152 component_id: int | None = None,
153 as_of: _date | None = None,
154) -> Document | None:
155 """The document of this (aircraft, doc_type[, component]) that is in
156 force as of `as_of` (default today): valid_from is None (meaning "in
157 force as soon as uploaded") or has already arrived. Among those, the
158 one with the latest valid_from wins; ties -- including the common
159 valid_from=None case -- break on uploaded_at, so behaviour for
160 documents that never set valid_from is unchanged from before this
161 concept existed. Deliberately ignores superseded_by_id: that's a
162 structural "replaced by" pointer set at upload time, not a statement
163 about which document is in force today."""
164 as_of = as_of or _date.today()
165 # Ordering (not a Python-side max()) so the comparison stays in SQL --
166 # a freshly-flushed-but-uncommitted Document's tz-aware uploaded_at can
167 # otherwise be compared against an already-committed sibling's
168 # SQLite-naive one and raise. NULL valid_from sorts last (lowest
169 # priority, same as treating it as `date.min`).
170 result: Document | None = (
171 Document.query.filter(
172 Document.aircraft_id == aircraft_id,
173 Document.doc_type == doc_type,
174 Document.component_id == component_id,
175 db.or_(Document.valid_from.is_(None), Document.valid_from <= as_of),
176 )
177 .order_by(Document.valid_from.desc().nullslast(), Document.uploaded_at.desc())
178 .first()
179 )
180 return result
183def effective_coverage_until(
184 aircraft_id: int,
185 doc_type: str,
186 component_id: int | None = None,
187 as_of: _date | None = None,
188) -> _date | None:
189 """How far into the future coverage is unbroken, following a chain of
190 documents whose valid_from picks up on or the day after the previous
191 one's valid_until (no gap) -- e.g. next quarter's insurance cert
192 uploaded a month ahead of the current one's expiry. None if nothing is
193 currently active (active_document_for finds no document in force);
194 otherwise at least the active document's own valid_until.
196 Deliberately independent of the single-hop upcoming_*_cert query in
197 aircraft/routes.py, which only looks one step ahead and doesn't check
198 for a gap (it's a display hint: "here's the next thing on file",
199 correct even when there IS a gap). This instead walks as many chained
200 documents as exist and stops the moment a real gap appears -- a real
201 gap still means genuine expiry risk between the two documents and must
202 still be reported as such.
203 """
204 as_of = as_of or _date.today()
205 active = active_document_for(aircraft_id, doc_type, component_id, as_of=as_of)
206 if active is None or active.valid_until is None:
207 return None
209 frontier: _date = active.valid_until
210 while True:
211 # frontier only ever advances to a candidate's own valid_until, and
212 # every candidate here must have valid_until > frontier -- so a
213 # document picked in a prior iteration can never qualify again
214 # (its valid_until now equals frontier, not exceeds it). No
215 # "already visited" bookkeeping needed to avoid a repeat/infinite
216 # loop.
217 candidates = Document.query.filter(
218 Document.aircraft_id == aircraft_id,
219 Document.doc_type == doc_type,
220 Document.component_id == component_id,
221 Document.valid_from > as_of,
222 Document.valid_from <= frontier + timedelta(days=1),
223 Document.valid_until.isnot(None),
224 Document.valid_until > frontier,
225 ).all()
226 nxt: Document | None = None
227 for c in candidates:
228 if nxt is None or c.valid_until > nxt.valid_until:
229 nxt = c
230 if nxt is None:
231 return frontier
232 frontier = nxt.valid_until
235def _recompute_expiry_field(ac: Aircraft, doc_type: str | None) -> None:
236 """Keep Aircraft.insurance_expiry/arc_expiry in step with whichever
237 Document of that doc_type is currently active (see
238 active_document_for). Called after any create/edit/delete that could
239 change which document is active, and once daily (see
240 services.notification_service._recompute_all_expiry_fields) so a
241 future-dated document activates on its valid_from even without a new
242 edit that day — safe to call for any doc_type, a no-op for ones that
243 don't drive an Aircraft field."""
244 field = _EXPIRY_DRIVING_DOC_TYPES.get(doc_type or "")
245 if not field:
246 return
247 assert doc_type is not None # implied by `field` being truthy above
248 active = active_document_for(ac.id, doc_type, component_id=None)
249 setattr(ac, field, active.valid_until if active else None)
252def _parse_date_field(raw: str) -> _date | None:
253 """Parse an optional YYYY-MM-DD form field; invalid/blank input is
254 treated as "not set" rather than rejected outright, matching how
255 valid_until has always been handled here."""
256 raw = raw.strip()
257 if not raw:
258 return None
259 try:
260 return _date.fromisoformat(raw)
261 except ValueError as exc:
262 log.debug("Invalid date field: %s", exc)
263 return None
266def _get_aircraft_document_or_404(aircraft: Aircraft, document_id: int) -> Document:
267 doc = db.session.get(Document, document_id)
268 if not doc or doc.aircraft_id != aircraft.id:
269 abort(404)
270 return doc
273def _delete_file(filename: str | None) -> None:
274 """Move file to _trash/ instead of hard-deleting.
276 Syncthing propagates the move to all peers; the file is recoverable.
277 If the file is not found, log and continue silently.
278 """
279 if not filename:
280 return
281 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
282 src = os.path.join(folder, filename)
283 if not os.path.exists(src):
284 current_app.logger.debug("File already absent, skipping trash: %s", filename)
285 return
286 try:
287 trash_dir = os.path.join(folder, "_trash")
288 os.makedirs(trash_dir, exist_ok=True)
289 dest_name = os.path.basename(filename)
290 dest = os.path.join(trash_dir, dest_name)
291 if os.path.exists(dest):
292 base, ext = os.path.splitext(dest_name)
293 dest = os.path.join(trash_dir, f"{base}_{uuid.uuid4().hex[:8]}{ext}")
294 os.rename(src, dest)
295 except OSError:
296 current_app.logger.debug("Could not move to trash: %s", filename)
299def _resolve_component(ac: Aircraft) -> Component | None:
300 raw = request.args.get("component_id") or request.form.get("component_id")
301 if not raw:
302 return None
303 try:
304 cid = int(raw)
305 except (ValueError, TypeError):
306 return None
307 comp = db.session.get(Component, cid)
308 return comp if (comp and comp.aircraft_id == ac.id) else None
311def _save_upload(file: FileStorage, label: str) -> tuple[str, str, int]:
312 """Save *file* flat to upload folder (legacy path, no canonical structure)."""
313 original = secure_filename(file.filename or "")
314 ext = os.path.splitext(original)[1].lower()
315 stored = f"doc_{label}_{uuid.uuid4().hex[:12]}{ext}"
316 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
317 os.makedirs(folder, exist_ok=True)
318 file.save(os.path.join(folder, stored))
319 mime = mimetypes.guess_type(original)[0] or "application/octet-stream"
320 size = os.path.getsize(os.path.join(folder, stored))
321 return stored, mime, size
324def _ensure_tenant_slug(tenant: Tenant) -> str:
325 """Return tenant.slug, generating one from the name if not yet set."""
326 if tenant.slug:
327 return str(tenant.slug)
328 base = _re.sub(r"[^a-z0-9]+", "-", tenant.name.lower()).strip("-")[:64]
329 slug = base
330 n = 1
331 while Tenant.query.filter(Tenant.slug == slug, Tenant.id != tenant.id).first():
332 slug = f"{base}-{n}"
333 n += 1
334 tenant.slug = slug
335 db.session.flush()
336 return slug
339def _safe_path_component(s: str) -> str:
340 """Strip characters that are unsafe in filesystem path segments."""
341 return _re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", s).strip()
344def _safe_join(upload_folder: str, *parts: str) -> str:
345 """Join parts under upload_folder; abort(400) if the result would escape it."""
346 root = os.path.realpath(upload_folder)
347 joined = os.path.normpath(os.path.join(root, *parts))
348 if not (joined == root or joined.startswith(root + os.sep)):
349 abort(400)
350 return joined
353_RECONCILE_FILENAME_RE = _re.compile(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$")
356def _parse_reconcile_filename(
357 cat_str: str, name_part: str
358) -> tuple[str | None, str | None, _date | None]:
359 """Parse a Syncthing-mounted path's category segment and its
360 "YYYY-MM-DD - title.ext" filename segment into (category, title_hint,
361 date_hint). Shared by scan_documents and rename_reconcile_folder's
362 inline rescan so a scan and a post-rename rescan can never disagree on
363 how to interpret the same on-disk layout."""
364 category = cat_str.lower() if cat_str.lower() in DocCategory.ALL else None
365 title_hint: str | None = None
366 date_hint: _date | None = None
367 m = _RECONCILE_FILENAME_RE.match(name_part)
368 if m:
369 with contextlib.suppress(
370 ValueError
371 ): # regex matched date-like string but it's invalid; treat as no date
372 date_hint = _date.fromisoformat(m.group(1))
373 title_hint = m.group(2)
374 else:
375 title_hint = os.path.splitext(name_part)[0]
376 return category, title_hint, date_hint
379def _save_upload_canonical(
380 file: FileStorage,
381 tenant: Tenant,
382 aircraft: Aircraft,
383 category: str,
384 title: str | None,
385) -> tuple[str, str, int]:
386 """Save *file* to the canonical Syncthing-compatible path structure.
388 Returns (relpath, mime_type, size_bytes) where relpath is relative to
389 UPLOAD_FOLDER and suitable for storage in Document.filename.
390 """
391 original = secure_filename(file.filename or "unnamed")
392 ext = os.path.splitext(original)[1].lower()
393 today = _date.today().isoformat()
394 safe_title = _safe_path_component(title or os.path.splitext(original)[0])[:100]
395 fname = f"{today} - {safe_title}{ext}"
397 slug = _ensure_tenant_slug(tenant)
398 safe_reg = aircraft.registration.replace("/", "-").replace(" ", "-").upper()
399 relpath = os.path.join(slug, safe_reg, category, fname)
401 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
402 full_dir = _safe_join(folder, slug, safe_reg, category)
403 os.makedirs(full_dir, exist_ok=True)
405 dest = _safe_join(folder, relpath)
406 # If a file with this name already exists (e.g. same title + date), add a short suffix
407 if os.path.exists(dest):
408 base, ext2 = os.path.splitext(fname)
409 relpath = os.path.join(
410 slug, safe_reg, category, f"{base}_{uuid.uuid4().hex[:6]}{ext2}"
411 )
412 dest = _safe_join(folder, relpath)
414 file.save(dest)
415 mime = mimetypes.guess_type(original)[0] or "application/octet-stream"
416 size = os.path.getsize(dest)
417 return relpath, mime, size
420def _current_role() -> Role | None:
421 tu = TenantUser.query.filter_by(user_id=session.get("user_id")).first()
422 return tu.role if tu else None
425def _doc_broken(doc: Document) -> bool:
426 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
427 return not os.path.exists(os.path.join(folder, doc.filename))
430# ── Title suggestions ─────────────────────────────────────────────────────────
433@documents_bp.route("/documents/title-suggestions")
434@login_required
435def title_suggestions() -> ResponseReturnValue:
436 q = request.args.get("q", "").strip()
437 owner_type = request.args.get("owner_type", "aircraft")
438 uid = int(session["user_id"])
440 if owner_type == "pilot":
441 base = Document.query.filter(
442 Document.pilot_user_id == uid,
443 Document.title.isnot(None),
444 )
445 else:
446 tid = _tenant_id()
447 aircraft_ids = [
448 row.id
449 for row in Aircraft.query.filter_by(tenant_id=tid)
450 .with_entities(Aircraft.id)
451 .all()
452 ]
453 base = Document.query.filter(
454 Document.aircraft_id.in_(aircraft_ids),
455 Document.title.isnot(None),
456 )
457 if owner_type == "component":
458 base = base.filter(Document.component_id.isnot(None))
459 else:
460 base = base.filter(
461 Document.component_id.is_(None),
462 Document.flight_entry_id.is_(None),
463 )
465 if q:
466 base = base.filter(Document.title.ilike(f"{q}%"))
468 rows = (
469 base.with_entities(Document.title)
470 .distinct()
471 .order_by(Document.title)
472 .limit(10)
473 .all()
474 )
475 return jsonify([r.title for r in rows])
478# ── Aircraft document list ────────────────────────────────────────────────────
481@documents_bp.route("/aircraft/<aircraft_ref:aircraft_id>/documents")
482@login_required
483def list_documents(aircraft_id: int) -> ResponseReturnValue:
484 ac = _get_aircraft_or_404(aircraft_id)
485 show_sensitive = request.args.get("sensitive") == "1"
486 doc_type_labels = dict(_AIRCRAFT_DOC_TYPES)
487 doc_type_labels[_NO_TYPE_FILTER] = _NO_TYPE_LABEL
488 filter_doc_type = request.args.get("doc_type") or None
489 if filter_doc_type not in doc_type_labels:
490 filter_doc_type = None
491 query = Document.query.filter_by(aircraft_id=ac.id).options(
492 joinedload(Document.component), # type: ignore[arg-type]
493 joinedload(Document.flight_entry), # type: ignore[arg-type]
494 joinedload(Document.expense), # type: ignore[arg-type]
495 )
496 if not show_sensitive:
497 query = query.filter_by(is_sensitive=False)
498 if filter_doc_type == _NO_TYPE_FILTER:
499 query = query.filter(Document.doc_type.is_(None))
500 elif filter_doc_type:
501 query = query.filter_by(doc_type=filter_doc_type)
502 query = query.order_by(Document.uploaded_at.desc())
504 page = request.args.get("page", 1, type=int)
505 pp_raw = request.args.get("per_page", str(_DOCS_DEFAULT_PER_PAGE))
506 show_all = pp_raw == "all"
507 per_page = (
508 None
509 if show_all
510 else (
511 int(pp_raw)
512 if pp_raw.isdigit() and int(pp_raw) in _DOCS_VALID_PER_PAGE
513 else _DOCS_DEFAULT_PER_PAGE
514 )
515 )
516 if show_all:
517 docs = query.all()
518 pagination = None
519 total_count = len(docs)
520 else:
521 pagination = query.paginate(page=page, per_page=per_page, error_out=False)
522 docs = pagination.items
523 total_count = pagination.total
525 sensitive_count = Document.query.filter_by(
526 aircraft_id=ac.id, is_sensitive=True
527 ).count()
528 role = _current_role()
529 is_owner = role in _OWNER_ROLES
530 broken_ids = {doc.id for doc in docs if _doc_broken(doc)}
531 # One active document per doc_type actually present in this list --
532 # cheap even unfiltered, since an aircraft only ever has the two
533 # expiry-driving types (ARC/insurance) to look up.
534 doc_types_shown = {
535 d.doc_type for d in docs if d.doc_type in _EXPIRY_DRIVING_DOC_TYPES
536 }
537 active_doc_ids = {
538 active.id
539 for dt in doc_types_shown
540 for active in [active_document_for(ac.id, dt, component_id=None)]
541 if active is not None
542 }
543 return render_template(
544 "documents/list.html",
545 aircraft=ac,
546 docs=docs,
547 pagination=pagination,
548 per_page=pp_raw,
549 valid_per_page=_DOCS_VALID_PER_PAGE,
550 total_count=total_count,
551 show_sensitive=show_sensitive,
552 sensitive_count=sensitive_count,
553 is_owner=is_owner,
554 broken_ids=broken_ids,
555 category_labels=_CATEGORY_LABELS,
556 filter_doc_type=filter_doc_type,
557 doc_type_filter_options=list(doc_type_labels.items()),
558 active_doc_ids=active_doc_ids,
559 expiry_driving_doc_types=_EXPIRY_DRIVING_DOC_TYPES,
560 )
563# ── Upload aircraft document ──────────────────────────────────────────────────
566@documents_bp.route(
567 "/aircraft/<aircraft_ref:aircraft_id>/documents/upload", methods=["GET", "POST"]
568)
569@login_required
570@require_role(*_OWNER_ROLES)
571def upload_document(aircraft_id: int) -> ResponseReturnValue:
572 ac = _get_aircraft_or_404(aircraft_id)
573 component = _resolve_component(ac)
575 if request.method == "POST":
576 file = request.files.get("file")
577 title = request.form.get("title", "").strip() or None
578 is_sensitive = bool(request.form.get("is_sensitive"))
579 doc_type = request.form.get("doc_type") or None
580 category = request.form.get("category") or None
581 if category and category not in DocCategory.ALL:
582 category = None
583 valid_from = _parse_date_field(request.form.get("valid_from", ""))
584 valid_until = _parse_date_field(request.form.get("valid_until", ""))
586 def _re_render(msg: str | None = None) -> str:
587 if msg:
588 flash(msg, "danger")
589 return render_template(
590 "documents/upload_form.html",
591 aircraft=ac,
592 component=component,
593 doc_types=_AIRCRAFT_DOC_TYPES,
594 categories=list(_CATEGORY_LABELS.items()),
595 )
597 if not file or not file.filename:
598 return _re_render(_("Please select a file to upload."))
600 original = secure_filename(file.filename)
601 ext = os.path.splitext(original)[1].lower()
602 if ext not in _ALLOWED_EXTS:
603 return _re_render(
604 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown")
605 )
607 if valid_from and valid_until and valid_from > valid_until:
608 return _re_render(_("'Valid from' must not be after 'Valid until'."))
610 # Use canonical path when a category is set and this is an aircraft doc
611 if category and not component:
612 tenant = _get_tenant()
613 stored, mime, size = _save_upload_canonical(
614 file, tenant, ac, category, title
615 )
616 else:
617 label = f"comp{component.id}" if component else f"ac{ac.id}"
618 stored, mime, size = _save_upload(file, label)
620 doc = Document(
621 aircraft_id=ac.id,
622 component_id=component.id if component else None,
623 filename=stored,
624 original_filename=original,
625 mime_type=mime,
626 size_bytes=size,
627 title=title,
628 doc_type=doc_type,
629 category=category,
630 valid_from=valid_from,
631 valid_until=valid_until,
632 is_sensitive=is_sensitive,
633 )
634 db.session.add(doc)
636 # Insurance document without an explicit type: infer it from the
637 # category for backward compatibility with the category-only flow.
638 if doc.doc_type is None and category == DocCategory.INSURANCE and not component:
639 doc.doc_type = DocType.INSURANCE_CERT
641 # A new insurance/ARC document becomes the active certificate for its
642 # type — supersede whichever one was active before it, then recompute
643 # the aircraft's synced insurance_expiry/arc_expiry from what's now
644 # active (see _recompute_expiry_field).
645 if doc.doc_type in _EXPIRY_DRIVING_DOC_TYPES and not component:
646 db.session.flush()
647 prev_cert = Document.query.filter(
648 Document.aircraft_id == ac.id,
649 Document.doc_type == doc.doc_type,
650 Document.superseded_by_id.is_(None),
651 Document.id != doc.id,
652 ).first()
653 if prev_cert:
654 prev_cert.superseded_by_id = doc.id
655 _recompute_expiry_field(ac, doc.doc_type)
657 db.session.commit()
658 activity(
659 "document.uploaded",
660 document_id=doc.id,
661 aircraft_id=ac.id,
662 title=doc.title or "",
663 )
665 flash(_("Document uploaded."), "success")
666 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
668 return render_template(
669 "documents/upload_form.html",
670 aircraft=ac,
671 component=component,
672 doc_types=_AIRCRAFT_DOC_TYPES,
673 categories=list(_CATEGORY_LABELS.items()),
674 )
677# ── Edit aircraft document ────────────────────────────────────────────────────
680@documents_bp.route(
681 "/aircraft/<aircraft_ref:aircraft_id>/documents/<int:document_id>/edit",
682 methods=["GET", "POST"],
683)
684@login_required
685@require_role(*_OWNER_ROLES)
686def edit_document(aircraft_id: int, document_id: int) -> ResponseReturnValue:
687 ac = _get_aircraft_or_404(aircraft_id)
688 doc = _get_aircraft_document_or_404(ac, document_id)
690 if request.method == "POST":
691 doc.title = request.form.get("title", "").strip() or None
692 doc.is_sensitive = bool(request.form.get("is_sensitive"))
693 category = request.form.get("category") or None
694 if category and category not in DocCategory.ALL:
695 category = None
697 old_doc_type = doc.doc_type
698 if not doc.component_id:
699 new_doc_type = request.form.get("doc_type") or None
700 if new_doc_type in dict(_AIRCRAFT_DOC_TYPES) or new_doc_type is None:
701 doc.doc_type = new_doc_type
703 old_category = doc.category
704 doc.category = category
706 # If the category changed and the file lives in the canonical path, move it
707 if category and old_category and category != old_category and doc.filename:
708 parts = doc.filename.replace("\\", "/").split("/")
709 if len(parts) >= 4 and parts[2] == old_category:
710 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
711 old_full = _safe_join(folder, doc.filename)
712 if os.path.exists(old_full):
713 new_relpath = "/".join(parts[:2] + [category] + parts[3:])
714 new_full = _safe_join(folder, new_relpath)
715 os.makedirs(os.path.dirname(new_full), exist_ok=True)
716 try:
717 os.rename(old_full, new_full)
718 doc.filename = new_relpath
719 except OSError as exc:
720 log.warning("Could not move document file: %s", exc)
722 new_valid_from = _parse_date_field(request.form.get("valid_from", ""))
723 new_valid_until = _parse_date_field(request.form.get("valid_until", ""))
724 if new_valid_from and new_valid_until and new_valid_from > new_valid_until:
725 flash(_("'Valid from' must not be after 'Valid until'."), "danger")
726 return render_template(
727 "documents/edit_form.html",
728 aircraft=ac,
729 doc=doc,
730 doc_types=_AIRCRAFT_DOC_TYPES,
731 categories=list(_CATEGORY_LABELS.items()),
732 )
733 doc.valid_from = new_valid_from
734 doc.valid_until = new_valid_until
736 # Recompute whichever Aircraft field(s) this document could affect —
737 # both its old and new doc_type, in case the type itself changed.
738 _recompute_expiry_field(ac, old_doc_type)
739 _recompute_expiry_field(ac, doc.doc_type)
741 db.session.commit()
742 flash(_("Document updated."), "success")
743 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
745 return render_template(
746 "documents/edit_form.html",
747 aircraft=ac,
748 doc=doc,
749 doc_types=_AIRCRAFT_DOC_TYPES,
750 categories=list(_CATEGORY_LABELS.items()),
751 )
754# ── Delete aircraft document ──────────────────────────────────────────────────
757@documents_bp.route(
758 "/aircraft/<aircraft_ref:aircraft_id>/documents/<int:document_id>/delete",
759 methods=["POST"],
760)
761@login_required
762@require_role(*_OWNER_ROLES)
763def delete_document(aircraft_id: int, document_id: int) -> ResponseReturnValue:
764 ac = _get_aircraft_or_404(aircraft_id)
765 doc = _get_aircraft_document_or_404(ac, document_id)
766 doc_type = doc.doc_type
767 activity("document.deleted", document_id=document_id, aircraft_id=aircraft_id)
768 _delete_file(doc.filename)
769 db.session.delete(doc)
770 db.session.commit()
772 # If the deleted document drove an Aircraft expiry field, recompute it —
773 # deleting the active cert reactivates whatever it had superseded (its
774 # own superseded_by_id is cleared by the FK's ON DELETE SET NULL) or
775 # clears the field if nothing is left.
776 if doc_type in _EXPIRY_DRIVING_DOC_TYPES:
777 _recompute_expiry_field(ac, doc_type)
778 db.session.commit()
780 flash(_("Document deleted."), "success")
781 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
784# ── Download all aircraft documents as ZIP ────────────────────────────────────
787@documents_bp.route("/aircraft/<aircraft_ref:aircraft_id>/documents/download-all")
788@login_required
789def download_all_documents(aircraft_id: int) -> ResponseReturnValue:
790 ac = _get_aircraft_or_404(aircraft_id)
791 role = _current_role()
792 include_sensitive = role in _OWNER_ROLES
794 query = Document.query.filter_by(aircraft_id=ac.id)
795 if not include_sensitive:
796 query = query.filter_by(is_sensitive=False)
797 docs = query.order_by(Document.uploaded_at.asc()).all()
799 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
800 buf = io.BytesIO()
801 manifest_lines = ["filename\ttitle\tcategory\ttype\tuploaded\n"]
803 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
804 for doc in docs:
805 path = os.path.join(folder, doc.filename)
806 arcname = doc.original_filename
807 if os.path.exists(path):
808 zf.write(path, arcname=arcname)
809 doc_type_label = doc.doc_type or ""
810 category_label = _CATEGORY_LABELS.get(
811 doc.category or "", doc.category or ""
812 )
813 uploaded = doc.uploaded_at.strftime("%Y-%m-%d") if doc.uploaded_at else ""
814 manifest_lines.append(
815 f"{arcname}\t{doc.title or ''}\t{category_label}\t{doc_type_label}\t{uploaded}\n"
816 )
817 zf.writestr("manifest.txt", "".join(manifest_lines))
819 buf.seek(0)
820 reg = ac.registration.replace("/", "-")
821 zip_name = f"aircraft-{reg}-documents.zip"
822 return Response(
823 buf.read(),
824 mimetype="application/zip",
825 headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
826 )
829# ── Insurance / ARC certificate quick upload ──────────────────────────────────
832def _upload_expiry_cert(
833 aircraft_id: int, doc_type: str, category: str, title: str
834) -> ResponseReturnValue:
835 """Shared handler for the small "Upload cert" forms on the aircraft
836 detail page's Insurance and ARC sections — a file plus its valid-until
837 date, which becomes the new active certificate for that doc_type."""
838 ac = _get_aircraft_or_404(aircraft_id)
839 file = request.files.get("file")
841 if not file or not file.filename:
842 flash(_("Please select a file to upload."), "danger")
843 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
845 original = secure_filename(file.filename)
846 ext = os.path.splitext(original)[1].lower()
847 if ext not in _ALLOWED_EXTS:
848 flash(_("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger")
849 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
851 valid_from = _parse_date_field(request.form.get("valid_from", ""))
852 valid_until = _parse_date_field(request.form.get("valid_until", ""))
853 if valid_from and valid_until and valid_from > valid_until:
854 flash(_("'Valid from' must not be after 'Valid until'."), "danger")
855 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
857 tenant = _get_tenant()
858 stored, mime, size = _save_upload_canonical(file, tenant, ac, category, title)
860 prev = Document.query.filter_by(
861 aircraft_id=ac.id,
862 doc_type=doc_type,
863 superseded_by_id=None,
864 component_id=None,
865 ).first()
867 new_cert = Document(
868 aircraft_id=ac.id,
869 filename=stored,
870 original_filename=original,
871 mime_type=mime,
872 size_bytes=size,
873 title=title,
874 doc_type=doc_type,
875 category=category,
876 valid_from=valid_from,
877 valid_until=valid_until,
878 is_sensitive=True,
879 )
880 db.session.add(new_cert)
881 db.session.flush()
883 if prev:
884 prev.superseded_by_id = new_cert.id
886 _recompute_expiry_field(ac, doc_type)
887 db.session.commit()
888 flash(_("Certificate uploaded."), "success")
889 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
892@documents_bp.route(
893 "/aircraft/<aircraft_ref:aircraft_id>/insurance-cert/upload", methods=["POST"]
894)
895@login_required
896@require_role(*_OWNER_ROLES)
897def upload_insurance_cert(aircraft_id: int) -> ResponseReturnValue:
898 return _upload_expiry_cert(
899 aircraft_id,
900 DocType.INSURANCE_CERT,
901 DocCategory.INSURANCE,
902 _("Insurance Certificate"),
903 )
906@documents_bp.route(
907 "/aircraft/<aircraft_ref:aircraft_id>/arc-cert/upload", methods=["POST"]
908)
909@login_required
910@require_role(*_OWNER_ROLES)
911def upload_arc_cert(aircraft_id: int) -> ResponseReturnValue:
912 return _upload_expiry_cert(
913 aircraft_id,
914 DocType.ARC,
915 DocCategory.AIRWORTHINESS,
916 _("Airworthiness Review Certificate"),
917 )
920# ── Pilot document upload ─────────────────────────────────────────────────────
923@documents_bp.route("/pilot/documents/upload", methods=["GET", "POST"])
924@login_required
925def upload_pilot_document() -> ResponseReturnValue:
926 uid = int(session["user_id"])
928 if request.method == "POST":
929 file = request.files.get("file")
930 title = request.form.get("title", "").strip() or None
931 doc_type = request.form.get("doc_type") or None
932 valid_until_str = request.form.get("valid_until", "").strip()
933 valid_until = None
934 if valid_until_str:
935 try:
936 valid_until = _date.fromisoformat(valid_until_str)
937 except ValueError as exc:
938 log.debug("Invalid valid_until date: %s", exc)
940 if not file or not file.filename:
941 flash(_("Please select a file to upload."), "danger")
942 return render_template(
943 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
944 )
946 original = secure_filename(file.filename)
947 ext = os.path.splitext(original)[1].lower()
948 if ext not in _ALLOWED_EXTS:
949 flash(
950 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger"
951 )
952 return render_template(
953 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
954 )
956 stored, mime, size = _save_upload(file, f"pilot{uid}")
958 doc = Document(
959 pilot_user_id=uid,
960 filename=stored,
961 original_filename=original,
962 mime_type=mime,
963 size_bytes=size,
964 title=title,
965 doc_type=doc_type,
966 valid_until=valid_until,
967 is_sensitive=True,
968 )
969 db.session.add(doc)
970 db.session.commit()
972 flash(_("Document uploaded."), "success")
973 return redirect(url_for("pilots.profile"))
975 return render_template(
976 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
977 )
980# ── Pilot document delete ─────────────────────────────────────────────────────
983@documents_bp.route("/pilot/documents/<int:document_id>/delete", methods=["POST"])
984@login_required
985def delete_pilot_document(document_id: int) -> ResponseReturnValue:
986 uid = int(session["user_id"])
987 doc = db.session.get(Document, document_id)
988 if not doc or doc.pilot_user_id != uid:
989 abort(404)
990 _delete_file(doc.filename)
991 db.session.delete(doc)
992 db.session.commit()
993 flash(_("Document deleted."), "success")
994 return redirect(url_for("pilots.profile"))
997# ── Reconcile: scan + list + import + ignore ──────────────────────────────────
1000@documents_bp.route("/documents/reconcile")
1001@login_required
1002@require_role(*_OWNER_ROLES)
1003def list_reconcile() -> ResponseReturnValue:
1004 import difflib
1006 tenant = _get_tenant()
1007 pending = (
1008 PendingReconcile.query.filter_by(
1009 tenant_id=tenant.id, reconciled_at=None, ignored=False
1010 )
1011 .order_by(PendingReconcile.detected_at.desc())
1012 .all()
1013 )
1014 aircraft_list = (
1015 Aircraft.query.filter_by(tenant_id=tenant.id)
1016 .order_by(Aircraft.registration)
1017 .all()
1018 )
1020 # For entries with an unrecognised category folder, suggest the closest match
1021 category_suggestions: dict[
1022 int, tuple[str, str]
1023 ] = {} # pr.id → (raw_folder, suggestion)
1024 for pr in pending:
1025 parts = pr.filepath.replace("\\", "/").split("/")
1026 if len(parts) >= 4:
1027 raw = parts[2]
1028 if raw.lower() not in DocCategory.ALL:
1029 close = difflib.get_close_matches(
1030 raw.lower(), DocCategory.ALL, n=1, cutoff=0.6
1031 )
1032 if close:
1033 # folder path up to and including the bad category dir
1034 bad_folder = "/".join(parts[:3])
1035 category_suggestions[pr.id] = (bad_folder, close[0])
1037 return render_template(
1038 "documents/reconcile.html",
1039 tenant=tenant,
1040 pending=pending,
1041 aircraft_list=aircraft_list,
1042 categories=list(_CATEGORY_LABELS.items()),
1043 category_suggestions=category_suggestions,
1044 )
1047@documents_bp.route("/documents/reconcile/scan", methods=["POST"])
1048@login_required
1049@require_role(*_OWNER_ROLES)
1050def scan_documents() -> ResponseReturnValue:
1051 tenant = _get_tenant()
1052 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1054 if not tenant.slug:
1055 flash(_("Set a Hangar ID in Settings before scanning for files."), "warning")
1056 return redirect(url_for("documents.list_reconcile"))
1058 slug_dir = os.path.join(folder, tenant.slug)
1059 if not os.path.isdir(slug_dir):
1060 flash(
1061 _(
1062 "No files found in '%(slug)s/'. Mount your Syncthing folder and try again.",
1063 slug=tenant.slug,
1064 ),
1065 "info",
1066 )
1067 return redirect(url_for("documents.list_reconcile"))
1069 # Build set of filenames already tracked in the documents table
1070 tid = tenant.id
1071 known: set[str] = {
1072 doc.filename
1073 for doc in Document.query.filter(
1074 Document.aircraft_id.in_(
1075 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id)
1076 )
1077 ).all()
1078 }
1079 # Prune stale pending entries whose file no longer exists on disk
1080 stale_removed = 0
1081 for pr in PendingReconcile.query.filter_by(
1082 tenant_id=tid, reconciled_at=None, ignored=False
1083 ).all():
1084 if not os.path.exists(os.path.join(folder, pr.filepath)):
1085 db.session.delete(pr)
1086 stale_removed += 1
1087 if stale_removed:
1088 db.session.flush()
1090 existing_pending: set[str] = {
1091 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all()
1092 }
1094 aircraft_by_reg: dict[str, Aircraft] = {
1095 ac.registration.upper().replace("-", "").replace(" ", ""): ac
1096 for ac in Aircraft.query.filter_by(tenant_id=tid).all()
1097 }
1099 new_count = 0
1100 for dirpath, _dirs, filenames in os.walk(slug_dir):
1101 for fname in filenames:
1102 if fname.startswith((".", "_")):
1103 continue
1104 full = os.path.join(dirpath, fname)
1105 relpath = os.path.relpath(full, folder)
1106 # Normalise to forward slashes for DB consistency
1107 relpath = relpath.replace("\\", "/")
1109 if relpath in known or relpath in existing_pending:
1110 continue
1112 # Parse canonical path: slug/reg/category/YYYY-MM-DD - title.ext
1113 parts = relpath.split("/")
1114 if is_app_managed(relpath):
1115 continue
1116 aircraft_obj: Aircraft | None = None
1117 category: str | None = None
1118 title_hint: str | None = None
1119 date_hint: _date | None = None
1121 if len(parts) >= 4:
1122 reg_raw = parts[1].upper().replace("-", "").replace(" ", "")
1123 aircraft_obj = aircraft_by_reg.get(reg_raw)
1124 category, title_hint, date_hint = _parse_reconcile_filename(
1125 parts[2], parts[3]
1126 )
1128 pr = PendingReconcile(
1129 tenant_id=tid,
1130 aircraft_id=aircraft_obj.id if aircraft_obj else None,
1131 filepath=relpath,
1132 category=category,
1133 title_hint=title_hint,
1134 date_hint=date_hint,
1135 )
1136 db.session.add(pr)
1137 new_count += 1
1139 db.session.commit()
1140 parts_msg = []
1141 if new_count:
1142 parts_msg.append(
1143 ngettext(
1144 "one new file queued for review",
1145 "%(n)s new files queued for review",
1146 new_count,
1147 n=new_count,
1148 )
1149 )
1150 if stale_removed:
1151 parts_msg.append(
1152 ngettext(
1153 "one missing file removed from queue",
1154 "%(n)s missing files removed from queue",
1155 stale_removed,
1156 n=stale_removed,
1157 )
1158 )
1159 if parts_msg:
1160 flash(
1161 _("Scan complete — %(details)s.", details=", ".join(parts_msg)), "success"
1162 )
1163 else:
1164 flash(_("Scan complete — no new files found."), "info")
1165 return redirect(url_for("documents.list_reconcile"))
1168@documents_bp.route("/documents/reconcile/rename-folder", methods=["POST"])
1169@login_required
1170@require_role(*_OWNER_ROLES)
1171def rename_reconcile_folder() -> ResponseReturnValue:
1172 """Rename a misnamed category folder on disk (e.g. 'Maintenance' → 'maintenance',
1173 or a typo like 'maintenence' → 'maintenance'), prune stale pending entries for
1174 the old path, then run a fresh scan so the corrected files are picked up immediately.
1175 """
1176 import shutil
1178 tenant = _get_tenant()
1179 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1181 bad_folder = request.form.get("bad_folder", "").strip().replace("\\", "/")
1182 new_category = request.form.get("new_category", "").strip()
1184 if new_category not in DocCategory.ALL:
1185 flash(_("Invalid category."), "danger")
1186 return redirect(url_for("documents.list_reconcile"))
1188 if not tenant.slug or not bad_folder.startswith(tenant.slug + "/"):
1189 abort(403)
1191 old_dir = _safe_join(folder, bad_folder)
1192 parent_rel = "/".join(bad_folder.split("/")[:2]) # slug/reg
1193 new_rel = parent_rel + "/" + new_category
1194 new_dir = _safe_join(folder, new_rel)
1196 if os.path.isdir(old_dir):
1197 if os.path.isdir(new_dir):
1198 for dirpath, _dirs, filenames in os.walk(old_dir):
1199 rel = os.path.relpath(dirpath, old_dir)
1200 dest_d = os.path.join(new_dir, rel)
1201 os.makedirs(dest_d, exist_ok=True)
1202 for fname in filenames:
1203 shutil.move(
1204 os.path.join(dirpath, fname), os.path.join(dest_d, fname)
1205 )
1206 shutil.rmtree(old_dir, ignore_errors=True)
1207 else:
1208 os.rename(old_dir, new_dir)
1210 # Prune stale pending entries for the old folder path
1211 tid = tenant.id
1212 for pr in PendingReconcile.query.filter(
1213 PendingReconcile.tenant_id == tid,
1214 PendingReconcile.filepath.like(bad_folder + "/%"),
1215 ).all():
1216 db.session.delete(pr)
1217 db.session.flush()
1219 # Inline scan: pick up the files now in the correct folder
1220 known: set[str] = {
1221 doc.filename
1222 for doc in Document.query.filter(
1223 Document.aircraft_id.in_(
1224 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id)
1225 )
1226 ).all()
1227 }
1228 existing_pending: set[str] = {
1229 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all()
1230 }
1231 aircraft_by_reg: dict[str, Aircraft] = {
1232 ac.registration.upper().replace("-", "").replace(" ", ""): ac
1233 for ac in Aircraft.query.filter_by(tenant_id=tid).all()
1234 }
1235 new_count = 0
1236 if os.path.isdir(new_dir):
1237 for dirpath, _dirs, filenames in os.walk(new_dir):
1238 for fname in filenames:
1239 if fname.startswith((".", "_")):
1240 continue
1241 relpath = os.path.relpath(os.path.join(dirpath, fname), folder).replace(
1242 "\\", "/"
1243 )
1244 full = _safe_join(folder, relpath)
1245 if relpath in known or relpath in existing_pending:
1246 continue
1247 parts = relpath.split("/")
1248 aircraft_obj: Aircraft | None = None
1249 category: str | None = None
1250 title_hint: str | None = None
1251 date_hint: _date | None = None
1252 if len(parts) >= 4:
1253 reg_raw = parts[1].upper().replace("-", "").replace(" ", "")
1254 aircraft_obj = aircraft_by_reg.get(reg_raw)
1255 category, title_hint, date_hint = _parse_reconcile_filename(
1256 parts[2], parts[3]
1257 )
1258 if aircraft_obj and category:
1259 mime = mimetypes.guess_type(fname)[0] or "application/octet-stream"
1260 size = os.path.getsize(full) if os.path.exists(full) else None
1261 doc = Document(
1262 aircraft_id=aircraft_obj.id,
1263 filename=relpath,
1264 original_filename=fname,
1265 mime_type=mime,
1266 size_bytes=size,
1267 title=title_hint,
1268 category=category,
1269 )
1270 db.session.add(doc)
1271 else:
1272 pr = PendingReconcile(
1273 tenant_id=tid,
1274 aircraft_id=aircraft_obj.id if aircraft_obj else None,
1275 filepath=relpath,
1276 category=category,
1277 title_hint=title_hint,
1278 date_hint=date_hint,
1279 )
1280 db.session.add(pr)
1281 new_count += 1
1283 db.session.commit()
1284 flash(
1285 ngettext(
1286 "Folder renamed to '%(cat)s' — one file processed.",
1287 "Folder renamed to '%(cat)s' — %(n)s files processed.",
1288 new_count,
1289 cat=new_category,
1290 n=new_count,
1291 ),
1292 "success",
1293 )
1294 return redirect(url_for("documents.list_reconcile"))
1297@documents_bp.route("/documents/reconcile/<int:pending_id>/import", methods=["POST"])
1298@login_required
1299@require_role(*_OWNER_ROLES)
1300def import_reconcile(pending_id: int) -> ResponseReturnValue:
1301 tenant = _get_tenant()
1302 pr = PendingReconcile.query.filter_by(
1303 id=pending_id, tenant_id=tenant.id
1304 ).first_or_404()
1306 aircraft_id_raw = request.form.get("aircraft_id")
1307 try:
1308 aircraft_id: int | None = int(aircraft_id_raw) if aircraft_id_raw else None
1309 except (ValueError, TypeError):
1310 aircraft_id = None
1311 if aircraft_id is not None:
1312 _get_aircraft_or_404(aircraft_id)
1314 title = request.form.get("title", "").strip() or pr.title_hint
1315 category = request.form.get("category") or pr.category
1316 if category and category not in DocCategory.ALL:
1317 category = None
1318 valid_until_str = request.form.get("valid_until", "").strip()
1319 valid_until: _date | None = None
1320 if valid_until_str:
1321 with contextlib.suppress(
1322 ValueError
1323 ): # malformed date submitted; valid_until stays None
1324 valid_until = _date.fromisoformat(valid_until_str)
1326 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1327 full_path = os.path.join(folder, pr.filepath)
1328 mime = (
1329 mimetypes.guess_type(os.path.basename(pr.filepath))[0]
1330 or "application/octet-stream"
1331 )
1332 size = os.path.getsize(full_path) if os.path.exists(full_path) else None
1334 doc = Document(
1335 aircraft_id=aircraft_id,
1336 filename=pr.filepath,
1337 original_filename=os.path.basename(pr.filepath),
1338 mime_type=mime,
1339 size_bytes=size,
1340 title=title,
1341 category=category,
1342 valid_until=valid_until,
1343 is_sensitive=False,
1344 )
1345 db.session.add(doc)
1346 pr.reconciled_at = datetime.now(UTC)
1347 db.session.commit()
1349 flash(_("Document imported."), "success")
1350 return redirect(url_for("documents.list_reconcile"))
1353@documents_bp.route("/documents/reconcile/<int:pending_id>/ignore", methods=["POST"])
1354@login_required
1355@require_role(*_OWNER_ROLES)
1356def ignore_reconcile(pending_id: int) -> ResponseReturnValue:
1357 tenant = _get_tenant()
1358 pr = PendingReconcile.query.filter_by(
1359 id=pending_id, tenant_id=tenant.id
1360 ).first_or_404()
1361 pr.ignored = True
1362 db.session.commit()
1363 flash(_("File ignored."), "info")
1364 return redirect(url_for("documents.list_reconcile"))