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
« 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
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
50log = logging.getLogger(__name__)
52documents_bp = Blueprint("documents", __name__)
54_OWNER_ROLES = (Role.ADMIN, Role.OWNER)
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}
71_PILOT_DOC_TYPES = [
72 (DocType.LICENSE, _l("Licence")),
73 (DocType.MEDICAL, _l("Medical certificate")),
74]
76_AIRCRAFT_DOC_TYPES = [
77 (DocType.INSURANCE_CERT, _l("Insurance Certificate")),
78 (DocType.ARC, _l("Airworthiness Review Certificate (ARC)")),
79]
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}
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}
103# ── Helpers ───────────────────────────────────────────────────────────────────
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)
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
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
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
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)
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
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
204def _delete_file(filename: str | None) -> None:
205 """Move file to _trash/ instead of hard-deleting.
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)
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
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
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
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()
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
284_RECONCILE_FILENAME_RE = _re.compile(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$")
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
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.
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}"
328 slug = _ensure_tenant_slug(tenant)
329 safe_reg = aircraft.registration.replace("/", "-").replace(" ", "-").upper()
330 relpath = os.path.join(slug, safe_reg, category, fname)
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)
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)
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
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
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))
361# ── Title suggestions ─────────────────────────────────────────────────────────
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"])
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 )
396 if q:
397 base = base.filter(Document.title.ilike(f"{q}%"))
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])
409# ── Aircraft document list ────────────────────────────────────────────────────
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 )
461# ── Upload aircraft document ──────────────────────────────────────────────────
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)
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", ""))
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 )
495 if not file or not file.filename:
496 return _re_render(_("Please select a file to upload."))
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 )
505 if valid_from and valid_until and valid_from > valid_until:
506 return _re_render(_("'Valid from' must not be after 'Valid until'."))
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)
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)
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
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)
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 )
563 flash(_("Document uploaded."), "success")
564 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
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 )
575# ── Edit aircraft document ────────────────────────────────────────────────────
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)
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
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
601 old_category = doc.category
602 doc.category = category
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)
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
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)
639 db.session.commit()
640 flash(_("Document updated."), "success")
641 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
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 )
652# ── Delete aircraft document ──────────────────────────────────────────────────
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()
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()
678 flash(_("Document deleted."), "success")
679 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
682# ── Download all aircraft documents as ZIP ────────────────────────────────────
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
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()
697 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
698 buf = io.BytesIO()
699 manifest_lines = ["filename\ttitle\tcategory\ttype\tuploaded\n"]
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))
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 )
727# ── Insurance / ARC certificate quick upload ──────────────────────────────────
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")
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))
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))
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))
755 tenant = _get_tenant()
756 stored, mime, size = _save_upload_canonical(file, tenant, ac, category, title)
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()
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()
781 if prev:
782 prev.superseded_by_id = new_cert.id
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))
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 )
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 )
818# ── Pilot document upload ─────────────────────────────────────────────────────
821@documents_bp.route("/pilot/documents/upload", methods=["GET", "POST"])
822@login_required
823def upload_pilot_document() -> ResponseReturnValue:
824 uid = int(session["user_id"])
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)
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 )
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 )
854 stored, mime, size = _save_upload(file, f"pilot{uid}")
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()
870 flash(_("Document uploaded."), "success")
871 return redirect(url_for("pilots.profile"))
873 return render_template(
874 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
875 )
878# ── Pilot document delete ─────────────────────────────────────────────────────
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"))
895# ── Reconcile: scan + list + import + ignore ──────────────────────────────────
898@documents_bp.route("/documents/reconcile")
899@login_required
900@require_role(*_OWNER_ROLES)
901def list_reconcile() -> ResponseReturnValue:
902 import difflib
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 )
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])
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 )
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")
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"))
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"))
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()
988 existing_pending: set[str] = {
989 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all()
990 }
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 }
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("\\", "/")
1007 if relpath in known or relpath in existing_pending:
1008 continue
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
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 )
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
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"))
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
1074 tenant = _get_tenant()
1075 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1077 bad_folder = request.form.get("bad_folder", "").strip().replace("\\", "/")
1078 new_category = request.form.get("new_category", "").strip()
1080 if new_category not in DocCategory.ALL:
1081 flash(_("Invalid category."), "danger")
1082 return redirect(url_for("documents.list_reconcile"))
1084 if not tenant.slug or not bad_folder.startswith(tenant.slug + "/"):
1085 abort(403)
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)
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)
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()
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
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"))
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()
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)
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)
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
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()
1245 flash(_("Document imported."), "success")
1246 return redirect(url_for("documents.list_reconcile"))
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"))