Files
ShooterHub/storage.py
Gérald Colangelo a4dad2a9f2 wip: claude
2026-03-23 11:39:51 +01:00

163 lines
5.1 KiB
Python

"""
File storage helpers.
All paths are relative to Config.STORAGE_ROOT (a Docker volume at /app/storage).
Layout:
csvs/{user_id}/{analysis_id}_{filename}
pdfs/{user_id}/{analysis_id}_report.pdf
equipment_photos/{user_id}/{item_id}_{uuid}.jpg
session_photos/{user_id}/{session_id}_{uuid}.jpg
"""
import io
import uuid
from pathlib import Path
from flask import current_app
from PIL import Image
def _root() -> Path:
return Path(current_app.config["STORAGE_ROOT"])
def _ensure(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
# ---------------------------------------------------------------------------
# Analysis files
# ---------------------------------------------------------------------------
def _to_python(obj):
"""Recursively convert numpy scalars/arrays to plain Python types for JSON storage."""
if isinstance(obj, dict):
return {k: _to_python(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_to_python(v) for v in obj]
if hasattr(obj, "item"): # numpy scalar → int/float
return obj.item()
if hasattr(obj, "tolist"): # numpy array → list
return obj.tolist()
return obj
def save_analysis(*, user, csv_bytes: bytes, pdf_bytes: bytes, overall: dict,
group_stats: list, filename: str, session_id: int | None = None,
is_public: bool = False) -> int:
"""Persist a completed analysis for a logged-in user. Returns the new Analysis.id."""
from extensions import db
from models import Analysis
overall = _to_python(overall)
group_stats = _to_python(group_stats)
shot_count = int(overall.get("count", 0))
group_count = len(group_stats)
analysis = Analysis(
user_id=user.id,
session_id=session_id,
is_public=is_public,
title=_default_title(filename),
csv_path="", # filled in below
pdf_path="",
overall_stats=overall,
group_stats=group_stats,
shot_count=shot_count,
group_count=group_count,
)
db.session.add(analysis)
db.session.flush() # assigns analysis.id without committing
csv_dir = _ensure(_root() / "csvs" / str(user.id))
pdf_dir = _ensure(_root() / "pdfs" / str(user.id))
safe_name = Path(filename).name.replace(" ", "_")
csv_rel = f"csvs/{user.id}/{analysis.id}_{safe_name}"
pdf_rel = f"pdfs/{user.id}/{analysis.id}_report.pdf"
(csv_dir / f"{analysis.id}_{safe_name}").write_bytes(csv_bytes)
(pdf_dir / f"{analysis.id}_report.pdf").write_bytes(pdf_bytes)
analysis.csv_path = csv_rel
analysis.pdf_path = pdf_rel
db.session.commit()
return analysis.id
def _default_title(filename: str) -> str:
stem = Path(filename).stem.replace("_", " ").replace("-", " ")
return stem[:255] if stem else "Analysis"
# ---------------------------------------------------------------------------
# Equipment photos
# ---------------------------------------------------------------------------
MAX_PHOTO_DIM = 1200
PHOTO_QUALITY = 85
def _save_photo(file_storage, dest_dir: Path, prefix: str) -> str:
"""
Validate, resize, re-encode as JPEG, and save an uploaded photo.
Returns the path relative to STORAGE_ROOT.
Raises ValueError on invalid image data.
"""
try:
img = Image.open(file_storage)
img.verify()
file_storage.seek(0)
img = Image.open(file_storage)
img = img.convert("RGB")
except Exception as exc:
raise ValueError(f"Invalid image file: {exc}") from exc
img.thumbnail((MAX_PHOTO_DIM, MAX_PHOTO_DIM), Image.LANCZOS)
_ensure(dest_dir)
unique = uuid.uuid4().hex
filename = f"{prefix}_{unique}.jpg"
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=PHOTO_QUALITY, optimize=True)
(dest_dir / filename).write_bytes(buf.getvalue())
return str(dest_dir.relative_to(_root()) / filename)
def save_equipment_photo(user_id: int, item_id: int, file_storage) -> str:
dest = _root() / "equipment_photos" / str(user_id)
return _save_photo(file_storage, dest, str(item_id))
def save_session_photo(user_id: int, session_id: int, file_storage) -> str:
dest = _root() / "session_photos" / str(user_id)
return _save_photo(file_storage, dest, str(session_id))
def save_avatar(user_id: int, file_storage) -> str:
dest = _root() / "avatars" / str(user_id)
return _save_photo(file_storage, dest, "avatar")
def save_analysis_group_photo(user_id: int, analysis_id: int, group_index: int,
file_storage) -> str:
dest = _root() / "analysis_group_photos" / str(user_id)
return _save_photo(file_storage, dest, f"{analysis_id}_g{group_index}")
def rotate_photo(rel_path: str, degrees: int) -> None:
"""Rotate a stored JPEG in-place. degrees is clockwise (90, -90, 180)."""
path = _root() / rel_path
if not path.exists():
return
img = Image.open(path).convert("RGB")
# Pillow rotates counter-clockwise; negate to get clockwise behaviour
rotated = img.rotate(-degrees, expand=True)
buf = io.BytesIO()
rotated.save(buf, format="JPEG", quality=PHOTO_QUALITY, optimize=True)
path.write_bytes(buf.getvalue())