Remove old stuff has project has been reworked in django
This commit is contained in:
86
CLAUDE.md
86
CLAUDE.md
@@ -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)
|
||||
17
Dockerfile
17
Dockerfile
@@ -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"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Generate a printable PRS dope card as PDF (A4 portrait)."""
|
||||
|
||||
from fpdf import FPDF
|
||||
|
||||
|
||||
# Column widths in mm
|
||||
_W = {
|
||||
"num": 10,
|
||||
"name": 28,
|
||||
"dist": 20,
|
||||
"time": 18,
|
||||
"pos": 26,
|
||||
"dope_e": 30,
|
||||
"dope_w": 30,
|
||||
"hits": 24,
|
||||
"notes": 0, # fills remaining width
|
||||
}
|
||||
_ROW_H = 8
|
||||
_HEAD_H = 9
|
||||
_DARK = (26, 26, 46) # #1a1a2e
|
||||
_LIGHT = (240, 244, 255) # #f0f4ff
|
||||
_GRID = (200, 210, 230)
|
||||
|
||||
|
||||
def _notes_w(epw: float) -> float:
|
||||
fixed = sum(v for k, v in _W.items() if k != "notes")
|
||||
return max(0, epw - fixed)
|
||||
|
||||
|
||||
def generate_dope_card(session, stages: list) -> bytes:
|
||||
pdf = FPDF(orientation="P", unit="mm", format="A4")
|
||||
pdf.set_auto_page_break(auto=True, margin=15)
|
||||
pdf.add_page()
|
||||
pdf.set_margins(10, 12, 10)
|
||||
|
||||
epw = pdf.w - pdf.l_margin - pdf.r_margin
|
||||
nw = _notes_w(epw)
|
||||
|
||||
# ── Header ──────────────────────────────────────────────────────────────
|
||||
pdf.set_font("Helvetica", "B", 18)
|
||||
pdf.set_text_color(*_DARK)
|
||||
pdf.cell(0, 10, "FICHE DE TIR — PRS", new_x="LMARGIN", new_y="NEXT", align="C")
|
||||
|
||||
pdf.set_font("Helvetica", "", 9)
|
||||
pdf.set_text_color(80, 80, 80)
|
||||
parts = [session.session_date.strftime("%d/%m/%Y")]
|
||||
if session.location_name:
|
||||
parts.append(session.location_name)
|
||||
if session.rifle:
|
||||
parts.append(session.rifle.name)
|
||||
if session.rifle.caliber:
|
||||
parts.append(session.rifle.caliber)
|
||||
if session.ammo_brand:
|
||||
parts.append(session.ammo_brand)
|
||||
if session.ammo_weight_gr:
|
||||
parts.append(f"{session.ammo_weight_gr} gr")
|
||||
pdf.cell(0, 5, " | ".join(parts), new_x="LMARGIN", new_y="NEXT", align="C")
|
||||
pdf.ln(4)
|
||||
|
||||
# ── Column headers ───────────────────────────────────────────────────────
|
||||
pdf.set_fill_color(*_DARK)
|
||||
pdf.set_text_color(255, 255, 255)
|
||||
pdf.set_font("Helvetica", "B", 8)
|
||||
|
||||
headers = [
|
||||
("N°", _W["num"]),
|
||||
("Nom", _W["name"]),
|
||||
("Dist.(m)", _W["dist"]),
|
||||
("Temps(s)", _W["time"]),
|
||||
("Position", _W["pos"]),
|
||||
("Dope Élév.", _W["dope_e"]),
|
||||
("Dope Dérive", _W["dope_w"]),
|
||||
("Coups/Poss.", _W["hits"]),
|
||||
("Notes", nw),
|
||||
]
|
||||
for label, w in headers:
|
||||
pdf.cell(w, _HEAD_H, label, border=0, fill=True, align="C")
|
||||
pdf.ln()
|
||||
|
||||
# ── Stage rows ───────────────────────────────────────────────────────────
|
||||
pdf.set_text_color(30, 30, 30)
|
||||
pdf.set_font("Helvetica", "", 9)
|
||||
|
||||
for i, st in enumerate(stages):
|
||||
fill = i % 2 == 0
|
||||
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
|
||||
pdf.set_draw_color(*_GRID)
|
||||
|
||||
hits_str = ""
|
||||
if st.get("hits") is not None:
|
||||
hits_str = str(st["hits"])
|
||||
if st.get("possible"):
|
||||
hits_str += f"/{st['possible']}"
|
||||
elif st.get("possible"):
|
||||
hits_str = f"—/{st['possible']}"
|
||||
|
||||
row = [
|
||||
(str(st.get("num", i + 1)), _W["num"], "C"),
|
||||
(st.get("name") or "", _W["name"], "L"),
|
||||
(str(st.get("distance_m") or ""), _W["dist"], "C"),
|
||||
(str(st.get("time_s") or ""), _W["time"], "C"),
|
||||
(_pos_label(st.get("position", "")), _W["pos"], "L"),
|
||||
(st.get("dope_elevation") or "", _W["dope_e"], "C"),
|
||||
(st.get("dope_windage") or "", _W["dope_w"], "C"),
|
||||
(hits_str, _W["hits"], "C"),
|
||||
(st.get("notes") or "", nw, "L"),
|
||||
]
|
||||
for val, w, align in row:
|
||||
pdf.cell(w, _ROW_H, val, border="B", fill=fill, align=align)
|
||||
pdf.ln()
|
||||
|
||||
# ── Blank rows for hand-written stages ──────────────────────────────────
|
||||
spare = max(0, 10 - len(stages))
|
||||
for i in range(min(spare, 5)):
|
||||
fill = (len(stages) + i) % 2 == 0
|
||||
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
|
||||
for _, w, _ in row: # reuse last row widths
|
||||
pdf.cell(w, _ROW_H, "", border="B", fill=fill)
|
||||
pdf.ln()
|
||||
|
||||
# ── Footer ───────────────────────────────────────────────────────────────
|
||||
pdf.ln(4)
|
||||
pdf.set_font("Helvetica", "I", 7)
|
||||
pdf.set_text_color(160, 160, 160)
|
||||
pdf.cell(0, 5, "The Shooter's Network — fiche générée automatiquement",
|
||||
new_x="LMARGIN", new_y="NEXT", align="C")
|
||||
|
||||
return bytes(pdf.output())
|
||||
|
||||
|
||||
_POSITION_LABELS = {
|
||||
"prone": "Couché",
|
||||
"standing": "Debout",
|
||||
"kneeling": "Agenouillé",
|
||||
"sitting": "Assis",
|
||||
"barricade": "Barricade",
|
||||
"rooftop": "Toit",
|
||||
"unknown": "Variable",
|
||||
}
|
||||
|
||||
|
||||
def _pos_label(slug: str) -> str:
|
||||
return _POSITION_LABELS.get(slug, slug.replace("_", " ").title() if slug else "")
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
218
app.py
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
extensions=jinja2.ext.autoescape,jinja2.ext.with_
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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()}
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
40
config.py
40
config.py
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()"
|
||||
@@ -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.")
|
||||
@@ -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.
|
||||
@@ -1 +0,0 @@
|
||||
Single-database configuration for Flask.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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')
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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
207
models.py
@@ -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}"
|
||||
@@ -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
|
||||
63
sample.csv
63
sample.csv
@@ -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,,,,,,
|
||||
|
162
storage.py
162
storage.py
@@ -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())
|
||||
@@ -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 %}
|
||||
@@ -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> ›
|
||||
{% else %}
|
||||
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a> ›
|
||||
{% 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') }}
|
||||
· {{ analysis.shot_count }} {{ _('shot(s)') }}
|
||||
· {{ 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;">
|
||||
⇓ {{ _('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;">← {{ _('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 %}–{% 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') }} — {{ 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 }} – {{ stat.time_end }} | {{ 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 %}–{% 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;">
|
||||
👤
|
||||
</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 %}
|
||||
@@ -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;">
|
||||
👤
|
||||
</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu" style="min-width:200px;">
|
||||
<a href="{{ url_for('analyze') }}">📊 {{ _('Analyse CSV') }}</a>
|
||||
<a href="{{ url_for('tools_measure') }}">🎯 {{ _('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">▼</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;">👤</span>
|
||||
{% endif %}
|
||||
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
|
||||
<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu">
|
||||
<a href="{{ url_for('auth.profile') }}">👤 {{ _('Profile') }}</a>
|
||||
<hr>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button type="submit">→ {{ _('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">☰</button>
|
||||
|
||||
{# Mobile menu panel #}
|
||||
<div class="nav-mobile-menu">
|
||||
<a href="{{ url_for('analyze') }}">📊 {{ _('Analyse CSV') }}</a>
|
||||
<a href="{{ url_for('tools_measure') }}">🎯 {{ _('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;">✕</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;">‹</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;">›</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>
|
||||
@@ -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 %}
|
||||
@@ -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> ›
|
||||
{{ 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">▼</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;">👤</span>{% endif %}
|
||||
<span>{{ current_user.display_name or current_user.email.split('@')[0] }}</span>
|
||||
<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu">
|
||||
<a href="{{ url_for('auth.profile') }}">👤 {{ _('Profile') }}</a>
|
||||
<hr>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button type="submit">→ {{ _('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 %}
|
||||
@@ -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 %}
|
||||
–
|
||||
{% 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') }} — {{ 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 }} – {{ stat.time_end }} | {{ 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 %}
|
||||
–
|
||||
{% 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 %}
|
||||
@@ -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> ›
|
||||
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> ›
|
||||
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 & 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 %}
|
||||
@@ -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> › {% 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 %} {{ 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 %} 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;">N°</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;">
|
||||
✕
|
||||
</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 }}
|
||||
· {{ 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 %}✓{% else %}▶{% 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') }} · {{ 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;">
|
||||
↧ 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>
|
||||
· {{ gs.count }} {{ _('shots') }}
|
||||
· {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
|
||||
{% if gs.std_speed is not none %} · {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
|
||||
· {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }}
|
||||
</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') }} · {{ 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 %}✓{% else %}▶{% 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;">
|
||||
✎ {{ _('Note') }}
|
||||
</summary>
|
||||
<form method="post"
|
||||
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=grp_idx) }}"
|
||||
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
|
||||
<textarea name="note" rows="2"
|
||||
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:400px;">{{ gs.note or '' }}</textarea>
|
||||
<div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Save note') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{# Group photo upload #}
|
||||
<details>
|
||||
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline-block;padding:.2rem .5rem;border:1px solid #ddd;border-radius:4px;background:#fafafa;">
|
||||
📷 {{ _('Add photo') }}
|
||||
</summary>
|
||||
<form method="post"
|
||||
action="{{ url_for('analyses.upload_group_photo', analysis_id=a.id, group_index=grp_idx) }}"
|
||||
enctype="multipart/form-data"
|
||||
style="margin-top:.4rem;display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-end;">
|
||||
<div>
|
||||
<input type="file" name="photo" accept="image/*" required style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" name="caption" placeholder="{{ _('Caption (optional)') }}"
|
||||
style="padding:.35rem .6rem;border:1px solid #ccc;border-radius:4px;font-size:0.85rem;width:160px;">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .8rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Upload') }}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% 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;">
|
||||
⚙ {{ _('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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Binary file not shown.
@@ -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."
|
||||
Binary file not shown.
@@ -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 ""
|
||||
Binary file not shown.
@@ -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."
|
||||
Reference in New Issue
Block a user