`recommendation` n'avait aucun écrivain : les quatre couches de lecture étaient livrées, mais rien ne produisait de ligne. Le moteur comble ce trou. Le catalogue `REGLES` vit dans `app/services/`, pas dans `ml/` : il lit `alert.type`, `alert.severity`, `alert.value` et `alert.threshold`, sans modèle ni feature, et s'appuie sur deux repositories existants. L'arbitrage avec l'ADR 0005, qui annonçait #38 du côté ML, est tranché par l'ADR 0006. Sept règles, cinq par type d'alerte et deux transverses (sévérité critique, dépassement d'au moins 20 % du seuil), donc une à trois recommandations par alerte. L'idempotence est portée par la base : `create_missing()` insère en `ON CONFLICT DO NOTHING` sur `uq_recommendation_alert_rule`, ce qui supprime la fenêtre entre un contrôle préalable et l'insertion. `rule_reference` devient de ce fait une clé fonctionnelle, d'où le suffixe de version sur chaque référence. Deux déclencheurs : `POST /api/v1/recommendations/generate` réservé `admin`, et `python -m app.cli generate-recommendations` (cible `make recommendations`). Limite connue : aucune source n'alimente `alert` aujourd'hui, ni détection interne (#104) ni ingestion de l'API Mock. La route répond, le rapport reste à zéro, et la chaîne s'allume sans retoucher le moteur le jour où les alertes existent. Tests : 80 unitaires et API verts, plus 6 d'intégration dont l'idempotence jouée contre PostgreSQL. Closes #38
202 lines
6.9 KiB
Python
202 lines
6.9 KiB
Python
# Pourquoi : `create_admin()` est une commande et non une révision Alembic. Une révision qui
|
|
# insérerait un compte graverait son empreinte dans Git pour toujours, et son mot de passe
|
|
# serait connu de quiconque lit le dépôt. L'ADR 0001 pose par ailleurs qu'Alembic porte le
|
|
# schéma, pas les données.
|
|
# Piège : le mot de passe ne transite jamais par `argv`, visible de tout `ps`, ni par
|
|
# l'historique du shell. Il est saisi par `getpass` ou tiré au sort par la commande.
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import secrets
|
|
import string
|
|
import sys
|
|
from getpass import getpass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pydantic import SecretStr
|
|
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.hashing import build_hasher
|
|
from app.core.roles import Role
|
|
from app.db.session import get_session_factory
|
|
from app.main import create_app
|
|
from app.repositories.alert import AlertRepository
|
|
from app.repositories.recommendation import RecommendationRepository
|
|
from app.repositories.user import UserRepository
|
|
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
|
|
from app.services.recommendation import RecommendationService
|
|
|
|
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
|
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
|
|
|
|
|
async def create_admin(
|
|
settings: Settings, *, email: str, password: str, force: bool
|
|
) -> tuple[bool, str]:
|
|
hacheur = build_hasher(
|
|
time_cost=settings.argon2_time_cost,
|
|
memory_cost_kib=settings.argon2_memory_cost_kib,
|
|
parallelism=settings.argon2_parallelism,
|
|
max_concurrency=settings.argon2_max_concurrency,
|
|
)
|
|
empreinte = await hacheur.hash(password)
|
|
|
|
async with get_session_factory()() as session:
|
|
depot = UserRepository(session)
|
|
|
|
if not force and await depot.count_active_admins() > 0:
|
|
return False, "Un administrateur actif existe déjà, relancer avec --force pour forcer"
|
|
|
|
if await depot.get_by_email(email) is not None:
|
|
return False, f"Le compte {email} existe déjà"
|
|
|
|
await depot.create(
|
|
email=email,
|
|
password_hash=empreinte,
|
|
role=Role.ADMIN,
|
|
must_change_password=True,
|
|
)
|
|
await session.commit()
|
|
|
|
return (
|
|
True,
|
|
f"Administrateur {email.strip().lower()} créé, mot de passe à changer à la connexion",
|
|
)
|
|
|
|
|
|
async def generate_recommendations(*, site_id: str | None) -> str:
|
|
async with get_session_factory()() as session:
|
|
service = RecommendationService(
|
|
recommendations=RecommendationRepository(session),
|
|
alerts=AlertRepository(session),
|
|
transaction=session,
|
|
)
|
|
rapport = await service.generate(site_id=site_id)
|
|
|
|
return (
|
|
f"{rapport.alertes_examinees} alerte(s) examinée(s), "
|
|
f"{rapport.recommandations_creees} recommandation(s) créée(s), "
|
|
f"{rapport.deja_presentes} déjà présente(s)"
|
|
)
|
|
|
|
|
|
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
|
|
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
|
|
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
|
|
def settings_du_contrat() -> Settings:
|
|
return Settings(
|
|
_env_file=None,
|
|
name="EnerVision API",
|
|
version="0.1.0",
|
|
env="local",
|
|
api_prefix="/api/v1",
|
|
secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"),
|
|
database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision",
|
|
)
|
|
|
|
|
|
def schema_du_contrat() -> dict[str, Any]:
|
|
schema: dict[str, Any] = create_app(settings_du_contrat()).openapi()
|
|
return schema
|
|
|
|
|
|
def rend_le_contrat() -> str:
|
|
return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n"
|
|
|
|
|
|
def export_openapi(destination: Path) -> str:
|
|
destination.write_text(rend_le_contrat(), encoding="utf-8")
|
|
return f"Contrat OpenAPI écrit dans {destination}"
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
|
|
sous_commandes = parser.add_subparsers(dest="commande", required=True)
|
|
|
|
admin = sous_commandes.add_parser("create-admin", help="Crée le premier administrateur")
|
|
admin.add_argument("--email", required=True)
|
|
admin.add_argument(
|
|
"--generate", action="store_true", help="Tire un mot de passe au sort et l'affiche une fois"
|
|
)
|
|
admin.add_argument(
|
|
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
|
|
)
|
|
|
|
contrat = sous_commandes.add_parser(
|
|
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
|
)
|
|
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
|
|
|
recommandations = sous_commandes.add_parser(
|
|
"generate-recommendations",
|
|
help="Applique le moteur de règles aux alertes en base",
|
|
)
|
|
recommandations.add_argument(
|
|
"--site-id", default=None, help="Limite le traitement aux alertes d'un site"
|
|
)
|
|
return parser
|
|
|
|
|
|
def genere_mot_de_passe() -> str:
|
|
tirage = secrets.SystemRandom()
|
|
classes = [
|
|
string.ascii_uppercase,
|
|
string.ascii_lowercase,
|
|
string.digits,
|
|
SPECIAL_CHARACTERS,
|
|
]
|
|
reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes)
|
|
caracteres = [tirage.choice(classe) for classe in classes]
|
|
caracteres += [tirage.choice("".join(classes)) for _ in range(reste)]
|
|
tirage.shuffle(caracteres)
|
|
return "".join(caracteres)
|
|
|
|
|
|
def read_password(*, generate: bool) -> str:
|
|
if generate:
|
|
mot_de_passe = genere_mot_de_passe()
|
|
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
|
|
return mot_de_passe
|
|
|
|
mot_de_passe = getpass("Mot de passe : ")
|
|
if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
|
|
raise SystemExit(f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères")
|
|
try:
|
|
valide_complexite(mot_de_passe)
|
|
except ValueError as erreur:
|
|
raise SystemExit(str(erreur)) from erreur
|
|
if mot_de_passe != getpass("Confirmation : "):
|
|
raise SystemExit("Les deux saisies diffèrent")
|
|
return mot_de_passe
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
arguments = build_parser().parse_args(argv)
|
|
|
|
if arguments.commande == "export-openapi":
|
|
print(export_openapi(Path(arguments.output)))
|
|
return 0
|
|
|
|
if arguments.commande == "generate-recommendations":
|
|
print(asyncio.run(generate_recommendations(site_id=arguments.site_id)))
|
|
return 0
|
|
|
|
mot_de_passe = read_password(generate=arguments.generate)
|
|
|
|
succes, message = asyncio.run(
|
|
create_admin(
|
|
get_settings(),
|
|
email=arguments.email,
|
|
password=mot_de_passe,
|
|
force=arguments.force,
|
|
)
|
|
)
|
|
print(message)
|
|
return 0 if succes else 1
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
sys.exit(main())
|