Coverage for app/documents/routes.py: 100%
554 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1import contextlib
2import io
3import logging
4import mimetypes
5import os
6import re as _re
7import uuid
8import zipfile
9from datetime import date as _date
10from datetime import datetime, timezone
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 werkzeug.datastructures import FileStorage
27from werkzeug.utils import secure_filename
29from flask_babel import gettext as _, ngettext # pyright: ignore[reportMissingImports]
31from models import ( # pyright: ignore[reportMissingImports]
32 Aircraft,
33 Component,
34 DocCategory,
35 DocType,
36 Document,
37 PendingReconcile,
38 Role,
39 Tenant,
40 TenantUser,
41 db,
42)
43from utils import activity, login_required, require_role, user_can_access_aircraft # pyright: ignore[reportMissingImports]
45log = logging.getLogger(__name__)
47documents_bp = Blueprint("documents", __name__)
49_OWNER_ROLES = (Role.ADMIN, Role.OWNER)
51_ALLOWED_EXTS = {
52 ".jpg",
53 ".jpeg",
54 ".png",
55 ".gif",
56 ".webp",
57 ".heic",
58 ".pdf",
59 ".doc",
60 ".docx",
61 ".xls",
62 ".xlsx",
63 ".txt",
64}
66_PILOT_DOC_TYPES = [
67 (DocType.LICENSE, "Licence"),
68 (DocType.MEDICAL, "Medical certificate"),
69]
71# Human-readable labels for each DocCategory value
72_CATEGORY_LABELS: dict[str, str] = {
73 DocCategory.MAINTENANCE: "Maintenance",
74 DocCategory.INSURANCE: "Insurance",
75 DocCategory.POH: "POH / Flight Manual",
76 DocCategory.AIRWORTHINESS: "Airworthiness",
77 DocCategory.LOGBOOK: "Logbook",
78 DocCategory.INVOICE: "Invoice",
79 DocCategory.OTHER: "Other",
80 DocCategory.UNCATEGORISED: "Uncategorised",
81}
84# ── Helpers ───────────────────────────────────────────────────────────────────
87def _tenant_id() -> int:
88 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
89 if not tu:
90 abort(403)
91 return int(tu.tenant_id)
94def _get_tenant() -> Tenant:
95 tid = _tenant_id()
96 t = db.session.get(Tenant, tid)
97 if not t:
98 abort(403) # pragma: no cover
99 return t
102def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
103 ac = db.session.get(Aircraft, aircraft_id)
104 if (
105 not ac
106 or ac.tenant_id != _tenant_id()
107 or not user_can_access_aircraft(aircraft_id)
108 ):
109 abort(404)
110 return ac
113def _get_aircraft_document_or_404(aircraft: Aircraft, document_id: int) -> Document:
114 doc = db.session.get(Document, document_id)
115 if not doc or doc.aircraft_id != aircraft.id:
116 abort(404)
117 return doc
120def _delete_file(filename: str | None) -> None:
121 """Move file to _trash/ instead of hard-deleting.
123 Syncthing propagates the move to all peers; the file is recoverable.
124 If the file is not found, log and continue silently.
125 """
126 if not filename:
127 return
128 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
129 src = os.path.join(folder, filename)
130 if not os.path.exists(src):
131 current_app.logger.debug("File already absent, skipping trash: %s", filename)
132 return
133 try:
134 trash_dir = os.path.join(folder, "_trash")
135 os.makedirs(trash_dir, exist_ok=True)
136 dest_name = os.path.basename(filename)
137 dest = os.path.join(trash_dir, dest_name)
138 if os.path.exists(dest):
139 base, ext = os.path.splitext(dest_name)
140 dest = os.path.join(trash_dir, f"{base}_{uuid.uuid4().hex[:8]}{ext}")
141 os.rename(src, dest)
142 except OSError:
143 current_app.logger.debug("Could not move to trash: %s", filename)
146def _resolve_component(ac: Aircraft) -> Component | None:
147 raw = request.args.get("component_id") or request.form.get("component_id")
148 if not raw:
149 return None
150 try:
151 cid = int(raw)
152 except (ValueError, TypeError):
153 return None
154 comp = db.session.get(Component, cid)
155 return comp if (comp and comp.aircraft_id == ac.id) else None
158def _save_upload(file: FileStorage, label: str) -> tuple[str, str, int]:
159 """Save *file* flat to upload folder (legacy path, no canonical structure)."""
160 original = secure_filename(file.filename or "")
161 ext = os.path.splitext(original)[1].lower()
162 stored = f"doc_{label}_{uuid.uuid4().hex[:12]}{ext}"
163 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
164 os.makedirs(folder, exist_ok=True)
165 file.save(os.path.join(folder, stored))
166 mime = mimetypes.guess_type(original)[0] or "application/octet-stream"
167 size = os.path.getsize(os.path.join(folder, stored))
168 return stored, mime, size
171def _ensure_tenant_slug(tenant: Tenant) -> str:
172 """Return tenant.slug, generating one from the name if not yet set."""
173 if tenant.slug:
174 return str(tenant.slug)
175 base = _re.sub(r"[^a-z0-9]+", "-", tenant.name.lower()).strip("-")[:64]
176 slug = base
177 n = 1
178 while Tenant.query.filter(Tenant.slug == slug, Tenant.id != tenant.id).first():
179 slug = f"{base}-{n}"
180 n += 1
181 tenant.slug = slug
182 db.session.flush()
183 return slug
186def _safe_path_component(s: str) -> str:
187 """Strip characters that are unsafe in filesystem path segments."""
188 return _re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", s).strip()
191def _safe_join(upload_folder: str, *parts: str) -> str:
192 """Join parts under upload_folder; abort(400) if the result would escape it."""
193 root = os.path.realpath(upload_folder)
194 joined = os.path.normpath(os.path.join(root, *parts))
195 if not (joined == root or joined.startswith(root + os.sep)):
196 abort(400)
197 return joined
200def _save_upload_canonical(
201 file: FileStorage,
202 tenant: Tenant,
203 aircraft: Aircraft,
204 category: str,
205 title: str | None,
206) -> tuple[str, str, int]:
207 """Save *file* to the canonical Syncthing-compatible path structure.
209 Returns (relpath, mime_type, size_bytes) where relpath is relative to
210 UPLOAD_FOLDER and suitable for storage in Document.filename.
211 """
212 original = secure_filename(file.filename or "unnamed")
213 ext = os.path.splitext(original)[1].lower()
214 today = _date.today().isoformat()
215 safe_title = _safe_path_component(title or os.path.splitext(original)[0])[:100]
216 fname = f"{today} - {safe_title}{ext}"
218 slug = _ensure_tenant_slug(tenant)
219 safe_reg = aircraft.registration.replace("/", "-").replace(" ", "-").upper()
220 relpath = os.path.join(slug, safe_reg, category, fname)
222 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
223 full_dir = _safe_join(folder, slug, safe_reg, category)
224 os.makedirs(full_dir, exist_ok=True)
226 dest = _safe_join(folder, relpath)
227 # If a file with this name already exists (e.g. same title + date), add a short suffix
228 if os.path.exists(dest):
229 base, ext2 = os.path.splitext(fname)
230 relpath = os.path.join(
231 slug, safe_reg, category, f"{base}_{uuid.uuid4().hex[:6]}{ext2}"
232 )
233 dest = _safe_join(folder, relpath)
235 file.save(dest)
236 mime = mimetypes.guess_type(original)[0] or "application/octet-stream"
237 size = os.path.getsize(dest)
238 return relpath, mime, size
241def _current_role() -> Role | None:
242 tu = TenantUser.query.filter_by(user_id=session.get("user_id")).first()
243 return tu.role if tu else None
246def _doc_broken(doc: Document) -> bool:
247 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
248 return not os.path.exists(os.path.join(folder, doc.filename))
251# ── Title suggestions ─────────────────────────────────────────────────────────
254@documents_bp.route("/documents/title-suggestions")
255@login_required
256def title_suggestions() -> ResponseReturnValue:
257 q = request.args.get("q", "").strip()
258 owner_type = request.args.get("owner_type", "aircraft")
259 uid = int(session["user_id"])
261 if owner_type == "pilot":
262 base = Document.query.filter(
263 Document.pilot_user_id == uid,
264 Document.title.isnot(None),
265 )
266 else:
267 tid = _tenant_id()
268 aircraft_ids = [
269 row.id
270 for row in Aircraft.query.filter_by(tenant_id=tid)
271 .with_entities(Aircraft.id)
272 .all()
273 ]
274 base = Document.query.filter(
275 Document.aircraft_id.in_(aircraft_ids),
276 Document.title.isnot(None),
277 )
278 if owner_type == "component":
279 base = base.filter(Document.component_id.isnot(None))
280 else:
281 base = base.filter(
282 Document.component_id.is_(None),
283 Document.flight_entry_id.is_(None),
284 )
286 if q:
287 base = base.filter(Document.title.ilike(f"{q}%"))
289 rows = (
290 base.with_entities(Document.title)
291 .distinct()
292 .order_by(Document.title)
293 .limit(10)
294 .all()
295 )
296 return jsonify([r.title for r in rows])
299# ── Aircraft document list ────────────────────────────────────────────────────
302@documents_bp.route("/aircraft/<int:aircraft_id>/documents")
303@login_required
304def list_documents(aircraft_id: int) -> ResponseReturnValue:
305 ac = _get_aircraft_or_404(aircraft_id)
306 show_sensitive = request.args.get("sensitive") == "1"
307 query = Document.query.filter_by(aircraft_id=ac.id)
308 if not show_sensitive:
309 query = query.filter_by(is_sensitive=False)
310 docs = query.order_by(Document.uploaded_at.desc()).all()
311 sensitive_count = Document.query.filter_by(
312 aircraft_id=ac.id, is_sensitive=True
313 ).count()
314 role = _current_role()
315 is_owner = role in _OWNER_ROLES
316 broken_ids = {doc.id for doc in docs if _doc_broken(doc)}
317 return render_template(
318 "documents/list.html",
319 aircraft=ac,
320 docs=docs,
321 show_sensitive=show_sensitive,
322 sensitive_count=sensitive_count,
323 is_owner=is_owner,
324 broken_ids=broken_ids,
325 category_labels=_CATEGORY_LABELS,
326 )
329# ── Upload aircraft document ──────────────────────────────────────────────────
332@documents_bp.route(
333 "/aircraft/<int:aircraft_id>/documents/upload", methods=["GET", "POST"]
334)
335@login_required
336@require_role(*_OWNER_ROLES)
337def upload_document(aircraft_id: int) -> ResponseReturnValue:
338 ac = _get_aircraft_or_404(aircraft_id)
339 component = _resolve_component(ac)
341 if request.method == "POST":
342 file = request.files.get("file")
343 title = request.form.get("title", "").strip() or None
344 is_sensitive = bool(request.form.get("is_sensitive"))
345 doc_type = request.form.get("doc_type") or None
346 category = request.form.get("category") or None
347 if category and category not in DocCategory.ALL:
348 category = None
349 valid_until_str = request.form.get("valid_until", "").strip()
350 valid_until = None
351 if valid_until_str:
352 try:
353 valid_until = _date.fromisoformat(valid_until_str)
354 except ValueError as exc:
355 log.debug("Invalid valid_until date: %s", exc)
357 def _re_render(msg: str | None = None) -> str:
358 if msg:
359 flash(msg, "danger")
360 return render_template(
361 "documents/upload_form.html",
362 aircraft=ac,
363 component=component,
364 doc_types=_PILOT_DOC_TYPES,
365 categories=list(_CATEGORY_LABELS.items()),
366 )
368 if not file or not file.filename:
369 return _re_render(_("Please select a file to upload."))
371 original = secure_filename(file.filename)
372 ext = os.path.splitext(original)[1].lower()
373 if ext not in _ALLOWED_EXTS:
374 return _re_render(
375 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown")
376 )
378 # Use canonical path when a category is set and this is an aircraft doc
379 if category and not component:
380 tenant = _get_tenant()
381 stored, mime, size = _save_upload_canonical(
382 file, tenant, ac, category, title
383 )
384 else:
385 label = f"comp{component.id}" if component else f"ac{ac.id}"
386 stored, mime, size = _save_upload(file, label)
388 doc = Document(
389 aircraft_id=ac.id,
390 component_id=component.id if component else None,
391 filename=stored,
392 original_filename=original,
393 mime_type=mime,
394 size_bytes=size,
395 title=title,
396 doc_type=doc_type,
397 category=category,
398 valid_until=valid_until,
399 is_sensitive=is_sensitive,
400 )
401 db.session.add(doc)
403 # Insurance document with an expiry date: auto-update the aircraft's
404 # insurance_expiry (if the new date is later) and make this document
405 # the active certificate so the "View certificate" link appears.
406 if category == DocCategory.INSURANCE and valid_until and not component:
407 doc.doc_type = DocType.INSURANCE_CERT
408 if ac.insurance_expiry is None or valid_until > ac.insurance_expiry:
409 ac.insurance_expiry = valid_until
410 db.session.flush()
411 prev_cert = Document.query.filter(
412 Document.aircraft_id == ac.id,
413 Document.doc_type == DocType.INSURANCE_CERT,
414 Document.superseded_by_id.is_(None),
415 Document.id != doc.id,
416 ).first()
417 if prev_cert:
418 prev_cert.superseded_by_id = doc.id
420 db.session.commit()
421 activity(
422 "document.uploaded",
423 document_id=doc.id,
424 aircraft_id=ac.id,
425 title=doc.title or "",
426 )
428 flash(_("Document uploaded."), "success")
429 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
431 return render_template(
432 "documents/upload_form.html",
433 aircraft=ac,
434 component=component,
435 doc_types=_PILOT_DOC_TYPES,
436 categories=list(_CATEGORY_LABELS.items()),
437 )
440# ── Edit aircraft document ────────────────────────────────────────────────────
443@documents_bp.route(
444 "/aircraft/<int:aircraft_id>/documents/<int:document_id>/edit",
445 methods=["GET", "POST"],
446)
447@login_required
448@require_role(*_OWNER_ROLES)
449def edit_document(aircraft_id: int, document_id: int) -> ResponseReturnValue:
450 ac = _get_aircraft_or_404(aircraft_id)
451 doc = _get_aircraft_document_or_404(ac, document_id)
453 if request.method == "POST":
454 doc.title = request.form.get("title", "").strip() or None
455 doc.is_sensitive = bool(request.form.get("is_sensitive"))
456 category = request.form.get("category") or None
457 if category and category not in DocCategory.ALL:
458 category = None
460 old_category = doc.category
461 doc.category = category
463 # If the category changed and the file lives in the canonical path, move it
464 if category and old_category and category != old_category and doc.filename:
465 parts = doc.filename.replace("\\", "/").split("/")
466 if len(parts) >= 4 and parts[2] == old_category:
467 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
468 old_full = _safe_join(folder, doc.filename)
469 if os.path.exists(old_full):
470 new_relpath = "/".join(parts[:2] + [category] + parts[3:])
471 new_full = _safe_join(folder, new_relpath)
472 os.makedirs(os.path.dirname(new_full), exist_ok=True)
473 try:
474 os.rename(old_full, new_full)
475 doc.filename = new_relpath
476 except OSError as exc:
477 log.warning("Could not move document file: %s", exc)
479 valid_until_str = request.form.get("valid_until", "").strip()
480 doc.valid_until = None
481 if valid_until_str:
482 try:
483 doc.valid_until = _date.fromisoformat(valid_until_str)
484 except ValueError as exc:
485 log.debug("Invalid valid_until date: %s", exc)
486 db.session.commit()
487 flash(_("Document updated."), "success")
488 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
490 return render_template(
491 "documents/edit_form.html",
492 aircraft=ac,
493 doc=doc,
494 categories=list(_CATEGORY_LABELS.items()),
495 )
498# ── Delete aircraft document ──────────────────────────────────────────────────
501@documents_bp.route(
502 "/aircraft/<int:aircraft_id>/documents/<int:document_id>/delete", methods=["POST"]
503)
504@login_required
505@require_role(*_OWNER_ROLES)
506def delete_document(aircraft_id: int, document_id: int) -> ResponseReturnValue:
507 ac = _get_aircraft_or_404(aircraft_id)
508 doc = _get_aircraft_document_or_404(ac, document_id)
509 activity("document.deleted", document_id=document_id, aircraft_id=aircraft_id)
510 _delete_file(doc.filename)
511 db.session.delete(doc)
512 db.session.commit()
513 flash(_("Document deleted."), "success")
514 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
517# ── Download all aircraft documents as ZIP ────────────────────────────────────
520@documents_bp.route("/aircraft/<int:aircraft_id>/documents/download-all")
521@login_required
522def download_all_documents(aircraft_id: int) -> ResponseReturnValue:
523 ac = _get_aircraft_or_404(aircraft_id)
524 role = _current_role()
525 include_sensitive = role in _OWNER_ROLES
527 query = Document.query.filter_by(aircraft_id=ac.id)
528 if not include_sensitive:
529 query = query.filter_by(is_sensitive=False)
530 docs = query.order_by(Document.uploaded_at.asc()).all()
532 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
533 buf = io.BytesIO()
534 manifest_lines = ["filename\ttitle\tcategory\ttype\tuploaded\n"]
536 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
537 for doc in docs:
538 path = os.path.join(folder, doc.filename)
539 arcname = doc.original_filename
540 if os.path.exists(path):
541 zf.write(path, arcname=arcname)
542 doc_type_label = doc.doc_type or ""
543 category_label = _CATEGORY_LABELS.get(
544 doc.category or "", doc.category or ""
545 )
546 uploaded = doc.uploaded_at.strftime("%Y-%m-%d") if doc.uploaded_at else ""
547 manifest_lines.append(
548 f"{arcname}\t{doc.title or ''}\t{category_label}\t{doc_type_label}\t{uploaded}\n"
549 )
550 zf.writestr("manifest.txt", "".join(manifest_lines))
552 buf.seek(0)
553 reg = ac.registration.replace("/", "-")
554 zip_name = f"aircraft-{reg}-documents.zip"
555 return Response(
556 buf.read(),
557 mimetype="application/zip",
558 headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
559 )
562# ── Insurance certificate upload ──────────────────────────────────────────────
565@documents_bp.route(
566 "/aircraft/<int:aircraft_id>/insurance-cert/upload", methods=["POST"]
567)
568@login_required
569@require_role(*_OWNER_ROLES)
570def upload_insurance_cert(aircraft_id: int) -> ResponseReturnValue:
571 ac = _get_aircraft_or_404(aircraft_id)
572 file = request.files.get("file")
574 if not file or not file.filename:
575 flash(_("Please select a file to upload."), "danger")
576 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
578 original = secure_filename(file.filename)
579 ext = os.path.splitext(original)[1].lower()
580 if ext not in _ALLOWED_EXTS:
581 flash(_("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger")
582 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
584 # Insurance certificates go into the canonical insurance folder
585 tenant = _get_tenant()
586 stored, mime, size = _save_upload_canonical(
587 file, tenant, ac, DocCategory.INSURANCE, _("Insurance Certificate")
588 )
590 # Mark previous insurance certificate as superseded
591 prev = (
592 Document.query.filter_by(
593 aircraft_id=ac.id,
594 doc_type=DocType.INSURANCE_CERT,
595 )
596 .filter(Document.superseded_by_id.is_(None))
597 .first()
598 )
600 new_cert = Document(
601 aircraft_id=ac.id,
602 filename=stored,
603 original_filename=original,
604 mime_type=mime,
605 size_bytes=size,
606 title=_("Insurance Certificate"),
607 doc_type=DocType.INSURANCE_CERT,
608 category=DocCategory.INSURANCE,
609 valid_until=ac.insurance_expiry,
610 is_sensitive=True,
611 )
612 db.session.add(new_cert)
613 db.session.flush()
615 if prev:
616 prev.superseded_by_id = new_cert.id
618 db.session.commit()
619 flash(_("Insurance certificate uploaded."), "success")
620 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
623# ── Pilot document upload ─────────────────────────────────────────────────────
626@documents_bp.route("/pilot/documents/upload", methods=["GET", "POST"])
627@login_required
628def upload_pilot_document() -> ResponseReturnValue:
629 uid = int(session["user_id"])
631 if request.method == "POST":
632 file = request.files.get("file")
633 title = request.form.get("title", "").strip() or None
634 doc_type = request.form.get("doc_type") or None
635 valid_until_str = request.form.get("valid_until", "").strip()
636 valid_until = None
637 if valid_until_str:
638 try:
639 valid_until = _date.fromisoformat(valid_until_str)
640 except ValueError as exc:
641 log.debug("Invalid valid_until date: %s", exc)
643 if not file or not file.filename:
644 flash(_("Please select a file to upload."), "danger")
645 return render_template(
646 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
647 )
649 original = secure_filename(file.filename)
650 ext = os.path.splitext(original)[1].lower()
651 if ext not in _ALLOWED_EXTS:
652 flash(
653 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger"
654 )
655 return render_template(
656 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
657 )
659 stored, mime, size = _save_upload(file, f"pilot{uid}")
661 doc = Document(
662 pilot_user_id=uid,
663 filename=stored,
664 original_filename=original,
665 mime_type=mime,
666 size_bytes=size,
667 title=title,
668 doc_type=doc_type,
669 valid_until=valid_until,
670 is_sensitive=True,
671 )
672 db.session.add(doc)
673 db.session.commit()
675 flash(_("Document uploaded."), "success")
676 return redirect(url_for("pilots.profile"))
678 return render_template(
679 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
680 )
683# ── Pilot document delete ─────────────────────────────────────────────────────
686@documents_bp.route("/pilot/documents/<int:document_id>/delete", methods=["POST"])
687@login_required
688def delete_pilot_document(document_id: int) -> ResponseReturnValue:
689 uid = int(session["user_id"])
690 doc = db.session.get(Document, document_id)
691 if not doc or doc.pilot_user_id != uid:
692 abort(404)
693 _delete_file(doc.filename)
694 db.session.delete(doc)
695 db.session.commit()
696 flash(_("Document deleted."), "success")
697 return redirect(url_for("pilots.profile"))
700# ── Reconcile: scan + list + import + ignore ──────────────────────────────────
703@documents_bp.route("/documents/reconcile")
704@login_required
705@require_role(*_OWNER_ROLES)
706def list_reconcile() -> ResponseReturnValue:
707 import difflib
709 tenant = _get_tenant()
710 pending = (
711 PendingReconcile.query.filter_by(
712 tenant_id=tenant.id, reconciled_at=None, ignored=False
713 )
714 .order_by(PendingReconcile.detected_at.desc())
715 .all()
716 )
717 aircraft_list = (
718 Aircraft.query.filter_by(tenant_id=tenant.id)
719 .order_by(Aircraft.registration)
720 .all()
721 )
723 # For entries with an unrecognised category folder, suggest the closest match
724 category_suggestions: dict[
725 int, tuple[str, str]
726 ] = {} # pr.id → (raw_folder, suggestion)
727 for pr in pending:
728 parts = pr.filepath.replace("\\", "/").split("/")
729 if len(parts) >= 4:
730 raw = parts[2]
731 if raw.lower() not in DocCategory.ALL:
732 close = difflib.get_close_matches(
733 raw.lower(), DocCategory.ALL, n=1, cutoff=0.6
734 )
735 if close:
736 # folder path up to and including the bad category dir
737 bad_folder = "/".join(parts[:3])
738 category_suggestions[pr.id] = (bad_folder, close[0])
740 return render_template(
741 "documents/reconcile.html",
742 tenant=tenant,
743 pending=pending,
744 aircraft_list=aircraft_list,
745 categories=list(_CATEGORY_LABELS.items()),
746 category_suggestions=category_suggestions,
747 )
750@documents_bp.route("/documents/reconcile/scan", methods=["POST"])
751@login_required
752@require_role(*_OWNER_ROLES)
753def scan_documents() -> ResponseReturnValue:
754 tenant = _get_tenant()
755 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
757 if not tenant.slug:
758 flash(_("Set a Hangar ID in Settings before scanning for files."), "warning")
759 return redirect(url_for("documents.list_reconcile"))
761 slug_dir = os.path.join(folder, tenant.slug)
762 if not os.path.isdir(slug_dir):
763 flash(
764 _(
765 "No files found in '%(slug)s/'. Mount your Syncthing folder and try again.",
766 slug=tenant.slug,
767 ),
768 "info",
769 )
770 return redirect(url_for("documents.list_reconcile"))
772 # Build set of filenames already tracked in the documents table
773 tid = tenant.id
774 known: set[str] = {
775 doc.filename
776 for doc in Document.query.filter(
777 Document.aircraft_id.in_(
778 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id)
779 )
780 ).all()
781 }
782 # Prune stale pending entries whose file no longer exists on disk
783 stale_removed = 0
784 for pr in PendingReconcile.query.filter_by(
785 tenant_id=tid, reconciled_at=None, ignored=False
786 ).all():
787 if not os.path.exists(os.path.join(folder, pr.filepath)):
788 db.session.delete(pr)
789 stale_removed += 1
790 if stale_removed:
791 db.session.flush()
793 existing_pending: set[str] = {
794 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all()
795 }
797 aircraft_by_reg: dict[str, Aircraft] = {
798 ac.registration.upper().replace("-", "").replace(" ", ""): ac
799 for ac in Aircraft.query.filter_by(tenant_id=tid).all()
800 }
802 new_count = 0
803 for dirpath, _dirs, filenames in os.walk(slug_dir):
804 for fname in filenames:
805 if fname.startswith(".") or fname.startswith("_"):
806 continue
807 full = os.path.join(dirpath, fname)
808 relpath = os.path.relpath(full, folder)
809 # Normalise to forward slashes for DB consistency
810 relpath = relpath.replace("\\", "/")
812 if relpath in known or relpath in existing_pending:
813 continue
815 # Parse canonical path: slug/reg/category/YYYY-MM-DD - title.ext
816 parts = relpath.split("/")
817 aircraft_obj: Aircraft | None = None
818 category: str | None = None
819 title_hint: str | None = None
820 date_hint: _date | None = None
822 if len(parts) >= 4:
823 reg_raw = parts[1].upper().replace("-", "").replace(" ", "")
824 aircraft_obj = aircraft_by_reg.get(reg_raw)
825 cat_str = parts[2]
826 if cat_str.lower() in DocCategory.ALL:
827 category = cat_str.lower()
828 # Parse "YYYY-MM-DD - title.ext"
829 m = _re.match(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$", parts[3])
830 if m:
831 with contextlib.suppress(
832 ValueError
833 ): # regex matched date-like string but it's invalid; treat as no date
834 date_hint = _date.fromisoformat(m.group(1))
835 title_hint = m.group(2)
836 else:
837 title_hint = os.path.splitext(parts[3])[0]
839 pr = PendingReconcile(
840 tenant_id=tid,
841 aircraft_id=aircraft_obj.id if aircraft_obj else None,
842 filepath=relpath,
843 category=category,
844 title_hint=title_hint,
845 date_hint=date_hint,
846 )
847 db.session.add(pr)
848 new_count += 1
850 db.session.commit()
851 parts_msg = []
852 if new_count:
853 parts_msg.append(
854 ngettext(
855 "one new file queued for review",
856 "%(n)s new files queued for review",
857 new_count,
858 n=new_count,
859 )
860 )
861 if stale_removed:
862 parts_msg.append(
863 ngettext(
864 "one missing file removed from queue",
865 "%(n)s missing files removed from queue",
866 stale_removed,
867 n=stale_removed,
868 )
869 )
870 if parts_msg:
871 flash(
872 _("Scan complete — %(details)s.", details=", ".join(parts_msg)), "success"
873 )
874 else:
875 flash(_("Scan complete — no new files found."), "info")
876 return redirect(url_for("documents.list_reconcile"))
879@documents_bp.route("/documents/reconcile/rename-folder", methods=["POST"])
880@login_required
881@require_role(*_OWNER_ROLES)
882def rename_reconcile_folder() -> ResponseReturnValue:
883 """Rename a misnamed category folder on disk (e.g. 'Maintenance' → 'maintenance',
884 or a typo like 'maintenence' → 'maintenance'), prune stale pending entries for
885 the old path, then run a fresh scan so the corrected files are picked up immediately.
886 """
887 import shutil
889 tenant = _get_tenant()
890 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
892 bad_folder = request.form.get("bad_folder", "").strip().replace("\\", "/")
893 new_category = request.form.get("new_category", "").strip()
895 if new_category not in DocCategory.ALL:
896 flash(_("Invalid category."), "danger")
897 return redirect(url_for("documents.list_reconcile"))
899 if not tenant.slug or not bad_folder.startswith(tenant.slug + "/"):
900 abort(403)
902 old_dir = _safe_join(folder, bad_folder)
903 parent_rel = "/".join(bad_folder.split("/")[:2]) # slug/reg
904 new_rel = parent_rel + "/" + new_category
905 new_dir = _safe_join(folder, new_rel)
907 if os.path.isdir(old_dir):
908 if os.path.isdir(new_dir):
909 for dirpath, _dirs, filenames in os.walk(old_dir):
910 rel = os.path.relpath(dirpath, old_dir)
911 dest_d = os.path.join(new_dir, rel)
912 os.makedirs(dest_d, exist_ok=True)
913 for fname in filenames:
914 shutil.move(
915 os.path.join(dirpath, fname), os.path.join(dest_d, fname)
916 )
917 shutil.rmtree(old_dir, ignore_errors=True)
918 else:
919 os.rename(old_dir, new_dir)
921 # Prune stale pending entries for the old folder path
922 tid = tenant.id
923 for pr in PendingReconcile.query.filter(
924 PendingReconcile.tenant_id == tid,
925 PendingReconcile.filepath.like(bad_folder + "/%"),
926 ).all():
927 db.session.delete(pr)
928 db.session.flush()
930 # Inline scan: pick up the files now in the correct folder
931 known: set[str] = {
932 doc.filename
933 for doc in Document.query.filter(
934 Document.aircraft_id.in_(
935 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id)
936 )
937 ).all()
938 }
939 existing_pending: set[str] = {
940 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all()
941 }
942 aircraft_by_reg: dict[str, Aircraft] = {
943 ac.registration.upper().replace("-", "").replace(" ", ""): ac
944 for ac in Aircraft.query.filter_by(tenant_id=tid).all()
945 }
946 new_count = 0
947 if os.path.isdir(new_dir):
948 for dirpath, _dirs, filenames in os.walk(new_dir):
949 for fname in filenames:
950 if fname.startswith(".") or fname.startswith("_"):
951 continue
952 relpath = os.path.relpath(os.path.join(dirpath, fname), folder).replace(
953 "\\", "/"
954 )
955 full = _safe_join(folder, relpath)
956 if relpath in known or relpath in existing_pending:
957 continue
958 parts = relpath.split("/")
959 aircraft_obj: Aircraft | None = None
960 category: str | None = None
961 title_hint: str | None = None
962 date_hint: _date | None = None
963 if len(parts) >= 4:
964 reg_raw = parts[1].upper().replace("-", "").replace(" ", "")
965 aircraft_obj = aircraft_by_reg.get(reg_raw)
966 cat_str = parts[2]
967 if cat_str.lower() in DocCategory.ALL:
968 category = cat_str.lower()
969 m = _re.match(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$", parts[3])
970 if m:
971 with contextlib.suppress(
972 ValueError
973 ): # regex matched date-like string but it's invalid; treat as no date
974 date_hint = _date.fromisoformat(m.group(1))
975 title_hint = m.group(2)
976 else:
977 title_hint = os.path.splitext(parts[3])[0]
978 if aircraft_obj and category:
979 mime = mimetypes.guess_type(fname)[0] or "application/octet-stream"
980 size = os.path.getsize(full) if os.path.exists(full) else None
981 doc = Document(
982 aircraft_id=aircraft_obj.id,
983 filename=relpath,
984 original_filename=fname,
985 mime_type=mime,
986 size_bytes=size,
987 title=title_hint,
988 category=category,
989 )
990 db.session.add(doc)
991 else:
992 pr = PendingReconcile(
993 tenant_id=tid,
994 aircraft_id=aircraft_obj.id if aircraft_obj else None,
995 filepath=relpath,
996 category=category,
997 title_hint=title_hint,
998 date_hint=date_hint,
999 )
1000 db.session.add(pr)
1001 new_count += 1
1003 db.session.commit()
1004 flash(
1005 ngettext(
1006 "Folder renamed to '%(cat)s' — one file processed.",
1007 "Folder renamed to '%(cat)s' — %(n)s files processed.",
1008 new_count,
1009 cat=new_category,
1010 n=new_count,
1011 ),
1012 "success",
1013 )
1014 return redirect(url_for("documents.list_reconcile"))
1017@documents_bp.route("/documents/reconcile/<int:pending_id>/import", methods=["POST"])
1018@login_required
1019@require_role(*_OWNER_ROLES)
1020def import_reconcile(pending_id: int) -> ResponseReturnValue:
1021 tenant = _get_tenant()
1022 pr = PendingReconcile.query.filter_by(
1023 id=pending_id, tenant_id=tenant.id
1024 ).first_or_404()
1026 aircraft_id_raw = request.form.get("aircraft_id")
1027 try:
1028 aircraft_id: int | None = int(aircraft_id_raw) if aircraft_id_raw else None
1029 except (ValueError, TypeError):
1030 aircraft_id = None
1032 title = request.form.get("title", "").strip() or pr.title_hint
1033 category = request.form.get("category") or pr.category
1034 if category and category not in DocCategory.ALL:
1035 category = None
1036 valid_until_str = request.form.get("valid_until", "").strip()
1037 valid_until: _date | None = None
1038 if valid_until_str:
1039 with contextlib.suppress(
1040 ValueError
1041 ): # malformed date submitted; valid_until stays None
1042 valid_until = _date.fromisoformat(valid_until_str)
1044 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1045 full_path = os.path.join(folder, pr.filepath)
1046 mime = (
1047 mimetypes.guess_type(os.path.basename(pr.filepath))[0]
1048 or "application/octet-stream"
1049 )
1050 size = os.path.getsize(full_path) if os.path.exists(full_path) else None
1052 doc = Document(
1053 aircraft_id=aircraft_id,
1054 filename=pr.filepath,
1055 original_filename=os.path.basename(pr.filepath),
1056 mime_type=mime,
1057 size_bytes=size,
1058 title=title,
1059 category=category,
1060 valid_until=valid_until,
1061 is_sensitive=False,
1062 )
1063 db.session.add(doc)
1064 pr.reconciled_at = datetime.now(timezone.utc)
1065 db.session.commit()
1067 flash(_("Document imported."), "success")
1068 return redirect(url_for("documents.list_reconcile"))
1071@documents_bp.route("/documents/reconcile/<int:pending_id>/ignore", methods=["POST"])
1072@login_required
1073@require_role(*_OWNER_ROLES)
1074def ignore_reconcile(pending_id: int) -> ResponseReturnValue:
1075 tenant = _get_tenant()
1076 pr = PendingReconcile.query.filter_by(
1077 id=pending_id, tenant_id=tenant.id
1078 ).first_or_404()
1079 pr.ignored = True
1080 db.session.commit()
1081 flash(_("File ignored."), "info")
1082 return redirect(url_for("documents.list_reconcile"))