Aller au contenu

Forge - Référence API et CLI

Accueil Retour

Cette page décrit l'API publique actuelle de Forge 1.5.0. Elle est organisée comme un index interactif : cliquez sur un élément pour afficher les détails, les signatures et les exemples.

Pour les flux guidés, voir aussi le guide de démarrage, le CRUD explicite et l'architecture des entités.

Schéma complet

Voir le schéma complet
flowchart LR
    CLI["CLI forge"] --> Project["Projet Forge"]
    CLI --> EntityGen["Génération entités"]
    CLI --> CrudGen["Génération CRUD"]
    CLI --> DbTools["db:init / db:apply"]
    CLI --> Starters["starter:list / starter:build"]

    Project --> CoreConfig["core.forge"]
    Project --> Router["core.http.router"]
    Project --> App["core.application"]
    Project --> Templates["core.templating + integrations.jinja2"]
    Project --> Controllers["core.mvc.controller"]
    Project --> Forms["core.forms"]
    Project --> Security["core.security"]
    Project --> Database["core.database"]
    Project --> Uploads["core.uploads"]
    Project --> Entities["mvc/entities"]

    App --> Router
    App --> Security
    Router --> Request["core.http.request"]
    Router --> Response["core.http.response"]
    Controllers --> Response
    Controllers --> Templates
    Controllers --> Forms
    Controllers --> Security
    Controllers --> Database
    Forms --> Validation["core.validation"]
    EntityGen --> Entities
    Entities --> GeneratedSql["*.sql / relations.sql"]
    Entities --> GeneratedBase["*_base.py"]
    Entities --> ManualClass["classe métier manuelle"]
    Database --> MariaDB["MariaDB"]
    DbTools --> MariaDB
    Uploads --> Storage["storage/uploads"]

Index API

Chaque entrée ci-dessous est repliée par défaut. Ouvrez uniquement la partie utile pour lire le détail de l'API sans quitter l'index.

core.forge - Configuration centrale

core.forge est le registre de configuration du noyau. Les modules core/ lisent leurs paramètres avec get().

Fonctions

API Signature Description
configure configure(**kwargs) -> None Configure Forge. Lève KeyError si une clé est inconnue.
get get(key: str) -> object Retourne une valeur. Lève KeyError si la clé est inconnue.

Clés disponibles

Clé Défaut Description
app_name "Forge" Nom de l'application
app_env "dev" Environnement actif
views_dir mvc/views Dossier des templates
sql_dir mvc/models/sql Dossier des requêtes SQL
upload_root storage/uploads Racine des uploads
upload_max_size 5242880 Taille maximale d'un fichier
upload_allowed_extensions ["jpg", "jpeg", "png", "webp", "pdf"] Extensions autorisées
upload_allowed_mime_types ["image/jpeg", "image/png", "image/webp", "application/pdf"] MIME autorisés
mail_host "" Hôte SMTP
mail_port 587 Port SMTP
mail_username "" Utilisateur SMTP
mail_password "" Mot de passe SMTP
mail_from "" Expéditeur par défaut
mail_use_tls False Active STARTTLS
mail_use_ssl False Utilise SMTP_SSL
mail_timeout 10 Timeout SMTP en secondes
mail_enabled True Active ou désactive l'envoi réel
db_host "localhost" Hôte MariaDB
db_port 3306 Port MariaDB
db_name "forge_db" Nom de la base
db_user "root" Utilisateur MariaDB
db_password "" Mot de passe
db_pool_size 5 Taille du pool
css_visible "block" Classe pagination visible
css_hidden "hidden" Classe pagination masquée
router None Routeur actif pour url_for()

Les chemins relatifs views_dir, sql_dir et upload_root sont résolus depuis la racine du projet.

Exemple

from core.forge import configure, get

configure(
    app_name="Carnet",
    app_env="dev",
    db_name="carnet_dev",
    db_user="carnet_app",
)

print(get("app_name"))
core.http.request - Requête HTTP

Classes

API Description
Request Représente une requête HTTP entrante.
UploadedFile Fichier reçu dans un formulaire multipart/form-data.
RequestEntityTooLarge Exception levée si le body dépasse la limite autorisée.

Request

Attributs principaux :

Attribut Type Description
original_method str Méthode reçue avant override.
method str Méthode effective. POST peut devenir PUT, PATCH ou DELETE via _method.
path str Chemin sans query string.
headers HTTPMessage En-têtes HTTP.
params dict[str, list[str]] Query string parsée avec parse_qs.
body dict[str, list[str]] Formulaire parsé.
json_body dict JSON parsé si Content-Type vaut application/json.
files dict[str, UploadedFile] Fichiers uploadés.
ip str Adresse IP client.
route_params dict[str, str] Paramètres injectés par le routeur.

Exemple

def create_contact(request):
    name = request.body.get("name", [""])[0]
    avatar = request.files.get("avatar")

    if avatar:
        print(avatar.filename, avatar.content_type, avatar.size)

    return Response(201, f"Contact {name} créé")

Notes :

  • GET, HEAD et OPTIONS ne lisent pas de body.
  • La limite de body par défaut est 1_048_576 octets.
  • Les uploads multipart utilisent au minimum 1 MiB et ajoutent une marge de 65_536 octets à upload_max_size.
core.http.response - Réponse HTTP

Classe

Response(status=200, body=b"", content_type="text/html; charset=utf-8", headers=None)
Attribut Type Description
status int Code HTTP.
body bytes Corps de réponse. Une str est encodée en UTF-8.
content_type str Type MIME.
headers dict En-têtes additionnels.

Exemples

from core.http.response import Response

def health(request):
    return Response(200, "OK", content_type="text/plain; charset=utf-8")
return Response(
    302,
    "",
    headers={"Location": "/login"},
)
core.http.helpers - Helper HTML

Fonction

API Signature Description
html html(template, status=200, context=None, raw=False) -> Response Rend un template et retourne une Response.

raw=True lit le fichier depuis views_dir sans passer par Jinja2. C'est utile pour des fichiers contenant des accolades ou des fragments non templatisés.

Exemple

from core.http.helpers import html

def dashboard(request):
    return html("dashboard/index.html", context={"title": "Tableau de bord"})
core.http.router - Router, routes et URL nommées

Classes et constantes

API Description
Router Registre de routes.
RouteEntry Route compilée avec méthode, pattern, handler et options.
RouteGroup Groupe de routes partageant un préfixe et des options.
SAFE_METHODS {"GET", "HEAD", "OPTIONS"}
UNSAFE_METHODS {"POST", "PUT", "PATCH", "DELETE"}

Méthodes publiques de Router

API Signature Description
add add(method, pattern, handler, name=None, public=False, csrf=True, api=False) -> Router Ajoute une route.
group group(prefix, public=False, csrf=True, api=False) -> RouteGroup Crée un groupe utilisable avec with.
match match(method, path) -> tuple[RouteEntry, dict] \| None Retourne la route et ses paramètres.
resolve resolve(method, path) -> tuple[handler, dict] \| None Retourne le handler et ses paramètres.
is_public is_public(path, method=None) -> bool Indique si une route est publique.
iter_routes iter_routes() -> list[RouteEntry] Liste les routes déclarées.
url_for url_for(name, **params) -> str Génère une URL depuis une route nommée.

Patterns supportés

Pattern URL Paramètres
/contacts /contacts {}
/contacts/{id} /contacts/42 {"id": "42"}
/api/{version}/contacts/{id} /api/v1/contacts/5 {"version": "v1", "id": "5"}

Exemples

from core.http.router import Router

router = Router()
router.add("GET", "/", home, name="home", public=True, csrf=False)
router.add("POST", "/contacts", create_contact, name="contacts.create")

url = router.url_for("contacts.create")
with router.group("/admin", public=False) as admin:
    admin.add("GET", "", admin_index, name="admin.index")
    admin.add("POST", "/users", create_user, name="admin.users.create")

Une route POST, PUT, PATCH ou DELETE est protégée par CSRF par défaut, sauf si csrf=False.

core.application - Dispatch applicatif

Classe

Application(router, middlewares=None, login_url="/login", csrf_middleware=None)

Méthode

API Signature Description
dispatch dispatch(request) -> Response Résout la route, applique CSRF et middlewares, appelle le handler.

Flux réel

  1. Recherche de la route.
  2. Si aucune route ne correspond, retourne errors/404.html.
  3. Injection de request.route_params.
  4. Vérification CSRF pour les méthodes unsafe si la route le demande.
  5. Exécution des middlewares pour les routes non publiques.
  6. Appel du handler.
  7. En cas d'exception non gérée, retourne errors/500.html.

Exemple middleware

from core.http.response import Response

class AdminOnly:
    def check(self, request):
        if not request.headers.get("X-Admin"):
            return Response(403, "Interdit")
        return None
core.templating et integrations.jinja2 - Templates

API

Élément Signature Description
template_manager singleton TemplateManager Renderer actif.
TemplateManager.register register(renderer) -> None Enregistre ou remplace le renderer.
TemplateManager.render render(template, context) -> str Rend un template. Lève RuntimeError sans renderer.
Jinja2Renderer Jinja2Renderer(views_dir: str) Renderer Jinja2 avec autoescape HTML.

Jinja2Renderer expose le helper global url_for(name, **params), branché sur core.forge.get("router").

Exemples

from core.forge import configure, get
from core.templating.manager import template_manager
from integrations.jinja2.renderer import Jinja2Renderer

configure(views_dir="mvc/views", router=router)
template_manager.register(Jinja2Renderer(get("views_dir")))
<a href="{{ url_for('contacts.show', id=contact.ContactId) }}">
  Voir
</a>
core.security - Sessions, auth, CSRF et mots de passe

Sessions mémoire

API Description
creer_session() Crée une session et retourne son identifiant.
get_session(session_id) Retourne la session ou None.
supprimer_session(session_id) Supprime une session.
regenerer_session(old_session_id) Crée une nouvelle session en copiant les données.
authentifier_session(session_id, utilisateur) Marque une session comme authentifiée et retourne un nouveau session_id.
get_session_id(request) Lit le cookie session_id.
est_authentifie(request) Vérifie l'authentification.
get_utilisateur(request) Retourne l'utilisateur de session.
utilisateur_a_role(request, role) Vérifie un rôle.
set_flash(session_id, message, level="success") Stocke un message flash.
get_flash(session_id) Lit et consomme le flash.

