Coverage for app/documents/routes.py: 17%
552 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-01 12:21 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-01 12:21 +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
200_RECONCILE_FILENAME_RE = _re.compile(r"^(\d{4}-\d{2}-\d{2}) - (.+?)(\.[^.]+)?$")
203def _parse_reconcile_filename(
204 cat_str: str, name_part: str
205) -> tuple[str | None, str | None, _date | None]:
206 """Parse a Syncthing-mounted path's category segment and its
207 "YYYY-MM-DD - title.ext" filename segment into (category, title_hint,
208 date_hint). Shared by scan_documents and rename_reconcile_folder's
209 inline rescan so a scan and a post-rename rescan can never disagree on
210 how to interpret the same on-disk layout."""
211 category = cat_str.lower() if cat_str.lower() in DocCategory.ALL else None
212 title_hint: str | None = None
213 date_hint: _date | None = None
214 m = _RECONCILE_FILENAME_RE.match(name_part)
215 if m:
216 with contextlib.suppress(
217 ValueError
218 ): # regex matched date-like string but it's invalid; treat as no date
219 date_hint = _date.fromisoformat(m.group(1))
220 title_hint = m.group(2)
221 else:
222 title_hint = os.path.splitext(name_part)[0]
223 return category, title_hint, date_hint
226def _save_upload_canonical(
227 file: FileStorage,
228 tenant: Tenant,
229 aircraft: Aircraft,
230 category: str,
231 title: str | None,
232) -> tuple[str, str, int]:
233 """Save *file* to the canonical Syncthing-compatible path structure.
235 Returns (relpath, mime_type, size_bytes) where relpath is relative to
236 UPLOAD_FOLDER and suitable for storage in Document.filename.
237 """
238 original = secure_filename(file.filename or "unnamed")
239 ext = os.path.splitext(original)[1].lower()
240 today = _date.today().isoformat()
241 safe_title = _safe_path_component(title or os.path.splitext(original)[0])[:100]
242 fname = f"{today} - {safe_title}{ext}"
244 slug = _ensure_tenant_slug(tenant)
245 safe_reg = aircraft.registration.replace("/", "-").replace(" ", "-").upper()
246 relpath = os.path.join(slug, safe_reg, category, fname)
248 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
249 full_dir = _safe_join(folder, slug, safe_reg, category)
250 os.makedirs(full_dir, exist_ok=True)
252 dest = _safe_join(folder, relpath)
253 # If a file with this name already exists (e.g. same title + date), add a short suffix
254 if os.path.exists(dest):
255 base, ext2 = os.path.splitext(fname)
256 relpath = os.path.join(
257 slug, safe_reg, category, f"{base}_{uuid.uuid4().hex[:6]}{ext2}"
258 )
259 dest = _safe_join(folder, relpath)
261 file.save(dest)
262 mime = mimetypes.guess_type(original)[0] or "application/octet-stream"
263 size = os.path.getsize(dest)
264 return relpath, mime, size
267def _current_role() -> Role | None:
268 tu = TenantUser.query.filter_by(user_id=session.get("user_id")).first()
269 return tu.role if tu else None
272def _doc_broken(doc: Document) -> bool:
273 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
274 return not os.path.exists(os.path.join(folder, doc.filename))
277# ── Title suggestions ─────────────────────────────────────────────────────────
280@documents_bp.route("/documents/title-suggestions")
281@login_required
282def title_suggestions() -> ResponseReturnValue:
283 q = request.args.get("q", "").strip()
284 owner_type = request.args.get("owner_type", "aircraft")
285 uid = int(session["user_id"])
287 if owner_type == "pilot":
288 base = Document.query.filter(
289 Document.pilot_user_id == uid,
290 Document.title.isnot(None),
291 )
292 else:
293 tid = _tenant_id()
294 aircraft_ids = [
295 row.id
296 for row in Aircraft.query.filter_by(tenant_id=tid)
297 .with_entities(Aircraft.id)
298 .all()
299 ]
300 base = Document.query.filter(
301 Document.aircraft_id.in_(aircraft_ids),
302 Document.title.isnot(None),
303 )
304 if owner_type == "component":
305 base = base.filter(Document.component_id.isnot(None))
306 else:
307 base = base.filter(
308 Document.component_id.is_(None),
309 Document.flight_entry_id.is_(None),
310 )
312 if q:
313 base = base.filter(Document.title.ilike(f"{q}%"))
315 rows = (
316 base.with_entities(Document.title)
317 .distinct()
318 .order_by(Document.title)
319 .limit(10)
320 .all()
321 )
322 return jsonify([r.title for r in rows])
325# ── Aircraft document list ────────────────────────────────────────────────────
328@documents_bp.route("/aircraft/<aircraft_ref:aircraft_id>/documents")
329@login_required
330def list_documents(aircraft_id: int) -> ResponseReturnValue:
331 ac = _get_aircraft_or_404(aircraft_id)
332 show_sensitive = request.args.get("sensitive") == "1"
333 query = Document.query.filter_by(aircraft_id=ac.id)
334 if not show_sensitive:
335 query = query.filter_by(is_sensitive=False)
336 docs = query.order_by(Document.uploaded_at.desc()).all()
337 sensitive_count = Document.query.filter_by(
338 aircraft_id=ac.id, is_sensitive=True
339 ).count()
340 role = _current_role()
341 is_owner = role in _OWNER_ROLES
342 broken_ids = {doc.id for doc in docs if _doc_broken(doc)}
343 return render_template(
344 "documents/list.html",
345 aircraft=ac,
346 docs=docs,
347 show_sensitive=show_sensitive,
348 sensitive_count=sensitive_count,
349 is_owner=is_owner,
350 broken_ids=broken_ids,
351 category_labels=_CATEGORY_LABELS,
352 )
355# ── Upload aircraft document ──────────────────────────────────────────────────
358@documents_bp.route(
359 "/aircraft/<aircraft_ref:aircraft_id>/documents/upload", methods=["GET", "POST"]
360)
361@login_required
362@require_role(*_OWNER_ROLES)
363def upload_document(aircraft_id: int) -> ResponseReturnValue:
364 ac = _get_aircraft_or_404(aircraft_id)
365 component = _resolve_component(ac)
367 if request.method == "POST":
368 file = request.files.get("file")
369 title = request.form.get("title", "").strip() or None
370 is_sensitive = bool(request.form.get("is_sensitive"))
371 doc_type = request.form.get("doc_type") or None
372 category = request.form.get("category") or None
373 if category and category not in DocCategory.ALL:
374 category = None
375 valid_until_str = request.form.get("valid_until", "").strip()
376 valid_until = None
377 if valid_until_str:
378 try:
379 valid_until = _date.fromisoformat(valid_until_str)
380 except ValueError as exc:
381 log.debug("Invalid valid_until date: %s", exc)
383 def _re_render(msg: str | None = None) -> str:
384 if msg:
385 flash(msg, "danger")
386 return render_template(
387 "documents/upload_form.html",
388 aircraft=ac,
389 component=component,
390 doc_types=_PILOT_DOC_TYPES,
391 categories=list(_CATEGORY_LABELS.items()),
392 )
394 if not file or not file.filename:
395 return _re_render(_("Please select a file to upload."))
397 original = secure_filename(file.filename)
398 ext = os.path.splitext(original)[1].lower()
399 if ext not in _ALLOWED_EXTS:
400 return _re_render(
401 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown")
402 )
404 # Use canonical path when a category is set and this is an aircraft doc
405 if category and not component:
406 tenant = _get_tenant()
407 stored, mime, size = _save_upload_canonical(
408 file, tenant, ac, category, title
409 )
410 else:
411 label = f"comp{component.id}" if component else f"ac{ac.id}"
412 stored, mime, size = _save_upload(file, label)
414 doc = Document(
415 aircraft_id=ac.id,
416 component_id=component.id if component else None,
417 filename=stored,
418 original_filename=original,
419 mime_type=mime,
420 size_bytes=size,
421 title=title,
422 doc_type=doc_type,
423 category=category,
424 valid_until=valid_until,
425 is_sensitive=is_sensitive,
426 )
427 db.session.add(doc)
429 # Insurance document with an expiry date: auto-update the aircraft's
430 # insurance_expiry (if the new date is later) and make this document
431 # the active certificate so the "View certificate" link appears.
432 if category == DocCategory.INSURANCE and valid_until and not component:
433 doc.doc_type = DocType.INSURANCE_CERT
434 if ac.insurance_expiry is None or valid_until > ac.insurance_expiry:
435 ac.insurance_expiry = valid_until
436 db.session.flush()
437 prev_cert = Document.query.filter(
438 Document.aircraft_id == ac.id,
439 Document.doc_type == DocType.INSURANCE_CERT,
440 Document.superseded_by_id.is_(None),
441 Document.id != doc.id,
442 ).first()
443 if prev_cert:
444 prev_cert.superseded_by_id = doc.id
446 db.session.commit()
447 activity(
448 "document.uploaded",
449 document_id=doc.id,
450 aircraft_id=ac.id,
451 title=doc.title or "",
452 )
454 flash(_("Document uploaded."), "success")
455 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
457 return render_template(
458 "documents/upload_form.html",
459 aircraft=ac,
460 component=component,
461 doc_types=_PILOT_DOC_TYPES,
462 categories=list(_CATEGORY_LABELS.items()),
463 )
466# ── Edit aircraft document ────────────────────────────────────────────────────
469@documents_bp.route(
470 "/aircraft/<aircraft_ref:aircraft_id>/documents/<int:document_id>/edit",
471 methods=["GET", "POST"],
472)
473@login_required
474@require_role(*_OWNER_ROLES)
475def edit_document(aircraft_id: int, document_id: int) -> ResponseReturnValue:
476 ac = _get_aircraft_or_404(aircraft_id)
477 doc = _get_aircraft_document_or_404(ac, document_id)
479 if request.method == "POST":
480 doc.title = request.form.get("title", "").strip() or None
481 doc.is_sensitive = bool(request.form.get("is_sensitive"))
482 category = request.form.get("category") or None
483 if category and category not in DocCategory.ALL:
484 category = None
486 old_category = doc.category
487 doc.category = category
489 # If the category changed and the file lives in the canonical path, move it
490 if category and old_category and category != old_category and doc.filename:
491 parts = doc.filename.replace("\\", "/").split("/")
492 if len(parts) >= 4 and parts[2] == old_category:
493 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
494 old_full = _safe_join(folder, doc.filename)
495 if os.path.exists(old_full):
496 new_relpath = "/".join(parts[:2] + [category] + parts[3:])
497 new_full = _safe_join(folder, new_relpath)
498 os.makedirs(os.path.dirname(new_full), exist_ok=True)
499 try:
500 os.rename(old_full, new_full)
501 doc.filename = new_relpath
502 except OSError as exc:
503 log.warning("Could not move document file: %s", exc)
505 valid_until_str = request.form.get("valid_until", "").strip()
506 doc.valid_until = None
507 if valid_until_str:
508 try:
509 doc.valid_until = _date.fromisoformat(valid_until_str)
510 except ValueError as exc:
511 log.debug("Invalid valid_until date: %s", exc)
512 db.session.commit()
513 flash(_("Document updated."), "success")
514 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
516 return render_template(
517 "documents/edit_form.html",
518 aircraft=ac,
519 doc=doc,
520 categories=list(_CATEGORY_LABELS.items()),
521 )
524# ── Delete aircraft document ──────────────────────────────────────────────────
527@documents_bp.route(
528 "/aircraft/<aircraft_ref:aircraft_id>/documents/<int:document_id>/delete",
529 methods=["POST"],
530)
531@login_required
532@require_role(*_OWNER_ROLES)
533def delete_document(aircraft_id: int, document_id: int) -> ResponseReturnValue:
534 ac = _get_aircraft_or_404(aircraft_id)
535 doc = _get_aircraft_document_or_404(ac, document_id)
536 activity("document.deleted", document_id=document_id, aircraft_id=aircraft_id)
537 _delete_file(doc.filename)
538 db.session.delete(doc)
539 db.session.commit()
540 flash(_("Document deleted."), "success")
541 return redirect(url_for("documents.list_documents", aircraft_id=ac.id))
544# ── Download all aircraft documents as ZIP ────────────────────────────────────
547@documents_bp.route("/aircraft/<aircraft_ref:aircraft_id>/documents/download-all")
548@login_required
549def download_all_documents(aircraft_id: int) -> ResponseReturnValue:
550 ac = _get_aircraft_or_404(aircraft_id)
551 role = _current_role()
552 include_sensitive = role in _OWNER_ROLES
554 query = Document.query.filter_by(aircraft_id=ac.id)
555 if not include_sensitive:
556 query = query.filter_by(is_sensitive=False)
557 docs = query.order_by(Document.uploaded_at.asc()).all()
559 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
560 buf = io.BytesIO()
561 manifest_lines = ["filename\ttitle\tcategory\ttype\tuploaded\n"]
563 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
564 for doc in docs:
565 path = os.path.join(folder, doc.filename)
566 arcname = doc.original_filename
567 if os.path.exists(path):
568 zf.write(path, arcname=arcname)
569 doc_type_label = doc.doc_type or ""
570 category_label = _CATEGORY_LABELS.get(
571 doc.category or "", doc.category or ""
572 )
573 uploaded = doc.uploaded_at.strftime("%Y-%m-%d") if doc.uploaded_at else ""
574 manifest_lines.append(
575 f"{arcname}\t{doc.title or ''}\t{category_label}\t{doc_type_label}\t{uploaded}\n"
576 )
577 zf.writestr("manifest.txt", "".join(manifest_lines))
579 buf.seek(0)
580 reg = ac.registration.replace("/", "-")
581 zip_name = f"aircraft-{reg}-documents.zip"
582 return Response(
583 buf.read(),
584 mimetype="application/zip",
585 headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
586 )
589# ── Insurance certificate upload ──────────────────────────────────────────────
592@documents_bp.route(
593 "/aircraft/<aircraft_ref:aircraft_id>/insurance-cert/upload", methods=["POST"]
594)
595@login_required
596@require_role(*_OWNER_ROLES)
597def upload_insurance_cert(aircraft_id: int) -> ResponseReturnValue:
598 ac = _get_aircraft_or_404(aircraft_id)
599 file = request.files.get("file")
601 if not file or not file.filename:
602 flash(_("Please select a file to upload."), "danger")
603 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
605 original = secure_filename(file.filename)
606 ext = os.path.splitext(original)[1].lower()
607 if ext not in _ALLOWED_EXTS:
608 flash(_("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger")
609 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
611 # Insurance certificates go into the canonical insurance folder
612 tenant = _get_tenant()
613 stored, mime, size = _save_upload_canonical(
614 file, tenant, ac, DocCategory.INSURANCE, _("Insurance Certificate")
615 )
617 # Mark previous insurance certificate as superseded
618 prev = (
619 Document.query.filter_by(
620 aircraft_id=ac.id,
621 doc_type=DocType.INSURANCE_CERT,
622 )
623 .filter(Document.superseded_by_id.is_(None))
624 .first()
625 )
627 new_cert = Document(
628 aircraft_id=ac.id,
629 filename=stored,
630 original_filename=original,
631 mime_type=mime,
632 size_bytes=size,
633 title=_("Insurance Certificate"),
634 doc_type=DocType.INSURANCE_CERT,
635 category=DocCategory.INSURANCE,
636 valid_until=ac.insurance_expiry,
637 is_sensitive=True,
638 )
639 db.session.add(new_cert)
640 db.session.flush()
642 if prev:
643 prev.superseded_by_id = new_cert.id
645 db.session.commit()
646 flash(_("Insurance certificate uploaded."), "success")
647 return redirect(url_for("aircraft.detail", aircraft_id=ac.id))
650# ── Pilot document upload ─────────────────────────────────────────────────────
653@documents_bp.route("/pilot/documents/upload", methods=["GET", "POST"])
654@login_required
655def upload_pilot_document() -> ResponseReturnValue:
656 uid = int(session["user_id"])
658 if request.method == "POST":
659 file = request.files.get("file")
660 title = request.form.get("title", "").strip() or None
661 doc_type = request.form.get("doc_type") or None
662 valid_until_str = request.form.get("valid_until", "").strip()
663 valid_until = None
664 if valid_until_str:
665 try:
666 valid_until = _date.fromisoformat(valid_until_str)
667 except ValueError as exc:
668 log.debug("Invalid valid_until date: %s", exc)
670 if not file or not file.filename:
671 flash(_("Please select a file to upload."), "danger")
672 return render_template(
673 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
674 )
676 original = secure_filename(file.filename)
677 ext = os.path.splitext(original)[1].lower()
678 if ext not in _ALLOWED_EXTS:
679 flash(
680 _("File type '%(ext)s' is not allowed.", ext=ext or "unknown"), "danger"
681 )
682 return render_template(
683 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
684 )
686 stored, mime, size = _save_upload(file, f"pilot{uid}")
688 doc = Document(
689 pilot_user_id=uid,
690 filename=stored,
691 original_filename=original,
692 mime_type=mime,
693 size_bytes=size,
694 title=title,
695 doc_type=doc_type,
696 valid_until=valid_until,
697 is_sensitive=True,
698 )
699 db.session.add(doc)
700 db.session.commit()
702 flash(_("Document uploaded."), "success")
703 return redirect(url_for("pilots.profile"))
705 return render_template(
706 "documents/pilot_upload_form.html", doc_types=_PILOT_DOC_TYPES
707 )
710# ── Pilot document delete ─────────────────────────────────────────────────────
713@documents_bp.route("/pilot/documents/<int:document_id>/delete", methods=["POST"])
714@login_required
715def delete_pilot_document(document_id: int) -> ResponseReturnValue:
716 uid = int(session["user_id"])
717 doc = db.session.get(Document, document_id)
718 if not doc or doc.pilot_user_id != uid:
719 abort(404)
720 _delete_file(doc.filename)
721 db.session.delete(doc)
722 db.session.commit()
723 flash(_("Document deleted."), "success")
724 return redirect(url_for("pilots.profile"))
727# ── Reconcile: scan + list + import + ignore ──────────────────────────────────
730@documents_bp.route("/documents/reconcile")
731@login_required
732@require_role(*_OWNER_ROLES)
733def list_reconcile() -> ResponseReturnValue:
734 import difflib
736 tenant = _get_tenant()
737 pending = (
738 PendingReconcile.query.filter_by(
739 tenant_id=tenant.id, reconciled_at=None, ignored=False
740 )
741 .order_by(PendingReconcile.detected_at.desc())
742 .all()
743 )
744 aircraft_list = (
745 Aircraft.query.filter_by(tenant_id=tenant.id)
746 .order_by(Aircraft.registration)
747 .all()
748 )
750 # For entries with an unrecognised category folder, suggest the closest match
751 category_suggestions: dict[
752 int, tuple[str, str]
753 ] = {} # pr.id → (raw_folder, suggestion)
754 for pr in pending:
755 parts = pr.filepath.replace("\\", "/").split("/")
756 if len(parts) >= 4:
757 raw = parts[2]
758 if raw.lower() not in DocCategory.ALL:
759 close = difflib.get_close_matches(
760 raw.lower(), DocCategory.ALL, n=1, cutoff=0.6
761 )
762 if close:
763 # folder path up to and including the bad category dir
764 bad_folder = "/".join(parts[:3])
765 category_suggestions[pr.id] = (bad_folder, close[0])
767 return render_template(
768 "documents/reconcile.html",
769 tenant=tenant,
770 pending=pending,
771 aircraft_list=aircraft_list,
772 categories=list(_CATEGORY_LABELS.items()),
773 category_suggestions=category_suggestions,
774 )
777@documents_bp.route("/documents/reconcile/scan", methods=["POST"])
778@login_required
779@require_role(*_OWNER_ROLES)
780def scan_documents() -> ResponseReturnValue:
781 tenant = _get_tenant()
782 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
784 if not tenant.slug:
785 flash(_("Set a Hangar ID in Settings before scanning for files."), "warning")
786 return redirect(url_for("documents.list_reconcile"))
788 slug_dir = os.path.join(folder, tenant.slug)
789 if not os.path.isdir(slug_dir):
790 flash(
791 _(
792 "No files found in '%(slug)s/'. Mount your Syncthing folder and try again.",
793 slug=tenant.slug,
794 ),
795 "info",
796 )
797 return redirect(url_for("documents.list_reconcile"))
799 # Build set of filenames already tracked in the documents table
800 tid = tenant.id
801 known: set[str] = {
802 doc.filename
803 for doc in Document.query.filter(
804 Document.aircraft_id.in_(
805 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id)
806 )
807 ).all()
808 }
809 # Prune stale pending entries whose file no longer exists on disk
810 stale_removed = 0
811 for pr in PendingReconcile.query.filter_by(
812 tenant_id=tid, reconciled_at=None, ignored=False
813 ).all():
814 if not os.path.exists(os.path.join(folder, pr.filepath)):
815 db.session.delete(pr)
816 stale_removed += 1
817 if stale_removed:
818 db.session.flush()
820 existing_pending: set[str] = {
821 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all()
822 }
824 aircraft_by_reg: dict[str, Aircraft] = {
825 ac.registration.upper().replace("-", "").replace(" ", ""): ac
826 for ac in Aircraft.query.filter_by(tenant_id=tid).all()
827 }
829 new_count = 0
830 for dirpath, _dirs, filenames in os.walk(slug_dir):
831 for fname in filenames:
832 if fname.startswith(".") or fname.startswith("_"):
833 continue
834 full = os.path.join(dirpath, fname)
835 relpath = os.path.relpath(full, folder)
836 # Normalise to forward slashes for DB consistency
837 relpath = relpath.replace("\\", "/")
839 if relpath in known or relpath in existing_pending:
840 continue
842 # Parse canonical path: slug/reg/category/YYYY-MM-DD - title.ext
843 parts = relpath.split("/")
844 aircraft_obj: Aircraft | None = None
845 category: str | None = None
846 title_hint: str | None = None
847 date_hint: _date | None = None
849 if len(parts) >= 4:
850 reg_raw = parts[1].upper().replace("-", "").replace(" ", "")
851 aircraft_obj = aircraft_by_reg.get(reg_raw)
852 category, title_hint, date_hint = _parse_reconcile_filename(
853 parts[2], parts[3]
854 )
856 pr = PendingReconcile(
857 tenant_id=tid,
858 aircraft_id=aircraft_obj.id if aircraft_obj else None,
859 filepath=relpath,
860 category=category,
861 title_hint=title_hint,
862 date_hint=date_hint,
863 )
864 db.session.add(pr)
865 new_count += 1
867 db.session.commit()
868 parts_msg = []
869 if new_count:
870 parts_msg.append(
871 ngettext(
872 "one new file queued for review",
873 "%(n)s new files queued for review",
874 new_count,
875 n=new_count,
876 )
877 )
878 if stale_removed:
879 parts_msg.append(
880 ngettext(
881 "one missing file removed from queue",
882 "%(n)s missing files removed from queue",
883 stale_removed,
884 n=stale_removed,
885 )
886 )
887 if parts_msg:
888 flash(
889 _("Scan complete — %(details)s.", details=", ".join(parts_msg)), "success"
890 )
891 else:
892 flash(_("Scan complete — no new files found."), "info")
893 return redirect(url_for("documents.list_reconcile"))
896@documents_bp.route("/documents/reconcile/rename-folder", methods=["POST"])
897@login_required
898@require_role(*_OWNER_ROLES)
899def rename_reconcile_folder() -> ResponseReturnValue:
900 """Rename a misnamed category folder on disk (e.g. 'Maintenance' → 'maintenance',
901 or a typo like 'maintenence' → 'maintenance'), prune stale pending entries for
902 the old path, then run a fresh scan so the corrected files are picked up immediately.
903 """
904 import shutil
906 tenant = _get_tenant()
907 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
909 bad_folder = request.form.get("bad_folder", "").strip().replace("\\", "/")
910 new_category = request.form.get("new_category", "").strip()
912 if new_category not in DocCategory.ALL:
913 flash(_("Invalid category."), "danger")
914 return redirect(url_for("documents.list_reconcile"))
916 if not tenant.slug or not bad_folder.startswith(tenant.slug + "/"):
917 abort(403)
919 old_dir = _safe_join(folder, bad_folder)
920 parent_rel = "/".join(bad_folder.split("/")[:2]) # slug/reg
921 new_rel = parent_rel + "/" + new_category
922 new_dir = _safe_join(folder, new_rel)
924 if os.path.isdir(old_dir):
925 if os.path.isdir(new_dir):
926 for dirpath, _dirs, filenames in os.walk(old_dir):
927 rel = os.path.relpath(dirpath, old_dir)
928 dest_d = os.path.join(new_dir, rel)
929 os.makedirs(dest_d, exist_ok=True)
930 for fname in filenames:
931 shutil.move(
932 os.path.join(dirpath, fname), os.path.join(dest_d, fname)
933 )
934 shutil.rmtree(old_dir, ignore_errors=True)
935 else:
936 os.rename(old_dir, new_dir)
938 # Prune stale pending entries for the old folder path
939 tid = tenant.id
940 for pr in PendingReconcile.query.filter(
941 PendingReconcile.tenant_id == tid,
942 PendingReconcile.filepath.like(bad_folder + "/%"),
943 ).all():
944 db.session.delete(pr)
945 db.session.flush()
947 # Inline scan: pick up the files now in the correct folder
948 known: set[str] = {
949 doc.filename
950 for doc in Document.query.filter(
951 Document.aircraft_id.in_(
952 Aircraft.query.filter_by(tenant_id=tid).with_entities(Aircraft.id)
953 )
954 ).all()
955 }
956 existing_pending: set[str] = {
957 pr.filepath for pr in PendingReconcile.query.filter_by(tenant_id=tid).all()
958 }
959 aircraft_by_reg: dict[str, Aircraft] = {
960 ac.registration.upper().replace("-", "").replace(" ", ""): ac
961 for ac in Aircraft.query.filter_by(tenant_id=tid).all()
962 }
963 new_count = 0
964 if os.path.isdir(new_dir):
965 for dirpath, _dirs, filenames in os.walk(new_dir):
966 for fname in filenames:
967 if fname.startswith(".") or fname.startswith("_"):
968 continue
969 relpath = os.path.relpath(os.path.join(dirpath, fname), folder).replace(
970 "\\", "/"
971 )
972 full = _safe_join(folder, relpath)
973 if relpath in known or relpath in existing_pending:
974 continue
975 parts = relpath.split("/")
976 aircraft_obj: Aircraft | None = None
977 category: str | None = None
978 title_hint: str | None = None
979 date_hint: _date | None = None
980 if len(parts) >= 4:
981 reg_raw = parts[1].upper().replace("-", "").replace(" ", "")
982 aircraft_obj = aircraft_by_reg.get(reg_raw)
983 category, title_hint, date_hint = _parse_reconcile_filename(
984 parts[2], parts[3]
985 )
986 if aircraft_obj and category:
987 mime = mimetypes.guess_type(fname)[0] or "application/octet-stream"
988 size = os.path.getsize(full) if os.path.exists(full) else None
989 doc = Document(
990 aircraft_id=aircraft_obj.id,
991 filename=relpath,
992 original_filename=fname,
993 mime_type=mime,
994 size_bytes=size,
995 title=title_hint,
996 category=category,
997 )
998 db.session.add(doc)
999 else:
1000 pr = PendingReconcile(
1001 tenant_id=tid,
1002 aircraft_id=aircraft_obj.id if aircraft_obj else None,
1003 filepath=relpath,
1004 category=category,
1005 title_hint=title_hint,
1006 date_hint=date_hint,
1007 )
1008 db.session.add(pr)
1009 new_count += 1
1011 db.session.commit()
1012 flash(
1013 ngettext(
1014 "Folder renamed to '%(cat)s' — one file processed.",
1015 "Folder renamed to '%(cat)s' — %(n)s files processed.",
1016 new_count,
1017 cat=new_category,
1018 n=new_count,
1019 ),
1020 "success",
1021 )
1022 return redirect(url_for("documents.list_reconcile"))
1025@documents_bp.route("/documents/reconcile/<int:pending_id>/import", methods=["POST"])
1026@login_required
1027@require_role(*_OWNER_ROLES)
1028def import_reconcile(pending_id: int) -> ResponseReturnValue:
1029 tenant = _get_tenant()
1030 pr = PendingReconcile.query.filter_by(
1031 id=pending_id, tenant_id=tenant.id
1032 ).first_or_404()
1034 aircraft_id_raw = request.form.get("aircraft_id")
1035 try:
1036 aircraft_id: int | None = int(aircraft_id_raw) if aircraft_id_raw else None
1037 except (ValueError, TypeError):
1038 aircraft_id = None
1039 if aircraft_id is not None:
1040 _get_aircraft_or_404(aircraft_id)
1042 title = request.form.get("title", "").strip() or pr.title_hint
1043 category = request.form.get("category") or pr.category
1044 if category and category not in DocCategory.ALL:
1045 category = None
1046 valid_until_str = request.form.get("valid_until", "").strip()
1047 valid_until: _date | None = None
1048 if valid_until_str:
1049 with contextlib.suppress(
1050 ValueError
1051 ): # malformed date submitted; valid_until stays None
1052 valid_until = _date.fromisoformat(valid_until_str)
1054 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1055 full_path = os.path.join(folder, pr.filepath)
1056 mime = (
1057 mimetypes.guess_type(os.path.basename(pr.filepath))[0]
1058 or "application/octet-stream"
1059 )
1060 size = os.path.getsize(full_path) if os.path.exists(full_path) else None
1062 doc = Document(
1063 aircraft_id=aircraft_id,
1064 filename=pr.filepath,
1065 original_filename=os.path.basename(pr.filepath),
1066 mime_type=mime,
1067 size_bytes=size,
1068 title=title,
1069 category=category,
1070 valid_until=valid_until,
1071 is_sensitive=False,
1072 )
1073 db.session.add(doc)
1074 pr.reconciled_at = datetime.now(timezone.utc)
1075 db.session.commit()
1077 flash(_("Document imported."), "success")
1078 return redirect(url_for("documents.list_reconcile"))
1081@documents_bp.route("/documents/reconcile/<int:pending_id>/ignore", methods=["POST"])
1082@login_required
1083@require_role(*_OWNER_ROLES)
1084def ignore_reconcile(pending_id: int) -> ResponseReturnValue:
1085 tenant = _get_tenant()
1086 pr = PendingReconcile.query.filter_by(
1087 id=pending_id, tenant_id=tenant.id
1088 ).first_or_404()
1089 pr.ignored = True
1090 db.session.commit()
1091 flash(_("File ignored."), "info")
1092 return redirect(url_for("documents.list_reconcile"))