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

222 lines
7.1 KiB
Python

import io
import json
from pathlib import Path
from flask import (
Blueprint, abort, current_app, flash, redirect, request,
render_template, send_from_directory, url_for,
)
from flask_babel import _
from flask_login import current_user, login_required
from extensions import db
from models import Analysis
analyses_bp = Blueprint("analyses", __name__, url_prefix="/analyses")
def _can_view(analysis: Analysis) -> bool:
if analysis.is_public:
return True
return current_user.is_authenticated and analysis.user_id == current_user.id
@analyses_bp.route("/<int:analysis_id>")
def detail(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if not _can_view(a):
abort(403)
# Re-generate charts from the stored CSV
storage_root = current_app.config["STORAGE_ROOT"]
csv_path = Path(storage_root) / a.csv_path
if not csv_path.exists():
abort(410) # CSV was deleted
from analyzer.parser import parse_csv
from analyzer.grouper import detect_groups
from analyzer.stats import compute_group_stats
from analyzer.charts import render_group_charts, render_overview_chart
csv_bytes = csv_path.read_bytes()
df = parse_csv(io.BytesIO(csv_bytes))
groups = detect_groups(df)
group_stats = compute_group_stats(groups)
charts = render_group_charts(
groups,
y_min=a.overall_stats["min_speed"],
y_max=a.overall_stats["max_speed"],
)
overview_chart = render_overview_chart(group_stats)
groups_display = list(zip(group_stats, charts))
return render_template(
"analyses/detail.html",
analysis=a,
overall=a.overall_stats,
groups_display=groups_display,
overview_chart=overview_chart,
has_pdf=bool(a.pdf_path and (Path(storage_root) / a.pdf_path).exists()),
)
@analyses_bp.route("/<int:analysis_id>/delete", methods=["POST"])
@login_required
def delete(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
back = url_for("sessions.detail", session_id=a.session_id) if a.session_id \
else url_for("dashboard.index")
storage_root = current_app.config["STORAGE_ROOT"]
for path_attr in ("csv_path", "pdf_path"):
rel = getattr(a, path_attr, None)
if rel:
try:
(Path(storage_root) / rel).unlink(missing_ok=True)
except Exception:
pass
db.session.delete(a)
db.session.commit()
flash(_("Analysis deleted."), "success")
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/rename", methods=["POST"])
@login_required
def rename(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
title = request.form.get("title", "").strip()
if title:
a.title = title[:255]
db.session.commit()
flash(_("Title updated."), "success")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/regroup", methods=["POST"])
@login_required
def regroup(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
try:
outlier_factor = float(request.form.get("outlier_factor", 5))
outlier_factor = max(1.0, min(20.0, outlier_factor))
except (TypeError, ValueError):
outlier_factor = 5.0
manual_splits_raw = request.form.get("manual_splits", "").strip()
manual_splits = None
if manual_splits_raw:
try:
parsed = json.loads(manual_splits_raw)
if isinstance(parsed, list):
manual_splits = [int(x) for x in parsed]
except (json.JSONDecodeError, ValueError, TypeError):
pass
storage_root = current_app.config["STORAGE_ROOT"]
csv_path = Path(storage_root) / a.csv_path
if not csv_path.exists():
abort(410)
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 _to_python
csv_bytes = csv_path.read_bytes()
df = parse_csv(io.BytesIO(csv_bytes))
groups = detect_groups(df, outlier_factor=outlier_factor, manual_splits=manual_splits)
overall = compute_overall_stats(df)
group_stats = compute_group_stats(groups)
# Preserve existing notes
old_stats = a.group_stats or []
for i, gs in enumerate(group_stats):
if i < len(old_stats) and old_stats[i].get("note"):
gs["note"] = old_stats[i]["note"]
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)
if a.pdf_path:
pdf_path = Path(storage_root) / a.pdf_path
try:
pdf_path.write_bytes(pdf_bytes)
except Exception:
pass
a.grouping_outlier_factor = outlier_factor
a.grouping_manual_splits = manual_splits
a.group_stats = _to_python(group_stats)
a.overall_stats = _to_python(overall)
a.shot_count = int(overall.get("count", 0))
a.group_count = len(group_stats)
db.session.commit()
flash(_("Regrouped."), "success")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/groups/<int:group_index>/note", methods=["POST"])
@login_required
def save_group_note(analysis_id: int, group_index: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
note = request.form.get("note", "").strip()
group_stats = list(a.group_stats or [])
if 0 <= group_index < len(group_stats):
group_stats[group_index] = dict(group_stats[group_index])
group_stats[group_index]["note"] = note or None
a.group_stats = group_stats
db.session.commit()
flash(_("Note saved."), "success")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/pdf")
def download_pdf(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if not _can_view(a):
abort(403)
if not a.pdf_path:
abort(404)
storage_root = current_app.config["STORAGE_ROOT"]
pdf_dir = Path(storage_root) / Path(a.pdf_path).parent
filename = Path(a.pdf_path).name
return send_from_directory(pdf_dir, filename, as_attachment=True,
download_name=f"{a.title}.pdf")