transfer from private server/git server
This commit is contained in:
6
web/routes/__init__.py
Normal file
6
web/routes/__init__.py
Normal 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
62
web/routes/annonces.py
Normal 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
86
web/routes/auth.py
Normal 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
55
web/routes/dashboard.py
Normal 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
44
web/routes/evenements.py
Normal 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
59
web/routes/fichiers.py
Normal 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
46
web/routes/rgpd.py
Normal 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"],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user