Remove old stuff has project has been reworked in django

This commit is contained in:
Gérald Colangelo
2026-04-02 11:21:29 +02:00
parent 85de9781d7
commit 7710a876df
84 changed files with 0 additions and 10404 deletions

View File

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

View File

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

View File

View File

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

View File

@@ -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 = [
("", _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 "")

View File

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

View File

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

View File

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

View File

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

218
app.py
View File

@@ -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/<int:user_id>")
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/<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

View File

@@ -1,3 +0,0 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

View File

View File

@@ -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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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"))

View File

@@ -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("/<int:analysis_id>")
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("/<int:analysis_id>/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("/<int:analysis_id>/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("/<int:analysis_id>/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("/<int:analysis_id>/groups/<int:group_index>/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("/<int:analysis_id>/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("/<int:analysis_id>/groups/<int:group_index>/photo", methods=["POST"])
@login_required
def upload_group_photo(analysis_id: int, group_index: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
photo_file = request.files.get("photo")
if not photo_file or not photo_file.filename:
flash(_("No photo selected."), "error")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
from storage import save_analysis_group_photo
try:
photo_path = save_analysis_group_photo(
current_user.id, a.id, group_index, photo_file
)
except ValueError as e:
flash(str(e), "error")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
caption = request.form.get("caption", "").strip() or None
photo = AnalysisGroupPhoto(
analysis_id=a.id,
group_index=group_index,
photo_path=photo_path,
caption=caption,
)
db.session.add(photo)
db.session.commit()
flash(_("Photo added."), "success")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
@analyses_bp.route("/group-photos/<int:photo_id>/delete", methods=["POST"])
@login_required
def delete_group_photo(photo_id: int):
photo = db.session.get(AnalysisGroupPhoto, photo_id)
if photo is None:
abort(404)
if photo.analysis.user_id != current_user.id:
abort(403)
storage_root = current_app.config["STORAGE_ROOT"]
try:
(Path(storage_root) / photo.photo_path).unlink(missing_ok=True)
except Exception:
pass
analysis_id = photo.analysis_id
session_id = photo.analysis.session_id
db.session.delete(photo)
db.session.commit()
flash(_("Photo deleted."), "success")
back = (url_for("sessions.detail", session_id=session_id)
if session_id else url_for("analyses.detail", analysis_id=analysis_id))
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/group-photos/<int:photo_id>/annotate",
methods=["GET", "POST"])
@login_required
def annotate_group_photo(analysis_id: int, photo_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
photo = db.session.get(AnalysisGroupPhoto, photo_id)
if photo is None or photo.analysis_id != analysis_id:
abort(404)
if request.method == "POST":
data = request.get_json(force=True)
photo.annotations = data
db.session.commit()
return jsonify({"ok": True})
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
# Pre-fill shooting distance from session if available
session_dist_m = None
if a.session_id:
from models import ShootingSession
s = db.session.get(ShootingSession, a.session_id)
if s and s.distance_m:
session_dist_m = s.distance_m
return render_template("analyses/annotate_group_photo.html",
analysis=a, photo=photo,
back_url=back, session_dist_m=session_dist_m)
@analyses_bp.route("/group-photos/<path:filepath>")
def serve_group_photo(filepath: str):
"""Serve an analysis group photo. Private analysis photos are owner-only."""
try:
owner_id = int(filepath.split("/")[0])
except (ValueError, IndexError):
abort(404)
is_owner = current_user.is_authenticated and current_user.id == owner_id
if not is_owner:
# Check if analysis is public
from sqlalchemy import select
photo = db.session.scalars(
select(AnalysisGroupPhoto).where(
AnalysisGroupPhoto.photo_path == f"analysis_group_photos/{filepath}"
)
).first()
if photo is None or not photo.analysis.is_public:
abort(403)
storage_root = current_app.config["STORAGE_ROOT"]
return send_from_directory(Path(storage_root) / "analysis_group_photos", filepath)

View File

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

View File

@@ -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("/<int:analysis_id>")
@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("/<int:analysis_id>")
@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("/<int:analysis_id>/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))

View File

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

View File

@@ -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("/<int:item_id>")
@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("/<int:item_id>")
@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("/<int:item_id>")
@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()

View File

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

View File

@@ -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("/<int:session_id>")
@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("/<int:session_id>")
@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("/<int:session_id>")
@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("/<int:session_id>/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("/<int:session_id>/photos/<int:photo_id>")
@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("/<int:session_id>/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))

View File

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

View File

@@ -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/<token>")
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/<int:user_id>")
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"))

View File

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

View File

@@ -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("/<int:item_id>")
@login_required
def detail(item_id: int):
item = _own_item(item_id)
return render_template("equipment/detail.html", item=item, categories=dict(_t_categories()))
@equipment_bp.route("/<int:item_id>/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("/<int:item_id>/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("/<int:item_id>/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/<path:filepath>")
@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

View File

@@ -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("/<int:session_id>")
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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/photos/<int:photo_id>/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("/<int:session_id>/photos/<int:photo_id>/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("/<int:session_id>/photos/<int:photo_id>/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/<path:filepath>")
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
Single-database configuration for Flask.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

207
models.py
View File

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

View File

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

View File

@@ -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,,,,,,
1 Balle de fusil 169 0 gr
2 # Vitesse (MPS) Δ Moyenne (MPS) ÉK (J) Facteur de puissance (N s) Temps Âme nettoyée Âme froide Notes de tir
3 1 807,4 6,1 3569,4 8,8 09:30:37
4 2 801,1 -0,2 3514,0 8,8 09:31:06
5 3 799,3 -2,0 3498,4 8,8 09:31:28
6 4 800,1 -1,3 3505,0 8,8 09:32:17
7 5 800,3 -1,0 3507,1 8,8 09:32:36
8 6 789,5 -11,8 3412,9 8,6 09:49:40
9 7 800,7 -0,7 3510,2 8,8 09:49:54
10 8 795,1 -6,2 3461,6 8,7 09:50:10
11 9 799,8 -1,5 3502,6 8,8 09:50:27
12 10 798,9 -2,4 3494,8 8,7 09:50:57
13 11 795,4 -5,9 3464,0 8,7 09:53:04
14 12 798,6 -2,7 3492,0 8,7 09:53:15
15 13 798,1 -3,2 3487,8 8,7 09:53:28
16 14 798,3 -3,0 3489,3 8,7 09:53:44
17 15 797,6 -3,7 3483,7 8,7 09:54:02
18 16 800,3 -1,0 3506,9 8,8 10:08:55
19 17 800,6 -0,7 3509,9 8,8 10:09:24
20 18 804,6 3,2 3544,5 8,8 10:09:38
21 19 799,8 -1,6 3502,4 8,8 10:09:55
22 20 802,1 0,8 3523,0 8,8 10:10:15
23 21 802,0 0,6 3521,7 8,8 10:26:11
24 22 804,0 2,7 3539,7 8,8 10:26:32
25 23 805,1 3,8 3549,4 8,8 10:27:23
26 24 802,8 1,5 3529,3 8,8 10:27:50
27 25 805,8 4,4 3555,1 8,8 10:28:15
28 26 808,1 6,8 3575,9 8,8 10:43:45
29 27 798,5 -2,9 3490,8 8,7 10:44:04
30 28 798,4 -3,0 3489,9 8,7 10:44:20
31 29 801,6 0,3 3518,7 8,8 10:44:48
32 30 800,1 -1,2 3505,5 8,8 10:45:10
33 31 802,4 1,1 3525,3 8,8 10:57:50
34 32 796,9 -4,5 3477,1 8,7 10:58:08
35 33 800,9 -0,5 3511,8 8,8 10:58:27
36 34 800,7 -0,7 3510,2 8,8 10:58:43
37 35 797,3 -4,1 3480,3 8,7 10:59:02
38 36 796,2 -5,1 3471,0 8,7 11:14:15
39 37 809,7 8,4 3589,9 8,9 11:14:37
40 38 802,9 1,6 3529,6 8,8 11:15:02
41 39 810,3 8,9 3594,7 8,9 11:15:27
42 40 804,2 2,9 3541,5 8,8 11:15:57
43 41 807,1 5,8 3567,2 8,8 11:28:00
44 42 805,4 4,1 3552,0 8,8 11:28:20
45 43 802,0 0,7 3522,2 8,8 11:28:50
46 44 804,6 3,3 3544,6 8,8 11:29:11
47 45 807,6 6,3 3571,4 8,8 11:29:32
48 46 807,3 6,0 3568,5 8,8 11:30:05
49 47 795,8 -5,5 3467,8 8,7 11:30:19
50 48 798,2 -3,1 3488,9 8,7 11:30:36
51 49 801,9 0,5 3520,6 8,8 11:30:49
52 50 801,2 -0,2 3514,5 8,8 11:31:10
53 -
54 VITESSE MOYENNE 801,3
55 FACTEUR DE PUISSANCE MOYEN 8,8
56 ÉCART-TYPE 4,1
57 SPREAD 20,8
58 Poids du projectile (GRAINS) 169,0
59 ÉNERGIE CINÉTIQUE MOY. 3516,1
60 Note sur la session
61 -
62 Date MARS 16, 2026 09:13
63 Tous les tirs inclus dans les calculs

View File

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

View File

@@ -1,124 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin — Users') }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">{{ _('User Management') }}</h1>
<span style="font-size:0.85rem;color:#888;">{{ users|length }} {{ _('users') }}</span>
</div>
<div style="overflow-x:auto;">
<table style="min-width:900px;">
<thead>
<tr>
<th>{{ _('User') }}</th>
<th>{{ _('Provider') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Language') }}</th>
<th>{{ _('Joined') }}</th>
<th>{{ _('Last login') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
{# User info #}
<td>
<div style="display:flex;align-items:center;gap:.6rem;">
{% if u.effective_avatar_url %}
<img src="{{ u.effective_avatar_url }}" style="width:28px;height:28px;border-radius:50%;object-fit:cover;" alt="">
{% else %}
<div style="width:28px;height:28px;border-radius:50%;background:#e0e4f0;display:flex;align-items:center;justify-content:center;font-size:.75rem;color:#666;font-weight:700;">
{{ (u.display_name or u.email)[0].upper() }}
</div>
{% endif %}
<div>
<div style="font-weight:600;font-size:.9rem;">{{ u.display_name or '—' }}</div>
<div style="font-size:.78rem;color:#888;">{{ u.email }}</div>
</div>
</div>
</td>
{# Provider #}
<td style="font-size:.82rem;color:#666;">
{% if u.provider == 'google' %}🔵 Google
{% elif u.provider == 'github' %}⚫ GitHub
{% else %}🔑 {{ _('Local') }}
{% endif %}
</td>
{# Role badge + change form #}
<td>
<form method="post" action="{{ url_for('admin.change_role', user_id=u.id) }}"
style="display:flex;gap:.4rem;align-items:center;">
<select name="role" style="padding:.2rem .5rem;font-size:.82rem;border:1px solid #ccc;border-radius:4px;background:#fff;">
{% for r in roles %}
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
<button type="submit"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.2rem .6rem;font-size:.78rem;cursor:pointer;">
{{ _('Set') }}
</button>
</form>
</td>
{# Language #}
<td style="font-size:.85rem;color:#666;">
{{ u.language or '—' }}
</td>
{# Dates #}
<td style="font-size:.8rem;color:#888;white-space:nowrap;">{{ u.created_at.strftime('%d %b %Y') }}</td>
<td style="font-size:.8rem;color:#888;white-space:nowrap;">
{{ u.last_login_at.strftime('%d %b %Y') if u.last_login_at else '—' }}
</td>
{# Actions #}
<td>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;">
{# Reset password (local accounts only) #}
{% if u.provider == 'local' %}
<details style="display:inline;">
<summary style="display:inline-block;padding:.2rem .6rem;background:#f0f4ff;color:#1a1a2e;
border:1px solid #c8d4f0;border-radius:4px;font-size:.78rem;cursor:pointer;list-style:none;">
🔑 {{ _('Reset pwd') }}
</summary>
<form method="post" action="{{ url_for('admin.reset_password', user_id=u.id) }}"
style="display:flex;gap:.4rem;align-items:center;margin-top:.35rem;flex-wrap:wrap;">
<input type="password" name="new_password" required minlength="8"
placeholder="{{ _('New password (min 8)') }}"
style="padding:.3rem .6rem;border:1px solid #ccc;border-radius:4px;font-size:.82rem;width:180px;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.3rem .7rem;font-size:.78rem;cursor:pointer;">
{{ _('Save') }}
</button>
</form>
</details>
{% endif %}
{# Delete — cannot delete yourself #}
{% if u.id != current_user.id %}
<form method="post" action="{{ url_for('admin.delete_user', user_id=u.id) }}"
onsubmit="return confirm('{{ _('Delete user %(email)s? All their data will be permanently removed.', email=u.email) | e }}');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;
padding:.2rem .6rem;font-size:.78rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
{% else %}
<span style="font-size:.75rem;color:#aaa;">{{ _('(you)') }}</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -1,93 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ analysis.title }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
{% if analysis.session_id %}
<a href="{{ url_for('sessions.detail', session_id=analysis.session_id) }}">{{ _('Session') }}</a> &rsaquo;
{% else %}
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a> &rsaquo;
{% endif %}
{{ _('Analysis') }}
</div>
<h1 style="margin:0;">{{ analysis.title }}</h1>
<div style="font-size:0.85rem;color:#888;margin-top:.3rem;">
{{ analysis.created_at.strftime('%d %b %Y') }}
&nbsp;&middot;&nbsp; {{ analysis.shot_count }} {{ _('shot(s)') }}
&nbsp;&middot;&nbsp; {{ analysis.group_count }} {{ _('group(s)') }}
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;">
{% if has_pdf %}
<a href="{{ url_for('analyses.download_pdf', analysis_id=analysis.id) }}"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
&#8659; {{ _('Download PDF report') }}
</a>
{% endif %}
{% if current_user.is_authenticated and current_user.id == analysis.user_id %}
<form method="post" action="{{ url_for('analyses.delete', analysis_id=analysis.id) }}"
onsubmit="return confirm('{{ _('Delete this analysis? The CSV and PDF will be permanently removed.') | e }}');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
{% endif %}
<a href="{{ url_for('analyze') }}" style="font-size:0.9rem;color:#666;">&#8592; {{ _('New analysis') }}</a>
</div>
</div>
<h2>{{ _('Overall Statistics') }}</h2>
<table style="max-width:480px;">
<thead>
<tr><th>{{ _('Metric') }}</th><th>{{ _('Value') }}</th></tr>
</thead>
<tbody>
<tr><td>{{ _('Total shots') }}</td><td>{{ overall.count }}</td></tr>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}&ndash;{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
<h2>{{ _('Groups') }} &mdash; {{ groups_display|length }} {{ _('group(s) detected') }}</h2>
{% for stat, chart_b64 in groups_display %}
<div class="group-section">
<h3>{{ _('Group %(n)s', n=stat.group_index) }}</h3>
<div class="group-meta">
{{ stat.time_start }} &ndash; {{ stat.time_end }} &nbsp;|&nbsp; {{ stat.count }} {{ _('shot(s)') }}
</div>
<table style="max-width:480px;">
<thead>
<tr><th>{{ _('Metric') }}</th><th>{{ _('Value') }}</th></tr>
</thead>
<tbody>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}&ndash;{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ chart_b64 }}"
alt="Speed chart for group {{ stat.group_index }}">
</div>
{% endfor %}
{% endblock %}

View File

@@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block title %}Confirm your email — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Check your inbox</h1>
<p style="color:#555;margin-bottom:1.25rem;">
A confirmation link has been sent to <strong>{{ email }}</strong>.
Click the link in that email to activate your account.
</p>
<p style="color:#888;font-size:0.9rem;margin-bottom:1.5rem;">
Didn't receive it? Check your spam folder, or request a new link below.
</p>
<form method="post" action="{{ url_for('auth.resend_confirmation') }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit"
style="background:#f0f4ff;color:#1f77b4;border:1px solid #c0d4f0;border-radius:4px;padding:0.55rem 1.2rem;font-size:0.92rem;cursor:pointer;">
Resend confirmation email
</button>
</form>
{% endblock %}

View File

@@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Sign in') }} — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>{{ _('Sign in') }}</h1>
<form method="post" action="{{ url_for('auth.login') }}" style="max-width:360px;margin-bottom:1.5rem;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Email') }}</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Password') }}</label>
<input type="password" name="password" required autocomplete="current-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
{{ _('Sign in') }}
</button>
</form>
{% if show_resend %}
<form method="post" action="{{ url_for('auth.resend_confirmation') }}" style="margin-bottom:1.5rem;">
<input type="hidden" name="email" value="{{ resend_email }}">
<button type="submit" class="btn-link" style="color:#1f77b4;font-size:0.88rem;">
{{ _('Resend confirmation email') }}
</button>
</form>
{% endif %}
<p style="font-size:0.9rem;color:#555;margin-bottom:1.5rem;">
{{ _("Don't have an account?") }} <a href="{{ url_for('auth.register') }}">{{ _('Create one') }}</a>
</p>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem;max-width:360px;">
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
<span style="font-size:0.8rem;color:#999;">{{ _('or continue with') }}</span>
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem;max-width:360px;">
<a href="{{ url_for('auth.login_google') }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1.1rem;border:1px solid #dadce0;border-radius:6px;color:#3c4043;text-decoration:none;font-size:0.92rem;font-weight:500;background:#fff;">
<svg width="17" height="17" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
{{ _('Continue with Google') }}
</a>
<a href="{{ url_for('auth.login_github') }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.65rem 1.1rem;border:1px solid #d0d7de;border-radius:6px;color:#24292f;text-decoration:none;font-size:0.92rem;font-weight:500;background:#f6f8fa;">
<svg width="17" height="17" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/>
</svg>
{{ _('Continue with GitHub') }}
</a>
</div>
{% endblock %}

View File

@@ -1,107 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Profile') }} — The Shooter's Network{% endblock %}
{% block content %}
<h1>{{ _('Profile') }}</h1>
{# ---- Avatar + display name ---- #}
<h2>{{ _('Account') }}</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
enctype="multipart/form-data"
style="max-width:480px;">
<input type="hidden" name="action" value="update_profile">
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:1.5rem;">
{% set av = current_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" alt="Avatar"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid #e0e0e0;">
{% else %}
<div style="width:80px;height:80px;border-radius:50%;background:#e0e6f0;display:flex;align-items:center;justify-content:center;font-size:2rem;color:#888;">
&#128100;
</div>
{% endif %}
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
{{ _('Profile picture') }}
</label>
<input type="file" name="avatar" accept="image/*" style="font-size:0.9rem;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">{{ _('JPEG/PNG, max 1200 px, auto-resized.') }}</div>
</div>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Display name') }}</label>
<input type="text" name="display_name"
value="{{ current_user.display_name or '' }}"
required
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Bio') }}</label>
<textarea name="bio" rows="4" placeholder="{{ _('Tell others a bit about yourself…') }}"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ current_user.bio or '' }}</textarea>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Email') }}</label>
<input type="text" value="{{ current_user.email }}" disabled
style="width:100%;padding:.55rem .75rem;border:1px solid #e0e0e0;border-radius:4px;font-size:0.95rem;background:#f5f5f5;color:#888;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">
Logged in via <strong>{{ current_user.provider.title() }}</strong>
</div>
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;font-size:.95rem;">
<input type="checkbox" name="show_equipment_public"
{% if current_user.show_equipment_public %}checked{% endif %}
style="width:1rem;height:1rem;">
{{ _('Show my equipment on my public profile') }}
</label>
</div>
<div style="display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
{{ _('Save changes') }}
</button>
<a href="{{ url_for('public_profile', user_id=current_user.id) }}"
style="font-size:0.9rem;color:#1f77b4;" target="_blank">
{{ _('View my public profile →') }}
</a>
</div>
</form>
{# ---- Change password (local accounts only) ---- #}
{% if current_user.provider == 'local' %}
<h2>{{ _('Change password') }}</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
style="max-width:480px;">
<input type="hidden" name="action" value="change_password">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Current password') }}</label>
<input type="password" name="current_password" required
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('New password') }}</label>
<input type="password" name="new_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Confirm') }}</label>
<input type="password" name="confirm_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
{{ _('Change password') }}
</button>
</form>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap;">
{% set av = profile_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" alt="Avatar"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid #e0e0e0;flex-shrink:0;">
{% else %}
<div style="width:80px;height:80px;border-radius:50%;background:#e0e6f0;display:flex;align-items:center;justify-content:center;font-size:2.5rem;color:#888;flex-shrink:0;">
&#128100;
</div>
{% endif %}
<div>
<h1 style="margin:0 0 .25rem;">{{ profile_user.display_name or profile_user.email.split('@')[0] }}</h1>
<div style="font-size:0.85rem;color:#888;">
{{ _('Member since %(date)s', date=profile_user.created_at.strftime('%B %Y')) }}
</div>
{% if profile_user.bio %}
<p style="margin-top:.75rem;color:#444;white-space:pre-wrap;max-width:600px;">{{ profile_user.bio }}</p>
{% endif %}
</div>
</div>
{# ---- Public Sessions ---- #}
<h2>{{ _('Sessions') }}{% if public_sessions %} ({{ public_sessions|length }}){% endif %}</h2>
{% if public_sessions %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>{{ _('Session') }}</th><th>{{ _('Location') }}</th><th>{{ _('Distance') }}</th></tr>
</thead>
<tbody>
{% for s in public_sessions %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('sessions.detail', session_id=s.id) }}'">
<td>
<a href="{{ url_for('sessions.detail', session_id=s.id) }}" style="color:inherit;text-decoration:none;">
{{ s.session_date.strftime('%d %b %Y') }}
</a>
</td>
<td style="color:#666;">{{ s.location_name or '—' }}</td>
<td style="color:#666;">{% if s.distance_m %}{{ s.distance_m }} m{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No public sessions yet.') }}</p>
{% endif %}
{# ---- Equipment (optional) ---- #}
{% if equipment is not none %}
<h2>{{ _('Equipment') }}</h2>
{% if equipment %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>{{ _('Name') }}</th><th>{{ _('Category') }}</th><th>{{ _('Brand / Model') }}</th><th>{{ _('Caliber') }}</th></tr>
</thead>
<tbody>
{% for item in equipment %}
<tr>
<td>{{ item.name }}</td>
<td style="color:#666;font-size:0.88rem;">{{ item.category.title() }}</td>
<td style="color:#666;font-size:0.88rem;">
{% if item.brand or item.model %}
{{ item.brand or '' }}{% if item.brand and item.model %} {% endif %}{{ item.model or '' }}
{% else %}—{% endif %}
</td>
<td style="color:#666;font-size:0.88rem;">{{ item.caliber or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No equipment listed.') }}</p>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -1,33 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Create account') }} — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>{{ _('Create account') }}</h1>
<form method="post" action="{{ url_for('auth.register') }}" style="max-width:360px;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Email') }}</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Password') }}
<span style="font-weight:400;color:#888;">{{ _('(min. 8 characters)') }}</span>
</label>
<input type="password" name="password" required autocomplete="new-password" minlength="8"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Confirm password') }}</label>
<input type="password" name="confirm_password" required autocomplete="new-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
{{ _('Create account') }}
</button>
</form>
<p style="font-size:0.9rem;color:#555;margin-top:1.25rem;">
{{ _('Already have an account?') }} <a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a>
</p>
{% endblock %}

View File

@@ -1,534 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}The Shooter's Network{% endblock %}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #f4f5f7;
color: #222;
min-height: 100vh;
}
/* ── Nav ── */
.nav {
background: #1a1a2e;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 52px;
gap: 1rem;
position: relative;
}
.nav-brand {
font-weight: 700;
font-size: 1rem;
color: #fff;
text-decoration: none;
letter-spacing: 0.02em;
white-space: nowrap;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.nav-links a {
color: #c8cfe0;
text-decoration: none;
font-size: 0.9rem;
}
.nav-links a:hover { color: #fff; }
.nav-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
vertical-align: middle;
object-fit: cover;
}
.nav-right {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
/* ── Hamburger (hidden on desktop) ── */
.nav-hamburger {
display: none;
background: none;
border: none;
color: #fff;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.4rem;
}
/* ── Mobile menu panel ── */
.nav-mobile-menu {
display: none;
position: absolute;
top: 52px;
left: 0; right: 0;
background: #1a1a2e;
border-top: 1px solid rgba(255,255,255,.12);
padding: 0.75rem 1.5rem 1rem;
z-index: 200;
flex-direction: column;
gap: 0;
}
.nav-mobile-menu a,
.nav-mobile-menu button {
display: block;
width: 100%;
text-align: left;
color: #c8cfe0;
text-decoration: none;
font-size: 0.95rem;
padding: 0.6rem 0;
border-bottom: 1px solid rgba(255,255,255,.07);
background: none;
border-left: none;
border-right: none;
border-top: none;
cursor: pointer;
font-family: inherit;
}
.nav-mobile-menu a:last-child,
.nav-mobile-menu button:last-child { border-bottom: none; }
.nav-mobile-menu a:hover,
.nav-mobile-menu button:hover { color: #fff; }
.nav.open .nav-mobile-menu { display: flex; }
@media (max-width: 640px) {
.nav-links { display: none; }
.nav-right { display: none; }
.nav-hamburger { display: block; }
}
/* ── User dropdown ── */
.nav-dropdown { position: relative; }
.nav-user-btn {
display: flex;
align-items: center;
gap: 0.4rem;
background: none;
border: 1px solid rgba(255,255,255,.25);
border-radius: 20px;
padding: 0.25rem 0.75rem 0.25rem 0.4rem;
color: #c8cfe0;
cursor: pointer;
font-size: 0.88rem;
font-family: inherit;
white-space: nowrap;
}
.nav-user-btn:hover { border-color: rgba(255,255,255,.55); color: #fff; }
.nav-user-btn .caret { font-size: 0.65rem; opacity: .7; }
.nav-dd-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 6px);
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,.12);
min-width: 160px;
z-index: 100;
overflow: hidden;
}
.nav-dropdown.open .nav-dd-menu { display: block; }
.nav-dd-menu a,
.nav-dd-menu button {
display: block;
width: 100%;
text-align: left;
padding: 0.65rem 1rem;
font-size: 0.9rem;
color: #222;
text-decoration: none;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.nav-dd-menu a:hover,
.nav-dd-menu button:hover { background: #f4f5f7; }
.nav-dd-menu hr { border: none; border-top: 1px solid #e8e8e8; margin: 0; }
.btn-link {
background: none;
border: none;
color: #c8cfe0;
cursor: pointer;
font-size: 0.9rem;
padding: 0;
font-family: inherit;
}
.btn-link:hover { color: #fff; text-decoration: underline; }
/* ── Flash messages ── */
.flashes {
max-width: 960px;
margin: 1rem auto 0;
padding: 0 1rem;
}
.flash {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
font-size: 0.92rem;
}
.flash.error { background: #fff0f0; border-left: 4px solid #e74c3c; color: #c0392b; }
.flash.success { background: #f0fff4; border-left: 4px solid #27ae60; color: #1e8449; }
.flash.message { background: #f0f4ff; border-left: 4px solid #1f77b4; color: #154360; }
/* ── Page content ── */
.page { padding: 2rem 1rem; }
.container {
max-width: 960px;
margin: 0 auto;
background: #fff;
border-radius: 8px;
padding: 2rem 2.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
h1 { font-size: 1.8rem; margin-bottom: 1.5rem; color: #1a1a2e; }
h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; color: #1a1a2e; border-bottom: 2px solid #e0e0e0; padding-bottom: 0.3rem; }
h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #333; }
a { color: #1f77b4; text-decoration: none; }
a:hover { text-decoration: underline; }
.error {
background: #fff0f0;
border-left: 4px solid #e74c3c;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1.25rem;
color: #c0392b;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
}
th, td {
text-align: left;
padding: 0.55rem 0.9rem;
border-bottom: 1px solid #e8e8e8;
font-size: 0.92rem;
}
th { background: #f0f4ff; font-weight: 600; color: #444; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafbff; }
.group-section {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.group-meta { font-size: 0.88rem; color: #666; margin-bottom: 0.75rem; }
.chart-img {
width: 100%;
max-width: 860px;
display: block;
margin-top: 1rem;
border-radius: 4px;
}
</style>
</head>
<body>
{% block body %}
<nav class="nav" id="mainNav">
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{# Tools dropdown — always visible #}
<div class="nav-dropdown" id="toolsDropdown">
<button class="nav-user-btn" onclick="toggleToolsDropdown(event)"
style="border:none;padding:.25rem .55rem;font-size:0.9rem;color:#c8cfe0;gap:.3rem;">
{{ _('Tools') }} <span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu" style="min-width:200px;">
<a href="{{ url_for('analyze') }}">&#128202;&ensp;{{ _('Analyse CSV') }}</a>
<a href="{{ url_for('tools_measure') }}">&#127919;&ensp;{{ _('Measure group (photo)') }}</a>
</div>
</div>
{% if current_user.is_authenticated %}
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
{% if current_user.role == 'admin' %}<a href="{{ url_for('admin.index') }}" style="color:#f9a825;">{{ _('Admin') }}</a>{% endif %}
{% endif %}
</div>
<div class="nav-right">
{# Language switcher #}
<div class="nav-dropdown" id="langDropdown">
<button class="nav-user-btn" onclick="toggleLangDropdown(event)" style="padding:.2rem .55rem;gap:.35rem;font-size:1.1rem;">
{% if current_lang == 'fr' %}🇫🇷{% elif current_lang == 'de' %}🇩🇪{% else %}🇬🇧{% endif %}<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu" style="min-width:130px;">
<a href="{{ url_for('set_lang', lang='en') }}">🇬🇧 English</a>
<a href="{{ url_for('set_lang', lang='fr') }}">🇫🇷 Français</a>
<a href="{{ url_for('set_lang', lang='de') }}">🇩🇪 Deutsch</a>
</div>
</div>
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
{% set av = current_user.effective_avatar_url %}
{% if av %}
<img src="{{ av }}" class="nav-avatar" alt="">
{% else %}
<span style="font-size:1.1rem;line-height:1;">&#128100;</span>
{% endif %}
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;{{ _('Profile') }}</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;{{ _('Logout') }}</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">{{ _('Login') }}</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
{{ _('Join free') }}
</a>
{% endif %}
</div>
{# Hamburger — only visible on mobile #}
<button class="nav-hamburger" onclick="toggleMobileNav(event)" aria-label="Menu">&#9776;</button>
{# Mobile menu panel #}
<div class="nav-mobile-menu">
<a href="{{ url_for('analyze') }}">&#128202;&ensp;{{ _('Analyse CSV') }}</a>
<a href="{{ url_for('tools_measure') }}">&#127919;&ensp;{{ _('Measure group (photo)') }}</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
{% if current_user.role == 'admin' %}<a href="{{ url_for('admin.index') }}" style="color:#f9a825;">{{ _('Admin') }}</a>{% endif %}
<a href="{{ url_for('auth.profile') }}">{{ _('Profile') }}</a>
<form method="post" action="{{ url_for('auth.logout') }}" style="padding:0;border:none;">
<button type="submit">{{ _('Logout') }}</button>
</form>
{% else %}
<a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a>
<a href="{{ url_for('auth.register') }}">{{ _('Join free') }}</a>
{% endif %}
<div style="padding:.5rem 0;border-top:1px solid rgba(255,255,255,.1);margin-top:.25rem;display:flex;gap:1rem;">
<a href="{{ url_for('set_lang', lang='en') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇬🇧</a>
<a href="{{ url_for('set_lang', lang='fr') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇫🇷</a>
<a href="{{ url_for('set_lang', lang='de') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇩🇪</a>
</div>
</div>
</nav>
<script>
function toggleDropdown(e) {
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
function toggleLangDropdown(e) {
e.stopPropagation();
document.getElementById('langDropdown').classList.toggle('open');
}
function toggleToolsDropdown(e) {
e.stopPropagation();
document.getElementById('toolsDropdown').classList.toggle('open');
}
function toggleMobileNav(e) {
e.stopPropagation();
document.getElementById('mainNav').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
var l = document.getElementById('langDropdown');
if (l) l.classList.remove('open');
var t = document.getElementById('toolsDropdown');
if (t) t.classList.remove('open');
var n = document.getElementById('mainNav');
if (n) n.classList.remove('open');
});
</script>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="page">
<div class="container">
{% block content %}{% endblock %}
</div>
</div>
{% endblock %}
{# ── Lightbox ── always present, activated by any img[data-gallery] #}
<div id="lb" role="dialog" aria-modal="true" aria-label="Photo viewer"
style="display:none;position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.88);
align-items:center;justify-content:center;">
<button id="lb-close" aria-label="Close"
style="position:absolute;top:.9rem;right:1.2rem;background:none;border:none;
color:#fff;font-size:2rem;line-height:1;cursor:pointer;opacity:.8;">&#x2715;</button>
<button id="lb-prev" aria-label="Previous"
style="position:absolute;left:.75rem;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;color:#fff;font-size:2.5rem;
line-height:1;padding:.2rem .55rem;border-radius:6px;cursor:pointer;">&#8249;</button>
<button id="lb-next" aria-label="Next"
style="position:absolute;right:.75rem;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;color:#fff;font-size:2.5rem;
line-height:1;padding:.2rem .55rem;border-radius:6px;cursor:pointer;">&#8250;</button>
<div style="max-width:92vw;text-align:center;pointer-events:none;">
<img id="lb-img" src="" alt=""
style="max-width:92vw;max-height:88vh;object-fit:contain;border-radius:4px;
display:block;margin:0 auto;pointer-events:none;">
<div id="lb-caption"
style="color:#ddd;margin-top:.6rem;font-size:.9rem;min-height:1.2em;"></div>
<div id="lb-counter"
style="color:#aaa;font-size:.78rem;margin-top:.2rem;"></div>
</div>
</div>
<script>
(function () {
var lb = document.getElementById('lb');
var lbImg = document.getElementById('lb-img');
var lbCap = document.getElementById('lb-caption');
var lbCnt = document.getElementById('lb-counter');
var lbPrev = document.getElementById('lb-prev');
var lbNext = document.getElementById('lb-next');
var gallery = []; // [{src, caption}]
var current = 0;
function open(items, idx) {
gallery = items;
current = idx;
render();
lb.style.display = 'flex';
document.body.style.overflow = 'hidden';
lb.focus();
}
function close() {
lb.style.display = 'none';
document.body.style.overflow = '';
}
function render() {
var item = gallery[current];
lbImg.src = item.src;
lbImg.alt = item.caption || '';
lbCap.textContent = item.caption || '';
if (gallery.length > 1) {
lbPrev.style.display = '';
lbNext.style.display = '';
lbCnt.textContent = (current + 1) + ' / ' + gallery.length;
} else {
lbPrev.style.display = 'none';
lbNext.style.display = 'none';
lbCnt.textContent = '';
}
}
function move(delta) {
current = (current + delta + gallery.length) % gallery.length;
render();
}
// Click outside the image closes
lb.addEventListener('click', function (e) {
if (e.target === lb) close();
});
document.getElementById('lb-close').addEventListener('click', close);
lbPrev.addEventListener('click', function (e) { e.stopPropagation(); move(-1); });
lbNext.addEventListener('click', function (e) { e.stopPropagation(); move(1); });
document.addEventListener('keydown', function (e) {
if (lb.style.display === 'none') return;
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') move(-1);
if (e.key === 'ArrowRight') move(1);
});
// Touch swipe support
var touchStartX = null;
lb.addEventListener('touchstart', function (e) { touchStartX = e.touches[0].clientX; });
lb.addEventListener('touchend', function (e) {
if (touchStartX === null) return;
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 40) move(dx < 0 ? 1 : -1);
touchStartX = null;
});
// Wire up all gallery images after DOM is ready
function initGalleries() {
var groups = {};
document.querySelectorAll('img[data-gallery]').forEach(function (img) {
var g = img.getAttribute('data-gallery');
if (!groups[g]) groups[g] = [];
groups[g].push({ src: img.getAttribute('data-src') || img.src,
caption: img.getAttribute('data-caption') || '',
el: img });
});
Object.keys(groups).forEach(function (g) {
var items = groups[g];
items.forEach(function (item, idx) {
item.el.style.cursor = 'zoom-in';
item.el.addEventListener('click', function (e) {
e.preventDefault();
open(items.map(function (x) { return { src: x.src, caption: x.caption }; }), idx);
});
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGalleries);
} else {
initGalleries();
}
})();
</script>
<script>
/* Auto-inject CSRF token into every POST form on the page */
(function() {
var token = document.querySelector('meta[name="csrf-token"]');
if (!token) return;
var t = token.getAttribute('content');
document.querySelectorAll('form').forEach(function(f) {
var method = (f.getAttribute('method') || 'get').toLowerCase();
if (method !== 'post') return;
if (f.querySelector('input[name="csrf_token"]')) return; // already present
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = t;
f.appendChild(inp);
});
})();
</script>
</body>
</html>

View File

@@ -1,67 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Dashboard') }} — The Shooter's Network{% endblock %}
{% block content %}
<h1>{{ _('Dashboard') }}</h1>
<p style="color:#555;margin-bottom:2rem;">
{{ _('Welcome back, %(name)s.', name=(current_user.display_name or current_user.email)) }}
</p>
<div style="display:flex;gap:1.5rem;margin-bottom:2.5rem;flex-wrap:wrap;">
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
{{ _('+ New session') }}
</a>
<a href="{{ url_for('equipment.new') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
{{ _('+ Add equipment') }}
</a>
<a href="{{ url_for('analyze') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
{{ _('New analysis') }}
</a>
</div>
<h2>{{ _('Recent Analyses') }}</h2>
{% if analyses %}
<table>
<thead>
<tr>
<th>{{ _('Title') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Shots') }}</th>
<th>{{ _('Groups') }}</th>
<th>{{ _('Visibility') }}</th>
</tr>
</thead>
<tbody>
{% for a in analyses %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('analyses.detail', analysis_id=a.id) }}'">
<td><a href="{{ url_for('analyses.detail', analysis_id=a.id) }}" style="color:inherit;text-decoration:none;">{{ a.title }}</a></td>
<td style="white-space:nowrap;color:#666;font-size:0.88rem;">{{ a.created_at.strftime('%d %b %Y') }}</td>
<td>{{ a.shot_count }}</td>
<td>{{ a.group_count }}</td>
<td style="color:{% if a.is_public %}#27ae60{% else %}#888{% endif %};font-size:0.88rem;">
{{ _('Public') if a.is_public else _('Private') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#888;margin-top:1rem;">
{{ _('No analyses yet.') }} <a href="{{ url_for('analyze') }}">{{ _('Upload a CSV file') }}</a> {{ _('to get started — it will be saved here automatically.') }}
</p>
{% endif %}
<div style="display:flex;gap:2rem;margin-top:2.5rem;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">{{ _('Equipment') }}</div>
<a href="{{ url_for('equipment.index') }}">{{ _('Manage your rifles, scopes & gear →') }}</a>
</div>
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">{{ _('Sessions') }}</div>
<a href="{{ url_for('sessions.index') }}">{{ _('View your shooting sessions →') }}</a>
</div>
</div>
{% endblock %}

View File

@@ -1,72 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ item.name }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.2rem;">
<a href="{{ url_for('equipment.index') }}">Equipment</a> &rsaquo;
{{ categories.get(item.category, item.category).title() }}
</div>
<h1 style="margin:0;">{{ item.name }}</h1>
</div>
<div style="display:flex;gap:0.75rem;">
<a href="{{ url_for('equipment.edit', item_id=item.id) }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
Edit
</a>
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}"
onsubmit="return confirm('Delete {{ item.name }}?');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
</button>
</form>
</div>
</div>
{% if item.photo_url %}
<div style="margin-bottom:1.5rem;">
<img src="{{ item.photo_url }}"
data-gallery="equipment-{{ item.id }}"
data-src="{{ item.photo_url }}"
data-caption="{{ item.name }}"
alt="{{ item.name }}"
style="max-width:480px;width:100%;border-radius:8px;display:block;margin-bottom:.6rem;">
<div style="display:flex;gap:.5rem;">
{% for label, deg in [('↺ Left', -90), ('↻ Right', 90), ('180°', 180)] %}
<form method="post" action="{{ url_for('equipment.rotate_photo_view', item_id=item.id) }}">
<input type="hidden" name="degrees" value="{{ deg }}">
<button type="submit"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
{{ label }}
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
<table style="max-width:480px;">
<tbody>
<tr><td style="color:#888;width:140px;">Category</td><td>{{ categories.get(item.category, item.category).title() }}</td></tr>
{% if item.brand %}<tr><td style="color:#888;">Brand</td><td>{{ item.brand }}</td></tr>{% endif %}
{% if item.model %}<tr><td style="color:#888;">Model</td><td>{{ item.model }}</td></tr>{% endif %}
{% if item.category == 'scope' %}
{% if item.magnification %}<tr><td style="color:#888;">Magnification</td><td>{{ item.magnification }}</td></tr>{% endif %}
{% if item.reticle %}<tr><td style="color:#888;">Reticle</td><td>{{ item.reticle }}</td></tr>{% endif %}
{% if item.unit %}<tr><td style="color:#888;">Unit</td><td>{{ item.unit }}</td></tr>{% endif %}
{% else %}
{% if item.caliber %}<tr><td style="color:#888;">Caliber</td><td>{{ item.caliber }}</td></tr>{% endif %}
{% endif %}
{% if item.serial_number %}<tr><td style="color:#888;">Serial</td><td>{{ item.serial_number }}</td></tr>{% endif %}
<tr><td style="color:#888;">Added</td><td>{{ item.created_at.strftime('%d %b %Y') }}</td></tr>
</tbody>
</table>
{% if item.notes %}
<div style="margin-top:1.5rem;">
<h3>Notes</h3>
<p style="color:#555;white-space:pre-wrap;">{{ item.notes }}</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<h1>{{ _('Edit') if editing else _('Add equipment') }}</h1>
{% set f = prefill or item %}
<form method="post"
action="{{ url_for('equipment.edit', item_id=item.id) if editing else url_for('equipment.new') }}"
enctype="multipart/form-data"
style="max-width:520px;">
<div style="margin-bottom:1rem;">
<label class="field-label">{{ _('Category *') }}</label>
<select name="category" required style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
{% for key, label in categories %}
<option value="{{ key }}" {% if (f and f.category == key) or (not f and key == 'rifle') %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">{{ _('Name *') }}</label>
<input type="text" name="name" value="{{ f.name if f else '' }}" required
placeholder="e.g. Tikka T3x, Glock 17"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="field-label">{{ _('Brand') }}</label>
<input type="text" name="brand" value="{{ f.brand if f else '' }}"
placeholder="e.g. Tikka, Leupold"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">{{ _('Model') }}</label>
<input type="text" name="model" value="{{ f.model if f else '' }}"
placeholder="e.g. T3x, VX-3HD"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<div id="rifle-fields" style="margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div>
<label class="field-label">{{ _('Caliber') }}</label>
<input type="text" name="caliber" value="{{ f.caliber if f else '' }}"
placeholder="e.g. .308 Win, 6.5 CM"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
</div>
<div id="scope-fields" style="display:none;margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;">
<div>
<label class="field-label">{{ _('Magnification') }}</label>
<input type="text" name="magnification" value="{{ f.magnification if f else '' }}"
placeholder="e.g. 3-15x50"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">{{ _('Reticle') }}</label>
<select name="reticle" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="FFP" {% if f and f.reticle == 'FFP' %}selected{% endif %}>FFP (First Focal Plane)</option>
<option value="SFP" {% if f and f.reticle == 'SFP' %}selected{% endif %}>SFP (Second Focal Plane)</option>
</select>
</div>
<div>
<label class="field-label">{{ _('Unit') }}</label>
<select name="unit" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="MOA" {% if f and f.unit == 'MOA' %}selected{% endif %}>MOA</option>
<option value="MRAD" {% if f and f.unit == 'MRAD' %}selected{% endif %}>MRAD</option>
</select>
</div>
</div>
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">{{ _('Serial number') }}</label>
<input type="text" name="serial_number" value="{{ f.serial_number if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">{{ _('Notes') }}</label>
<textarea name="notes" rows="3"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label class="field-label">{{ _('Photo') }}</label>
{% if editing and item.photo_url %}
<div style="margin-bottom:0.5rem;">
<img src="{{ item.photo_url }}" alt="Current photo"
style="height:80px;border-radius:4px;object-fit:cover;">
<span style="font-size:0.82rem;color:#888;margin-left:0.5rem;">{{ _('Upload a new one to replace it.') }}</span>
</div>
{% endif %}
<input type="file" name="photo" accept="image/*"
style="font-size:0.92rem;">
</div>
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ _('Save changes') if editing else _('Add equipment') }}
</button>
<a href="{{ url_for('equipment.detail', item_id=item.id) if editing else url_for('equipment.index') }}"
style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
</div>
</form>
<style>.field-label { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
<script>
function toggleCategoryFields() {
var cat = document.querySelector('[name="category"]').value;
var isScope = cat === 'scope';
document.getElementById('scope-fields').style.display = isScope ? '' : 'none';
document.getElementById('rifle-fields').style.display = isScope ? 'none' : '';
}
document.querySelector('[name="category"]').addEventListener('change', toggleCategoryFields);
toggleCategoryFields(); // run on load
</script>
{% endblock %}

View File

@@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Equipment') }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">{{ _('My Equipment') }}</h1>
<a href="{{ url_for('equipment.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
{{ _('+ Add item') }}
</a>
</div>
{% 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 %}
<h2>{{ cat_label }}s</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;margin-bottom:2rem;">
{% for item in group %}
<div style="border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">
{% if item.photo_url %}
<img src="{{ item.photo_url }}" alt="{{ item.name }}"
style="width:100%;height:150px;object-fit:cover;display:block;">
{% else %}
<div style="width:100%;height:80px;background:#f0f4ff;display:flex;align-items:center;justify-content:center;font-size:2rem;color:#c0c8e0;">
{% if item.category == 'rifle' or item.category == 'handgun' %}🔫
{% elif item.category == 'scope' %}🔭
{% else %}🔩{% endif %}
</div>
{% endif %}
<div style="padding:0.9rem 1rem;">
<div style="font-weight:600;color:#1a1a2e;margin-bottom:0.2rem;">{{ item.name }}</div>
{% if item.brand or item.model %}
<div style="font-size:0.85rem;color:#666;margin-bottom:0.3rem;">
{{ [item.brand, item.model] | select | join(' · ') }}
</div>
{% endif %}
{% if item.caliber %}
<div style="font-size:0.82rem;color:#888;margin-bottom:0.6rem;">{{ item.caliber }}</div>
{% endif %}
<div style="display:flex;gap:0.75rem;margin-top:0.5rem;">
<a href="{{ url_for('equipment.detail', item_id=item.id) }}" style="font-size:0.85rem;">{{ _('View') }}</a>
<a href="{{ url_for('equipment.edit', item_id=item.id) }}" style="font-size:0.85rem;">{{ _('Edit') }}</a>
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}" style="display:inline;"
onsubmit="return confirm('{{ _('Delete %(name)s?', name=item.name) | e }}');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">{{ _('Delete') }}</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🔫</div>
<p style="margin-bottom:1rem;">{{ _('No equipment yet.') }}</p>
<a href="{{ url_for('equipment.new') }}">{{ _('Add your first item') }}</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,174 +0,0 @@
{% extends "base.html" %}
{% block title %}The Shooter's Network — Track, analyze, share{% endblock %}
{% block body %}
<nav class="nav">
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
{% endif %}
</div>
<div class="nav-right">
{# Language switcher #}
<div class="nav-dropdown" id="langDropdown">
<button class="nav-user-btn" onclick="toggleLangDropdown(event)" style="padding:.2rem .55rem;gap:.35rem;font-size:1.1rem;">
{% if current_lang == 'fr' %}🇫🇷{% elif current_lang == 'de' %}🇩🇪{% else %}🇬🇧{% endif %}<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu" style="min-width:130px;">
<a href="{{ url_for('set_lang', lang='en') }}">🇬🇧 English</a>
<a href="{{ url_for('set_lang', lang='fr') }}">🇫🇷 Français</a>
<a href="{{ url_for('set_lang', lang='de') }}">🇩🇪 Deutsch</a>
</div>
</div>
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
{% set av = current_user.effective_avatar_url %}
{% if av %}<img src="{{ av }}" class="nav-avatar" alt="">
{% else %}<span style="font-size:1.1rem;line-height:1;">&#128100;</span>{% endif %}
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;{{ _('Profile') }}</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;{{ _('Logout') }}</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">{{ _('Login') }}</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
{{ _('Join free') }}
</a>
{% endif %}
</div>
</nav>
<script>
function toggleDropdown(e) {
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
function toggleLangDropdown(e) {
e.stopPropagation();
document.getElementById('langDropdown').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
var l = document.getElementById('langDropdown');
if (l) l.classList.remove('open');
});
</script>
<!-- ── Hero ── -->
<section style="background:#1a1a2e;color:#fff;padding:4rem 1.5rem;text-align:center;">
<h1 style="font-size:2.4rem;font-weight:800;color:#fff;margin-bottom:0.75rem;letter-spacing:-0.02em;">
The Shooter's Network
</h1>
<p style="font-size:1.15rem;color:#a0aec0;max-width:560px;margin:0 auto 2rem;">
Analyze your ballistic data, track every session, manage your equipment,
and share your performance with the community.
</p>
<div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
{{ _('New Analysis') }}
</a>
<a href="{{ url_for('sessions.new') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
{{ _('Log a Session') }}
</a>
{% else %}
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
{{ _('Get started — free') }}
</a>
<a href="{{ url_for('analyze') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
{{ _('Try without account') }}
</a>
{% endif %}
</div>
</section>
<!-- ── Features ── -->
<section style="background:#f8f9fb;padding:2.5rem 1.5rem;">
<div style="max-width:900px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:1.25rem;">
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">📊</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Ballistic Analysis') }}</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.') }}</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🎯</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Session Tracking') }}</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.') }}</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🤝</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Community Feed') }}</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Share your public sessions and see what other shooters are achieving on the range.') }}</p>
</div>
</div>
</section>
<!-- ── Flash messages ── -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div style="max-width:960px;margin:1rem auto;padding:0 1.5rem;">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- ── Public sessions feed ── -->
<section style="padding:2.5rem 1.5rem 3rem;">
<div style="max-width:960px;margin:0 auto;">
<h2 style="font-size:1.3rem;color:#1a1a2e;margin-bottom:1.25rem;border-bottom:2px solid #e0e0e0;padding-bottom:.4rem;">
{{ _('Latest sessions') }}
</h2>
{% if public_sessions %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1.1rem;">
{% for s in public_sessions %}
<a href="{{ url_for('sessions.detail', session_id=s.id) }}"
style="display:block;background:#fff;border:1px solid #e8e8e8;border-radius:8px;padding:1.1rem 1.25rem;text-decoration:none;color:inherit;">
<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.65rem;">
{% if s.user.avatar_url %}
<img src="{{ s.user.avatar_url }}" style="width:26px;height:26px;border-radius:50%;object-fit:cover;" alt="">
{% else %}
<div style="width:26px;height:26px;border-radius:50%;background:#e0e4f0;display:flex;align-items:center;justify-content:center;font-size:0.72rem;color:#666;font-weight:700;">
{{ (s.user.display_name or s.user.email)[0].upper() }}
</div>
{% endif %}
<span style="font-size:0.83rem;color:#666;">{{ s.user.display_name or s.user.email.split('@')[0] }}</span>
</div>
<div style="font-weight:600;color:#1a1a2e;margin-bottom:0.35rem;font-size:0.95rem;">{{ s.label }}</div>
<div style="font-size:0.81rem;color:#888;display:flex;flex-wrap:wrap;gap:.3rem .65rem;">
<span>{{ s.session_date.strftime('%d %b %Y') }}</span>
{% if s.location_name %}<span>📍 {{ s.location_name }}</span>{% endif %}
{% if s.distance_m %}<span>{{ s.distance_m }} m</span>{% endif %}
{% if s.weather_cond %}<span>{{ s.weather_cond.replace('_', ' ').title() }}</span>{% endif %}
{% if s.weather_temp_c is not none %}<span>{{ s.weather_temp_c }}°C</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p style="color:#aaa;text-align:center;padding:3rem 0;">
{{ _('No public sessions yet. Be the first to share one!') }}
</p>
{% endif %}
</div>
</section>
{% endblock %}

View File

@@ -1,82 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">{{ _('Analysis Results') }}</h1>
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
<a href="/">{{ _('← Upload another file') }}</a>
{% if saved_analysis_id %}
<a href="{{ url_for('analyses.detail', analysis_id=saved_analysis_id) }}"
style="font-size:0.9rem;color:#1f77b4;">{{ _('View saved report →') }}</a>
{% endif %}
<a href="data:application/pdf;base64,{{ pdf_b64 }}"
download="ballistic_report.pdf"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
{{ _('⬙ Download PDF report') }}
</a>
</div>
</div>
<h2>{{ _('Overall Statistics') }}</h2>
<table>
<thead>
<tr>
<th>{{ _('Metric') }}</th>
<th>{{ _('Value') }}</th>
</tr>
</thead>
<tbody>
<tr><td>{{ _('Total shots') }}</td><td>{{ overall.count }}</td></tr>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if overall.std_speed is not none %}
{{ "%.4f"|format(overall.std_speed) }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
<h2>{{ _('Groups') }} &mdash; {{ groups_display|length }} {{ _('group(s) detected') }}</h2>
{% for stat, chart_b64 in groups_display %}
<div class="group-section">
<h3>{{ _('Group %(n)s', n=stat.group_index) }}</h3>
<div class="group-meta">
{{ stat.time_start }} &ndash; {{ stat.time_end }} &nbsp;|&nbsp; {{ stat.count }} shot(s)
</div>
<table>
<thead>
<tr>
<th>{{ _('Metric') }}</th>
<th>{{ _('Value') }}</th>
</tr>
</thead>
<tbody>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if stat.std_speed is not none %}
{{ "%.4f"|format(stat.std_speed) }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
</tbody>
</table>
<img class="chart-img" src="data:image/png;base64,{{ chart_b64 }}" alt="Speed chart for group {{ stat.group_index }}">
</div>
{% endfor %}
{% endblock %}

View File

@@ -1,647 +0,0 @@
{% extends "base.html" %}
{% block title %}Annotate photo — {{ session.label }}{% endblock %}
{% block content %}
<div style="margin-bottom:1rem;">
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo;
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> &rsaquo;
Annotate
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
<a href="{{ url_for('sessions.detail', session_id=session.id) }}"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;
padding:.45rem 1rem;font-size:0.88rem;text-decoration:none;white-space:nowrap;">
✕ Close
</a>
</div>
</div>
<div style="display:flex;gap:1.5rem;align-items:flex-start;">
{# ── Canvas ── #}
<div style="flex:1;min-width:0;">
<canvas id="ann-canvas"
style="width:100%;border-radius:6px;cursor:crosshair;display:block;
box-shadow:0 2px 8px rgba(0,0,0,.18);background:#111;"></canvas>
</div>
{# ── Control panel ── #}
<div style="width:260px;flex-shrink:0;">
{# Step indicator #}
<div style="margin-bottom:1.25rem;">
<div id="si-0" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Reference line
</div>
<div id="si-1" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Point of Aim
</div>
<div id="si-2" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Points of Impact
</div>
<div id="si-3" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Results
</div>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Shooting distance (always visible, pre-filled from session) #}
<div style="margin-bottom:.75rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Shooting distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="shoot-dist" min="1" step="1" placeholder="100"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="shoot-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="m">m</option>
<option value="yd">yd</option>
</select>
</div>
</div>
{# Clean barrel checkbox #}
<div style="margin-bottom:1rem;">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:0.88rem;color:#444;">
<input type="checkbox" id="clean-barrel" style="width:1rem;height:1rem;">
Clean barrel (first shot)
</label>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Step 0: Reference line #}
<div id="panel-0" class="step-panel">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click <strong>two points</strong> on the image to draw a reference line — e.g. a known grid square or target diameter.
</p>
<div id="ref-dist-row" style="display:none;margin-bottom:.75rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Real distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="ref-dist" min="0.1" step="0.1" placeholder="50"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="ref-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="in">in</option>
</select>
</div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-next-0" disabled onclick="goStep(1)">Next →</button>
<button class="btn-ghost" onclick="resetRef()">Reset</button>
</div>
</div>
{# Step 1: POA #}
<div id="panel-1" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click your <strong>Point of Aim</strong> — the center of the target or wherever you were aiming.
</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-ghost" onclick="goStep(0)">← Back</button>
<button class="btn-ghost" onclick="poa=null;redraw();">Reset POA</button>
</div>
</div>
{# Step 2: POIs #}
<div id="panel-2" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click each <strong>bullet hole</strong>. Click an existing point to remove it.
</p>
<p id="poi-count" style="font-size:0.88rem;font-weight:600;color:#1a1a2e;margin-bottom:.75rem;">0 impacts</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-compute" disabled onclick="compute()">Compute →</button>
<button class="btn-ghost" onclick="goStep(1)">← Back</button>
<button class="btn-ghost" onclick="undoPoi()">Undo last</button>
</div>
</div>
{# Step 3: Results #}
<div id="panel-3" class="step-panel" style="display:none;">
<div id="results-box" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save &amp; close</button>
<button class="btn-ghost" onclick="goStep(2)">← Edit</button>
</div>
<div id="save-status" style="font-size:0.82rem;margin-top:.5rem;"></div>
</div>
</div>{# end control panel #}
</div>
<style>
.btn-primary {
background: #1a1a2e; color: #fff; border: none; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
.btn-ghost {
background: #f0f4ff; color: #1a1a2e; border: 1px solid #c8d4f0; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.stat-row { display: flex; justify-content: space-between; font-size: 0.85rem;
padding: .3rem 0; border-bottom: 1px solid #f0f0f0; }
.stat-label { color: #666; }
.stat-val { font-weight: 600; color: #1a1a2e; }
.stat-section { font-size: 0.78rem; text-transform: uppercase; letter-spacing: .05em;
color: #888; margin: .75rem 0 .35rem; }
</style>
<script>
const PHOTO_URL = {{ photo.photo_url | tojson }};
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
const SESSION_URL = {{ url_for('sessions.detail', session_id=session.id) | tojson }};
const SESSION_DIST_M = {{ (session.distance_m or 'null') }};
const EXISTING = {{ (photo.annotations or {}) | tojson }};
// ── State ──────────────────────────────────────────────────────────────────
let step = 0;
// Reference: coords in natural image pixels (fractions stored on save)
let refP1 = null, refP2 = null, refMm = null;
let refClickStage = 0; // 0=waiting p1, 1=waiting p2, 2=done
let poa = null; // natural px
let pois = []; // natural px array
let stats = null;
let mousePos = null; // canvas px, for rubber-band
// ── Canvas / image ─────────────────────────────────────────────────────────
const canvas = document.getElementById('ann-canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = PHOTO_URL;
img.onload = () => { resizeCanvas(); loadExisting(); redraw(); };
function resizeCanvas() {
// Canvas internal size = natural image size (so all coords stay in nat px)
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
}
// Convert canvas mouse event → natural image pixels
function evToNat(e) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
}
// ── Mouse events ───────────────────────────────────────────────────────────
canvas.addEventListener('mousemove', e => {
if (step === 0 && refClickStage === 1) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
mousePos = { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
redraw();
}
});
canvas.addEventListener('click', e => {
const p = evToNat(e);
if (step === 0) handleRefClick(p);
else if (step === 1) handlePoaClick(p);
else if (step === 2) handlePoiClick(p);
});
canvas.addEventListener('mouseleave', () => { mousePos = null; redraw(); });
// ── Step 0: Reference line ─────────────────────────────────────────────────
function handleRefClick(p) {
if (refClickStage === 0) {
refP1 = p; refP2 = null; refClickStage = 1;
canvas.style.cursor = 'crosshair';
redraw();
} else if (refClickStage === 1) {
refP2 = p; refClickStage = 2; mousePos = null;
document.getElementById('ref-dist-row').style.display = '';
redraw();
updateNextBtn0();
}
}
function resetRef() {
refP1 = refP2 = null; refClickStage = 0; refMm = null; mousePos = null;
document.getElementById('ref-dist-row').style.display = 'none';
document.getElementById('ref-dist').value = '';
updateNextBtn0(); redraw();
}
document.getElementById('ref-dist').addEventListener('input', updateNextBtn0);
function updateNextBtn0() {
const v = parseFloat(document.getElementById('ref-dist').value);
document.getElementById('btn-next-0').disabled = !(refP1 && refP2 && v > 0);
}
// ── Step 1: POA ────────────────────────────────────────────────────────────
function handlePoaClick(p) {
poa = p; redraw();
// Auto-advance to step 2
goStep(2);
}
// ── Step 2: POIs ───────────────────────────────────────────────────────────
const HIT_RADIUS = 14; // canvas display px
function handlePoiClick(p) {
// Check if clicking near an existing POI to remove it
const r = canvas.getBoundingClientRect();
const dispScale = r.width / img.naturalWidth; // nat px → display px
for (let i = pois.length - 1; i >= 0; i--) {
const dx = (pois[i].x - p.x) * dispScale;
const dy = (pois[i].y - p.y) * dispScale;
if (Math.sqrt(dx*dx + dy*dy) < HIT_RADIUS) {
pois.splice(i, 1);
updatePoiUI(); redraw(); return;
}
}
pois.push(p);
updatePoiUI(); redraw();
}
function undoPoi() { if (pois.length) { pois.pop(); updatePoiUI(); redraw(); } }
function updatePoiUI() {
document.getElementById('poi-count').textContent = pois.length + ' impact' + (pois.length !== 1 ? 's' : '');
document.getElementById('btn-compute').disabled = pois.length < 1;
}
// ── Step navigation ────────────────────────────────────────────────────────
function goStep(n) {
// Validate before advancing
if (n === 1) {
const distVal = parseFloat(document.getElementById('ref-dist').value);
const unitSel = document.getElementById('ref-unit').value;
if (!(refP1 && refP2 && distVal > 0)) { alert('Please draw the reference line and enter its distance.'); return; }
refMm = toMm(distVal, unitSel);
}
step = n;
updateStepUI(); redraw();
}
function updateStepUI() {
// Panels
for (let i = 0; i <= 3; i++) {
document.getElementById('panel-' + i).style.display = (i === step) ? '' : 'none';
}
// Step indicators
const labels = ['Reference line', 'Point of Aim', 'Points of Impact', 'Results'];
for (let i = 0; i <= 3; i++) {
const el = document.getElementById('si-' + i);
const num = el.querySelector('.si-num');
if (i < step) {
el.style.background = '#e8f5e9'; el.style.color = '#27ae60';
num.style.background = '#27ae60'; num.style.color = '#fff';
num.textContent = '✓';
} else if (i === step) {
el.style.background = '#f0f4ff'; el.style.color = '#1a1a2e';
num.style.background = '#1a1a2e'; num.style.color = '#fff';
num.textContent = i + 1;
} else {
el.style.background = ''; el.style.color = '#aaa';
num.style.background = '#e0e0e0'; num.style.color = '#888';
num.textContent = i + 1;
}
}
// Cursor
canvas.style.cursor = (step <= 2) ? 'crosshair' : 'default';
}
// ── Computation ────────────────────────────────────────────────────────────
function dist2(a, b) { return Math.sqrt((a.x-b.x)**2 + (a.y-b.y)**2); }
function toMm(val, unit) {
if (unit === 'cm') return val * 10;
if (unit === 'in') return val * 25.4;
return val;
}
function toMoa(sizeMm, distM) {
// true angular MOA
return Math.atan(sizeMm / (distM * 1000)) * (180 / Math.PI * 60);
}
function compute() {
const shootDistEl = document.getElementById('shoot-dist');
const shootUnitEl = document.getElementById('shoot-unit');
let distM = parseFloat(shootDistEl.value);
if (isNaN(distM) || distM <= 0) { alert('Enter a valid shooting distance first.'); shootDistEl.focus(); return; }
if (shootUnitEl.value === 'yd') distM *= 0.9144; // yards → metres
// Scale factor: pixels per mm
const refPxDist = dist2(refP1, refP2);
const pxPerMm = refPxDist / refMm;
// Convert POIs to mm relative to POA
const poisMm = pois.map(p => ({
x: (p.x - poa.x) / pxPerMm,
y: (p.y - poa.y) / pxPerMm,
}));
// Group centre
const cx = poisMm.reduce((s, p) => s + p.x, 0) / poisMm.length;
const cy = poisMm.reduce((s, p) => s + p.y, 0) / poisMm.length;
// Extreme Spread: max pairwise distance
let es = 0, esI = 0, esJ = 0;
for (let i = 0; i < poisMm.length; i++) {
for (let j = i + 1; j < poisMm.length; j++) {
const d = dist2(poisMm[i], poisMm[j]);
if (d > es) { es = d; esI = i; esJ = j; }
}
}
// Mean Radius: average distance from group centre
const mr = poisMm.reduce((s, p) => s + dist2(p, {x:cx,y:cy}), 0) / poisMm.length;
// POA → centre
const poaToCenter = dist2({x:0,y:0}, {x:cx,y:cy});
stats = {
shot_count: pois.length,
group_size_mm: es,
group_size_moa: distM > 0 ? toMoa(es, distM) : null,
mean_radius_mm: mr,
mean_radius_moa: distM > 0 ? toMoa(mr, distM) : null,
center_x_mm: cx, // + = right, - = left
center_y_mm: cy, // + = down, - = up
center_dist_mm: poaToCenter,
center_dist_moa: distM > 0 ? toMoa(poaToCenter, distM) : null,
shooting_distance_m: distM,
es_poi_indices: [esI, esJ],
};
renderResults();
goStep(3);
redraw();
}
function renderResults() {
if (!stats) return;
const f1 = v => (v != null ? v.toFixed(1) : '—');
const f2 = v => (v != null ? v.toFixed(2) : '—');
const sign = v => v >= 0 ? '+' : '';
const dir = (mm, axis) => {
if (axis === 'x') return mm > 0 ? 'right' : mm < 0 ? 'left' : 'center';
return mm > 0 ? 'low' : mm < 0 ? 'high' : 'center';
};
document.getElementById('results-box').innerHTML = `
<div class="stat-section">Group size</div>
<div class="stat-row"><span class="stat-label">Extreme Spread</span>
<span class="stat-val">${f2(stats.group_size_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.group_size_mm)} mm</span></div>
<div class="stat-section">Precision</div>
<div class="stat-row"><span class="stat-label">Mean Radius</span>
<span class="stat-val">${f2(stats.mean_radius_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.mean_radius_mm)} mm</span></div>
<div class="stat-section">Center vs POA</div>
<div class="stat-row"><span class="stat-label">Distance</span>
<span class="stat-val">${f2(stats.center_dist_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label">Horiz.</span>
<span class="stat-val">${f1(Math.abs(stats.center_x_mm))} mm ${dir(stats.center_x_mm,'x')}</span></div>
<div class="stat-row"><span class="stat-label">Vert.</span>
<span class="stat-val">${f1(Math.abs(stats.center_y_mm))} mm ${dir(stats.center_y_mm,'y')}</span></div>
<div class="stat-section">Info</div>
<div class="stat-row"><span class="stat-label">Shots</span>
<span class="stat-val">${stats.shot_count}</span></div>
<div class="stat-row"><span class="stat-label">@ distance</span>
<span class="stat-val">${stats.shooting_distance_m.toFixed(0)} m</span></div>
<div class="stat-row"><span class="stat-label">Clean barrel</span>
<span class="stat-val">${document.getElementById('clean-barrel').checked ? 'Yes' : 'No'}</span></div>
`;
}
// ── Drawing ────────────────────────────────────────────────────────────────
const COLORS = {
ref: '#2196f3',
poa: '#e53935',
poi: '#1565c0',
center: '#ff9800',
es: '#9c27b0',
mr: '#00897b',
};
function lineW(px) {
// px in display pixels → natural pixels
const r = canvas.getBoundingClientRect();
return px * (img.naturalWidth / r.width);
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const lw = lineW(2);
const dotR = lineW(7);
// Reference line
if (refP1) {
const p2 = refClickStage === 1 && mousePos ? mousePos : refP2;
if (p2) {
ctx.save();
ctx.setLineDash([lineW(8), lineW(5)]);
ctx.strokeStyle = COLORS.ref; ctx.lineWidth = lw;
ctx.beginPath(); ctx.moveTo(refP1.x, refP1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
ctx.setLineDash([]);
// Endpoints
drawDot(refP1, dotR * 0.7, COLORS.ref);
if (refP2) {
drawDot(refP2, dotR * 0.7, COLORS.ref);
// Label
const mid = { x: (refP1.x + refP2.x) / 2, y: (refP1.y + refP2.y) / 2 };
drawLabel(mid, refMm ? refMm.toFixed(0) + ' mm' : '?', COLORS.ref, lineW(12));
}
ctx.restore();
} else {
drawDot(refP1, dotR * 0.7, COLORS.ref);
}
}
// POA
if (poa) {
const r = dotR * 1.3;
ctx.save();
ctx.strokeStyle = COLORS.poa; ctx.lineWidth = lw * 1.5;
// Circle
ctx.beginPath(); ctx.arc(poa.x, poa.y, r, 0, Math.PI * 2); ctx.stroke();
// Crosshair
ctx.beginPath();
ctx.moveTo(poa.x - r * 1.6, poa.y); ctx.lineTo(poa.x - r * 0.4, poa.y);
ctx.moveTo(poa.x + r * 0.4, poa.y); ctx.lineTo(poa.x + r * 1.6, poa.y);
ctx.moveTo(poa.x, poa.y - r * 1.6); ctx.lineTo(poa.x, poa.y - r * 0.4);
ctx.moveTo(poa.x, poa.y + r * 0.4); ctx.lineTo(poa.x, poa.y + r * 1.6);
ctx.stroke();
ctx.restore();
}
// Group overlay (if computed)
if (stats && poa) {
const pxPerMm = dist2(refP1, refP2) / refMm;
const cx = poa.x + stats.center_x_mm * pxPerMm;
const cy = poa.y + stats.center_y_mm * pxPerMm;
// Mean radius circle
const mrPx = stats.mean_radius_mm * pxPerMm;
ctx.save();
ctx.setLineDash([lineW(6), lineW(4)]);
ctx.strokeStyle = COLORS.mr; ctx.lineWidth = lw;
ctx.beginPath(); ctx.arc(cx, cy, mrPx, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// ES line between furthest pair
if (stats.shot_count >= 2) {
const [ei, ej] = stats.es_poi_indices;
ctx.save();
ctx.strokeStyle = COLORS.es; ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(pois[ei].x, pois[ei].y);
ctx.lineTo(pois[ej].x, pois[ej].y);
ctx.stroke();
ctx.restore();
}
// Group centre
drawDot({x:cx,y:cy}, dotR * 0.8, COLORS.center);
// Line POA → centre
if (dist2(poa, {x:cx,y:cy}) > dotR) {
ctx.save();
ctx.strokeStyle = COLORS.center; ctx.lineWidth = lw * 0.7;
ctx.setLineDash([lineW(4), lineW(3)]);
ctx.beginPath(); ctx.moveTo(poa.x, poa.y); ctx.lineTo(cx, cy); ctx.stroke();
ctx.restore();
}
}
// POIs
pois.forEach((p, i) => {
drawDot(p, dotR, COLORS.poi);
drawLabel(p, String(i + 1), '#fff', dotR * 0.85);
});
}
function drawDot(p, r, color) {
ctx.save();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawLabel(p, text, color, size) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `bold ${size}px system-ui,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, p.x, p.y);
ctx.restore();
}
// ── Save ───────────────────────────────────────────────────────────────────
async function saveAnnotations() {
const btn = document.getElementById('btn-save');
const status = document.getElementById('save-status');
btn.disabled = true;
status.textContent = 'Saving…';
const refDistVal = parseFloat(document.getElementById('ref-dist').value);
const refUnitVal = document.getElementById('ref-unit').value;
// Store coords as fractions of natural image size for portability
function toFrac(p) { return { x: p.x / img.naturalWidth, y: p.y / img.naturalHeight }; }
const payload = {
ref: { p1: toFrac(refP1), p2: toFrac(refP2), dist_value: refDistVal, dist_unit: refUnitVal, dist_mm: refMm },
poa: toFrac(poa),
pois: pois.map(toFrac),
shooting_distance_m: stats.shooting_distance_m,
clean_barrel: document.getElementById('clean-barrel').checked,
stats: stats,
};
try {
const resp = await fetch(SAVE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.href = SESSION_URL;
} else {
throw new Error('Server error');
}
} catch {
status.style.color = '#e53935';
status.textContent = 'Save failed.';
btn.disabled = false;
}
}
// ── Load existing annotations ──────────────────────────────────────────────
function loadExisting() {
// Always pre-fill shooting distance from session if available
if (SESSION_DIST_M) {
document.getElementById('shoot-dist').value = SESSION_DIST_M;
document.getElementById('shoot-unit').value = 'm';
}
if (!EXISTING || !EXISTING.ref) return;
const W = img.naturalWidth, H = img.naturalHeight;
function fromFrac(f) { return { x: f.x * W, y: f.y * H }; }
refP1 = fromFrac(EXISTING.ref.p1);
refP2 = fromFrac(EXISTING.ref.p2);
refMm = EXISTING.ref.dist_mm;
refClickStage = 2;
document.getElementById('ref-dist').value = EXISTING.ref.dist_value || '';
document.getElementById('ref-unit').value = EXISTING.ref.dist_unit || 'mm';
document.getElementById('ref-dist-row').style.display = '';
updateNextBtn0();
if (EXISTING.poa) poa = fromFrac(EXISTING.poa);
if (EXISTING.pois) pois = EXISTING.pois.map(fromFrac);
if (EXISTING.shooting_distance_m) {
document.getElementById('shoot-dist').value = EXISTING.shooting_distance_m.toFixed(0);
document.getElementById('shoot-unit').value = 'm';
}
if (EXISTING.clean_barrel) {
document.getElementById('clean-barrel').checked = true;
}
if (EXISTING.stats) {
stats = EXISTING.stats;
renderResults();
goStep(3);
} else if (pois.length > 0) {
goStep(2); updatePoiUI();
} else if (poa) {
goStep(2);
} else {
goStep(0);
}
redraw();
}
// ── Init ───────────────────────────────────────────────────────────────────
updateStepUI();
updatePoiUI();
</script>
{% endblock %}

View File

@@ -1,757 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ session.label }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.2rem;">
{% if is_owner %}<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo; {% endif %}
{{ session.session_date.strftime('%d %b %Y') }}
{% if session.is_public %}
<span style="background:#e8f5e9;color:#27ae60;font-size:0.75rem;padding:.1rem .45rem;border-radius:3px;margin-left:.4rem;">{{ _('Public') }}</span>
{% endif %}
</div>
<h1 style="margin:0;">{{ session.label }}</h1>
<div style="font-size:0.88rem;color:#666;margin-top:.4rem;">
by {{ session.user.display_name or session.user.email.split('@')[0] }}
</div>
</div>
{% if is_owner %}
<div style="display:flex;gap:.75rem;">
<a href="{{ url_for('sessions.edit', session_id=session.id) }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
{{ _('Edit') }}
</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=session.id) }}"
onsubmit="return confirm('{{ _('Delete this session? This cannot be undone.') | e }}');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
</div>
{% endif %}
</div>
{# ---- Stats cards ---- #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;margin-bottom:2rem;">
{% if session.location_name or session.distance_m %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Location') }}</div>
{% if session.location_name %}<div style="font-weight:600;">{{ session.location_name }}</div>{% endif %}
{% if session.distance_m %}<div style="color:#555;font-size:0.9rem;">{{ session.distance_m }} m</div>{% endif %}
</div>
{% endif %}
{% if session.weather_cond or session.weather_temp_c is not none or session.weather_wind_kph is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Weather') }}</div>
{% if session.weather_cond %}<div style="font-weight:600;">{{ session.weather_cond.replace('_',' ').title() }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.weather_temp_c is not none %}{{ session.weather_temp_c }}°C{% endif %}
{% if session.weather_wind_kph is not none %}&nbsp; {{ session.weather_wind_kph }} {{ _('km/h wind') }}{% endif %}
</div>
</div>
{% endif %}
{% if session.rifle %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Rifle / Handgun') }}</div>
<div style="font-weight:600;">{{ session.rifle.name }}</div>
{% if session.rifle.caliber %}<div style="color:#555;font-size:0.9rem;">{{ session.rifle.caliber }}</div>{% endif %}
</div>
{% endif %}
{% if session.scope %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Scope') }}</div>
<div style="font-weight:600;">{{ session.scope.name }}</div>
</div>
{% endif %}
{% if session.ammo_brand or session.ammo_weight_gr is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Ammunition') }}</div>
{% if session.ammo_brand %}<div style="font-weight:600;">{{ session.ammo_brand }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.ammo_weight_gr is not none %}{{ session.ammo_weight_gr }} gr{% endif %}
{% if session.ammo_lot %}&nbsp; lot {{ session.ammo_lot }}{% endif %}
</div>
</div>
{% endif %}
{% if session.shooting_position %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Position') }}</div>
<div style="font-weight:600;">{{ session.shooting_position.replace('_',' ').title() }}</div>
</div>
{% endif %}
</div>
{% if session.notes %}
<h2>{{ _('Notes') }}</h2>
<p style="color:#555;white-space:pre-wrap;">{{ session.notes }}</p>
{% endif %}
{# ---- PRS Stages ---- #}
{% if session.session_type == 'prs' %}
<h2>{{ _('PRS Stages') }}</h2>
{% set stages = session.prs_stages or [] %}
{# Stage table (read + edit combined) #}
<div style="overflow-x:auto;margin-bottom:1rem;">
<table id="prs-table" style="min-width:900px;font-size:0.88rem;">
<thead>
<tr>
<th style="width:36px;"></th>
<th>{{ _('Name') }}</th>
<th style="width:80px;">Dist. (m)</th>
<th style="width:75px;">{{ _('Time (s)') }}</th>
<th style="width:110px;">Position</th>
<th style="width:110px;">{{ _('Elevation Dope') }}</th>
<th style="width:110px;">{{ _('Windage Dope') }}</th>
<th style="width:80px;">{{ _('Hits/Poss.') }}</th>
<th>{{ _('Notes') }}</th>
{% if is_owner %}<th style="width:36px;"></th>{% endif %}
</tr>
</thead>
<tbody id="prs-tbody">
{% for st in stages %}
<tr data-idx="{{ loop.index0 }}">
<td style="text-align:center;font-weight:600;">{{ st.num or loop.index }}</td>
<td>{{ st.name or '' }}</td>
<td style="text-align:center;">{{ st.distance_m or '' }}</td>
<td style="text-align:center;">{{ st.time_s or '' }}</td>
<td>{{ st.position or '' }}</td>
<td style="text-align:center;">{{ st.dope_elevation or '' }}</td>
<td style="text-align:center;">{{ st.dope_windage or '' }}</td>
<td style="text-align:center;">
{% if st.hits is not none %}{{ st.hits }}{% endif %}{% if st.possible %}/{{ st.possible }}{% endif %}
</td>
<td>{{ st.notes or '' }}</td>
{% if is_owner %}<td></td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_owner %}
<div style="display:flex;gap:.75rem;flex-wrap:wrap;margin-bottom:1.5rem;">
<button onclick="enterEditMode()" id="btn-edit-stages"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
{{ _('✏ Edit stages') }}
</button>
<a href="{{ url_for('sessions.dope_card', session_id=session.id) }}" target="_blank"
style="background:#27ae60;color:#fff;padding:.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
{{ _('📄 Generate dope card (PDF)') }}
</a>
</div>
{# Hidden form used by JS to save stages #}
<form method="post" action="{{ url_for('sessions.save_stages', session_id=session.id) }}" id="stages-form" style="display:none;">
<input type="hidden" name="stages_json" id="stages-json-input">
</form>
<script>
(function () {
var PRS_POS = {{ prs_positions | tojson }};
var stages = {{ (session.prs_stages or []) | tojson }};
function posLabel(slug) {
var m = PRS_POS.find(function(p) { return p[0] === slug; });
return m ? m[1] : slug;
}
function posSelect(val) {
var s = document.createElement('select');
s.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem;border:1px solid #ccc;border-radius:3px;';
PRS_POS.forEach(function(p) {
var o = document.createElement('option');
o.value = p[0]; o.textContent = p[1];
if (p[0] === val) o.selected = true;
s.appendChild(o);
});
return s;
}
function inp(val, type, placeholder) {
var i = document.createElement('input');
i.type = type || 'text';
i.value = val == null ? '' : val;
if (placeholder) i.placeholder = placeholder;
i.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem .4rem;border:1px solid #ccc;border-radius:3px;box-sizing:border-box;';
return i;
}
function collectStages() {
var rows = document.querySelectorAll('#prs-tbody tr');
var result = [];
rows.forEach(function(tr, i) {
var cells = tr.querySelectorAll('td');
if (!cells.length) return;
function val(idx) {
var el = cells[idx];
if (!el) return '';
var input = el.querySelector('input,select,textarea');
return input ? (input.value || '') : (el.textContent.trim() || '');
}
result.push({
num: parseInt(val(0)) || i + 1,
name: val(1),
distance_m: parseInt(val(2)) || null,
time_s: parseInt(val(3)) || null,
position: val(4),
dope_elevation: val(5),
dope_windage: val(6),
hits: val(7).split('/')[0] !== '' ? parseInt(val(7).split('/')[0]) : null,
possible: val(7).split('/')[1] !== undefined ? (parseInt(val(7).split('/')[1]) || null) : null,
notes: val(8),
});
});
return result;
}
function addRow(st) {
var tbody = document.getElementById('prs-tbody');
var idx = tbody.rows.length;
st = st || { num: idx + 1 };
var tr = document.createElement('tr');
tr.dataset.idx = idx;
var hitsVal = '';
if (st.hits != null) hitsVal = st.hits;
if (st.possible != null) hitsVal = (hitsVal !== '' ? hitsVal : '') + '/' + st.possible;
[
inp(st.num, 'number'),
inp(st.name),
inp(st.distance_m, 'number'),
inp(st.time_s, 'number'),
posSelect(st.position || ''),
inp(st.dope_elevation, 'text', 'ex : 3.5 MOA'),
inp(st.dope_windage, 'text', 'ex : 0.5 MOA D'),
inp(hitsVal, 'text', 'x/y'),
inp(st.notes),
].forEach(function(el) {
var td = document.createElement('td');
td.appendChild(el);
tr.appendChild(td);
});
// Remove button
var tdDel = document.createElement('td');
tdDel.style.textAlign = 'center';
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '✕';
btn.style.cssText = 'background:none;border:none;color:#c0392b;cursor:pointer;font-size:1rem;padding:0 .3rem;';
btn.onclick = function() { tr.remove(); renumber(); };
tdDel.appendChild(btn);
tr.appendChild(tdDel);
tbody.appendChild(tr);
}
function renumber() {
document.querySelectorAll('#prs-tbody tr').forEach(function(tr, i) {
var first = tr.querySelector('td:first-child input');
if (first && first.type === 'number') first.value = i + 1;
});
}
window.enterEditMode = function () {
// Replace read-only rows with editable rows
var tbody = document.getElementById('prs-tbody');
tbody.innerHTML = '';
stages.forEach(function(st) { addRow(st); });
if (!stages.length) addRow(null);
document.getElementById('btn-edit-stages').style.display = 'none';
var STR_ADD_STAGE = '{{ _("+ Add stage") | e }}';
var STR_SAVE = '{{ _("💾 Save") | e }}';
var STR_CANCEL = '{{ _("Cancel") | e }}';
// Add / Save / Cancel buttons
var bar = document.createElement('div');
bar.id = 'edit-bar';
bar.style.cssText = 'display:flex;gap:.6rem;flex-wrap:wrap;margin:.75rem 0;';
bar.innerHTML =
'<button type="button" onclick="addRow(null);renumber();" ' +
'style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
STR_ADD_STAGE + '</button>' +
'<button type="button" onclick="saveStages()" ' +
'style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
STR_SAVE + '</button>' +
'<button type="button" onclick="cancelEdit()" ' +
'style="background:none;color:#666;border:none;font-size:0.88rem;cursor:pointer;padding:.4rem .5rem;">' + STR_CANCEL + '</button>';
document.getElementById('prs-table').after(bar);
};
window.saveStages = function () {
var data = collectStages();
document.getElementById('stages-json-input').value = JSON.stringify(data);
document.getElementById('stages-form').submit();
};
window.cancelEdit = function () {
location.reload();
};
window.addRow = addRow;
window.renumber = renumber;
})();
</script>
{% endif %}
{% endif %}{# end prs #}
{# ---- Photos ---- #}
{% if session.photos or is_owner %}
<h2>{{ _('Photos') }}</h2>
{% if session.photos %}
<div style="display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
{% for photo in session.photos %}
<div>
<div style="position:relative;display:inline-block;">
<img src="{{ photo.photo_url }}"
data-gallery="session-{{ session.id }}"
data-src="{{ photo.photo_url }}"
data-caption="{{ photo.caption or '' }}"
alt="{{ photo.caption or '' }}"
style="height:180px;width:auto;border-radius:6px;object-fit:cover;display:block;">
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.delete_photo', session_id=session.id, photo_id=photo.id) }}"
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');"
style="position:absolute;top:4px;right:4px;">
<button type="submit"
style="background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:3px;padding:.2rem .45rem;font-size:0.8rem;cursor:pointer;line-height:1.2;">
&#x2715;
</button>
</form>
{% endif %}
</div>
{% if photo.annotations and photo.annotations.stats %}
{% set s = photo.annotations.stats %}
{% set a = photo.annotations %}
<div style="margin-top:.5rem;background:#f8f9fb;border:1px solid #e0e0e0;border-radius:6px;padding:.6rem .75rem;font-size:0.8rem;min-width:180px;">
<div style="font-weight:700;color:#1a1a2e;margin-bottom:.35rem;font-size:0.82rem;">
{{ s.shot_count }} shot{{ 's' if s.shot_count != 1 }}
&middot; {{ s.shooting_distance_m | int }} m
{% if a.clean_barrel %}<span style="background:#e8f5e9;color:#27ae60;border-radius:3px;padding:.05rem .3rem;margin-left:.3rem;">{{ _('clean barrel') }}</span>{% endif %}
</div>
<table style="width:100%;border-collapse:collapse;margin:0;">
<tr>
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Group ES') }}</td>
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.group_size_moa) }} MOA</td>
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.group_size_mm) }} mm</td>
</tr>
<tr>
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Mean Radius') }}</td>
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.mean_radius_moa) }} MOA</td>
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.mean_radius_mm) }} mm</td>
</tr>
<tr>
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Centre') }}</td>
<td colspan="2" style="text-align:right;padding:.15rem 0;border:none;">
{{ '%.2f'|format(s.center_dist_moa) }} MOA
<span style="color:#888;">({{ '%.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 '' }})</span>
</td>
</tr>
</table>
</div>
{% endif %}
{% if photo.caption %}
<div style="font-size:0.78rem;color:#666;margin-top:.25rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ photo.caption }}
</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;gap:.35rem;margin-top:.35rem;">
{% for label, deg in [('↺', -90), ('↻', 90), ('180°', 180)] %}
<form method="post" action="{{ url_for('sessions.rotate_photo_view', session_id=session.id, photo_id=photo.id) }}">
<input type="hidden" name="degrees" value="{{ deg }}">
<button type="submit"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.2rem .55rem;font-size:0.8rem;cursor:pointer;">
{{ label }}
</button>
</form>
{% endfor %}
</div>
<a href="{{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) }}"
style="display:inline-block;margin-top:.4rem;padding:.3rem .75rem;border-radius:4px;font-size:0.82rem;text-decoration:none;
{% if photo.annotations and photo.annotations.stats %}
background:#e8f5e9;color:#27ae60;border:1px solid #a5d6a7;
{% else %}
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
{% endif %}">
{% if photo.annotations and photo.annotations.stats %}&#10003;{% else %}&#9654;{% endif %}
{{ _('Measure group') }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.upload_photo', session_id=session.id) }}"
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Add photo') }}</label>
<input type="file" name="photo" accept="image/*" required style="font-size:0.9rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Caption (optional)') }}</label>
<input type="text" name="caption" placeholder="e.g. 300 m target"
style="padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
{{ _('Upload') }}
</button>
</form>
{% endif %}
{% endif %}
{# ---- Analyses ---- #}
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
{% if analyses_display %}
{% for a, groups_display, overview_chart, split_positions in analyses_display %}
<details open style="border:1px solid #e0e0e0;border-radius:8px;margin-bottom:1.25rem;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:.75rem;padding:.85rem 1.25rem;
background:#f8f9fb;cursor:pointer;list-style:none;flex-wrap:wrap;">
<span style="font-weight:700;font-size:1rem;color:#1a1a2e;flex:1;">{{ a.title }}</span>
<span style="font-size:0.82rem;color:#666;">{{ a.shot_count }} {{ _('shots') }} &middot; {{ a.group_count }} {{ _('group') if a.group_count == 1 else _('groups') }}</span>
<span style="font-size:0.82rem;color:#888;">{{ "%.2f"|format(a.overall_stats.mean_speed) }} {{ _('m/s mean') }}</span>
<span style="font-size:0.78rem;color:#aaa;">{{ a.created_at.strftime('%d %b %Y') }}</span>
</summary>
<div style="padding:1.25rem 1.5rem;">
{# --- Owner action bar: rename / standalone link / PDF / delete --- #}
{% if is_owner %}
<div style="display:flex;flex-wrap:wrap;gap:.6rem;align-items:center;margin-bottom:1.25rem;">
{# Rename inline form #}
<details style="display:inline;">
<summary style="display:inline-block;padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;
border:1px solid #c8d4f0;border-radius:4px;font-size:0.82rem;cursor:pointer;list-style:none;">
{{ _('✏ Rename') }}
</summary>
<form method="post" action="{{ url_for('analyses.rename', analysis_id=a.id) }}"
style="display:flex;gap:.5rem;align-items:center;margin-top:.5rem;">
<input type="text" name="title" value="{{ a.title }}" required
style="padding:.4rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;min-width:220px;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.85rem;cursor:pointer;">
{{ _('Save') }}
</button>
</form>
</details>
<a href="{{ url_for('analyses.detail', analysis_id=a.id) }}"
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
border-radius:4px;font-size:0.82rem;text-decoration:none;">
{{ _('Full view') }}
</a>
{% if a.pdf_path %}
<a href="{{ url_for('analyses.download_pdf', analysis_id=a.id) }}"
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
border-radius:4px;font-size:0.82rem;text-decoration:none;">
&#8615; PDF
</a>
{% endif %}
<form method="post" action="{{ url_for('analyses.delete', analysis_id=a.id) }}"
onsubmit="return confirm('{{ _('Delete this analysis? This cannot be undone.') | e }}');"
style="display:inline;">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;
padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
</div>
{% endif %}
{# --- Overview chart --- #}
{% if overview_chart %}
<img src="data:image/png;base64,{{ overview_chart }}" class="chart-img" alt="Overview chart"
style="margin-bottom:1.25rem;">
{% 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 %}
<div class="group-section" style="margin-bottom:1.25rem;border:1px solid #eee;border-radius:6px;padding:.9rem 1rem;">
<div class="group-meta" style="margin-bottom:.6rem;">
<strong>{{ _('Group %(n)s', n=loop.index) }}</strong>
&nbsp;&middot;&nbsp; {{ gs.count }} {{ _('shots') }}
&nbsp;&middot;&nbsp; {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
{% if gs.std_speed is not none %}&nbsp;&middot;&nbsp; {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
&nbsp;&middot;&nbsp; {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }}
</div>
{# 2-column layout: chart 65% / photos 35% #}
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap;">
{# Left: chart #}
<div style="flex:1 1 60%;min-width:0;">
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart"
style="width:100%;max-width:none;margin-top:0;">
</div>
{# Right: group photos #}
{% if grp_photos %}
<div style="flex:0 0 33%;min-width:180px;display:flex;flex-direction:column;gap:.75rem;">
{% for gp in grp_photos %}
<div>
<img src="{{ gp.photo_url }}"
data-gallery="grp-{{ a.id }}-{{ grp_idx }}"
data-src="{{ gp.photo_url }}"
data-caption="{{ gp.caption or '' }}"
alt="{{ gp.caption or '' }}"
style="width:100%;border-radius:5px;object-fit:cover;display:block;cursor:zoom-in;">
{# Annotation stats if available #}
{% if gp.annotations and gp.annotations.stats %}
{% set s = gp.annotations.stats %}
<div style="margin-top:.4rem;background:#f8f9fb;border:1px solid #e0e0e0;border-radius:6px;padding:.5rem .65rem;font-size:0.78rem;">
<div style="font-weight:700;color:#1a1a2e;margin-bottom:.3rem;">
{{ s.shot_count }} {{ _('shots') }} &middot; {{ s.shooting_distance_m | int }} m
</div>
<table style="width:100%;border-collapse:collapse;margin:0;">
<tr>
<td style="color:#666;padding:.1rem 0;border:none;">{{ _('Group ES') }}</td>
<td style="font-weight:600;text-align:right;padding:.1rem 0;border:none;">{{ '%.2f'|format(s.group_size_moa) }} MOA</td>
</tr>
<tr>
<td style="color:#666;padding:.1rem 0;border:none;">{{ _('Mean Radius') }}</td>
<td style="font-weight:600;text-align:right;padding:.1rem 0;border:none;">{{ '%.2f'|format(s.mean_radius_moa) }} MOA</td>
</tr>
</table>
</div>
{% endif %}
{% if gp.caption %}
<div style="font-size:0.75rem;color:#666;margin-top:.2rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ gp.caption }}</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;gap:.4rem;margin-top:.35rem;flex-wrap:wrap;">
<a href="{{ url_for('analyses.annotate_group_photo', analysis_id=a.id, photo_id=gp.id) }}"
style="display:inline-block;padding:.2rem .55rem;border-radius:4px;font-size:0.78rem;text-decoration:none;
{% if gp.annotations and gp.annotations.stats %}
background:#e8f5e9;color:#27ae60;border:1px solid #a5d6a7;
{% else %}
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
{% endif %}">
{% if gp.annotations and gp.annotations.stats %}&#10003;{% else %}&#9654;{% endif %}
{{ _('Measure group') }}
</a>
<form method="post" action="{{ url_for('analyses.delete_group_photo', photo_id=gp.id) }}"
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:3px;padding:.2rem .45rem;font-size:0.78rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>{# end 2-col #}
{% if gs.note %}
<div style="margin-top:.75rem;padding:.5rem .75rem;background:#fffbea;border-left:3px solid #f0c040;
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;align-items:flex-start;">
{# Note editor #}
<details>
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline-block;padding:.2rem .5rem;border:1px solid #ddd;border-radius:4px;background:#fafafa;">
&#9998; {{ _('Note') }}
</summary>
<form method="post"
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=grp_idx) }}"
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
<textarea name="note" rows="2"
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:400px;">{{ gs.note or '' }}</textarea>
<div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
{{ _('Save note') }}
</button>
</div>
</form>
</details>
{# Group photo upload #}
<details>
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline-block;padding:.2rem .5rem;border:1px solid #ddd;border-radius:4px;background:#fafafa;">
&#128247; {{ _('Add photo') }}
</summary>
<form method="post"
action="{{ url_for('analyses.upload_group_photo', analysis_id=a.id, group_index=grp_idx) }}"
enctype="multipart/form-data"
style="margin-top:.4rem;display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-end;">
<div>
<input type="file" name="photo" accept="image/*" required style="font-size:0.85rem;">
</div>
<div>
<input type="text" name="caption" placeholder="{{ _('Caption (optional)') }}"
style="padding:.35rem .6rem;border:1px solid #ccc;border-radius:4px;font-size:0.85rem;width:160px;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .8rem;font-size:0.82rem;cursor:pointer;">
{{ _('Upload') }}
</button>
</form>
</details>
</div>
{% endif %}
</div>
{% endfor %}
{% elif groups_display is none %}
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
{% endif %}
{# --- Visual Group Editor (owner only) --- #}
{% if is_owner and groups_display %}
<details style="margin-top:1rem;border-top:1px solid #e8e8e8;padding-top:.9rem;">
<summary style="font-size:0.85rem;color:#888;cursor:pointer;list-style:none;">
&#9881; {{ _('Edit groups') }}
</summary>
<div style="margin-top:.75rem;">
<div style="font-size:0.8rem;color:#aaa;margin-bottom:.6rem;">
{{ _('Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint.') }}
</div>
<div id="grp-editor-{{ a.id }}" style="display:flex;align-items:stretch;flex-wrap:wrap;gap:0;user-select:none;"></div>
<form id="regroup-form-{{ a.id }}" method="post"
action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
style="margin-top:.75rem;display:flex;gap:.6rem;align-items:center;">
<input type="hidden" name="forced_splits" id="forced-splits-{{ a.id }}">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem 1rem;font-size:0.85rem;cursor:pointer;">
{{ _('Apply') }}
</button>
<button type="button" onclick="grpReset{{ a.id }}()"
style="background:none;color:#666;border:1px solid #ddd;border-radius:4px;padding:.4rem .75rem;font-size:0.85rem;cursor:pointer;">
{{ _('Reset') }}
</button>
</form>
</div>
</details>
<script>
(function() {
var AID = {{ a.id }};
var TOTAL = {{ a.shot_count }};
var INIT_SPLITS = {{ split_positions | tojson }};
var splits = INIT_SPLITS.slice();
var COLORS = ['#dbeafe','#dcfce7','#fef9c3','#fce7f3','#ede9fe','#ccfbf1','#ffedd5'];
var S_SHOTS = {{ _('shots') | tojson }};
var S_MERGE = {{ _('Merge') | tojson }};
var S_SPLIT = {{ _('Split') | tojson }};
function render() {
var el = document.getElementById('grp-editor-' + AID);
el.innerHTML = '';
var bounds = [0].concat(splits).concat([TOTAL]);
for (var i = 0; i < bounds.length - 1; i++) {
var start = bounds[i], end = bounds[i+1], cnt = end - start;
var pct = Math.max(8, Math.round(cnt / TOTAL * 100));
var color = COLORS[i % COLORS.length];
var box = document.createElement('div');
box.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;justify-content:center;' +
'width:' + pct + '%;min-width:56px;background:' + color + ';border:1px solid #ccc;border-radius:4px;' +
'padding:.4rem .3rem;text-align:center;font-size:0.78rem;box-sizing:border-box;';
box.innerHTML = '<strong style="font-size:.82rem;">' + (i+1) + '</strong>' +
'<span style="color:#555;">' + cnt + ' ' + S_SHOTS + '</span>';
if (cnt >= 2) {
var mid = start + Math.floor(cnt / 2);
var sb = document.createElement('button');
sb.type = 'button'; sb.title = S_SPLIT; sb.textContent = '⊣⊢';
sb.style.cssText = 'margin-top:.3rem;font-size:.7rem;padding:.1rem .3rem;border:1px dashed #888;border-radius:3px;background:rgba(255,255,255,.6);cursor:pointer;';
(function(m){ sb.onclick = function(){ addSplit(m); }; })(mid);
box.appendChild(sb);
}
el.appendChild(box);
if (i < bounds.length - 2) {
var sep = document.createElement('div');
sep.style.cssText = 'display:inline-flex;align-items:center;padding:0 .15rem;';
var mb = document.createElement('button');
mb.type = 'button'; mb.title = S_MERGE; mb.textContent = '✕';
mb.style.cssText = 'border:none;background:none;color:#c0392b;font-size:.9rem;cursor:pointer;padding:.15rem .3rem;';
var capSplit = bounds[i+1];
(function(sp){ mb.onclick = function(){ removeSplit(sp); }; })(capSplit);
sep.appendChild(mb);
el.appendChild(sep);
}
}
}
function addSplit(pos) {
if (splits.indexOf(pos) === -1) { splits.push(pos); splits.sort(function(a,b){return a-b;}); }
render();
}
function removeSplit(pos) {
splits = splits.filter(function(s){ return s !== pos; });
render();
}
window['grpReset' + AID] = function() { splits = INIT_SPLITS.slice(); render(); };
document.getElementById('regroup-form-' + AID).addEventListener('submit', function() {
document.getElementById('forced-splits-' + AID).value = JSON.stringify(splits);
});
render();
})();
</script>
{% endif %}
</div>
</details>
{% endfor %}
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No analyses yet.') }}</p>
{% endif %}
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.upload_csv', session_id=session.id) }}"
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Upload chronograph CSV') }}</label>
<input type="file" name="csv_file" accept=".csv,text/csv" required style="font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
{{ _('Analyse & link') }}
</button>
</form>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.4rem;">
{% if not editing %}
<a href="{{ url_for('sessions.new') }}" style="font-size:0.85rem;color:#888;text-decoration:none;">{{ _('← Change type') }}</a>
{% endif %}
</div>
<h1 style="margin-bottom:1.5rem;">
{{ _('Edit session') if editing else _('New session') }}
{% if type_name %}
<span style="font-size:0.95rem;font-weight:400;color:#666;margin-left:.6rem;">— {{ type_name }}</span>
{% endif %}
</h1>
{% set f = prefill or session %}
<form method="post"
action="{{ url_for('sessions.edit', session_id=session.id) if editing else url_for('sessions.new') }}"
style="max-width:600px;">
<input type="hidden" name="session_type" id="session_type_hidden" value="{{ eff_type }}">
{# In edit mode: allow changing type via a small selector #}
{% if editing %}
<div style="margin-bottom:1.5rem;padding:.75rem 1rem;background:#f8f9fb;border-radius:6px;display:flex;align-items:center;gap:1rem;">
<label style="font-size:.85rem;font-weight:600;color:#444;white-space:nowrap;">{{ _('Session type:') }}</label>
<select id="type_sel" onchange="document.getElementById('session_type_hidden').value=this.value;applyType(this.value);"
style="padding:.4rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;background:#fff;">
{% for slug, name, _ in (session_types or []) %}
<option value="{{ slug }}" {% if slug == eff_type %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{# ── Section: basic information ── #}
<h2>{{ _('Basic information') }}</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">{{ _('Date *') }}</label>
<input type="date" name="session_date" required
value="{{ (f.session_date.isoformat() if f.session_date else '') if f else (today or '') }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
{# Distance: shown for long_range and pistol_25m (hidden for prs — per stage) #}
<div id="field_distance_wrap">
<label class="fl">{{ _('Distance (m)') }}</label>
<input type="number" name="distance_m" min="1" max="5000" id="field_distance"
value="{{ f.distance_m if f and f.distance_m else (prefill_distance or '') }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<div style="margin-bottom:1rem;">
<label class="fl">{{ _('Location') }}</label>
<input type="text" name="location_name" value="{{ f.location_name if f else '' }}"
placeholder="ex : Nom du stand, commune"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
{# ── Section: shooting position (long_range and pistol_25m) ── #}
<div id="section_position">
<div style="margin-bottom:1rem;">
<label class="fl">{{ _('Shooting position') }}</label>
<select name="shooting_position" id="field_position"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
{# Options injected by JS based on type #}
</select>
</div>
</div>
{# ── Section: weather ── #}
<h2>{{ _('Weather') }}</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">{{ _('Conditions') }}</label>
<select name="weather_cond"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
{% for val, label in weather_conditions %}
<option value="{{ val }}" {% if f and f.weather_cond == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="fl">{{ _('Temp. (°C)') }}</label>
<input type="number" name="weather_temp_c" step="0.1"
value="{{ f.weather_temp_c if f and f.weather_temp_c is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">{{ _('Wind (km/h)') }}</label>
<input type="number" name="weather_wind_kph" step="0.1" min="0"
value="{{ f.weather_wind_kph if f and f.weather_wind_kph is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
{# ── Section: equipment & ammunition ── #}
<h2>{{ _('Equipment & Ammunition') }}</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">{{ _('Rifle / Handgun') }}</label>
<select name="rifle_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">{{ _('— none —') }}</option>
{% for r in rifles %}
<option value="{{ r.id }}" {% if f and f.rifle_id == r.id %}selected{% endif %}>
{{ r.name }}{% if r.caliber %} ({{ r.caliber }}){% endif %}
</option>
{% endfor %}
</select>
{% if not rifles %}
<div style="font-size:0.78rem;color:#aaa;margin-top:.25rem;">
<a href="{{ url_for('equipment.new') }}">{{ _('Add a rifle first') }}</a>
</div>
{% endif %}
</div>
<div>
<label class="fl">{{ _('Scope') }}</label>
<select name="scope_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">{{ _('— none —') }}</option>
{% for sc in scopes %}
<option value="{{ sc.id }}" {% if f and f.scope_id == sc.id %}selected{% endif %}>{{ sc.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">{{ _('Ammo brand') }}</label>
<input type="text" name="ammo_brand" value="{{ f.ammo_brand if f else '' }}"
placeholder="ex : Lapua, RWS"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">{{ _('Bullet weight (gr)') }}</label>
<input type="number" name="ammo_weight_gr" step="0.1" min="0"
value="{{ f.ammo_weight_gr if f and f.ammo_weight_gr is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">{{ _('Lot number') }}</label>
<input type="text" name="ammo_lot" value="{{ f.ammo_lot if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
{# ── Section: notes & visibility ── #}
<h2>{{ _('Notes & Visibility') }}</h2>
<div style="margin-bottom:1rem;">
<label class="fl">{{ _('Notes') }}</label>
<textarea name="notes" rows="4"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:flex;align-items:center;gap:0.6rem;cursor:pointer;font-size:0.95rem;">
<input type="checkbox" name="is_public" value="1"
{% if f and f.is_public %}checked{% endif %}
style="width:16px;height:16px;">
{{ _('Make this session public (visible in the community feed)') }}
</label>
</div>
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ _('Save') if editing else _('Create session') }}
</button>
<a href="{{ url_for('sessions.detail', session_id=session.id) if editing else url_for('sessions.index') }}"
style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
</div>
</form>
<style>.fl { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
<script>
(function () {
var LR_POS = {{ (long_range_positions | tojson) if long_range_positions else '[]' }};
var P25_POS = {{ (pistol_25m_positions | tojson) if pistol_25m_positions else '[]' }};
function buildOptions(sel, opts, currentVal) {
sel.innerHTML = '';
opts.forEach(function(o) {
var opt = document.createElement('option');
opt.value = o[0]; opt.textContent = o[1];
if (o[0] === currentVal) opt.selected = true;
sel.appendChild(opt);
});
}
var currentPosition = {{ ((f.shooting_position if f else None) or '') | tojson }};
function applyType(t) {
var distWrap = document.getElementById('field_distance_wrap');
var posSection = document.getElementById('section_position');
var posSelect = document.getElementById('field_position');
if (t === 'prs') {
if (distWrap) distWrap.style.display = 'none';
if (posSection) posSection.style.display = 'none';
} else if (t === 'pistol_25m') {
if (distWrap) distWrap.style.display = '';
if (posSection) posSection.style.display = '';
buildOptions(posSelect, P25_POS, currentPosition);
var distField = document.getElementById('field_distance');
if (distField && !distField.value) distField.value = '25';
} else {
// long_range
if (distWrap) distWrap.style.display = '';
if (posSection) posSection.style.display = '';
buildOptions(posSelect, LR_POS, currentPosition);
}
}
document.addEventListener('DOMContentLoaded', function () {
applyType({{ eff_type | tojson }});
});
// Expose for the type selector in edit mode
window.applyType = applyType;
})();
</script>
{% endblock %}

View File

@@ -1,58 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('Sessions') }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">{{ _('My Sessions') }}</h1>
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
{{ _('+ New session') }}
</a>
</div>
{% if sessions %}
<table>
<thead>
<tr>
<th>{{ _('Session') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Location') }}</th>
<th>{{ _('Visibility') }}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td><a href="{{ url_for('sessions.detail', session_id=s.id) }}">{{ s.session_date.strftime('%d %b %Y') }}</a></td>
<td style="font-size:0.82rem;">
{% if s.session_type == 'long_range' %}
<span style="background:#f0f4ff;color:#1a1a2e;padding:.15rem .5rem;border-radius:3px;">{{ _('Long Range') }}</span>
{% elif s.session_type == 'prs' %}
<span style="background:#fff3e0;color:#e65100;padding:.15rem .5rem;border-radius:3px;">PRS</span>
{% elif s.session_type == 'pistol_25m' %}
<span style="background:#f3e5f5;color:#6a1b9a;padding:.15rem .5rem;border-radius:3px;">{{ _('25m Pistol') }}</span>
{% else %}—{% endif %}
</td>
<td style="color:#666;font-size:0.88rem;">{{ s.location_name or '—' }}</td>
<td style="font-size:0.85rem;color:{% if s.is_public %}#27ae60{% else %}#aaa{% endif %};">
{{ _('Public') if s.is_public else _('Private') }}
</td>
<td style="white-space:nowrap;">
<a href="{{ url_for('sessions.edit', session_id=s.id) }}" style="font-size:0.85rem;margin-right:.75rem;">{{ _('Edit') }}</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=s.id) }}" style="display:inline;"
onsubmit="return confirm('{{ _('Delete this session?') | e }}');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">{{ _('Delete') }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🎯</div>
<p style="margin-bottom:1rem;">{{ _('No sessions recorded yet.') }}</p>
<a href="{{ url_for('sessions.new') }}">{{ _('Log your first session') }}</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,40 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ _('New session') }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:.5rem;">
<h1 style="margin:0;">{{ _('New session') }}</h1>
<a href="{{ url_for('sessions.index') }}" style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
</div>
<p style="color:#666;margin-bottom:2rem;font-size:0.95rem;">{{ _('Choose the session type.') }}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1.5rem;">
{% for slug, name, desc in session_types %}
<a href="{{ url_for('sessions.new', type=slug) }}"
style="display:flex;flex-direction:column;padding:1.5rem 1.75rem;border:2px solid #e0e0e0;border-radius:10px;
text-decoration:none;color:inherit;background:#fff;
transition:border-color .15s,box-shadow .15s,transform .1s;"
onmouseover="this.style.borderColor='#1a1a2e';this.style.boxShadow='0 4px 14px rgba(0,0,0,.1)';this.style.transform='translateY(-2px)'"
onmouseout="this.style.borderColor='#e0e0e0';this.style.boxShadow='none';this.style.transform='none'">
{% if slug == 'long_range' %}
<div style="font-size:2rem;margin-bottom:.6rem;">🎯</div>
{% elif slug == 'prs' %}
<div style="font-size:2rem;margin-bottom:.6rem;">🏔️</div>
{% else %}
<div style="font-size:2rem;margin-bottom:.6rem;">🔫</div>
{% endif %}
<div style="font-weight:700;font-size:1.1rem;color:#1a1a2e;margin-bottom:.4rem;">{{ _(name) }}</div>
<div style="font-size:0.85rem;color:#777;line-height:1.5;flex:1;">{{ _(desc) }}</div>
{% if slug == 'prs' %}
<div style="margin-top:1rem;font-size:0.78rem;color:#1f77b4;font-weight:600;">
{{ _('Stage management, PDF dope card ↗') }}
</div>
{% endif %}
</a>
{% endfor %}
</div>
{% endblock %}

View File

@@ -1,30 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ _('New Analysis') }}</h1>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<p style="margin-bottom:1.5rem; color:#555;">
{{ _('Upload a CSV file to analyse shot groups. The file must contain the following columns:') }}
<strong>index</strong>, <strong>speed</strong>, <strong>standard deviation</strong>,
<strong>energy</strong>, <strong>power factor</strong>, <strong>time of the day</strong>.
</p>
<form method="POST" action="/analyze" enctype="multipart/form-data" style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
<input
type="file"
name="csv_file"
accept=".csv,text/csv"
required
style="border:1px solid #ccc;border-radius:4px;padding:0.5rem 0.75rem;background:#fafafa;font-size:0.95rem;"
>
<button
type="submit"
style="background:#1f77b4;color:#fff;border:none;border-radius:4px;padding:0.55rem 1.4rem;font-size:0.95rem;cursor:pointer;"
>
{{ _('Analyze') }}
</button>
</form>
{% endblock %}

View File

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

View File

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

View File

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