Forge - Référence API et CLI¶
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¶
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,HEADetOPTIONSne lisent pas de body.- La limite de body par défaut est
1_048_576octets. - Les uploads multipart utilisent au minimum
1 MiBet ajoutent une marge de65_536octets àupload_max_size.
core.http.response - Réponse HTTP
Classe¶
| 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¶
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¶
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¶
Méthode¶
| API | Signature | Description |
|---|---|---|
dispatch |
dispatch(request) -> Response |
Résout la route, applique CSRF et middlewares, appelle le handler. |
Flux réel¶
- Recherche de la route.
- Si aucune route ne correspond, retourne
errors/404.html. - Injection de
request.route_params. - Vérification CSRF pour les méthodes unsafe si la route le demande.
- Exécution des middlewares pour les routes non publiques.
- Appel du handler.
- En cas d'exception non gérée, retourne
errors/500.html.
Exemple middleware¶
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¶
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"])
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 parsave_upload+attach_media_to_entityappelés depuis le contrôleur généré parmake:crud.RelationField— hérite deChoiceField. Ne fait aucune requête SQL ; la liste de choix est fournie par le contrôleur ou le formulaire viaoptions.DateField/DateTimeField— retournent des objets Python typés (datetime.date/datetime.datetime).make:crudgénère ces champs directement pour les colonnesDATE/DATETIME.SlugField— valide le format slug, ne slugifie pas automatiquement. Les caractères accentués, majuscules et underscores sont refusés.TextAreaField— fournit un helperrender()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¶
core.mvc.view - Pagination
Classe¶
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¶
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¶
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.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.
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 :
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 :
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 :
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 :
désigne le fichier :
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 :
original: fichier conservé tel quel.medium: image redimensionnée dans un maximum de1280 x 1280.thumbnail: image redimensionnée dans un maximum de300 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 :
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 :
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 :
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/ :
Vérifie :
- présence de
translations/ - présence de
translations/fr.json - validité JSON de chaque
*.jsontrouvé - 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 :
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.jsonoutranslations/es.jsonn'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 estMailer + 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_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
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 :
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 :
Exemple : forge make:public-form DemandeSejour génère :
mvc/views/public/demande_sejours/form.htmlmvc/controllers/public_demandes_sejours_controller.py- Routes :
GET /demande_sejours/newetPOST /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 automatiquement — textarea (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 :
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 :
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é :
pageabsent ou invalide → page 1.page < 1→ page 1.pagetrop grand est borné à la dernière page.limitest fixe dans le CRUD généré ; aucun paramètre GETlimitn'est généré.q, les filtres,sortetdirectionsont conservés dans les liens Précédent / Suivant.- Les liens Précédent / Suivant restent des
hrefclassiques ; sans HTMX, ils rechargent la page complète. - Avec HTMX, ces liens remplacent uniquement
#crud-resultset 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)
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 :
Règles :
sortdoit être une clé connue de l'entité générée ;sortinvalide est ignoré et le modèle utilise le tri par défaut ;directionaccepte uniquementascoudesc;directioninvalide revient àasc;- les liens de tri conservent
qet les filtres actifs ; - les liens de pagination conservent
q, les filtres,sortetdirection.
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=falseou"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 :
Le filtre relationnel est rendu sous forme de <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
ImageFieldouFileFieldpar entréemedia, avec label etrequiredissus de la déclaration. - Vue formulaire :
enctype="multipart/form-data"sur le<form>,<input type="file">avecaccept="image/*"pour les images. - Contrôleur
create: - Import de
save_upload,attach_media_to_entity,delete_media,list_media_for_entitydepuiscore.uploads. - Exclusion des clés média de
form.cleaned_dataavant l'insert SQL. - Capture de l'identifiant créé (
cursor.lastrowid). - Pour chaque média soumis :
save_upload(file, category, variants=...)puisattach_media_to_entity(saved, entity_name=..., entity_id=created_id, role=..., position=0). - Contrôleur
update(pourmultiple=false) : - Si un nouveau fichier est soumis :
list_media_for_entitypour trouver l'ancien,delete_media(..., delete_files=True)pour le supprimer, puissave_upload+attach_media_to_entitypour attacher le nouveau. - Si
_delete_media_<name>est coché (sans nouveau fichier) :delete_mediauniquement, 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
showetedit(pourmultiple=false) : get_cover_media(entity_name, entity_id, role=...)chargé pour chaque entrée, passé au contexte.show.html: images affichées avecthumbnail_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 dansJinja2Renderer.
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¶
nameobligatoire, normalisé en snake_case. Seuls lettres, chiffres, espaces et tirets en entrée (espaces et tirets →_). Le nom normalisé doit commencer par une lettre.labeloptionnel — utilisenamesi absent.coloroptionnel — chaîne libre.- Au plus un statut
is_initial=Truedans une liste. Plusieursis_final=Trueautorisés. - Doublons de
namedans 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_statusetto_statusobligatoires, 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 dansSTATUTS. Sansstatuses, cette vérification est sautée.can_transitionetget_available_transitionsnormalisent 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
statutgé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.