transfer from private server/git server

This commit is contained in:
M1n-0
2026-08-21 09:12:02 +02:00
parent 45c8982178
commit 6ecb014238
75 changed files with 4344 additions and 274 deletions

6
web/routes/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Couche routes/ — orchestration HTTP uniquement (blueprints Flask).
Chaque route : (1) authentifie/autorise via services.auth_service,
(2) délègue la logique métier à la couche services/, (3) retourne une
réponse JSON ou un rendu de template. Aucune route n'exécute de SQL.
"""

62
web/routes/annonces.py Normal file
View File

@@ -0,0 +1,62 @@
"""routes/annonces.py — API CRUD du module Annonces (EXF-01 à EXF-04)."""
from flask import Blueprint, current_app, jsonify, request
from models.annonce_model import AnnonceNotFoundError
from services import annonce_service
from services.auth_service import client_ip, current_user, require_admin, require_login
bp = Blueprint("annonces", __name__, url_prefix="/api/annonces")
def _db():
return current_app.config["DB"]
@bp.route("", methods=["GET"])
@require_login
def list_annonces():
return jsonify(annonce_service.list_annonces(_db()))
@bp.route("", methods=["POST"])
@require_admin
def create_annonce():
data = request.json or {}
user = current_user()
try:
new_id = annonce_service.create_annonce(
_db(), data.get("titre"), data.get("contenu"), user["sub"],
data.get("epinglee", False), client_ip(),
)
except annonce_service.ValidationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"id": new_id}), 201
@bp.route("/<int:annonce_id>", methods=["PUT"])
@require_admin
def update_annonce(annonce_id):
data = request.json or {}
user = current_user()
try:
annonce_service.update_annonce(
_db(), annonce_id, data.get("titre"), data.get("contenu"), user["sub"],
data.get("epinglee", False), client_ip(),
)
except annonce_service.ValidationError as exc:
return jsonify({"error": str(exc)}), 400
except AnnonceNotFoundError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"status": "updated"})
@bp.route("/<int:annonce_id>", methods=["DELETE"])
@require_admin
def delete_annonce(annonce_id):
user = current_user()
try:
annonce_service.delete_annonce(_db(), annonce_id, user["sub"], client_ip())
except AnnonceNotFoundError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"status": "deleted"})

86
web/routes/auth.py Normal file
View File

@@ -0,0 +1,86 @@
"""
routes/auth.py — Flux d'authentification OIDC (Authorization Code Flow) avec
Keycloak, via Authlib. 7 étapes documentées dans la rétro-documentation
(RD-3) et le journal de bord :
1. Détection de l'absence de session (décorateur / vérification session)
2. Génération de l'URL d'autorisation (+ nonce anti-rejeu)
3. Redirection de l'utilisateur vers Keycloak
4. Retour sur /auth avec le code d'autorisation
5. Échange du code contre un token (authorize_access_token)
6. Vérification du token (signature, nonce, expiration — parse_id_token)
7. Création de la session applicative Flask
"""
import uuid
from flask import Blueprint, redirect, url_for, session
from config import Config
from services import audit_service
from services.auth_service import client_ip
def register_oauth(oauth):
"""Enregistre le client OIDC Keycloak auprès d'Authlib (appelé depuis app.py)."""
return oauth.register(
name="keycloak",
client_id=Config.OIDC_CLIENT_ID,
client_secret=Config.OIDC_CLIENT_SECRET,
server_metadata_url=Config.OIDC_DISCOVERY_URL,
client_kwargs={"scope": "openid profile email"},
)
def init_auth_routes(oauth):
# Le blueprint est créé ICI (et non au niveau du module) afin que
# chaque appel de create_app() (production, ou une fois par test en
# environnement pytest) produise un blueprint neuf. Un blueprint créé
# au niveau module serait décoré une seule fois puis réutilisé tel
# quel, ce que Flask refuse dès le second register_blueprint().
bp = Blueprint("auth", __name__)
keycloak = register_oauth(oauth)
@bp.route("/login")
def login():
# Étape 2 : génération de l'URL d'autorisation avec un nonce
nonce = uuid.uuid4().hex
session["nonce"] = nonce
redirect_uri = url_for("auth.auth_callback", _external=True, _scheme="https")
# Étape 3 : redirection vers Keycloak
return keycloak.authorize_redirect(redirect_uri, nonce=nonce)
@bp.route("/auth")
def auth_callback():
db = _get_db()
# Étape 5 : échange du code contre un token
token = keycloak.authorize_access_token()
nonce = session.pop("nonce", None)
# Étape 6 : vérification (signature + nonce) du token
userinfo = keycloak.parse_id_token(token, nonce=nonce)
# Étape 7 : création de la session applicative
session["user"] = userinfo
session["id_token"] = token.get("id_token")
audit_service.log(db, "LOGIN", None, "SUCCESS", userinfo.get("sub"), client_ip())
return redirect("/")
@bp.route("/logout")
def logout():
db = _get_db()
user = session.get("user") or {}
id_token = session.get("id_token")
audit_service.log(db, "LOGOUT", None, "SUCCESS", user.get("sub"), client_ip())
session.clear()
return redirect(
f"{Config.OIDC_LOGOUT_URL}"
f"?post_logout_redirect_uri={Config.PUBLIC_BASE_URL}"
f"&id_token_hint={id_token}"
)
return bp
def _get_db():
from flask import current_app
return current_app.config["DB"]

55
web/routes/dashboard.py Normal file
View File

