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.
@@ -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,2 @@
1
+ # pyright: strict
2
+ """Commandes CLI de Forge Admin (dispatchées par `forge` quand le paquet est installé)."""
@@ -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."""