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_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") 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 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, prefill=request.form) db.session.commit() flash("Session created.", "success") return redirect(url_for("sessions.detail", session_id=s.id)) return render_template("sessions/form.html", session=None, rifles=_user_rifles(), scopes=_user_scopes(), weather_conditions=WEATHER_CONDITIONS, today=date.today().isoformat()) @sessions_bp.route("/") 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() return render_template("sessions/detail.html", session=s, analyses=analyses, is_owner=is_owner) @sessions_bp.route("//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, prefill=request.form) for analysis in s.analyses: analysis.is_public = s.is_public db.session.commit() flash("Session updated.", "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) @sessions_bp.route("//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")) # --------------------------------------------------------------------------- # CSV upload # --------------------------------------------------------------------------- @sessions_bp.route("//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("//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("//photos//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("//photos//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("//photos//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/") 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)