Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85de9781d7 | ||
|
|
a4dad2a9f2 |
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
**First-time setup:**
|
||||
```bash
|
||||
cp .env.example .env # fill in SECRET_KEY, DB_PASSWORD, OAuth credentials
|
||||
```
|
||||
|
||||
**Run the app (only way to test — no host Python):**
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
**Database migrations:**
|
||||
```bash
|
||||
# Applied automatically on every `docker compose up` via entrypoint.sh.
|
||||
|
||||
# To create a new migration after changing models.py (DB must be running):
|
||||
DB_PASS=$(grep DB_PASSWORD .env | cut -d= -f2)
|
||||
docker run --rm -v "$(pwd)":/app -w /app \
|
||||
-e FLASK_APP=app -e DATABASE_URL="postgresql+psycopg://ballistic:${DB_PASS}@db:5432/ballistic" \
|
||||
-e SECRET_KEY=dev --network ballistictool_default --entrypoint flask \
|
||||
ballistictool-web db migrate -m "description"
|
||||
# Then restart so entrypoint applies it:
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
> **Important:** `docker compose run web flask db ...` won't work for init/migrate because the container is ephemeral and writes to its own filesystem. Use the bind-mount `docker run` form above so files persist to the host and get committed to git.
|
||||
|
||||
**Smoke-test imports without starting the DB:**
|
||||
```bash
|
||||
docker compose run --no-deps --rm --entrypoint python web -c "from app import create_app; create_app()"
|
||||
```
|
||||
|
||||
> The host Python environment is externally-managed (Debian). Do not run `pip install` on the host. All dependency testing must happen inside Docker.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
app.py — create_app() factory; registers extensions, blueprints, and core routes
|
||||
config.py — Config class reading all env vars (SECRET_KEY, DATABASE_URL, OAuth keys)
|
||||
extensions.py — module-level db/login_manager/migrate instances (no init_app here)
|
||||
models.py — SQLAlchemy models: User, EquipmentItem, ShootingSession, Analysis
|
||||
storage.py — file I/O helpers: save_analysis(), save_equipment_photo()
|
||||
blueprints/ — feature blueprints (auth, dashboard, analyses, equipment, sessions)
|
||||
migrations/ — Alembic migration scripts (committed to git)
|
||||
.env — gitignored secrets (copy from .env.example)
|
||||
entrypoint.sh — runs `flask db upgrade` then starts gunicorn
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Flask web app that processes ballistic CSV data, computes statistics, renders charts, and generates PDF reports.
|
||||
|
||||
**Request flow for `POST /analyze`:**
|
||||
|
||||
```
|
||||
Upload CSV
|
||||
→ analyzer/parser.py parse_csv() — normalize CSV (handles locale variants)
|
||||
→ analyzer/grouper.py detect_groups() — split into shot groups by time gaps
|
||||
→ analyzer/stats.py compute_*_stats() — per-group + overall statistics
|
||||
→ analyzer/charts.py render_*_charts() — base64 PNG images via matplotlib
|
||||
→ analyzer/pdf_report.py generate_pdf() — fpdf2 multi-page PDF (returned as bytes)
|
||||
→ templates/results.html — renders stats + embedded images + PDF link
|
||||
```
|
||||
|
||||
**Group detection algorithm** (`grouper.py`): splits shots where the gap between consecutive timestamps exceeds `median_gap × OUTLIER_FACTOR` (5). This is the core domain logic that determines what counts as a separate shooting session.
|
||||
|
||||
**CSV parsing** (`parser.py`): handles BOM, various decimal separators (`.` / `,`), and time formats (`HH:MM:SS`, `HH:MM:SS.fff`, `HH:MM:SS,fff`). Expected columns map French headers to internal names: `speed`, `std_dev`, `energy`, `power_factor`, `time`.
|
||||
|
||||
**Charts** use matplotlib's `Agg` (non-interactive) backend. Images are base64-encoded and embedded directly in HTML and PDF — no static asset serving.
|
||||
|
||||
**PDF** is returned as raw bytes from `generate_pdf()` and served inline via Flask's `send_file`.
|
||||
|
||||
## Stack
|
||||
|
||||
- Python 3.12, Flask 3.0, gunicorn (2 workers, port 5000)
|
||||
- PostgreSQL 16 via Docker Compose; SQLAlchemy 2.0 + Flask-Migrate (Alembic) for ORM/migrations
|
||||
- DB driver: `psycopg[binary]` (psycopg3) — connection URL scheme is `postgresql+psycopg://`
|
||||
- Auth: Authlib (OAuth2 flows) + Flask-Login (session/`current_user`); providers: Google, GitHub
|
||||
- File storage: Docker volume at `/app/storage`; Pillow for equipment photo validation/resize
|
||||
- pandas + numpy for data processing; matplotlib for charts; fpdf2 for PDF generation
|
||||
- Docker / Docker Compose for deployment (no host install)
|
||||
@@ -5,17 +5,39 @@ OUTLIER_FACTOR = 5
|
||||
|
||||
|
||||
def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR,
|
||||
manual_splits: list | None = None) -> list:
|
||||
manual_splits: list | None = None,
|
||||
forced_splits: list | None = None) -> list:
|
||||
"""Split shots into groups.
|
||||
|
||||
Auto-detection: consecutive shots with a time gap > median_gap * outlier_factor
|
||||
start a new group. manual_splits is an optional list of shot indices (0-based
|
||||
positions in df) where a split should be forced, regardless of timing.
|
||||
Both mechanisms are merged and deduplicated.
|
||||
forced_splits: when provided, ONLY these split positions are used — auto-detection
|
||||
is bypassed entirely. Use this for user-defined groupings from the visual editor.
|
||||
|
||||
manual_splits: added on top of auto-detected splits (when forced_splits is None).
|
||||
Both auto+manual mechanisms are merged and deduplicated.
|
||||
"""
|
||||
if len(df) <= 1:
|
||||
return [df]
|
||||
|
||||
def _build_groups(all_splits):
|
||||
if not all_splits:
|
||||
return [df]
|
||||
groups = []
|
||||
prev = 0
|
||||
for pos in all_splits:
|
||||
group = df.iloc[prev:pos]
|
||||
if len(group) > 0:
|
||||
groups.append(group.reset_index(drop=True))
|
||||
prev = pos
|
||||
last = df.iloc[prev:]
|
||||
if len(last) > 0:
|
||||
groups.append(last.reset_index(drop=True))
|
||||
return groups
|
||||
|
||||
# Forced mode: user controls exact split positions, no auto-detection
|
||||
if forced_splits is not None:
|
||||
valid = sorted(s for s in forced_splits if 0 < s < len(df))
|
||||
return _build_groups(valid)
|
||||
|
||||
times = df["time"]
|
||||
diffs = times.diff().dropna()
|
||||
|
||||
@@ -35,20 +57,4 @@ def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR,
|
||||
|
||||
# Merge with manual splits (filter to valid range)
|
||||
extra = set(manual_splits) if manual_splits else set()
|
||||
all_splits = sorted(auto_splits | extra)
|
||||
|
||||
if not all_splits:
|
||||
return [df]
|
||||
|
||||
groups = []
|
||||
prev = 0
|
||||
for pos in all_splits:
|
||||
group = df.iloc[prev:pos]
|
||||
if len(group) > 0:
|
||||
groups.append(group.reset_index(drop=True))
|
||||
prev = pos
|
||||
last = df.iloc[prev:]
|
||||
if len(last) > 0:
|
||||
groups.append(last.reset_index(drop=True))
|
||||
|
||||
return groups
|
||||
return _build_groups(sorted(auto_splits | extra))
|
||||
|
||||
21
app.py
21
app.py
@@ -3,11 +3,14 @@ import io
|
||||
|
||||
from flask import Flask, redirect, request, render_template, session as flask_session
|
||||
from flask_login import current_user
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import Config
|
||||
from extensions import babel, db, jwt, login_manager, migrate, oauth
|
||||
|
||||
csrf = CSRFProtect()
|
||||
|
||||
SUPPORTED_LANGS = ["fr", "en", "de"]
|
||||
|
||||
|
||||
@@ -33,12 +36,20 @@ def create_app(config_class=Config):
|
||||
login_manager.init_app(app)
|
||||
jwt.init_app(app)
|
||||
babel.init_app(app, locale_selector=_select_locale)
|
||||
csrf.init_app(app)
|
||||
|
||||
@app.context_processor
|
||||
def inject_locale():
|
||||
from flask_babel import get_locale
|
||||
return {"current_lang": str(get_locale())}
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
return response
|
||||
|
||||
|
||||
@jwt.unauthorized_loader
|
||||
def unauthorized_callback(reason):
|
||||
@@ -78,7 +89,10 @@ def create_app(config_class=Config):
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
try:
|
||||
return db.session.get(User, int(user_id))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
from blueprints.admin import admin_bp
|
||||
from blueprints.auth import auth_bp
|
||||
@@ -94,6 +108,7 @@ def create_app(config_class=Config):
|
||||
app.register_blueprint(analyses_bp)
|
||||
|
||||
from blueprints.api import api as api_bp
|
||||
csrf.exempt(api_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
@app.route("/u/<int:user_id>")
|
||||
@@ -140,6 +155,10 @@ def create_app(config_class=Config):
|
||||
).all()
|
||||
return render_template("index.html", public_sessions=public_sessions)
|
||||
|
||||
@app.route("/tools/measure")
|
||||
def tools_measure():
|
||||
return render_template("tools/measure.html")
|
||||
|
||||
@app.route("/analyze", methods=["GET", "POST"])
|
||||
def analyze():
|
||||
from analyzer.parser import parse_csv
|
||||
|
||||
@@ -3,14 +3,14 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (
|
||||
Blueprint, abort, current_app, flash, redirect, request,
|
||||
Blueprint, abort, current_app, flash, jsonify, 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
|
||||
from models import Analysis, AnalysisGroupPhoto
|
||||
|
||||
analyses_bp = Blueprint("analyses", __name__, url_prefix="/analyses")
|
||||
|
||||
@@ -116,22 +116,6 @@ def regroup(analysis_id: int):
|
||||
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():
|
||||
@@ -144,9 +128,46 @@ def regroup(analysis_id: int):
|
||||
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)
|
||||
# Check if this is a forced-split (visual editor) submission
|
||||
forced_splits_raw = request.form.get("forced_splits", "").strip()
|
||||
forced_splits = None
|
||||
if forced_splits_raw:
|
||||
try:
|
||||
parsed = json.loads(forced_splits_raw)
|
||||
if isinstance(parsed, list):
|
||||
forced_splits = [int(x) for x in parsed]
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if forced_splits is not None:
|
||||
# Visual editor mode: exact user-defined group boundaries
|
||||
new_outlier_factor = None
|
||||
new_manual_splits = forced_splits
|
||||
csv_bytes = csv_path.read_bytes()
|
||||
df = parse_csv(io.BytesIO(csv_bytes))
|
||||
groups = detect_groups(df, forced_splits=forced_splits)
|
||||
else:
|
||||
# Traditional mode: outlier_factor + optional manual_splits
|
||||
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
|
||||
new_outlier_factor = outlier_factor
|
||||
new_manual_splits = manual_splits
|
||||
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)
|
||||
|
||||
@@ -161,14 +182,14 @@ def regroup(analysis_id: int):
|
||||
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
|
||||
|
||||
if a.pdf_path:
|
||||
pdf_path = Path(storage_root) / a.pdf_path
|
||||
pdf_path_obj = Path(storage_root) / a.pdf_path
|
||||
try:
|
||||
pdf_path.write_bytes(pdf_bytes)
|
||||
pdf_path_obj.write_bytes(pdf_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
a.grouping_outlier_factor = outlier_factor
|
||||
a.grouping_manual_splits = manual_splits
|
||||
a.grouping_outlier_factor = new_outlier_factor
|
||||
a.grouping_manual_splits = new_manual_splits
|
||||
a.group_stats = _to_python(group_stats)
|
||||
a.overall_stats = _to_python(overall)
|
||||
a.shot_count = int(overall.get("count", 0))
|
||||
@@ -219,3 +240,133 @@ def download_pdf(analysis_id: int):
|
||||
filename = Path(a.pdf_path).name
|
||||
return send_from_directory(pdf_dir, filename, as_attachment=True,
|
||||
download_name=f"{a.title}.pdf")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analysis group photos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@analyses_bp.route("/<int:analysis_id>/groups/<int:group_index>/photo", methods=["POST"])
|
||||
@login_required
|
||||
def upload_group_photo(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)
|
||||
|
||||
photo_file = request.files.get("photo")
|
||||
if not photo_file or not photo_file.filename:
|
||||
flash(_("No photo selected."), "error")
|
||||
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)
|
||||
|
||||
from storage import save_analysis_group_photo
|
||||
try:
|
||||
photo_path = save_analysis_group_photo(
|
||||
current_user.id, a.id, group_index, photo_file
|
||||
)
|
||||
except ValueError as e:
|
||||
flash(str(e), "error")
|
||||
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)
|
||||
|
||||
caption = request.form.get("caption", "").strip() or None
|
||||
photo = AnalysisGroupPhoto(
|
||||
analysis_id=a.id,
|
||||
group_index=group_index,
|
||||
photo_path=photo_path,
|
||||
caption=caption,
|
||||
)
|
||||
db.session.add(photo)
|
||||
db.session.commit()
|
||||
flash(_("Photo added."), "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("/group-photos/<int:photo_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_group_photo(photo_id: int):
|
||||
photo = db.session.get(AnalysisGroupPhoto, photo_id)
|
||||
if photo is None:
|
||||
abort(404)
|
||||
if photo.analysis.user_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
try:
|
||||
(Path(storage_root) / photo.photo_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
analysis_id = photo.analysis_id
|
||||
session_id = photo.analysis.session_id
|
||||
db.session.delete(photo)
|
||||
db.session.commit()
|
||||
flash(_("Photo deleted."), "success")
|
||||
back = (url_for("sessions.detail", session_id=session_id)
|
||||
if session_id else url_for("analyses.detail", analysis_id=analysis_id))
|
||||
return redirect(back)
|
||||
|
||||
|
||||
@analyses_bp.route("/<int:analysis_id>/group-photos/<int:photo_id>/annotate",
|
||||
methods=["GET", "POST"])
|
||||
@login_required
|
||||
def annotate_group_photo(analysis_id: int, photo_id: int):
|
||||
a = db.session.get(Analysis, analysis_id)
|
||||
if a is None:
|
||||
abort(404)
|
||||
if a.user_id != current_user.id:
|
||||
abort(403)
|
||||
photo = db.session.get(AnalysisGroupPhoto, photo_id)
|
||||
if photo is None or photo.analysis_id != analysis_id:
|
||||
abort(404)
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.get_json(force=True)
|
||||
photo.annotations = data
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
back = (url_for("sessions.detail", session_id=a.session_id)
|
||||
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
|
||||
|
||||
# Pre-fill shooting distance from session if available
|
||||
session_dist_m = None
|
||||
if a.session_id:
|
||||
from models import ShootingSession
|
||||
s = db.session.get(ShootingSession, a.session_id)
|
||||
if s and s.distance_m:
|
||||
session_dist_m = s.distance_m
|
||||
|
||||
return render_template("analyses/annotate_group_photo.html",
|
||||
analysis=a, photo=photo,
|
||||
back_url=back, session_dist_m=session_dist_m)
|
||||
|
||||
|
||||
@analyses_bp.route("/group-photos/<path:filepath>")
|
||||
def serve_group_photo(filepath: str):
|
||||
"""Serve an analysis group photo. Private analysis photos are owner-only."""
|
||||
try:
|
||||
owner_id = int(filepath.split("/")[0])
|
||||
except (ValueError, IndexError):
|
||||
abort(404)
|
||||
|
||||
is_owner = current_user.is_authenticated and current_user.id == owner_id
|
||||
if not is_owner:
|
||||
# Check if analysis is public
|
||||
from sqlalchemy import select
|
||||
photo = db.session.scalars(
|
||||
select(AnalysisGroupPhoto).where(
|
||||
AnalysisGroupPhoto.photo_path == f"analysis_group_photos/{filepath}"
|
||||
)
|
||||
).first()
|
||||
if photo is None or not photo.analysis.is_public:
|
||||
abort(403)
|
||||
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
return send_from_directory(Path(storage_root) / "analysis_group_photos", filepath)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from flask import Blueprint, request
|
||||
import secrets
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from flask_jwt_extended import create_access_token, jwt_required
|
||||
|
||||
from extensions import db
|
||||
@@ -28,17 +30,22 @@ def register():
|
||||
if existing:
|
||||
return err("Email already registered.", 409)
|
||||
|
||||
needs_confirmation = current_app.config.get("EMAIL_CONFIRMATION_REQUIRED", False)
|
||||
u = User(
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
provider="local",
|
||||
provider_id=email,
|
||||
email_confirmed=True,
|
||||
email_confirmed=not needs_confirmation,
|
||||
email_confirm_token=secrets.token_urlsafe(32) if needs_confirmation else None,
|
||||
)
|
||||
u.set_password(password)
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
|
||||
if needs_confirmation:
|
||||
return err("Account created. Please confirm your email before logging in.", 201)
|
||||
|
||||
token = create_access_token(identity=str(u.id))
|
||||
return created({"user": serialize_user(u), "access_token": token})
|
||||
|
||||
@@ -55,6 +62,9 @@ def login():
|
||||
if not u or not u.check_password(password):
|
||||
return err("Invalid email or password.", 401)
|
||||
|
||||
if current_app.config.get("EMAIL_CONFIRMATION_REQUIRED") and not u.email_confirmed:
|
||||
return err("Please confirm your email address before logging in.", 403)
|
||||
|
||||
token = create_access_token(identity=str(u.id))
|
||||
return ok({"user": serialize_user(u), "access_token": token})
|
||||
|
||||
|
||||
@@ -15,13 +15,17 @@ from storage import rotate_photo, save_equipment_photo
|
||||
|
||||
equipment_bp = Blueprint("equipment", __name__, url_prefix="/equipment")
|
||||
|
||||
CATEGORIES = [
|
||||
_CATEGORIES_RAW = [
|
||||
("rifle", "Rifle"),
|
||||
("handgun", "Handgun"),
|
||||
("scope", "Scope"),
|
||||
("other", "Other"),
|
||||
]
|
||||
CATEGORY_KEYS = [k for k, _ in CATEGORIES]
|
||||
CATEGORY_KEYS = [k for k, _ in _CATEGORIES_RAW]
|
||||
|
||||
|
||||
def _t_categories():
|
||||
return [(k, _(l)) for k, l in _CATEGORIES_RAW]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,7 +80,7 @@ def index():
|
||||
.where(EquipmentItem.user_id == current_user.id)
|
||||
.order_by(EquipmentItem.category, EquipmentItem.name)
|
||||
).all()
|
||||
return render_template("equipment/list.html", items=items, categories=CATEGORIES)
|
||||
return render_template("equipment/list.html", items=items, categories=_t_categories())
|
||||
|
||||
|
||||
@equipment_bp.route("/new", methods=["GET", "POST"])
|
||||
@@ -90,20 +94,20 @@ def new():
|
||||
db.session.expunge(item)
|
||||
flash(error, "error")
|
||||
return render_template("equipment/form.html", item=None,
|
||||
categories=CATEGORIES, prefill=request.form)
|
||||
categories=_t_categories(), prefill=request.form)
|
||||
db.session.flush()
|
||||
_handle_photo(item, is_new=True)
|
||||
db.session.commit()
|
||||
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)
|
||||
return render_template("equipment/form.html", item=None, categories=_t_categories())
|
||||
|
||||
|
||||
@equipment_bp.route("/<int:item_id>")
|
||||
@login_required
|
||||
def detail(item_id: int):
|
||||
item = _own_item(item_id)
|
||||
return render_template("equipment/detail.html", item=item, categories=dict(CATEGORIES))
|
||||
return render_template("equipment/detail.html", item=item, categories=dict(_t_categories()))
|
||||
|
||||
|
||||
@equipment_bp.route("/<int:item_id>/edit", methods=["GET", "POST"])
|
||||
@@ -115,12 +119,12 @@ def edit(item_id: int):
|
||||
if error:
|
||||
flash(error, "error")
|
||||
return render_template("equipment/form.html", item=item,
|
||||
categories=CATEGORIES, prefill=request.form)
|
||||
categories=_t_categories(), prefill=request.form)
|
||||
_handle_photo(item, is_new=False)
|
||||
db.session.commit()
|
||||
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)
|
||||
return render_template("equipment/form.html", item=item, categories=_t_categories())
|
||||
|
||||
|
||||
@equipment_bp.route("/<int:item_id>/delete", methods=["POST"])
|
||||
|
||||
@@ -64,6 +64,23 @@ WEATHER_CONDITIONS = [
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translation helpers (call at request time, not module level)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _t_weather():
|
||||
return [(v, _(l)) for v, l in WEATHER_CONDITIONS]
|
||||
|
||||
def _t_positions(pairs):
|
||||
return [(v, _(l)) for v, l in pairs]
|
||||
|
||||
def _t_session_types():
|
||||
return [(s, _(n), _(d)) for s, n, d in SESSION_TYPES]
|
||||
|
||||
def _t_prs_positions():
|
||||
return [(v, _(l)) for v, l in PRS_STAGE_POSITIONS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,10 +189,10 @@ def new():
|
||||
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,
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||
prefill=request.form)
|
||||
db.session.commit()
|
||||
flash(_("Session saved."), "success")
|
||||
@@ -186,15 +203,15 @@ def new():
|
||||
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,
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_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)
|
||||
return render_template("sessions/type_picker.html", session_types=_t_session_types())
|
||||
|
||||
|
||||
@sessions_bp.route("/<int:session_id>")
|
||||
@@ -217,15 +234,20 @@ def detail(session_id: int):
|
||||
from analyzer.charts import render_group_charts, render_overview_chart
|
||||
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
prs_positions = _t_prs_positions()
|
||||
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)
|
||||
if a.grouping_outlier_factor is None and splits is not None:
|
||||
# Forced mode: user defined exact split positions
|
||||
groups = detect_groups(df, forced_splits=splits)
|
||||
else:
|
||||
factor = a.grouping_outlier_factor if a.grouping_outlier_factor is not None else OUTLIER_FACTOR
|
||||
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
|
||||
@@ -238,14 +260,21 @@ def detail(session_id: int):
|
||||
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))
|
||||
# Compute split positions from cumulative group counts (for visual editor)
|
||||
cumulative = 0
|
||||
split_positions = []
|
||||
for gs, _chart in groups_display[:-1]:
|
||||
cumulative += gs["count"]
|
||||
split_positions.append(cumulative)
|
||||
analyses_display.append((a, groups_display, overview_chart, split_positions))
|
||||
except Exception:
|
||||
analyses_display.append((a, None, None))
|
||||
analyses_display.append((a, None, None, []))
|
||||
else:
|
||||
analyses_display.append((a, None, None))
|
||||
analyses_display.append((a, None, None, []))
|
||||
|
||||
return render_template("sessions/detail.html", session=s,
|
||||
analyses=analyses, analyses_display=analyses_display,
|
||||
prs_positions=prs_positions,
|
||||
is_owner=is_owner)
|
||||
|
||||
|
||||
@@ -259,10 +288,10 @@ def edit(session_id: int):
|
||||
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,
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||
prefill=request.form)
|
||||
for analysis in s.analyses:
|
||||
analysis.is_public = s.is_public
|
||||
@@ -271,10 +300,10 @@ def edit(session_id: int):
|
||||
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)
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS))
|
||||
|
||||
|
||||
@sessions_bp.route("/<int:session_id>/delete", methods=["POST"])
|
||||
|
||||
18
config.py
18
config.py
@@ -3,8 +3,22 @@ from datetime import timedelta
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
|
||||
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret"))
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-change-in-production"
|
||||
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") or os.environ.get("SECRET_KEY") or "dev-secret"
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
"""Call this in production to ensure required secrets are set."""
|
||||
import os as _os
|
||||
if _os.environ.get("FLASK_ENV") == "production" or _os.environ.get("FLASK_DEBUG") == "0":
|
||||
if cls.SECRET_KEY in ("dev-secret-change-in-production", "dev-secret"):
|
||||
raise RuntimeError(
|
||||
"SECRET_KEY must be set to a strong random value in production. "
|
||||
"Set the SECRET_KEY environment variable."
|
||||
)
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=24)
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"DATABASE_URL", "sqlite:///dev.db"
|
||||
|
||||
34
migrations/versions/a9f3d82c1e47_analysis_group_photos.py
Normal file
34
migrations/versions/a9f3d82c1e47_analysis_group_photos.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""analysis_group_photos
|
||||
|
||||
Revision ID: a9f3d82c1e47
|
||||
Revises: 6818f37f4124
|
||||
Create Date: 2026-03-20 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a9f3d82c1e47'
|
||||
down_revision = '6818f37f4124'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'analysis_group_photos',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('analysis_id', sa.Integer(), nullable=False),
|
||||
sa.Column('group_index', sa.Integer(), nullable=False),
|
||||
sa.Column('photo_path', sa.Text(), nullable=False),
|
||||
sa.Column('caption', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['analysis_id'], ['analyses.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('analysis_group_photos')
|
||||
24
models.py
24
models.py
@@ -162,6 +162,10 @@ class Analysis(db.Model):
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="analyses")
|
||||
session: Mapped["ShootingSession | None"] = relationship("ShootingSession", back_populates="analyses")
|
||||
group_photos: Mapped[list["AnalysisGroupPhoto"]] = relationship(
|
||||
"AnalysisGroupPhoto", back_populates="analysis", cascade="all, delete-orphan",
|
||||
order_by="AnalysisGroupPhoto.group_index, AnalysisGroupPhoto.created_at"
|
||||
)
|
||||
|
||||
|
||||
class SessionPhoto(db.Model):
|
||||
@@ -181,3 +185,23 @@ class SessionPhoto(db.Model):
|
||||
def photo_url(self) -> str:
|
||||
rel = self.photo_path.removeprefix("session_photos/")
|
||||
return f"/sessions/photos/{rel}"
|
||||
|
||||
|
||||
class AnalysisGroupPhoto(db.Model):
|
||||
__tablename__ = "analysis_group_photos"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
analysis_id: Mapped[int] = mapped_column(ForeignKey("analyses.id"), nullable=False)
|
||||
group_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
photo_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
caption: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
annotations: Mapped[dict | None] = mapped_column(db.JSON)
|
||||
|
||||
analysis: Mapped["Analysis"] = relationship("Analysis", back_populates="group_photos")
|
||||
|
||||
@property
|
||||
def photo_url(self) -> str:
|
||||
rel = self.photo_path.removeprefix("analysis_group_photos/")
|
||||
return f"/analyses/group-photos/{rel}"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Flask>=3.0
|
||||
Flask-Babel>=3.0
|
||||
Flask-WTF>=1.2
|
||||
python-dotenv>=1.0
|
||||
Flask-SQLAlchemy>=3.1
|
||||
Flask-Migrate>=4.0
|
||||
|
||||
@@ -143,6 +143,12 @@ def save_avatar(user_id: int, file_storage) -> str:
|
||||
return _save_photo(file_storage, dest, "avatar")
|
||||
|
||||
|
||||
def save_analysis_group_photo(user_id: int, analysis_id: int, group_index: int,
|
||||
file_storage) -> str:
|
||||
dest = _root() / "analysis_group_photos" / str(user_id)
|
||||
return _save_photo(file_storage, dest, f"{analysis_id}_g{group_index}")
|
||||
|
||||
|
||||
def rotate_photo(rel_path: str, degrees: int) -> None:
|
||||
"""Rotate a stored JPEG in-place. degrees is clockwise (90, -90, 180)."""
|
||||
path = _root() / rel_path
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}The Shooter's Network{% endblock %}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -245,8 +246,18 @@
|
||||
<a href="/" class="nav-brand">The Shooter's Network</a>
|
||||
|
||||
<div class="nav-links">
|
||||
{# Tools dropdown — always visible #}
|
||||
<div class="nav-dropdown" id="toolsDropdown">
|
||||
<button class="nav-user-btn" onclick="toggleToolsDropdown(event)"
|
||||
style="border:none;padding:.25rem .55rem;font-size:0.9rem;color:#c8cfe0;gap:.3rem;">
|
||||
{{ _('Tools') }} <span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu" style="min-width:200px;">
|
||||
<a href="{{ url_for('analyze') }}">📊 {{ _('Analyse CSV') }}</a>
|
||||
<a href="{{ url_for('tools_measure') }}">🎯 {{ _('Measure group (photo)') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
|
||||
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
|
||||
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
|
||||
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
|
||||
@@ -300,8 +311,9 @@
|
||||
|
||||
{# Mobile menu panel #}
|
||||
<div class="nav-mobile-menu">
|
||||
<a href="{{ url_for('analyze') }}">📊 {{ _('Analyse CSV') }}</a>
|
||||
<a href="{{ url_for('tools_measure') }}">🎯 {{ _('Measure group (photo)') }}</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
|
||||
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
|
||||
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
|
||||
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
|
||||
@@ -331,6 +343,10 @@
|
||||
e.stopPropagation();
|
||||
document.getElementById('langDropdown').classList.toggle('open');
|
||||
}
|
||||
function toggleToolsDropdown(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('toolsDropdown').classList.toggle('open');
|
||||
}
|
||||
function toggleMobileNav(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('mainNav').classList.toggle('open');
|
||||
@@ -340,6 +356,8 @@
|
||||
if (d) d.classList.remove('open');
|
||||
var l = document.getElementById('langDropdown');
|
||||
if (l) l.classList.remove('open');
|
||||
var t = document.getElementById('toolsDropdown');
|
||||
if (t) t.classList.remove('open');
|
||||
var n = document.getElementById('mainNav');
|
||||
if (n) n.classList.remove('open');
|
||||
});
|
||||
@@ -495,5 +513,22 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/* Auto-inject CSRF token into every POST form on the page */
|
||||
(function() {
|
||||
var token = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!token) return;
|
||||
var t = token.getAttribute('content');
|
||||
document.querySelectorAll('form').forEach(function(f) {
|
||||
var method = (f.getAttribute('method') || 'get').toLowerCase();
|
||||
if (method !== 'post') return;
|
||||
if (f.querySelector('input[name="csrf_token"]')) return; // already present
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = t;
|
||||
f.appendChild(inp);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -158,15 +158,7 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var PRS_POS = [
|
||||
["prone", "Couché"],
|
||||
["standing", "Debout"],
|
||||
["kneeling", "Agenouillé"],
|
||||
["sitting", "Assis"],
|
||||
["barricade", "Barricade"],
|
||||
["rooftop", "Toit"],
|
||||
["unknown", "Variable"],
|
||||
];
|
||||
var PRS_POS = {{ prs_positions | tojson }};
|
||||
|
||||
var stages = {{ (session.prs_stages or []) | tojson }};
|
||||
|
||||
@@ -434,7 +426,7 @@
|
||||
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
|
||||
|
||||
{% if analyses_display %}
|
||||
{% for a, groups_display, overview_chart in analyses_display %}
|
||||
{% for a, groups_display, overview_chart, split_positions in analyses_display %}
|
||||
<details open style="border:1px solid #e0e0e0;border-radius:8px;margin-bottom:1.25rem;overflow:hidden;">
|
||||
<summary style="display:flex;align-items:center;gap:.75rem;padding:.85rem 1.25rem;
|
||||
background:#f8f9fb;cursor:pointer;list-style:none;flex-wrap:wrap;">
|
||||
@@ -502,15 +494,89 @@
|
||||
{# --- Per-group cards --- #}
|
||||
{% if groups_display %}
|
||||
{% for gs, chart in groups_display %}
|
||||
<div class="group-section" style="margin-bottom:1rem;">
|
||||
<div class="group-meta">
|
||||
{% set grp_idx = loop.index0 %}
|
||||
{% set grp_photos = a.group_photos | selectattr('group_index', 'equalto', grp_idx) | list %}
|
||||
<div class="group-section" style="margin-bottom:1.25rem;border:1px solid #eee;border-radius:6px;padding:.9rem 1rem;">
|
||||
<div class="group-meta" style="margin-bottom:.6rem;">
|
||||
<strong>{{ _('Group %(n)s', n=loop.index) }}</strong>
|
||||
· {{ gs.count }} {{ _('shots') }}
|
||||
· {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
|
||||
{% if gs.std_speed is not none %} · {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
|
||||
· {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }}
|
||||
</div>
|
||||
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart">
|
||||
|
||||
{# 2-column layout: chart 65% / photos 35% #}
|
||||
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap;">
|
||||
|
||||
{# Left: chart #}
|
||||
<div style="flex:1 1 60%;min-width:0;">
|
||||
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart"
|
||||
style="width:100%;max-width:none;margin-top:0;">
|
||||
</div>
|
||||
|
||||
{# Right: group photos #}
|
||||
{% if grp_photos %}
|
||||
<div style="flex:0 0 33%;min-width:180px;display:flex;flex-direction:column;gap:.75rem;">
|
||||
{% for gp in grp_photos %}
|
||||
<div>
|
||||
<img src="{{ gp.photo_url }}"
|
||||
data-gallery="grp-{{ a.id }}-{{ grp_idx }}"
|
||||
data-src="{{ gp.photo_url }}"
|
||||
data-caption="{{ gp.caption or '' }}"
|
||||
alt="{{ gp.caption or '' }}"
|
||||
style="width:100%;border-radius:5px;object-fit:cover;display:block;cursor:zoom-in;">
|
||||
|
||||
{# Annotation stats if available #}
|
||||
{% if gp.annotations and gp.annotations.stats %}
|
||||
{% set s = gp.annotations.stats %}
|
||||
<div style="margin-top:.4rem;background:#f8f9fb;border:1px solid #e0e0e0;border-radius:6px;padding:.5rem .65rem;font-size:0.78rem;">
|
||||
<div style="font-weight:700;color:#1a1a2e;margin-bottom:.3rem;">
|
||||
{{ s.shot_count }} {{ _('shots') }} · {{ s.shooting_distance_m | int }} m
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0;">
|
||||
<tr>
|
||||
<td style="color:#666;padding:.1rem 0;border:none;">{{ _('Group ES') }}</td>
|
||||
<td style="font-weight:600;text-align:right;padding:.1rem 0;border:none;">{{ '%.2f'|format(s.group_size_moa) }} MOA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#666;padding:.1rem 0;border:none;">{{ _('Mean Radius') }}</td>
|
||||
<td style="font-weight:600;text-align:right;padding:.1rem 0;border:none;">{{ '%.2f'|format(s.mean_radius_moa) }} MOA</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if gp.caption %}
|
||||
<div style="font-size:0.75rem;color:#666;margin-top:.2rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ gp.caption }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_owner %}
|
||||
<div style="display:flex;gap:.4rem;margin-top:.35rem;flex-wrap:wrap;">
|
||||
<a href="{{ url_for('analyses.annotate_group_photo', analysis_id=a.id, photo_id=gp.id) }}"
|
||||
style="display:inline-block;padding:.2rem .55rem;border-radius:4px;font-size:0.78rem;text-decoration:none;
|
||||
{% if gp.annotations and gp.annotations.stats %}
|
||||
background:#e8f5e9;color:#27ae60;border:1px solid #a5d6a7;
|
||||
{% else %}
|
||||
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
|
||||
{% endif %}">
|
||||
{% if gp.annotations and gp.annotations.stats %}✓{% else %}▶{% endif %}
|
||||
{{ _('Measure group') }}
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('analyses.delete_group_photo', photo_id=gp.id) }}"
|
||||
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');">
|
||||
<button type="submit"
|
||||
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:3px;padding:.2rem .45rem;font-size:0.78rem;cursor:pointer;">
|
||||
{{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>{# end 2-col #}
|
||||
|
||||
{% if gs.note %}
|
||||
<div style="margin-top:.75rem;padding:.5rem .75rem;background:#fffbea;border-left:3px solid #f0c040;
|
||||
@@ -518,23 +584,49 @@
|
||||
{% endif %}
|
||||
|
||||
{% if is_owner %}
|
||||
<details style="margin-top:.75rem;">
|
||||
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline;">
|
||||
✎ {{ _('Note') }}
|
||||
</summary>
|
||||
<form method="post"
|
||||
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=loop.index0) }}"
|
||||
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
|
||||
<textarea name="note" rows="2"
|
||||
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:520px;">{{ gs.note or '' }}</textarea>
|
||||
<div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;align-items:flex-start;">
|
||||
{# Note editor #}
|
||||
<details>
|
||||
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline-block;padding:.2rem .5rem;border:1px solid #ddd;border-radius:4px;background:#fafafa;">
|
||||
✎ {{ _('Note') }}
|
||||
</summary>
|
||||
<form method="post"
|
||||
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=grp_idx) }}"
|
||||
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
|
||||
<textarea name="note" rows="2"
|
||||
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:400px;">{{ gs.note or '' }}</textarea>
|
||||
<div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Save note') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{# Group photo upload #}
|
||||
<details>
|
||||
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline-block;padding:.2rem .5rem;border:1px solid #ddd;border-radius:4px;background:#fafafa;">
|
||||
📷 {{ _('Add photo') }}
|
||||
</summary>
|
||||
<form method="post"
|
||||
action="{{ url_for('analyses.upload_group_photo', analysis_id=a.id, group_index=grp_idx) }}"
|
||||
enctype="multipart/form-data"
|
||||
style="margin-top:.4rem;display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-end;">
|
||||
<div>
|
||||
<input type="file" name="photo" accept="image/*" required style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" name="caption" placeholder="{{ _('Caption (optional)') }}"
|
||||
style="padding:.35rem .6rem;border:1px solid #ccc;border-radius:4px;font-size:0.85rem;width:160px;">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .85rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Save note') }}
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .8rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Upload') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -542,45 +634,101 @@
|
||||
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# --- Re-group panel (owner only) --- #}
|
||||
{% if is_owner %}
|
||||
{# --- Visual Group Editor (owner only) --- #}
|
||||
{% if is_owner and groups_display %}
|
||||
<details style="margin-top:1rem;border-top:1px solid #e8e8e8;padding-top:.9rem;">
|
||||
<summary style="font-size:0.85rem;color:#888;cursor:pointer;list-style:none;">
|
||||
⚙ {{ _('Re-group settings') }}
|
||||
⚙ {{ _('Edit groups') }}
|
||||
</summary>
|
||||
<form method="post" action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
||||
style="margin-top:.75rem;display:flex;flex-direction:column;gap:.75rem;max-width:480px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
||||
{{ _('Outlier factor:') }}
|
||||
<span id="factor_val_{{ a.id }}">{{ a.grouping_outlier_factor or 5 }}</span>
|
||||
</label>
|
||||
<input type="range" name="outlier_factor" min="1" max="20" step="0.5"
|
||||
value="{{ a.grouping_outlier_factor or 5 }}"
|
||||
style="width:100%;"
|
||||
oninput="document.getElementById('factor_val_{{ a.id }}').textContent=this.value">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:#aaa;">
|
||||
<span>{{ _('1 (fine)') }}</span><span>{{ _('20 (coarse)') }}</span>
|
||||
</div>
|
||||
<div style="margin-top:.75rem;">
|
||||
<div style="font-size:0.8rem;color:#aaa;margin-bottom:.6rem;">
|
||||
{{ _('Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint.') }}
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
||||
{{ _('Manual split indices (JSON array, e.g. [5, 12])') }}
|
||||
</label>
|
||||
<input type="text" name="manual_splits"
|
||||
value="{{ (a.grouping_manual_splits | tojson) if a.grouping_manual_splits else '' }}"
|
||||
placeholder="e.g. [5, 12]"
|
||||
style="width:100%;padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
||||
<div style="font-size:0.75rem;color:#aaa;margin-top:.2rem;">{{ _('Shot positions (0-based) where a new group should always begin.') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="grp-editor-{{ a.id }}" style="display:flex;align-items:stretch;flex-wrap:wrap;gap:0;user-select:none;"></div>
|
||||
<form id="regroup-form-{{ a.id }}" method="post"
|
||||
action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
||||
style="margin-top:.75rem;display:flex;gap:.6rem;align-items:center;">
|
||||
<input type="hidden" name="forced_splits" id="forced-splits-{{ a.id }}">
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.45rem 1.1rem;font-size:0.88rem;cursor:pointer;">
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem 1rem;font-size:0.85rem;cursor:pointer;">
|
||||
{{ _('Apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<button type="button" onclick="grpReset{{ a.id }}()"
|
||||
style="background:none;color:#666;border:1px solid #ddd;border-radius:4px;padding:.4rem .75rem;font-size:0.85rem;cursor:pointer;">
|
||||
{{ _('Reset') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var AID = {{ a.id }};
|
||||
var TOTAL = {{ a.shot_count }};
|
||||
var INIT_SPLITS = {{ split_positions | tojson }};
|
||||
var splits = INIT_SPLITS.slice();
|
||||
var COLORS = ['#dbeafe','#dcfce7','#fef9c3','#fce7f3','#ede9fe','#ccfbf1','#ffedd5'];
|
||||
var S_SHOTS = {{ _('shots') | tojson }};
|
||||
var S_MERGE = {{ _('Merge') | tojson }};
|
||||
var S_SPLIT = {{ _('Split') | tojson }};
|
||||
|
||||
function render() {
|
||||
var el = document.getElementById('grp-editor-' + AID);
|
||||
el.innerHTML = '';
|
||||
var bounds = [0].concat(splits).concat([TOTAL]);
|
||||
for (var i = 0; i < bounds.length - 1; i++) {
|
||||
var start = bounds[i], end = bounds[i+1], cnt = end - start;
|
||||
var pct = Math.max(8, Math.round(cnt / TOTAL * 100));
|
||||
var color = COLORS[i % COLORS.length];
|
||||
|
||||
var box = document.createElement('div');
|
||||
box.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;justify-content:center;' +
|
||||
'width:' + pct + '%;min-width:56px;background:' + color + ';border:1px solid #ccc;border-radius:4px;' +
|
||||
'padding:.4rem .3rem;text-align:center;font-size:0.78rem;box-sizing:border-box;';
|
||||
box.innerHTML = '<strong style="font-size:.82rem;">' + (i+1) + '</strong>' +
|
||||
'<span style="color:#555;">' + cnt + ' ' + S_SHOTS + '</span>';
|
||||
|
||||
if (cnt >= 2) {
|
||||
var mid = start + Math.floor(cnt / 2);
|
||||
var sb = document.createElement('button');
|
||||
sb.type = 'button'; sb.title = S_SPLIT; sb.textContent = '⊣⊢';
|
||||
sb.style.cssText = 'margin-top:.3rem;font-size:.7rem;padding:.1rem .3rem;border:1px dashed #888;border-radius:3px;background:rgba(255,255,255,.6);cursor:pointer;';
|
||||
(function(m){ sb.onclick = function(){ addSplit(m); }; })(mid);
|
||||
box.appendChild(sb);
|
||||
}
|
||||
el.appendChild(box);
|
||||
|
||||
if (i < bounds.length - 2) {
|
||||
var sep = document.createElement('div');
|
||||
sep.style.cssText = 'display:inline-flex;align-items:center;padding:0 .15rem;';
|
||||
var mb = document.createElement('button');
|
||||
mb.type = 'button'; mb.title = S_MERGE; mb.textContent = '✕';
|
||||
mb.style.cssText = 'border:none;background:none;color:#c0392b;font-size:.9rem;cursor:pointer;padding:.15rem .3rem;';
|
||||
var capSplit = bounds[i+1];
|
||||
(function(sp){ mb.onclick = function(){ removeSplit(sp); }; })(capSplit);
|
||||
sep.appendChild(mb);
|
||||
el.appendChild(sep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSplit(pos) {
|
||||
if (splits.indexOf(pos) === -1) { splits.push(pos); splits.sort(function(a,b){return a-b;}); }
|
||||
render();
|
||||
}
|
||||
function removeSplit(pos) {
|
||||
splits = splits.filter(function(s){ return s !== pos; });
|
||||
render();
|
||||
}
|
||||
window['grpReset' + AID] = function() { splits = INIT_SPLITS.slice(); render(); };
|
||||
|
||||
document.getElementById('regroup-form-' + AID).addEventListener('submit', function() {
|
||||
document.getElementById('forced-splits-' + AID).value = JSON.stringify(splits);
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,15 @@ msgstr ""
|
||||
msgid "New Analysis"
|
||||
msgstr "Neue Analyse"
|
||||
|
||||
msgid "Tools"
|
||||
msgstr "Werkzeuge"
|
||||
|
||||
msgid "Analyse CSV"
|
||||
msgstr "CSV analysieren"
|
||||
|
||||
msgid "Measure group (photo)"
|
||||
msgstr "Gruppe messen (Foto)"
|
||||
|
||||
msgid "Equipment"
|
||||
msgstr "Ausrüstung"
|
||||
|
||||
@@ -717,3 +726,87 @@ msgstr "Benutzer %(email)s gelöscht."
|
||||
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
|
||||
msgid "— select —"
|
||||
msgstr "— auswählen —"
|
||||
|
||||
msgid "Prone"
|
||||
msgstr "Liegend"
|
||||
|
||||
msgid "Bench rest"
|
||||
msgstr "Auflage"
|
||||
|
||||
msgid "Standing"
|
||||
msgstr "Stehend"
|
||||
|
||||
msgid "Standing with support"
|
||||
msgstr "Stehend mit Stütze"
|
||||
|
||||
msgid "Kneeling"
|
||||
msgstr "Kniend"
|
||||
|
||||
msgid "Sitting"
|
||||
msgstr "Sitzend"
|
||||
|
||||
msgid "Sitting with support"
|
||||
msgstr "Sitzend mit Stütze"
|
||||
|
||||
msgid "Barricade"
|
||||
msgstr "Barrikade"
|
||||
|
||||
msgid "Rooftop"
|
||||
msgstr "Dach"
|
||||
|
||||
msgid "Variable"
|
||||
msgstr "Variabel"
|
||||
|
||||
msgid "Sunny"
|
||||
msgstr "Sonnig"
|
||||
|
||||
msgid "Partly cloudy"
|
||||
msgstr "Teilweise bewölkt"
|
||||
|
||||
msgid "Overcast"
|
||||
msgstr "Bedeckt"
|
||||
|
||||
msgid "Rain"
|
||||
msgstr "Regen"
|
||||
|
||||
msgid "Wind"
|
||||
msgstr "Wind"
|
||||
|
||||
msgid "Snow"
|
||||
msgstr "Schnee"
|
||||
|
||||
msgid "Fog"
|
||||
msgstr "Nebel"
|
||||
|
||||
msgid "Rifle"
|
||||
msgstr "Gewehr"
|
||||
|
||||
msgid "Handgun"
|
||||
msgstr "Pistole"
|
||||
|
||||
msgid "Other"
|
||||
msgstr "Sonstiges"
|
||||
|
||||
msgid "Edit groups"
|
||||
msgstr "Gruppen bearbeiten"
|
||||
|
||||
msgid "Merge"
|
||||
msgstr "Zusammenführen"
|
||||
|
||||
msgid "Split"
|
||||
msgstr "Aufteilen"
|
||||
|
||||
msgid "Reset"
|
||||
msgstr "Zurücksetzen"
|
||||
|
||||
msgid "Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint."
|
||||
msgstr "Klicken Sie auf ✕ zwischen Gruppen zum Zusammenführen. Klicken Sie auf ⊣⊢ in einer Gruppe zum Aufteilen."
|
||||
|
||||
msgid "No photo selected."
|
||||
msgstr "Kein Foto ausgewählt."
|
||||
|
||||
msgid "Photo added."
|
||||
msgstr "Foto hinzugefügt."
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,15 @@ msgstr ""
|
||||
msgid "New Analysis"
|
||||
msgstr "Nouvelle analyse"
|
||||
|
||||
msgid "Tools"
|
||||
msgstr "Outils"
|
||||
|
||||
msgid "Analyse CSV"
|
||||
msgstr "Analyser un CSV"
|
||||
|
||||
msgid "Measure group (photo)"
|
||||
msgstr "Mesurer un groupement (photo)"
|
||||
|
||||
msgid "Equipment"
|
||||
msgstr "Équipement"
|
||||
|
||||
@@ -717,3 +726,87 @@ msgstr "Utilisateur %(email)s supprimé."
|
||||
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
|
||||
msgid "— select —"
|
||||
msgstr "— choisir —"
|
||||
|
||||
msgid "Prone"
|
||||
msgstr "Couché"
|
||||
|
||||
msgid "Bench rest"
|
||||
msgstr "Banc de tir"
|
||||
|
||||
msgid "Standing"
|
||||
msgstr "Debout"
|
||||
|
||||
msgid "Standing with support"
|
||||
msgstr "Debout avec appui"
|
||||
|
||||
msgid "Kneeling"
|
||||
msgstr "Agenouillé"
|
||||
|
||||
msgid "Sitting"
|
||||
msgstr "Assis"
|
||||
|
||||
msgid "Sitting with support"
|
||||
msgstr "Assis avec appui"
|
||||
|
||||
msgid "Barricade"
|
||||
msgstr "Barricade"
|
||||
|
||||
msgid "Rooftop"
|
||||
msgstr "Toit"
|
||||
|
||||
msgid "Variable"
|
||||
msgstr "Variable"
|
||||
|
||||
msgid "Sunny"
|
||||
msgstr "Ensoleillé"
|
||||
|
||||
msgid "Partly cloudy"
|
||||
msgstr "Partiellement nuageux"
|
||||
|
||||
msgid "Overcast"
|
||||
msgstr "Couvert"
|
||||
|
||||
msgid "Rain"
|
||||
msgstr "Pluie"
|
||||
|
||||
msgid "Wind"
|
||||
msgstr "Vent"
|
||||
|
||||
msgid "Snow"
|
||||
msgstr "Neige"
|
||||
|
||||
msgid "Fog"
|
||||
msgstr "Brouillard"
|
||||
|
||||
msgid "Rifle"
|
||||
msgstr "Carabine"
|
||||
|
||||
msgid "Handgun"
|
||||
msgstr "Pistolet"
|
||||
|
||||
msgid "Other"
|
||||
msgstr "Autre"
|
||||
|
||||
msgid "Edit groups"
|
||||
msgstr "Modifier les groupes"
|
||||
|
||||
msgid "Merge"
|
||||
msgstr "Fusionner"
|
||||
|
||||
msgid "Split"
|
||||
msgstr "Diviser"
|
||||
|
||||
msgid "Reset"
|
||||
msgstr "Réinitialiser"
|
||||
|
||||
msgid "Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint."
|
||||
msgstr "Cliquez sur ✕ entre les groupes pour les fusionner. Cliquez sur ⊣⊢ dans un groupe pour le diviser au milieu."
|
||||
|
||||
msgid "No photo selected."
|
||||
msgstr "Aucune photo sélectionnée."
|
||||
|
||||
msgid "Photo added."
|
||||
msgstr "Photo ajoutée."
|
||||
|
||||
Reference in New Issue
Block a user