Coverage for app/services/gps_archive.py: 100%
39 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
1"""Keep every uploaded GPS log file as-is, on disk.
3A GPS import only stores a simplified track in the database (at most
4_MAX_TRACK_POINTS points, a handful of fields — see aircraft/gps_import.py).
5The original files carry far more (one point per second, and for Garmin
6data logs also airspeed, attitude, engine and fuel figures, …), so a copy of
7each is kept under the uploads folder, where backups pick it up like any
8other uploaded file, for future features to reprocess. Nothing about the
9copy goes into the database.
11Layout (relative to UPLOAD_FOLDER, see services.upload_layout):
12 <tenant-slug>/<REG>/gps_logs/<file> — uploaded for a managed aircraft
13 _pilots/<user-id>/gps_logs/<file> — uploaded from a pilot's own logbook
14 (or for an aircraft not managed here)
15"""
17from __future__ import annotations
19import hashlib
20import os
22from werkzeug.utils import secure_filename # pyright: ignore[reportMissingImports]
24from services.upload_layout import ( # pyright: ignore[reportMissingImports]
25 GPS_ARCHIVE_DIRNAME,
26 PILOT_ARCHIVE_ROOT,
27)
30def pilot_archive_parts(user_id: int) -> tuple[str, ...]:
31 """Path components (under UPLOAD_FOLDER) of a pilot's GPS log archive."""
32 return (PILOT_ARCHIVE_ROOT, str(user_id), GPS_ARCHIVE_DIRNAME)
35def _sha256_file(path: str) -> str:
36 digest = hashlib.sha256()
37 with open(path, "rb") as fh:
38 for chunk in iter(lambda: fh.read(1024 * 1024), b""):
39 digest.update(chunk)
40 return digest.hexdigest()
43def _find_identical(dest_dir: str, data: bytes, digest: str) -> str | None:
44 """Path of a file already in *dest_dir* with exactly this content, if any.
46 Only files of the same size are hashed, so an upload usually costs no
47 reads at all.
48 """
49 size = len(data)
50 for entry in os.scandir(dest_dir):
51 if entry.name.startswith(".") or not entry.is_file():
52 continue
53 if entry.stat().st_size == size and _sha256_file(entry.path) == digest:
54 return entry.path
55 return None
58def archive_gps_file(data: bytes, original_filename: str, dest_dir: str) -> str:
59 """Store *data* in *dest_dir* under its (sanitised) original name and
60 return the stored file's full path.
62 A file whose content is already in *dest_dir* — under any name — stores
63 nothing new; the existing copy's path is returned. This only governs the
64 archive: the upload is parsed and offered for import either way (e.g.
65 after its flight was deleted). A different file arriving under a name
66 already taken — "flight.gpx" from two different days, say — gets a short
67 content-hash suffix rather than overwriting the earlier one.
68 """
69 os.makedirs(dest_dir, exist_ok=True)
70 digest = hashlib.sha256(data).hexdigest()
71 existing = _find_identical(dest_dir, data, digest)
72 if existing:
73 return existing
74 name = secure_filename(original_filename) or "gps_log"
75 path = os.path.join(dest_dir, name)
76 if os.path.exists(path):
77 stem, ext = os.path.splitext(name)
78 path = os.path.join(dest_dir, f"{stem}-{digest[:8]}{ext}")
79 if os.path.exists(path): # short-hash collision: use the full hash
80 path = os.path.join(dest_dir, f"{stem}-{digest}{ext}")
81 # Write under a dot-prefixed temporary name first (the document scan
82 # ignores those) so an interrupted write never leaves a truncated file
83 # under the real name.
84 tmp_path = os.path.join(dest_dir, f".{os.path.basename(path)}.part")
85 with open(tmp_path, "wb") as fh:
86 fh.write(data)
87 os.replace(tmp_path, path)
88 return path