wip, claude + docker-compose modifications

This commit is contained in:
Gérald Colangelo
2026-03-20 15:41:55 +01:00
parent 54b8cc991e
commit 457abdf59a
13 changed files with 437 additions and 7 deletions

11
app.py
View File

@@ -12,9 +12,15 @@ 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"
@@ -74,11 +80,13 @@ def create_app(config_class=Config):
def load_user(user_id):
return db.session.get(User, int(user_id))
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)
@@ -116,6 +124,9 @@ def create_app(config_class=Config):
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("/")

100
blueprints/admin.py Normal file
View File

@@ -0,0 +1,100 @@
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import _
from flask_login import current_user, login_required
from extensions import db
from models import User
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
ROLES = ["user", "admin"]
def _require_admin():
if not current_user.is_authenticated or current_user.role != "admin":
abort(403)
# ---------------------------------------------------------------------------
# User list
# ---------------------------------------------------------------------------
@admin_bp.route("/")
@login_required
def index():
_require_admin()
users = db.session.scalars(
db.select(User).order_by(User.created_at.desc())
).all()
return render_template("admin/users.html", users=users, roles=ROLES)
# ---------------------------------------------------------------------------
# Change role
# ---------------------------------------------------------------------------
@admin_bp.route("/users/<int:user_id>/role", methods=["POST"])
@login_required
def change_role(user_id: int):
_require_admin()
user = db.session.get(User, user_id)
if user is None:
abort(404)
new_role = request.form.get("role", "user")
if new_role not in ROLES:
flash(_("Invalid role."), "error")
return redirect(url_for("admin.index"))
# Prevent removing the last admin
if user.role == "admin" and new_role != "admin":
admin_count = db.session.scalar(
db.select(db.func.count()).select_from(User).where(User.role == "admin")
)
if admin_count <= 1:
flash(_("Cannot remove the last admin."), "error")
return redirect(url_for("admin.index"))
user.role = new_role
db.session.commit()
flash(_("Role updated for %(email)s.", email=user.email), "success")
return redirect(url_for("admin.index"))
# ---------------------------------------------------------------------------
# Reset password
# ---------------------------------------------------------------------------
@admin_bp.route("/users/<int:user_id>/password", methods=["POST"])
@login_required
def reset_password(user_id: int):
_require_admin()
user = db.session.get(User, user_id)
if user is None:
abort(404)
new_pw = request.form.get("new_password", "").strip()
if len(new_pw) < 8:
flash(_("Password must be at least 8 characters."), "error")
return redirect(url_for("admin.index"))
user.set_password(new_pw)
db.session.commit()
flash(_("Password reset for %(email)s.", email=user.email), "success")
return redirect(url_for("admin.index"))
# ---------------------------------------------------------------------------
# Delete user
# ---------------------------------------------------------------------------
@admin_bp.route("/users/<int:user_id>/delete", methods=["POST"])
@login_required
def delete_user(user_id: int):
_require_admin()
if user_id == current_user.id:
flash(_("You cannot delete your own account."), "error")
return redirect(url_for("admin.index"))
user = db.session.get(User, user_id)
if user is None:
abort(404)
email = user.email
db.session.delete(user)
db.session.commit()
flash(_("User %(email)s deleted.", email=email), "success")
return redirect(url_for("admin.index"))

View File

@@ -12,6 +12,7 @@ from flask import (
render_template,
request,
send_from_directory,
session as flask_session,
url_for,
)
from flask_babel import _
@@ -28,6 +29,13 @@ 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 == "":
@@ -107,7 +115,7 @@ def login():
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
login_user(user)
_login(user)
return redirect(_safe_next())
return render_template("auth/login.html")
@@ -167,7 +175,7 @@ def register():
_dispatch_confirmation(user)
return render_template("auth/confirm_pending.html", email=email)
login_user(user)
_login(user)
flash(_("Account created! Welcome."), "success")
return redirect(url_for("dashboard.index"))
@@ -186,7 +194,7 @@ def confirm_email(token: str):
user.email_confirmed = True
user.email_confirm_token = None
db.session.commit()
login_user(user)
_login(user)
flash(_("Email confirmed! Welcome."), "success")
return redirect(url_for("dashboard.index"))
@@ -243,7 +251,7 @@ def callback_google():
flash(_("This email is already registered with a different login method."), "error")
return redirect(url_for("auth.login"))
login_user(user)
_login(user)
return redirect(_safe_next())
@@ -293,7 +301,7 @@ def callback_github():
flash(_("This email is already registered with a different login method."), "error")
return redirect(url_for("auth.login"))
login_user(user)
_login(user)
return redirect(_safe_next())

View File

@@ -13,11 +13,11 @@ services:
interval: 10s
timeout: 5s
retries: 5
networks:
- ${NETWORK}
web:
build: .
ports:
- "5000:5000"
restart: unless-stopped
depends_on:
db:
@@ -30,7 +30,14 @@ services:
volumes:
- app_storage:/app/storage
- .:/app # bind-mount source so code changes are live without a rebuild
networks:
- ${NETWORK}
volumes:
postgres_data:
app_storage:
networks:
${NETWORK}:
external: true

View File

@@ -0,0 +1,44 @@
"""user_role_and_language
Revision ID: 6818f37f4124
Revises: bf96ceb7f076
Create Date: 2026-03-19 15:51:15.091825
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6818f37f4124'
down_revision = 'bf96ceb7f076'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('role', sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column('language', sa.String(length=10), nullable=True))
op.execute("UPDATE users SET role = 'user' WHERE role IS NULL")
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column('role', nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('language')
batch_op.drop_column('role')
op.execute("UPDATE users SET role = 'user' WHERE role IS NULL")
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column('role', nullable=False)
# ### end Alembic commands ###

View File

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

124
templates/admin/users.html Normal file
View File

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

View File

@@ -250,6 +250,7 @@
<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>
@@ -304,6 +305,7 @@
<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>

View File

@@ -651,3 +651,69 @@ 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"

View File

@@ -651,3 +651,69 @@ 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"