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,
|
||||
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))
|
||||
|
||||
@@ -10,7 +10,7 @@ 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,98 @@ 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("/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")
|
||||
|
||||
CATEGORIES = [
|
||||
_CATEGORIES_RAW = [
|
||||
("rifle", "Rifle"),
|
||||
("handgun", "Handgun"),
|
||||
("scope", "Scope"),
|
||||
("other", "Other"),
|
||||
]
|
||||
CATEGORY_KEYS = [k for k, _ in CATEGORIES]
|
||||
CATEGORY_KEYS = [k for k, _ in _CATEGORIES_RAW]
|
||||
|
||||
|
||||
def _t_categories():
|
||||
return [(k, _(l)) for k, l in _CATEGORIES_RAW]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,7 +80,7 @@ def index():
|
||||
.where(EquipmentItem.user_id == current_user.id)
|
||||
.order_by(EquipmentItem.category, EquipmentItem.name)
|
||||
).all()
|
||||
return render_template("equipment/list.html", items=items, categories=CATEGORIES)
|
||||
return render_template("equipment/list.html", items=items, categories=_t_categories())
|
||||
|
||||
|
||||
@equipment_bp.route("/new", methods=["GET", "POST"])
|
||||
@@ -90,20 +94,20 @@ def new():
|
||||
db.session.expunge(item)
|
||||
flash(error, "error")
|
||||
return render_template("equipment/form.html", item=None,
|
||||
categories=CATEGORIES, prefill=request.form)
|
||||
categories=_t_categories(), prefill=request.form)
|
||||
db.session.flush()
|
||||
_handle_photo(item, is_new=True)
|
||||
db.session.commit()
|
||||
flash(_("'%(name)s' added.", name=item.name), "success")
|
||||
return redirect(url_for("equipment.detail", item_id=item.id))
|
||||
return render_template("equipment/form.html", item=None, categories=CATEGORIES)
|
||||
return render_template("equipment/form.html", item=None, categories=_t_categories())
|
||||
|
||||
|
||||
@equipment_bp.route("/<int:item_id>")
|
||||
@login_required
|
||||
def detail(item_id: int):
|
||||
item = _own_item(item_id)
|
||||
return render_template("equipment/detail.html", item=item, categories=dict(CATEGORIES))
|
||||
return render_template("equipment/detail.html", item=item, categories=dict(_t_categories()))
|
||||
|
||||
|
||||
@equipment_bp.route("/<int:item_id>/edit", methods=["GET", "POST"])
|
||||
@@ -115,12 +119,12 @@ def edit(item_id: int):
|
||||
if error:
|
||||
flash(error, "error")
|
||||
return render_template("equipment/form.html", item=item,
|
||||
categories=CATEGORIES, prefill=request.form)
|
||||
categories=_t_categories(), prefill=request.form)
|
||||
_handle_photo(item, is_new=False)
|
||||
db.session.commit()
|
||||
flash(_("'%(name)s' updated.", name=item.name), "success")
|
||||
return redirect(url_for("equipment.detail", item_id=item.id))
|
||||
return render_template("equipment/form.html", item=item, categories=CATEGORIES)
|
||||
return render_template("equipment/form.html", item=item, categories=_t_categories())
|
||||
|
||||
|
||||
@equipment_bp.route("/<int:item_id>/delete", methods=["POST"])
|
||||
|
||||
@@ -64,6 +64,23 @@ WEATHER_CONDITIONS = [
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translation helpers (call at request time, not module level)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _t_weather():
|
||||
return [(v, _(l)) for v, l in WEATHER_CONDITIONS]
|
||||
|
||||
def _t_positions(pairs):
|
||||
return [(v, _(l)) for v, l in pairs]
|
||||
|
||||
def _t_session_types():
|
||||
return [(s, _(n), _(d)) for s, n, d in SESSION_TYPES]
|
||||
|
||||
def _t_prs_positions():
|
||||
return [(v, _(l)) for v, l in PRS_STAGE_POSITIONS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,10 +189,10 @@ def new():
|
||||
flash(error, "error")
|
||||
return render_template("sessions/form.html", session=None,
|
||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||
weather_conditions=WEATHER_CONDITIONS,
|
||||
session_types=SESSION_TYPES,
|
||||
long_range_positions=LONG_RANGE_POSITIONS,
|
||||
pistol_25m_positions=PISTOL_25M_POSITIONS,
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||
prefill=request.form)
|
||||
db.session.commit()
|
||||
flash(_("Session saved."), "success")
|
||||
@@ -186,15 +203,15 @@ def new():
|
||||
prefill_distance = _FIXED_DISTANCES.get(selected_type)
|
||||
return render_template("sessions/form.html", session=None,
|
||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||
weather_conditions=WEATHER_CONDITIONS,
|
||||
session_types=SESSION_TYPES,
|
||||
long_range_positions=LONG_RANGE_POSITIONS,
|
||||
pistol_25m_positions=PISTOL_25M_POSITIONS,
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||
selected_type=selected_type,
|
||||
prefill_distance=prefill_distance,
|
||||
today=date.today().isoformat())
|
||||
# Step 1: show type picker
|
||||
return render_template("sessions/type_picker.html", session_types=SESSION_TYPES)
|
||||
return render_template("sessions/type_picker.html", session_types=_t_session_types())
|
||||
|
||||
|
||||
@sessions_bp.route("/<int:session_id>")
|
||||
@@ -217,15 +234,20 @@ def detail(session_id: int):
|
||||
from analyzer.charts import render_group_charts, render_overview_chart
|
||||
|
||||
storage_root = current_app.config["STORAGE_ROOT"]
|
||||
prs_positions = _t_prs_positions()
|
||||
analyses_display = []
|
||||
for a in analyses:
|
||||
csv_path = Path(storage_root) / a.csv_path
|
||||
if csv_path.exists():
|
||||
try:
|
||||
df = parse_csv(io.BytesIO(csv_path.read_bytes()))
|
||||
factor = a.grouping_outlier_factor if a.grouping_outlier_factor is not None else OUTLIER_FACTOR
|
||||
splits = a.grouping_manual_splits or None
|
||||
groups = detect_groups(df, outlier_factor=factor, manual_splits=splits)
|
||||
if a.grouping_outlier_factor is None and splits is not None:
|
||||
# Forced mode: user defined exact split positions
|
||||
groups = detect_groups(df, forced_splits=splits)
|
||||
else:
|
||||
factor = a.grouping_outlier_factor if a.grouping_outlier_factor is not None else OUTLIER_FACTOR
|
||||
groups = detect_groups(df, outlier_factor=factor, manual_splits=splits)
|
||||
overall = compute_overall_stats(df)
|
||||
group_stats = compute_group_stats(groups)
|
||||
# Merge stored notes into freshly computed stats
|
||||
@@ -238,14 +260,21 @@ def detail(session_id: int):
|
||||
y_max=overall["max_speed"])
|
||||
overview_chart = render_overview_chart(group_stats)
|
||||
groups_display = list(zip(group_stats, charts))
|
||||
analyses_display.append((a, groups_display, overview_chart))
|
||||
# Compute split positions from cumulative group counts (for visual editor)
|
||||
cumulative = 0
|
||||
split_positions = []
|
||||
for gs, _chart in groups_display[:-1]:
|
||||
cumulative += gs["count"]
|
||||
split_positions.append(cumulative)
|
||||
analyses_display.append((a, groups_display, overview_chart, split_positions))
|
||||
except Exception:
|
||||
analyses_display.append((a, None, None))
|
||||
analyses_display.append((a, None, None, []))
|
||||
else:
|
||||
analyses_display.append((a, None, None))
|
||||
analyses_display.append((a, None, None, []))
|
||||
|
||||
return render_template("sessions/detail.html", session=s,
|
||||
analyses=analyses, analyses_display=analyses_display,
|
||||
prs_positions=prs_positions,
|
||||
is_owner=is_owner)
|
||||
|
||||
|
||||
@@ -259,10 +288,10 @@ def edit(session_id: int):
|
||||
flash(error, "error")
|
||||
return render_template("sessions/form.html", session=s,
|
||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||
weather_conditions=WEATHER_CONDITIONS,
|
||||
session_types=SESSION_TYPES,
|
||||
long_range_positions=LONG_RANGE_POSITIONS,
|
||||
pistol_25m_positions=PISTOL_25M_POSITIONS,
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
|
||||
prefill=request.form)
|
||||
for analysis in s.analyses:
|
||||
analysis.is_public = s.is_public
|
||||
@@ -271,10 +300,10 @@ def edit(session_id: int):
|
||||
return redirect(url_for("sessions.detail", session_id=s.id))
|
||||
return render_template("sessions/form.html", session=s,
|
||||
rifles=_user_rifles(), scopes=_user_scopes(),
|
||||
weather_conditions=WEATHER_CONDITIONS,
|
||||
session_types=SESSION_TYPES,
|
||||
long_range_positions=LONG_RANGE_POSITIONS,
|
||||
pistol_25m_positions=PISTOL_25M_POSITIONS)
|
||||
weather_conditions=_t_weather(),
|
||||
session_types=_t_session_types(),
|
||||
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
|
||||
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS))
|
||||
|
||||
|
||||
@sessions_bp.route("/<int:session_id>/delete", methods=["POST"])
|
||||
|
||||
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")
|
||||
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,21 @@ 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)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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,8 +494,10 @@
|
||||
{# --- Per-group cards --- #}
|
||||
{% if groups_display %}
|
||||
{% for gs, chart in groups_display %}
|
||||
<div class="group-section" style="margin-bottom:1rem;">
|
||||
<div class="group-meta">
|
||||
{% set grp_idx = loop.index0 %}
|
||||
{% set grp_photos = a.group_photos | selectattr('group_index', 'equalto', grp_idx) | list %}
|
||||
<div class="group-section" style="margin-bottom:1.25rem;border:1px solid #eee;border-radius:6px;padding:.9rem 1rem;">
|
||||
<div class="group-meta" style="margin-bottom:.6rem;">
|
||||
<strong>{{ _('Group %(n)s', n=loop.index) }}</strong>
|
||||
· {{ gs.count }} {{ _('shots') }}
|
||||
· {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
|
||||
@@ -517,24 +511,75 @@
|
||||
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_owner %}
|
||||
<details style="margin-top:.75rem;">
|
||||
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline;">
|
||||
✎ {{ _('Note') }}
|
||||
</summary>
|
||||
<form method="post"
|
||||
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=loop.index0) }}"
|
||||
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
|
||||
<textarea name="note" rows="2"
|
||||
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:520px;">{{ gs.note or '' }}</textarea>
|
||||
<div>
|
||||
{# Group photos #}
|
||||
{% if grp_photos %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;">
|
||||
{% for gp in grp_photos %}
|
||||
<div>
|
||||
<img src="{{ gp.photo_url }}" alt="{{ gp.caption or '' }}"
|
||||
style="height:120px;width:auto;border-radius:5px;object-fit:cover;display:block;">
|
||||
{% if gp.caption %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% 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"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .85rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Save note') }}
|
||||
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:3px;padding:.1rem .4rem;font-size:0.75rem;cursor:pointer;">
|
||||
{{ _('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</form>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -542,45 +587,101 @@
|
||||
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# --- Re-group panel (owner only) --- #}
|
||||
{% if is_owner %}
|
||||
{# --- Visual Group Editor (owner only) --- #}
|
||||
{% if is_owner and groups_display %}
|
||||
<details style="margin-top:1rem;border-top:1px solid #e8e8e8;padding-top:.9rem;">
|
||||
<summary style="font-size:0.85rem;color:#888;cursor:pointer;list-style:none;">
|
||||
⚙ {{ _('Re-group settings') }}
|
||||
⚙ {{ _('Edit groups') }}
|
||||
</summary>
|
||||
<form method="post" action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
||||
style="margin-top:.75rem;display:flex;flex-direction:column;gap:.75rem;max-width:480px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
||||
{{ _('Outlier factor:') }}
|
||||
<span id="factor_val_{{ a.id }}">{{ a.grouping_outlier_factor or 5 }}</span>
|
||||
</label>
|
||||
<input type="range" name="outlier_factor" min="1" max="20" step="0.5"
|
||||
value="{{ a.grouping_outlier_factor or 5 }}"
|
||||
style="width:100%;"
|
||||
oninput="document.getElementById('factor_val_{{ a.id }}').textContent=this.value">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:#aaa;">
|
||||
<span>{{ _('1 (fine)') }}</span><span>{{ _('20 (coarse)') }}</span>
|
||||
</div>
|
||||
<div style="margin-top:.75rem;">
|
||||
<div style="font-size:0.8rem;color:#aaa;margin-bottom:.6rem;">
|
||||
{{ _('Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint.') }}
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
||||
{{ _('Manual split indices (JSON array, e.g. [5, 12])') }}
|
||||
</label>
|
||||
<input type="text" name="manual_splits"
|
||||
value="{{ (a.grouping_manual_splits | tojson) if a.grouping_manual_splits else '' }}"
|
||||
placeholder="e.g. [5, 12]"
|
||||
style="width:100%;padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
||||
<div style="font-size:0.75rem;color:#aaa;margin-top:.2rem;">{{ _('Shot positions (0-based) where a new group should always begin.') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="grp-editor-{{ a.id }}" style="display:flex;align-items:stretch;flex-wrap:wrap;gap:0;user-select:none;"></div>
|
||||
<form id="regroup-form-{{ a.id }}" method="post"
|
||||
action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
||||
style="margin-top:.75rem;display:flex;gap:.6rem;align-items:center;">
|
||||
<input type="hidden" name="forced_splits" id="forced-splits-{{ a.id }}">
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.45rem 1.1rem;font-size:0.88rem;cursor:pointer;">
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem 1rem;font-size:0.85rem;cursor:pointer;">
|
||||
{{ _('Apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<button type="button" onclick="grpReset{{ a.id }}()"
|
||||
style="background:none;color:#666;border:1px solid #ddd;border-radius:4px;padding:.4rem .75rem;font-size:0.85rem;cursor:pointer;">
|
||||
{{ _('Reset') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var AID = {{ a.id }};
|
||||
var TOTAL = {{ a.shot_count }};
|
||||
var INIT_SPLITS = {{ split_positions | tojson }};
|
||||
var splits = INIT_SPLITS.slice();
|
||||
var COLORS = ['#dbeafe','#dcfce7','#fef9c3','#fce7f3','#ede9fe','#ccfbf1','#ffedd5'];
|
||||
var S_SHOTS = {{ _('shots') | tojson }};
|
||||
var S_MERGE = {{ _('Merge') | tojson }};
|
||||
var S_SPLIT = {{ _('Split') | tojson }};
|
||||
|
||||
function render() {
|
||||
var el = document.getElementById('grp-editor-' + AID);
|
||||
el.innerHTML = '';
|
||||
var bounds = [0].concat(splits).concat([TOTAL]);
|
||||
for (var i = 0; i < bounds.length - 1; i++) {
|
||||
var start = bounds[i], end = bounds[i+1], cnt = end - start;
|
||||
var pct = Math.max(8, Math.round(cnt / TOTAL * 100));
|
||||
var color = COLORS[i % COLORS.length];
|
||||
|
||||
var box = document.createElement('div');
|
||||
box.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;justify-content:center;' +
|
||||
'width:' + pct + '%;min-width:56px;background:' + color + ';border:1px solid #ccc;border-radius:4px;' +
|
||||
'padding:.4rem .3rem;text-align:center;font-size:0.78rem;box-sizing:border-box;';
|
||||
box.innerHTML = '<strong style="font-size:.82rem;">' + (i+1) + '</strong>' +
|
||||
'<span style="color:#555;">' + cnt + ' ' + S_SHOTS + '</span>';
|
||||
|
||||
if (cnt >= 2) {
|
||||
var mid = start + Math.floor(cnt / 2);
|
||||
var sb = document.createElement('button');
|
||||
sb.type = 'button'; sb.title = S_SPLIT; sb.textContent = '⊣⊢';
|
||||
sb.style.cssText = 'margin-top:.3rem;font-size:.7rem;padding:.1rem .3rem;border:1px dashed #888;border-radius:3px;background:rgba(255,255,255,.6);cursor:pointer;';
|
||||
(function(m){ sb.onclick = function(){ addSplit(m); }; })(mid);
|
||||
box.appendChild(sb);
|
||||
}
|
||||
el.appendChild(box);
|
||||
|
||||
if (i < bounds.length - 2) {
|
||||
var sep = document.createElement('div');
|
||||
sep.style.cssText = 'display:inline-flex;align-items:center;padding:0 .15rem;';
|
||||
var mb = document.createElement('button');
|
||||
mb.type = 'button'; mb.title = S_MERGE; mb.textContent = '✕';
|
||||
mb.style.cssText = 'border:none;background:none;color:#c0392b;font-size:.9rem;cursor:pointer;padding:.15rem .3rem;';
|
||||
var capSplit = bounds[i+1];
|
||||
(function(sp){ mb.onclick = function(){ removeSplit(sp); }; })(capSplit);
|
||||
sep.appendChild(mb);
|
||||
el.appendChild(sep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSplit(pos) {
|
||||
if (splits.indexOf(pos) === -1) { splits.push(pos); splits.sort(function(a,b){return a-b;}); }
|
||||
render();
|
||||
}
|
||||
function removeSplit(pos) {
|
||||
splits = splits.filter(function(s){ return s !== pos; });
|
||||
render();
|
||||
}
|
||||
window['grpReset' + AID] = function() { splits = INIT_SPLITS.slice(); render(); };
|
||||
|
||||
document.getElementById('regroup-form-' + AID).addEventListener('submit', function() {
|
||||
document.getElementById('forced-splits-' + AID).value = JSON.stringify(splits);
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
Binary file not shown.
@@ -717,3 +717,87 @@ msgstr "Benutzer %(email)s gelöscht."
|
||||
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
|
||||
msgid "— select —"
|
||||
msgstr "— auswählen —"
|
||||
|
||||
msgid "Prone"
|
||||
msgstr "Liegend"
|
||||
|
||||
msgid "Bench rest"
|
||||
msgstr "Auflage"
|
||||
|
||||
msgid "Standing"
|
||||
msgstr "Stehend"
|
||||
|
||||
msgid "Standing with support"
|
||||
msgstr "Stehend mit Stütze"
|
||||
|
||||
msgid "Kneeling"
|
||||
msgstr "Kniend"
|
||||
|
||||
msgid "Sitting"
|
||||
msgstr "Sitzend"
|
||||
|
||||
msgid "Sitting with support"
|
||||
msgstr "Sitzend mit Stütze"
|
||||
|
||||
msgid "Barricade"
|
||||
msgstr "Barrikade"
|
||||
|
||||
msgid "Rooftop"
|
||||
msgstr "Dach"
|
||||
|
||||
msgid "Variable"
|
||||
msgstr "Variabel"
|
||||
|
||||
msgid "Sunny"
|
||||
msgstr "Sonnig"
|
||||
|
||||
msgid "Partly cloudy"
|
||||
msgstr "Teilweise bewölkt"
|
||||
|
||||
msgid "Overcast"
|
||||
msgstr "Bedeckt"
|
||||
|
||||
msgid "Rain"
|
||||
msgstr "Regen"
|
||||
|
||||
msgid "Wind"
|
||||
msgstr "Wind"
|
||||
|
||||
msgid "Snow"
|
||||
msgstr "Schnee"
|
||||
|
||||
msgid "Fog"
|
||||
msgstr "Nebel"
|
||||
|
||||
msgid "Rifle"
|
||||
msgstr "Gewehr"
|
||||
|
||||
msgid "Handgun"
|
||||
msgstr "Pistole"
|
||||
|
||||
msgid "Other"
|
||||
msgstr "Sonstiges"
|
||||
|
||||
msgid "Edit groups"
|
||||
msgstr "Gruppen bearbeiten"
|
||||
|
||||
msgid "Merge"
|
||||
msgstr "Zusammenführen"
|
||||
|
||||
msgid "Split"
|
||||
msgstr "Aufteilen"
|
||||
|
||||
msgid "Reset"
|
||||
msgstr "Zurücksetzen"
|
||||
|
||||
msgid "Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint."
|
||||
msgstr "Klicken Sie auf ✕ zwischen Gruppen zum Zusammenführen. Klicken Sie auf ⊣⊢ in einer Gruppe zum Aufteilen."
|
||||
|
||||
msgid "No photo selected."
|
||||
msgstr "Kein Foto ausgewählt."
|
||||
|
||||
msgid "Photo added."
|
||||
msgstr "Foto hinzugefügt."
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -717,3 +717,87 @@ msgstr "Utilisateur %(email)s supprimé."
|
||||
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
|
||||
msgid "— select —"
|
||||
msgstr "— choisir —"
|
||||
|
||||
msgid "Prone"
|
||||
msgstr "Couché"
|
||||
|
||||
msgid "Bench rest"
|
||||
msgstr "Banc de tir"
|
||||
|
||||
msgid "Standing"
|
||||
msgstr "Debout"
|
||||
|
||||
msgid "Standing with support"
|
||||
msgstr "Debout avec appui"
|
||||
|
||||
msgid "Kneeling"
|
||||
msgstr "Agenouillé"
|
||||
|
||||
msgid "Sitting"
|
||||
msgstr "Assis"
|
||||
|
||||
msgid "Sitting with support"
|
||||
msgstr "Assis avec appui"
|
||||
|
||||
msgid "Barricade"
|
||||
msgstr "Barricade"
|
||||
|
||||
msgid "Rooftop"
|
||||
msgstr "Toit"
|
||||
|
||||
msgid "Variable"
|
||||
msgstr "Variable"
|
||||
|
||||
msgid "Sunny"
|
||||
msgstr "Ensoleillé"
|
||||
|
||||
msgid "Partly cloudy"
|
||||
msgstr "Partiellement nuageux"
|
||||
|
||||
msgid "Overcast"
|
||||
msgstr "Couvert"
|
||||
|
||||
msgid "Rain"
|
||||
msgstr "Pluie"
|
||||
|
||||
msgid "Wind"
|
||||
msgstr "Vent"
|
||||
|
||||
msgid "Snow"
|
||||
msgstr "Neige"
|
||||
|
||||
msgid "Fog"
|
||||
msgstr "Brouillard"
|
||||
|
||||
msgid "Rifle"
|
||||
msgstr "Carabine"
|
||||
|
||||
msgid "Handgun"
|
||||
msgstr "Pistolet"
|
||||
|
||||
msgid "Other"
|
||||
msgstr "Autre"
|
||||
|
||||
msgid "Edit groups"
|
||||
msgstr "Modifier les groupes"
|
||||
|
||||
msgid "Merge"
|
||||
msgstr "Fusionner"
|
||||
|
||||
msgid "Split"
|
||||
msgstr "Diviser"
|
||||
|
||||
msgid "Reset"
|
||||
msgstr "Réinitialiser"
|
||||
|
||||
msgid "Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint."
|
||||
msgstr "Cliquez sur ✕ entre les groupes pour les fusionner. Cliquez sur ⊣⊢ dans un groupe pour le diviser au milieu."
|
||||
|
||||
msgid "No photo selected."
|
||||
msgstr "Aucune photo sélectionnée."
|
||||
|
||||
msgid "Photo added."
|
||||
msgstr "Photo ajoutée."
|
||||
|
||||
Reference in New Issue
Block a user