@@ -0,0 +1,55 @@
"""routes/dashboard.py — page d'accueil + API des widgets (layout)."""
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for
from services import block_service
from services.auth_service import current_user, is_admin, is_authenticated, require_admin
bp = Blueprint("dashboard", __name__)
def _db():
return current_app.config["DB"]
@bp.route("/")
def index():
if not is_authenticated():
return redirect(url_for("auth.login"))
return render_template("view/index.html", user=current_user())
@bp.route("/api/is_admin")
def api_is_admin():
return jsonify({"admin": is_admin()})
@bp.route("/api/layout", methods=["GET"])
def get_layout():
return jsonify(block_service.list_blocks(_db()))
@bp.route("/api/layout/save", methods=["POST"])
@require_admin
def save_layout():
block_service.save_layout(_db(), request.json or [])
return jsonify({"status": "saved"})
@bp.route("/api/block/add", methods=["POST"])
@require_admin
def add_block():
data = request.json or {}
block = block_service.add_block(
_db(), data.get("type"), data.get("column", "center"), data.get("data", {})
)
return jsonify(block)
@bp.route("/api/block/<int:block_id>", methods=["DELETE"])
@require_admin
def delete_block(block_id):
rows = block_service.delete_block(_db(), block_id)
if rows:
return jsonify({"status": "deleted"})
return jsonify({"error": "not found"}), 404

44
web/routes/evenements.py Normal file
View File

@@ -0,0 +1,44 @@
"""routes/evenements.py — API CRUD du module Planning (EXF-09, EXF-10)."""
from flask import Blueprint, current_app, jsonify, request
from services import evenement_service
from services.auth_service import client_ip, current_user, require_admin, require_login
bp = Blueprint("evenements", __name__, url_prefix="/api/evenements")
def _db():
return current_app.config["DB"]
@bp.route("", methods=["GET"])
@require_login
def list_evenements():
return jsonify(evenement_service.list_evenements(_db()))
@bp.route("", methods=["POST"])
@require_admin
def create_evenement():
data = request.json or {}
user = current_user()
try:
new_id = evenement_service.create_evenement(
_db(), data.get("titre"), data.get("description"),
data.get("date_debut"), data.get("date_fin"), user["sub"], client_ip(),
)
except evenement_service.ValidationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"id": new_id}), 201
@bp.route("/<int:evenement_id>", methods=["DELETE"])
@require_admin
def delete_evenement(evenement_id):
user = current_user()
try:
evenement_service.delete_evenement(_db(), evenement_id, user["sub"], client_ip())
except LookupError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"status": "deleted"})

59
web/routes/fichiers.py Normal file
View File

@@ -0,0 +1,59 @@
"""routes/fichiers.py — API upload/download du module Fichiers (EXF-05 à EXF-08)."""
from flask import Blueprint, current_app, jsonify, request, send_file
from services import fichier_service
from services.auth_service import client_ip, current_user, is_admin, require_login
bp = Blueprint("fichiers", __name__, url_prefix="/api/fichiers")
def _db():
return current_app.config["DB"]
@bp.route("", methods=["GET"])
@require_login
def list_fichiers():
return jsonify(fichier_service.list_fichiers(_db()))
@bp.route("/upload", methods=["POST"])
@require_login
def upload():
user = current_user()
try:
fichier_id = fichier_service.handle_upload(
_db(), request.files.get("file"), user["sub"], client_ip()
)
except fichier_service.FileValidationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"id": fichier_id}), 201
@bp.route("/<int:fichier_id>/download", methods=["GET"])
@require_login
def download(fichier_id):
user = current_user()
try:
path, original_name = fichier_service.prepare_download(
_db(), fichier_id, user["sub"], is_admin(user), client_ip()
)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
except PermissionError:
return jsonify({"error": "forbidden"}), 403
return send_file(path, as_attachment=True, download_name=original_name)
@bp.route("/<int:fichier_id>", methods=["DELETE"])
@require_login
def delete(fichier_id):
user = current_user()
try:
fichier_service.delete_fichier(_db(), fichier_id, user["sub"], is_admin(user), client_ip())
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
except PermissionError:
return jsonify({"error": "forbidden"}), 403
return jsonify({"status": "deleted"})

46
web/routes/rgpd.py Normal file
View File

@@ -0,0 +1,46 @@
"""
routes/rgpd.py — Droit à l'effacement RGPD (art. 17) : anonymisation des
données d'un utilisateur (identifié par son `sub` OIDC Keycloak).
Réservé aux administrateurs (@require_admin), au même titre que les autres
routes de modification (annonces, fichiers, blocks) — cf. auth_service.py.
"""
from flask import Blueprint, current_app, jsonify
from config import Config
from services import rgpd_service
from services.auth_service import client_ip, current_user, require_admin
bp = Blueprint("rgpd", __name__, url_prefix="/api/admin/users")
def _db():
return current_app.config["DB"]
def _pepper():
return getattr(Config, "RGPD_ANON_PEPPER", None) or Config.SECRET_KEY
@bp.route("/<sub>/data", methods=["DELETE"])
@require_admin
def anonymize_user_data(sub):
actor = current_user()
try:
result = rgpd_service.anonymize_user(
_db(), sub, _pepper(), actor.get("sub"), client_ip()
)
except rgpd_service.InvalidSubError as exc:
return jsonify({"error": str(exc)}), 400
if not result["anonymized"]:
return jsonify({"error": "aucune donnée trouvée pour cet utilisateur"}), 404
return jsonify(
{
"status": "anonymized",
"pseudonym": result["pseudonym"],
"rows_updated": result["rows"],
}
)