diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 4f933f0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,86 +0,0 @@ -# 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) diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a8b8140..0000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -RUN pybabel compile -d translations 2>/dev/null || true -RUN chmod +x entrypoint.sh - -ENV FLASK_APP=app - -EXPOSE 5000 - -ENTRYPOINT ["./entrypoint.sh"] diff --git a/analyzer/__init__.py b/analyzer/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/analyzer/__pycache__/__init__.cpython-311.pyc b/analyzer/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 1cd0667..0000000 Binary files a/analyzer/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/analyzer/__pycache__/charts.cpython-311.pyc b/analyzer/__pycache__/charts.cpython-311.pyc deleted file mode 100644 index f107dd8..0000000 Binary files a/analyzer/__pycache__/charts.cpython-311.pyc and /dev/null differ diff --git a/analyzer/__pycache__/grouper.cpython-311.pyc b/analyzer/__pycache__/grouper.cpython-311.pyc deleted file mode 100644 index 62aa549..0000000 Binary files a/analyzer/__pycache__/grouper.cpython-311.pyc and /dev/null differ diff --git a/analyzer/__pycache__/parser.cpython-311.pyc b/analyzer/__pycache__/parser.cpython-311.pyc deleted file mode 100644 index 0bd1e79..0000000 Binary files a/analyzer/__pycache__/parser.cpython-311.pyc and /dev/null differ diff --git a/analyzer/__pycache__/pdf_report.cpython-311.pyc b/analyzer/__pycache__/pdf_report.cpython-311.pyc deleted file mode 100644 index 59fa01e..0000000 Binary files a/analyzer/__pycache__/pdf_report.cpython-311.pyc and /dev/null differ diff --git a/analyzer/__pycache__/stats.cpython-311.pyc b/analyzer/__pycache__/stats.cpython-311.pyc deleted file mode 100644 index e4d5237..0000000 Binary files a/analyzer/__pycache__/stats.cpython-311.pyc and /dev/null differ diff --git a/analyzer/charts.py b/analyzer/charts.py deleted file mode 100644 index d029515..0000000 --- a/analyzer/charts.py +++ /dev/null @@ -1,82 +0,0 @@ -import io -import base64 -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -import pandas as pd - - -def render_group_charts(groups: list, y_min: float, y_max: float) -> list: - padding_fraction = 0.05 - y_range = y_max - y_min - if y_range == 0: - y_pad = 1.0 - else: - y_pad = y_range * padding_fraction - - charts = [] - for i, g in enumerate(groups): - fig, ax = plt.subplots(figsize=(9, 4)) - - x = g["time"] - y = g["speed"] - - ax.plot(x, y, marker="o", linewidth=1.5, markersize=5, color="#1f77b4") - - ax.set_ylim(y_min - y_pad, y_max + y_pad) - - ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) - fig.autofmt_xdate(rotation=30) - - ax.set_title(f"Group {i + 1} — {len(g)} shot(s)") - ax.set_xlabel("Time of Day") - ax.set_ylabel("Speed") - ax.grid(True, alpha=0.3) - fig.tight_layout() - - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=100) - plt.close(fig) - buf.seek(0) - charts.append(base64.b64encode(buf.read()).decode("utf-8")) - - return charts - - -def render_overview_chart(group_stats: list) -> str: - """Dual-axis line chart: avg speed and avg std dev per group.""" - indices = [s["group_index"] for s in group_stats] - speeds = [s["mean_speed"] for s in group_stats] - stds = [s["std_speed"] if s["std_speed"] is not None else 0.0 for s in group_stats] - - fig, ax1 = plt.subplots(figsize=(7, 3)) - - color_speed = "#1f77b4" - color_std = "#d62728" - - ax1.plot(indices, speeds, marker="o", linewidth=1.8, markersize=5, - color=color_speed, label="Avg speed") - ax1.set_xlabel("Group") - ax1.set_ylabel("Avg speed", color=color_speed) - ax1.tick_params(axis="y", labelcolor=color_speed) - ax1.set_xticks(indices) - - ax2 = ax1.twinx() - ax2.plot(indices, stds, marker="s", linewidth=1.8, markersize=5, - color=color_std, linestyle="--", label="Avg std dev") - ax2.set_ylabel("Avg std dev", color=color_std) - ax2.tick_params(axis="y", labelcolor=color_std) - - lines1, labels1 = ax1.get_legend_handles_labels() - lines2, labels2 = ax2.get_legend_handles_labels() - ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=8, loc="upper right") - - ax1.grid(True, alpha=0.3) - fig.tight_layout() - - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=100) - plt.close(fig) - buf.seek(0) - return base64.b64encode(buf.read()).decode("utf-8") diff --git a/analyzer/dope_card.py b/analyzer/dope_card.py deleted file mode 100644 index a23e555..0000000 --- a/analyzer/dope_card.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Generate a printable PRS dope card as PDF (A4 portrait).""" - -from fpdf import FPDF - - -# Column widths in mm -_W = { - "num": 10, - "name": 28, - "dist": 20, - "time": 18, - "pos": 26, - "dope_e": 30, - "dope_w": 30, - "hits": 24, - "notes": 0, # fills remaining width -} -_ROW_H = 8 -_HEAD_H = 9 -_DARK = (26, 26, 46) # #1a1a2e -_LIGHT = (240, 244, 255) # #f0f4ff -_GRID = (200, 210, 230) - - -def _notes_w(epw: float) -> float: - fixed = sum(v for k, v in _W.items() if k != "notes") - return max(0, epw - fixed) - - -def generate_dope_card(session, stages: list) -> bytes: - pdf = FPDF(orientation="P", unit="mm", format="A4") - pdf.set_auto_page_break(auto=True, margin=15) - pdf.add_page() - pdf.set_margins(10, 12, 10) - - epw = pdf.w - pdf.l_margin - pdf.r_margin - nw = _notes_w(epw) - - # ── Header ────────────────────────────────────────────────────────────── - pdf.set_font("Helvetica", "B", 18) - pdf.set_text_color(*_DARK) - pdf.cell(0, 10, "FICHE DE TIR — PRS", new_x="LMARGIN", new_y="NEXT", align="C") - - pdf.set_font("Helvetica", "", 9) - pdf.set_text_color(80, 80, 80) - parts = [session.session_date.strftime("%d/%m/%Y")] - if session.location_name: - parts.append(session.location_name) - if session.rifle: - parts.append(session.rifle.name) - if session.rifle.caliber: - parts.append(session.rifle.caliber) - if session.ammo_brand: - parts.append(session.ammo_brand) - if session.ammo_weight_gr: - parts.append(f"{session.ammo_weight_gr} gr") - pdf.cell(0, 5, " | ".join(parts), new_x="LMARGIN", new_y="NEXT", align="C") - pdf.ln(4) - - # ── Column headers ─────────────────────────────────────────────────────── - pdf.set_fill_color(*_DARK) - pdf.set_text_color(255, 255, 255) - pdf.set_font("Helvetica", "B", 8) - - headers = [ - ("N°", _W["num"]), - ("Nom", _W["name"]), - ("Dist.(m)", _W["dist"]), - ("Temps(s)", _W["time"]), - ("Position", _W["pos"]), - ("Dope Élév.", _W["dope_e"]), - ("Dope Dérive", _W["dope_w"]), - ("Coups/Poss.", _W["hits"]), - ("Notes", nw), - ] - for label, w in headers: - pdf.cell(w, _HEAD_H, label, border=0, fill=True, align="C") - pdf.ln() - - # ── Stage rows ─────────────────────────────────────────────────────────── - pdf.set_text_color(30, 30, 30) - pdf.set_font("Helvetica", "", 9) - - for i, st in enumerate(stages): - fill = i % 2 == 0 - pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255))) - pdf.set_draw_color(*_GRID) - - hits_str = "" - if st.get("hits") is not None: - hits_str = str(st["hits"]) - if st.get("possible"): - hits_str += f"/{st['possible']}" - elif st.get("possible"): - hits_str = f"—/{st['possible']}" - - row = [ - (str(st.get("num", i + 1)), _W["num"], "C"), - (st.get("name") or "", _W["name"], "L"), - (str(st.get("distance_m") or ""), _W["dist"], "C"), - (str(st.get("time_s") or ""), _W["time"], "C"), - (_pos_label(st.get("position", "")), _W["pos"], "L"), - (st.get("dope_elevation") or "", _W["dope_e"], "C"), - (st.get("dope_windage") or "", _W["dope_w"], "C"), - (hits_str, _W["hits"], "C"), - (st.get("notes") or "", nw, "L"), - ] - for val, w, align in row: - pdf.cell(w, _ROW_H, val, border="B", fill=fill, align=align) - pdf.ln() - - # ── Blank rows for hand-written stages ────────────────────────────────── - spare = max(0, 10 - len(stages)) - for i in range(min(spare, 5)): - fill = (len(stages) + i) % 2 == 0 - pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255))) - for _, w, _ in row: # reuse last row widths - pdf.cell(w, _ROW_H, "", border="B", fill=fill) - pdf.ln() - - # ── Footer ─────────────────────────────────────────────────────────────── - pdf.ln(4) - pdf.set_font("Helvetica", "I", 7) - pdf.set_text_color(160, 160, 160) - pdf.cell(0, 5, "The Shooter's Network — fiche générée automatiquement", - new_x="LMARGIN", new_y="NEXT", align="C") - - return bytes(pdf.output()) - - -_POSITION_LABELS = { - "prone": "Couché", - "standing": "Debout", - "kneeling": "Agenouillé", - "sitting": "Assis", - "barricade": "Barricade", - "rooftop": "Toit", - "unknown": "Variable", -} - - -def _pos_label(slug: str) -> str: - return _POSITION_LABELS.get(slug, slug.replace("_", " ").title() if slug else "") diff --git a/analyzer/grouper.py b/analyzer/grouper.py deleted file mode 100644 index baaeb26..0000000 --- a/analyzer/grouper.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import timedelta -import pandas as pd - -OUTLIER_FACTOR = 5 - - -def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR, - manual_splits: list | None = None, - forced_splits: list | None = None) -> list: - """Split shots into groups. - - 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() - - if diffs.empty: - return [df] - - median_gap = diffs.median() - - # Auto-detect splits based on time gaps - auto_splits: set[int] = set() - if median_gap != timedelta(0): - threshold = outlier_factor * median_gap - for idx, gap in diffs.items(): - if gap > threshold: - pos = df.index.get_loc(idx) - auto_splits.add(pos) - - # Merge with manual splits (filter to valid range) - extra = set(manual_splits) if manual_splits else set() - return _build_groups(sorted(auto_splits | extra)) diff --git a/analyzer/parser.py b/analyzer/parser.py deleted file mode 100644 index 681286e..0000000 --- a/analyzer/parser.py +++ /dev/null @@ -1,107 +0,0 @@ -import csv -import io -import pandas as pd -from datetime import datetime, timedelta - -CANONICAL_COLS = ["idx", "speed", "std_dev", "energy", "power_factor", "time"] -TIME_FORMATS = ["%H:%M:%S.%f", "%H:%M:%S", "%H:%M:%S,%f"] - - -def parse_csv(stream) -> pd.DataFrame: - raw = stream.read() - if isinstance(raw, bytes): - raw = raw.decode("utf-8-sig") - # Strip BOM characters that may appear anywhere in the file - raw = raw.replace("\ufeff", "") - - data_rows = [] - for line in raw.splitlines(): - fields = _split_line(line) - if len(fields) >= 6 and _is_index(fields[0]) and _is_time(fields[5]): - data_rows.append(fields[:6]) - - if len(data_rows) < 2: - raise ValueError( - "Could not find valid data rows in the CSV. " - "Expected rows with: integer index, 4 numeric values, and a time (HH:MM:SS)." - ) - - df = pd.DataFrame(data_rows, columns=CANONICAL_COLS) - - for col in ("speed", "std_dev", "energy", "power_factor"): - df[col] = _parse_numeric(df[col]) - - df["time"] = _parse_time_column(df["time"]) - df = df.sort_values("time").reset_index(drop=True) - return df[["speed", "std_dev", "energy", "power_factor", "time"]] - - -def _split_line(line: str) -> list: - """Parse one CSV line, respecting quoted fields.""" - for row in csv.reader([line], quotechar='"', doublequote=True, skipinitialspace=True): - return [f.strip() for f in row] - return [] - - -def _is_index(val: str) -> bool: - """True if the value is a non-negative integer (auto-increment row index).""" - try: - return int(val.strip()) >= 0 - except (ValueError, AttributeError): - return False - - -def _is_time(val: str) -> bool: - """True if the value parses as HH:MM:SS or HH:MM:SS.fff.""" - cleaned = val.strip() - for fmt in TIME_FORMATS: - try: - datetime.strptime(cleaned, fmt) - return True - except ValueError: - continue - return False - - -def _parse_numeric(col: pd.Series) -> pd.Series: - """Parse a numeric column, accepting both '.' and ',' as decimal separator.""" - result = pd.to_numeric(col, errors="coerce") - if result.isna().any(): - result = pd.to_numeric( - col.astype(str).str.replace(",", ".", regex=False), - errors="coerce", - ) - if result.isna().any(): - bad = col[result.isna()].tolist() - raise ValueError(f"Non-numeric values in column: {bad}") - return result - - -def _parse_time_column(col: pd.Series) -> pd.Series: - today = datetime.today().date() - cleaned = col.astype(str).str.strip() - - parsed = None - for fmt in TIME_FORMATS: - candidate = pd.to_datetime(cleaned, format=fmt, errors="coerce") - if candidate.notna().all(): - parsed = candidate - break - - if parsed is None: - candidate = pd.to_datetime(cleaned, errors="coerce") - if candidate.notna().all(): - parsed = candidate - - if parsed is None: - raise ValueError( - "Could not parse time column. Expected format: HH:MM:SS or HH:MM:SS.fff" - ) - - parsed = parsed.apply(lambda t: datetime.combine(today, t.time())) - - times = parsed.tolist() - for i in range(1, len(times)): - if times[i] < times[i - 1]: - times[i] += timedelta(days=1) - return pd.Series(times, index=col.index) diff --git a/analyzer/pdf_report.py b/analyzer/pdf_report.py deleted file mode 100644 index 8d5f548..0000000 --- a/analyzer/pdf_report.py +++ /dev/null @@ -1,95 +0,0 @@ -import base64 -import io -from datetime import datetime - -from fpdf import FPDF - -_COL_LABEL = 80 -_COL_VALUE = 50 -_ROW_H = 7 - - -def generate_pdf(overall: dict, group_stats: list, charts: list, overview_chart: str) -> bytes: - pdf = FPDF() - pdf.set_auto_page_break(auto=True, margin=15) - - pdf.add_page() - _title_block(pdf) - _overall_section(pdf, overall, overview_chart) - - for stat, chart_b64 in zip(group_stats, charts): - _group_section(pdf, stat, chart_b64) - - return bytes(pdf.output()) - - -# --------------------------------------------------------------------------- - -def _title_block(pdf: FPDF): - pdf.set_font("Helvetica", "B", 18) - pdf.cell(0, 12, "Ballistic Analysis Report", new_x="LMARGIN", new_y="NEXT", align="C") - pdf.set_font("Helvetica", "", 9) - pdf.cell( - 0, 5, - f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", - new_x="LMARGIN", new_y="NEXT", align="C", - ) - pdf.ln(8) - - -def _overall_section(pdf: FPDF, overall: dict, overview_chart: str): - _section_heading(pdf, "Overall Statistics") - rows = [ - ("Total shots", str(overall["count"])), - ("Min speed", f"{overall['min_speed']:.4f}"), - ("Max speed", f"{overall['max_speed']:.4f}"), - ("Mean speed", f"{overall['mean_speed']:.4f}"), - ("Std dev (speed)", f"{overall['std_speed']:.4f}" if overall["std_speed"] is not None else "n/a"), - ] - _table(pdf, rows) - img_bytes = base64.b64decode(overview_chart) - pdf.image(io.BytesIO(img_bytes), x=pdf.l_margin, w=min(140, pdf.epw)) - pdf.ln(4) - - -def _group_section(pdf: FPDF, stat: dict, chart_b64: str): - pdf.ln(4) - heading = ( - f"Group {stat['group_index']} - " - f"{stat['time_start']} to {stat['time_end']} " - f"({stat['count']} shot(s))" - ) - _section_heading(pdf, heading) - - rows = [ - ("Min speed", f"{stat['min_speed']:.4f}"), - ("Max speed", f"{stat['max_speed']:.4f}"), - ("Mean speed", f"{stat['mean_speed']:.4f}"), - ("Std dev (speed)", f"{stat['std_speed']:.4f}" if stat["std_speed"] is not None else "n/a"), - ] - _table(pdf, rows) - - img_bytes = base64.b64decode(chart_b64) - # Check remaining page space; add new page if chart won't fit - if pdf.get_y() + 75 > pdf.page_break_trigger: - pdf.add_page() - pdf.image(io.BytesIO(img_bytes), x=pdf.l_margin, w=pdf.epw) - pdf.ln(4) - - -def _section_heading(pdf: FPDF, text: str): - pdf.set_font("Helvetica", "B", 12) - pdf.set_fill_color(230, 236, 255) - pdf.cell(0, 8, text, new_x="LMARGIN", new_y="NEXT", fill=True) - pdf.ln(2) - - -def _table(pdf: FPDF, rows: list): - for i, (label, value) in enumerate(rows): - fill = i % 2 == 0 - pdf.set_fill_color(248, 249, 252) if fill else pdf.set_fill_color(255, 255, 255) - pdf.set_font("Helvetica", "", 10) - pdf.cell(_COL_LABEL, _ROW_H, label, border=0, fill=fill) - pdf.set_font("Helvetica", "B", 10) - pdf.cell(_COL_VALUE, _ROW_H, value, border=0, fill=fill, new_x="LMARGIN", new_y="NEXT") - pdf.ln(3) diff --git a/analyzer/stats.py b/analyzer/stats.py deleted file mode 100644 index dd218d4..0000000 --- a/analyzer/stats.py +++ /dev/null @@ -1,30 +0,0 @@ -import pandas as pd - - -def compute_overall_stats(df: pd.DataFrame) -> dict: - s = df["speed"] - return { - "min_speed": s.min(), - "max_speed": s.max(), - "mean_speed": s.mean(), - "std_speed": s.std(ddof=1), - "count": len(df), - } - - -def compute_group_stats(groups: list) -> list: - result = [] - for i, g in enumerate(groups): - s = g["speed"] - std = s.std(ddof=1) if len(g) > 1 else None - result.append({ - "group_index": i + 1, - "count": len(g), - "min_speed": s.min(), - "max_speed": s.max(), - "mean_speed": s.mean(), - "std_speed": std, - "time_start": g["time"].min().strftime("%H:%M:%S"), - "time_end": g["time"].max().strftime("%H:%M:%S"), - }) - return result diff --git a/app.py b/app.py deleted file mode 100644 index 9eae118..0000000 --- a/app.py +++ /dev/null @@ -1,218 +0,0 @@ -import base64 -import io - -from flask import Flask, redirect, request, render_template, session as flask_session -from flask_login import current_user -from flask_wtf.csrf import CSRFProtect -from sqlalchemy import select - -from config import Config -from extensions import babel, db, jwt, login_manager, migrate, oauth - -csrf = CSRFProtect() - -SUPPORTED_LANGS = ["fr", "en", "de"] - - -def _select_locale(): - # 1. Explicit session override (set via flag switcher) - lang = flask_session.get("lang") - if lang in SUPPORTED_LANGS: - return lang - # 2. Authenticated user's stored preference - if current_user.is_authenticated and current_user.language in SUPPORTED_LANGS: - flask_session["lang"] = current_user.language - return current_user.language - # 3. Browser Accept-Language header - return request.accept_languages.best_match(SUPPORTED_LANGS) or "en" - - -def create_app(config_class=Config): - app = Flask(__name__) - app.config.from_object(config_class) - - db.init_app(app) - migrate.init_app(app, db) - login_manager.init_app(app) - jwt.init_app(app) - babel.init_app(app, locale_selector=_select_locale) - csrf.init_app(app) - - @app.context_processor - def inject_locale(): - from flask_babel import get_locale - return {"current_lang": str(get_locale())} - - @app.after_request - def set_security_headers(response): - response.headers.setdefault("X-Content-Type-Options", "nosniff") - response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") - response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") - return response - - - @jwt.unauthorized_loader - def unauthorized_callback(reason): - from flask import jsonify - return jsonify({"error": {"code": "UNAUTHORIZED", "message": reason}}), 401 - - @jwt.expired_token_loader - def expired_callback(jwt_header, jwt_payload): - from flask import jsonify - return jsonify({"error": {"code": "TOKEN_EXPIRED", "message": "Token has expired"}}), 401 - - @jwt.invalid_token_loader - def invalid_callback(reason): - from flask import jsonify - return jsonify({"error": {"code": "INVALID_TOKEN", "message": reason}}), 422 - - oauth.init_app(app) - oauth.register( - name="google", - client_id=app.config["GOOGLE_CLIENT_ID"], - client_secret=app.config["GOOGLE_CLIENT_SECRET"], - server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": "openid email profile"}, - ) - oauth.register( - name="github", - client_id=app.config["GITHUB_CLIENT_ID"], - client_secret=app.config["GITHUB_CLIENT_SECRET"], - access_token_url="https://github.com/login/oauth/access_token", - authorize_url="https://github.com/login/oauth/authorize", - api_base_url="https://api.github.com/", - client_kwargs={"scope": "user:email"}, - ) - - # Must import models after db is initialised so Alembic can detect them - from models import User # noqa: F401 - - @login_manager.user_loader - def load_user(user_id): - try: - return db.session.get(User, int(user_id)) - except (ValueError, TypeError): - return None - - from blueprints.admin import admin_bp - from blueprints.auth import auth_bp - from blueprints.dashboard import dashboard_bp - from blueprints.equipment import equipment_bp - from blueprints.sessions import sessions_bp - from blueprints.analyses import analyses_bp - app.register_blueprint(admin_bp) - app.register_blueprint(auth_bp) - app.register_blueprint(dashboard_bp) - app.register_blueprint(equipment_bp) - app.register_blueprint(sessions_bp) - app.register_blueprint(analyses_bp) - - from blueprints.api import api as api_bp - csrf.exempt(api_bp) - app.register_blueprint(api_bp) - - @app.route("/u/") - def public_profile(user_id: int): - from models import User, ShootingSession, EquipmentItem - from flask import abort, render_template - user = db.session.get(User, user_id) - if user is None: - abort(404) - public_sessions = db.session.scalars( - db.select(ShootingSession) - .filter_by(user_id=user.id, is_public=True) - .order_by(ShootingSession.session_date.desc()) - ).all() - equipment = None - if user.show_equipment_public: - equipment = db.session.scalars( - db.select(EquipmentItem) - .filter_by(user_id=user.id) - .order_by(EquipmentItem.category, EquipmentItem.name) - ).all() - return render_template("auth/public_profile.html", - profile_user=user, - public_sessions=public_sessions, - equipment=equipment) - - @app.route("/set-lang/") - def set_lang(lang: str): - if lang in SUPPORTED_LANGS: - flask_session["lang"] = lang - if current_user.is_authenticated: - current_user.language = lang - db.session.commit() - return redirect(request.referrer or "/") - - @app.route("/") - def index(): - from models import ShootingSession - public_sessions = db.session.scalars( - select(ShootingSession) - .where(ShootingSession.is_public == True) # noqa: E712 - .order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc()) - .limit(12) - ).all() - return render_template("index.html", public_sessions=public_sessions) - - @app.route("/tools/measure") - def tools_measure(): - return render_template("tools/measure.html") - - @app.route("/analyze", methods=["GET", "POST"]) - def analyze(): - from analyzer.parser import parse_csv - from analyzer.grouper import detect_groups - from analyzer.stats import compute_overall_stats, compute_group_stats - from analyzer.charts import render_group_charts, render_overview_chart - from analyzer.pdf_report import generate_pdf - - if request.method == "GET": - return render_template("upload.html") - - if "csv_file" not in request.files or request.files["csv_file"].filename == "": - return render_template("upload.html", error="No file selected.") - - file = request.files["csv_file"] - - try: - csv_bytes = file.read() - df = parse_csv(io.BytesIO(csv_bytes)) - groups = detect_groups(df) - overall = compute_overall_stats(df) - group_stats = compute_group_stats(groups) - charts = render_group_charts( - groups, - y_min=overall["min_speed"], - y_max=overall["max_speed"], - ) - overview_chart = render_overview_chart(group_stats) - except ValueError as e: - return render_template("upload.html", error=str(e)) - - pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) - pdf_b64 = base64.b64encode(pdf_bytes).decode("utf-8") - - saved_analysis_id = None - if current_user.is_authenticated: - from storage import save_analysis - saved_analysis_id = save_analysis( - user=current_user, - csv_bytes=csv_bytes, - pdf_bytes=pdf_bytes, - overall=overall, - group_stats=group_stats, - filename=file.filename or "upload.csv", - ) - - groups_display = list(zip(group_stats, charts)) - return render_template( - "results.html", - overall=overall, - groups_display=groups_display, - overview_chart=overview_chart, - pdf_b64=pdf_b64, - saved_analysis_id=saved_analysis_id, - ) - - return app diff --git a/babel.cfg b/babel.cfg deleted file mode 100644 index f0234b3..0000000 --- a/babel.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[python: **.py] -[jinja2: **/templates/**.html] -extensions=jinja2.ext.autoescape,jinja2.ext.with_ diff --git a/blueprints/__init__.py b/blueprints/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/blueprints/admin.py b/blueprints/admin.py deleted file mode 100644 index e8d6281..0000000 --- a/blueprints/admin.py +++ /dev/null @@ -1,100 +0,0 @@ -from flask import Blueprint, abort, flash, redirect, render_template, request, url_for -from flask_babel import _ -from flask_login import current_user, login_required - -from extensions import db -from models import User - -admin_bp = Blueprint("admin", __name__, url_prefix="/admin") - -ROLES = ["user", "admin"] - - -def _require_admin(): - if not current_user.is_authenticated or current_user.role != "admin": - abort(403) - - -# --------------------------------------------------------------------------- -# User list -# --------------------------------------------------------------------------- - -@admin_bp.route("/") -@login_required -def index(): - _require_admin() - users = db.session.scalars( - db.select(User).order_by(User.created_at.desc()) - ).all() - return render_template("admin/users.html", users=users, roles=ROLES) - - -# --------------------------------------------------------------------------- -# Change role -# --------------------------------------------------------------------------- - -@admin_bp.route("/users//role", methods=["POST"]) -@login_required -def change_role(user_id: int): - _require_admin() - user = db.session.get(User, user_id) - if user is None: - abort(404) - new_role = request.form.get("role", "user") - if new_role not in ROLES: - flash(_("Invalid role."), "error") - return redirect(url_for("admin.index")) - # Prevent removing the last admin - if user.role == "admin" and new_role != "admin": - admin_count = db.session.scalar( - db.select(db.func.count()).select_from(User).where(User.role == "admin") - ) - if admin_count <= 1: - flash(_("Cannot remove the last admin."), "error") - return redirect(url_for("admin.index")) - user.role = new_role - db.session.commit() - flash(_("Role updated for %(email)s.", email=user.email), "success") - return redirect(url_for("admin.index")) - - -# --------------------------------------------------------------------------- -# Reset password -# --------------------------------------------------------------------------- - -@admin_bp.route("/users//password", methods=["POST"]) -@login_required -def reset_password(user_id: int): - _require_admin() - user = db.session.get(User, user_id) - if user is None: - abort(404) - new_pw = request.form.get("new_password", "").strip() - if len(new_pw) < 8: - flash(_("Password must be at least 8 characters."), "error") - return redirect(url_for("admin.index")) - user.set_password(new_pw) - db.session.commit() - flash(_("Password reset for %(email)s.", email=user.email), "success") - return redirect(url_for("admin.index")) - - -# --------------------------------------------------------------------------- -# Delete user -# --------------------------------------------------------------------------- - -@admin_bp.route("/users//delete", methods=["POST"]) -@login_required -def delete_user(user_id: int): - _require_admin() - if user_id == current_user.id: - flash(_("You cannot delete your own account."), "error") - return redirect(url_for("admin.index")) - user = db.session.get(User, user_id) - if user is None: - abort(404) - email = user.email - db.session.delete(user) - db.session.commit() - flash(_("User %(email)s deleted.", email=email), "success") - return redirect(url_for("admin.index")) diff --git a/blueprints/analyses.py b/blueprints/analyses.py deleted file mode 100644 index 3cc77e0..0000000 --- a/blueprints/analyses.py +++ /dev/null @@ -1,372 +0,0 @@ -import io -import json -from pathlib import Path - -from flask import ( - Blueprint, abort, current_app, flash, jsonify, redirect, request, - render_template, send_from_directory, url_for, -) -from flask_babel import _ -from flask_login import current_user, login_required - -from extensions import db -from models import Analysis, AnalysisGroupPhoto - -analyses_bp = Blueprint("analyses", __name__, url_prefix="/analyses") - - -def _can_view(analysis: Analysis) -> bool: - if analysis.is_public: - return True - return current_user.is_authenticated and analysis.user_id == current_user.id - - -@analyses_bp.route("/") -def detail(analysis_id: int): - a = db.session.get(Analysis, analysis_id) - if a is None: - abort(404) - if not _can_view(a): - abort(403) - - # Re-generate charts from the stored CSV - storage_root = current_app.config["STORAGE_ROOT"] - csv_path = Path(storage_root) / a.csv_path - if not csv_path.exists(): - abort(410) # CSV was deleted - - from analyzer.parser import parse_csv - from analyzer.grouper import detect_groups - from analyzer.stats import compute_group_stats - from analyzer.charts import render_group_charts, render_overview_chart - - csv_bytes = csv_path.read_bytes() - df = parse_csv(io.BytesIO(csv_bytes)) - groups = detect_groups(df) - group_stats = compute_group_stats(groups) - charts = render_group_charts( - groups, - y_min=a.overall_stats["min_speed"], - y_max=a.overall_stats["max_speed"], - ) - overview_chart = render_overview_chart(group_stats) - - groups_display = list(zip(group_stats, charts)) - return render_template( - "analyses/detail.html", - analysis=a, - overall=a.overall_stats, - groups_display=groups_display, - overview_chart=overview_chart, - has_pdf=bool(a.pdf_path and (Path(storage_root) / a.pdf_path).exists()), - ) - - -@analyses_bp.route("//delete", methods=["POST"]) -@login_required -def delete(analysis_id: int): - a = db.session.get(Analysis, analysis_id) - if a is None: - abort(404) - if a.user_id != current_user.id: - abort(403) - - back = url_for("sessions.detail", session_id=a.session_id) if a.session_id \ - else url_for("dashboard.index") - - storage_root = current_app.config["STORAGE_ROOT"] - for path_attr in ("csv_path", "pdf_path"): - rel = getattr(a, path_attr, None) - if rel: - try: - (Path(storage_root) / rel).unlink(missing_ok=True) - except Exception: - pass - - db.session.delete(a) - db.session.commit() - flash(_("Analysis deleted."), "success") - return redirect(back) - - -@analyses_bp.route("//rename", methods=["POST"]) -@login_required -def rename(analysis_id: int): - a = db.session.get(Analysis, analysis_id) - if a is None: - abort(404) - if a.user_id != current_user.id: - abort(403) - title = request.form.get("title", "").strip() - if title: - a.title = title[:255] - db.session.commit() - flash(_("Title updated."), "success") - back = (url_for("sessions.detail", session_id=a.session_id) - if a.session_id else url_for("analyses.detail", analysis_id=a.id)) - return redirect(back) - - -@analyses_bp.route("//regroup", methods=["POST"]) -@login_required -def regroup(analysis_id: int): - a = db.session.get(Analysis, analysis_id) - if a is None: - abort(404) - if a.user_id != current_user.id: - abort(403) - - storage_root = current_app.config["STORAGE_ROOT"] - csv_path = Path(storage_root) / a.csv_path - if not csv_path.exists(): - abort(410) - - from analyzer.parser import parse_csv - from analyzer.grouper import detect_groups - from analyzer.stats import compute_overall_stats, compute_group_stats - from analyzer.charts import render_group_charts, render_overview_chart - from analyzer.pdf_report import generate_pdf - from storage import _to_python - - # 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) - - # Preserve existing notes - old_stats = a.group_stats or [] - for i, gs in enumerate(group_stats): - if i < len(old_stats) and old_stats[i].get("note"): - gs["note"] = old_stats[i]["note"] - - charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"]) - overview_chart = render_overview_chart(group_stats) - pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) - - if a.pdf_path: - pdf_path_obj = Path(storage_root) / a.pdf_path - try: - pdf_path_obj.write_bytes(pdf_bytes) - except Exception: - pass - - 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)) - a.group_count = len(group_stats) - db.session.commit() - - flash(_("Regrouped."), "success") - back = (url_for("sessions.detail", session_id=a.session_id) - if a.session_id else url_for("analyses.detail", analysis_id=a.id)) - return redirect(back) - - -@analyses_bp.route("//groups//note", methods=["POST"]) -@login_required -def save_group_note(analysis_id: int, group_index: int): - a = db.session.get(Analysis, analysis_id) - if a is None: - abort(404) - if a.user_id != current_user.id: - abort(403) - - note = request.form.get("note", "").strip() - group_stats = list(a.group_stats or []) - if 0 <= group_index < len(group_stats): - group_stats[group_index] = dict(group_stats[group_index]) - group_stats[group_index]["note"] = note or None - a.group_stats = group_stats - db.session.commit() - flash(_("Note saved."), "success") - - back = (url_for("sessions.detail", session_id=a.session_id) - if a.session_id else url_for("analyses.detail", analysis_id=a.id)) - return redirect(back) - - -@analyses_bp.route("//pdf") -def download_pdf(analysis_id: int): - a = db.session.get(Analysis, analysis_id) - if a is None: - abort(404) - if not _can_view(a): - abort(403) - if not a.pdf_path: - abort(404) - - storage_root = current_app.config["STORAGE_ROOT"] - pdf_dir = Path(storage_root) / Path(a.pdf_path).parent - 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("//groups//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//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//annotate", - methods=["GET", "POST"]) -@login_required -def annotate_group_photo(analysis_id: int, photo_id: int): - a = db.session.get(Analysis, analysis_id) - if a is None: - abort(404) - if a.user_id != current_user.id: - abort(403) - photo = db.session.get(AnalysisGroupPhoto, photo_id) - if photo is None or photo.analysis_id != analysis_id: - abort(404) - - if request.method == "POST": - data = request.get_json(force=True) - photo.annotations = data - db.session.commit() - return jsonify({"ok": True}) - - back = (url_for("sessions.detail", session_id=a.session_id) - if a.session_id else url_for("analyses.detail", analysis_id=a.id)) - - # Pre-fill shooting distance from session if available - session_dist_m = None - if a.session_id: - from models import ShootingSession - s = db.session.get(ShootingSession, a.session_id) - if s and s.distance_m: - session_dist_m = s.distance_m - - return render_template("analyses/annotate_group_photo.html", - analysis=a, photo=photo, - back_url=back, session_dist_m=session_dist_m) - - -@analyses_bp.route("/group-photos/") -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) diff --git a/blueprints/api/__init__.py b/blueprints/api/__init__.py deleted file mode 100644 index dfde527..0000000 --- a/blueprints/api/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import Blueprint - -from .auth import auth_bp -from .equipment import equipment_bp -from .sessions import sessions_bp -from .analyses import analyses_bp -from .feed import feed_bp - -api = Blueprint("api", __name__, url_prefix="/api/v1") -api.register_blueprint(auth_bp) -api.register_blueprint(equipment_bp) -api.register_blueprint(sessions_bp) -api.register_blueprint(analyses_bp) -api.register_blueprint(feed_bp) diff --git a/blueprints/api/analyses.py b/blueprints/api/analyses.py deleted file mode 100644 index 742b940..0000000 --- a/blueprints/api/analyses.py +++ /dev/null @@ -1,178 +0,0 @@ -import base64 -import io -from pathlib import Path - -from flask import Blueprint, current_app, request -from flask_jwt_extended import jwt_required -from sqlalchemy import func, select - -from extensions import db -from models import Analysis -from .utils import ( - created, err, no_content, ok, - current_api_user, serialize_analysis, -) - -analyses_bp = Blueprint("api_analyses", __name__, url_prefix="/analyses") - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _remove_analysis_files(analysis: Analysis, storage_root: str) -> None: - root = Path(storage_root) - for path_attr in ("csv_path", "pdf_path"): - rel = getattr(analysis, path_attr, None) - if rel: - try: - root.joinpath(rel).unlink(missing_ok=True) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# Routes -# --------------------------------------------------------------------------- - -@analyses_bp.post("/upload") -@jwt_required(optional=True) -def upload(): - from analyzer.parser import parse_csv - from analyzer.grouper import detect_groups - from analyzer.stats import compute_overall_stats, compute_group_stats - from analyzer.charts import render_group_charts, render_overview_chart - from analyzer.pdf_report import generate_pdf - - file = request.files.get("csv_file") - if not file or not file.filename: - return err("No csv_file provided.", 400) - - try: - csv_bytes = file.read() - df = parse_csv(io.BytesIO(csv_bytes)) - groups = detect_groups(df) - overall = compute_overall_stats(df) - group_stats = compute_group_stats(groups) - charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"]) - overview_chart = render_overview_chart(group_stats) - pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) - except ValueError as e: - return err(str(e), 422) - - pdf_b64 = base64.b64encode(pdf_bytes).decode() - - saved_id = None - user = current_api_user() - if user: - from storage import save_analysis - saved_id = save_analysis( - user=user, - csv_bytes=csv_bytes, - pdf_bytes=pdf_bytes, - overall=overall, - group_stats=group_stats, - filename=file.filename or "upload.csv", - ) - - return ok({ - "overall_stats": overall, - "group_stats": group_stats, - "charts": charts, - "overview_chart": overview_chart, - "pdf_b64": pdf_b64, - "saved_id": saved_id, - }) - - -@analyses_bp.get("/") -@jwt_required() -def list_analyses(): - user = current_api_user() - if not user: - return err("User not found.", 404) - - try: - page = max(1, int(request.args.get("page", 1))) - per_page = min(100, max(1, int(request.args.get("per_page", 20)))) - except (TypeError, ValueError): - page, per_page = 1, 20 - - total = db.session.scalar( - select(func.count()).select_from(Analysis) - .where(Analysis.user_id == user.id) - ) or 0 - - analyses = db.session.scalars( - select(Analysis) - .where(Analysis.user_id == user.id) - .order_by(Analysis.created_at.desc()) - .offset((page - 1) * per_page) - .limit(per_page) - ).all() - - return ok({ - "data": [serialize_analysis(a) for a in analyses], - "total": total, - "page": page, - "per_page": per_page, - }) - - -@analyses_bp.get("/") -@jwt_required(optional=True) -def get_analysis(analysis_id: int): - a = db.session.get(Analysis, analysis_id) - if not a: - return err("Analysis not found.", 404) - - user = current_api_user() - is_owner = user and a.user_id == user.id - - if not a.is_public and not is_owner: - return err("Access denied.", 403) - - return ok(serialize_analysis(a)) - - -@analyses_bp.delete("/") -@jwt_required() -def delete_analysis(analysis_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - a = db.session.get(Analysis, analysis_id) - if not a: - return err("Analysis not found.", 404) - if a.user_id != user.id: - return err("Access denied.", 403) - - storage_root = current_app.config["STORAGE_ROOT"] - _remove_analysis_files(a, storage_root) - - db.session.delete(a) - db.session.commit() - return no_content() - - -@analyses_bp.patch("//visibility") -@jwt_required() -def toggle_visibility(analysis_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - a = db.session.get(Analysis, analysis_id) - if not a: - return err("Analysis not found.", 404) - if a.user_id != user.id: - return err("Access denied.", 403) - - body = request.get_json(silent=True) or {} - if "is_public" not in body: - return err("is_public field is required.", 400) - - a.is_public = bool(body["is_public"]) - db.session.commit() - return ok(serialize_analysis(a)) diff --git a/blueprints/api/auth.py b/blueprints/api/auth.py deleted file mode 100644 index 2736d80..0000000 --- a/blueprints/api/auth.py +++ /dev/null @@ -1,93 +0,0 @@ -import secrets - -from flask import Blueprint, current_app, request -from flask_jwt_extended import create_access_token, jwt_required - -from extensions import db -from models import User -from .utils import created, err, ok, current_api_user, serialize_user - -auth_bp = Blueprint("api_auth", __name__, url_prefix="/auth") - - -@auth_bp.post("/register") -def register(): - body = request.get_json(silent=True) or {} - email = (body.get("email") or "").strip().lower() - password = body.get("password") or "" - display_name = (body.get("display_name") or "").strip() or None - - # Validation - if not email or "@" not in email or "." not in email.split("@")[-1]: - return err("A valid email address is required.", 400) - if len(password) < 8: - return err("Password must be at least 8 characters.", 400) - - # Uniqueness check - existing = db.session.scalar( - db.select(User).where(User.email == email) - ) - if existing: - return err("Email already registered.", 409) - - needs_confirmation = current_app.config.get("EMAIL_CONFIRMATION_REQUIRED", False) - u = User( - email=email, - display_name=display_name, - provider="local", - provider_id=email, - email_confirmed=not needs_confirmation, - email_confirm_token=secrets.token_urlsafe(32) if needs_confirmation else None, - ) - u.set_password(password) - db.session.add(u) - db.session.commit() - - if needs_confirmation: - return err("Account created. Please confirm your email before logging in.", 201) - - token = create_access_token(identity=str(u.id)) - return created({"user": serialize_user(u), "access_token": token}) - - -@auth_bp.post("/login") -def login(): - body = request.get_json(silent=True) or {} - email = (body.get("email") or "").strip().lower() - password = body.get("password") or "" - - u = db.session.scalar( - db.select(User).where(User.email == email, User.provider == "local") - ) - if not u or not u.check_password(password): - return err("Invalid email or password.", 401) - - if current_app.config.get("EMAIL_CONFIRMATION_REQUIRED") and not u.email_confirmed: - return err("Please confirm your email address before logging in.", 403) - - token = create_access_token(identity=str(u.id)) - return ok({"user": serialize_user(u), "access_token": token}) - - -@auth_bp.get("/me") -@jwt_required() -def me(): - u = current_api_user() - if not u: - return err("User not found.", 404) - return ok(serialize_user(u)) - - -@auth_bp.patch("/me") -@jwt_required() -def update_me(): - u = current_api_user() - if not u: - return err("User not found.", 404) - - body = request.get_json(silent=True) or {} - if "display_name" in body: - u.display_name = (body["display_name"] or "").strip() or None - - db.session.commit() - return ok(serialize_user(u)) diff --git a/blueprints/api/equipment.py b/blueprints/api/equipment.py deleted file mode 100644 index 30ec9a6..0000000 --- a/blueprints/api/equipment.py +++ /dev/null @@ -1,222 +0,0 @@ -from pathlib import Path - -from flask import Blueprint, current_app, request -from flask_jwt_extended import jwt_required -from sqlalchemy import select - -from extensions import db -from models import EquipmentItem -from storage import save_equipment_photo -from .utils import ( - created, err, no_content, ok, - current_api_user, serialize_equipment, -) - -equipment_bp = Blueprint("api_equipment", __name__, url_prefix="/equipment") - -CATEGORY_KEYS = ["rifle", "handgun", "scope", "other"] - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _read_fields(category: str) -> dict: - """Read fields from JSON body or multipart form data.""" - if request.is_json: - body = request.get_json(silent=True) or {} - get = lambda key, default="": body.get(key, default) - else: - get = lambda key, default="": request.form.get(key, default) - - fields: dict = {} - for key in ("name", "brand", "model", "serial_number", "notes"): - val = (get(key) or "").strip() - fields[key] = val or None - - # name is required — caller checks non-None - fields["name"] = (get("name") or "").strip() - - if category == "scope": - fields["magnification"] = (get("magnification") or "").strip() or None - fields["reticle"] = (get("reticle") or "").strip() or None - fields["unit"] = (get("unit") or "").strip() or None - else: - fields["caliber"] = (get("caliber") or "").strip() or None - - return fields - - -def _apply_fields(item: EquipmentItem, fields: dict) -> None: - for key, val in fields.items(): - setattr(item, key, val) - - -def _remove_photo(photo_path: str, storage_root: str) -> None: - try: - Path(storage_root).joinpath(photo_path).unlink(missing_ok=True) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# Routes -# --------------------------------------------------------------------------- - -@equipment_bp.get("/") -@jwt_required() -def list_equipment(): - user = current_api_user() - if not user: - return err("User not found.", 404) - items = db.session.scalars( - select(EquipmentItem) - .where(EquipmentItem.user_id == user.id) - .order_by(EquipmentItem.category, EquipmentItem.name) - ).all() - return ok([serialize_equipment(i) for i in items]) - - -@equipment_bp.post("/") -@jwt_required() -def create_equipment(): - user = current_api_user() - if not user: - return err("User not found.", 404) - - # Category can come from JSON or form - if request.is_json: - body = request.get_json(silent=True) or {} - category = (body.get("category") or "").strip() - else: - category = (request.form.get("category") or "").strip() - - if not category: - return err("category is required.", 400) - if category not in CATEGORY_KEYS: - return err(f"category must be one of: {', '.join(CATEGORY_KEYS)}.", 400) - - fields = _read_fields(category) - if not fields.get("name"): - return err("name is required.", 400) - - item = EquipmentItem(user_id=user.id, category=category) - _apply_fields(item, fields) - db.session.add(item) - db.session.flush() # get item.id before photo upload - - photo = request.files.get("photo") - if photo and photo.filename: - try: - item.photo_path = save_equipment_photo(user.id, item.id, photo) - except ValueError as e: - db.session.rollback() - return err(str(e), 422) - - db.session.commit() - return created(serialize_equipment(item)) - - -@equipment_bp.get("/") -@jwt_required() -def get_equipment(item_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - item = db.session.get(EquipmentItem, item_id) - if not item: - return err("Equipment item not found.", 404) - if item.user_id != user.id: - return err("Access denied.", 403) - - return ok(serialize_equipment(item)) - - -@equipment_bp.patch("/") -@jwt_required() -def update_equipment(item_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - item = db.session.get(EquipmentItem, item_id) - if not item: - return err("Equipment item not found.", 404) - if item.user_id != user.id: - return err("Access denied.", 403) - - # Determine category (may be updated or use existing) - if request.is_json: - body = request.get_json(silent=True) or {} - new_category = (body.get("category") or "").strip() or None - else: - new_category = (request.form.get("category") or "").strip() or None - - if new_category: - if new_category not in CATEGORY_KEYS: - return err(f"category must be one of: {', '.join(CATEGORY_KEYS)}.", 400) - item.category = new_category - - category = item.category - - # Only update fields present in the request - if request.is_json: - body = request.get_json(silent=True) or {} - get = lambda key: body.get(key) - has = lambda key: key in body - else: - get = lambda key: request.form.get(key) - has = lambda key: key in request.form - - for key in ("name", "brand", "model", "serial_number", "notes"): - if has(key): - val = (get(key) or "").strip() or None - if key == "name" and not val: - return err("name cannot be empty.", 400) - setattr(item, key, val) - - if category == "scope": - for key in ("magnification", "reticle", "unit"): - if has(key): - setattr(item, key, (get(key) or "").strip() or None) - else: - if has("caliber"): - item.caliber = (get("caliber") or "").strip() or None - - # Handle photo upload - photo = request.files.get("photo") - if photo and photo.filename: - try: - old_path = item.photo_path - item.photo_path = save_equipment_photo(user.id, item.id, photo) - if old_path: - storage_root = current_app.config["STORAGE_ROOT"] - _remove_photo(old_path, storage_root) - except ValueError as e: - return err(str(e), 422) - - db.session.commit() - return ok(serialize_equipment(item)) - - -@equipment_bp.delete("/") -@jwt_required() -def delete_equipment(item_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - item = db.session.get(EquipmentItem, item_id) - if not item: - return err("Equipment item not found.", 404) - if item.user_id != user.id: - return err("Access denied.", 403) - - if item.photo_path: - storage_root = current_app.config["STORAGE_ROOT"] - _remove_photo(item.photo_path, storage_root) - - db.session.delete(item) - db.session.commit() - return no_content() diff --git a/blueprints/api/feed.py b/blueprints/api/feed.py deleted file mode 100644 index 225bf17..0000000 --- a/blueprints/api/feed.py +++ /dev/null @@ -1,37 +0,0 @@ -from flask import Blueprint, request -from sqlalchemy import func, select - -from extensions import db -from models import ShootingSession -from .utils import ok, serialize_session - -feed_bp = Blueprint("api_feed", __name__, url_prefix="/feed") - - -@feed_bp.get("/") -def feed(): - try: - page = max(1, int(request.args.get("page", 1))) - per_page = min(100, max(1, int(request.args.get("per_page", 20)))) - except (TypeError, ValueError): - page, per_page = 1, 20 - - total = db.session.scalar( - select(func.count()).select_from(ShootingSession) - .where(ShootingSession.is_public == True) # noqa: E712 - ) or 0 - - sessions = db.session.scalars( - select(ShootingSession) - .where(ShootingSession.is_public == True) # noqa: E712 - .order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc()) - .offset((page - 1) * per_page) - .limit(per_page) - ).all() - - return ok({ - "data": [serialize_session(s, include_user=True) for s in sessions], - "total": total, - "page": page, - "per_page": per_page, - }) diff --git a/blueprints/api/sessions.py b/blueprints/api/sessions.py deleted file mode 100644 index 900ed1c..0000000 --- a/blueprints/api/sessions.py +++ /dev/null @@ -1,327 +0,0 @@ -import io -from datetime import date -from pathlib import Path - -from flask import Blueprint, current_app, request -from flask_jwt_extended import jwt_required -from sqlalchemy import func, select - -from extensions import db -from models import SessionPhoto, ShootingSession -from .utils import ( - created, err, no_content, ok, - current_api_user, serialize_analysis, serialize_session, serialize_session_photo, -) - -sessions_bp = Blueprint("api_sessions", __name__, url_prefix="/sessions") - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _int_or_none(v): - try: - result = int(v) - return result if result > 0 else None - except (TypeError, ValueError): - return None - - -def _float_or_none(v): - try: - return float(v) if v is not None and str(v).strip() else None - except (TypeError, ValueError): - return None - - -# --------------------------------------------------------------------------- -# Routes -# --------------------------------------------------------------------------- - -@sessions_bp.get("/") -@jwt_required() -def list_sessions(): - user = current_api_user() - if not user: - return err("User not found.", 404) - - try: - page = max(1, int(request.args.get("page", 1))) - per_page = min(100, max(1, int(request.args.get("per_page", 20)))) - except (TypeError, ValueError): - page, per_page = 1, 20 - - total = db.session.scalar( - select(func.count()).select_from(ShootingSession) - .where(ShootingSession.user_id == user.id) - ) or 0 - - sessions = db.session.scalars( - select(ShootingSession) - .where(ShootingSession.user_id == user.id) - .order_by(ShootingSession.session_date.desc(), ShootingSession.created_at.desc()) - .offset((page - 1) * per_page) - .limit(per_page) - ).all() - - return ok({ - "data": [serialize_session(s) for s in sessions], - "total": total, - "page": page, - "per_page": per_page, - }) - - -@sessions_bp.post("/") -@jwt_required() -def create_session(): - user = current_api_user() - if not user: - return err("User not found.", 404) - - body = request.get_json(silent=True) or {} - - date_str = (body.get("session_date") or "").strip() - if not date_str: - return err("session_date is required.", 400) - try: - session_date = date.fromisoformat(date_str) - except ValueError: - return err("session_date must be a valid ISO date string (YYYY-MM-DD).", 400) - - s = ShootingSession(user_id=user.id, session_date=session_date) - - s.is_public = bool(body.get("is_public", False)) - s.location_name = (body.get("location_name") or "").strip() or None - s.location_lat = _float_or_none(body.get("location_lat")) - s.location_lon = _float_or_none(body.get("location_lon")) - s.distance_m = _int_or_none(body.get("distance_m")) - s.weather_cond = (body.get("weather_cond") or "").strip() or None - s.weather_temp_c = _float_or_none(body.get("weather_temp_c")) - s.weather_wind_kph = _float_or_none(body.get("weather_wind_kph")) - s.rifle_id = _int_or_none(body.get("rifle_id")) - s.scope_id = _int_or_none(body.get("scope_id")) - s.ammo_brand = (body.get("ammo_brand") or "").strip() or None - s.ammo_weight_gr = _float_or_none(body.get("ammo_weight_gr")) - s.ammo_lot = (body.get("ammo_lot") or "").strip() or None - s.notes = (body.get("notes") or "").strip() or None - - db.session.add(s) - db.session.commit() - return created(serialize_session(s)) - - -@sessions_bp.get("/") -@jwt_required(optional=True) -def get_session(session_id: int): - s = db.session.get(ShootingSession, session_id) - if not s: - return err("Session not found.", 404) - - user = current_api_user() - is_owner = user and s.user_id == user.id - - if not s.is_public and not is_owner: - return err("Access denied.", 403) - - return ok(serialize_session(s, include_user=True)) - - -@sessions_bp.patch("/") -@jwt_required() -def update_session(session_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - s = db.session.get(ShootingSession, session_id) - if not s: - return err("Session not found.", 404) - if s.user_id != user.id: - return err("Access denied.", 403) - - body = request.get_json(silent=True) or {} - - if "session_date" in body: - try: - s.session_date = date.fromisoformat(body["session_date"]) - except (ValueError, TypeError): - return err("session_date must be a valid ISO date string (YYYY-MM-DD).", 400) - - if "is_public" in body: - s.is_public = bool(body["is_public"]) - for analysis in s.analyses: - analysis.is_public = s.is_public - if "location_name" in body: - s.location_name = (body["location_name"] or "").strip() or None - if "location_lat" in body: - s.location_lat = _float_or_none(body["location_lat"]) - if "location_lon" in body: - s.location_lon = _float_or_none(body["location_lon"]) - if "distance_m" in body: - s.distance_m = _int_or_none(body["distance_m"]) - if "weather_cond" in body: - s.weather_cond = (body["weather_cond"] or "").strip() or None - if "weather_temp_c" in body: - s.weather_temp_c = _float_or_none(body["weather_temp_c"]) - if "weather_wind_kph" in body: - s.weather_wind_kph = _float_or_none(body["weather_wind_kph"]) - if "rifle_id" in body: - s.rifle_id = _int_or_none(body["rifle_id"]) - if "scope_id" in body: - s.scope_id = _int_or_none(body["scope_id"]) - if "ammo_brand" in body: - s.ammo_brand = (body["ammo_brand"] or "").strip() or None - if "ammo_weight_gr" in body: - s.ammo_weight_gr = _float_or_none(body["ammo_weight_gr"]) - if "ammo_lot" in body: - s.ammo_lot = (body["ammo_lot"] or "").strip() or None - if "notes" in body: - s.notes = (body["notes"] or "").strip() or None - - db.session.commit() - return ok(serialize_session(s)) - - -@sessions_bp.delete("/") -@jwt_required() -def delete_session(session_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - s = db.session.get(ShootingSession, session_id) - if not s: - return err("Session not found.", 404) - if s.user_id != user.id: - return err("Access denied.", 403) - - storage_root = current_app.config["STORAGE_ROOT"] - for photo in s.photos: - try: - (Path(storage_root) / photo.photo_path).unlink(missing_ok=True) - except Exception: - pass - - db.session.delete(s) - db.session.commit() - return no_content() - - -# --------------------------------------------------------------------------- -# Photos -# --------------------------------------------------------------------------- - -@sessions_bp.post("//photos") -@jwt_required() -def upload_photo(session_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - s = db.session.get(ShootingSession, session_id) - if not s: - return err("Session not found.", 404) - if s.user_id != user.id: - return err("Access denied.", 403) - - photo_file = request.files.get("photo") - if not photo_file or not photo_file.filename: - return err("No photo file provided.", 400) - - from storage import save_session_photo - try: - photo_path = save_session_photo(user.id, session_id, photo_file) - except ValueError as e: - return err(str(e), 422) - - caption = (request.form.get("caption") or "").strip() or None - photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption) - db.session.add(photo) - db.session.commit() - return created(serialize_session_photo(photo)) - - -@sessions_bp.delete("//photos/") -@jwt_required() -def delete_photo(session_id: int, photo_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - s = db.session.get(ShootingSession, session_id) - if not s: - return err("Session not found.", 404) - if s.user_id != user.id: - return err("Access denied.", 403) - - photo = db.session.get(SessionPhoto, photo_id) - if not photo or photo.session_id != session_id: - return err("Photo not found.", 404) - - storage_root = current_app.config["STORAGE_ROOT"] - try: - (Path(storage_root) / photo.photo_path).unlink(missing_ok=True) - except Exception: - pass - - db.session.delete(photo) - db.session.commit() - return no_content() - - -# --------------------------------------------------------------------------- -# CSV upload -# --------------------------------------------------------------------------- - -@sessions_bp.post("//csv") -@jwt_required() -def upload_csv(session_id: int): - user = current_api_user() - if not user: - return err("User not found.", 404) - - s = db.session.get(ShootingSession, session_id) - if not s: - return err("Session not found.", 404) - if s.user_id != user.id: - return err("Access denied.", 403) - - csv_file = request.files.get("csv_file") - if not csv_file or not csv_file.filename: - return err("No csv_file provided.", 400) - - from analyzer.parser import parse_csv - from analyzer.grouper import detect_groups - from analyzer.stats import compute_overall_stats, compute_group_stats - from analyzer.charts import render_group_charts, render_overview_chart - from analyzer.pdf_report import generate_pdf - from storage import save_analysis - - try: - csv_bytes = csv_file.read() - df = parse_csv(io.BytesIO(csv_bytes)) - groups = detect_groups(df) - overall = compute_overall_stats(df) - group_stats = compute_group_stats(groups) - charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"]) - overview_chart = render_overview_chart(group_stats) - pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) - except ValueError as e: - return err(str(e), 422) - - analysis_id = save_analysis( - user=user, - csv_bytes=csv_bytes, - pdf_bytes=pdf_bytes, - overall=overall, - group_stats=group_stats, - filename=csv_file.filename or "upload.csv", - session_id=session_id, - is_public=s.is_public, - ) - - from models import Analysis - analysis = db.session.get(Analysis, analysis_id) - return created(serialize_analysis(analysis)) diff --git a/blueprints/api/utils.py b/blueprints/api/utils.py deleted file mode 100644 index 1945a58..0000000 --- a/blueprints/api/utils.py +++ /dev/null @@ -1,79 +0,0 @@ -from flask import jsonify -from flask_jwt_extended import get_jwt_identity -from extensions import db -from models import User - - -def ok(data, status=200): - return jsonify({"data": data}), status - - -def created(data): - return jsonify({"data": data}), 201 - - -def no_content(): - return "", 204 - - -def err(message: str, status: int = 400, code: str | None = None): - _codes = {400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN", - 404: "NOT_FOUND", 409: "CONFLICT", 422: "UNPROCESSABLE"} - return jsonify({"error": {"code": code or _codes.get(status, "ERROR"), "message": message}}), status - - -def current_api_user() -> User | None: - uid = get_jwt_identity() - return db.session.get(User, int(uid)) if uid else None - - -# --------------------------------------------------------------------------- -# Serializers -# --------------------------------------------------------------------------- - -def serialize_user(u) -> dict: - return {"id": u.id, "email": u.email, "display_name": u.display_name, - "avatar_url": u.avatar_url, "provider": u.provider, - "created_at": u.created_at.isoformat()} - - -def serialize_equipment(item) -> dict: - base = {"id": item.id, "category": item.category, "name": item.name, - "brand": item.brand, "model": item.model, "serial_number": item.serial_number, - "notes": item.notes, "photo_url": item.photo_url, - "created_at": item.created_at.isoformat(), "updated_at": item.updated_at.isoformat()} - if item.category == "scope": - base.update({"magnification": item.magnification, "reticle": item.reticle, "unit": item.unit}) - else: - base["caliber"] = item.caliber - return base - - -def serialize_session_photo(p) -> dict: - return {"id": p.id, "photo_url": p.photo_url, "caption": p.caption, - "created_at": p.created_at.isoformat()} - - -def serialize_session(s, include_user: bool = False) -> dict: - d = {"id": s.id, "label": s.label, "session_date": s.session_date.isoformat(), - "is_public": s.is_public, "location_name": s.location_name, - "location_lat": s.location_lat, "location_lon": s.location_lon, - "distance_m": s.distance_m, "weather_cond": s.weather_cond, - "weather_temp_c": float(s.weather_temp_c) if s.weather_temp_c is not None else None, - "weather_wind_kph": float(s.weather_wind_kph) if s.weather_wind_kph is not None else None, - "rifle_id": s.rifle_id, "scope_id": s.scope_id, - "ammo_brand": s.ammo_brand, - "ammo_weight_gr": float(s.ammo_weight_gr) if s.ammo_weight_gr is not None else None, - "ammo_lot": s.ammo_lot, "notes": s.notes, - "photos": [serialize_session_photo(p) for p in s.photos], - "created_at": s.created_at.isoformat(), "updated_at": s.updated_at.isoformat()} - if include_user: - d["user"] = serialize_user(s.user) - return d - - -def serialize_analysis(a) -> dict: - return {"id": a.id, "title": a.title, "is_public": a.is_public, - "shot_count": a.shot_count, "group_count": a.group_count, - "overall_stats": a.overall_stats, "group_stats": a.group_stats, - "session_id": a.session_id, "created_at": a.created_at.isoformat()} diff --git a/blueprints/auth.py b/blueprints/auth.py deleted file mode 100644 index e135ecd..0000000 --- a/blueprints/auth.py +++ /dev/null @@ -1,393 +0,0 @@ -import secrets -from datetime import datetime, timezone -from pathlib import Path -from urllib.parse import urlparse - -from flask import ( - Blueprint, - abort, - current_app, - flash, - redirect, - render_template, - request, - send_from_directory, - session as flask_session, - url_for, -) -from flask_babel import _ -from flask_login import current_user, login_required, login_user, logout_user -from sqlalchemy.exc import IntegrityError - -from extensions import db, oauth -from models import User - -auth_bp = Blueprint("auth", __name__, url_prefix="/auth") - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _login(user: "User") -> None: - """Log in user and restore their language preference into the session.""" - login_user(user) - if user.language: - flask_session["lang"] = user.language - - -def _safe_next() -> str: - target = request.args.get("next") or "" - if target and urlparse(target).netloc == "": - return target - return url_for("dashboard.index") - - -def _upsert_oauth_user(*, provider: str, provider_id: str, email: str, - display_name: str | None, avatar_url: str | None) -> User | None: - """Find-or-create a user for an OAuth login. Returns None on email conflict.""" - user = db.session.scalar( - db.select(User).filter_by(provider=provider, provider_id=provider_id) - ) - now = datetime.now(timezone.utc) - if user is None: - user = User( - email=email, - provider=provider, - provider_id=provider_id, - display_name=display_name, - avatar_url=avatar_url, - email_confirmed=True, - created_at=now, - ) - db.session.add(user) - try: - db.session.commit() - except IntegrityError: - db.session.rollback() - return None # email already taken by a different provider/local account - else: - user.display_name = display_name or user.display_name - user.avatar_url = avatar_url or user.avatar_url - user.last_login_at = now - db.session.commit() - return user - - -def _dispatch_confirmation(user: User) -> None: - """ - Log the confirmation URL to container logs. - Replace the body of this function with a real mail call when ready. - """ - confirm_url = url_for("auth.confirm_email", token=user.email_confirm_token, _external=True) - current_app.logger.warning( - "EMAIL CONFIRMATION — %s — open this URL to confirm: %s", - user.email, - confirm_url, - ) - - -# --------------------------------------------------------------------------- -# Local login / register -# --------------------------------------------------------------------------- - -@auth_bp.route("/login", methods=["GET", "POST"]) -def login(): - if current_user.is_authenticated: - return redirect(url_for("dashboard.index")) - - if request.method == "POST": - email = request.form.get("email", "").strip().lower() - password = request.form.get("password", "") - - user = db.session.scalar( - db.select(User).filter_by(email=email, provider="local") - ) - - if user is None or not user.check_password(password): - flash(_("Invalid email or password."), "error") - return render_template("auth/login.html", prefill_email=email) - - if current_app.config["EMAIL_CONFIRMATION_REQUIRED"] and not user.email_confirmed: - flash(_("Please confirm your email address before logging in."), "error") - return render_template("auth/login.html", prefill_email=email, - show_resend=True, resend_email=email) - - user.last_login_at = datetime.now(timezone.utc) - db.session.commit() - _login(user) - return redirect(_safe_next()) - - return render_template("auth/login.html") - - -@auth_bp.route("/register", methods=["GET", "POST"]) -def register(): - if current_user.is_authenticated: - return redirect(url_for("dashboard.index")) - - if request.method == "POST": - email = request.form.get("email", "").strip().lower() - password = request.form.get("password", "") - confirm = request.form.get("confirm_password", "") - - # Validate - error = None - if not email or "@" not in email or "." not in email.split("@")[-1]: - error = _("Please enter a valid email address.") - elif len(password) < 8: - error = _("Password must be at least 8 characters.") - elif password != confirm: - error = _("Passwords do not match.") - else: - existing = db.session.scalar(db.select(User).filter_by(email=email)) - if existing: - if existing.provider == "local": - error = _("An account with this email already exists.") - else: - error = _( - "This email is linked to a %(provider)s account. " - "Please log in with %(provider2)s.", - provider=existing.provider.title(), - provider2=existing.provider.title(), - ) - - if error: - flash(error, "error") - return render_template("auth/register.html", prefill_email=email) - - needs_confirmation = current_app.config["EMAIL_CONFIRMATION_REQUIRED"] - now = datetime.now(timezone.utc) - user = User( - email=email, - provider="local", - provider_id=email, - display_name=email.split("@")[0], - email_confirmed=not needs_confirmation, - email_confirm_token=secrets.token_urlsafe(32) if needs_confirmation else None, - created_at=now, - ) - user.set_password(password) - db.session.add(user) - db.session.commit() - - if needs_confirmation: - _dispatch_confirmation(user) - return render_template("auth/confirm_pending.html", email=email) - - _login(user) - flash(_("Account created! Welcome."), "success") - return redirect(url_for("dashboard.index")) - - return render_template("auth/register.html") - - -@auth_bp.route("/confirm/") -def confirm_email(token: str): - user = db.session.scalar( - db.select(User).filter_by(email_confirm_token=token) - ) - if user is None: - flash(_("Invalid or expired confirmation link."), "error") - return redirect(url_for("auth.login")) - - user.email_confirmed = True - user.email_confirm_token = None - db.session.commit() - _login(user) - flash(_("Email confirmed! Welcome."), "success") - return redirect(url_for("dashboard.index")) - - -@auth_bp.route("/resend-confirmation", methods=["POST"]) -def resend_confirmation(): - email = request.form.get("email", "").strip().lower() - user = db.session.scalar( - db.select(User).filter_by(email=email, provider="local") - ) - if user and not user.email_confirmed: - if not user.email_confirm_token: - user.email_confirm_token = secrets.token_urlsafe(32) - db.session.commit() - _dispatch_confirmation(user) - # Vague message to prevent email enumeration - flash(_("If that account exists and is unconfirmed, a new link has been sent."), "message") - return redirect(url_for("auth.login")) - - -# --------------------------------------------------------------------------- -# OAuth — Google -# --------------------------------------------------------------------------- - -@auth_bp.route("/login/google") -def login_google(): - return oauth.google.authorize_redirect( - url_for("auth.callback_google", _external=True) - ) - - -@auth_bp.route("/callback/google") -def callback_google(): - try: - token = oauth.google.authorize_access_token() - except Exception: - flash(_("Google login failed. Please try again."), "error") - return redirect(url_for("auth.login")) - - info = token.get("userinfo") or {} - email = info.get("email") - if not email: - flash(_("Could not retrieve your email from Google."), "error") - return redirect(url_for("auth.login")) - - user = _upsert_oauth_user( - provider="google", - provider_id=info["sub"], - email=email, - display_name=info.get("name"), - avatar_url=info.get("picture"), - ) - if user is None: - flash(_("This email is already registered with a different login method."), "error") - return redirect(url_for("auth.login")) - - _login(user) - return redirect(_safe_next()) - - -# --------------------------------------------------------------------------- -# OAuth — GitHub -# --------------------------------------------------------------------------- - -@auth_bp.route("/login/github") -def login_github(): - return oauth.github.authorize_redirect( - url_for("auth.callback_github", _external=True) - ) - - -@auth_bp.route("/callback/github") -def callback_github(): - try: - token = oauth.github.authorize_access_token() - except Exception: - flash(_("GitHub login failed. Please try again."), "error") - return redirect(url_for("auth.login")) - - resp = oauth.github.get("user", token=token) - info = resp.json() - - email = info.get("email") - if not email: - emails_resp = oauth.github.get("user/emails", token=token) - emails = emails_resp.json() if emails_resp.status_code == 200 else [] - email = next( - (e["email"] for e in emails if e.get("primary") and e.get("verified")), - None, - ) - - if not email: - flash(_("Could not retrieve a verified email from GitHub."), "error") - return redirect(url_for("auth.login")) - - user = _upsert_oauth_user( - provider="github", - provider_id=str(info["id"]), - email=email, - display_name=info.get("name") or info.get("login"), - avatar_url=info.get("avatar_url"), - ) - if user is None: - flash(_("This email is already registered with a different login method."), "error") - return redirect(url_for("auth.login")) - - _login(user) - return redirect(_safe_next()) - - -# --------------------------------------------------------------------------- -# Profile -# --------------------------------------------------------------------------- - -@auth_bp.route("/profile", methods=["GET", "POST"]) -@login_required -def profile(): - if request.method == "POST": - action = request.form.get("action") - - if action == "update_profile": - display_name = request.form.get("display_name", "").strip() - if not display_name: - flash(_("Display name cannot be empty."), "error") - else: - current_user.display_name = display_name - current_user.bio = request.form.get("bio", "").strip() or None - current_user.show_equipment_public = bool(request.form.get("show_equipment_public")) - avatar_file = request.files.get("avatar") - if avatar_file and avatar_file.filename: - from storage import save_avatar - try: - old_path = current_user.avatar_path - current_user.avatar_path = save_avatar(current_user.id, avatar_file) - if old_path: - _remove_avatar_file(old_path) - except ValueError as e: - flash(str(e), "error") - db.session.rollback() - return render_template("auth/profile.html") - db.session.commit() - flash(_("Profile updated."), "success") - - elif action == "change_password": - if current_user.provider != "local": - flash(_("Password change is only available for local accounts."), "error") - else: - current_pw = request.form.get("current_password", "") - new_pw = request.form.get("new_password", "") - confirm_pw = request.form.get("confirm_password", "") - if not current_user.check_password(current_pw): - flash(_("Current password is incorrect."), "error") - elif len(new_pw) < 8: - flash(_("New password must be at least 8 characters."), "error") - elif new_pw != confirm_pw: - flash(_("Passwords do not match."), "error") - else: - current_user.set_password(new_pw) - db.session.commit() - flash(_("Password changed."), "success") - - return redirect(url_for("auth.profile")) - - return render_template("auth/profile.html") - - -@auth_bp.route("/avatar/") -def avatar(user_id: int): - user = db.session.get(User, user_id) - if not user or not user.avatar_path: - abort(404) - storage_root = current_app.config["STORAGE_ROOT"] - rel = user.avatar_path.removeprefix("avatars/") - return send_from_directory(Path(storage_root) / "avatars", rel) - - -def _remove_avatar_file(avatar_path: str) -> None: - try: - storage_root = current_app.config["STORAGE_ROOT"] - (Path(storage_root) / avatar_path).unlink(missing_ok=True) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# Public profile -# --------------------------------------------------------------------------- - -# --------------------------------------------------------------------------- -# Logout -# --------------------------------------------------------------------------- - -@auth_bp.route("/logout", methods=["POST"]) -def logout(): - logout_user() - return redirect(url_for("index")) diff --git a/blueprints/dashboard.py b/blueprints/dashboard.py deleted file mode 100644 index 381394c..0000000 --- a/blueprints/dashboard.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import Blueprint, render_template -from flask_login import current_user, login_required -from sqlalchemy import select - -from extensions import db -from models import Analysis - -dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/dashboard") - - -@dashboard_bp.route("/") -@login_required -def index(): - analyses = db.session.scalars( - select(Analysis) - .where(Analysis.user_id == current_user.id) - .order_by(Analysis.created_at.desc()) - .limit(50) - ).all() - return render_template("dashboard/index.html", analyses=analyses) diff --git a/blueprints/equipment.py b/blueprints/equipment.py deleted file mode 100644 index 297e548..0000000 --- a/blueprints/equipment.py +++ /dev/null @@ -1,196 +0,0 @@ -from pathlib import Path - -from flask import ( - abort, current_app, flash, redirect, render_template, - request, send_from_directory, url_for, -) -from flask import Blueprint -from flask_babel import _ -from flask_login import current_user, login_required -from sqlalchemy import select - -from extensions import db -from models import EquipmentItem -from storage import rotate_photo, save_equipment_photo - -equipment_bp = Blueprint("equipment", __name__, url_prefix="/equipment") - -_CATEGORIES_RAW = [ - ("rifle", "Rifle"), - ("handgun", "Handgun"), - ("scope", "Scope"), - ("other", "Other"), -] -CATEGORY_KEYS = [k for k, _ in _CATEGORIES_RAW] - - -def _t_categories(): - return [(k, _(l)) for k, l in _CATEGORIES_RAW] - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _own_item(item_id: int) -> EquipmentItem: - item = db.session.get(EquipmentItem, item_id) - if item is None: - abort(404) - if item.user_id != current_user.id: - abort(403) - return item - - -def _apply_form(item: EquipmentItem) -> str | None: - """Write request.form fields onto item. Returns an error string or None.""" - name = request.form.get("name", "").strip() - category = request.form.get("category", "").strip() - if not name: - return _("Name is required.") - if category not in CATEGORY_KEYS: - return _("Invalid category.") - item.name = name - item.category = category - item.brand = request.form.get("brand", "").strip() or None - item.model = request.form.get("model", "").strip() or None - item.serial_number = request.form.get("serial_number", "").strip() or None - item.notes = request.form.get("notes", "").strip() or None - if category == "scope": - item.magnification = request.form.get("magnification", "").strip() or None - item.reticle = request.form.get("reticle", "").strip() or None - item.unit = request.form.get("unit", "").strip() or None - item.caliber = None - else: - item.caliber = request.form.get("caliber", "").strip() or None - item.magnification = None - item.reticle = None - item.unit = None - return None - - -# --------------------------------------------------------------------------- -# Routes -# --------------------------------------------------------------------------- - -@equipment_bp.route("/") -@login_required -def index(): - items = db.session.scalars( - select(EquipmentItem) - .where(EquipmentItem.user_id == current_user.id) - .order_by(EquipmentItem.category, EquipmentItem.name) - ).all() - return render_template("equipment/list.html", items=items, categories=_t_categories()) - - -@equipment_bp.route("/new", methods=["GET", "POST"]) -@login_required -def new(): - if request.method == "POST": - item = EquipmentItem(user_id=current_user.id) - db.session.add(item) - error = _apply_form(item) - if error: - db.session.expunge(item) - flash(error, "error") - return render_template("equipment/form.html", item=None, - 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=_t_categories()) - - -@equipment_bp.route("/") -@login_required -def detail(item_id: int): - item = _own_item(item_id) - return render_template("equipment/detail.html", item=item, categories=dict(_t_categories())) - - -@equipment_bp.route("//edit", methods=["GET", "POST"]) -@login_required -def edit(item_id: int): - item = _own_item(item_id) - if request.method == "POST": - error = _apply_form(item) - if error: - flash(error, "error") - return render_template("equipment/form.html", item=item, - 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=_t_categories()) - - -@equipment_bp.route("//delete", methods=["POST"]) -@login_required -def delete(item_id: int): - item = _own_item(item_id) - name = item.name - if item.photo_path: - _remove_photo_file(item.photo_path) - db.session.delete(item) - db.session.commit() - flash(_("'%(name)s' deleted.", name=name), "success") - return redirect(url_for("equipment.index")) - - -@equipment_bp.route("//photo/rotate", methods=["POST"]) -@login_required -def rotate_photo_view(item_id: int): - item = _own_item(item_id) - if not item.photo_path: - flash(_("No photo to rotate."), "error") - return redirect(url_for("equipment.detail", item_id=item_id)) - try: - degrees = int(request.form.get("degrees", 0)) - except ValueError: - abort(400) - if degrees not in (-90, 90, 180): - abort(400) - rotate_photo(item.photo_path, degrees) - return redirect(url_for("equipment.detail", item_id=item_id)) - - -@equipment_bp.route("/photos/") -@login_required -def photo(filepath: str): - """Serve equipment photo. Only the owning user may access it.""" - try: - owner_id = int(filepath.split("/")[0]) - except (ValueError, IndexError): - abort(404) - if owner_id != current_user.id: - abort(403) - storage_root = current_app.config["STORAGE_ROOT"] - return send_from_directory(Path(storage_root) / "equipment_photos", filepath) - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - -def _handle_photo(item: EquipmentItem, *, is_new: bool) -> None: - photo_file = request.files.get("photo") - if not (photo_file and photo_file.filename): - return - try: - old_path = item.photo_path - item.photo_path = save_equipment_photo(current_user.id, item.id, photo_file) - if old_path: - _remove_photo_file(old_path) - except ValueError as e: - flash(str(e), "error") - - -def _remove_photo_file(photo_path: str) -> None: - try: - storage_root = current_app.config["STORAGE_ROOT"] - (Path(storage_root) / photo_path).unlink(missing_ok=True) - except Exception: - pass diff --git a/blueprints/sessions.py b/blueprints/sessions.py deleted file mode 100644 index e696dfe..0000000 --- a/blueprints/sessions.py +++ /dev/null @@ -1,511 +0,0 @@ -import io -from datetime import date -from pathlib import Path - -from flask import ( - Blueprint, abort, current_app, flash, jsonify, redirect, - render_template, request, send_from_directory, url_for, -) -from flask_babel import _ -from flask_login import current_user, login_required -from sqlalchemy import select - -from extensions import db -from models import Analysis, EquipmentItem, SessionPhoto, ShootingSession -from storage import rotate_photo, save_session_photo - -sessions_bp = Blueprint("sessions", __name__, url_prefix="/sessions") - -# (slug, display name, short description) -SESSION_TYPES = [ - ("long_range", "Long Range Practice", "Long range precision shooting (100m+)"), - ("prs", "PRS", "Precision Rifle Series — training & competition"), - ("pistol_25m", "25m Pistol", "25m precision pistol shooting"), -] - -LONG_RANGE_POSITIONS = [ - ("", "— select —"), - ("prone", "Prone"), - ("bench", "Bench rest"), - ("standing", "Standing"), - ("kneeling", "Kneeling"), -] - -PISTOL_25M_POSITIONS = [ - ("", "— select —"), - ("debout", "Standing"), - ("debout_appui", "Standing with support"), - ("assis", "Sitting"), - ("assis_appui", "Sitting with support"), -] - -PRS_STAGE_POSITIONS = [ - ("prone", "Prone"), - ("standing", "Standing"), - ("kneeling", "Kneeling"), - ("sitting", "Sitting"), - ("barricade", "Barricade"), - ("rooftop", "Rooftop"), - ("unknown", "Variable"), -] - -# Kept as reference but not used in UI -_FIXED_DISTANCES = {"pistol_25m": 25} - -WEATHER_CONDITIONS = [ - ("", "— select —"), - ("sunny", "Sunny"), - ("partly_cloudy", "Partly cloudy"), - ("overcast", "Overcast"), - ("rain", "Rain"), - ("wind", "Wind"), - ("snow", "Snow"), - ("fog", "Fog"), -] - - -# --------------------------------------------------------------------------- -# 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 -# --------------------------------------------------------------------------- - -def _own_session(session_id: int) -> ShootingSession: - s = db.session.get(ShootingSession, session_id) - if s is None: - abort(404) - if s.user_id != current_user.id: - abort(403) - return s - - -def _user_rifles(): - return db.session.scalars( - select(EquipmentItem) - .where(EquipmentItem.user_id == current_user.id, - EquipmentItem.category.in_(["rifle", "handgun"])) - .order_by(EquipmentItem.name) - ).all() - - -def _user_scopes(): - return db.session.scalars( - select(EquipmentItem) - .where(EquipmentItem.user_id == current_user.id, - EquipmentItem.category == "scope") - .order_by(EquipmentItem.name) - ).all() - - -def _apply_form(s: ShootingSession) -> str | None: - """Write request.form fields onto session. Returns error string or None.""" - date_str = request.form.get("session_date", "").strip() - if not date_str: - return "Date is required." - try: - s.session_date = date.fromisoformat(date_str) - except ValueError: - return "Invalid date." - - s.is_public = bool(request.form.get("is_public")) - s.location_name = request.form.get("location_name", "").strip() or None - s.distance_m = _int_or_none(request.form.get("distance_m")) - s.weather_cond = request.form.get("weather_cond") or None - s.weather_temp_c = _float_or_none(request.form.get("weather_temp_c")) - s.weather_wind_kph = _float_or_none(request.form.get("weather_wind_kph")) - s.rifle_id = _int_or_none(request.form.get("rifle_id")) - s.scope_id = _int_or_none(request.form.get("scope_id")) - s.ammo_brand = request.form.get("ammo_brand", "").strip() or None - s.ammo_weight_gr = _float_or_none(request.form.get("ammo_weight_gr")) - s.ammo_lot = request.form.get("ammo_lot", "").strip() or None - s.notes = request.form.get("notes", "").strip() or None - s.session_type = request.form.get("session_type") or None - s.shooting_position = request.form.get("shooting_position") or None - return None - - -def _int_or_none(val): - try: - v = int(val) - return v if v > 0 else None - except (TypeError, ValueError): - return None - - -def _float_or_none(val): - try: - return float(val) if val and str(val).strip() else None - except (TypeError, ValueError): - return None - - -def _remove_file(rel_path: str) -> None: - try: - storage_root = current_app.config["STORAGE_ROOT"] - (Path(storage_root) / rel_path).unlink(missing_ok=True) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# Routes -# --------------------------------------------------------------------------- - -@sessions_bp.route("/") -@login_required -def index(): - sessions = db.session.scalars( - select(ShootingSession) - .where(ShootingSession.user_id == current_user.id) - .order_by(ShootingSession.session_date.desc()) - ).all() - return render_template("sessions/list.html", sessions=sessions) - - -@sessions_bp.route("/new", methods=["GET", "POST"]) -@login_required -def new(): - if request.method == "POST": - s = ShootingSession(user_id=current_user.id) - db.session.add(s) - error = _apply_form(s) - if error: - db.session.expunge(s) - flash(error, "error") - return render_template("sessions/form.html", session=None, - rifles=_user_rifles(), scopes=_user_scopes(), - 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") - return redirect(url_for("sessions.detail", session_id=s.id)) - - selected_type = request.args.get("type") - if selected_type: - prefill_distance = _FIXED_DISTANCES.get(selected_type) - return render_template("sessions/form.html", session=None, - rifles=_user_rifles(), scopes=_user_scopes(), - weather_conditions=_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=_t_session_types()) - - -@sessions_bp.route("/") -def detail(session_id: int): - s = db.session.get(ShootingSession, session_id) - if s is None: - abort(404) - is_owner = current_user.is_authenticated and s.user_id == current_user.id - if not s.is_public and not is_owner: - abort(403) - analyses = db.session.scalars( - select(Analysis) - .where(Analysis.session_id == session_id) - .order_by(Analysis.created_at) - ).all() - - from analyzer.parser import parse_csv - from analyzer.grouper import detect_groups, OUTLIER_FACTOR - from analyzer.stats import compute_overall_stats, compute_group_stats - from analyzer.charts import render_group_charts, render_overview_chart - - storage_root = current_app.config["STORAGE_ROOT"] - 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())) - splits = a.grouping_manual_splits or None - 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 - stored = a.group_stats or [] - for i, gs in enumerate(group_stats): - if i < len(stored) and stored[i].get("note"): - gs["note"] = stored[i]["note"] - charts = render_group_charts(groups, - y_min=overall["min_speed"], - y_max=overall["max_speed"]) - overview_chart = render_overview_chart(group_stats) - groups_display = list(zip(group_stats, charts)) - # 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, [])) - else: - 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) - - -@sessions_bp.route("//edit", methods=["GET", "POST"]) -@login_required -def edit(session_id: int): - s = _own_session(session_id) - if request.method == "POST": - error = _apply_form(s) - if error: - flash(error, "error") - return render_template("sessions/form.html", session=s, - rifles=_user_rifles(), scopes=_user_scopes(), - 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 - db.session.commit() - flash(_("Session saved."), "success") - return redirect(url_for("sessions.detail", session_id=s.id)) - return render_template("sessions/form.html", session=s, - rifles=_user_rifles(), scopes=_user_scopes(), - weather_conditions=_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("//delete", methods=["POST"]) -@login_required -def delete(session_id: int): - s = _own_session(session_id) - for photo in s.photos: - _remove_file(photo.photo_path) - db.session.delete(s) - db.session.commit() - flash(_("Session deleted."), "success") - return redirect(url_for("sessions.index")) - - -# --------------------------------------------------------------------------- -# PRS stages -# --------------------------------------------------------------------------- - -@sessions_bp.route("//stages", methods=["POST"]) -@login_required -def save_stages(session_id: int): - import json - s = _own_session(session_id) - raw = request.form.get("stages_json", "[]") - try: - stages = json.loads(raw) - if not isinstance(stages, list): - stages = [] - except (json.JSONDecodeError, ValueError): - stages = [] - s.prs_stages = stages - db.session.commit() - flash(_("Stages saved."), "success") - return redirect(url_for("sessions.detail", session_id=session_id)) - - -@sessions_bp.route("//dope-card") -def dope_card(session_id: int): - s = db.session.get(ShootingSession, session_id) - if s is None: - abort(404) - is_owner = current_user.is_authenticated and s.user_id == current_user.id - if not s.is_public and not is_owner: - abort(403) - if s.session_type != "prs": - abort(400) - - from analyzer.dope_card import generate_dope_card - from flask import make_response - - stages = s.prs_stages or [] - pdf_bytes = generate_dope_card(s, stages) - resp = make_response(pdf_bytes) - resp.headers["Content-Type"] = "application/pdf" - fname = f"dope_card_{s.session_date.isoformat()}.pdf" - resp.headers["Content-Disposition"] = f'inline; filename="{fname}"' - return resp - - -# --------------------------------------------------------------------------- -# CSV upload -# --------------------------------------------------------------------------- - -@sessions_bp.route("//upload-csv", methods=["POST"]) -@login_required -def upload_csv(session_id: int): - _own_session(session_id) - - csv_file = request.files.get("csv_file") - if not csv_file or not csv_file.filename: - flash(_("No CSV file selected."), "error") - return redirect(url_for("sessions.detail", session_id=session_id)) - - from analyzer.parser import parse_csv - from analyzer.grouper import detect_groups - from analyzer.stats import compute_overall_stats, compute_group_stats - from analyzer.charts import render_group_charts, render_overview_chart - from analyzer.pdf_report import generate_pdf - from storage import save_analysis - - try: - csv_bytes = csv_file.read() - df = parse_csv(io.BytesIO(csv_bytes)) - groups = detect_groups(df) - overall = compute_overall_stats(df) - group_stats = compute_group_stats(groups) - charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"]) - overview_chart = render_overview_chart(group_stats) - pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) - except ValueError as e: - flash(str(e), "error") - return redirect(url_for("sessions.detail", session_id=session_id)) - - s = db.session.get(ShootingSession, session_id) - save_analysis( - user=current_user, - csv_bytes=csv_bytes, - pdf_bytes=pdf_bytes, - overall=overall, - group_stats=group_stats, - filename=csv_file.filename or "upload.csv", - session_id=session_id, - is_public=s.is_public if s else False, - ) - flash(_("CSV analysed and linked to this session."), "success") - return redirect(url_for("sessions.detail", session_id=session_id)) - - -# --------------------------------------------------------------------------- -# Photo upload / delete / serve -# --------------------------------------------------------------------------- - -@sessions_bp.route("//upload-photo", methods=["POST"]) -@login_required -def upload_photo(session_id: int): - _own_session(session_id) - - photo_file = request.files.get("photo") - if not photo_file or not photo_file.filename: - flash(_("No photo selected."), "error") - return redirect(url_for("sessions.detail", session_id=session_id)) - - try: - photo_path = save_session_photo(current_user.id, session_id, photo_file) - except ValueError as e: - flash(str(e), "error") - return redirect(url_for("sessions.detail", session_id=session_id)) - - caption = request.form.get("caption", "").strip() or None - photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption) - db.session.add(photo) - db.session.commit() - flash(_("Photo added."), "success") - return redirect(url_for("sessions.detail", session_id=session_id)) - - -@sessions_bp.route("//photos//delete", methods=["POST"]) -@login_required -def delete_photo(session_id: int, photo_id: int): - _own_session(session_id) - photo = db.session.get(SessionPhoto, photo_id) - if photo is None or photo.session_id != session_id: - abort(404) - _remove_file(photo.photo_path) - db.session.delete(photo) - db.session.commit() - flash(_("Photo deleted."), "success") - return redirect(url_for("sessions.detail", session_id=session_id)) - - -@sessions_bp.route("//photos//rotate", methods=["POST"]) -@login_required -def rotate_photo_view(session_id: int, photo_id: int): - _own_session(session_id) - photo = db.session.get(SessionPhoto, photo_id) - if photo is None or photo.session_id != session_id: - abort(404) - try: - degrees = int(request.form.get("degrees", 0)) - except ValueError: - abort(400) - if degrees not in (-90, 90, 180): - abort(400) - rotate_photo(photo.photo_path, degrees) - return redirect(url_for("sessions.detail", session_id=session_id)) - - -@sessions_bp.route("//photos//annotate", methods=["GET", "POST"]) -@login_required -def annotate_photo(session_id: int, photo_id: int): - _own_session(session_id) - photo = db.session.get(SessionPhoto, photo_id) - if photo is None or photo.session_id != session_id: - abort(404) - - if request.method == "POST": - data = request.get_json(force=True) - photo.annotations = data - db.session.commit() - return jsonify({"ok": True}) - - s = db.session.get(ShootingSession, session_id) - return render_template("sessions/annotate_photo.html", session=s, photo=photo) - - -@sessions_bp.route("/photos/") -def serve_photo(filepath: str): - """Serve a session photo. Private session photos are owner-only.""" - try: - user_id = int(filepath.split("/")[0]) - except (ValueError, IndexError): - abort(404) - - is_owner = current_user.is_authenticated and current_user.id == user_id - if not is_owner: - photo = db.session.scalars( - select(SessionPhoto).where( - SessionPhoto.photo_path == f"session_photos/{filepath}" - ) - ).first() - if photo is None or not photo.session.is_public: - abort(403) - - storage_root = current_app.config["STORAGE_ROOT"] - return send_from_directory(Path(storage_root) / "session_photos", filepath) diff --git a/config.py b/config.py deleted file mode 100644 index 66b019f..0000000 --- a/config.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -from datetime import timedelta - - -class Config: - SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-change-in-production" - JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") or os.environ.get("SECRET_KEY") or "dev-secret" - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - - @classmethod - def validate(cls): - """Call this in production to ensure required secrets are set.""" - import os as _os - if _os.environ.get("FLASK_ENV") == "production" or _os.environ.get("FLASK_DEBUG") == "0": - if cls.SECRET_KEY in ("dev-secret-change-in-production", "dev-secret"): - raise RuntimeError( - "SECRET_KEY must be set to a strong random value in production. " - "Set the SECRET_KEY environment variable." - ) - JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=24) - SQLALCHEMY_DATABASE_URI = os.environ.get( - "DATABASE_URL", "sqlite:///dev.db" - ) - SQLALCHEMY_TRACK_MODIFICATIONS = False - MAX_CONTENT_LENGTH = 16 * 1024 * 1024 - STORAGE_ROOT = os.environ.get("STORAGE_ROOT", "/app/storage") - - GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") - GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") - GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "") - GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "") - - # Set to "true" in .env to require users to confirm their email before logging in. - # When disabled (default), local accounts are confirmed immediately on registration. - # Confirmation URL is always logged to the container logs for debugging. - EMAIL_CONFIRMATION_REQUIRED: bool = ( - os.environ.get("EMAIL_CONFIRMATION_REQUIRED", "false").lower() == "true" - ) diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index 3b560f2..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,50 +0,0 @@ -services: - db: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_DB: ballistic - POSTGRES_USER: ballistic - POSTGRES_PASSWORD: "${DB_PASSWORD}" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ballistic"] - interval: 10s - timeout: 5s - retries: 5 - networks: - local_net: - aliases: - - ${COMPOSE_PROJECT_NAME}-db - - web: - build: . - restart: unless-stopped - depends_on: - db: - condition: service_healthy - env_file: - - .env - environment: - DATABASE_URL: "postgresql+psycopg://ballistic:${DB_PASSWORD}@db:5432/ballistic" - STORAGE_ROOT: "/app/storage" - volumes: - - app_storage:/app/storage - - .:/app # bind-mount source so code changes are live without a rebuild - networks: - local_net: - aliases: - - ${COMPOSE_PROJECT_NAME}-web - - - -volumes: - postgres_data: - app_storage: - - -networks: - local_net: - name: ${NETWORK} - external: true diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index 1c84e92..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -e - -mkdir -p /app/storage/csvs /app/storage/pdfs /app/storage/equipment_photos - -pybabel compile -d translations 2>/dev/null || true - -flask db upgrade - -exec python -m gunicorn --bind 0.0.0.0:5000 --workers 2 "app:create_app()" diff --git a/extensions.py b/extensions.py deleted file mode 100644 index a67819b..0000000 --- a/extensions.py +++ /dev/null @@ -1,16 +0,0 @@ -from authlib.integrations.flask_client import OAuth -from flask_babel import Babel, lazy_gettext as _l -from flask_jwt_extended import JWTManager -from flask_login import LoginManager -from flask_migrate import Migrate -from flask_sqlalchemy import SQLAlchemy - -db = SQLAlchemy() -login_manager = LoginManager() -migrate = Migrate() -oauth = OAuth() -jwt = JWTManager() -babel = Babel() - -login_manager.login_view = "auth.login" -login_manager.login_message = _l("Please log in to access this page.") diff --git a/memory/feedback_no_host_install.md b/memory/feedback_no_host_install.md deleted file mode 100644 index fd7e8c9..0000000 --- a/memory/feedback_no_host_install.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: no_host_pip_install -description: Do not run pip install on the host machine; user is on Debian with externally-managed Python. All dependency testing must happen inside Docker containers. -type: feedback ---- - -Do not run `pip install` on the host machine. - -**Why:** The host is Debian with an externally-managed Python environment; pip installs are blocked system-wide. - -**How to apply:** Any testing that requires libraries not already on the host must be done inside a Docker container (`docker compose run` or `docker build`). Skip host-level install tests entirely and note to the user that they should verify inside the container. diff --git a/migrations/README b/migrations/README deleted file mode 100644 index 0e04844..0000000 --- a/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini deleted file mode 100644 index ec9d45c..0000000 --- a/migrations/alembic.ini +++ /dev/null @@ -1,50 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic,flask_migrate - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[logger_flask_migrate] -level = INFO -handlers = -qualname = flask_migrate - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py deleted file mode 100644 index 4c97092..0000000 --- a/migrations/env.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging -from logging.config import fileConfig - -from flask import current_app - -from alembic import context - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') - - -def get_engine(): - try: - # this works with Flask-SQLAlchemy<3 and Alchemical - return current_app.extensions['migrate'].db.get_engine() - except (TypeError, AttributeError): - # this works with Flask-SQLAlchemy>=3 - return current_app.extensions['migrate'].db.engine - - -def get_engine_url(): - try: - return get_engine().url.render_as_string(hide_password=False).replace( - '%', '%%') - except AttributeError: - return str(get_engine().url).replace('%', '%%') - - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -config.set_main_option('sqlalchemy.url', get_engine_url()) -target_db = current_app.extensions['migrate'].db - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def get_metadata(): - if hasattr(target_db, 'metadatas'): - return target_db.metadatas[None] - return target_db.metadata - - -def run_migrations_offline(): - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, target_metadata=get_metadata(), literal_binds=True - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online(): - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - - # this callback is used to prevent an auto-migration from being generated - # when there are no changes to the schema - # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html - def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, 'autogenerate', False): - script = directives[0] - if script.upgrade_ops.is_empty(): - directives[:] = [] - logger.info('No changes in schema detected.') - - conf_args = current_app.extensions['migrate'].configure_args - if conf_args.get("process_revision_directives") is None: - conf_args["process_revision_directives"] = process_revision_directives - - connectable = get_engine() - - with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=get_metadata(), - **conf_args - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako deleted file mode 100644 index 2c01563..0000000 --- a/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/03057ef71b9c_user_bio.py b/migrations/versions/03057ef71b9c_user_bio.py deleted file mode 100644 index 043029d..0000000 --- a/migrations/versions/03057ef71b9c_user_bio.py +++ /dev/null @@ -1,32 +0,0 @@ -"""user bio - -Revision ID: 03057ef71b9c -Revises: 52a38793e62e -Create Date: 2026-03-17 14:57:43.452741 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '03057ef71b9c' -down_revision = '52a38793e62e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('bio', sa.Text(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('bio') - - # ### end Alembic commands ### diff --git a/migrations/versions/1bc445c89261_drop_session_title.py b/migrations/versions/1bc445c89261_drop_session_title.py deleted file mode 100644 index fa50631..0000000 --- a/migrations/versions/1bc445c89261_drop_session_title.py +++ /dev/null @@ -1,32 +0,0 @@ -"""drop session title - -Revision ID: 1bc445c89261 -Revises: a403e38c1c2e -Create Date: 2026-03-17 13:32:53.010390 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '1bc445c89261' -down_revision = 'a403e38c1c2e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.drop_column('title') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.add_column(sa.Column('title', sa.VARCHAR(length=255), autoincrement=False, nullable=False)) - - # ### end Alembic commands ### diff --git a/migrations/versions/1ec8afb14573_initial_schema.py b/migrations/versions/1ec8afb14573_initial_schema.py deleted file mode 100644 index 5503e1e..0000000 --- a/migrations/versions/1ec8afb14573_initial_schema.py +++ /dev/null @@ -1,101 +0,0 @@ -"""initial schema - -Revision ID: 1ec8afb14573 -Revises: -Create Date: 2026-03-17 09:30:15.508359 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '1ec8afb14573' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('display_name', sa.String(length=120), nullable=True), - sa.Column('avatar_url', sa.Text(), nullable=True), - sa.Column('provider', sa.String(length=20), nullable=False), - sa.Column('provider_id', sa.String(length=255), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email'), - sa.UniqueConstraint('provider', 'provider_id') - ) - op.create_table('equipment_items', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('category', sa.String(length=30), nullable=False), - sa.Column('name', sa.String(length=200), nullable=False), - sa.Column('brand', sa.String(length=120), nullable=True), - sa.Column('model', sa.String(length=120), nullable=True), - sa.Column('serial_number', sa.String(length=120), nullable=True), - sa.Column('caliber', sa.String(length=60), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('photo_path', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('shooting_sessions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(length=255), nullable=False), - sa.Column('session_date', sa.Date(), nullable=False), - sa.Column('location_name', sa.String(length=255), nullable=True), - sa.Column('location_lat', sa.Double(), nullable=True), - sa.Column('location_lon', sa.Double(), nullable=True), - sa.Column('distance_m', sa.Integer(), nullable=True), - sa.Column('weather_temp_c', sa.Numeric(precision=5, scale=1), nullable=True), - sa.Column('weather_wind_kph', sa.Numeric(precision=5, scale=1), nullable=True), - sa.Column('weather_cond', sa.String(length=80), nullable=True), - sa.Column('rifle_id', sa.Integer(), nullable=True), - sa.Column('scope_id', sa.Integer(), nullable=True), - sa.Column('ammo_brand', sa.String(length=120), nullable=True), - sa.Column('ammo_weight_gr', sa.Numeric(precision=7, scale=2), nullable=True), - sa.Column('ammo_lot', sa.String(length=80), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['rifle_id'], ['equipment_items.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['scope_id'], ['equipment_items.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('analyses', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('session_id', sa.Integer(), nullable=True), - sa.Column('title', sa.String(length=255), nullable=False), - sa.Column('is_public', sa.Boolean(), nullable=False), - sa.Column('csv_path', sa.Text(), nullable=False), - sa.Column('pdf_path', sa.Text(), nullable=True), - sa.Column('overall_stats', sa.JSON(), nullable=False), - sa.Column('group_stats', sa.JSON(), nullable=False), - sa.Column('shot_count', sa.Integer(), nullable=False), - sa.Column('group_count', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['session_id'], ['shooting_sessions.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('analyses') - op.drop_table('shooting_sessions') - op.drop_table('equipment_items') - op.drop_table('users') - # ### end Alembic commands ### diff --git a/migrations/versions/2b8adad5972b_local_auth_fields.py b/migrations/versions/2b8adad5972b_local_auth_fields.py deleted file mode 100644 index 080f952..0000000 --- a/migrations/versions/2b8adad5972b_local_auth_fields.py +++ /dev/null @@ -1,38 +0,0 @@ -"""local auth fields - -Revision ID: 2b8adad5972b -Revises: 1ec8afb14573 -Create Date: 2026-03-17 09:46:47.843894 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '2b8adad5972b' -down_revision = '1ec8afb14573' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('password_hash', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('email_confirmed', sa.Boolean(), nullable=False)) - batch_op.add_column(sa.Column('email_confirm_token', sa.String(length=128), nullable=True)) - batch_op.create_index('ix_users_email_confirm_token', ['email_confirm_token'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_index('ix_users_email_confirm_token') - batch_op.drop_column('email_confirm_token') - batch_op.drop_column('email_confirmed') - batch_op.drop_column('password_hash') - - # ### end Alembic commands ### diff --git a/migrations/versions/52a38793e62e_user_show_equipment_public.py b/migrations/versions/52a38793e62e_user_show_equipment_public.py deleted file mode 100644 index 4effc4e..0000000 --- a/migrations/versions/52a38793e62e_user_show_equipment_public.py +++ /dev/null @@ -1,32 +0,0 @@ -"""user show equipment public - -Revision ID: 52a38793e62e -Revises: 1bc445c89261 -Create Date: 2026-03-17 14:47:03.751535 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '52a38793e62e' -down_revision = '1bc445c89261' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('show_equipment_public', sa.Boolean(), nullable=False, server_default=sa.false())) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('show_equipment_public') - - # ### end Alembic commands ### diff --git a/migrations/versions/6818f37f4124_user_role_and_language.py b/migrations/versions/6818f37f4124_user_role_and_language.py deleted file mode 100644 index 7dd4f30..0000000 --- a/migrations/versions/6818f37f4124_user_role_and_language.py +++ /dev/null @@ -1,44 +0,0 @@ -"""user_role_and_language - -Revision ID: 6818f37f4124 -Revises: bf96ceb7f076 -Create Date: 2026-03-19 15:51:15.091825 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '6818f37f4124' -down_revision = 'bf96ceb7f076' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('role', sa.String(length=20), nullable=True)) - batch_op.add_column(sa.Column('language', sa.String(length=10), nullable=True)) - - op.execute("UPDATE users SET role = 'user' WHERE role IS NULL") - - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.alter_column('role', nullable=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('language') - batch_op.drop_column('role') - - op.execute("UPDATE users SET role = 'user' WHERE role IS NULL") - - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.alter_column('role', nullable=False) - - # ### end Alembic commands ### diff --git a/migrations/versions/875675ed7b5a_scope_fields.py b/migrations/versions/875675ed7b5a_scope_fields.py deleted file mode 100644 index 50e4923..0000000 --- a/migrations/versions/875675ed7b5a_scope_fields.py +++ /dev/null @@ -1,36 +0,0 @@ -"""scope fields - -Revision ID: 875675ed7b5a -Revises: eb04fe02f528 -Create Date: 2026-03-17 11:17:08.772131 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '875675ed7b5a' -down_revision = 'eb04fe02f528' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('equipment_items', schema=None) as batch_op: - batch_op.add_column(sa.Column('magnification', sa.String(length=50), nullable=True)) - batch_op.add_column(sa.Column('reticle', sa.String(length=10), nullable=True)) - batch_op.add_column(sa.Column('unit', sa.String(length=10), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('equipment_items', schema=None) as batch_op: - batch_op.drop_column('unit') - batch_op.drop_column('reticle') - batch_op.drop_column('magnification') - - # ### end Alembic commands ### diff --git a/migrations/versions/a403e38c1c2e_user_avatar_path.py b/migrations/versions/a403e38c1c2e_user_avatar_path.py deleted file mode 100644 index ba193c8..0000000 --- a/migrations/versions/a403e38c1c2e_user_avatar_path.py +++ /dev/null @@ -1,32 +0,0 @@ -"""user avatar path - -Revision ID: a403e38c1c2e -Revises: b94b21ec5fa9 -Create Date: 2026-03-17 12:50:50.122814 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'a403e38c1c2e' -down_revision = 'b94b21ec5fa9' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('avatar_path', sa.Text(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('avatar_path') - - # ### end Alembic commands ### diff --git a/migrations/versions/a9f3d82c1e47_analysis_group_photos.py b/migrations/versions/a9f3d82c1e47_analysis_group_photos.py deleted file mode 100644 index 2928e16..0000000 --- a/migrations/versions/a9f3d82c1e47_analysis_group_photos.py +++ /dev/null @@ -1,34 +0,0 @@ -"""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') diff --git a/migrations/versions/b94b21ec5fa9_session_photos.py b/migrations/versions/b94b21ec5fa9_session_photos.py deleted file mode 100644 index 5d0261f..0000000 --- a/migrations/versions/b94b21ec5fa9_session_photos.py +++ /dev/null @@ -1,36 +0,0 @@ -"""session photos - -Revision ID: b94b21ec5fa9 -Revises: 875675ed7b5a -Create Date: 2026-03-17 11:41:05.860460 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'b94b21ec5fa9' -down_revision = '875675ed7b5a' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('session_photos', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('session_id', 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=False), - sa.ForeignKeyConstraint(['session_id'], ['shooting_sessions.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('session_photos') - # ### end Alembic commands ### diff --git a/migrations/versions/bf96ceb7f076_prs_stages_and_drop_competition_fields.py b/migrations/versions/bf96ceb7f076_prs_stages_and_drop_competition_fields.py deleted file mode 100644 index 69ea61b..0000000 --- a/migrations/versions/bf96ceb7f076_prs_stages_and_drop_competition_fields.py +++ /dev/null @@ -1,36 +0,0 @@ -"""prs_stages and drop competition fields - -Revision ID: bf96ceb7f076 -Revises: edf627601b3d -Create Date: 2026-03-18 15:41:28.882592 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'bf96ceb7f076' -down_revision = 'edf627601b3d' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.add_column(sa.Column('prs_stages', sa.JSON(), nullable=True)) - batch_op.drop_column('competition_stage') - batch_op.drop_column('competition_division') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.add_column(sa.Column('competition_division', sa.VARCHAR(length=100), autoincrement=False, nullable=True)) - batch_op.add_column(sa.Column('competition_stage', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) - batch_op.drop_column('prs_stages') - - # ### end Alembic commands ### diff --git a/migrations/versions/d46dc696b3c3_session_photo_annotations.py b/migrations/versions/d46dc696b3c3_session_photo_annotations.py deleted file mode 100644 index 95d90a4..0000000 --- a/migrations/versions/d46dc696b3c3_session_photo_annotations.py +++ /dev/null @@ -1,32 +0,0 @@ -"""session_photo annotations - -Revision ID: d46dc696b3c3 -Revises: 03057ef71b9c -Create Date: 2026-03-17 15:35:22.180323 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'd46dc696b3c3' -down_revision = '03057ef71b9c' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('session_photos', schema=None) as batch_op: - batch_op.add_column(sa.Column('annotations', sa.JSON(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('session_photos', schema=None) as batch_op: - batch_op.drop_column('annotations') - - # ### end Alembic commands ### diff --git a/migrations/versions/eb04fe02f528_session_is_public.py b/migrations/versions/eb04fe02f528_session_is_public.py deleted file mode 100644 index 6c29233..0000000 --- a/migrations/versions/eb04fe02f528_session_is_public.py +++ /dev/null @@ -1,32 +0,0 @@ -"""session is_public - -Revision ID: eb04fe02f528 -Revises: 2b8adad5972b -Create Date: 2026-03-17 10:13:53.102589 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'eb04fe02f528' -down_revision = '2b8adad5972b' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_public', sa.Boolean(), nullable=False)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.drop_column('is_public') - - # ### end Alembic commands ### diff --git a/migrations/versions/edf627601b3d_session_type_and_analysis_grouping_.py b/migrations/versions/edf627601b3d_session_type_and_analysis_grouping_.py deleted file mode 100644 index cef9a79..0000000 --- a/migrations/versions/edf627601b3d_session_type_and_analysis_grouping_.py +++ /dev/null @@ -1,49 +0,0 @@ -"""session_type and analysis_grouping_fields - -Revision ID: edf627601b3d -Revises: d46dc696b3c3 -Create Date: 2026-03-18 14:12:25.811674 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'edf627601b3d' -down_revision = 'd46dc696b3c3' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('analyses', schema=None) as batch_op: - batch_op.add_column(sa.Column('grouping_outlier_factor', sa.Double(), nullable=True)) - batch_op.add_column(sa.Column('grouping_manual_splits', sa.JSON(), nullable=True)) - - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.add_column(sa.Column('session_type', sa.String(length=50), nullable=True)) - batch_op.add_column(sa.Column('competition_stage', sa.String(length=255), nullable=True)) - batch_op.add_column(sa.Column('competition_division', sa.String(length=100), nullable=True)) - batch_op.add_column(sa.Column('shooting_position', sa.String(length=100), nullable=True)) - - # Backfill existing sessions as long_range precision - op.execute("UPDATE shooting_sessions SET session_type = 'long_range' WHERE session_type IS NULL") - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: - batch_op.drop_column('shooting_position') - batch_op.drop_column('competition_division') - batch_op.drop_column('competition_stage') - batch_op.drop_column('session_type') - - with op.batch_alter_table('analyses', schema=None) as batch_op: - batch_op.drop_column('grouping_manual_splits') - batch_op.drop_column('grouping_outlier_factor') - - # ### end Alembic commands ### diff --git a/models.py b/models.py deleted file mode 100644 index 2ccae43..0000000 --- a/models.py +++ /dev/null @@ -1,207 +0,0 @@ -from datetime import date, datetime, timezone - -from flask_login import UserMixin -from sqlalchemy import ( - Boolean, - Date, - DateTime, - Double, - ForeignKey, - Index, - Integer, - Numeric, - String, - Text, -) -from sqlalchemy.orm import Mapped, mapped_column, relationship -from werkzeug.security import check_password_hash, generate_password_hash - -from extensions import db - - -def _now() -> datetime: - return datetime.now(timezone.utc) - - -class User(UserMixin, db.Model): - __tablename__ = "users" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - display_name: Mapped[str | None] = mapped_column(String(120)) - avatar_url: Mapped[str | None] = mapped_column(Text) # OAuth-provided URL - avatar_path: Mapped[str | None] = mapped_column(Text) # locally uploaded file - provider: Mapped[str] = mapped_column(String(20), nullable=False) # 'google' | 'github' | 'local' - provider_id: Mapped[str] = mapped_column(String(255), nullable=False) - password_hash: Mapped[str | None] = mapped_column(Text) - email_confirmed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - email_confirm_token: Mapped[str | None] = mapped_column(String(128)) - show_equipment_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - bio: Mapped[str | None] = mapped_column(Text) - role: Mapped[str] = mapped_column(String(20), nullable=False, default="user") - language: Mapped[str | None] = mapped_column(String(10)) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - - __table_args__ = ( - db.UniqueConstraint("provider", "provider_id"), - Index("ix_users_email_confirm_token", "email_confirm_token"), - ) - - analyses: Mapped[list["Analysis"]] = relationship( - "Analysis", back_populates="user", cascade="all, delete-orphan" - ) - equipment: Mapped[list["EquipmentItem"]] = relationship( - "EquipmentItem", back_populates="user", cascade="all, delete-orphan" - ) - sessions: Mapped[list["ShootingSession"]] = relationship( - "ShootingSession", back_populates="user", cascade="all, delete-orphan" - ) - - @property - def effective_avatar_url(self) -> str | None: - if self.avatar_path: - return f"/auth/avatar/{self.id}" - return self.avatar_url - - def set_password(self, password: str) -> None: - self.password_hash = generate_password_hash(password) - - def check_password(self, password: str) -> bool: - return bool(self.password_hash and check_password_hash(self.password_hash, password)) - - -class EquipmentItem(db.Model): - __tablename__ = "equipment_items" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - # 'rifle' | 'handgun' | 'scope' | 'other' - category: Mapped[str] = mapped_column(String(30), nullable=False) - name: Mapped[str] = mapped_column(String(200), nullable=False) - brand: Mapped[str | None] = mapped_column(String(120)) - model: Mapped[str | None] = mapped_column(String(120)) - serial_number: Mapped[str | None] = mapped_column(String(120)) - caliber: Mapped[str | None] = mapped_column(String(60)) - magnification: Mapped[str | None] = mapped_column(String(50)) - reticle: Mapped[str | None] = mapped_column(String(10)) - unit: Mapped[str | None] = mapped_column(String(10)) - notes: Mapped[str | None] = mapped_column(Text) - photo_path: Mapped[str | None] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) - - user: Mapped["User"] = relationship("User", back_populates="equipment") - - @property - def photo_url(self) -> str | None: - if not self.photo_path: - return None - rel = self.photo_path.removeprefix("equipment_photos/") - return f"/equipment/photos/{rel}" - - -class ShootingSession(db.Model): - __tablename__ = "shooting_sessions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - session_date: Mapped[date] = mapped_column(Date, nullable=False) - location_name: Mapped[str | None] = mapped_column(String(255)) - location_lat: Mapped[float | None] = mapped_column(Double) - location_lon: Mapped[float | None] = mapped_column(Double) - distance_m: Mapped[int | None] = mapped_column(Integer) - weather_temp_c: Mapped[float | None] = mapped_column(Numeric(5, 1)) - weather_wind_kph: Mapped[float | None] = mapped_column(Numeric(5, 1)) - weather_cond: Mapped[str | None] = mapped_column(String(80)) - rifle_id: Mapped[int | None] = mapped_column(ForeignKey("equipment_items.id", ondelete="SET NULL")) - scope_id: Mapped[int | None] = mapped_column(ForeignKey("equipment_items.id", ondelete="SET NULL")) - ammo_brand: Mapped[str | None] = mapped_column(String(120)) - ammo_weight_gr: Mapped[float | None] = mapped_column(Numeric(7, 2)) - ammo_lot: Mapped[str | None] = mapped_column(String(80)) - notes: Mapped[str | None] = mapped_column(Text) - session_type: Mapped[str | None] = mapped_column(String(50)) - shooting_position: Mapped[str | None] = mapped_column(String(100)) - prs_stages: Mapped[list | None] = mapped_column(db.JSON) - is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) - - @property - def label(self) -> str: - date_str = self.session_date.strftime("%d %b %Y") - return f"{date_str} — {self.location_name}" if self.location_name else date_str - - user: Mapped["User"] = relationship("User", back_populates="sessions") - rifle: Mapped["EquipmentItem | None"] = relationship("EquipmentItem", foreign_keys=[rifle_id]) - scope: Mapped["EquipmentItem | None"] = relationship("EquipmentItem", foreign_keys=[scope_id]) - analyses: Mapped[list["Analysis"]] = relationship("Analysis", back_populates="session") - photos: Mapped[list["SessionPhoto"]] = relationship( - "SessionPhoto", back_populates="session", cascade="all, delete-orphan", - order_by="SessionPhoto.created_at" - ) - - -class Analysis(db.Model): - __tablename__ = "analyses" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) - session_id: Mapped[int | None] = mapped_column(ForeignKey("shooting_sessions.id", ondelete="SET NULL")) - title: Mapped[str] = mapped_column(String(255), nullable=False) - is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - csv_path: Mapped[str] = mapped_column(Text, nullable=False) - pdf_path: Mapped[str | None] = mapped_column(Text) - overall_stats: Mapped[dict] = mapped_column(db.JSON, nullable=False) - group_stats: Mapped[list] = mapped_column(db.JSON, nullable=False) - shot_count: Mapped[int] = mapped_column(Integer, nullable=False) - group_count: Mapped[int] = mapped_column(Integer, nullable=False) - grouping_outlier_factor: Mapped[float | None] = mapped_column(Double) - grouping_manual_splits: Mapped[list | None] = mapped_column(db.JSON) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - - 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): - __tablename__ = "session_photos" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - session_id: Mapped[int] = mapped_column(ForeignKey("shooting_sessions.id"), nullable=False) - photo_path: Mapped[str] = mapped_column(Text, nullable=False) - caption: Mapped[str | None] = mapped_column(String(255)) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - - annotations: Mapped[dict | None] = mapped_column(db.JSON) - - session: Mapped["ShootingSession"] = relationship("ShootingSession", back_populates="photos") - - @property - def photo_url(self) -> str: - rel = self.photo_path.removeprefix("session_photos/") - return f"/sessions/photos/{rel}" - - -class AnalysisGroupPhoto(db.Model): - __tablename__ = "analysis_group_photos" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - analysis_id: Mapped[int] = mapped_column(ForeignKey("analyses.id"), nullable=False) - group_index: Mapped[int] = mapped_column(Integer, nullable=False) - photo_path: Mapped[str] = mapped_column(Text, nullable=False) - caption: Mapped[str | None] = mapped_column(String(255)) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - - annotations: Mapped[dict | None] = mapped_column(db.JSON) - - analysis: Mapped["Analysis"] = relationship("Analysis", back_populates="group_photos") - - @property - def photo_url(self) -> str: - rel = self.photo_path.removeprefix("analysis_group_photos/") - return f"/analyses/group-photos/{rel}" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a638c2e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -Flask>=3.0 -Flask-Babel>=3.0 -Flask-WTF>=1.2 -python-dotenv>=1.0 -Flask-SQLAlchemy>=3.1 -Flask-Migrate>=4.0 -Flask-Login>=0.6 -Flask-JWT-Extended>=4.6 -Authlib>=1.3 -httpx>=0.27 -requests>=2.31 -psycopg[binary]>=3.1 -Pillow>=10.0 -pandas>=1.5 -matplotlib>=3.6 -numpy>=1.24 -gunicorn>=21.0 -fpdf2>=2.7 diff --git a/sample.csv b/sample.csv deleted file mode 100644 index a682a18..0000000 --- a/sample.csv +++ /dev/null @@ -1,63 +0,0 @@ -Balle de fusil 169,0 gr -#,Vitesse (MPS),Δ Moyenne (MPS),ÉK (J),Facteur de puissance (N s),Temps,Âme nettoyée,Âme froide,Notes de tir -1,"807,4","6,1","3569,4","8,8",09:30:37,,, -2,"801,1","-0,2","3514,0","8,8",09:31:06,,, -3,"799,3","-2,0","3498,4","8,8",09:31:28,,, -4,"800,1","-1,3","3505,0","8,8",09:32:17,,, -5,"800,3","-1,0","3507,1","8,8",09:32:36,,, -6,"789,5","-11,8","3412,9","8,6",09:49:40,,, -7,"800,7","-0,7","3510,2","8,8",09:49:54,,, -8,"795,1","-6,2","3461,6","8,7",09:50:10,,, -9,"799,8","-1,5","3502,6","8,8",09:50:27,,, -10,"798,9","-2,4","3494,8","8,7",09:50:57,,, -11,"795,4","-5,9","3464,0","8,7",09:53:04,,, -12,"798,6","-2,7","3492,0","8,7",09:53:15,,, -13,"798,1","-3,2","3487,8","8,7",09:53:28,,, -14,"798,3","-3,0","3489,3","8,7",09:53:44,,, -15,"797,6","-3,7","3483,7","8,7",09:54:02,,, -16,"800,3","-1,0","3506,9","8,8",10:08:55,,, -17,"800,6","-0,7","3509,9","8,8",10:09:24,,, -18,"804,6","3,2","3544,5","8,8",10:09:38,,, -19,"799,8","-1,6","3502,4","8,8",10:09:55,,, -20,"802,1","0,8","3523,0","8,8",10:10:15,,, -21,"802,0","0,6","3521,7","8,8",10:26:11,,, -22,"804,0","2,7","3539,7","8,8",10:26:32,,, -23,"805,1","3,8","3549,4","8,8",10:27:23,,, -24,"802,8","1,5","3529,3","8,8",10:27:50,,, -25,"805,8","4,4","3555,1","8,8",10:28:15,,, -26,"808,1","6,8","3575,9","8,8",10:43:45,,, -27,"798,5","-2,9","3490,8","8,7",10:44:04,,, -28,"798,4","-3,0","3489,9","8,7",10:44:20,,, -29,"801,6","0,3","3518,7","8,8",10:44:48,,, -30,"800,1","-1,2","3505,5","8,8",10:45:10,,, -31,"802,4","1,1","3525,3","8,8",10:57:50,,, -32,"796,9","-4,5","3477,1","8,7",10:58:08,,, -33,"800,9","-0,5","3511,8","8,8",10:58:27,,, -34,"800,7","-0,7","3510,2","8,8",10:58:43,,, -35,"797,3","-4,1","3480,3","8,7",10:59:02,,, -36,"796,2","-5,1","3471,0","8,7",11:14:15,,, -37,"809,7","8,4","3589,9","8,9",11:14:37,,, -38,"802,9","1,6","3529,6","8,8",11:15:02,,, -39,"810,3","8,9","3594,7","8,9",11:15:27,,, -40,"804,2","2,9","3541,5","8,8",11:15:57,,, -41,"807,1","5,8","3567,2","8,8",11:28:00,,, -42,"805,4","4,1","3552,0","8,8",11:28:20,,, -43,"802,0","0,7","3522,2","8,8",11:28:50,,, -44,"804,6","3,3","3544,6","8,8",11:29:11,,, -45,"807,6","6,3","3571,4","8,8",11:29:32,,, -46,"807,3","6,0","3568,5","8,8",11:30:05,,, -47,"795,8","-5,5","3467,8","8,7",11:30:19,,, -48,"798,2","-3,1","3488,9","8,7",11:30:36,,, -49,"801,9","0,5","3520,6","8,8",11:30:49,,, -50,"801,2","-0,2","3514,5","8,8",11:31:10,,, --,,,,,, -VITESSE MOYENNE,"801,3",,,,, -FACTEUR DE PUISSANCE MOYEN,"8,8",,,,, -ÉCART-TYPE,"4,1",,,,, -SPREAD,"20,8",,,,, -Poids du projectile (GRAINS),"169,0",,,,, -ÉNERGIE CINÉTIQUE MOY.,"3516,1",,,,, -Note sur la session,,,,,, --,,,,,, -Date,"MARS 16, 2026 09:13",,,,, -Tous les tirs inclus dans les calculs,,,,,, diff --git a/storage.py b/storage.py deleted file mode 100644 index a26353c..0000000 --- a/storage.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -File storage helpers. - -All paths are relative to Config.STORAGE_ROOT (a Docker volume at /app/storage). -Layout: - csvs/{user_id}/{analysis_id}_{filename} - pdfs/{user_id}/{analysis_id}_report.pdf - equipment_photos/{user_id}/{item_id}_{uuid}.jpg - session_photos/{user_id}/{session_id}_{uuid}.jpg -""" - -import io -import uuid -from pathlib import Path - -from flask import current_app -from PIL import Image - - -def _root() -> Path: - return Path(current_app.config["STORAGE_ROOT"]) - - -def _ensure(path: Path) -> Path: - path.mkdir(parents=True, exist_ok=True) - return path - - -# --------------------------------------------------------------------------- -# Analysis files -# --------------------------------------------------------------------------- - -def _to_python(obj): - """Recursively convert numpy scalars/arrays to plain Python types for JSON storage.""" - if isinstance(obj, dict): - return {k: _to_python(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_to_python(v) for v in obj] - if hasattr(obj, "item"): # numpy scalar → int/float - return obj.item() - if hasattr(obj, "tolist"): # numpy array → list - return obj.tolist() - return obj - - -def save_analysis(*, user, csv_bytes: bytes, pdf_bytes: bytes, overall: dict, - group_stats: list, filename: str, session_id: int | None = None, - is_public: bool = False) -> int: - """Persist a completed analysis for a logged-in user. Returns the new Analysis.id.""" - from extensions import db - from models import Analysis - - overall = _to_python(overall) - group_stats = _to_python(group_stats) - shot_count = int(overall.get("count", 0)) - group_count = len(group_stats) - - analysis = Analysis( - user_id=user.id, - session_id=session_id, - is_public=is_public, - title=_default_title(filename), - csv_path="", # filled in below - pdf_path="", - overall_stats=overall, - group_stats=group_stats, - shot_count=shot_count, - group_count=group_count, - ) - db.session.add(analysis) - db.session.flush() # assigns analysis.id without committing - - csv_dir = _ensure(_root() / "csvs" / str(user.id)) - pdf_dir = _ensure(_root() / "pdfs" / str(user.id)) - - safe_name = Path(filename).name.replace(" ", "_") - csv_rel = f"csvs/{user.id}/{analysis.id}_{safe_name}" - pdf_rel = f"pdfs/{user.id}/{analysis.id}_report.pdf" - - (csv_dir / f"{analysis.id}_{safe_name}").write_bytes(csv_bytes) - (pdf_dir / f"{analysis.id}_report.pdf").write_bytes(pdf_bytes) - - analysis.csv_path = csv_rel - analysis.pdf_path = pdf_rel - db.session.commit() - - return analysis.id - - -def _default_title(filename: str) -> str: - stem = Path(filename).stem.replace("_", " ").replace("-", " ") - return stem[:255] if stem else "Analysis" - - -# --------------------------------------------------------------------------- -# Equipment photos -# --------------------------------------------------------------------------- - -MAX_PHOTO_DIM = 1200 -PHOTO_QUALITY = 85 - - -def _save_photo(file_storage, dest_dir: Path, prefix: str) -> str: - """ - Validate, resize, re-encode as JPEG, and save an uploaded photo. - Returns the path relative to STORAGE_ROOT. - Raises ValueError on invalid image data. - """ - try: - img = Image.open(file_storage) - img.verify() - file_storage.seek(0) - img = Image.open(file_storage) - img = img.convert("RGB") - except Exception as exc: - raise ValueError(f"Invalid image file: {exc}") from exc - - img.thumbnail((MAX_PHOTO_DIM, MAX_PHOTO_DIM), Image.LANCZOS) - - _ensure(dest_dir) - unique = uuid.uuid4().hex - filename = f"{prefix}_{unique}.jpg" - - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=PHOTO_QUALITY, optimize=True) - (dest_dir / filename).write_bytes(buf.getvalue()) - - return str(dest_dir.relative_to(_root()) / filename) - - -def save_equipment_photo(user_id: int, item_id: int, file_storage) -> str: - dest = _root() / "equipment_photos" / str(user_id) - return _save_photo(file_storage, dest, str(item_id)) - - -def save_session_photo(user_id: int, session_id: int, file_storage) -> str: - dest = _root() / "session_photos" / str(user_id) - return _save_photo(file_storage, dest, str(session_id)) - - -def save_avatar(user_id: int, file_storage) -> str: - dest = _root() / "avatars" / str(user_id) - 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 - if not path.exists(): - return - img = Image.open(path).convert("RGB") - # Pillow rotates counter-clockwise; negate to get clockwise behaviour - rotated = img.rotate(-degrees, expand=True) - buf = io.BytesIO() - rotated.save(buf, format="JPEG", quality=PHOTO_QUALITY, optimize=True) - path.write_bytes(buf.getvalue()) diff --git a/templates/admin/users.html b/templates/admin/users.html deleted file mode 100644 index b28f631..0000000 --- a/templates/admin/users.html +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Admin — Users') }} — The Shooter's Network{% endblock %} -{% block content %} - -
-

