CRUD explicite — Forge¶
Forge génère un squelette CRUD lisible et modifiable à partir d'une entité JSON. La génération produit un point de départ, pas une cage : chaque fichier est ouvert, explicite, et ne sera jamais écrasé si vous le modifiez.
1. Doctrine¶
Le développeur comprend toujours ce qui est exécuté.
- Le JSON d'entité est la source canonique
- Le SQL reste visible dans le modèle applicatif
- Le code généré est lisible et modifiable
- Les fichiers existants ne sont jamais écrasés
mvc/routes.pyreste un fichier manuel — Forge ne l'écrit pas- Forge ne génère pas de repository magique ni d'ORM implicite
2. Cycle forge make:crud¶
flowchart TD
A["contact.json<br/>défini et validé"] --> B["forge build:model"]
B --> C["contact.sql + contact_base.py"]
C --> D["forge db:apply"]
D --> E["Table contact dans MariaDB"]
E --> F["forge make:crud Contact --dry-run"]
F --> G["Prévisualisation"]
G --> H["forge make:crud Contact"]
H --> I["Fichiers CRUD créés"]
I --> J["Copier les routes dans mvc/routes.py"]
J --> K["forge routes:list"]
K --> L["python app.py"]
Prérequis avant forge make:crud¶
forge make:entity Contact # créer l'entité
# éditer mvc/entities/contact/contact.json
forge build:model # générer contact.sql et contact_base.py
forge db:apply # créer la table dans MariaDB
Génération¶
forge make:crud Contact --dry-run # prévisualiser sans écrire
forge make:crud Contact # générer les fichiers
Sortie type :
[CRÉÉ] mvc/controllers/contact_controller.py
[CRÉÉ] mvc/models/contact_model.py
[CRÉÉ] mvc/forms/contact_form.py
[CRÉÉ] mvc/views/layouts/app.html
[CRÉÉ] mvc/views/contact/index.html
[CRÉÉ] mvc/views/contact/show.html
[CRÉÉ] mvc/views/contact/form.html
Routes à ajouter dans mvc/routes.py :
──────────────────────────────────────────────────────────────────────
from mvc.controllers.contact_controller import ContactController
with router.group("/contacts") as g:
g.add("GET", "", ContactController.index, name="contact_index")
g.add("GET", "/new", ContactController.new, name="contact_new")
g.add("POST", "", ContactController.create, name="contact_create")
g.add("GET", "/{id}", ContactController.show, name="contact_show")
g.add("GET", "/{id}/edit", ContactController.edit, name="contact_edit")
g.add("POST", "/{id}", ContactController.update, name="contact_update")
g.add("POST", "/{id}/delete", ContactController.destroy, name="contact_destroy")
Si un fichier existe déjà, il est marqué [PRÉSERVÉ] et non touché. Pour régénérer un fichier modifié manuellement, le supprimer avant de relancer la commande.
3. Routes¶
Les routes sont affichées par forge make:crud mais jamais injectées automatiquement. Les copier dans mvc/routes.py :
from mvc.controllers.contact_controller import ContactController
with router.group("/contacts") as g:
g.add("GET", "", ContactController.index, name="contact_index")
g.add("GET", "/new", ContactController.new, name="contact_new")
g.add("POST", "", ContactController.create, name="contact_create")
g.add("GET", "/{id}", ContactController.show, name="contact_show")
g.add("GET", "/{id}/edit", ContactController.edit, name="contact_edit")
g.add("POST", "/{id}", ContactController.update, name="contact_update")
g.add("POST", "/{id}/delete", ContactController.destroy, name="contact_destroy")
Ordre obligatoire
/new doit être déclaré avant /{id}. Le routeur parcourt les routes dans l'ordre — sinon new est capturé comme identifiant.
Par défaut, un groupe de routes sans public=True est protégé par les middlewares d'authentification.
4. Formulaires générés¶
Structure¶
core/forms/ ← mécanique générique (Form, Field, cleaned_data, erreurs)
mvc/forms/ ← formulaires applicatifs (ContactForm, LoginForm…)
mvc/validators/ ← règles réutilisables
Un formulaire Forge lit les données HTTP, valide, remplit cleaned_data et produit des erreurs affichables. Il ne fait pas de requête SQL et ne décide pas d'une redirection.
Exemple généré¶
from core.forms import Form, StringField
class ContactForm(Form):
nom = StringField(label="Nom", required=True, max_length=80)
prenom = StringField(label="Prénom", required=True, max_length=80)
email = StringField(label="Email", required=False, max_length=120)
telephone = StringField(label="Tél", required=False, max_length=20)
Utilisation dans un contrôleur¶
form = ContactForm.from_request(request)
if not form.is_valid():
return BaseController.validation_error(
"contact/form.html",
context={"form": form, "action": "/contacts", "titre": "Nouveau contact"},
request=request,
)
add_contact(form.cleaned_data)
return BaseController.redirect_with_flash(request, "/contacts", "Contact créé.")
Dans un template Jinja2¶
<input name="nom" value="{{ form.value('nom') }}">
{% if form.has_error('nom') %}
<p class="text-red-600">{{ form.error('nom') }}</p>
{% endif %}
Mappage SQL → champ de formulaire¶
| Type SQL | Champ Python généré |
|---|---|
VARCHAR(n), CHAR(n) |
StringField(max_length=n) |
TEXT, LONGTEXT |
StringField() + <textarea> dans le template |
INT, BIGINT, TINYINT |
IntegerField() |
DECIMAL, FLOAT, DOUBLE |
DecimalField() ; cleaned_data contient un Decimal |
BOOL, BOOLEAN |
BooleanField() |
DATE, DATETIME |
StringField() + avertissement [WARN] |
Mappage SQL → type d'input HTML¶
Le générateur déduit le type HTML à partir du type SQL et du nom du champ :
| Condition | type HTML généré |
|---|---|
python_type: "date" ou SQL DATE |
date |
python_type: "datetime" ou SQL DATETIME/TIMESTAMP |
datetime-local |
python_type: "int" ou "float" |
number |
python_type: "bool" |
checkbox |
Champ nommé email, mail, courriel |
email |
Champ nommé tel, telephone, phone, portable |
tel |
Champ nommé url, site, website, lien |
url |
SQL TEXT, TINYTEXT… |
<textarea> |
| Autres VARCHAR | text |
Choix numérique V1
Les JSON d'entité gardent python_type: "float" pour les types SQL décimaux afin de rester compatibles avec la doctrine V1. Le formulaire généré utilise toutefois DecimalField, plus sûr pour la saisie utilisateur. Si votre classe métier attend strictement un float, convertissez explicitement dans le contrôleur ou dans votre code applicatif manuel.
Relations many_to_one¶
Si mvc/entities/relations.json déclare une relation V1 many_to_one dont l'entité courante est from_entity, forge make:crud exploite cette relation dans le formulaire généré.
Exemple : Contact.ville_id -> Ville.id.
- le champ FK devient un
ChoiceField; - la vue
form.htmlgénère un<select>; - le contrôleur fournit les choix au formulaire ;
- le modèle généré ajoute une fonction simple de lecture des choix ;
- le SQL reste visible et explicite.
Comme le format V1 ne possède pas encore de label_field, Forge choisit le libellé affiché avec ce fallback :
- premier champ texte non-PK de l'entité cible ;
- sinon clé primaire cible.
Le CRUD sans relation, ou avec un relations.json vide, conserve le comportement classique.
5. Modèle applicatif SQL généré¶
Le modèle expose des fonctions avec SQL visible et paramétré. Pas d'abstraction cachée.
from core.database.connection import get_connection, close_connection
SELECT_ALL = "SELECT * FROM contact ORDER BY Id"
SELECT_BY_ID = "SELECT * FROM contact WHERE Id = ?"
INSERT = "INSERT INTO contact (Nom, Prenom, Email, Telephone) VALUES (?, ?, ?, ?)"
UPDATE = "UPDATE contact SET Nom = ?, Prenom = ?, Email = ?, Telephone = ? WHERE Id = ?"
DELETE = "DELETE FROM contact WHERE Id = ?"
def get_contacts():
connection = None
cursor = None
try:
connection = get_connection()
cursor = connection.cursor(dictionary=True)
cursor.execute(SELECT_ALL)
return cursor.fetchall()
finally:
if cursor:
cursor.close()
close_connection(connection)
def add_contact(data):
connection = None
cursor = None
try:
connection = get_connection()
cursor = connection.cursor()
cursor.execute(INSERT, (data["nom"], data["prenom"], data["email"], data["telephone"]))
connection.commit()
finally:
if cursor:
cursor.close()
close_connection(connection)
Règles appliquées :
- Noms de table et colonnes issus du JSON canonique
- Paramètres ? — jamais d'interpolation directe
- La clé primaire auto-incrémentée est exclue des INSERT
- INSERT, UPDATE, DELETE font un commit() explicite
- Connexion et curseur fermés dans un finally
Pagination, recherche et tri¶
Le modèle généré contient deux fonctions supplémentaires :
_SEARCH_COLS = ["Nom", "Email"] # champs VARCHAR/TEXT interrogeables
_ALLOWED_SORT = {"nom": "Nom", "email": "Email", "id": "Id"}
_DEFAULT_SORT = "Id"
def count_contacts(q=None):
"""Nombre d'enregistrements, avec filtre LIKE optionnel."""
...
def find_contacts_paginated(q=None, sort=None, direction="asc", limit=10, offset=0):
"""Liste paginée, triée et filtrée."""
...
_SEARCH_COLS— colonnesVARCHAR/CHAR/TEXT; la recherche est ignorée si la liste est vide._ALLOWED_SORT— seuls les champs déclarés dans le JSON sont acceptés comme clé de tri. Toute valeur inconnue revient au tri par défaut.- Le tri est construit par concaténation de chaînes whitelistées (
sort_coletsort_dir), jamais par interpolation de valeurs utilisateur. - La recherche utilise des
?paramétrés (LIKE ?) — pas d'injection possible.
Le contrôleur généré lit les paramètres GET page, q, sort, direction et passe un dictionnaire pagination à la vue.
6. Vues générées¶
forge make:crud crée un layout applicatif et trois vues :
mvc/views/layouts/app.html ← layout Jinja2 (créé si absent)
mvc/views/contact/index.html ← liste
mvc/views/contact/show.html ← détail
mvc/views/contact/form.html ← création et modification
Les vues héritent du layout :
Le champ CSRF est inclus dans tous les formulaires POST :
7. Personnalisation après génération¶
Les fichiers générés sont des points de départ — ils sont lisibles et à adapter librement.
| Fichier | Adaptations typiques |
|---|---|
contact_controller.py |
Ajouter @require_auth, pagination, tri, règles métier |
contact_form.py |
Ajouter ChoiceField, validations croisées via clean(), remplacer les StringField de type DATE |
contact_model.py |
Ajouter des requêtes métier, des jointures, de la pagination |
contact/index.html |
Ajouter des colonnes, la pagination, le tri |
layouts/app.html |
Menu de navigation, nom de l'application, styles |
Régénération partielle
Pour régénérer un seul fichier modifié, le supprimer puis relancer forge make:crud Contact.
Les autres fichiers du CRUD sont préservés ([PRÉSERVÉ]).
8. Limites V1.1¶
Ce que les relations many_to_one apportent déjà¶
Depuis V1.1, si mvc/entities/relations.json déclare une relation many_to_one dont l'entité courante est from_entity, le CRUD généré exploite cette relation dans le formulaire :
- le champ FK devient un
ChoiceField; - la vue
form.htmlgénère un<select>avec les options de la table cible ; - le contrôleur fournit les choix via une fonction dédiée ;
- le modèle ajoute
get_{cible}_choices()— requête SQL visible et explicite.
Ce que forge make:crud ne génère pas encore¶
forge make:crud ne génère pas encore automatiquement :
- les jointures SQL dans les vues liste et détail (le champ FK affiche son ID brut, pas le libellé de la table liée) ;
- les champs
RelatedIdsFieldpour les pivots ; - les permissions fines ;
- le
many_to_manydéclaratif ; - les relations ordonnées ou principales.
CRUD média : ce qui est généré et ce qui reste à venir¶
Les entités déclarant une clé "media" bénéficient d'une génération complète :
formulaire multipart avec ImageField/FileField, upload à la création, remplacement
et suppression explicite à l'édition, preview dans les vues show et edit, suppression
des fichiers physiques à la destruction de l'entité parente, galerie multiple=true
(multi-upload, affichage, suppression individuelle, réorganisation par position, alt_text).
Ce qui reste à venir :
- back-office média intégré ;
- permissions média (accès contrôlé aux fichiers servis via
/media/...).
Voir Référence API et CLI et Module média pour les détails.