Compare commits

2 Commits

Author SHA1 Message Date
Gérald Colangelo
85de9781d7 wip: claude 2026-03-23 18:50:18 +01:00
Gérald Colangelo
a4dad2a9f2 wip: claude 2026-03-23 11:39:51 +01:00
19 changed files with 896 additions and 143 deletions

86
CLAUDE.md Normal file
View 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)

View File

@@ -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
View File

@@ -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

View File

@@ -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)

View File

@@ -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})

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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"

View 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')

View File

@@ -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}"

View File

@@ -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

View File

@@ -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

View File

@@ -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">&#9660;</span>
</button>
<div class="nav-dd-menu" style="min-width:200px;">
<a href="{{ url_for('analyze') }}">&#128202;&ensp;{{ _('Analyse CSV') }}</a>
<a href="{{ url_for('tools_measure') }}">&#127919;&ensp;{{ _('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') }}">&#128202;&ensp;{{ _('Analyse CSV') }}</a>
<a href="{{ url_for('tools_measure') }}">&#127919;&ensp;{{ _('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>

View File

@@ -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>
&nbsp;&middot;&nbsp; {{ gs.count }} {{ _('shots') }}
&nbsp;&middot;&nbsp; {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
{% if gs.std_speed is not none %}&nbsp;&middot;&nbsp; {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
&nbsp;&middot;&nbsp; {{ _('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') }} &middot; {{ 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 %}&#10003;{% else %}&#9654;{% 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;">
&#9998; {{ _('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;">
&#9998; {{ _('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;">
&#128247; {{ _('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;">
&#9881; {{ _('Re-group settings') }}
&#9881; {{ _('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>

View File

@@ -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."

View File

@@ -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."