forge-mvc-admin 1.0.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge_mvc_admin/__init__.py +46 -0
- forge_mvc_admin/cli/__init__.py +2 -0
- forge_mvc_admin/cli/doctor.py +215 -0
- forge_mvc_admin/cli/init.py +124 -0
- forge_mvc_admin/exceptions.py +19 -0
- forge_mvc_admin/http.py +432 -0
- forge_mvc_admin/py.typed +0 -0
- forge_mvc_admin/query.py +157 -0
- forge_mvc_admin/registry.py +58 -0
- forge_mvc_admin/resources.py +106 -0
- forge_mvc_admin/templates/admin/dashboard.html +15 -0
- forge_mvc_admin/templates/admin/delete.html +17 -0
- forge_mvc_admin/templates/admin/detail.html +17 -0
- forge_mvc_admin/templates/admin/form.html +18 -0
- forge_mvc_admin/templates/admin/layout.html +16 -0
- forge_mvc_admin/templates/admin/list.html +29 -0
- forge_mvc_admin-1.0.0rc1.dist-info/METADATA +53 -0
- forge_mvc_admin-1.0.0rc1.dist-info/RECORD +21 -0
- forge_mvc_admin-1.0.0rc1.dist-info/WHEEL +5 -0
- forge_mvc_admin-1.0.0rc1.dist-info/licenses/LICENSE +36 -0
- forge_mvc_admin-1.0.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# pyright: strict
|
|
2
|
+
"""forge-mvc-admin — Opt-in de back-office applicatif Forge.
|
|
3
|
+
|
|
4
|
+
Couche châssis (couche 1 de l'architecture hybride, voir la roadmap de cadrage
|
|
5
|
+
section 7) : ce paquet porte le contrat d'une ressource administrable
|
|
6
|
+
(`AdminResource`) et le registre explicite (`AdminRegistry`) que les vues
|
|
7
|
+
consommeront. Les vues, les actions et la sécurité HTTP viendront par les
|
|
8
|
+
tickets `ADMIN-*` suivants.
|
|
9
|
+
|
|
10
|
+
Voir `docs/roadmap/forge-admin-roadmap.md`.
|
|
11
|
+
"""
|
|
12
|
+
from forge_mvc_admin.exceptions import (
|
|
13
|
+
AdminError,
|
|
14
|
+
AdminRegistryError,
|
|
15
|
+
AdminResourceError,
|
|
16
|
+
)
|
|
17
|
+
from forge_mvc_admin.http import AdminController, register_admin_routes
|
|
18
|
+
from forge_mvc_admin.registry import AdminRegistry, registry
|
|
19
|
+
from forge_mvc_admin.resources import AdminResource
|
|
20
|
+
|
|
21
|
+
__version__ = "1.0.0rc1"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AdminResource",
|
|
25
|
+
"AdminRegistry",
|
|
26
|
+
"registry",
|
|
27
|
+
"AdminController",
|
|
28
|
+
"register_admin_routes",
|
|
29
|
+
"AdminError",
|
|
30
|
+
"AdminResourceError",
|
|
31
|
+
"AdminRegistryError",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Enregistre les templates embarqués (templates/admin/…) auprès du cœur (ADR-046),
|
|
35
|
+
# de sorte que `render("admin/…")` les résolve. Dégradation gracieuse si le cœur
|
|
36
|
+
# ou jinja2 ne sont pas présents (ex. analyse statique du paquet hors runtime).
|
|
37
|
+
try:
|
|
38
|
+
from jinja2 import PackageLoader as _PackageLoader
|
|
39
|
+
|
|
40
|
+
from core.mvc.controller.registry import (
|
|
41
|
+
register_jinja_template_loader as _register_loader,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_register_loader(_PackageLoader("forge_mvc_admin", "templates"))
|
|
45
|
+
except ImportError:
|
|
46
|
+
pass
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# pyright: strict
|
|
2
|
+
"""Commande ``forge admin:doctor`` — ADMIN-DOCTOR-001.
|
|
3
|
+
|
|
4
|
+
Rapproche les ressources admin déclarées (objets ``AdminResource`` enregistrés
|
|
5
|
+
par ``mvc/admin/resources.py`` du projet) du contrat d'entité réel lu sur disque
|
|
6
|
+
(``mvc/entities/<snake>/<snake>.json``).
|
|
7
|
+
|
|
8
|
+
Lecture seule : aucune connexion base. La commande lit les déclarations et les
|
|
9
|
+
contrats, puis signale les écarts. Sévérité : ``fail`` seulement quand la
|
|
10
|
+
déclaration ne charge pas ; tout écart au contrat est un ``warn`` (le contrat
|
|
11
|
+
peut être en retard sur la base, et l'admin interroge la table directement).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Literal, cast
|
|
21
|
+
|
|
22
|
+
from forge_mvc_admin.exceptions import AdminResourceError
|
|
23
|
+
from forge_mvc_admin.registry import registry
|
|
24
|
+
from forge_mvc_admin.resources import AdminResource
|
|
25
|
+
|
|
26
|
+
__all__ = ["CheckResult", "reconcile", "run_all", "main"]
|
|
27
|
+
|
|
28
|
+
_TAGS = {"ok": "[OK] ", "warn": "[WARN]", "fail": "[FAIL]", "skip": "[SKIP]"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CheckResult:
|
|
33
|
+
status: Literal["ok", "warn", "fail", "skip"]
|
|
34
|
+
label: str
|
|
35
|
+
detail: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class _EntityContract:
|
|
40
|
+
table: str
|
|
41
|
+
columns: frozenset[str]
|
|
42
|
+
pk: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_package() -> CheckResult:
|
|
46
|
+
try:
|
|
47
|
+
import forge_mvc_admin
|
|
48
|
+
except Exception as exc: # pragma: no cover - le paquet est importé pour arriver ici
|
|
49
|
+
return CheckResult("fail", "paquet", f"import impossible : {exc}")
|
|
50
|
+
version = getattr(forge_mvc_admin, "__version__", None)
|
|
51
|
+
if not version:
|
|
52
|
+
return CheckResult("warn", "paquet", "importable mais sans __version__")
|
|
53
|
+
return CheckResult("ok", "paquet", f"forge-mvc-admin {version}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_entity_contracts(root: Path) -> dict[str, _EntityContract]:
|
|
57
|
+
"""Lit les contrats d'entité du projet, indexés par nom d'entité.
|
|
58
|
+
|
|
59
|
+
Lecture directe du JSON (lenient) : une colonne vaut ``column`` si présent,
|
|
60
|
+
sinon le nom logique du champ.
|
|
61
|
+
"""
|
|
62
|
+
contracts: dict[str, _EntityContract] = {}
|
|
63
|
+
entities_dir = root / "mvc" / "entities"
|
|
64
|
+
if not entities_dir.is_dir():
|
|
65
|
+
return contracts
|
|
66
|
+
for json_path in sorted(entities_dir.glob("*/*.json")):
|
|
67
|
+
try:
|
|
68
|
+
raw = json.loads(json_path.read_text(encoding="utf-8"))
|
|
69
|
+
except (json.JSONDecodeError, OSError):
|
|
70
|
+
continue
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
continue
|
|
73
|
+
data = cast("dict[str, Any]", raw)
|
|
74
|
+
name = data.get("name") or data.get("entity")
|
|
75
|
+
table = data.get("table")
|
|
76
|
+
if not isinstance(name, str) or not isinstance(table, str):
|
|
77
|
+
continue
|
|
78
|
+
columns: set[str] = set()
|
|
79
|
+
pk: str | None = None
|
|
80
|
+
fields = data.get("fields")
|
|
81
|
+
if isinstance(fields, list):
|
|
82
|
+
for raw_field in cast("list[Any]", fields):
|
|
83
|
+
if not isinstance(raw_field, dict):
|
|
84
|
+
continue
|
|
85
|
+
field = cast("dict[str, Any]", raw_field)
|
|
86
|
+
column = field.get("column") or field.get("name")
|
|
87
|
+
if isinstance(column, str):
|
|
88
|
+
columns.add(column)
|
|
89
|
+
if field.get("primary_key"):
|
|
90
|
+
pk = column
|
|
91
|
+
contracts[name] = _EntityContract(table=table, columns=frozenset(columns), pk=pk)
|
|
92
|
+
return contracts
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def reconcile(resource: AdminResource, contracts: dict[str, _EntityContract]) -> list[CheckResult]:
|
|
96
|
+
"""Rapproche une ressource d'un contrat d'entité. Écarts = `warn`."""
|
|
97
|
+
label = f"ressource '{resource.slug}'"
|
|
98
|
+
contract = contracts.get(resource.entity)
|
|
99
|
+
if contract is None:
|
|
100
|
+
return [
|
|
101
|
+
CheckResult(
|
|
102
|
+
"warn",
|
|
103
|
+
label,
|
|
104
|
+
f"entité {resource.entity!r} introuvable dans mvc/entities — "
|
|
105
|
+
"vérification impossible (la table est interrogée directement)",
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
issues: list[CheckResult] = []
|
|
110
|
+
if contract.table != resource.table:
|
|
111
|
+
issues.append(
|
|
112
|
+
CheckResult(
|
|
113
|
+
"warn",
|
|
114
|
+
label,
|
|
115
|
+
f"table déclarée {resource.table!r} ≠ contrat {contract.table!r}",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
checked = [
|
|
120
|
+
("list_fields", resource.list_fields),
|
|
121
|
+
("form_fields", resource.form_fields),
|
|
122
|
+
]
|
|
123
|
+
for kind, names in checked:
|
|
124
|
+
for column in names:
|
|
125
|
+
if column not in contract.columns:
|
|
126
|
+
issues.append(
|
|
127
|
+
CheckResult("warn", label, f"{kind} : colonne {column!r} absente du contrat")
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
order_by = resource.order_by or (resource.list_fields[0] if resource.list_fields else "")
|
|
131
|
+
if order_by and order_by not in contract.columns:
|
|
132
|
+
issues.append(CheckResult("warn", label, f"order_by : colonne {order_by!r} absente du contrat"))
|
|
133
|
+
|
|
134
|
+
if resource.pk not in contract.columns:
|
|
135
|
+
issues.append(CheckResult("warn", label, f"clé primaire {resource.pk!r} absente du contrat"))
|
|
136
|
+
|
|
137
|
+
if not issues:
|
|
138
|
+
return [CheckResult("ok", label, f"conforme au contrat {resource.entity}")]
|
|
139
|
+
return issues
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_declared_resources(root: Path) -> tuple[list[AdminResource], CheckResult | None]:
|
|
143
|
+
"""Importe ``mvc/admin/resources.py`` pour peupler le registre, puis le lit.
|
|
144
|
+
|
|
145
|
+
Repart d'un registre vide pour refléter exactement le module du projet, et
|
|
146
|
+
nettoie ``sys.path`` après import. Retourne ``(ressources, None)`` ou
|
|
147
|
+
``([], CheckResult)`` décrivant l'empêchement (skip/fail).
|
|
148
|
+
"""
|
|
149
|
+
module_file = root / "mvc" / "admin" / "resources.py"
|
|
150
|
+
if not module_file.is_file():
|
|
151
|
+
return [], CheckResult(
|
|
152
|
+
"skip", "ressources admin", "mvc/admin/resources.py absent — lance forge admin:init"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
registry.clear()
|
|
156
|
+
root_str = str(root)
|
|
157
|
+
added = root_str not in sys.path
|
|
158
|
+
if added:
|
|
159
|
+
sys.path.insert(0, root_str)
|
|
160
|
+
for module_name in ("mvc.admin.resources", "mvc.admin", "mvc"):
|
|
161
|
+
sys.modules.pop(module_name, None)
|
|
162
|
+
try:
|
|
163
|
+
importlib.import_module("mvc.admin.resources")
|
|
164
|
+
except AdminResourceError as exc:
|
|
165
|
+
return [], CheckResult("fail", "ressources admin", f"déclaration invalide : {exc}")
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
return [], CheckResult("fail", "ressources admin", f"import impossible : {exc}")
|
|
168
|
+
finally:
|
|
169
|
+
if added:
|
|
170
|
+
try:
|
|
171
|
+
sys.path.remove(root_str)
|
|
172
|
+
except ValueError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
return list(registry.all()), None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run_all(root: Path) -> list[CheckResult]:
|
|
179
|
+
results: list[CheckResult] = [_check_package()]
|
|
180
|
+
resources, problem = _load_declared_resources(root)
|
|
181
|
+
if problem is not None:
|
|
182
|
+
results.append(problem)
|
|
183
|
+
return results
|
|
184
|
+
if not resources:
|
|
185
|
+
results.append(CheckResult("warn", "ressources admin", "aucune ressource déclarée (registre vide)"))
|
|
186
|
+
return results
|
|
187
|
+
results.append(CheckResult("ok", "ressources admin", f"{len(resources)} ressource(s) déclarée(s)"))
|
|
188
|
+
contracts = _load_entity_contracts(root)
|
|
189
|
+
for resource in resources:
|
|
190
|
+
results.extend(reconcile(resource, contracts))
|
|
191
|
+
return results
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def print_report(results: list[CheckResult]) -> None:
|
|
195
|
+
for result in results:
|
|
196
|
+
tag = _TAGS.get(result.status, "[????]")
|
|
197
|
+
line = f"{tag} {result.label}"
|
|
198
|
+
if result.detail:
|
|
199
|
+
line += f" — {result.detail}"
|
|
200
|
+
print(line)
|
|
201
|
+
warns = sum(1 for r in results if r.status == "warn")
|
|
202
|
+
fails = sum(1 for r in results if r.status == "fail")
|
|
203
|
+
print()
|
|
204
|
+
print(f"{warns} avertissement(s), {fails} erreur(s).")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def has_failures(results: list[CheckResult]) -> bool:
|
|
208
|
+
return any(r.status == "fail" for r in results)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main(args: list[str] | None = None) -> int:
|
|
212
|
+
"""Point d'entrée appelé par ``forge.py`` pour ``forge admin:doctor``."""
|
|
213
|
+
results = run_all(Path.cwd())
|
|
214
|
+
print_report(results)
|
|
215
|
+
return 1 if has_failures(results) else 0
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# pyright: strict
|
|
2
|
+
"""Commande ``forge admin:init`` — ADMIN-INIT-COMMAND-001.
|
|
3
|
+
|
|
4
|
+
Prépare la structure ``mvc/admin/`` d'un projet Forge pour le back-office. À ce
|
|
5
|
+
stade du châssis, la commande crée :
|
|
6
|
+
|
|
7
|
+
- ``mvc/admin/__init__.py`` ;
|
|
8
|
+
- ``mvc/admin/resources.py`` : fichier où l'application déclare ses ressources
|
|
9
|
+
administrables (``AdminResource`` enregistrées dans le registre).
|
|
10
|
+
|
|
11
|
+
Volontairement scoped (mirror ``video:init`` / ``iot:init``) :
|
|
12
|
+
|
|
13
|
+
- idempotente : un fichier déjà présent à l'identique n'est pas réécrit ;
|
|
14
|
+
- jamais d'écrasement silencieux : un fichier présent au contenu différent
|
|
15
|
+
provoque un ``WARN`` et n'est pas touché (charte §9) ;
|
|
16
|
+
- pas de choix interactif ; pas de génération de vues ni de templates, qui
|
|
17
|
+
viendront avec les tickets de dashboard et de rendu.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"STATUS_OK",
|
|
25
|
+
"STATUS_INFO",
|
|
26
|
+
"STATUS_WARN",
|
|
27
|
+
"STATUS_ERROR",
|
|
28
|
+
"ADMIN_INIT_PY",
|
|
29
|
+
"ADMIN_RESOURCES_PY",
|
|
30
|
+
"init_admin",
|
|
31
|
+
"main",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
STATUS_OK = "[OK]"
|
|
35
|
+
STATUS_INFO = "[INFO]"
|
|
36
|
+
STATUS_WARN = "[WARN]"
|
|
37
|
+
STATUS_ERROR = "[ERREUR]"
|
|
38
|
+
|
|
39
|
+
ADMIN_INIT_PY = '''\
|
|
40
|
+
"""Back-office Forge Admin du projet.
|
|
41
|
+
|
|
42
|
+
Les ressources administrables sont déclarées dans ``resources.py``.
|
|
43
|
+
"""
|
|
44
|
+
'''
|
|
45
|
+
|
|
46
|
+
ADMIN_RESOURCES_PY = '''\
|
|
47
|
+
"""Ressources administrables du projet (Forge Admin).
|
|
48
|
+
|
|
49
|
+
Déclarez ici les entités à administrer en enregistrant des ``AdminResource``
|
|
50
|
+
dans le registre. Adaptez l'exemple à vos entités, puis décommentez-le.
|
|
51
|
+
|
|
52
|
+
from forge_mvc_admin import AdminResource, registry
|
|
53
|
+
|
|
54
|
+
registry.register(AdminResource(
|
|
55
|
+
entity="Article",
|
|
56
|
+
slug="articles",
|
|
57
|
+
label="Article",
|
|
58
|
+
plural_label="Articles",
|
|
59
|
+
list_fields=("title", "published_at"),
|
|
60
|
+
form_fields=("title", "body"),
|
|
61
|
+
table="articles",
|
|
62
|
+
))
|
|
63
|
+
"""
|
|
64
|
+
'''
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_if_new(target: Path, content: str) -> str:
|
|
68
|
+
"""Écrit `content` si le fichier est absent. N'écrase jamais.
|
|
69
|
+
|
|
70
|
+
Retourne ``"created"``, ``"identical"`` ou ``"different"``.
|
|
71
|
+
"""
|
|
72
|
+
if target.exists():
|
|
73
|
+
if target.read_text(encoding="utf-8") == content:
|
|
74
|
+
return "identical"
|
|
75
|
+
return "different"
|
|
76
|
+
target.write_text(content, encoding="utf-8")
|
|
77
|
+
return "created"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def init_admin(project_root: Path) -> int:
|
|
81
|
+
"""Crée la structure ``mvc/admin/`` sous ``project_root``.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
0
|
|
86
|
+
Succès, y compris en relecture idempotente.
|
|
87
|
+
1
|
|
88
|
+
Le dossier ``mvc/`` est absent (ce n'est pas un projet Forge).
|
|
89
|
+
"""
|
|
90
|
+
mvc_dir = project_root / "mvc"
|
|
91
|
+
if not mvc_dir.is_dir():
|
|
92
|
+
print(f"{STATUS_ERROR} Ce dossier ne ressemble pas à un projet Forge.")
|
|
93
|
+
print(
|
|
94
|
+
"Conseil : lance cette commande à la racine du projet "
|
|
95
|
+
"(dossier mvc/ attendu)."
|
|
96
|
+
)
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
admin_dir = mvc_dir / "admin"
|
|
100
|
+
if not admin_dir.exists():
|
|
101
|
+
admin_dir.mkdir(parents=True)
|
|
102
|
+
print(f"{STATUS_INFO} Dossier mvc/admin/ créé.")
|
|
103
|
+
|
|
104
|
+
files: list[tuple[str, Path, str]] = [
|
|
105
|
+
("mvc/admin/__init__.py", admin_dir / "__init__.py", ADMIN_INIT_PY),
|
|
106
|
+
("mvc/admin/resources.py", admin_dir / "resources.py", ADMIN_RESOURCES_PY),
|
|
107
|
+
]
|
|
108
|
+
for label, target, content in files:
|
|
109
|
+
status = _write_if_new(target, content)
|
|
110
|
+
if status == "created":
|
|
111
|
+
print(f"{STATUS_OK} Créé : {label}")
|
|
112
|
+
elif status == "identical":
|
|
113
|
+
print(f"{STATUS_OK} Déjà présent (identique) : {label}")
|
|
114
|
+
else:
|
|
115
|
+
print(f"{STATUS_WARN} {label} existe et diffère — aucune modification.")
|
|
116
|
+
|
|
117
|
+
print()
|
|
118
|
+
print(f"{STATUS_INFO} Déclarez vos ressources dans mvc/admin/resources.py.")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(args: list[str] | None = None) -> int:
|
|
123
|
+
"""Point d'entrée appelé par ``forge.py`` pour ``forge admin:init``."""
|
|
124
|
+
return init_admin(Path.cwd())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# pyright: strict
|
|
2
|
+
"""Exceptions de Forge Admin.
|
|
3
|
+
|
|
4
|
+
Toutes héritent de `ValueError` (convention des opt-ins Forge), via une base
|
|
5
|
+
commune `AdminError` qui permet d'attraper l'ensemble en un seul `except`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AdminError(ValueError):
|
|
11
|
+
"""Base de toutes les erreurs Forge Admin."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AdminResourceError(AdminError):
|
|
15
|
+
"""Contrat de ressource admin invalide."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdminRegistryError(AdminError):
|
|
19
|
+
"""Opération invalide sur le registre des ressources admin."""
|