Le stockage de session est en mémoire. Il convient au développement, à la pédagogie et aux petites applications mono-processus. Les sessions sont perdues au redémarrage, ne sont pas partagées entre workers et ne conviennent pas au scaling horizontal.

Middleware et décorateurs

API Description
AuthMiddleware(login_url="/login") Redirige vers /login si la session n'est pas authentifiée.
CsrfMiddleware(field_name="csrf_token", header_name="X-CSRF-Token") Vérifie le token CSRF sur les routes unsafe.
require_auth Décorateur de handler authentifié.
require_csrf Décorateur de handler avec CSRF.
require_role(role) Décorateur de handler limité à un rôle.

Mots de passe et limitation

API Description
hacher_mot_de_passe(password) Hash PBKDF2-HMAC-SHA256 avec sel.
verifier_mot_de_passe(password, stored) Vérifie un mot de passe.
enregistrer_tentative(ip) Enregistre une tentative par IP.
est_limite(ip) Limite après 5 tentatives dans une fenêtre de 60 secondes.

Exemples

from core.security.session import authentifier_session, creer_session, get_session

session_id = creer_session()
session_id = authentifier_session(session_id, {
    "UtilisateurId": 1,
    "Login": "roger",
    "roles": ["admin"],
})

session = get_session(session_id)
print(session["utilisateur"]["login"])
from core.security.decorators import require_role

@require_role("admin")
def admin_dashboard(request):
    return self.render("admin/dashboard.html", request=request)
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
core.forms - Formulaires et champs

Classe Form

API Description
Form(data=None, **options) Instancie un formulaire.
Form.from_request(request, **options) Crée un formulaire depuis request.body et request.files.
is_bound Indique si le formulaire a reçu des données.
errors Erreurs par champ.
non_field_errors Erreurs globales.
field_errors(name) Erreurs d'un champ.
value(name, default="") Valeur nettoyée ou brute.
error(name) Première erreur d'un champ.
has_error(name) Indique si un champ a une erreur.
add_error(name, message) Ajoute une erreur.
is_valid() Lance la validation et retourne un booléen.
clean() Hook de validation globale à surcharger.
context() Contexte prêt pour le template.

Champs

Champ Valeur nettoyée Usage
StringField str Texte, longueur, regex.
IntegerField int Entiers, min, max.
DecimalField Decimal Valeurs décimales précises.
BooleanField bool Checkbox.
ChoiceField str Valeur parmi une liste.
RelatedIdsField list[int] Liste d'identifiants reliés.
EmailField str Adresse email (max 254 car., RFC 5321).
PhoneField str Numéro de téléphone français (format local ou +33).
UrlField str URL http:// ou https:// (max 2048 car.).
DateField datetime.date Date HTML natif, format strict YYYY-MM-DD.
DateTimeField datetime.datetime Date/heure HTML natif, format YYYY-MM-DDTHH:MM ou YYYY-MM-DDTHH:MM:SS.
TextAreaField str Texte long, rendu HTML <textarea>, support min_length/max_length, render() avec échappement XSS.
RelationField str ou int Relation many_to_one, validée par liste de choix, destinée aux clés étrangères.
SlugField str Slug URL-safe en minuscules, chiffres et tirets (max 120 car. par défaut).
FileField UploadedFile Fichier uploadé, validation extension, taille maximale et type MIME optionnel.
ImageField UploadedFile Image uploadée, extensions et MIME image contrôlés, sans sauvegarde automatique.

Exemple

from core.forms import Form, StringField, IntegerField

class ContactForm(Form):
    prenom = StringField(required=True, max_length=100)
    age = IntegerField(required=False, min_value=0)

    def clean(self):
        if self.cleaned_data.get("prenom") == "admin":
            self.add_error("prenom", "Ce prénom est réservé.")


form = ContactForm.from_request(request)
if form.is_valid():
    contact.prenom = form.cleaned_data["prenom"]

DecimalField retourne un Decimal. Les CRUD générés gardent la doctrine Forge actuelle : les champs JSON de type Python float restent convertis en float.

Champs de formulaire avancés — notes d'usage

  • FileField / ImageField — valident uniquement les métadonnées (extension, taille, MIME). Ils ne sauvegardent rien et ne créent aucune entrée en base. La persistance est assurée par save_upload + attach_media_to_entity appelés depuis le contrôleur généré par make:crud.
  • RelationField — hérite de ChoiceField. Ne fait aucune requête SQL ; la liste de choix est fournie par le contrôleur ou le formulaire via options.
  • DateField / DateTimeField — retournent des objets Python typés (datetime.date / datetime.datetime). make:crud génère ces champs directement pour les colonnes DATE / DATETIME.
  • SlugField — valide le format slug, ne slugifie pas automatiquement. Les caractères accentués, majuscules et underscores sont refusés.
  • TextAreaField — fournit un helper render() pour générer une balise <textarea> avec échappement XSS. Le rendu principal reste assuré par les templates Jinja2.
core.mvc.controller - BaseController

Méthodes publiques

API Description
render(template, status=200, context=None, base="layouts/base.html", request=None, raw=False) Rend une vue Jinja2. Injecte un token CSRF si request est fourni et si raw=False. Le paramètre base est conservé pour compatibilité historique mais n'enveloppe plus le template.
redirect(location, status=302) Redirection simple.
redirect_with_flash(request, location, type_, message, status=302) Flash puis redirection.
redirect_to_route(name, status=302, **params) Redirection vers une route nommée.
not_found() Réponse 404.
bad_request() Réponse 400.
forbidden() Réponse 403.
validation_error(context=None) Réponse 422.
server_error() Réponse 500.
set_flash(request, type_, message) Ajoute un flash.
csrf_token(request) Retourne le token CSRF de session.
current_user(request) Retourne l'utilisateur courant.
include(template, context=None) Rend un fragment.
json(data, status=200) Retourne du JSON.
body(request) Aplatit request.body.
json_body(request) Retourne request.json_body.
render_form(template, form, status=200, context=None, request=None) Rend un template avec contexte de formulaire.
form_context(form) Retourne form.context().

Exemple

from core.mvc.controller import BaseController

class ContactController(BaseController):
    def index(self, request):
        return self.render(
            "contacts/index.html",
            context={"contacts": []},
            request=request,
        )

    def api_index(self, request):
        return self.json({"contacts": []})

Dans les CRUD générés, les identifiants de route sont parsés avant usage. Une valeur invalide comme /contacts/abc retourne une réponse de type not_found() au lieu de provoquer une erreur serveur.

core.mvc.model - Validation MVC minimale

API

Élément Description
Validator Validateur simple avec erreurs par champ.
DoublonError Exception métier pour signaler un doublon.

Validator

Méthode Description
required(field, value, message=None) Vérifie une présence.
max_length(field, value, max_len, message=None) Vérifie une longueur maximale.
add_error(field, message) Ajoute une erreur.
is_valid() Retourne True sans erreur.
errors Dictionnaire des erreurs.

Exemple

from core.mvc.model.validator import Validator

validator = Validator()
validator.required("Email", email)
validator.max_length("Nom", nom, 100)

if not validator.is_valid():
    return self.validation_error({"errors": validator.errors})
core.mvc.view - Pagination

Classe

Pagination(request, total, par_page)

Attributs et méthodes

API Description
total Nombre total d'éléments.
par_page Taille de page.
nb_pages Nombre de pages.
page Page courante, lue depuis ?page=.
limit Limite SQL.
offset Offset SQL.
pages Liste des numéros de page.
context Contexte avec classes CSS css_visible / css_hidden.
to_dict() Version dictionnaire avec booléens has_prev et has_next.

Exemple

pagination = Pagination(request, total=125, par_page=20)
rows = fetch_all("SELECT * FROM Contact LIMIT ? OFFSET ?", [
    pagination.limit,
    pagination.offset,
])

return self.render("contacts/index.html", {
    "contacts": rows,
    "pagination": pagination.context,
})
mvc.helpers - Fragments HTML utiles aux vues

API

Élément Signature Description
render_flash_html render_flash_html(request) -> str Lit le flash de session, le consomme et rend partials/flash.html.
render_errors_html render_errors_html(errors: list[str]) -> str Convertit une liste d'erreurs en <ul> HTML échappée.

Niveaux de flash

Niveau Classes CSS
success bg-green-100 border-green-400 text-green-800
error bg-red-100 border-red-400 text-red-800
warning bg-gray-100 border-gray-300 text-gray-800
info bg-gray-100 border-gray-300 text-gray-800

Exemples

from mvc.helpers.flash import render_flash_html

flash = render_flash_html(request)
return self.render("contacts/index.html", {
    "flash": flash,
}, request=request)
{{ flash | safe }}
from mvc.helpers.form_errors import render_errors_html

errors_html = render_errors_html(["Le nom est obligatoire."])
core.database - MariaDB, transactions et SQL loader

Connexions

API Description
get_connection() Retourne une connexion du pool MariaDB.
close_connection(conn) Rend ou ferme une connexion.

La connexion lit db_host, db_port, db_name, db_user, db_password et db_pool_size dans core.forge.

Helpers SQL

API Description
fetch_one(query, params=None, tx=None) Retourne une ligne ou None.
fetch_all(query, params=None, tx=None) Retourne toutes les lignes.
execute(query, params=None, tx=None) Exécute une requête et retourne le nombre de lignes affectées.
insert(query, params=None, tx=None) Exécute un insert et retourne le dernier id.

Sans transaction explicite, chaque helper ouvre une connexion et commit/rollback lui-même.

Transactions

API Description
Transaction Transaction explicite avec connexion dédiée.
transaction() Context manager transactionnel.

SQL loader

API Description
charger_queries(nom_fichier) Charge un fichier depuis {sql_dir}/{app_env}/ et le met en cache.

Le cache est invalidé si la taille ou mtime_ns du fichier change.

Exemples

from core.database.db import fetch_all

contacts = fetch_all("SELECT * FROM Contact ORDER BY Nom")
from core.database.transaction import transaction
from core.database.db import execute, insert

