wip: claude
This commit is contained in:
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,
|
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.
|
"""Split shots into groups.
|
||||||
|
|
||||||
Auto-detection: consecutive shots with a time gap > median_gap * outlier_factor
|
forced_splits: when provided, ONLY these split positions are used — auto-detection
|
||||||
start a new group. manual_splits is an optional list of shot indices (0-based
|
is bypassed entirely. Use this for user-defined groupings from the visual editor.
|
||||||
positions in df) where a split should be forced, regardless of timing.
|
|
||||||
Both mechanisms are merged and deduplicated.
|
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:
|
if len(df) <= 1:
|
||||||
return [df]
|
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"]
|
times = df["time"]
|
||||||
diffs = times.diff().dropna()
|
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)
|
# Merge with manual splits (filter to valid range)
|
||||||
extra = set(manual_splits) if manual_splits else set()
|
extra = set(manual_splits) if manual_splits else set()
|
||||||
all_splits = sorted(auto_splits | extra)
|
return _build_groups(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
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from flask_babel import _
|
|||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from models import Analysis
|
from models import Analysis, AnalysisGroupPhoto
|
||||||
|
|
||||||
analyses_bp = Blueprint("analyses", __name__, url_prefix="/analyses")
|
analyses_bp = Blueprint("analyses", __name__, url_prefix="/analyses")
|
||||||
|
|
||||||
@@ -116,22 +116,6 @@ def regroup(analysis_id: int):
|
|||||||
if a.user_id != current_user.id:
|
if a.user_id != current_user.id:
|
||||||
abort(403)
|
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"]
|
storage_root = current_app.config["STORAGE_ROOT"]
|
||||||
csv_path = Path(storage_root) / a.csv_path
|
csv_path = Path(storage_root) / a.csv_path
|
||||||
if not csv_path.exists():
|
if not csv_path.exists():
|
||||||
@@ -144,9 +128,46 @@ def regroup(analysis_id: int):
|
|||||||
from analyzer.pdf_report import generate_pdf
|
from analyzer.pdf_report import generate_pdf
|
||||||
from storage import _to_python
|
from storage import _to_python
|
||||||
|
|
||||||
csv_bytes = csv_path.read_bytes()
|
# Check if this is a forced-split (visual editor) submission
|
||||||
df = parse_csv(io.BytesIO(csv_bytes))
|
forced_splits_raw = request.form.get("forced_splits", "").strip()
|
||||||
groups = detect_groups(df, outlier_factor=outlier_factor, manual_splits=manual_splits)
|
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)
|
overall = compute_overall_stats(df)
|
||||||
group_stats = compute_group_stats(groups)
|
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)
|
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
|
||||||
|
|
||||||
if a.pdf_path:
|
if a.pdf_path:
|
||||||
pdf_path = Path(storage_root) / a.pdf_path
|
pdf_path_obj = Path(storage_root) / a.pdf_path
|
||||||
try:
|
try:
|
||||||
pdf_path.write_bytes(pdf_bytes)
|
pdf_path_obj.write_bytes(pdf_bytes)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
a.grouping_outlier_factor = outlier_factor
|
a.grouping_outlier_factor = new_outlier_factor
|
||||||
a.grouping_manual_splits = manual_splits
|
a.grouping_manual_splits = new_manual_splits
|
||||||
a.group_stats = _to_python(group_stats)
|
a.group_stats = _to_python(group_stats)
|
||||||
a.overall_stats = _to_python(overall)
|
a.overall_stats = _to_python(overall)
|
||||||
a.shot_count = int(overall.get("count", 0))
|
a.shot_count = int(overall.get("count", 0))
|
||||||
@@ -219,3 +240,98 @@ def download_pdf(analysis_id: int):
|
|||||||
filename = Path(a.pdf_path).name
|
filename = Path(a.pdf_path).name
|
||||||
return send_from_directory(pdf_dir, filename, as_attachment=True,
|
return send_from_directory(pdf_dir, filename, as_attachment=True,
|
||||||
download_name=f"{a.title}.pdf")
|
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("/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)
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ from storage import rotate_photo, save_equipment_photo
|
|||||||
|
|
||||||
equipment_bp = Blueprint("equipment", __name__, url_prefix="/equipment")
|
equipment_bp = Blueprint("equipment", __name__, url_prefix="/equipment")
|
||||||
|
|
||||||
CATEGORIES = [
|
_CATEGORIES_RAW = [
|
||||||
("rifle", "Rifle"),
|
("rifle", "Rifle"),
|
||||||
("handgun", "Handgun"),
|
("handgun", "Handgun"),
|
||||||
("scope", "Scope"),
|
("scope", "Scope"),
|
||||||
("other", "Other"),
|
("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)
|
.where(EquipmentItem.user_id == current_user.id)
|
||||||
.order_by(EquipmentItem.category, EquipmentItem.name)
|
.order_by(EquipmentItem.category, EquipmentItem.name)
|
||||||
).all()
|
).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"])
|
@equipment_bp.route("/new", methods=["GET", "POST"])
|
||||||
@@ -90,20 +94,20 @@ def new():
|
|||||||
db.session.expunge(item)
|
db.session.expunge(item)
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
return render_template("equipment/form.html", item=None,
|
return render_template("equipment/form.html", item=None,
|
||||||
categories=CATEGORIES, prefill=request.form)
|
categories=_t_categories(), prefill=request.form)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
_handle_photo(item, is_new=True)
|
_handle_photo(item, is_new=True)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("'%(name)s' added.", name=item.name), "success")
|
flash(_("'%(name)s' added.", name=item.name), "success")
|
||||||
return redirect(url_for("equipment.detail", item_id=item.id))
|
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>")
|
@equipment_bp.route("/<int:item_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def detail(item_id: int):
|
def detail(item_id: int):
|
||||||
item = _own_item(item_id)
|
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"])
|
@equipment_bp.route("/<int:item_id>/edit", methods=["GET", "POST"])
|
||||||
@@ -115,12 +119,12 @@ def edit(item_id: int):
|
|||||||
if error:
|
if error:
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
return render_template("equipment/form.html", item=item,
|
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)
|
_handle_photo(item, is_new=False)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("'%(name)s' updated.", name=item.name), "success")
|
flash(_("'%(name)s' updated.", name=item.name), "success")
|
||||||
return redirect(url_for("equipment.detail", item_id=item.id))
|
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"])
|
@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
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -172,10 +189,10 @@ def new():
|
|||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
return render_template("sessions/form.html", session=None,
|
return render_template("sessions/form.html", session=None,
|
||||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||||
weather_conditions=WEATHER_CONDITIONS,
|
weather_conditions=_t_weather(),
|
||||||
session_types=SESSION_TYPES,
|
session_types=_t_session_types(),
|
||||||
long_range_positions=LONG_RANGE_POSITIONS,
|
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||||
pistol_25m_positions=PISTOL_25M_POSITIONS,
|
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||||
prefill=request.form)
|
prefill=request.form)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("Session saved."), "success")
|
flash(_("Session saved."), "success")
|
||||||
@@ -186,15 +203,15 @@ def new():
|
|||||||
prefill_distance = _FIXED_DISTANCES.get(selected_type)
|
prefill_distance = _FIXED_DISTANCES.get(selected_type)
|
||||||
return render_template("sessions/form.html", session=None,
|
return render_template("sessions/form.html", session=None,
|
||||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||||
weather_conditions=WEATHER_CONDITIONS,
|
weather_conditions=_t_weather(),
|
||||||
session_types=SESSION_TYPES,
|
session_types=_t_session_types(),
|
||||||
long_range_positions=LONG_RANGE_POSITIONS,
|
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||||
pistol_25m_positions=PISTOL_25M_POSITIONS,
|
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||||
selected_type=selected_type,
|
selected_type=selected_type,
|
||||||
prefill_distance=prefill_distance,
|
prefill_distance=prefill_distance,
|
||||||
today=date.today().isoformat())
|
today=date.today().isoformat())
|
||||||
# Step 1: show type picker
|
# 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>")
|
@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
|
from analyzer.charts import render_group_charts, render_overview_chart
|
||||||
|
|
||||||
storage_root = current_app.config["STORAGE_ROOT"]
|
storage_root = current_app.config["STORAGE_ROOT"]
|
||||||
|
prs_positions = _t_prs_positions()
|
||||||
analyses_display = []
|
analyses_display = []
|
||||||
for a in analyses:
|
for a in analyses:
|
||||||
csv_path = Path(storage_root) / a.csv_path
|
csv_path = Path(storage_root) / a.csv_path
|
||||||
if csv_path.exists():
|
if csv_path.exists():
|
||||||
try:
|
try:
|
||||||
df = parse_csv(io.BytesIO(csv_path.read_bytes()))
|
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
|
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)
|
overall = compute_overall_stats(df)
|
||||||
group_stats = compute_group_stats(groups)
|
group_stats = compute_group_stats(groups)
|
||||||
# Merge stored notes into freshly computed stats
|
# Merge stored notes into freshly computed stats
|
||||||
@@ -238,14 +260,21 @@ def detail(session_id: int):
|
|||||||
y_max=overall["max_speed"])
|
y_max=overall["max_speed"])
|
||||||
overview_chart = render_overview_chart(group_stats)
|
overview_chart = render_overview_chart(group_stats)
|
||||||
groups_display = list(zip(group_stats, charts))
|
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:
|
except Exception:
|
||||||
analyses_display.append((a, None, None))
|
analyses_display.append((a, None, None, []))
|
||||||
else:
|
else:
|
||||||
analyses_display.append((a, None, None))
|
analyses_display.append((a, None, None, []))
|
||||||
|
|
||||||
return render_template("sessions/detail.html", session=s,
|
return render_template("sessions/detail.html", session=s,
|
||||||
analyses=analyses, analyses_display=analyses_display,
|
analyses=analyses, analyses_display=analyses_display,
|
||||||
|
prs_positions=prs_positions,
|
||||||
is_owner=is_owner)
|
is_owner=is_owner)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,10 +288,10 @@ def edit(session_id: int):
|
|||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
return render_template("sessions/form.html", session=s,
|
return render_template("sessions/form.html", session=s,
|
||||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||||
weather_conditions=WEATHER_CONDITIONS,
|
weather_conditions=_t_weather(),
|
||||||
session_types=SESSION_TYPES,
|
session_types=_t_session_types(),
|
||||||
long_range_positions=LONG_RANGE_POSITIONS,
|
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||||
pistol_25m_positions=PISTOL_25M_POSITIONS,
|
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||||
prefill=request.form)
|
prefill=request.form)
|
||||||
for analysis in s.analyses:
|
for analysis in s.analyses:
|
||||||
analysis.is_public = s.is_public
|
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 redirect(url_for("sessions.detail", session_id=s.id))
|
||||||
return render_template("sessions/form.html", session=s,
|
return render_template("sessions/form.html", session=s,
|
||||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||||
weather_conditions=WEATHER_CONDITIONS,
|
weather_conditions=_t_weather(),
|
||||||
session_types=SESSION_TYPES,
|
session_types=_t_session_types(),
|
||||||
long_range_positions=LONG_RANGE_POSITIONS,
|
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||||
pistol_25m_positions=PISTOL_25M_POSITIONS)
|
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS))
|
||||||
|
|
||||||
|
|
||||||
@sessions_bp.route("/<int:session_id>/delete", methods=["POST"])
|
@sessions_bp.route("/<int:session_id>/delete", methods=["POST"])
|
||||||
|
|||||||
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')
|
||||||
22
models.py
22
models.py
@@ -162,6 +162,10 @@ class Analysis(db.Model):
|
|||||||
|
|
||||||
user: Mapped["User"] = relationship("User", back_populates="analyses")
|
user: Mapped["User"] = relationship("User", back_populates="analyses")
|
||||||
session: Mapped["ShootingSession | None"] = relationship("ShootingSession", 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):
|
class SessionPhoto(db.Model):
|
||||||
@@ -181,3 +185,21 @@ class SessionPhoto(db.Model):
|
|||||||
def photo_url(self) -> str:
|
def photo_url(self) -> str:
|
||||||
rel = self.photo_path.removeprefix("session_photos/")
|
rel = self.photo_path.removeprefix("session_photos/")
|
||||||
return f"/sessions/photos/{rel}"
|
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)
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ def save_avatar(user_id: int, file_storage) -> str:
|
|||||||
return _save_photo(file_storage, dest, "avatar")
|
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:
|
def rotate_photo(rel_path: str, degrees: int) -> None:
|
||||||
"""Rotate a stored JPEG in-place. degrees is clockwise (90, -90, 180)."""
|
"""Rotate a stored JPEG in-place. degrees is clockwise (90, -90, 180)."""
|
||||||
path = _root() / rel_path
|
path = _root() / rel_path
|
||||||
|
|||||||
@@ -158,15 +158,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var PRS_POS = [
|
var PRS_POS = {{ prs_positions | tojson }};
|
||||||
["prone", "Couché"],
|
|
||||||
["standing", "Debout"],
|
|
||||||
["kneeling", "Agenouillé"],
|
|
||||||
["sitting", "Assis"],
|
|
||||||
["barricade", "Barricade"],
|
|
||||||
["rooftop", "Toit"],
|
|
||||||
["unknown", "Variable"],
|
|
||||||
];
|
|
||||||
|
|
||||||
var stages = {{ (session.prs_stages or []) | tojson }};
|
var stages = {{ (session.prs_stages or []) | tojson }};
|
||||||
|
|
||||||
@@ -434,7 +426,7 @@
|
|||||||
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
|
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
|
||||||
|
|
||||||
{% if analyses_display %}
|
{% 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;">
|
<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;
|
<summary style="display:flex;align-items:center;gap:.75rem;padding:.85rem 1.25rem;
|
||||||
background:#f8f9fb;cursor:pointer;list-style:none;flex-wrap:wrap;">
|
background:#f8f9fb;cursor:pointer;list-style:none;flex-wrap:wrap;">
|
||||||
@@ -502,8 +494,10 @@
|
|||||||
{# --- Per-group cards --- #}
|
{# --- Per-group cards --- #}
|
||||||
{% if groups_display %}
|
{% if groups_display %}
|
||||||
{% for gs, chart in groups_display %}
|
{% for gs, chart in groups_display %}
|
||||||
<div class="group-section" style="margin-bottom:1rem;">
|
{% set grp_idx = loop.index0 %}
|
||||||
<div class="group-meta">
|
{% 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>
|
<strong>{{ _('Group %(n)s', n=loop.index) }}</strong>
|
||||||
· {{ gs.count }} {{ _('shots') }}
|
· {{ gs.count }} {{ _('shots') }}
|
||||||
· {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
|
· {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
|
||||||
@@ -517,24 +511,75 @@
|
|||||||
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
|
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if is_owner %}
|
{# Group photos #}
|
||||||
<details style="margin-top:.75rem;">
|
{% if grp_photos %}
|
||||||
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline;">
|
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;">
|
||||||
✎ {{ _('Note') }}
|
{% for gp in grp_photos %}
|
||||||
</summary>
|
<div>
|
||||||
<form method="post"
|
<img src="{{ gp.photo_url }}" alt="{{ gp.caption or '' }}"
|
||||||
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=loop.index0) }}"
|
style="height:120px;width:auto;border-radius:5px;object-fit:cover;display:block;">
|
||||||
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
|
{% if gp.caption %}
|
||||||
<textarea name="note" rows="2"
|
<div style="font-size:0.75rem;color:#666;margin-top:.2rem;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ gp.caption }}</div>
|
||||||
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>
|
{% endif %}
|
||||||
<div>
|
{% if is_owner %}
|
||||||
|
<form method="post" action="{{ url_for('analyses.delete_group_photo', photo_id=gp.id) }}"
|
||||||
|
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');"
|
||||||
|
style="margin-top:.2rem;">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .85rem;font-size:0.82rem;cursor:pointer;">
|
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:3px;padding:.1rem .4rem;font-size:0.75rem;cursor:pointer;">
|
||||||
{{ _('Save note') }}
|
{{ _('Delete') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
{% endif %}
|
||||||
</details>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_owner %}
|
||||||
|
<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 .8rem;font-size:0.82rem;cursor:pointer;">
|
||||||
|
{{ _('Upload') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -542,45 +587,101 @@
|
|||||||
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
|
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# --- Re-group panel (owner only) --- #}
|
{# --- Visual Group Editor (owner only) --- #}
|
||||||
{% if is_owner %}
|
{% if is_owner and groups_display %}
|
||||||
<details style="margin-top:1rem;border-top:1px solid #e8e8e8;padding-top:.9rem;">
|
<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;">
|
<summary style="font-size:0.85rem;color:#888;cursor:pointer;list-style:none;">
|
||||||
⚙ {{ _('Re-group settings') }}
|
⚙ {{ _('Edit groups') }}
|
||||||
</summary>
|
</summary>
|
||||||
<form method="post" action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
<div style="margin-top:.75rem;">
|
||||||
style="margin-top:.75rem;display:flex;flex-direction:column;gap:.75rem;max-width:480px;">
|
<div style="font-size:0.8rem;color:#aaa;margin-bottom:.6rem;">
|
||||||
<div>
|
{{ _('Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint.') }}
|
||||||
<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>
|
</div>
|
||||||
<div>
|
<div id="grp-editor-{{ a.id }}" style="display:flex;align-items:stretch;flex-wrap:wrap;gap:0;user-select:none;"></div>
|
||||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
<form id="regroup-form-{{ a.id }}" method="post"
|
||||||
{{ _('Manual split indices (JSON array, e.g. [5, 12])') }}
|
action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
||||||
</label>
|
style="margin-top:.75rem;display:flex;gap:.6rem;align-items:center;">
|
||||||
<input type="text" name="manual_splits"
|
<input type="hidden" name="forced_splits" id="forced-splits-{{ a.id }}">
|
||||||
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>
|
|
||||||
<button type="submit"
|
<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') }}
|
{{ _('Apply') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" onclick="grpReset{{ a.id }}()"
|
||||||
</form>
|
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>
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
@@ -717,3 +717,87 @@ msgstr "Benutzer %(email)s gelöscht."
|
|||||||
|
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "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.
@@ -717,3 +717,87 @@ msgstr "Utilisateur %(email)s supprimé."
|
|||||||
|
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "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