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