Vibe coded a bit more ... now we have session, attached picture and analysis, MOA group computation
This commit is contained in:
343
blueprints/sessions.py
Normal file
343
blueprints/sessions.py
Normal file
@@ -0,0 +1,343 @@
|
||||
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("/<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()
|
||||
return render_template("sessions/detail.html", session=s,
|
||||
analyses=analyses, 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,
|
||||
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("/<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"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
Reference in New Issue
Block a user