with transaction() as tx:
    contact_id = insert(
        "INSERT INTO Contact (Nom) VALUES (?)",
        ["Dupont"],
        tx=tx,
    )
    execute(
        "INSERT INTO Log (Message) VALUES (?)",
        [f"Contact {contact_id} créé"],
        tx=tx,
    )
from core.database.sql_loader import charger_queries

queries = charger_queries("contacts.sql")
rows = fetch_all(queries["select_all"])

En production, l'utilisateur applicatif MariaDB doit rester limité aux droits runtime (SELECT, INSERT, UPDATE, DELETE). Les droits de migration (CREATE, ALTER, DROP, INDEX, REFERENCES) doivent être réservés à un utilisateur d'administration ou de migration.

Table technique forge_migrations

forge db:init prépare aussi la table forge_migrations dans la base du projet avec CREATE TABLE IF NOT EXISTS. Cette table est le socle des migrations SQL versionnées de Forge.

Elle stocke la version, le nom, le fichier, le checksum, la date d'application et le temps d'exécution d'une migration. Les migrations restent des fichiers SQL lisibles dans mvc/migrations/.

Le workflow complet est documenté dans Migrations SQL.

forge migration:status

forge migration:status compare les fichiers SQL locaux avec les lignes de forge_migrations. Elle est en lecture seule et affiche APPLIED, PENDING, CHANGED ou MISSING.

forge migration:make

forge migration:make <nom> crée une migration SQL vide dans mvc/migrations/. La commande accepte aussi des sources explicites :

  • --from-entity <Entite> : copie le SQL généré d'une entité ;
  • --from-entities : concatène tous les SQL d'entités ;
  • --from-diff <Entite> : génère seulement les changements prudents du diff.

Forge n'applique jamais une migration au moment de sa création.

forge migration:diff

forge migration:diff --entity <Entite> compare le JSON canonique d'une entité avec les colonnes réelles de la table MariaDB. La commande est strictement en lecture seule.

forge migration:apply

forge migration:apply applique uniquement les migrations locales en statut PENDING, dans l'ordre croissant de version. Chaque fichier SQL reste lisible dans mvc/migrations/.

La commande refuse l'exécution si une migration CHANGED ou MISSING existe. Elle arrête immédiatement au premier échec SQL et n'enregistre pas la migration échouée dans forge_migrations.

Front et CSS

Tailwind est le framework CSS officiel de Forge pour les templates générés. Forge ne maintient pas plusieurs variantes Bootstrap, Bulma, Foundation ou autres frameworks CSS.

Le fichier source Tailwind est static/src/input.css. Le fichier compilé servi par l'application est static/tailwind.css.

npm install
npm run build:css

Node.js/npm est nécessaire uniquement pour recompiler le CSS, pas pour exécuter le serveur Python Forge lorsque static/tailwind.css existe déjà.

Voir aussi : Front et CSS.

core.uploads - Uploads et stockage

Gestionnaire

API Description
SavedUpload Résultat d'un upload sauvegardé.
upload_root() Racine des fichiers uploadés.
save_upload(file, category="documents", variants=False) Valide puis sauvegarde un fichier.
delete_media_file(path, variants=False) Supprime un fichier média relatif, et ses variantes si demandé.
serve_media_file(path) Retourne une réponse HTTP pour un fichier média relatif sûr.
attach_media_to_entity(saved_upload, entity_name=..., entity_id=...) Crée une ligne media depuis un upload déjà stocké.
create_media_record(...) Insère une ligne dans la table media.
get_media_record(media_id) Récupère un média par identifiant.
list_media_for_entity(entity_name, entity_id, role=None) Liste les médias liés à une entité.
delete_media_record(media_id) Supprime uniquement la ligne SQL media.
delete_media(media_id, delete_files=False, variants=True) Supprime une ligne media et, explicitement, ses fichiers.
update_media_alt_text(media_id, alt_text) Met à jour le texte alternatif d'un média. Chaîne vide → None.
update_media_position(media_id, position) Met à jour la position d'un média dans une galerie.
media_url(path) Construit une URL locale /media/... depuis un chemin média sûr.
get_media_gallery(entity_name, entity_id, role="gallery") Retourne une galerie ordonnée enrichie avec URLs et variantes.
get_cover_media(entity_name, entity_id, role="cover") Retourne l'image de couverture d'une entité, ou None.
delete_upload(path_or_saved_upload) Supprime un fichier.
get_upload_path(relative_path) Résout un chemin d'upload.
normalize_media_path(path) Normalise un chemin média relatif à storage/uploads.
media_path_to_storage_path(path, root=...) Résout un chemin média relatif sous la racine d'upload.
generate_image_variants(path, root=...) Génère les variantes medium et thumbnail d'une image.

Stockage

API Description
ensure_upload_dirs() Crée les dossiers nécessaires.
safe_category(category) Nettoie un nom de catégorie.
secure_filename(filename) Nettoie un nom de fichier.
generate_unique_filename(filename) Génère un nom unique.
category_dir(category) Retourne le dossier de catégorie.
save_bytes(data, filename, category) Sauvegarde des octets.
delete_file(relative_path) Supprime un fichier.

Validation et exceptions

API Description
validate_upload_metadata(filename, size, content_type) Valide nom, taille, extension et MIME.
UploadError Exception de base.
UploadTooLargeError Fichier trop volumineux.
UploadInvalidExtensionError Extension refusée.
UploadInvalidMimeTypeError MIME refusé.
UploadStorageError Erreur d'écriture ou suppression.

Exemple

from core.uploads.manager import save_upload

def upload_avatar(request):
    file = request.files.get("avatar")
    if not file:
        return self.bad_request()

    saved = save_upload(file, category="avatars")
    return self.json({
        "filename": saved.filename,
        "path": saved.path,
        "size": saved.size,
    })

Pour une image, les variantes restent optionnelles :

saved = save_upload(file, category="images", variants=True)

saved.path
# "images/photo.png"

saved.variants
# {
#     "medium": "images/medium/photo.png",
#     "thumbnail": "images/thumbnail/photo.png",
# }

Sans variants=True, save_upload() conserve le comportement historique et ne génère que le fichier original. L'option est réservée à category="images".

Supprimer un fichier média :

from core.uploads import delete_media_file

delete_media_file("images/photo.png", variants=True)
# {
#     "images/photo.png": True,
#     "images/medium/photo.png": True,
#     "images/thumbnail/photo.png": True,
# }

Par défaut, seule la cible est supprimée. Avec variants=True, Forge calcule les chemins medium et thumbnail avec la même convention que la génération des variantes. Les chemins doivent rester relatifs à storage/uploads; les chemins absolus, URL et traversals (..) sont refusés.

Servir un fichier média :

/media/images/photo.png
/media/images/medium/photo.png
/media/documents/contrat.pdf

La route /media/<chemin-relatif> lit uniquement dans storage/uploads/. Un chemin dangereux ou absent retourne 404. Le Content-Type est déduit avec la bibliothèque standard mimetypes, avec fallback application/octet-stream.

Créer et lister des métadonnées Media :

from core.uploads import create_media_record, list_media_for_entity

media_id = create_media_record(
    entity_name="hebergement",
    entity_id=12,
    path="images/photo.png",
    original_name="photo.png",
    mime_type="image/png",
    role="gallery",
    position=1,
)

medias = list_media_for_entity("hebergement", 12)

create_media_record() normalise path, renseigne role="default" et position=0 si rien n'est fourni, puis insère une ligne SQL explicite. list_media_for_entity() filtre par entity_name, entity_id, optionnellement par role, et trie par position ASC, puis id ASC.

Attacher un upload déjà stocké :

from core.uploads import attach_media_to_entity, save_upload

saved = save_upload(file, category="images", variants=True)

media_id = attach_media_to_entity(
    saved,
    entity_name="hebergement",
    entity_id=12,
    role="gallery",
    position=1,
    alt_text="Vue extérieure",
)

save_upload() écrit le fichier. attach_media_to_entity() crée seulement la ligne SQL Media avec path, original_name, mime_type et size issus de l'upload. Les variantes restent celles de l'upload ; aucune interface CRUD n'est générée ici.

Supprimer un média complet :

from core.uploads import delete_media

result = delete_media(
    media_id,
    delete_files=True,
    variants=True,
)

delete_media_record() supprime seulement la ligne SQL. delete_media_file() supprime seulement les fichiers. delete_media() combine les deux, mais garde delete_files=False par défaut pour éviter une suppression physique accidentelle.

Récupérer une galerie ordonnée :

from core.uploads import get_media_gallery

gallery = get_media_gallery("hebergement", 12)

Par défaut, une galerie utilise role="gallery". Chaque élément contient path, url, et, pour les images, medium_url et thumbnail_url. Les documents non-image gardent medium_url et thumbnail_url à None. Forge ne génère pas de galerie HTML dans cette API.

Récupérer l'image de couverture :

from core.uploads import get_cover_media

cover = get_cover_media("hebergement", 12)

La convention est role="cover". L'élément retourné utilise la même structure que la galerie, avec url, medium_url et thumbnail_url pour les images. Si aucun cover n'existe, la fonction retourne None. Avec fallback_to_gallery=True, Forge peut retourner la première image de galerie, sans choisir de document non-image et sans modifier la base.

mvc/entities/media - Socle Media v2

Forge fournit une entité canonique Media comme première base de Média v2. Elle décrit uniquement les métadonnées persistables d'un fichier déjà stocké, sans ajouter de galerie, miniature, route publique ou intégration CRUD.

Media.path stocke toujours un chemin relatif normalisé sous storage/uploads, jamais un chemin absolu système. Par exemple :

Media.path = "images/photo.jpg"

désigne le fichier :

storage/uploads/images/photo.jpg

Les chemins absolus, URL et traversals (..) sont refusés par normalize_media_path().

Variantes image

Forge peut générer trois chemins de variantes pour une image stockée :

images/photo.png
images/medium/photo.png
images/thumbnail/photo.png
  • original : fichier conservé tel quel.
  • medium : image redimensionnée dans un maximum de 1280 x 1280.
  • thumbnail : image redimensionnée dans un maximum de 300 x 300.

