wip: claude
This commit is contained in:
21
app.py
21
app.py
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
18
config.py
18
config.py
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">▼</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('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') }}">📊 {{ _('Analyse CSV') }}</a>
|
||||
<a href="{{ url_for('tools_measure') }}">🎯 {{ _('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>
|
||||
|
||||
@@ -504,38 +504,85 @@
|
||||
{% if gs.std_speed is not none %} · {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
|
||||
· {{ _('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') }} · {{ 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 %}
|
||||
|
||||
{# 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 #}
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user