wip: claude

This commit is contained in:
Gérald Colangelo
2026-03-23 18:50:18 +01:00
parent a4dad2a9f2
commit 85de9781d7
13 changed files with 215 additions and 34 deletions

21
app.py
View File

@@ -3,11 +3,14 @@ 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"]
@@ -33,12 +36,20 @@ def create_app(config_class=Config):
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):
@@ -78,7 +89,10 @@ def create_app(config_class=Config):
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(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
@@ -94,6 +108,7 @@ def create_app(config_class=Config):
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>")
@@ -140,6 +155,10 @@ def create_app(config_class=Config):
).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

View File

@@ -3,7 +3,7 @@ import json
from pathlib import Path
from flask import (
Blueprint, abort, current_app, flash, redirect, request,
Blueprint, abort, current_app, flash, jsonify, redirect, request,
render_template, send_from_directory, url_for,
)
from flask_babel import _
@@ -313,6 +313,41 @@ def delete_group_photo(photo_id: int):
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."""

View File

@@ -1,4 +1,6 @@
from flask import Blueprint, request
import secrets
from flask import Blueprint, current_app, request
from flask_jwt_extended import create_access_token, jwt_required
from extensions import db
@@ -28,17 +30,22 @@ def register():
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=True,
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})
@@ -55,6 +62,9 @@ def login():
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})

View File

@@ -3,8 +3,22 @@ from datetime import timedelta
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret"))
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"

View File

@@ -197,6 +197,8 @@ class AnalysisGroupPhoto(db.Model):
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

View File

@@ -1,5 +1,6 @@
Flask>=3.0
Flask-Babel>=3.0
Flask-WTF>=1.2
python-dotenv>=1.0
Flask-SQLAlchemy>=3.1
Flask-Migrate>=4.0

View File

@@ -3,6 +3,7 @@
<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; }
@@ -245,8 +246,18 @@
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{# Tools dropdown — always visible #}
<div class="nav-dropdown" id="toolsDropdown">
<button class="nav-user-btn" onclick="toggleToolsDropdown(event)"
style="border:none;padding:.25rem .55rem;font-size:0.9rem;color:#c8cfe0;gap:.3rem;">
{{ _('Tools') }} <span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu" style="min-width:200px;">
<a href="{{ url_for('analyze') }}">&#128202;&ensp;{{ _('Analyse CSV') }}</a>
<a href="{{ url_for('tools_measure') }}">&#127919;&ensp;{{ _('Measure group (photo)') }}</a>
</div>
</div>
{% if current_user.is_authenticated %}
<a href="{{ url_for('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>
@@ -300,8 +311,9 @@
{# Mobile menu panel #}
<div class="nav-mobile-menu">
<a href="{{ url_for('analyze') }}">&#128202;&ensp;{{ _('Analyse CSV') }}</a>
<a href="{{ url_for('tools_measure') }}">&#127919;&ensp;{{ _('Measure group (photo)') }}</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('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>
@@ -331,6 +343,10 @@
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');
@@ -340,6 +356,8 @@
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');
});
@@ -495,5 +513,22 @@
})();
</script>
<script>
/* Auto-inject CSRF token into every POST form on the page */
(function() {
var token = document.querySelector('meta[name="csrf-token"]');
if (!token) return;
var t = token.getAttribute('content');
document.querySelectorAll('form').forEach(function(f) {
var method = (f.getAttribute('method') || 'get').toLowerCase();
if (method !== 'post') return;
if (f.querySelector('input[name="csrf_token"]')) return; // already present
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = t;
f.appendChild(inp);
});
})();
</script>
</body>
</html>

View File

@@ -504,38 +504,85 @@
{% if gs.std_speed is not none %}&nbsp;&middot;&nbsp; {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
&nbsp;&middot;&nbsp; {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }}
</div>
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart">
{# 2-column layout: chart 65% / photos 35% #}
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap;">
{# Left: chart #}
<div style="flex:1 1 60%;min-width:0;">
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart"
style="width:100%;max-width:none;margin-top:0;">
</div>
{# Right: group photos #}
{% if grp_photos %}
<div style="flex:0 0 33%;min-width:180px;display:flex;flex-direction:column;gap:.75rem;">
{% for gp in grp_photos %}
<div>
<img src="{{ gp.photo_url }}"
data-gallery="grp-{{ a.id }}-{{ grp_idx }}"
data-src="{{ gp.photo_url }}"
data-caption="{{ gp.caption or '' }}"
alt="{{ gp.caption or '' }}"
style="width:100%;border-radius:5px;object-fit:cover;display:block;cursor:zoom-in;">
{# Annotation stats if available #}
{% if gp.annotations and gp.annotations.stats %}
{% set s = gp.annotations.stats %}
<div style="margin-top:.4rem;background:#f8f9fb;border:1px solid #e0e0e0;border-radius:6px;padding:.5rem .65rem;font-size:0.78rem;">
<div style="font-weight:700;color:#1a1a2e;margin-bottom:.3rem;">
{{ s.shot_count }} {{ _('shots') }} &middot; {{ s.shooting_distance_m | int }} m
</div>
<table style="width:100%;border-collapse:collapse;margin:0;">
<tr>
<td style="color:#666;padding:.1rem 0;border:none;">{{ _('Group ES') }}</td>
<td style="font-weight:600;text-align:right;padding:.1rem 0;border:none;">{{ '%.2f'|format(s.group_size_moa) }} MOA</td>
</tr>
<tr>
<td style="color:#666;padding:.1rem 0;border:none;">{{ _('Mean Radius') }}</td>
<td style="font-weight:600;text-align:right;padding:.1rem 0;border:none;">{{ '%.2f'|format(s.mean_radius_moa) }} MOA</td>
</tr>
</table>
</div>
{% endif %}
{% if gp.caption %}
<div style="font-size:0.75rem;color:#666;margin-top:.2rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ gp.caption }}</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;gap:.4rem;margin-top:.35rem;flex-wrap:wrap;">
<a href="{{ url_for('analyses.annotate_group_photo', analysis_id=a.id, photo_id=gp.id) }}"
style="display:inline-block;padding:.2rem .55rem;border-radius:4px;font-size:0.78rem;text-decoration:none;
{% if gp.annotations and gp.annotations.stats %}
background:#e8f5e9;color:#27ae60;border:1px solid #a5d6a7;
{% else %}
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
{% endif %}">
{% if gp.annotations and gp.annotations.stats %}&#10003;{% else %}&#9654;{% endif %}
{{ _('Measure group') }}
</a>
<form method="post" action="{{ url_for('analyses.delete_group_photo', photo_id=gp.id) }}"
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:3px;padding:.2rem .45rem;font-size:0.78rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>{# end 2-col #}
{% if gs.note %}
<div style="margin-top:.75rem;padding:.5rem .75rem;background:#fffbea;border-left:3px solid #f0c040;
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
{% endif %}
{# Group photos #}
{% if grp_photos %}
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;">
{% for gp in grp_photos %}
<div>
<img src="{{ gp.photo_url }}" alt="{{ gp.caption or '' }}"
style="height:120px;width:auto;border-radius:5px;object-fit:cover;display:block;">
{% if gp.caption %}
<div style="font-size:0.75rem;color:#666;margin-top:.2rem;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ gp.caption }}</div>
{% endif %}
{% if is_owner %}
<form method="post" action="{{ url_for('analyses.delete_group_photo', photo_id=gp.id) }}"
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');"
style="margin-top:.2rem;">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:3px;padding:.1rem .4rem;font-size:0.75rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;align-items:flex-start;">
{# Note editor #}

View File

@@ -7,6 +7,15 @@ msgstr ""
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"

View File

@@ -7,6 +7,15 @@ msgstr ""
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"