Files
ShooterHub/blueprints/sessions.py
2026-03-19 16:42:37 +01:00

483 lines
18 KiB
Python

import io
from datetime import date
from pathlib import Path
from flask import (
Blueprint, abort, current_app, flash, jsonify, redirect,
render_template, request, send_from_directory, url_for,
)
from flask_babel import _
from flask_login import current_user, login_required
from sqlalchemy import select
from extensions import db
from models import Analysis, EquipmentItem, SessionPhoto, ShootingSession
from storage import rotate_photo, save_session_photo
sessions_bp = Blueprint("sessions", __name__, url_prefix="/sessions")
# (slug, display name, short description)
SESSION_TYPES = [
("long_range", "Long Range Practice", "Long range precision shooting (100m+)"),
("prs", "PRS", "Precision Rifle Series — training & competition"),
("pistol_25m", "25m Pistol", "25m precision pistol shooting"),
]
LONG_RANGE_POSITIONS = [
("", "— select —"),
("prone", "Prone"),
("bench", "Bench rest"),
("standing", "Standing"),
("kneeling", "Kneeling"),
]
PISTOL_25M_POSITIONS = [
("", "— select —"),
("debout", "Standing"),
("debout_appui", "Standing with support"),
("assis", "Sitting"),
("assis_appui", "Sitting with support"),
]
PRS_STAGE_POSITIONS = [
("prone", "Prone"),
("standing", "Standing"),
("kneeling", "Kneeling"),
("sitting", "Sitting"),
("barricade", "Barricade"),
("rooftop", "Rooftop"),
("unknown", "Variable"),
]
# Kept as reference but not used in UI
_FIXED_DISTANCES = {"pistol_25m": 25}
WEATHER_CONDITIONS = [
("", "— select —"),
("sunny", "Sunny"),
("partly_cloudy", "Partly cloudy"),
("overcast", "Overcast"),
("rain", "Rain"),
("wind", "Wind"),
("snow", "Snow"),
("fog", "Fog"),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _own_session(session_id: int) -> ShootingSession:
s = db.session.get(ShootingSession, session_id)
if s is None:
abort(404)
if s.user_id != current_user.id:
abort(403)
return s
def _user_rifles():
return db.session.scalars(
select(EquipmentItem)
.where(EquipmentItem.user_id == current_user.id,
EquipmentItem.category.in_(["rifle", "handgun"]))
.order_by(EquipmentItem.name)
).all()
def _user_scopes():
return db.session.scalars(
select(EquipmentItem)
.where(EquipmentItem.user_id == current_user.id,
EquipmentItem.category == "scope")
.order_by(EquipmentItem.name)
).all()
def _apply_form(s: ShootingSession) -> str | None:
"""Write request.form fields onto session. Returns error string or None."""
date_str = request.form.get("session_date", "").strip()
if not date_str:
return "Date is required."
try:
s.session_date = date.fromisoformat(date_str)
except ValueError:
return "Invalid date."
s.is_public = bool(request.form.get("is_public"))
s.location_name = request.form.get("location_name", "").strip() or None
s.distance_m = _int_or_none(request.form.get("distance_m"))
s.weather_cond = request.form.get("weather_cond") or None
s.weather_temp_c = _float_or_none(request.form.get("weather_temp_c"))
s.weather_wind_kph = _float_or_none(request.form.get("weather_wind_kph"))
s.rifle_id = _int_or_none(request.form.get("rifle_id"))
s.scope_id = _int_or_none(request.form.get("scope_id"))
s.ammo_brand = request.form.get("ammo_brand", "").strip() or None
s.ammo_weight_gr = _float_or_none(request.form.get("ammo_weight_gr"))
s.ammo_lot = request.form.get("ammo_lot", "").strip() or None
s.notes = request.form.get("notes", "").strip() or None
s.session_type = request.form.get("session_type") or None
s.shooting_position = request.form.get("shooting_position") or None
return None
def _int_or_none(val):
try:
v = int(val)
return v if v > 0 else None
except (TypeError, ValueError):
return None
def _float_or_none(val):
try:
return float(val) if val and str(val).strip() else None
except (TypeError, ValueError):
return None
def _remove_file(rel_path: str) -> None:
try:
storage_root = current_app.config["STORAGE_ROOT"]
(Path(storage_root) / rel_path).unlink(missing_ok=True)
except Exception:
pass
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@sessions_bp.route("/")
@login_required
def index():
sessions = db.session.scalars(
select(ShootingSession)
.where(ShootingSession.user_id == current_user.id)
.order_by(ShootingSession.session_date.desc())
).all()
return render_template("sessions/list.html", sessions=sessions)
@sessions_bp.route("/new", methods=["GET", "POST"])
@login_required
def new():
if request.method == "POST":
s = ShootingSession(user_id=current_user.id)
db.session.add(s)
error = _apply_form(s)
if error:
db.session.expunge(s)
flash(error, "error")
return render_template("sessions/form.html", session=None,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS,
prefill=request.form)
db.session.commit()
flash(_("Session saved."), "success")
return redirect(url_for("sessions.detail", session_id=s.id))
selected_type = request.args.get("type")
if selected_type:
prefill_distance = _FIXED_DISTANCES.get(selected_type)
return render_template("sessions/form.html", session=None,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS,
selected_type=selected_type,
prefill_distance=prefill_distance,
today=date.today().isoformat())
# Step 1: show type picker
return render_template("sessions/type_picker.html", session_types=SESSION_TYPES)
@sessions_bp.route("/<int:session_id>")
def detail(session_id: int):
s = db.session.get(ShootingSession, session_id)
if s is None:
abort(404)
is_owner = current_user.is_authenticated and s.user_id == current_user.id
if not s.is_public and not is_owner:
abort(403)
analyses = db.session.scalars(
select(Analysis)
.where(Analysis.session_id == session_id)
.order_by(Analysis.created_at)
).all()
from analyzer.parser import parse_csv
from analyzer.grouper import detect_groups, OUTLIER_FACTOR
from analyzer.stats import compute_overall_stats, compute_group_stats
from analyzer.charts import render_group_charts, render_overview_chart
storage_root = current_app.config["STORAGE_ROOT"]
analyses_display = []
for a in analyses:
csv_path = Path(storage_root) / a.csv_path
if csv_path.exists():
try:
df = parse_csv(io.BytesIO(csv_path.read_bytes()))
factor = a.grouping_outlier_factor if a.grouping_outlier_factor is not None else OUTLIER_FACTOR
splits = a.grouping_manual_splits or None
groups = detect_groups(df, outlier_factor=factor, manual_splits=splits)
overall = compute_overall_stats(df)
group_stats = compute_group_stats(groups)
# Merge stored notes into freshly computed stats
stored = a.group_stats or []
for i, gs in enumerate(group_stats):
if i < len(stored) and stored[i].get("note"):
gs["note"] = stored[i]["note"]
charts = render_group_charts(groups,
y_min=overall["min_speed"],
y_max=overall["max_speed"])
overview_chart = render_overview_chart(group_stats)
groups_display = list(zip(group_stats, charts))
analyses_display.append((a, groups_display, overview_chart))
except Exception:
analyses_display.append((a, None, None))
else:
analyses_display.append((a, None, None))
return render_template("sessions/detail.html", session=s,
analyses=analyses, analyses_display=analyses_display,
is_owner=is_owner)
@sessions_bp.route("/<int:session_id>/edit", methods=["GET", "POST"])
@login_required
def edit(session_id: int):
s = _own_session(session_id)
if request.method == "POST":
error = _apply_form(s)
if error:
flash(error, "error")
return render_template("sessions/form.html", session=s,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS,
prefill=request.form)
for analysis in s.analyses:
analysis.is_public = s.is_public
db.session.commit()
flash(_("Session saved."), "success")
return redirect(url_for("sessions.detail", session_id=s.id))
return render_template("sessions/form.html", session=s,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS)
@sessions_bp.route("/<int:session_id>/delete", methods=["POST"])
@login_required
def delete(session_id: int):
s = _own_session(session_id)
for photo in s.photos:
_remove_file(photo.photo_path)
db.session.delete(s)
db.session.commit()
flash(_("Session deleted."), "success")
return redirect(url_for("sessions.index"))
# ---------------------------------------------------------------------------
# PRS stages
# ---------------------------------------------------------------------------
@sessions_bp.route("/<int:session_id>/stages", methods=["POST"])
@login_required
def save_stages(session_id: int):
import json
s = _own_session(session_id)
raw = request.form.get("stages_json", "[]")
try:
stages = json.loads(raw)
if not isinstance(stages, list):
stages = []
except (json.JSONDecodeError, ValueError):
stages = []
s.prs_stages = stages
db.session.commit()
flash(_("Stages saved."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))
@sessions_bp.route("/<int:session_id>/dope-card")
def dope_card(session_id: int):
s = db.session.get(ShootingSession, session_id)
if s is None:
abort(404)
is_owner = current_user.is_authenticated and s.user_id == current_user.id
if not s.is_public and not is_owner:
abort(403)
if s.session_type != "prs":
abort(400)
from analyzer.dope_card import generate_dope_card
from flask import make_response
stages = s.prs_stages or []
pdf_bytes = generate_dope_card(s, stages)
resp = make_response(pdf_bytes)
resp.headers["Content-Type"] = "application/pdf"
fname = f"dope_card_{s.session_date.isoformat()}.pdf"
resp.headers["Content-Disposition"] = f'inline; filename="{fname}"'
return resp
# ---------------------------------------------------------------------------
# CSV upload
# ---------------------------------------------------------------------------
@sessions_bp.route("/<int:session_id>/upload-csv", methods=["POST"])
@login_required
def upload_csv(session_id: int):
_own_session(session_id)
csv_file = request.files.get("csv_file")
if not csv_file or not csv_file.filename:
flash(_("No CSV file selected."), "error")
return redirect(url_for("sessions.detail", session_id=session_id))
from analyzer.parser import parse_csv
from analyzer.grouper import detect_groups
from analyzer.stats import compute_overall_stats, compute_group_stats
from analyzer.charts import render_group_charts, render_overview_chart
from analyzer.pdf_report import generate_pdf
from storage import save_analysis
try:
csv_bytes = csv_file.read()
df = parse_csv(io.BytesIO(csv_bytes))
groups = detect_groups(df)
overall = compute_overall_stats(df)
group_stats = compute_group_stats(groups)
charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"])
overview_chart = render_overview_chart(group_stats)
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("sessions.detail", session_id=session_id))
s = db.session.get(ShootingSession, session_id)
save_analysis(
user=current_user,
csv_bytes=csv_bytes,
pdf_bytes=pdf_bytes,
overall=overall,
group_stats=group_stats,
filename=csv_file.filename or "upload.csv",
session_id=session_id,
is_public=s.is_public if s else False,
)
flash(_("CSV analysed and linked to this session."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))
# ---------------------------------------------------------------------------
# Photo upload / delete / serve
# ---------------------------------------------------------------------------
@sessions_bp.route("/<int:session_id>/upload-photo", methods=["POST"])
@login_required
def upload_photo(session_id: int):
_own_session(session_id)
photo_file = request.files.get("photo")
if not photo_file or not photo_file.filename:
flash(_("No photo selected."), "error")
return redirect(url_for("sessions.detail", session_id=session_id))
try:
photo_path = save_session_photo(current_user.id, session_id, photo_file)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("sessions.detail", session_id=session_id))
caption = request.form.get("caption", "").strip() or None
photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption)
db.session.add(photo)
db.session.commit()
flash(_("Photo added."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))
@sessions_bp.route("/<int:session_id>/photos/<int:photo_id>/delete", methods=["POST"])
@login_required
def delete_photo(session_id: int, photo_id: int):
_own_session(session_id)
photo = db.session.get(SessionPhoto, photo_id)
if photo is None or photo.session_id != session_id:
abort(404)
_remove_file(photo.photo_path)
db.session.delete(photo)
db.session.commit()
flash(_("Photo deleted."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))
@sessions_bp.route("/<int:session_id>/photos/<int:photo_id>/rotate", methods=["POST"])
@login_required
def rotate_photo_view(session_id: int, photo_id: int):
_own_session(session_id)
photo = db.session.get(SessionPhoto, photo_id)
if photo is None or photo.session_id != session_id:
abort(404)
try:
degrees = int(request.form.get("degrees", 0))
except ValueError:
abort(400)
if degrees not in (-90, 90, 180):
abort(400)
rotate_photo(photo.photo_path, degrees)
return redirect(url_for("sessions.detail", session_id=session_id))
@sessions_bp.route("/<int:session_id>/photos/<int:photo_id>/annotate", methods=["GET", "POST"])
@login_required
def annotate_photo(session_id: int, photo_id: int):
_own_session(session_id)
photo = db.session.get(SessionPhoto, photo_id)
if photo is None or photo.session_id != session_id:
abort(404)
if request.method == "POST":
data = request.get_json(force=True)
photo.annotations = data
db.session.commit()
return jsonify({"ok": True})
s = db.session.get(ShootingSession, session_id)
return render_template("sessions/annotate_photo.html", session=s, photo=photo)
@sessions_bp.route("/photos/<path:filepath>")
def serve_photo(filepath: str):
"""Serve a session photo. Private session photos are owner-only."""
try:
user_id = int(filepath.split("/")[0])
except (ValueError, IndexError):
abort(404)
is_owner = current_user.is_authenticated and current_user.id == user_id
if not is_owner:
photo = db.session.scalars(
select(SessionPhoto).where(
SessionPhoto.photo_path == f"session_photos/{filepath}"
)
).first()
if photo is None or not photo.session.is_public:
abort(403)
storage_root = current_app.config["STORAGE_ROOT"]
return send_from_directory(Path(storage_root) / "session_photos", filepath)