Les proportions sont conservées. Les formats acceptés sont jpg, jpeg, png et webp. Les chemins retournés restent relatifs à storage/uploads.

L'intégration CRUD complète (formulaires, upload, remplacement, suppression, preview) est disponible via la clé "media" dans entity.json — voir la section Génération CRUD media ci-dessous. Les galeries multiple=true et les permissions média restent à venir.

Dans le flux d'upload générique, save_upload(file, category="images", variants=True) génère medium et thumbnail explicitement. Le chemin saved.path reste celui de l'original ; saved.variants contient uniquement les variantes redimensionnées.

delete_media_file("images/photo.png", variants=True) supprime l'original et les variantes fichier si elles existent. Une variante absente retourne False dans le résultat, sans faire échouer la suppression. La suppression en base de l'entité Media reste à la charge d'un futur ticket.

La route /media/<chemin-relatif> permet de servir ces fichiers sans exposer directement le système de fichiers. Exemple : Media.path = "images/photo.png" devient /media/images/photo.png. Les variantes sont accessibles si elles existent, par exemple /media/images/medium/photo.png.

Les métadonnées SQL sont manipulées avec l'API create_media_record(), get_media_record(), list_media_for_entity() et delete_media_record(). La suppression SQL ne supprime pas les fichiers physiques ; utiliser delete_media_file() séparément lorsque c'est voulu.

attach_media_to_entity() relie un SavedUpload à une entité métier en créant une ligne media. Elle ne déplace pas le fichier, ne régénère pas les variantes et ne déclenche aucune suppression automatique.

delete_media(media_id, delete_files=True, variants=True) supprime d'abord les fichiers demandés, puis la ligne SQL. Si le chemin stocké est dangereux, la suppression est refusée et la ligne SQL n'est pas supprimée silencieusement.

get_media_gallery("hebergement", 12) retourne les médias role="gallery" triés par position, puis id, avec les URLs locales /media/.... Les images reçoivent aussi les URLs medium et thumbnail; les documents non-image n'ont pas de variantes inventées.

get_cover_media("hebergement", 12) retourne le premier média role="cover" trié par position, puis id. Le fallback vers la galerie est optionnel via fallback_to_gallery=True et reste une aide de lecture, sans génération HTML ni modification des enregistrements.

Champs

Champ Rôle
id Clé primaire technique.
entity_name Nom de l'entité applicative liée.
entity_id Identifiant de l'enregistrement applicatif lié.
path Chemin relatif du fichier stocké.
original_name Nom original du fichier.
mime_type Type MIME déclaré ou détecté.
size Taille du fichier en octets.
role Rôle générique du média, par défaut default.
position Ordre d'affichage futur, par défaut 0.
alt_text Texte alternatif optionnel.
created_at Date de création du média.

Les projections standard media.sql et media_base.py sont générées depuis mvc/entities/media/media.json avec le mécanisme habituel forge build:model.

Limites

Les variantes d'images (thumbnail, medium), l'intégration FileField / ImageField dans make:crud, l'upload CRUD et les routes publiques média sont disponibles. Les galeries multiple=true, les permissions média et les règles métier applicatives restent à venir.

core.i18n - Internationalisation

core.i18n fournit une API Python minimale pour traduire des clés depuis un catalogue JSON local.

Le catalogue français est :

translations/fr.json

Il contient des clés génériques plates en notation pointée (common.save, crud.create, validation.required, etc.).

Utilisation

from core.i18n import trans, load_catalog

# Traduction d'une clé (locale fr par défaut)
trans("common.save")                      # → "Enregistrer"
trans("common.save", locale="fr")         # → "Enregistrer"

# Clé absente : la clé est retournée telle quelle
trans("clé.inconnue")                     # → "clé.inconnue"

# Chargement explicite du catalogue
catalog = load_catalog("fr")
catalog = load_catalog("fr", translations_dir="translations")

API disponible

API Description
trans(key, locale=None, translations_dir="translations") Traduit une clé avec fallback. Utilise la langue par défaut si locale est absent. Retourne la clé si introuvable partout.
load_catalog(locale, translations_dir="translations") Charge et valide un catalogue JSON. Lève TranslationCatalogError si absent, invalide ou mal formé.
get_default_locale() Retourne la langue par défaut active ("fr" initialement).
set_default_locale(locale) Change la langue par défaut. Lève I18nError si la valeur est vide ou non-chaîne.
get_fallback_locale() Retourne la locale de fallback active ("fr" initialement).
set_fallback_locale(locale) Change la locale de fallback. Accepte None pour désactiver. Lève I18nError si chaîne vide.
I18nError Exception de base de la brique i18n.
TranslationCatalogError Catalogue absent, JSON invalide, ou structure incorrecte.

Langue par défaut et fallback

La langue par défaut et la langue de fallback sont toutes deux "fr" initialement :

from core.i18n import get_default_locale, set_default_locale
from core.i18n import get_fallback_locale, set_fallback_locale

get_default_locale()          # → "fr"
get_fallback_locale()         # → "fr"
set_default_locale("fr")
set_fallback_locale("fr")     # None pour désactiver le fallback

Elles peuvent aussi être définies au démarrage via le registre Forge :

import core.forge as forge
forge.configure(i18n_default_locale="fr", i18n_fallback_locale="fr")

Comportement de trans() avec fallback

trans("common.save", locale="en")
 1. cherche dans translations/en.json
 2. si clé absente → cherche dans translations/fr.json  (fallback)
 3. si clé absente partout → retourne "common.save"

Le catalogue de la locale demandée doit exister (lève TranslationCatalogError sinon). Le catalogue de fallback absent est ignoré silencieusement.

Utilisation dans les templates Jinja

trans() est disponible comme global Jinja dans tous les templates Forge et utilise la même langue par défaut :

