WIP: claude works hard
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (
|
||||
Blueprint, abort, current_app, flash, redirect,
|
||||
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
|
||||
@@ -83,7 +85,122 @@ def delete(analysis_id: int):
|
||||
|
||||
db.session.delete(a)
|
||||
db.session.commit()
|
||||
flash("Analysis deleted.", "success")
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from flask import (
|
||||
send_from_directory,
|
||||
url_for,
|
||||
)
|
||||
from flask_babel import _
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
@@ -96,11 +97,11 @@ def login():
|
||||
)
|
||||
|
||||
if user is None or not user.check_password(password):
|
||||
flash("Invalid email or password.", "error")
|
||||
flash(_("Invalid email or password."), "error")
|
||||
return render_template("auth/login.html", prefill_email=email)
|
||||
|
||||
if current_app.config["EMAIL_CONFIRMATION_REQUIRED"] and not user.email_confirmed:
|
||||
flash("Please confirm your email address before logging in.", "error")
|
||||
flash(_("Please confirm your email address before logging in."), "error")
|
||||
return render_template("auth/login.html", prefill_email=email,
|
||||
show_resend=True, resend_email=email)
|
||||
|
||||
@@ -125,20 +126,22 @@ def register():
|
||||
# Validate
|
||||
error = None
|
||||
if not email or "@" not in email or "." not in email.split("@")[-1]:
|
||||
error = "Please enter a valid email address."
|
||||
error = _("Please enter a valid email address.")
|
||||
elif len(password) < 8:
|
||||
error = "Password must be at least 8 characters."
|
||||
error = _("Password must be at least 8 characters.")
|
||||
elif password != confirm:
|
||||
error = "Passwords do not match."
|
||||
error = _("Passwords do not match.")
|
||||
else:
|
||||
existing = db.session.scalar(db.select(User).filter_by(email=email))
|
||||
if existing:
|
||||
if existing.provider == "local":
|
||||
error = "An account with this email already exists."
|
||||
error = _("An account with this email already exists.")
|
||||
else:
|
||||
error = (
|
||||
f"This email is linked to a {existing.provider.title()} account. "
|
||||
f"Please log in with {existing.provider.title()}."
|
||||
error = _(
|
||||
"This email is linked to a %(provider)s account. "
|
||||
"Please log in with %(provider2)s.",
|
||||
provider=existing.provider.title(),
|
||||
provider2=existing.provider.title(),
|
||||
)
|
||||
|
||||
if error:
|
||||
@@ -165,7 +168,7 @@ def register():
|
||||
return render_template("auth/confirm_pending.html", email=email)
|
||||
|
||||
login_user(user)
|
||||
flash("Account created! Welcome.", "success")
|
||||
flash(_("Account created! Welcome."), "success")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
return render_template("auth/register.html")
|
||||
@@ -177,14 +180,14 @@ def confirm_email(token: str):
|
||||
db.select(User).filter_by(email_confirm_token=token)
|
||||
)
|
||||
if user is None:
|
||||
flash("Invalid or expired confirmation link.", "error")
|
||||
flash(_("Invalid or expired confirmation link."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user.email_confirmed = True
|
||||
user.email_confirm_token = None
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
flash("Email confirmed! Welcome.", "success")
|
||||
flash(_("Email confirmed! Welcome."), "success")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
|
||||
@@ -200,7 +203,7 @@ def resend_confirmation():
|
||||
db.session.commit()
|
||||
_dispatch_confirmation(user)
|
||||
# Vague message to prevent email enumeration
|
||||
flash("If that account exists and is unconfirmed, a new link has been sent.", "message")
|
||||
flash(_("If that account exists and is unconfirmed, a new link has been sent."), "message")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@@ -220,13 +223,13 @@ def callback_google():
|
||||
try:
|
||||
token = oauth.google.authorize_access_token()
|
||||
except Exception:
|
||||
flash("Google login failed. Please try again.", "error")
|
||||
flash(_("Google login failed. Please try again."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
info = token.get("userinfo") or {}
|
||||
email = info.get("email")
|
||||
if not email:
|
||||
flash("Could not retrieve your email from Google.", "error")
|
||||
flash(_("Could not retrieve your email from Google."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user = _upsert_oauth_user(
|
||||
@@ -237,7 +240,7 @@ def callback_google():
|
||||
avatar_url=info.get("picture"),
|
||||
)
|
||||
if user is None:
|
||||
flash("This email is already registered with a different login method.", "error")
|
||||
flash(_("This email is already registered with a different login method."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
login_user(user)
|
||||
@@ -260,7 +263,7 @@ def callback_github():
|
||||
try:
|
||||
token = oauth.github.authorize_access_token()
|
||||
except Exception:
|
||||
flash("GitHub login failed. Please try again.", "error")
|
||||
flash(_("GitHub login failed. Please try again."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
resp = oauth.github.get("user", token=token)
|
||||
@@ -276,7 +279,7 @@ def callback_github():
|
||||
)
|
||||
|
||||
if not email:
|
||||
flash("Could not retrieve a verified email from GitHub.", "error")
|
||||
flash(_("Could not retrieve a verified email from GitHub."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user = _upsert_oauth_user(
|
||||
@@ -287,7 +290,7 @@ def callback_github():
|
||||
avatar_url=info.get("avatar_url"),
|
||||
)
|
||||
if user is None:
|
||||
flash("This email is already registered with a different login method.", "error")
|
||||
flash(_("This email is already registered with a different login method."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
login_user(user)
|
||||
@@ -307,7 +310,7 @@ def profile():
|
||||
if action == "update_profile":
|
||||
display_name = request.form.get("display_name", "").strip()
|
||||
if not display_name:
|
||||
flash("Display name cannot be empty.", "error")
|
||||
flash(_("Display name cannot be empty."), "error")
|
||||
else:
|
||||
current_user.display_name = display_name
|
||||
current_user.bio = request.form.get("bio", "").strip() or None
|
||||
@@ -325,25 +328,25 @@ def profile():
|
||||
db.session.rollback()
|
||||
return render_template("auth/profile.html")
|
||||
db.session.commit()
|
||||
flash("Profile updated.", "success")
|
||||
flash(_("Profile updated."), "success")
|
||||
|
||||
elif action == "change_password":
|
||||
if current_user.provider != "local":
|
||||
flash("Password change is only available for local accounts.", "error")
|
||||
flash(_("Password change is only available for local accounts."), "error")
|
||||
else:
|
||||
current_pw = request.form.get("current_password", "")
|
||||
new_pw = request.form.get("new_password", "")
|
||||
confirm_pw = request.form.get("confirm_password", "")
|
||||
if not current_user.check_password(current_pw):
|
||||
flash("Current password is incorrect.", "error")
|
||||
flash(_("Current password is incorrect."), "error")
|
||||
elif len(new_pw) < 8:
|
||||
flash("New password must be at least 8 characters.", "error")
|
||||
flash(_("New password must be at least 8 characters."), "error")
|
||||
elif new_pw != confirm_pw:
|
||||
flash("Passwords do not match.", "error")
|
||||
flash(_("Passwords do not match."), "error")
|
||||
else:
|
||||
current_user.set_password(new_pw)
|
||||
db.session.commit()
|
||||
flash("Password changed.", "success")
|
||||
flash(_("Password changed."), "success")
|
||||
|
||||
return redirect(url_for("auth.profile"))
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from flask import (
|
||||
request, send_from_directory, url_for,
|
||||
)
|
||||
from flask import Blueprint
|
||||
from flask_babel import _
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -41,9 +42,9 @@ def _apply_form(item: EquipmentItem) -> str | None:
|
||||
name = request.form.get("name", "").strip()
|
||||
category = request.form.get("category", "").strip()
|
||||
if not name:
|
||||
return "Name is required."
|
||||
return _("Name is required.")
|
||||
if category not in CATEGORY_KEYS:
|
||||
return "Invalid category."
|
||||
return _("Invalid category.")
|
||||
item.name = name
|
||||
item.category = category
|
||||
item.brand = request.form.get("brand", "").strip() or None
|
||||
@@ -93,7 +94,7 @@ def new():
|
||||
db.session.flush()
|
||||
_handle_photo(item, is_new=True)
|
||||
db.session.commit()
|
||||
flash(f"'{item.name}' added.", "success")
|
||||
flash(_("'%(name)s' added.", name=item.name), "success")
|
||||
return redirect(url_for("equipment.detail", item_id=item.id))
|
||||
return render_template("equipment/form.html", item=None, categories=CATEGORIES)
|
||||
|
||||
@@ -117,7 +118,7 @@ def edit(item_id: int):
|
||||
categories=CATEGORIES, prefill=request.form)
|
||||
_handle_photo(item, is_new=False)
|
||||
db.session.commit()
|
||||
flash(f"'{item.name}' updated.", "success")
|
||||
flash(_("'%(name)s' updated.", name=item.name), "success")
|
||||
return redirect(url_for("equipment.detail", item_id=item.id))
|
||||
return render_template("equipment/form.html", item=item, categories=CATEGORIES)
|
||||
|
||||
@@ -131,7 +132,7 @@ def delete(item_id: int):
|
||||
_remove_photo_file(item.photo_path)
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
flash(f"'{name}' deleted.", "success")
|
||||
flash(_("'%(name)s' deleted.", name=name), "success")
|
||||
return redirect(url_for("equipment.index"))
|
||||
|
||||
|
||||
@@ -140,7 +141,7 @@ def delete(item_id: int):
|
||||
def rotate_photo_view(item_id: int):
|
||||
item = _own_item(item_id)
|
||||
if not item.photo_path:
|
||||
flash("No photo to rotate.", "error")
|
||||
flash(_("No photo to rotate."), "error")
|
||||
return redirect(url_for("equipment.detail", item_id=item_id))
|
||||
try:
|
||||
degrees = int(request.form.get("degrees", 0))
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
|
||||
@@ -15,6 +16,42 @@ 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"),
|
||||
@@ -80,6 +117,8 @@ def _apply_form(s: ShootingSession) -> str | 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
|
||||
|
||||
|
||||
@@ -134,14 +173,28 @@ def new():
|
||||
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 created.", "success")
|
||||
flash(_("Session saved."), "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())
|
||||
|
||||
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>")
|
||||
@@ -157,8 +210,43 @@ def detail(session_id: int):
|
||||
.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, is_owner=is_owner)
|
||||
analyses=analyses, analyses_display=analyses_display,
|
||||
is_owner=is_owner)
|
||||
|
||||
|
||||
@sessions_bp.route("/<int:session_id>/edit", methods=["GET", "POST"])
|
||||
@@ -172,15 +260,21 @@ def edit(session_id: int):
|
||||
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 updated.", "success")
|
||||
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)
|
||||
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"])
|
||||
@@ -191,10 +285,55 @@ def delete(session_id: int):
|
||||
_remove_file(photo.photo_path)
|
||||
db.session.delete(s)
|
||||
db.session.commit()
|
||||
flash("Session deleted.", "success")
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -206,7 +345,7 @@ def upload_csv(session_id: int):
|
||||
|
||||
csv_file = request.files.get("csv_file")
|
||||
if not csv_file or not csv_file.filename:
|
||||
flash("No CSV file selected.", "error")
|
||||
flash(_("No CSV file selected."), "error")
|
||||
return redirect(url_for("sessions.detail", session_id=session_id))
|
||||
|
||||
from analyzer.parser import parse_csv
|
||||
@@ -240,7 +379,7 @@ def upload_csv(session_id: int):
|
||||
session_id=session_id,
|
||||
is_public=s.is_public if s else False,
|
||||
)
|
||||
flash("CSV analysed and linked to this session.", "success")
|
||||
flash(_("CSV analysed and linked to this session."), "success")
|
||||
return redirect(url_for("sessions.detail", session_id=session_id))
|
||||
|
||||
|
||||
@@ -255,7 +394,7 @@ def upload_photo(session_id: int):
|
||||
|
||||
photo_file = request.files.get("photo")
|
||||
if not photo_file or not photo_file.filename:
|
||||
flash("No photo selected.", "error")
|
||||
flash(_("No photo selected."), "error")
|
||||
return redirect(url_for("sessions.detail", session_id=session_id))
|
||||
|
||||
try:
|
||||
@@ -268,7 +407,7 @@ def upload_photo(session_id: int):
|
||||
photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption)
|
||||
db.session.add(photo)
|
||||
db.session.commit()
|
||||
flash("Photo added.", "success")
|
||||
flash(_("Photo added."), "success")
|
||||
return redirect(url_for("sessions.detail", session_id=session_id))
|
||||
|
||||
|
||||
@@ -282,7 +421,7 @@ def delete_photo(session_id: int, photo_id: int):
|
||||
_remove_file(photo.photo_path)
|
||||
db.session.delete(photo)
|
||||
db.session.commit()
|
||||
flash("Photo deleted.", "success")
|
||||
flash(_("Photo deleted."), "success")
|
||||
return redirect(url_for("sessions.detail", session_id=session_id))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user