{{ _('User Management') }}

- {{ users|length }} {{ _('users') }} -
- -
- - - - - - - - - - - - - - {% for u in users %} - - {# User info #} - - - {# Provider #} - - - {# Role badge + change form #} - - - {# Language #} - - - {# Dates #} - - - - {# Actions #} - - - {% endfor %} - -
{{ _('User') }}{{ _('Provider') }}{{ _('Role') }}{{ _('Language') }}{{ _('Joined') }}{{ _('Last login') }}{{ _('Actions') }}
-
- {% if u.effective_avatar_url %} - - {% else %} -
- {{ (u.display_name or u.email)[0].upper() }} -
- {% endif %} -
-
{{ u.display_name or '—' }}
-
{{ u.email }}
-
-
-
- {% if u.provider == 'google' %}🔵 Google - {% elif u.provider == 'github' %}⚫ GitHub - {% else %}🔑 {{ _('Local') }} - {% endif %} - -
- - -
-
- {{ u.language or '—' }} - {{ u.created_at.strftime('%d %b %Y') }} - {{ u.last_login_at.strftime('%d %b %Y') if u.last_login_at else '—' }} - -
- - {# Reset password (local accounts only) #} - {% if u.provider == 'local' %} -
- - 🔑 {{ _('Reset pwd') }} - -
- - -
-
- {% endif %} - - {# Delete — cannot delete yourself #} - {% if u.id != current_user.id %} -
- -
- {% else %} - {{ _('(you)') }} - {% endif %} - -
-
-
- -{% endblock %} diff --git a/templates/analyses/detail.html b/templates/analyses/detail.html deleted file mode 100644 index 746a6c3..0000000 --- a/templates/analyses/detail.html +++ /dev/null @@ -1,93 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ analysis.title }} — The Shooter's Network{% endblock %} -{% block content %} - -
-
-
- {% if analysis.session_id %} - {{ _('Session') }} › - {% else %} - {{ _('Dashboard') }} › - {% endif %} - {{ _('Analysis') }} -
-

{{ analysis.title }}

-
- {{ analysis.created_at.strftime('%d %b %Y') }} -  ·  {{ analysis.shot_count }} {{ _('shot(s)') }} -  ·  {{ analysis.group_count }} {{ _('group(s)') }} -
-
-
- {% if has_pdf %} - - ⇓ {{ _('Download PDF report') }} - - {% endif %} - {% if current_user.is_authenticated and current_user.id == analysis.user_id %} -
- -
- {% endif %} - ← {{ _('New analysis') }} -
-
- -

{{ _('Overall Statistics') }}

- - - - - - - - - - - - - - -
{{ _('Metric') }}{{ _('Value') }}
{{ _('Total shots') }}{{ overall.count }}
{{ _('Min speed') }}{{ "%.4f"|format(overall.min_speed) }}
{{ _('Max speed') }}{{ "%.4f"|format(overall.max_speed) }}
{{ _('Mean speed') }}{{ "%.4f"|format(overall.mean_speed) }}
{{ _('Std dev (speed)') }} - {% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}–{% endif %} -
- -Avg speed and std dev per group - -

{{ _('Groups') }} — {{ groups_display|length }} {{ _('group(s) detected') }}

- -{% for stat, chart_b64 in groups_display %} -
-

{{ _('Group %(n)s', n=stat.group_index) }}

-
- {{ stat.time_start }} – {{ stat.time_end }}  |  {{ stat.count }} {{ _('shot(s)') }} -
- - - - - - - - - - - - - -
{{ _('Metric') }}{{ _('Value') }}
{{ _('Min speed') }}{{ "%.4f"|format(stat.min_speed) }}
{{ _('Max speed') }}{{ "%.4f"|format(stat.max_speed) }}
{{ _('Mean speed') }}{{ "%.4f"|format(stat.mean_speed) }}
{{ _('Std dev (speed)') }} - {% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}–{% endif %} -
- Speed chart for group {{ stat.group_index }} -
-{% endfor %} - -{% endblock %} diff --git a/templates/auth/confirm_pending.html b/templates/auth/confirm_pending.html deleted file mode 100644 index cc3104a..0000000 --- a/templates/auth/confirm_pending.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base.html" %} -{% block title %}Confirm your email — Ballistic Analyzer{% endblock %} -{% block content %} -

Check your inbox

- -

- A confirmation link has been sent to {{ email }}. - Click the link in that email to activate your account. -

- -

- Didn't receive it? Check your spam folder, or request a new link below. -

- -
- - -
-{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html deleted file mode 100644 index cc714bc..0000000 --- a/templates/auth/login.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Sign in') }} — Ballistic Analyzer{% endblock %} -{% block content %} -

{{ _('Sign in') }}

- -
-
- - -
-
- - -
- -
- -{% if show_resend %} -
- - -
-{% endif %} - -

- {{ _("Don't have an account?") }} {{ _('Create one') }} -

- -
-
- {{ _('or continue with') }} -
-
- - -{% endblock %} diff --git a/templates/auth/profile.html b/templates/auth/profile.html deleted file mode 100644 index 3a84024..0000000 --- a/templates/auth/profile.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Profile') }} — The Shooter's Network{% endblock %} -{% block content %} -

{{ _('Profile') }}

- -{# ---- Avatar + display name ---- #} -

{{ _('Account') }}

-
- - -
- {% set av = current_user.effective_avatar_url %} - {% if av %} - Avatar - {% else %} -
- 👤 -
- {% endif %} -
- - -
{{ _('JPEG/PNG, max 1200 px, auto-resized.') }}
-
-
- -
- - -
- -
- - -
- -
- - -
- Logged in via {{ current_user.provider.title() }} -
-
- -
- -
- -
- - - {{ _('View my public profile →') }} - -
-
- -{# ---- Change password (local accounts only) ---- #} -{% if current_user.provider == 'local' %} -

{{ _('Change password') }}

-
- - -
- - -
-
-
- - -
-
- - -
-
- -
-{% endif %} - -{% endblock %} diff --git a/templates/auth/public_profile.html b/templates/auth/public_profile.html deleted file mode 100644 index bbc6c38..0000000 --- a/templates/auth/public_profile.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ profile_user.display_name or profile_user.email.split('@')[0] }} — The Shooter's Network{% endblock %} -{% block content %} - -
- {% set av = profile_user.effective_avatar_url %} - {% if av %} - Avatar - {% else %} -
- 👤 -
- {% endif %} -
-

{{ profile_user.display_name or profile_user.email.split('@')[0] }}

-
- {{ _('Member since %(date)s', date=profile_user.created_at.strftime('%B %Y')) }} -
- {% if profile_user.bio %} -

{{ profile_user.bio }}

- {% endif %} -
-
- -{# ---- Public Sessions ---- #} -

{{ _('Sessions') }}{% if public_sessions %} ({{ public_sessions|length }}){% endif %}

- -{% if public_sessions %} - - - - - - {% for s in public_sessions %} - - - - - - {% endfor %} - -
{{ _('Session') }}{{ _('Location') }}{{ _('Distance') }}
- - {{ s.session_date.strftime('%d %b %Y') }} - - {{ s.location_name or '—' }}{% if s.distance_m %}{{ s.distance_m }} m{% else %}—{% endif %}
-{% else %} -

{{ _('No public sessions yet.') }}

-{% endif %} - -{# ---- Equipment (optional) ---- #} -{% if equipment is not none %} -

{{ _('Equipment') }}

-{% if equipment %} - - - - - - {% for item in equipment %} - - - - - - - {% endfor %} - -
{{ _('Name') }}{{ _('Category') }}{{ _('Brand / Model') }}{{ _('Caliber') }}
{{ item.name }}{{ item.category.title() }} - {% if item.brand or item.model %} - {{ item.brand or '' }}{% if item.brand and item.model %} {% endif %}{{ item.model or '' }} - {% else %}—{% endif %} - {{ item.caliber or '—' }}
-{% else %} -

{{ _('No equipment listed.') }}

-{% endif %} -{% endif %} - -{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html deleted file mode 100644 index 8319646..0000000 --- a/templates/auth/register.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Create account') }} — Ballistic Analyzer{% endblock %} -{% block content %} -

{{ _('Create account') }}

- -
-
- - -
-
- - -
-
- - -
- -
- -

- {{ _('Already have an account?') }} {{ _('Sign in') }} -

-{% endblock %} diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 056c971..0000000 --- a/templates/base.html +++ /dev/null @@ -1,534 +0,0 @@ - - - - - - - {% block title %}The Shooter's Network{% endblock %} - - - - - {% block body %} - - - - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
{{ message }}
- {% endfor %} -
- {% endif %} - {% endwith %} - -
-
- {% block content %}{% endblock %} -
-
- {% endblock %} - - {# ── Lightbox ── always present, activated by any img[data-gallery] #} - - - - - - - - diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html deleted file mode 100644 index 13bf683..0000000 --- a/templates/dashboard/index.html +++ /dev/null @@ -1,67 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Dashboard') }} — The Shooter's Network{% endblock %} -{% block content %} -

{{ _('Dashboard') }}

-

- {{ _('Welcome back, %(name)s.', name=(current_user.display_name or current_user.email)) }} -

- - - -

{{ _('Recent Analyses') }}

- -{% if analyses %} - - - - - - - - - - - - {% for a in analyses %} - - - - - - - - {% endfor %} - -
{{ _('Title') }}{{ _('Date') }}{{ _('Shots') }}{{ _('Groups') }}{{ _('Visibility') }}
{{ a.title }}{{ a.created_at.strftime('%d %b %Y') }}{{ a.shot_count }}{{ a.group_count }} - {{ _('Public') if a.is_public else _('Private') }} -
-{% else %} -

- {{ _('No analyses yet.') }} {{ _('Upload a CSV file') }} {{ _('to get started — it will be saved here automatically.') }} -

-{% endif %} - -
- -
-
{{ _('Sessions') }}
- {{ _('View your shooting sessions →') }} -
-
-{% endblock %} diff --git a/templates/equipment/detail.html b/templates/equipment/detail.html deleted file mode 100644 index ba9e5ba..0000000 --- a/templates/equipment/detail.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ item.name }} — The Shooter's Network{% endblock %} -{% block content %} -
-
-
- Equipment › - {{ categories.get(item.category, item.category).title() }} -
-

{{ item.name }}

-
-
- - Edit - -
- -
-
-
- -{% if item.photo_url %} -
- {{ item.name }} -
- {% for label, deg in [('↺ Left', -90), ('↻ Right', 90), ('180°', 180)] %} -
- - -
- {% endfor %} -
-
-{% endif %} - - - - - {% if item.brand %}{% endif %} - {% if item.model %}{% endif %} - {% if item.category == 'scope' %} - {% if item.magnification %}{% endif %} - {% if item.reticle %}{% endif %} - {% if item.unit %}{% endif %} - {% else %} - {% if item.caliber %}{% endif %} - {% endif %} - {% if item.serial_number %}{% endif %} - - -
Category{{ categories.get(item.category, item.category).title() }}
Brand{{ item.brand }}
Model{{ item.model }}
Magnification{{ item.magnification }}
Reticle{{ item.reticle }}
Unit{{ item.unit }}
Caliber{{ item.caliber }}
Serial{{ item.serial_number }}
Added{{ item.created_at.strftime('%d %b %Y') }}
- -{% if item.notes %} -
-

Notes

-

{{ item.notes }}

-
-{% endif %} -{% endblock %} diff --git a/templates/equipment/form.html b/templates/equipment/form.html deleted file mode 100644 index ecd2c48..0000000 --- a/templates/equipment/form.html +++ /dev/null @@ -1,130 +0,0 @@ -{% extends "base.html" %} -{% set editing = item is not none %} -{% block title %}{{ _('Edit') if editing else _('Add equipment') }} — The Shooter's Network{% endblock %} -{% block content %} -

{{ _('Edit') if editing else _('Add equipment') }}

- -{% set f = prefill or item %} - -
- -
- - -
- -
- - -
- -
-
- - -
-
- - -
-
- -
-
-
- - -
-
-
- - - -
- - -
- -
- - -
- -
- - {% if editing and item.photo_url %} -
- Current photo - {{ _('Upload a new one to replace it.') }} -
- {% endif %} - -
- -
- - {{ _('Cancel') }} -
-
- - - - -{% endblock %} diff --git a/templates/equipment/list.html b/templates/equipment/list.html deleted file mode 100644 index 372e23b..0000000 --- a/templates/equipment/list.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Equipment') }} — The Shooter's Network{% endblock %} -{% block content %} -
-

{{ _('My Equipment') }}

- - {{ _('+ Add item') }} - -
- -{% if items %} - {% set cat_labels = dict(categories) %} - {% for cat_key, cat_label in categories %} - {% set group = items | selectattr('category', 'equalto', cat_key) | list %} - {% if group %} -

{{ cat_label }}s

-
- {% for item in group %} -
- {% if item.photo_url %} - {{ item.name }} - {% else %} -
- {% if item.category == 'rifle' or item.category == 'handgun' %}🔫 - {% elif item.category == 'scope' %}🔭 - {% else %}🔩{% endif %} -
- {% endif %} -
-
{{ item.name }}
- {% if item.brand or item.model %} -
- {{ [item.brand, item.model] | select | join(' · ') }} -
- {% endif %} - {% if item.caliber %} -
{{ item.caliber }}
- {% endif %} -
- {{ _('View') }} - {{ _('Edit') }} -
- -
-
-
-
- {% endfor %} -
- {% endif %} - {% endfor %} -{% else %} -
-
🔫
-

{{ _('No equipment yet.') }}

- {{ _('Add your first item') }} -
-{% endif %} -{% endblock %} diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 26a15bb..0000000 --- a/templates/index.html +++ /dev/null @@ -1,174 +0,0 @@ -{% extends "base.html" %} -{% block title %}The Shooter's Network — Track, analyze, share{% endblock %} - -{% block body %} - - - - -
-

- The Shooter's Network -

-

- Analyze your ballistic data, track every session, manage your equipment, - and share your performance with the community. -

- -
- - -
-
-
-
📊
-

{{ _('Ballistic Analysis') }}

-

{{ _('Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.') }}

-
-
-
🎯
-

{{ _('Session Tracking') }}

-

{{ _('Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.') }}

-
-
-
🤝
-

{{ _('Community Feed') }}

-

{{ _('Share your public sessions and see what other shooters are achieving on the range.') }}

-
-
-
- - -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
{{ message }}
- {% endfor %} -
- {% endif %} -{% endwith %} - - -
- -
-{% endblock %} diff --git a/templates/results.html b/templates/results.html deleted file mode 100644 index 72e3c60..0000000 --- a/templates/results.html +++ /dev/null @@ -1,82 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-

{{ _('Analysis Results') }}

- -
- -

{{ _('Overall Statistics') }}

- - - - - - - - - - - - - - - - - -
{{ _('Metric') }}{{ _('Value') }}
{{ _('Total shots') }}{{ overall.count }}
{{ _('Min speed') }}{{ "%.4f"|format(overall.min_speed) }}
{{ _('Max speed') }}{{ "%.4f"|format(overall.max_speed) }}
{{ _('Mean speed') }}{{ "%.4f"|format(overall.mean_speed) }}
{{ _('Std dev (speed)') }} - {% if overall.std_speed is not none %} - {{ "%.4f"|format(overall.std_speed) }} - {% else %} - – - {% endif %} -
- -Avg speed and std dev per group - -

{{ _('Groups') }} — {{ groups_display|length }} {{ _('group(s) detected') }}

- -{% for stat, chart_b64 in groups_display %} -
-

{{ _('Group %(n)s', n=stat.group_index) }}

-
- {{ stat.time_start }} – {{ stat.time_end }}  |  {{ stat.count }} shot(s) -
- - - - - - - - - - - - - - - - -
{{ _('Metric') }}{{ _('Value') }}
{{ _('Min speed') }}{{ "%.4f"|format(stat.min_speed) }}
{{ _('Max speed') }}{{ "%.4f"|format(stat.max_speed) }}
{{ _('Mean speed') }}{{ "%.4f"|format(stat.mean_speed) }}
{{ _('Std dev (speed)') }} - {% if stat.std_speed is not none %} - {{ "%.4f"|format(stat.std_speed) }} - {% else %} - – - {% endif %} -
- Speed chart for group {{ stat.group_index }} -
-{% endfor %} -{% endblock %} diff --git a/templates/sessions/annotate_photo.html b/templates/sessions/annotate_photo.html deleted file mode 100644 index 36f5147..0000000 --- a/templates/sessions/annotate_photo.html +++ /dev/null @@ -1,647 +0,0 @@ -{% extends "base.html" %} -{% block title %}Annotate photo — {{ session.label }}{% endblock %} -{% block content %} - -
-
- Sessions › - {{ session.label }} › - Annotate -
-
-

{{ photo.caption or 'Photo annotation' }}

- - ✕ Close - -
-
- -
- - {# ── Canvas ── #} -
- -
- - {# ── Control panel ── #} -
- - {# Step indicator #} -
-
- - Reference line -
-
- - Point of Aim -
-
- - Points of Impact -
-
- - Results -
-
- -
- - {# Shooting distance (always visible, pre-filled from session) #} -
- -
- - -
-
- - {# Clean barrel checkbox #} -
- -
- -
- - {# Step 0: Reference line #} -
-

- Click two points on the image to draw a reference line — e.g. a known grid square or target diameter. -

- -
- - -
-
- - {# Step 1: POA #} - - - {# Step 2: POIs #} - - - {# Step 3: Results #} - - -
{# end control panel #} -
- - - - - -{% endblock %} diff --git a/templates/sessions/detail.html b/templates/sessions/detail.html deleted file mode 100644 index 64df918..0000000 --- a/templates/sessions/detail.html +++ /dev/null @@ -1,757 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ session.label }} — The Shooter's Network{% endblock %} -{% block content %} - -
-
-
- {% if is_owner %}Sessions › {% endif %} - {{ session.session_date.strftime('%d %b %Y') }} - {% if session.is_public %} - {{ _('Public') }} - {% endif %} -
-

{{ session.label }}

-
- by {{ session.user.display_name or session.user.email.split('@')[0] }} -
-
- {% if is_owner %} -
- - {{ _('Edit') }} - -
- -
-
- {% endif %} -
- -{# ---- Stats cards ---- #} -
- - {% if session.location_name or session.distance_m %} -
-
{{ _('Location') }}
- {% if session.location_name %}
{{ session.location_name }}
{% endif %} - {% if session.distance_m %}
{{ session.distance_m }} m
{% endif %} -
- {% endif %} - - {% if session.weather_cond or session.weather_temp_c is not none or session.weather_wind_kph is not none %} -
-
{{ _('Weather') }}
- {% if session.weather_cond %}
{{ session.weather_cond.replace('_',' ').title() }}
{% endif %} -
- {% if session.weather_temp_c is not none %}{{ session.weather_temp_c }}°C{% endif %} - {% if session.weather_wind_kph is not none %}  {{ session.weather_wind_kph }} {{ _('km/h wind') }}{% endif %} -
-
- {% endif %} - - {% if session.rifle %} -
-
{{ _('Rifle / Handgun') }}
-
{{ session.rifle.name }}
- {% if session.rifle.caliber %}
{{ session.rifle.caliber }}
{% endif %} -
- {% endif %} - - {% if session.scope %} -
-
{{ _('Scope') }}
-
{{ session.scope.name }}
-
- {% endif %} - - {% if session.ammo_brand or session.ammo_weight_gr is not none %} -
-
{{ _('Ammunition') }}
- {% if session.ammo_brand %}
{{ session.ammo_brand }}
{% endif %} -
- {% if session.ammo_weight_gr is not none %}{{ session.ammo_weight_gr }} gr{% endif %} - {% if session.ammo_lot %}  lot {{ session.ammo_lot }}{% endif %} -
-
- {% endif %} - - {% if session.shooting_position %} -
-
{{ _('Position') }}
-
{{ session.shooting_position.replace('_',' ').title() }}
-
- {% endif %} - -
- -{% if session.notes %} -

{{ _('Notes') }}

-

{{ session.notes }}

-{% endif %} - -{# ---- PRS Stages ---- #} -{% if session.session_type == 'prs' %} -

{{ _('PRS Stages') }}

- -{% set stages = session.prs_stages or [] %} - -{# Stage table (read + edit combined) #} -
- - - - - - - - - - - - - {% if is_owner %}{% endif %} - - - - {% for st in stages %} - - - - - - - - - - - {% if is_owner %}{% endif %} - - {% endfor %} - -
{{ _('Name') }}Dist. (m){{ _('Time (s)') }}Position{{ _('Elevation Dope') }}{{ _('Windage Dope') }}{{ _('Hits/Poss.') }}{{ _('Notes') }}
{{ st.num or loop.index }}{{ st.name or '' }}{{ st.distance_m or '' }}{{ st.time_s or '' }}{{ st.position or '' }}{{ st.dope_elevation or '' }}{{ st.dope_windage or '' }} - {% if st.hits is not none %}{{ st.hits }}{% endif %}{% if st.possible %}/{{ st.possible }}{% endif %} - {{ st.notes or '' }}
-
- -{% if is_owner %} -
- - - {{ _('📄 Generate dope card (PDF)') }} - -
- -{# Hidden form used by JS to save stages #} - - - -{% endif %} - -{% endif %}{# end prs #} - -{# ---- Photos ---- #} -{% if session.photos or is_owner %} -

{{ _('Photos') }}

-{% if session.photos %} -
- {% for photo in session.photos %} -
-
- {{ photo.caption or '' }} - {% if is_owner %} -
- -
- {% endif %} -
- {% if photo.annotations and photo.annotations.stats %} - {% set s = photo.annotations.stats %} - {% set a = photo.annotations %} -
-
- {{ s.shot_count }} shot{{ 's' if s.shot_count != 1 }} - · {{ s.shooting_distance_m | int }} m - {% if a.clean_barrel %}{{ _('clean barrel') }}{% endif %} -
- - - - - - - - - - - - - - - -
{{ _('Group ES') }}{{ '%.2f'|format(s.group_size_moa) }} MOA{{ '%.1f'|format(s.group_size_mm) }} mm
{{ _('Mean Radius') }}{{ '%.2f'|format(s.mean_radius_moa) }} MOA{{ '%.1f'|format(s.mean_radius_mm) }} mm
{{ _('Centre') }} - {{ '%.2f'|format(s.center_dist_moa) }} MOA - ({{ '%.1f'|format(s.center_x_mm | abs) }} mm {{ 'R' if s.center_x_mm > 0 else 'L' if s.center_x_mm < 0 else '' }}, {{ '%.1f'|format(s.center_y_mm | abs) }} mm {{ _('low') if s.center_y_mm > 0 else _('high') if s.center_y_mm < 0 else '' }}) -
-
- {% endif %} - {% if photo.caption %} -
- {{ photo.caption }} -
- {% endif %} - {% if is_owner %} -
- {% for label, deg in [('↺', -90), ('↻', 90), ('180°', 180)] %} -
- - -
- {% endfor %} -
- - {% if photo.annotations and photo.annotations.stats %}✓{% else %}▶{% endif %} - {{ _('Measure group') }} - - {% endif %} -
- {% endfor %} -
-{% endif %} - -{% if is_owner %} -
-
- - -
-
- - -
- -
-{% endif %} -{% endif %} - -{# ---- Analyses ---- #} -

Analyses{% if analyses %} ({{ analyses|length }}){% endif %}

- -{% if analyses_display %} - {% for a, groups_display, overview_chart, split_positions in analyses_display %} -
- - {{ a.title }} - {{ a.shot_count }} {{ _('shots') }} · {{ a.group_count }} {{ _('group') if a.group_count == 1 else _('groups') }} - {{ "%.2f"|format(a.overall_stats.mean_speed) }} {{ _('m/s mean') }} - {{ a.created_at.strftime('%d %b %Y') }} - - -
- - {# --- Owner action bar: rename / standalone link / PDF / delete --- #} - {% if is_owner %} -
- - {# Rename inline form #} -
- - {{ _('✏ Rename') }} - -
- - -
-
- - - {{ _('Full view') }} - - - {% if a.pdf_path %} - - ↧ PDF - - {% endif %} - -
- -
-
- {% endif %} - - {# --- Overview chart --- #} - {% if overview_chart %} - Overview chart - {% endif %} - - {# --- Per-group cards --- #} - {% if groups_display %} - {% for gs, chart in groups_display %} - {% set grp_idx = loop.index0 %} - {% set grp_photos = a.group_photos | selectattr('group_index', 'equalto', grp_idx) | list %} -
-
- {{ _('Group %(n)s', n=loop.index) }} -  ·  {{ gs.count }} {{ _('shots') }} -  ·  {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s - {% if gs.std_speed is not none %} ·  {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %} -  ·  {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }} -
- - {# 2-column layout: chart 65% / photos 35% #} -
- - {# Left: chart #} -
- Group {{ loop.index }} chart -
- - {# Right: group photos #} - {% if grp_photos %} -
- {% for gp in grp_photos %} -
- {{ gp.caption or '' }} - - {# Annotation stats if available #} - {% if gp.annotations and gp.annotations.stats %} - {% set s = gp.annotations.stats %} -
-
- {{ s.shot_count }} {{ _('shots') }} · {{ s.shooting_distance_m | int }} m -
- - - - - - - - - -
{{ _('Group ES') }}{{ '%.2f'|format(s.group_size_moa) }} MOA
{{ _('Mean Radius') }}{{ '%.2f'|format(s.mean_radius_moa) }} MOA
-
- {% endif %} - - {% if gp.caption %} -
{{ gp.caption }}
- {% endif %} - - {% if is_owner %} - - {% endif %} -
- {% endfor %} -
- {% endif %} - -
{# end 2-col #} - - {% if gs.note %} -
{{ gs.note }}
- {% endif %} - - {% if is_owner %} -
- {# Note editor #} -
- - ✎ {{ _('Note') }} - -
- -
- -
-
-
- - {# Group photo upload #} -
- - 📷 {{ _('Add photo') }} - -
-
- -
-
- -
- -
-
-
- {% endif %} -
- {% endfor %} - {% elif groups_display is none %} -

{{ _('CSV file missing — cannot display charts.') }}

- {% endif %} - - {# --- Visual Group Editor (owner only) --- #} - {% if is_owner and groups_display %} -
- - ⚙ {{ _('Edit groups') }} - -
-
- {{ _('Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint.') }} -
-
-
- - - -
-
-
- - - {% endif %} - -
-
- {% endfor %} -{% else %} -

{{ _('No analyses yet.') }}

-{% endif %} - -{% if is_owner %} -
-
- - -
- -
-{% endif %} - -{% endblock %} diff --git a/templates/sessions/form.html b/templates/sessions/form.html deleted file mode 100644 index 7d40907..0000000 --- a/templates/sessions/form.html +++ /dev/null @@ -1,239 +0,0 @@ -{% extends "base.html" %} -{% set editing = session is not none %} - -{# Effective type: prefill form > existing session > URL param #} -{% set eff_type = (prefill.session_type if prefill else None) or (session.session_type if session else None) or selected_type or '' %} - -{# Find display name for this type #} -{% set type_name = '' %} -{% for slug, name, _ in (session_types or []) %}{% if slug == eff_type %}{% set type_name = name %}{% endif %}{% endfor %} - -{% block title %}{{ _('Edit session') if editing else _('New session') }} — The Shooter's Network{% endblock %} -{% block content %} - -
- {% if not editing %} - {{ _('← Change type') }} - {% endif %} -
- -

- {{ _('Edit session') if editing else _('New session') }} - {% if type_name %} - — {{ type_name }} - {% endif %} -

- -{% set f = prefill or session %} - -
- - - - {# In edit mode: allow changing type via a small selector #} - {% if editing %} -
- - -
- {% endif %} - - {# ── Section: basic information ── #} -

{{ _('Basic information') }}

- -
-
- - -
- - {# Distance: shown for long_range and pistol_25m (hidden for prs — per stage) #} -
- - -
-
- -
- - -
- - {# ── Section: shooting position (long_range and pistol_25m) ── #} -
-
- - -
-
- - {# ── Section: weather ── #} -

{{ _('Weather') }}

-
-
- - -
-
- - -
-
- - -
-
- - {# ── Section: equipment & ammunition ── #} -

{{ _('Equipment & Ammunition') }}

-
-
- - - {% if not rifles %} - - {% endif %} -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - {# ── Section: notes & visibility ── #} -

{{ _('Notes & Visibility') }}

-
- - -
-
- -
- -
- - {{ _('Cancel') }} -
-
- - - - -{% endblock %} diff --git a/templates/sessions/list.html b/templates/sessions/list.html deleted file mode 100644 index 6c27eb7..0000000 --- a/templates/sessions/list.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('Sessions') }} — The Shooter's Network{% endblock %} -{% block content %} -
-

{{ _('My Sessions') }}

- - {{ _('+ New session') }} - -
- -{% if sessions %} - - - - - - - - - - - - {% for s in sessions %} - - - - - - - - {% endfor %} - -
{{ _('Session') }}{{ _('Type') }}{{ _('Location') }}{{ _('Visibility') }}
{{ s.session_date.strftime('%d %b %Y') }} - {% if s.session_type == 'long_range' %} - {{ _('Long Range') }} - {% elif s.session_type == 'prs' %} - PRS - {% elif s.session_type == 'pistol_25m' %} - {{ _('25m Pistol') }} - {% else %}—{% endif %} - {{ s.location_name or '—' }} - {{ _('Public') if s.is_public else _('Private') }} - - {{ _('Edit') }} -
- -
-
-{% else %} -
-
🎯
-

{{ _('No sessions recorded yet.') }}

- {{ _('Log your first session') }} -
-{% endif %} -{% endblock %} diff --git a/templates/sessions/type_picker.html b/templates/sessions/type_picker.html deleted file mode 100644 index 458de14..0000000 --- a/templates/sessions/type_picker.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ _('New session') }} — The Shooter's Network{% endblock %} -{% block content %} -
-

{{ _('New session') }}

- {{ _('Cancel') }} -
-

{{ _('Choose the session type.') }}

- - -{% endblock %} diff --git a/templates/upload.html b/templates/upload.html deleted file mode 100644 index c7ae06c..0000000 --- a/templates/upload.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

{{ _('New Analysis') }}

- -{% if error %} -
{{ error }}
-{% endif %} - -

- {{ _('Upload a CSV file to analyse shot groups. The file must contain the following columns:') }} - index, speed, standard deviation, - energy, power factor, time of the day. -

- -
- - -
-{% endblock %} diff --git a/translations/de/LC_MESSAGES/messages.mo b/translations/de/LC_MESSAGES/messages.mo deleted file mode 100644 index a69a330..0000000 Binary files a/translations/de/LC_MESSAGES/messages.mo and /dev/null differ diff --git a/translations/de/LC_MESSAGES/messages.po b/translations/de/LC_MESSAGES/messages.po deleted file mode 100644 index 4180458..0000000 --- a/translations/de/LC_MESSAGES/messages.po +++ /dev/null @@ -1,812 +0,0 @@ -msgid "" -msgstr "" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Language: de\n" - -msgid "New Analysis" -msgstr "Neue Analyse" - -msgid "Tools" -msgstr "Werkzeuge" - -msgid "Analyse CSV" -msgstr "CSV analysieren" - -msgid "Measure group (photo)" -msgstr "Gruppe messen (Foto)" - -msgid "Equipment" -msgstr "Ausrüstung" - -msgid "Sessions" -msgstr "Sitzungen" - -msgid "Dashboard" -msgstr "Dashboard" - -msgid "Profile" -msgstr "Profil" - -msgid "Logout" -msgstr "Abmelden" - -msgid "Login" -msgstr "Anmelden" - -msgid "Join free" -msgstr "Kostenlos registrieren" - -msgid "Save" -msgstr "Speichern" - -msgid "Cancel" -msgstr "Abbrechen" - -msgid "Edit" -msgstr "Bearbeiten" - -msgid "Delete" -msgstr "Löschen" - -msgid "Apply" -msgstr "Anwenden" - -msgid "Upload" -msgstr "Hochladen" - -msgid "Notes" -msgstr "Notizen" - -msgid "Public" -msgstr "Öffentlich" - -msgid "Private" -msgstr "Privat" - -msgid "Date" -msgstr "Datum" - -msgid "Location" -msgstr "Ort" - -msgid "Name" -msgstr "Name" - -msgid "Category" -msgstr "Kategorie" - -msgid "Caliber" -msgstr "Kaliber" - -msgid "Distance" -msgstr "Distanz" - -msgid "Position" -msgstr "Position" - -msgid "Log a Session" -msgstr "Sitzung erfassen" - -msgid "Get started — free" -msgstr "Kostenlos starten" - -msgid "Try without account" -msgstr "Ohne Konto ausprobieren" - -msgid "Ballistic Analysis" -msgstr "Ballistische Analyse" - -msgid "Session Tracking" -msgstr "Sitzungsverfolgung" - -msgid "Community Feed" -msgstr "Community-Feed" - -msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports." -msgstr "Laden Sie CSV-Dateien von Ihrem Chronograph hoch und erhalten Sie sofort Schussgruppenstatistiken, Geschwindigkeitsdiagramme und PDF-Berichte." - -msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place." -msgstr "Erfassen Sie jeden Schießstandbesuch mit Ort, Wetter, Gewehr, Munition und Distanz. Alle Ihre Daten an einem Ort." - -msgid "Share your public sessions and see what other shooters are achieving on the range." -msgstr "Teilen Sie Ihre öffentlichen Sitzungen und sehen Sie, was andere Schützen auf dem Schießstand erreichen." - -msgid "Latest sessions" -msgstr "Neueste Sitzungen" - -msgid "No public sessions yet. Be the first to share one!" -msgstr "Noch keine öffentlichen Sitzungen. Seien Sie der Erste, der eine teilt!" - -msgid "Analyze" -msgstr "Analysieren" - -msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:" -msgstr "Laden Sie eine CSV-Datei hoch, um Schussgruppen zu analysieren. Die Datei muss folgende Spalten enthalten:" - -msgid "Analysis Results" -msgstr "Analyseergebnisse" - -msgid "← Upload another file" -msgstr "← Weitere Datei hochladen" - -msgid "View saved report →" -msgstr "Gespeicherten Bericht ansehen →" - -msgid "⬙ Download PDF report" -msgstr "⬙ PDF-Bericht herunterladen" - -msgid "Overall Statistics" -msgstr "Gesamtstatistiken" - -msgid "Metric" -msgstr "Messgröße" - -msgid "Value" -msgstr "Wert" - -msgid "Total shots" -msgstr "Schüsse gesamt" - -msgid "Min speed" -msgstr "Min. Geschwindigkeit" - -msgid "Max speed" -msgstr "Max. Geschwindigkeit" - -msgid "Mean speed" -msgstr "Mittlere Geschwindigkeit" - -msgid "Std dev (speed)" -msgstr "Standardabweichung (Geschwindigkeit)" - -msgid "Groups" -msgstr "Gruppen" - -msgid "group(s) detected" -msgstr "Gruppe(n) erkannt" - -msgid "Group %(n)s" -msgstr "Gruppe %(n)s" - -msgid "shot(s)" -msgstr "Schuss/Schüsse" - -msgid "group(s)" -msgstr "Gruppe(n)" - -msgid "Analysis" -msgstr "Analyse" - -msgid "Welcome back, %(name)s." -msgstr "Willkommen zurück, %(name)s." - -msgid "+ New session" -msgstr "+ Neue Sitzung" - -msgid "+ Add equipment" -msgstr "+ Ausrüstung hinzufügen" - -msgid "New analysis" -msgstr "Neue Analyse" - -msgid "Recent Analyses" -msgstr "Neueste Analysen" - -msgid "Title" -msgstr "Titel" - -msgid "Shots" -msgstr "Schüsse" - -msgid "Visibility" -msgstr "Sichtbarkeit" - -msgid "No analyses yet." -msgstr "Noch keine Analysen." - -msgid "Upload a CSV file" -msgstr "CSV-Datei hochladen" - -msgid "to get started — it will be saved here automatically." -msgstr "um zu beginnen – sie wird hier automatisch gespeichert." - -msgid "Manage your rifles, scopes & gear →" -msgstr "Gewehre, Optiken und Ausrüstung verwalten →" - -msgid "View your shooting sessions →" -msgstr "Schießsitzungen anzeigen →" - -msgid "Sign in" -msgstr "Anmelden" - -msgid "Password" -msgstr "Passwort" - -msgid "Email" -msgstr "E-Mail" - -msgid "Resend confirmation email" -msgstr "Bestätigungsmail erneut senden" - -msgid "Don't have an account?" -msgstr "Kein Konto?" - -msgid "Create one" -msgstr "Jetzt erstellen" - -msgid "or continue with" -msgstr "oder weiter mit" - -msgid "Continue with Google" -msgstr "Mit Google fortfahren" - -msgid "Continue with GitHub" -msgstr "Mit GitHub fortfahren" - -msgid "Create account" -msgstr "Konto erstellen" - -msgid "(min. 8 characters)" -msgstr "(min. 8 Zeichen)" - -msgid "Confirm password" -msgstr "Passwort bestätigen" - -msgid "Already have an account?" -msgstr "Bereits ein Konto?" - -msgid "Account" -msgstr "Konto" - -msgid "Profile picture" -msgstr "Profilbild" - -msgid "JPEG/PNG, max 1200 px, auto-resized." -msgstr "JPEG/PNG, max 1200 px, wird automatisch angepasst." - -msgid "Display name" -msgstr "Anzeigename" - -msgid "Bio" -msgstr "Bio" - -msgid "Tell others a bit about yourself…" -msgstr "Erzählen Sie etwas über sich…" - -msgid "Show my equipment on my public profile" -msgstr "Meine Ausrüstung im öffentlichen Profil anzeigen" - -msgid "Save changes" -msgstr "Änderungen speichern" - -msgid "View my public profile →" -msgstr "Mein öffentliches Profil ansehen →" - -msgid "Change password" -msgstr "Passwort ändern" - -msgid "Current password" -msgstr "Aktuelles Passwort" - -msgid "New password" -msgstr "Neues Passwort" - -msgid "Confirm" -msgstr "Bestätigen" - -msgid "Member since %(date)s" -msgstr "Mitglied seit %(date)s" - -msgid "Session" -msgstr "Sitzung" - -msgid "Brand / Model" -msgstr "Marke / Modell" - -msgid "No public sessions yet." -msgstr "Noch keine öffentlichen Sitzungen." - -msgid "No equipment listed." -msgstr "Keine Ausrüstung angegeben." - -msgid "My Equipment" -msgstr "Meine Ausrüstung" - -msgid "+ Add item" -msgstr "+ Artikel hinzufügen" - -msgid "View" -msgstr "Ansehen" - -msgid "Delete %(name)s?" -msgstr "%(name)s löschen?" - -msgid "No equipment yet." -msgstr "Noch keine Ausrüstung." - -msgid "Add your first item" -msgstr "Ersten Artikel hinzufügen" - -msgid "Add equipment" -msgstr "Ausrüstung hinzufügen" - -msgid "Category *" -msgstr "Kategorie *" - -msgid "Name *" -msgstr "Name *" - -msgid "Brand" -msgstr "Marke" - -msgid "Model" -msgstr "Modell" - -msgid "Magnification" -msgstr "Vergrößerung" - -msgid "Reticle" -msgstr "Absehen" - -msgid "Unit" -msgstr "Einheit" - -msgid "Serial number" -msgstr "Seriennummer" - -msgid "Photo" -msgstr "Foto" - -msgid "Upload a new one to replace it." -msgstr "Laden Sie eine neue hoch, um sie zu ersetzen." - -msgid "My Sessions" -msgstr "Meine Sitzungen" - -msgid "Type" -msgstr "Typ" - -msgid "Long Range" -msgstr "Long Range" - -msgid "25m Pistol" -msgstr "Pistole 25m" - -msgid "Delete this session?" -msgstr "Diese Sitzung löschen?" - -msgid "No sessions recorded yet." -msgstr "Noch keine Sitzungen aufgezeichnet." - -msgid "Log your first session" -msgstr "Erste Sitzung erfassen" - -msgid "New session" -msgstr "Neue Sitzung" - -msgid "Choose the session type." -msgstr "Wählen Sie den Sitzungstyp." - -msgid "Long Range Practice" -msgstr "Long Range Practice" - -msgid "Long range precision shooting (100m+)" -msgstr "Präzisionsschießen auf lange Distanz (100m+)" - -msgid "PRS" -msgstr "PRS" - -msgid "Precision Rifle Series — training & competition" -msgstr "Precision Rifle Series — Training & Wettkampf" - -msgid "25m precision pistol shooting" -msgstr "Präzisionsschießen Pistole 25m" - -msgid "Stage management, PDF dope card ↗" -msgstr "Stage-Verwaltung, PDF-Dopekarte ↗" - -msgid "← Change type" -msgstr "← Typ ändern" - -msgid "Edit session" -msgstr "Sitzung bearbeiten" - -msgid "Basic information" -msgstr "Grundinformationen" - -msgid "Date *" -msgstr "Datum *" - -msgid "Distance (m)" -msgstr "Distanz (m)" - -msgid "Shooting position" -msgstr "Schießposition" - -msgid "Weather" -msgstr "Wetter" - -msgid "Conditions" -msgstr "Bedingungen" - -msgid "Temp. (°C)" -msgstr "Temp. (°C)" - -msgid "Wind (km/h)" -msgstr "Wind (km/h)" - -msgid "Equipment & Ammunition" -msgstr "Ausrüstung & Munition" - -msgid "Rifle / Handgun" -msgstr "Gewehr / Pistole" - -msgid "— none —" -msgstr "— keine —" - -msgid "Add a rifle first" -msgstr "Zuerst ein Gewehr hinzufügen" - -msgid "Scope" -msgstr "Optik" - -msgid "Ammo brand" -msgstr "Munitionsmarke" - -msgid "Bullet weight (gr)" -msgstr "Geschossgewicht (gr)" - -msgid "Lot number" -msgstr "Losnummer" - -msgid "Notes & Visibility" -msgstr "Notizen & Sichtbarkeit" - -msgid "Make this session public (visible in the community feed)" -msgstr "Diese Sitzung öffentlich machen (im Community-Feed sichtbar)" - -msgid "Create session" -msgstr "Sitzung erstellen" - -msgid "Session type:" -msgstr "Sitzungstyp:" - -msgid "Delete this session? This cannot be undone." -msgstr "Diese Sitzung löschen? Dies kann nicht rückgängig gemacht werden." - -msgid "km/h wind" -msgstr "km/h Wind" - -msgid "Ammunition" -msgstr "Munition" - -msgid "PRS Stages" -msgstr "PRS-Stages" - -msgid "Time (s)" -msgstr "Zeit (s)" - -msgid "Elevation Dope" -msgstr "Erhöhungs-Dope" - -msgid "Windage Dope" -msgstr "Seiten-Dope" - -msgid "Hits/Poss." -msgstr "Treffer/Mögl." - -msgid "✏ Edit stages" -msgstr "✏ Stages bearbeiten" - -msgid "📄 Generate dope card (PDF)" -msgstr "📄 Dopekarte erstellen (PDF)" - -msgid "+ Add stage" -msgstr "+ Stage hinzufügen" - -msgid "💾 Save" -msgstr "💾 Speichern" - -msgid "Photos" -msgstr "Fotos" - -msgid "Add photo" -msgstr "Foto hinzufügen" - -msgid "Caption (optional)" -msgstr "Beschriftung (optional)" - -msgid "Measure group" -msgstr "Gruppe messen" - -msgid "Delete this photo?" -msgstr "Dieses Foto löschen?" - -msgid "clean barrel" -msgstr "sauberer Lauf" - -msgid "Group ES" -msgstr "Gruppen-ES" - -msgid "Mean Radius" -msgstr "Mittlerer Radius" - -msgid "Centre" -msgstr "Mittelpunkt" - -msgid "high" -msgstr "oben" - -msgid "low" -msgstr "unten" - -msgid "Analyses" -msgstr "Analysen" - -msgid "shots" -msgstr "Schüsse" - -msgid "group" -msgstr "Gruppe" - -msgid "groups" -msgstr "Gruppen" - -msgid "m/s mean" -msgstr "m/s Mittel" - -msgid "✏ Rename" -msgstr "✏ Umbenennen" - -msgid "Full view" -msgstr "Vollansicht" - -msgid "Delete this analysis? This cannot be undone." -msgstr "Diese Analyse löschen? Dies kann nicht rückgängig gemacht werden." - -msgid "mean" -msgstr "Mittel" - -msgid "SD" -msgstr "SA" - -msgid "ES" -msgstr "ES" - -msgid "Note" -msgstr "Notiz" - -msgid "Save note" -msgstr "Notiz speichern" - -msgid "CSV file missing — cannot display charts." -msgstr "CSV-Datei fehlt — Diagramme können nicht angezeigt werden." - -msgid "Re-group settings" -msgstr "Gruppierungseinstellungen" - -msgid "Outlier factor:" -msgstr "Ausreißer-Faktor:" - -msgid "1 (fine)" -msgstr "1 (fein)" - -msgid "20 (coarse)" -msgstr "20 (grob)" - -msgid "Manual split indices (JSON array, e.g. [5, 12])" -msgstr "Manuelle Teilungsindizes (JSON-Array, z.B. [5, 12])" - -msgid "Shot positions (0-based) where a new group should always begin." -msgstr "Schussposition (0-basiert), bei der eine neue Gruppe beginnen soll." - -msgid "Upload chronograph CSV" -msgstr "Chronograph-CSV hochladen" - -msgid "Analyse & link" -msgstr "Analysieren & verknüpfen" - -msgid "Invalid email or password." -msgstr "Ungültige E-Mail-Adresse oder ungültiges Passwort." - -msgid "Account created! Welcome." -msgstr "Konto erstellt! Willkommen." - -msgid "Profile updated." -msgstr "Profil aktualisiert." - -msgid "Password changed." -msgstr "Passwort geändert." - -msgid "'%(name)s' added." -msgstr "'%(name)s' hinzugefügt." - -msgid "'%(name)s' updated." -msgstr "'%(name)s' aktualisiert." - -msgid "'%(name)s' deleted." -msgstr "'%(name)s' gelöscht." - -msgid "Name is required." -msgstr "Name ist erforderlich." - -msgid "Invalid category." -msgstr "Ungültige Kategorie." - -msgid "Session saved." -msgstr "Sitzung gespeichert." - -msgid "Session deleted." -msgstr "Sitzung gelöscht." - -msgid "Stages saved." -msgstr "Stages gespeichert." - -msgid "Photo deleted." -msgstr "Foto gelöscht." - -msgid "Analysis deleted." -msgstr "Analyse gelöscht." - -msgid "Title updated." -msgstr "Titel aktualisiert." - -msgid "Regrouped." -msgstr "Neu gruppiert." - -msgid "Note saved." -msgstr "Notiz gespeichert." - -msgid "Please log in to access this page." -msgstr "Bitte melden Sie sich an, um auf diese Seite zuzugreifen." - -msgid "Admin — Users" -msgstr "Admin — Benutzer" - -msgid "User Management" -msgstr "Benutzerverwaltung" - -msgid "users" -msgstr "Benutzer" - -msgid "Provider" -msgstr "Anbieter" - -msgid "Role" -msgstr "Rolle" - -msgid "Language" -msgstr "Sprache" - -msgid "Joined" -msgstr "Beigetreten" - -msgid "Last login" -msgstr "Letzter Login" - -msgid "Actions" -msgstr "Aktionen" - -msgid "Local" -msgstr "Lokal" - -msgid "Set" -msgstr "Setzen" - -msgid "Reset pwd" -msgstr "Passwort reset" - -msgid "New password (min 8)" -msgstr "Neues Passwort (min 8)" - -msgid "Delete user %(email)s? All their data will be permanently removed." -msgstr "Benutzer %(email)s löschen? Alle Daten werden dauerhaft entfernt." - -msgid "(you)" -msgstr "(Sie)" - -msgid "Invalid role." -msgstr "Ungültige Rolle." - -msgid "Cannot remove the last admin." -msgstr "Der letzte Administrator kann nicht entfernt werden." - -msgid "Role updated for %(email)s." -msgstr "Rolle für %(email)s aktualisiert." - -msgid "Password reset for %(email)s." -msgstr "Passwort für %(email)s zurückgesetzt." - -msgid "You cannot delete your own account." -msgstr "Sie können Ihr eigenes Konto nicht löschen." - -msgid "User %(email)s deleted." -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." diff --git a/translations/en/LC_MESSAGES/messages.mo b/translations/en/LC_MESSAGES/messages.mo deleted file mode 100644 index 137f2d9..0000000 Binary files a/translations/en/LC_MESSAGES/messages.mo and /dev/null differ diff --git a/translations/en/LC_MESSAGES/messages.po b/translations/en/LC_MESSAGES/messages.po deleted file mode 100644 index b12a7aa..0000000 --- a/translations/en/LC_MESSAGES/messages.po +++ /dev/null @@ -1,653 +0,0 @@ -msgid "" -msgstr "" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Language: en\n" - -msgid "New Analysis" -msgstr "" - -msgid "Equipment" -msgstr "" - -msgid "Sessions" -msgstr "" - -msgid "Dashboard" -msgstr "" - -msgid "Profile" -msgstr "" - -msgid "Logout" -msgstr "" - -msgid "Login" -msgstr "" - -msgid "Join free" -msgstr "" - -msgid "Save" -msgstr "" - -msgid "Cancel" -msgstr "" - -msgid "Edit" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "Apply" -msgstr "" - -msgid "Upload" -msgstr "" - -msgid "Notes" -msgstr "" - -msgid "Public" -msgstr "" - -msgid "Private" -msgstr "" - -msgid "Date" -msgstr "" - -msgid "Location" -msgstr "" - -msgid "Name" -msgstr "" - -msgid "Category" -msgstr "" - -msgid "Caliber" -msgstr "" - -msgid "Distance" -msgstr "" - -msgid "Position" -msgstr "" - -msgid "Log a Session" -msgstr "" - -msgid "Get started — free" -msgstr "" - -msgid "Try without account" -msgstr "" - -msgid "Ballistic Analysis" -msgstr "" - -msgid "Session Tracking" -msgstr "" - -msgid "Community Feed" -msgstr "" - -msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports." -msgstr "" - -msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place." -msgstr "" - -msgid "Share your public sessions and see what other shooters are achieving on the range." -msgstr "" - -msgid "Latest sessions" -msgstr "" - -msgid "No public sessions yet. Be the first to share one!" -msgstr "" - -msgid "Analyze" -msgstr "" - -msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:" -msgstr "" - -msgid "Analysis Results" -msgstr "" - -msgid "← Upload another file" -msgstr "" - -msgid "View saved report →" -msgstr "" - -msgid "⬙ Download PDF report" -msgstr "" - -msgid "Overall Statistics" -msgstr "" - -msgid "Metric" -msgstr "" - -msgid "Value" -msgstr "" - -msgid "Total shots" -msgstr "" - -msgid "Min speed" -msgstr "" - -msgid "Max speed" -msgstr "" - -msgid "Mean speed" -msgstr "" - -msgid "Std dev (speed)" -msgstr "" - -msgid "Groups" -msgstr "" - -msgid "group(s) detected" -msgstr "" - -msgid "Group %(n)s" -msgstr "" - -msgid "shot(s)" -msgstr "" - -msgid "group(s)" -msgstr "" - -msgid "Analysis" -msgstr "" - -msgid "Welcome back, %(name)s." -msgstr "" - -msgid "+ New session" -msgstr "" - -msgid "+ Add equipment" -msgstr "" - -msgid "New analysis" -msgstr "" - -msgid "Recent Analyses" -msgstr "" - -msgid "Title" -msgstr "" - -msgid "Shots" -msgstr "" - -msgid "Visibility" -msgstr "" - -msgid "No analyses yet." -msgstr "" - -msgid "Upload a CSV file" -msgstr "" - -msgid "to get started — it will be saved here automatically." -msgstr "" - -msgid "Manage your rifles, scopes & gear →" -msgstr "" - -msgid "View your shooting sessions →" -msgstr "" - -msgid "Sign in" -msgstr "" - -msgid "Password" -msgstr "" - -msgid "Email" -msgstr "" - -msgid "Resend confirmation email" -msgstr "" - -msgid "Don't have an account?" -msgstr "" - -msgid "Create one" -msgstr "" - -msgid "or continue with" -msgstr "" - -msgid "Continue with Google" -msgstr "" - -msgid "Continue with GitHub" -msgstr "" - -msgid "Create account" -msgstr "" - -msgid "(min. 8 characters)" -msgstr "" - -msgid "Confirm password" -msgstr "" - -msgid "Already have an account?" -msgstr "" - -msgid "Account" -msgstr "" - -msgid "Profile picture" -msgstr "" - -msgid "JPEG/PNG, max 1200 px, auto-resized." -msgstr "" - -msgid "Display name" -msgstr "" - -msgid "Bio" -msgstr "" - -msgid "Tell others a bit about yourself…" -msgstr "" - -msgid "Show my equipment on my public profile" -msgstr "" - -msgid "Save changes" -msgstr "" - -msgid "View my public profile →" -msgstr "" - -msgid "Change password" -msgstr "" - -msgid "Current password" -msgstr "" - -msgid "New password" -msgstr "" - -msgid "Confirm" -msgstr "" - -msgid "Member since %(date)s" -msgstr "" - -msgid "Session" -msgstr "" - -msgid "Brand / Model" -msgstr "" - -msgid "No public sessions yet." -msgstr "" - -msgid "No equipment listed." -msgstr "" - -msgid "My Equipment" -msgstr "" - -msgid "+ Add item" -msgstr "" - -msgid "View" -msgstr "" - -msgid "Delete %(name)s?" -msgstr "" - -msgid "No equipment yet." -msgstr "" - -msgid "Add your first item" -msgstr "" - -msgid "Add equipment" -msgstr "" - -msgid "Category *" -msgstr "" - -msgid "Name *" -msgstr "" - -msgid "Brand" -msgstr "" - -msgid "Model" -msgstr "" - -msgid "Magnification" -msgstr "" - -msgid "Reticle" -msgstr "" - -msgid "Unit" -msgstr "" - -msgid "Serial number" -msgstr "" - -msgid "Photo" -msgstr "" - -msgid "Upload a new one to replace it." -msgstr "" - -msgid "My Sessions" -msgstr "" - -msgid "Type" -msgstr "" - -msgid "Long Range" -msgstr "" - -msgid "25m Pistol" -msgstr "" - -msgid "Delete this session?" -msgstr "" - -msgid "No sessions recorded yet." -msgstr "" - -msgid "Log your first session" -msgstr "" - -msgid "New session" -msgstr "" - -msgid "Choose the session type." -msgstr "" - -msgid "Long Range Practice" -msgstr "" - -msgid "Long range precision shooting (100m+)" -msgstr "" - -msgid "PRS" -msgstr "" - -msgid "Precision Rifle Series — training & competition" -msgstr "" - -msgid "25m precision pistol shooting" -msgstr "" - -msgid "Stage management, PDF dope card ↗" -msgstr "" - -msgid "← Change type" -msgstr "" - -msgid "Edit session" -msgstr "" - -msgid "Basic information" -msgstr "" - -msgid "Date *" -msgstr "" - -msgid "Distance (m)" -msgstr "" - -msgid "Shooting position" -msgstr "" - -msgid "Weather" -msgstr "" - -msgid "Conditions" -msgstr "" - -msgid "Temp. (°C)" -msgstr "" - -msgid "Wind (km/h)" -msgstr "" - -msgid "Equipment & Ammunition" -msgstr "" - -msgid "Rifle / Handgun" -msgstr "" - -msgid "— none —" -msgstr "" - -msgid "Add a rifle first" -msgstr "" - -msgid "Scope" -msgstr "" - -msgid "Ammo brand" -msgstr "" - -msgid "Bullet weight (gr)" -msgstr "" - -msgid "Lot number" -msgstr "" - -msgid "Notes & Visibility" -msgstr "" - -msgid "Make this session public (visible in the community feed)" -msgstr "" - -msgid "Create session" -msgstr "" - -msgid "Session type:" -msgstr "" - -msgid "Delete this session? This cannot be undone." -msgstr "" - -msgid "km/h wind" -msgstr "" - -msgid "Ammunition" -msgstr "" - -msgid "PRS Stages" -msgstr "" - -msgid "Time (s)" -msgstr "" - -msgid "Elevation Dope" -msgstr "" - -msgid "Windage Dope" -msgstr "" - -msgid "Hits/Poss." -msgstr "" - -msgid "✏ Edit stages" -msgstr "" - -msgid "📄 Generate dope card (PDF)" -msgstr "" - -msgid "+ Add stage" -msgstr "" - -msgid "💾 Save" -msgstr "" - -msgid "Photos" -msgstr "" - -msgid "Add photo" -msgstr "" - -msgid "Caption (optional)" -msgstr "" - -msgid "Measure group" -msgstr "" - -msgid "Delete this photo?" -msgstr "" - -msgid "clean barrel" -msgstr "" - -msgid "Group ES" -msgstr "" - -msgid "Mean Radius" -msgstr "" - -msgid "Centre" -msgstr "" - -msgid "high" -msgstr "" - -msgid "low" -msgstr "" - -msgid "Analyses" -msgstr "" - -msgid "shots" -msgstr "" - -msgid "group" -msgstr "" - -msgid "groups" -msgstr "" - -msgid "m/s mean" -msgstr "" - -msgid "✏ Rename" -msgstr "" - -msgid "Full view" -msgstr "" - -msgid "Delete this analysis? This cannot be undone." -msgstr "" - -msgid "mean" -msgstr "" - -msgid "SD" -msgstr "" - -msgid "ES" -msgstr "" - -msgid "Note" -msgstr "" - -msgid "Save note" -msgstr "" - -msgid "CSV file missing — cannot display charts." -msgstr "" - -msgid "Re-group settings" -msgstr "" - -msgid "Outlier factor:" -msgstr "" - -msgid "1 (fine)" -msgstr "" - -msgid "20 (coarse)" -msgstr "" - -msgid "Manual split indices (JSON array, e.g. [5, 12])" -msgstr "" - -msgid "Shot positions (0-based) where a new group should always begin." -msgstr "" - -msgid "Upload chronograph CSV" -msgstr "" - -msgid "Analyse & link" -msgstr "" - -msgid "Invalid email or password." -msgstr "" - -msgid "Account created! Welcome." -msgstr "" - -msgid "Profile updated." -msgstr "" - -msgid "Password changed." -msgstr "" - -msgid "'%(name)s' added." -msgstr "" - -msgid "'%(name)s' updated." -msgstr "" - -msgid "'%(name)s' deleted." -msgstr "" - -msgid "Name is required." -msgstr "" - -msgid "Invalid category." -msgstr "" - -msgid "Session saved." -msgstr "" - -msgid "Session deleted." -msgstr "" - -msgid "Stages saved." -msgstr "" - -msgid "Photo deleted." -msgstr "" - -msgid "Analysis deleted." -msgstr "" - -msgid "Title updated." -msgstr "" - -msgid "Regrouped." -msgstr "" - -msgid "Note saved." -msgstr "" - -msgid "Please log in to access this page." -msgstr "" diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo deleted file mode 100644 index 08fe20a..0000000 Binary files a/translations/fr/LC_MESSAGES/messages.mo and /dev/null differ diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po deleted file mode 100644 index fbb8ebc..0000000 --- a/translations/fr/LC_MESSAGES/messages.po +++ /dev/null @@ -1,812 +0,0 @@ -msgid "" -msgstr "" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Language: fr\n" - -msgid "New Analysis" -msgstr "Nouvelle analyse" - -msgid "Tools" -msgstr "Outils" - -msgid "Analyse CSV" -msgstr "Analyser un CSV" - -msgid "Measure group (photo)" -msgstr "Mesurer un groupement (photo)" - -msgid "Equipment" -msgstr "Équipement" - -msgid "Sessions" -msgstr "Séances" - -msgid "Dashboard" -msgstr "Tableau de bord" - -msgid "Profile" -msgstr "Profil" - -msgid "Logout" -msgstr "Déconnexion" - -msgid "Login" -msgstr "Connexion" - -msgid "Join free" -msgstr "Inscription gratuite" - -msgid "Save" -msgstr "Enregistrer" - -msgid "Cancel" -msgstr "Annuler" - -msgid "Edit" -msgstr "Modifier" - -msgid "Delete" -msgstr "Supprimer" - -msgid "Apply" -msgstr "Appliquer" - -msgid "Upload" -msgstr "Envoyer" - -msgid "Notes" -msgstr "Notes" - -msgid "Public" -msgstr "Public" - -msgid "Private" -msgstr "Privé" - -msgid "Date" -msgstr "Date" - -msgid "Location" -msgstr "Lieu" - -msgid "Name" -msgstr "Nom" - -msgid "Category" -msgstr "Catégorie" - -msgid "Caliber" -msgstr "Calibre" - -msgid "Distance" -msgstr "Distance" - -msgid "Position" -msgstr "Position" - -msgid "Log a Session" -msgstr "Enregistrer une séance" - -msgid "Get started — free" -msgstr "Commencer — gratuit" - -msgid "Try without account" -msgstr "Essayer sans compte" - -msgid "Ballistic Analysis" -msgstr "Analyse balistique" - -msgid "Session Tracking" -msgstr "Suivi des séances" - -msgid "Community Feed" -msgstr "Fil communautaire" - -msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports." -msgstr "Importez des fichiers CSV de votre chrono et obtenez instantanément des statistiques de groupes, des graphiques de vélocité et des rapports PDF." - -msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place." -msgstr "Enregistrez chaque séance avec lieu, météo, arme, munitions et distance. Toutes vos données au même endroit." - -msgid "Share your public sessions and see what other shooters are achieving on the range." -msgstr "Partagez vos séances publiques et voyez ce que les autres tireurs accomplissent sur le stand." - -msgid "Latest sessions" -msgstr "Dernières séances" - -msgid "No public sessions yet. Be the first to share one!" -msgstr "Aucune séance publique pour l'instant. Soyez le premier à en partager une !" - -msgid "Analyze" -msgstr "Analyser" - -msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:" -msgstr "Importez un fichier CSV pour analyser les groupes de tirs. Le fichier doit contenir les colonnes suivantes :" - -msgid "Analysis Results" -msgstr "Résultats de l'analyse" - -msgid "← Upload another file" -msgstr "← Importer un autre fichier" - -msgid "View saved report →" -msgstr "Voir le rapport sauvegardé →" - -msgid "⬙ Download PDF report" -msgstr "⬙ Télécharger le rapport PDF" - -msgid "Overall Statistics" -msgstr "Statistiques globales" - -msgid "Metric" -msgstr "Indicateur" - -msgid "Value" -msgstr "Valeur" - -msgid "Total shots" -msgstr "Tirs totaux" - -msgid "Min speed" -msgstr "Vitesse min." - -msgid "Max speed" -msgstr "Vitesse max." - -msgid "Mean speed" -msgstr "Vitesse moyenne" - -msgid "Std dev (speed)" -msgstr "Écart-type (vitesse)" - -msgid "Groups" -msgstr "Groupes" - -msgid "group(s) detected" -msgstr "groupe(s) détecté(s)" - -msgid "Group %(n)s" -msgstr "Groupe %(n)s" - -msgid "shot(s)" -msgstr "tir(s)" - -msgid "group(s)" -msgstr "groupe(s)" - -msgid "Analysis" -msgstr "Analyse" - -msgid "Welcome back, %(name)s." -msgstr "Bienvenue, %(name)s." - -msgid "+ New session" -msgstr "+ Nouvelle séance" - -msgid "+ Add equipment" -msgstr "+ Ajouter du matériel" - -msgid "New analysis" -msgstr "Nouvelle analyse" - -msgid "Recent Analyses" -msgstr "Analyses récentes" - -msgid "Title" -msgstr "Titre" - -msgid "Shots" -msgstr "Tirs" - -msgid "Visibility" -msgstr "Visibilité" - -msgid "No analyses yet." -msgstr "Aucune analyse pour l'instant." - -msgid "Upload a CSV file" -msgstr "Importez un fichier CSV" - -msgid "to get started — it will be saved here automatically." -msgstr "pour commencer — il sera automatiquement sauvegardé ici." - -msgid "Manage your rifles, scopes & gear →" -msgstr "Gérez vos armes, optiques et équipement →" - -msgid "View your shooting sessions →" -msgstr "Voir vos séances de tir →" - -msgid "Sign in" -msgstr "Connexion" - -msgid "Password" -msgstr "Mot de passe" - -msgid "Email" -msgstr "E-mail" - -msgid "Resend confirmation email" -msgstr "Renvoyer l'e-mail de confirmation" - -msgid "Don't have an account?" -msgstr "Pas de compte ?" - -msgid "Create one" -msgstr "Créez-en un" - -msgid "or continue with" -msgstr "ou continuer avec" - -msgid "Continue with Google" -msgstr "Continuer avec Google" - -msgid "Continue with GitHub" -msgstr "Continuer avec GitHub" - -msgid "Create account" -msgstr "Créer un compte" - -msgid "(min. 8 characters)" -msgstr "(min. 8 caractères)" - -msgid "Confirm password" -msgstr "Confirmer le mot de passe" - -msgid "Already have an account?" -msgstr "Vous avez déjà un compte ?" - -msgid "Account" -msgstr "Compte" - -msgid "Profile picture" -msgstr "Photo de profil" - -msgid "JPEG/PNG, max 1200 px, auto-resized." -msgstr "JPEG/PNG, max 1200 px, redimensionné automatiquement." - -msgid "Display name" -msgstr "Nom d'affichage" - -msgid "Bio" -msgstr "Bio" - -msgid "Tell others a bit about yourself…" -msgstr "Parlez un peu de vous…" - -msgid "Show my equipment on my public profile" -msgstr "Afficher mon équipement sur mon profil public" - -msgid "Save changes" -msgstr "Enregistrer les modifications" - -msgid "View my public profile →" -msgstr "Voir mon profil public →" - -msgid "Change password" -msgstr "Changer le mot de passe" - -msgid "Current password" -msgstr "Mot de passe actuel" - -msgid "New password" -msgstr "Nouveau mot de passe" - -msgid "Confirm" -msgstr "Confirmer" - -msgid "Member since %(date)s" -msgstr "Membre depuis %(date)s" - -msgid "Session" -msgstr "Séance" - -msgid "Brand / Model" -msgstr "Marque / Modèle" - -msgid "No public sessions yet." -msgstr "Aucune séance publique pour l'instant." - -msgid "No equipment listed." -msgstr "Aucun équipement renseigné." - -msgid "My Equipment" -msgstr "Mon équipement" - -msgid "+ Add item" -msgstr "+ Ajouter un article" - -msgid "View" -msgstr "Voir" - -msgid "Delete %(name)s?" -msgstr "Supprimer %(name)s ?" - -msgid "No equipment yet." -msgstr "Aucun équipement pour l'instant." - -msgid "Add your first item" -msgstr "Ajoutez votre premier article" - -msgid "Add equipment" -msgstr "Ajouter du matériel" - -msgid "Category *" -msgstr "Catégorie *" - -msgid "Name *" -msgstr "Nom *" - -msgid "Brand" -msgstr "Marque" - -msgid "Model" -msgstr "Modèle" - -msgid "Magnification" -msgstr "Grossissement" - -msgid "Reticle" -msgstr "Réticule" - -msgid "Unit" -msgstr "Unité" - -msgid "Serial number" -msgstr "Numéro de série" - -msgid "Photo" -msgstr "Photo" - -msgid "Upload a new one to replace it." -msgstr "Importez-en une nouvelle pour la remplacer." - -msgid "My Sessions" -msgstr "Mes séances" - -msgid "Type" -msgstr "Type" - -msgid "Long Range" -msgstr "Long Range" - -msgid "25m Pistol" -msgstr "Pistolet 25m" - -msgid "Delete this session?" -msgstr "Supprimer cette séance ?" - -msgid "No sessions recorded yet." -msgstr "Aucune séance enregistrée pour l'instant." - -msgid "Log your first session" -msgstr "Enregistrez votre première séance" - -msgid "New session" -msgstr "Nouvelle séance" - -msgid "Choose the session type." -msgstr "Choisissez le type de séance." - -msgid "Long Range Practice" -msgstr "Long Range Practice" - -msgid "Long range precision shooting (100m+)" -msgstr "Tir de précision longue distance (100 m+)" - -msgid "PRS" -msgstr "PRS" - -msgid "Precision Rifle Series — training & competition" -msgstr "Precision Rifle Series — entraînement & compétition" - -msgid "25m precision pistol shooting" -msgstr "Tir de précision pistolet à 25 m" - -msgid "Stage management, PDF dope card ↗" -msgstr "Gestion des stages, fiche de tir PDF ↗" - -msgid "← Change type" -msgstr "← Changer le type" - -msgid "Edit session" -msgstr "Modifier la séance" - -msgid "Basic information" -msgstr "Informations de base" - -msgid "Date *" -msgstr "Date *" - -msgid "Distance (m)" -msgstr "Distance (m)" - -msgid "Shooting position" -msgstr "Position de tir" - -msgid "Weather" -msgstr "Météo" - -msgid "Conditions" -msgstr "Conditions" - -msgid "Temp. (°C)" -msgstr "Temp. (°C)" - -msgid "Wind (km/h)" -msgstr "Vent (km/h)" - -msgid "Equipment & Ammunition" -msgstr "Équipement & Munitions" - -msgid "Rifle / Handgun" -msgstr "Arme" - -msgid "— none —" -msgstr "— aucune —" - -msgid "Add a rifle first" -msgstr "Ajouter une arme d'abord" - -msgid "Scope" -msgstr "Optique" - -msgid "Ammo brand" -msgstr "Marque munitions" - -msgid "Bullet weight (gr)" -msgstr "Poids ogive (gr)" - -msgid "Lot number" -msgstr "N° de lot" - -msgid "Notes & Visibility" -msgstr "Notes & Visibilité" - -msgid "Make this session public (visible in the community feed)" -msgstr "Rendre cette séance publique (visible dans le fil communautaire)" - -msgid "Create session" -msgstr "Créer la séance" - -msgid "Session type:" -msgstr "Type de séance :" - -msgid "Delete this session? This cannot be undone." -msgstr "Supprimer cette séance ? Cette action est irréversible." - -msgid "km/h wind" -msgstr "km/h de vent" - -msgid "Ammunition" -msgstr "Munitions" - -msgid "PRS Stages" -msgstr "Stages PRS" - -msgid "Time (s)" -msgstr "Temps (s)" - -msgid "Elevation Dope" -msgstr "Dope Élév." - -msgid "Windage Dope" -msgstr "Dope Dérive" - -msgid "Hits/Poss." -msgstr "Coups/Poss." - -msgid "✏ Edit stages" -msgstr "✏ Modifier les stages" - -msgid "📄 Generate dope card (PDF)" -msgstr "📄 Générer la fiche de tir (PDF)" - -msgid "+ Add stage" -msgstr "+ Ajouter un stage" - -msgid "💾 Save" -msgstr "💾 Enregistrer" - -msgid "Photos" -msgstr "Photos" - -msgid "Add photo" -msgstr "Ajouter une photo" - -msgid "Caption (optional)" -msgstr "Légende (optionnel)" - -msgid "Measure group" -msgstr "Mesurer le groupe" - -msgid "Delete this photo?" -msgstr "Supprimer cette photo ?" - -msgid "clean barrel" -msgstr "canon propre" - -msgid "Group ES" -msgstr "ES du groupe" - -msgid "Mean Radius" -msgstr "Rayon moyen" - -msgid "Centre" -msgstr "Centre" - -msgid "high" -msgstr "haut" - -msgid "low" -msgstr "bas" - -msgid "Analyses" -msgstr "Analyses" - -msgid "shots" -msgstr "tirs" - -msgid "group" -msgstr "groupe" - -msgid "groups" -msgstr "groupes" - -msgid "m/s mean" -msgstr "m/s moyen" - -msgid "✏ Rename" -msgstr "✏ Renommer" - -msgid "Full view" -msgstr "Vue complète" - -msgid "Delete this analysis? This cannot be undone." -msgstr "Supprimer cette analyse ? Cette action est irréversible." - -msgid "mean" -msgstr "moyen" - -msgid "SD" -msgstr "ET" - -msgid "ES" -msgstr "ES" - -msgid "Note" -msgstr "Note" - -msgid "Save note" -msgstr "Enregistrer la note" - -msgid "CSV file missing — cannot display charts." -msgstr "Fichier CSV manquant — impossible d'afficher les graphiques." - -msgid "Re-group settings" -msgstr "Paramètres de regroupement" - -msgid "Outlier factor:" -msgstr "Facteur d'anomalie :" - -msgid "1 (fine)" -msgstr "1 (fin)" - -msgid "20 (coarse)" -msgstr "20 (grossier)" - -msgid "Manual split indices (JSON array, e.g. [5, 12])" -msgstr "Indices de coupure manuels (tableau JSON, ex. [5, 12])" - -msgid "Shot positions (0-based) where a new group should always begin." -msgstr "Positions de tir (base 0) où un nouveau groupe doit toujours commencer." - -msgid "Upload chronograph CSV" -msgstr "Importer le CSV du chronographe" - -msgid "Analyse & link" -msgstr "Analyser & lier" - -msgid "Invalid email or password." -msgstr "Identifiant ou mot de passe incorrect." - -msgid "Account created! Welcome." -msgstr "Compte créé ! Bienvenue." - -msgid "Profile updated." -msgstr "Profil mis à jour." - -msgid "Password changed." -msgstr "Mot de passe modifié." - -msgid "'%(name)s' added." -msgstr "'%(name)s' ajouté." - -msgid "'%(name)s' updated." -msgstr "'%(name)s' mis à jour." - -msgid "'%(name)s' deleted." -msgstr "'%(name)s' supprimé." - -msgid "Name is required." -msgstr "Le nom est obligatoire." - -msgid "Invalid category." -msgstr "Catégorie invalide." - -msgid "Session saved." -msgstr "Séance sauvegardée." - -msgid "Session deleted." -msgstr "Séance supprimée." - -msgid "Stages saved." -msgstr "Stages sauvegardés." - -msgid "Photo deleted." -msgstr "Photo supprimée." - -msgid "Analysis deleted." -msgstr "Analyse supprimée." - -msgid "Title updated." -msgstr "Titre mis à jour." - -msgid "Regrouped." -msgstr "Regroupé." - -msgid "Note saved." -msgstr "Note sauvegardée." - -msgid "Please log in to access this page." -msgstr "Veuillez vous connecter pour accéder à cette page." - -msgid "Admin — Users" -msgstr "Admin — Utilisateurs" - -msgid "User Management" -msgstr "Gestion des utilisateurs" - -msgid "users" -msgstr "utilisateurs" - -msgid "Provider" -msgstr "Fournisseur" - -msgid "Role" -msgstr "Rôle" - -msgid "Language" -msgstr "Langue" - -msgid "Joined" -msgstr "Inscrit" - -msgid "Last login" -msgstr "Dernière connexion" - -msgid "Actions" -msgstr "Actions" - -msgid "Local" -msgstr "Local" - -msgid "Set" -msgstr "Définir" - -msgid "Reset pwd" -msgstr "Réinit. mdp" - -msgid "New password (min 8)" -msgstr "Nouveau mot de passe (min 8)" - -msgid "Delete user %(email)s? All their data will be permanently removed." -msgstr "Supprimer l'utilisateur %(email)s ? Toutes ses données seront définitivement supprimées." - -msgid "(you)" -msgstr "(vous)" - -msgid "Invalid role." -msgstr "Rôle invalide." - -msgid "Cannot remove the last admin." -msgstr "Impossible de supprimer le dernier administrateur." - -msgid "Role updated for %(email)s." -msgstr "Rôle mis à jour pour %(email)s." - -msgid "Password reset for %(email)s." -msgstr "Mot de passe réinitialisé pour %(email)s." - -msgid "You cannot delete your own account." -msgstr "Vous ne pouvez pas supprimer votre propre compte." - -msgid "User %(email)s deleted." -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."