{{ trans("common.save") }}           {# → Enregistrer #}
{{ trans("validation.required") }}   {# → Ce champ est obligatoire. #}
{{ trans("cle.inconnue") }}          {# → cle.inconnue #}
{{ trans("common.save", locale="fr") }}

Commandes CLI

forge i18n:init

Initialise la structure i18n d'un projet Forge :

forge i18n:init

Crée translations/ et translations/fr.json avec le catalogue français minimal si absents. Idempotente : ne modifie rien si les fichiers existent déjà. Ne crée pas en.json ni es.json.

forge i18n:check

Vérifie les catalogues présents dans translations/ :

forge i18n:check

Vérifie :

  • présence de translations/
  • présence de translations/fr.json
  • validité JSON de chaque *.json trouvé
  • chaque catalogue est un objet JSON
  • toutes les clés sont des chaînes non vides
  • toutes les valeurs sont des chaînes non vides
  • les clés utilisent la notation pointée (common.save, crud.create…)
  • aucune clé métier évidente (commune, sejour, hebergement, reservation…)

Ne fait pas :

  • ne crée aucun fichier
  • ne corrige rien automatiquement
  • ne synchronise pas les catalogues

Clés publiques dans translations/fr.json — les générateurs de pages publiques (make:public-page, make:public-list, make:public-show, make:public-form, make:public-contact) utilisent {{ trans('key') }} pour les chaînes génériques. Ces clés sont présentes dans translations/fr.json : public.page.generated, public.list.empty, public.show.back, public.show.not_found, public.form.submit, public.form.success, public.contact.title, public.contact.intro, public.contact.coordinates, public.contact.address, public.contact.email_label, public.contact.phone, public.contact.address_placeholder. Si une clé est absente du catalogue, trans() retourne la clé elle-même — la page reste fonctionnelle. Les noms d'entités et les routes ne sont pas traduits automatiquement.

Exemples de sortie :

[OK]         Dossier translations présent
[OK]         Catalogue fr.json valide — 18 clés vérifiées

En cas d'erreur :

[ERREUR]     translations/fr.json : JSON invalide
[ERREUR]     translations/fr.json : la clé "commonsave" n'utilise pas la notation pointée

Retourne 0 si tout est valide, 1 si au moins une erreur est détectée.

make:crud et i18n

forge make:crud génère des templates qui utilisent les clés i18n génériques via trans() :

Clé Jinja générée Clé catalogue
{{ trans('common.save') }} Enregistrer
{{ trans('common.cancel') }} Annuler
{{ trans('common.back') }} Retour
{{ trans('common.search') }} Rechercher
{{ trans('crud.show') }} Voir
{{ trans('crud.edit') }} Modifier
{{ trans('crud.delete') }} Supprimer
{{ trans('crud.empty') }} Aucun élément à afficher.
{{ trans('crud.actions') }} Actions

Les noms d'entités et de champs (Contact, nom, email…) ne sont pas traduits automatiquement — les traductions métier appartiennent à l'application, pas au core Forge.

translations/fr.json fournit les clés génériques de base. forge i18n:check permet de vérifier les catalogues.

Limites actuelles

  • aucun catalogue translations/en.json ou translations/es.json n'est encore fourni ;
  • aucune synchronisation automatique des clés entre catalogues n'est faite ;
  • les traductions métier (noms d'entités, champs) restent à la charge de l'application.
core.mail - Mail SMTP minimal

core.mail fournit une brique mail générique avec transports interchangeables, rendu de templates et journalisation optionnelle. Elle ne contient pas de logique métier.

API

API Description
MailMessage Message texte ou HTML avec alternative texte, protection injection headers.
Mailer.from_config() Construit un expéditeur depuis core.forge (transport configurable).
Mailer.send(message, *, message_type, related_entity, related_id) Envoie via le transport actif, journalise si MAIL_LOG_ENABLED=true.
MailTemplateRenderer Rendu Jinja2 de templates *_subject.txt / *_text.txt / *_html.html.
MailLogger Journalisation dans mail_log (sans corps du message).
FakeTransport Transport mémoire pour les tests unitaires.
MailConfigurationError Configuration incomplète ou incohérente.
MailSendError Erreur SMTP pendant l'envoi. Mailer.send() l'intercepte en TransportResult(success=False).

SMTPMailer (core/mail/smtp.py) est conservé provisoirement pour compatibilité. Le système recommandé depuis Forge 1.2 est Mailer + SmtpTransport.

Exemple

from core.mail import Mailer, MailMessage

message = MailMessage(
    subject="Bienvenue",
    to="test@example.com",
    body_text="Bonjour",
    body_html="<p>Bonjour</p>",
)
result = Mailer.from_config().send(message)

En développement, MAIL_ENABLED=false est la valeur par défaut — aucun mail ne part sans activation explicite. Les destinataires bcc sont utilisés dans l'enveloppe SMTP mais ne sont pas ajoutés aux en-têtes visibles du message.

core.validation - Décorateurs V1 des entités

Ces décorateurs vivent dans core/validation/decorators.py et lèvent ValidationError, l'exception centrale de validation V1.

Décorateurs autorisés

Décorateur Description
typed(expected_type) Vérifie le type Python sans transformation implicite. bool est refusé pour int.
nullable Marque explicitement une propriété nullable.
not_empty Refuse les chaînes vides ou blanches.
min_length(size) Longueur minimale d'une chaîne.
max_length(size) Longueur maximale d'une chaîne.
min_value(limit) Valeur numérique minimale.
max_value(limit) Valeur numérique maximale.
pattern(regex) Expression régulière avec fullmatch.

Exemple

from core.validation.decorators import max_length, not_empty, typed

class ContactBase:
    @property
    def nom(self):
        return self._nom

    @nom.setter
    @typed(str)
    @not_empty
    @max_length(100)
    def nom(self, value):
        self._nom = value

Les fichiers générés *_base.py peuvent utiliser ces décorateurs. La classe métier manuelle reste le bon endroit pour ajouter du comportement applicatif.

forge CLI - Commandes officielles

L'interface officielle est la commande forge. La version actuelle est 1.5.0.

Commandes

Commande Rôle
forge --version Affiche la version CLI.
forge new NomProjet [--ref REF] Crée un projet depuis v1.5.0 (dernier tag stable). --ref main est explicite pour le développement.
forge make:entity NomEntite Crée le JSON canonique d'une entité.
forge make:crud NomEntite [--dry-run] Génère contrôleur, vues et routes CRUD, avec champs relationnels many_to_one, formulaires many_to_many côté source, affichage many_to_many dans list/show côté source et partials internes de liste.
forge make:public-page <nom> Génère une page publique simple, son template, son contrôleur et sa route dédiée.
forge make:public-list <Entite> Génère une liste publique depuis une entité existante, séparée du CRUD admin. Intègre une colonne image si l'entité déclare un média field: image.
forge make:public-show <Entite> Génère une fiche publique depuis une entité existante, séparée du CRUD admin. Affiche les médias déclarés en lecture seule (image, galerie, fichier).
forge make:public-form <Entite> Génère un formulaire public (GET /new + POST /) avec validation serveur, INSERT SQL visible et redirect flash. Non destructif sur un contrôleur public existant.
forge make:public-contact Génère une page contact publique statique (/contact) avec coordonnées et adresse placeholder. Aucun traitement serveur, aucun envoi de mail.
forge make:relation Assistant de création de relation.
forge sync:entity NomEntite Régénère *_base.py et SQL depuis le JSON.
forge sync:relations Régénère relations.sql depuis relations.json, y compris les tables pivot many_to_many simples ou enrichies via pivot_fields.
forge sync:landing [--check] Synchronise ou vérifie la landing.
forge upload:init Prépare les dossiers d'uploads.
forge media:init Prépare les dossiers de variantes d'images.
forge auth:init Crée ou préserve les SQL Auth/User optionnels.
forge auth:doctor Vérifie les modules, contrats et SQL optionnels Auth/User sans accès base.
forge auth:status Affiche les briques Auth/User disponibles dans le projet.
forge auth:list-sql Liste les fichiers SQL Auth/User optionnels connus sans les appliquer.
forge auth:user:create --email <email> [--password ...\|--password-prompt] Crée un utilisateur local avec mot de passe hashé.
forge auth:user:list Liste les utilisateurs locaux sans afficher de secret.
forge auth:user:show (--id <id>\|--email <email>) Affiche un utilisateur local sans afficher de secret.
forge auth:user:disable (--id <id>\|--email <email>) Désactive un compte utilisateur local (is_active=FALSE) sans le supprimer.
forge auth:user:enable (--id <id>\|--email <email>) Réactive un compte utilisateur local (is_active=TRUE).
forge auth:user:password (--id <id>\|--email <email>) [--password ...\|--password-prompt] Change le mot de passe d'un utilisateur local (hash Argon2id). Aucun secret affiché.
forge auth:user:role:add (--id <id>\|--email <email>) --role <role> Attribue un rôle RBAC existant à un utilisateur.
forge auth:user:role:remove (--id <id>\|--email <email>) --role <role> Retire une association user_roles.
forge auth:user:roles (--id <id>\|--email <email>) Liste les rôles RBAC attribués à un utilisateur.
forge mail:init Crée templates mail, dossier log et SQL mail_log.
forge mail:doctor Vérifie la configuration mail.
forge mail:test --to <email> Envoie un message de test via le transport configuré.
forge mail:render <tpl> [--context f.json] Affiche le rendu d'un template sans envoyer.
forge mail:logs [--limit N] Affiche les derniers enregistrements de mail_log.
forge build:model Régénère les modèles.
forge check:model Vérifie la cohérence des modèles.
forge db:init Prépare l'environnement MariaDB et la table technique forge_migrations.
forge db:apply Applique le SQL généré.
forge migration:status Affiche l'état des migrations SQL versionnées, sans les appliquer.
forge migration:make Crée un fichier SQL de migration vide dans mvc/migrations/.
forge migration:make <nom> --from-diff <Entite> Génère une migration prudente depuis le diff d'une entité.
forge migration:diff --entity <Entite> Compare en lecture seule une entité JSON avec les colonnes MariaDB.
forge migration:apply Applique les migrations SQL locales en attente.
forge routes:list Liste les routes de l'application.
forge deploy:init Prépare les fichiers de déploiement.
forge deploy:check Vérifie la configuration de déploiement.
forge starter:list Liste les starter apps.
forge starter:build Reconstruit une starter app.
forge doctor Lance les diagnostics projet.
forge help Affiche l'aide.

forge auth:init crée ou préserve les fichiers SQL suivants sans les appliquer : users.sql, auth_tokens.sql, auth_mfa_factors.sql, auth_mfa_recovery_codes.sql, auth_oidc_accounts.sql, auth_oidc_identities.sql, user_roles.sql, auth_audit_log.sql et auth_rate_limit_attempts.sql.

Les commandes Auth dispatchables dans cette version sont celles listées ci-dessus. Les commandes auth:user:role:* manipulent uniquement les associations user_roles et ne créent ni rôle, ni permission, ni utilisateur. Aucune commande de consultation audit/rate limit n'est exposée par la CLI actuelle.

Exemples

forge --version
forge new GestionVentes
forge new ForgeDev --ref main
forge make:entity Contact
forge sync:entity Contact
forge make:crud Contact --dry-run
forge make:crud Contact
forge check:model
forge db:init
forge db:apply
forge routes:list
forge_cli.entities - Génération et validation des entités

Cette partie suit la doctrine V1 décrite dans l'architecture des entités.

Doctrine générée

Source Statut Règle
mvc/entities/<entite>/<entite>.json Canonique Décrit l'entité, ses champs et contraintes.
mvc/entities/<entite>/<entite>_base.py Régénérable Classe Python générée avec validation.
mvc/entities/<entite>/<entite>.sql Régénérable SQL de table.
mvc/entities/<entite>/<entite>.py Manuel Classe métier, jamais écrasée si elle existe.
mvc/entities/<entite>/__init__.py Manuel Jamais régénéré s'il existe.
mvc/entities/relations.json Canonique Relations globales.
mvc/entities/relations.sql Régénérable Contraintes de clés étrangères et tables pivot many_to_many, y compris colonnes pivot_fields.

Validation importante

Sujet Règle actuelle
Noms snake_case pour dossiers, tables et champs Python. Colonnes SQL en PascalCase.
Mots réservés SQL Les noms comme order, user, group ou select sont refusés.
Relations V1 accepte les relations many_to_one et many_to_many. sync:relations génère les pivots simples ou enrichis via pivot_fields. make:crud génère RelationField pour les many_to_one, un LEFT JOIN avec alias <fk>_label dans les requêtes de liste, un <select multiple> côté source pour les formulaires many_to_many, et l'affichage texte des libellés liés dans list/show côté source.
Clés primaires V1 limite les entités à une clé primaire simple.
Types SQL de FK Les types SQL normalisés doivent être compatibles. INT vers BIGINT ou INT UNSIGNED est refusé.
Génération Un JSON invalide bloque la génération.

Exemple JSON entité

{
  "entity": "Contact",
  "table": "contact",
  "primary_key": {
    "name": "contact_id",
    "sql": "ContactId",
    "python_type": "int",
    "sql_type": "INT",
    "auto_increment": true
  },
  "fields": [
    {
      "name": "prenom",
      "sql": "Prenom",
      "python_type": "str",
      "sql_type": "VARCHAR(100)",
      "nullable": false,
      "constraints": {
        "not_empty": true,
        "max_length": 100
      }
    }
  ]
}

Exemple relation

{
  "relations": [
    {
      "name": "contact_ville",
      "type": "many_to_one",
      "from_entity": "contact",
      "from_field": "ville_id",
      "to_entity": "ville",
      "to_field": "ville_id",
      "on_delete": "RESTRICT",
      "on_update": "CASCADE"
    }
  ]
}

Exemples de cycle complet

forge make:entity Ville
forge make:entity Contact
forge sync:entity Ville
forge sync:entity Contact
forge make:relation
forge sync:relations
forge check:model
forge db:apply
forge make:crud Contact

La génération CRUD applique la correction de robustesse sur les identifiants invalides : un id de route non entier est traité comme une ressource introuvable.

Pages publiques simples

forge make:public-page accueil génère une page publique indépendante du CRUD admin :

mvc/views/public/accueil.html
mvc/controllers/public_pages_controller.py
mvc/routes.py

Le template étend layouts/public.html, définit les blocs publics stables title et content, et affiche un contenu minimal. Le layout public expose aussi le bloc scripts pour les ajouts explicites de projet. La commande ajoute une route publique prudente /accueil et un handler dans PublicPagesController.

La génération est non destructive : un template existant est conservé, une route existante n'est pas dupliquée et le contrôleur est complété seulement quand l'insertion est sûre. Cette commande ne génère ni liste publique, ni fiche, ni formulaire, ni média, ni HTMX, ni JavaScript personnalisé. Elle ne remplace pas le CRUD admin et prépare seulement les pages publiques génériques de la Phase 6.

Convention publique stabilisée :

mvc/views/layouts/public.html  # layout visiteur
mvc/views/public/              # pages publiques générées
mvc/views/components/          # composants Jinja génériques réutilisables

Le layout public charge Tailwind et reste indépendant de HTMX, Alpine.js et i18n. Les pages publiques générées ne doivent pas exposer un CRUD admin brut aux visiteurs.

Listes publiques simples

forge make:public-list Hebergement génère une liste publique depuis une entité existante :

mvc/views/public/hebergements/index.html
mvc/controllers/public_hebergements_controller.py
mvc/routes.py

La route publique générée est /hebergements. Le template étend layouts/public.html, utilise les blocs title, content et scripts, affiche un titre, un tableau simple et un état vide. Le contrôleur contient une requête SQL lisible de type SELECT ... FROM table ORDER BY id DESC, sans ORM, sans recherche, sans filtre et sans pagination.

La liste publique affiche les champs simples déclarés dans l'entité et exclut les champs techniques ou sensibles évidents comme id, created_at, updated_at, password, password_hash, token et secret. Les champs de relation simples en _id et les relations complexes ne sont pas traités.

Si l'entité déclare au moins un média field: image dans sa configuration media, la liste ajoute une colonne miniature. Le contrôleur appelle get_cover_media pour chaque ligne après la requête principale.

Cette commande ne génère pas de formulaire public, pas de back-office, pas de bouton modifier/supprimer et n'expose aucune route CRUD admin. HTMX, Alpine.js et i18n restent optionnels et ne sont pas imposés.

Fiches publiques simples

forge make:public-show Hebergement génère une fiche publique de consultation depuis une entité existante :

mvc/views/public/hebergements/show.html
mvc/controllers/public_hebergements_controller.py
mvc/routes.py

La route publique générée est /hebergements/{id}. Le template étend layouts/public.html, utilise les blocs title, content et scripts, affiche un titre, les champs publics simples et un lien de retour vers /hebergements. Si la ligne demandée n'existe pas, le contrôleur renvoie BaseController.not_found().

Le contrôleur public est créé si nécessaire ou complété quand la classe attendue existe déjà. La requête SQL reste visible et simple : SELECT ... FROM table WHERE id = ?. Il n'y a pas d'ORM, pas de jointure, pas de recherche, pas de filtre, pas de pagination et pas de slug public.

Si l'entité déclare des médias dans sa configuration media, la fiche affiche chaque entrée en lecture seule dans l'ordre de la déclaration. Le contrôleur appelle get_cover_media pour les éléments uniques et list_media_for_entity pour les galeries.

La fiche publique exclut les mêmes champs techniques ou sensibles que la liste publique, ainsi que les champs relationnels bruts en _id. Elle ne génère aucun bouton modifier/supprimer, ne crée pas de formulaire, n'expose pas d'upload ni de suppression de média côté public et ne remplace pas le CRUD admin.

Médias dans les pages publiques

forge make:public-list et forge make:public-show intègrent les médias déclarés dans la clé media de la définition JSON d'une entité.

Comportement liste — si au moins un média field: image est déclaré, la liste générée ajoute une colonne miniature. Le SELECT inclut pk AS _entity_id. Le contrôleur enrichit chaque ligne avec get_cover_media(snake, row["_entity_id"], role=...). Les entités sans déclaration media ou avec uniquement des fichiers ne reçoivent aucune colonne supplémentaire.

Comportement fiche — chaque entrée media produit une section d'affichage :

Type Rendu
field: image, multiple: false <img> thumbnail ou URL originale
field: image, multiple: true grille de <img>
field: file, multiple: false lien vers le fichier
field: file, multiple: true liste de liens

Le contrôleur appelle get_cover_media pour les éléments uniques et list_media_for_entity pour les galeries. Les imports core.uploads sont ajoutés automatiquement si nécessaire. La génération est non destructive : un contrôleur existant est complété, jamais écrasé.

Limites strictes — aucun upload public, aucune suppression publique, aucune réorganisation, aucun carrousel JavaScript, aucune lightbox, aucun HTMX, aucun Alpine.js. Ces fonctionnalités restent réservées au CRUD admin ou à des tickets dédiés.

Formulaires publics

forge make:public-form <Entite> génère un formulaire de saisie public depuis une entité existante :

mvc/views/public/{plural}/form.html
mvc/controllers/public_{plural}_controller.py
mvc/routes.py

Exemple : forge make:public-form DemandeSejour génère :

  • mvc/views/public/demande_sejours/form.html
  • mvc/controllers/public_demandes_sejours_controller.py
  • Routes : GET /demande_sejours/new et POST /demande_sejours

Champs inclus — les champs non sensibles de l'entité (hors clé primaire, clés étrangères _id, created_at, updated_at, password*, token*, secret*, is_admin, is_active, email_verified_at, last_login_at).

Types d'input détectés automatiquementtextarea (sql TEXT), checkbox (bool), number (int/float), date, datetime-local, email (nom contenant email), url (nom contenant url, website ou site), tel (nom contenant phone), text par défaut.

Validation serveur — chaque champ non nullable est marqué required. En cas d'erreur, le formulaire est ré-affiché avec les messages d'erreur et les valeurs saisies. En cas de succès, un INSERT SQL visible est exécuté et l'utilisateur est redirigé vers le formulaire vide avec un message flash.

Non destructif — si le contrôleur public existe déjà, les méthodes new() et create() ainsi que les constantes INSERT_PUBLIC_FORM et FORM_FIELDS sont ajoutées sans toucher aux méthodes existantes.

Limites strictes — pas d'envoi d'email, pas de captcha, pas de workflow, pas d'upload, pas de HTMX, pas d'Alpine.js, pas d'exposition de routes admin, pas de pagination, pas d'i18n.

Page contact publique

forge make:public-contact génère une page contact statique sans argument :

mvc/views/public/contact.html
mvc/controllers/public_pages_controller.py
mvc/routes.py

La route générée est GET /contact. La méthode contact() est ajoutée à PublicPagesController (le même contrôleur que make:public-page). Le template contient un titre, une introduction, un bloc coordonnées (lien mailto + téléphone) et un bloc adresse — tous avec des valeurs placeholder à remplacer manuellement.

La commande est non destructive et idempotente : elle ne recrée pas la page si elle existe déjà, n'ajoute pas la route si elle est déjà présente, et ne modifie pas les méthodes existantes du contrôleur.

Ce que cette commande ne fait pas : envoi d'email, traitement de formulaire, création d'entité ou de table, captcha, HTMX, Alpine.js, i18n forcé. Pour un formulaire de contact traité côté serveur, utiliser make:public-form.

Partials CRUD générés

forge make:crud génère maintenant la page liste complète et trois partials internes :

mvc/views/<entite>/index.html
mvc/views/<entite>/_table.html
mvc/views/<entite>/_pagination.html
mvc/views/<entite>/_results.html

index.html reste la page HTML classique : elle étend le layout, affiche le titre, le formulaire GET de recherche, les filtres, puis inclut _results.html dans un conteneur stable #crud-results. _table.html contient la table, les colonnes classiques, les colonnes relationnelles déjà supportées, les actions, les formulaires de suppression et les états vides contextuels. _pagination.html contient les liens Précédent / Suivant et conserve q, les filtres, sort et direction. Ces liens restent de vrais liens href et reçoivent aussi les attributs HTMX progressifs hx-get, hx-target="#crud-results", hx-swap="innerHTML" et hx-push-url="true". _results.html combine _table.html et _pagination.html pour fournir un fragment unique quand la liste est rafraîchie par HTMX.

Le formulaire de recherche reste un formulaire GET standard. Il reçoit aussi les attributs HTMX progressifs hx-get, hx-target="#crud-results", hx-swap="innerHTML" et hx-push-url="true". HTMX n'est pas obligatoire : sans HTMX chargé, la recherche recharge la page complète comme avant. Quand l'en-tête HX-Request: true est présent, le contrôleur rend uniquement _results.html, sans layout complet. Aucun JavaScript personnalisé, aucune recherche live keyup et aucun JavaScript personnalisé ne sont générés. Les formulaires de suppression restent en method="post" avec le champ CSRF ; ils reçoivent aussi hx-post, hx-target="#crud-results", hx-swap="innerHTML" et hx-confirm pour remplacer uniquement les résultats quand HTMX est disponible. Forge n'utilise pas hx-delete dans le CRUD généré.

Listes CRUD générées : recherche et pagination

Les vues liste générées par make:crud embarquent une recherche texte et une pagination.

Recherche

Paramètre GET q :

/contacts?q=roger

La recherche utilise LIKE %q% sur tous les champs textuels (VARCHAR, CHAR, TEXT). Les champs numériques (INT, DECIMAL…), les dates, les booléens et les clés étrangères sont exclus. La clause SQL est toujours paramétrée (aucune concaténation directe). Il s'agit d'une recherche simple côté serveur : pas de full-text search, pas de moteur externe et pas de JavaScript personnalisé. HTMX améliore seulement la soumission du formulaire quand il est disponible.

Pagination

Paramètre GET page, 20 éléments par page dans le CRUD généré :

/contacts?page=2
/contacts?q=roger&page=2
  • page absent ou invalide → page 1.
  • page < 1 → page 1.
  • page trop grand est borné à la dernière page.
  • limit est fixe dans le CRUD généré ; aucun paramètre GET limit n'est généré.
  • q, les filtres, sort et direction sont conservés dans les liens Précédent / Suivant.
  • Les liens Précédent / Suivant restent des href classiques ; sans HTMX, ils rechargent la page complète.
  • Avec HTMX, ces liens remplacent uniquement #crud-results et poussent l'URL dans l'historique.

Le contrôleur généré utilise la classe commune core.mvc.view.pagination.Pagination pour calculer page, nb_pages, limit, offset, has_prev et has_next, puis ajoute les paramètres de liste actifs au contexte :

pagination_state = Pagination(request, total, limit)
pagination = pagination_state.to_dict()
pagination.update({
    "q": q,
    "sort": sort,
    "direction": direction,
    "filters": filters,
})

Ce ticket ne fournit pas d'infinite scroll ou de taille de page dynamique.

Suppression

Les actions de suppression générées restent des formulaires HTML classiques :

<form method="post" action="/contacts/12/delete?...">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    <button type="submit">Supprimer</button>
</form>

Le formulaire conserve method="post", l'action, le bouton submit et le champ csrf_token. Le CRUD généré n'ajoute pas de _method car la route générée reste une route POST /<ressource>/{id}/delete; si une application a un override de méthode personnalisé, il reste à sa charge.

Le même formulaire reçoit une amélioration HTMX progressive :

hx-post="/contacts/12/delete?..."
hx-target="#crud-results"
hx-swap="innerHTML"
hx-confirm="{{ trans('crud.confirm_delete') }}"

Sans HTMX, la suppression redirige vers la liste comme avant. Avec HX-Request: true, le contrôleur supprime l'enregistrement, recharge la liste avec q, filtres, sort, direction et page, puis rend _results.html sans layout complet. Aucun JavaScript personnalisé et aucun hx-delete ne sont générés.

SQL généré (exemple)

SELECT * FROM contact
WHERE Nom LIKE ? OR Email LIKE ?
ORDER BY Id DESC
LIMIT ? OFFSET ?

Listes CRUD générées : états vides contextuels

Les vues liste générées par make:crud affichent un état vide standard quand la collection affichée est vide. Le contrôleur transmet aussi empty_context au template pour distinguer les absences de résultats dues à une recherche ou à des filtres :

{% else %}
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-6 text-center text-gray-600">
    {% if empty_context == "search" %}
    {{ trans('crud.empty_search') }}
    {% elif empty_context == "filters" %}
    {{ trans('crud.empty_filters') }}
    {% elif empty_context == "search_filters" %}
    {{ trans('crud.empty_search_filters') }}
    {% else %}
    {{ trans('crud.empty') }}
    {% endif %}
</div>
{% endif %}

Les cas retenus sont :

Contexte Condition Clé i18n
Liste vide générale ni recherche active, ni filtre actif crud.empty
Recherche sans résultat q non vide après strip() crud.empty_search
Filtres sans résultat au moins un filtre réellement actif crud.empty_filters
Recherche + filtres sans résultat q actif et filtre actif crud.empty_search_filters

Les contrôles actifs restent visibles : pagination.q, pagination.filters, pagination.sort et pagination.direction sont conservés dans le contexte. Les relations many_to_many sans lien affichent dans la liste et Aucun <EntitéCible> dans la fiche show. Ce comportement est rendu côté serveur/Jinja : pas de JavaScript, pas de HTMX et pas de composant lourd.

Listes CRUD générées : tri simple

Les vues liste générées par make:crud embarquent un tri simple côté serveur sur les colonnes de l'entité affichées dans la liste.

Paramètres GET :

/contacts?sort=nom&direction=asc
/contacts?sort=email&direction=desc&q=roger&page=2

Règles :

  • sort doit être une clé connue de l'entité générée ;
  • sort invalide est ignoré et le modèle utilise le tri par défaut ;
  • direction accepte uniquement asc ou desc ;
  • direction invalide revient à asc ;
  • les liens de tri conservent q et les filtres actifs ;
  • les liens de pagination conservent q, les filtres, sort et direction.

Le SQL ORDER BY n'utilise jamais directement une valeur GET comme nom de colonne. Le modèle généré crée une allowlist :

_ALLOWED_SORT = {"nom": "Nom", "email": "Email", "id": "Id"}
sort_col = _ALLOWED_SORT.get(sort, _DEFAULT_SORT)
sort_dir = "DESC" if direction == "desc" else "ASC"

Le nom de colonne est donc choisi depuis l'allowlist, puis concaténé au SQL. C'est la méthode retenue car les noms de colonnes ne peuvent pas être passés proprement comme paramètres SQL ?.

Limites :

  • pas de tri multi-colonnes ;
  • pas de tri many_to_many ;
  • pas de tri relationnel avancé par libellé joint ;
  • pas de HTMX ou JavaScript.

Listes CRUD générées : filtres simples

En plus de la recherche texte q, chaque champ non-PK peut exposer un filtre d'égalité via la métadonnée "list".

{
  "name": "statut",
  "sql_type": "VARCHAR(50)",
  "python_type": "str",
  "nullable": false,
  "constraints": {},
  "list": { "filter": true }
}

Types SQL supportés pour list.filter=true :

Famille Exemples
Chaînes courtes VARCHAR(n), CHAR(n)
Entiers INT, BIGINT, SMALLINT, TINYINT, MEDIUMINT
Booléens BOOL, BOOLEAN

Types non supportés (erreur à la validation) : TEXT, DATE, DATETIME, TIMESTAMP, DECIMAL, FLOAT, DOUBLE.

Comportement généré

  • Champ VARCHAR/CHAR/INT<input type="text"> dans le formulaire de recherche.
  • Champ BOOL/BOOLEAN<select> avec Tous / Oui / Non.
  • Valeur filtrée transmise en GET : /contacts?statut=actif&actif=1&q=roger&page=2
  • Filtres conservés dans les liens de tri et de pagination via une boucle Jinja2 générique.
  • list.filter=false ou "list" absent → comportement actuel inchangé.

SQL généré

Recherche q et filtres sont combinés avec AND ; chaque groupe de LIKE est entre parenthèses :

SELECT * FROM contact
WHERE (Nom LIKE ? OR Email LIKE ?)
  AND Statut = ?
ORDER BY Id DESC
LIMIT ? OFFSET ?

Toutes les valeurs sont paramétrées (aucune concaténation directe).

Listes CRUD générées : filtres relationnels many_to_one

Une relation many_to_one déclarée dans mvc/entities/relations.json peut aussi être utilisée comme filtre de liste. Aucune nouvelle métadonnée obligatoire n'est nécessaire : la présence de la relation suffit.

Exemple minimal :

{
  "name": "hebergement_commune",
  "type": "many_to_one",
  "from_entity": "Hebergement",
  "to_entity": "Commune",
  "from_field": "commune_id",
  "to_field": "id",
  "foreign_key_name": "fk_hebergement_commune",
  "on_delete": "RESTRICT",
  "on_update": "CASCADE"
}

URL :

/hebergements?commune_id=3
/hebergements?q=gite&commune_id=3&page=2

Le filtre relationnel est rendu sous forme de <select> :

<select name="commune_id">
  <option value="">Tous les Commune</option>
  ...
</select>

Forge charge les options depuis l'entité liée avec une fonction modèle explicite. Le libellé est déduit du premier champ textuel disponible (VARCHAR, CHAR, TEXT) ; si l'entité liée n'a aucun champ textuel, Forge utilise la clé primaire comme libellé. Les options sont triées par ce libellé, ou par la clé primaire en fallback.

Le contrôleur généré ignore une valeur vide ou non numérique. La valeur valide est passée comme paramètre SQL, puis combinée avec q, les filtres simples, la pagination et le tri.

Le formulaire create/edit continue d'utiliser RelationField. Les fonctionnalités plus avancées comme l'autocomplete ou la recherche dans les options ne font pas partie de cette première version.

Contexte de vue

pagination.filters est toujours un dict (vide si aucun filtre actif) :

pagination = {
    "page": 1, "nb_pages": 3, "total": 55,
    "has_prev": False, "has_next": True,
    "q": "roger", "sort": "", "direction": "desc",
    "filters": {"statut": "actif", "actif": "1"},
}

Métadonnée form.field

Chaque champ peut porter une clé optionnelle "form" pour contrôler le type de champ généré par make:crud.

{
  "name": "email",
  "sql_type": "VARCHAR(254)",
  "python_type": "str",
  "nullable": false,
  "constraints": { "max_length": 254 },
  "form": { "field": "email" }
}
Valeur form.field Champ généré Import
string StringField StringField
email EmailField EmailField
phone PhoneField PhoneField
url UrlField UrlField
textarea TextAreaField TextAreaField
slug SlugField SlugField
date DateField DateField
datetime DateTimeField DateTimeField

La validation vérifie aussi la cohérence avec sql_type :

Valeur form.field sql_type accepté
string, email, phone, url, textarea, slug CHAR, VARCHAR, TEXT, TINYTEXT, MEDIUMTEXT, LONGTEXT
date DATE
datetime DATETIME, TIMESTAMP

Priorités : RelationField (défini via relations.json) est toujours prioritaire sur form.field. En l'absence de form.field, make:crud déduit le champ depuis python_type (comportement V1 inchangé).

file et image dans form.field : ces valeurs sont refusées à la validation de fields[].form.field. Les médias doivent être déclarés via la clé "media" à la racine de l'entité — voir la section Métadonnée media ci-dessous.

Métadonnée media (déclaration des médias liés)

La clé optionnelle "media" au niveau racine d'un entity.json déclare les médias liés à une entité. Elle n'ajoute aucune colonne SQL et ne modifie pas _base.py. make:crud l'utilise pour générer les champs de formulaire et l'upload à la création.

Les médias sont stockés dans la table media distincte via media.entity_name / media.entity_id. La table métier ne contient aucun champ média.

"media": [
  {
    "name": "cover",
    "field": "image",
    "role": "cover",
    "variants": true,
    "multiple": false,
    "required": false,
    "label": "Image principale"
  },
  {
    "name": "brochure",
    "field": "file",
    "role": "brochure",
    "label": "Brochure PDF"
  }
]
Clé Obligatoire Valeurs Défaut
name oui chaîne non vide, unique
field oui "image", "file"
role oui chaîne non vide, unique
variants non bool false
multiple non bool false
required non bool false
label non chaîne

Règles : variants=true est autorisé uniquement avec field="image". Les doublons name et role dans une même entité sont refusés à la validation. Voir docs/media.md pour les détails et la convention de rôles.

Génération CRUD media (make:crud + media)

Quand une entité déclare des médias, make:crud génère :

  • Formulaire : un ImageField ou FileField par entrée media, avec label et required issus de la déclaration.
  • Vue formulaire : enctype="multipart/form-data" sur le <form>, <input type="file"> avec accept="image/*" pour les images.
  • Contrôleur create :
  • Import de save_upload, attach_media_to_entity, delete_media, list_media_for_entity depuis core.uploads.
  • Exclusion des clés média de form.cleaned_data avant l'insert SQL.
  • Capture de l'identifiant créé (cursor.lastrowid).
  • Pour chaque média soumis : save_upload(file, category, variants=...) puis attach_media_to_entity(saved, entity_name=..., entity_id=created_id, role=..., position=0).
  • Contrôleur update (pour multiple=false) :
  • Si un nouveau fichier est soumis : list_media_for_entity pour trouver l'ancien, delete_media(..., delete_files=True) pour le supprimer, puis save_upload + attach_media_to_entity pour attacher le nouveau.
  • Si _delete_media_<name> est coché (sans nouveau fichier) : delete_media uniquement, pas d'upload.
  • Sans action : média existant conservé.
  • Les clés média et _delete_media_* ne sont jamais écrites dans la table métier.
  • Contrôleurs show et edit (pour multiple=false) :
  • get_cover_media(entity_name, entity_id, role=...) chargé pour chaque entrée, passé au contexte.
  • show.html : images affichées avec thumbnail_url or url ; fichiers avec un lien.
  • form.html : média existant affiché avant le champ upload avec un message de remplacement.

Les médias non soumis (champ vide) sont ignorés silencieusement. La case à cocher _delete_media_<name> permet la suppression explicite d'un média existant. multiple=true est validé et normalisé mais non exploité comme galerie CRUD (multi-upload, réorganisation).

Protection RBAC des routes CRUD (rbac.permissions)

La clé optionnelle "rbac" dans entity.json déclare les permissions requises par action CRUD. make:crud injecte alors @require_permission(...) dans le contrôleur généré.

"rbac": {
  "permissions": {
    "index":  "contacts.view",
    "show":   "contacts.view",
    "create": "contacts.create",
    "store":  "contacts.create",
    "edit":   "contacts.edit",
    "update": "contacts.edit",
    "delete": "contacts.delete"
  }
}

Actions acceptées : index, show, create (→ méthode new), store (→ méthode create), edit, update, delete (→ méthode destroy). Toute action inconnue déclenche une erreur à la génération. Sans clé rbac, le contrôleur est identique à celui généré sans RBAC.

Documentation complète : RBAC — Contrôle d'accès.


Workflow

core.workflow est une brique applicative générique. Elle modélise des états simples, des transitions explicitement autorisées et des helpers d'affichage pour les templates Jinja2. Elle est indépendante de tout métier applicatif.

Modules :

  • core/workflow/status.py — statuts et validation ;
  • core/workflow/transitions.py — transitions entre statuts ;
  • core/workflow/jinja.py — helpers d'affichage injectés automatiquement dans Jinja2Renderer.

Import principal :

from core.workflow import (
    WorkflowStatus, WorkflowStatusError,
    make_status, validate_statuses, find_status,
    normalize_status_name, validate_status_name,
    WorkflowTransition, WorkflowTransitionError,
    make_transition, validate_transitions,
    can_transition, get_available_transitions,
    workflow_status_label, workflow_status_color,
    workflow_status_badge_class, workflow_status_badge,
    make_workflow_jinja_helpers,
)

Workflow — Statuts génériques

Les statuts modélisent les états possibles d'une entité applicative. Ils restent génériques : draft, pending, published, archived, new, in_progress, done, cancelled

API

from core.workflow import make_status, validate_statuses, find_status

STATUTS = validate_statuses([
    make_status("draft",     label="Brouillon",  color="gray",   is_initial=True),
    make_status("pending",   label="En attente", color="yellow"),
    make_status("published", label="Publié",     color="green",  is_final=True),
    make_status("archived",  label="Archivé",    color="gray",   is_final=True),
])

s = find_status(STATUTS, "pending")
# → WorkflowStatus(name='pending', label='En attente', color='yellow', ...)

find_status(STATUTS, "unknown")
# → None

WorkflowStatus

@dataclass
class WorkflowStatus:
    name: str        # snake_case, obligatoire
    label: str = ""  # fallback vers name si vide
    color: str = ""  # libre, ex. "yellow", "#f59e0b"
    is_initial: bool = False
    is_final: bool = False

Le constructeur valide et normalise name automatiquement. Un nom vide ou contenant des caractères non autorisés lève WorkflowStatusError.

Fonctions

Fonction Comportement
normalize_status_name(value) Lowercase, espaces et tirets → _. Lève si chars interdits.
validate_status_name(value) Normalise puis vérifie le format [a-z][a-z0-9_]*. Lève sinon.
make_status(name, ...) Crée un WorkflowStatus validé. Raccourci du constructeur.
validate_statuses(statuses) Vérifie doublons et unicité du statut initial. Retourne la liste.
find_status(statuses, name) Retourne le statut correspondant ou None.

Règles

  • name obligatoire, normalisé en snake_case. Seuls lettres, chiffres, espaces et tirets en entrée (espaces et tirets → _). Le nom normalisé doit commencer par une lettre.
  • label optionnel — utilise name si absent. color optionnel — chaîne libre.
  • Au plus un statut is_initial=True dans une liste. Plusieurs is_final=True autorisés.
  • Doublons de name dans une liste → WorkflowStatusError.

Workflow — Transitions génériques

Les transitions décrivent explicitement les passages autorisés entre statuts. Aucune transition n'est jamais exécutée automatiquement.

API

from core.workflow import (
    make_status, validate_statuses,
    make_transition, validate_transitions,
    can_transition, get_available_transitions,
)

STATUTS = validate_statuses([
    make_status("draft",     is_initial=True),
    make_status("pending"),
    make_status("published", is_final=True),
    make_status("archived",  is_final=True),
])

TRANSITIONS = validate_transitions(
    [
        make_transition("draft",     "pending"),
        make_transition("pending",   "published"),
        make_transition("pending",   "draft"),
        make_transition("published", "archived"),
    ],
    statuses=STATUTS,
)

can_transition(TRANSITIONS, "draft", "pending")    # True
can_transition(TRANSITIONS, "draft", "published")  # False

available = get_available_transitions(TRANSITIONS, "pending")
# → [WorkflowTransition(from_status='pending', to_status='published'),
#    WorkflowTransition(from_status='pending', to_status='draft')]

WorkflowTransition

@dataclass(frozen=True)
class WorkflowTransition:
    from_status: str  # snake_case, obligatoire
    to_status: str    # snake_case, obligatoire

Immuable (frozen=True). Le constructeur valide et normalise les deux noms. Une transition vers soi-même lève WorkflowTransitionError.

Fonctions

Fonction Comportement
make_transition(from_status, to_status) Crée une WorkflowTransition validée.
validate_transitions(transitions, statuses=None) Vérifie doublons ; si statuses fourni, vérifie l'existence des statuts.
can_transition(transitions, from_name, to_name) True si la transition est définie, False sinon.
get_available_transitions(transitions, from_name) Liste toutes les transitions depuis from_name.

Règles

  • from_status et to_status obligatoires, normalisés en snake_case.
  • Transition vers le même statut (from == to) → WorkflowTransitionError.
  • Doublons (from, to) dans une liste → WorkflowTransitionError.
  • validate_transitions(..., statuses=STATUTS) vérifie que tous les noms référencés existent dans STATUTS. Sans statuses, cette vérification est sautée.
  • can_transition et get_available_transitions normalisent les noms passés en argument.

Workflow — Helpers Jinja

Les helpers d'affichage permettent de rendre un statut dans un template Jinja2 sans accéder à la base de données ni déclencher de transition.

Injection automatique

Les helpers sont injectés automatiquement dans tout Jinja2Renderer via make_workflow_jinja_helpers(). Aucune configuration supplémentaire n'est requise dans les templates.

Usage dans un template

{# Libellé seul #}
{{ workflow_status_label(demande.statut) }}

{# Classes CSS pour un <span> personnalisé #}
<span class="{{ workflow_status_badge_class(demande.statut) }}">
  {{ workflow_status_label(demande.statut) }}
</span>

{# Badge HTML complet (auto-échappé) #}
{{ workflow_status_badge(demande.statut) }}

Fonctions

Fonction Comportement
workflow_status_label(status) status.label ou status.name si vide. "" pour None.
workflow_status_color(status) status.color ou "gray" si absent ou non listé.
workflow_status_badge_class(status) Classes Tailwind complètes pour le badge.
workflow_status_badge(status) Markup HTML <span> prêt à l'emploi, auto-échappé.
make_workflow_jinja_helpers() Dict des quatre helpers — injection manuelle possible.

Couleurs prises en charge

color Palette Tailwind
gray (défaut) bg-gray-100 text-gray-700
blue bg-blue-100 text-blue-700
green bg-green-100 text-green-700
yellow bg-yellow-100 text-yellow-700
red bg-red-100 text-red-700
purple bg-purple-100 text-purple-700

Couleur absente ou non listée → palette gray. Classes de base communes : inline-flex items-center rounded-full px-2 py-1 text-xs font-medium.


Workflow — Limites actuelles

La brique Workflow fournit un socle de statuts, de transitions et d'affichage. Forge ne fournit pas encore dans ce socle :

  • table SQL de workflow (pas de colonne statut générée) ;
  • migration SQL ;
  • historique des changements de statut ;
  • auteur et timestamp du changement ;
  • intégration CRUD — les listes et formulaires admin n'affichent pas encore le statut ;
  • intégration pages publiques — les templates publics n'incluent pas encore le badge de statut ;
  • CLI workflow (forge make:workflow-status…) ;
  • générateur de contrôleur ou de template avec statut ;
  • permissions RBAC liées aux transitions (ex. : seul un admin peut publier) ;
  • notifications ou emails déclenchés par une transition ;
  • logique métier Communes & Séjours (statut de demande, de réservation, etc.).

Ces fonctionnalités peuvent être construites par l'application au-dessus du socle actuel, ou seront traitées dans des tickets ultérieurs.