feat: interface admin regles, refactoring viewer, README, pyproject.toml
- Nouveau module rules_manager.py : CRUD YAML pour les regles metier - Nouveau blueprint bp_rules.py + template admin_rules.html : interface web pour activer/desactiver/ajouter/supprimer des regles - Extraction helpers.py depuis app.py (filtres Jinja2, statistiques, scan dossiers, status systeme) — app.py passe de 1585 a 482 lignes - Suppression backward-compat re-exports dans cim10_extractor et cpam_response (imports corriges dans les tests) - README.md : architecture, modules, installation, utilisation - pyproject.toml : dependencies completes, config ruff, pytest, coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,19 +37,6 @@ from .cpam_validation import (
|
||||
_guardian_deterministic,
|
||||
)
|
||||
|
||||
# Backward compat — sera retiré dans un commit futur
|
||||
from .cpam_rag import _search_rag_queries # noqa: F401
|
||||
from .cpam_context import ( # noqa: F401
|
||||
_get_code_label,
|
||||
_get_cim10_definitions,
|
||||
_BIO_INTERPRETATION,
|
||||
_BIO_THRESHOLDS,
|
||||
_assess_dossier_strength,
|
||||
_build_bio_summary,
|
||||
_build_bio_confrontation,
|
||||
_check_das_bio_coherence,
|
||||
)
|
||||
from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial, _assess_quality_tier as _assess_quality_tier, _fuzzy_match_ref as _fuzzy_match_ref, _sanitize_unauthorized_codes as _sanitize_unauthorized_codes # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -54,12 +54,6 @@ from .validation_pipeline import (
|
||||
_validate_justifications,
|
||||
)
|
||||
|
||||
# Backward compat — sera retiré dans un commit futur
|
||||
from .bio_normals import BIO_NORMALS, _is_abnormal # noqa: F401
|
||||
from .validation_pipeline import _is_dp_family_redundant # noqa: F401
|
||||
from .diagnostic_extraction import _lookup_cim10 # noqa: F401
|
||||
from .diagnostic_extraction import _DAS_PATTERNS # noqa: F401
|
||||
from .diagnostic_extraction import _detect_nutrition_has2021 # noqa: F401
|
||||
|
||||
|
||||
def extract_medical_info(
|
||||
|
||||
1160
src/viewer/app.py
1160
src/viewer/app.py
File diff suppressed because it is too large
Load Diff
105
src/viewer/bp_rules.py
Normal file
105
src/viewer/bp_rules.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Blueprint Flask pour la gestion des règles métier (CRUD YAML)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
from .rules_manager import (
|
||||
list_rule_files,
|
||||
load_rule_file,
|
||||
toggle_rule,
|
||||
update_rule_field,
|
||||
add_rule,
|
||||
delete_rule,
|
||||
_find_file,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp_rules = Blueprint("rules", __name__)
|
||||
|
||||
|
||||
@bp_rules.route("/admin/rules")
|
||||
def admin_rules():
|
||||
"""Page principale de gestion des règles."""
|
||||
files = list_rule_files()
|
||||
# Pré-charger le contenu de chaque fichier
|
||||
for f in files:
|
||||
if f["exists"]:
|
||||
f["data"] = load_rule_file(f["id"])
|
||||
return render_template("admin_rules.html", rule_files=files)
|
||||
|
||||
|
||||
@bp_rules.route("/api/rules/<file_id>")
|
||||
def api_get_rules(file_id: str):
|
||||
"""Retourne le contenu complet d'un fichier de règles."""
|
||||
try:
|
||||
rf = _find_file(file_id)
|
||||
data = load_rule_file(file_id)
|
||||
return jsonify({"ok": True, "file_id": file_id, "label": rf["label"], "data": data})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@bp_rules.route("/api/rules/<file_id>/toggle", methods=["POST"])
|
||||
def api_toggle_rule(file_id: str):
|
||||
"""Active/désactive une règle."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
rule_path = body.get("rule_path", "")
|
||||
enabled = body.get("enabled", True)
|
||||
if not rule_path:
|
||||
return jsonify({"error": "rule_path requis"}), 400
|
||||
try:
|
||||
data = toggle_rule(file_id, rule_path, enabled)
|
||||
return jsonify({"ok": True, "data": data})
|
||||
except (ValueError, KeyError) as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@bp_rules.route("/api/rules/<file_id>/update", methods=["POST"])
|
||||
def api_update_rule(file_id: str):
|
||||
"""Met à jour un champ d'une règle."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
rule_path = body.get("rule_path", "")
|
||||
field = body.get("field", "")
|
||||
value = body.get("value")
|
||||
if not rule_path or not field:
|
||||
return jsonify({"error": "rule_path et field requis"}), 400
|
||||
try:
|
||||
data = update_rule_field(file_id, rule_path, field, value)
|
||||
return jsonify({"ok": True, "data": data})
|
||||
except (ValueError, KeyError) as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@bp_rules.route("/api/rules/<file_id>/add", methods=["POST"])
|
||||
def api_add_rule(file_id: str):
|
||||
"""Ajoute une nouvelle règle."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
parent_path = body.get("parent_path", "")
|
||||
rule_id = body.get("rule_id", "").strip()
|
||||
rule_data = body.get("rule_data", {})
|
||||
if not parent_path or not rule_id:
|
||||
return jsonify({"error": "parent_path et rule_id requis"}), 400
|
||||
try:
|
||||
data = add_rule(file_id, parent_path, rule_id, rule_data)
|
||||
return jsonify({"ok": True, "data": data})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@bp_rules.route("/api/rules/<file_id>/delete", methods=["POST"])
|
||||
def api_delete_rule(file_id: str):
|
||||
"""Supprime une règle."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
parent_path = body.get("parent_path", "")
|
||||
rule_id = body.get("rule_id", "").strip()
|
||||
if not parent_path or not rule_id:
|
||||
return jsonify({"error": "parent_path et rule_id requis"}), 400
|
||||
try:
|
||||
data = delete_rule(file_id, parent_path, rule_id)
|
||||
return jsonify({"ok": True, "data": data})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
702
src/viewer/helpers.py
Normal file
702
src/viewer/helpers.py
Normal file
@@ -0,0 +1,702 @@
|
||||
"""Fonctions utilitaires et filtres Jinja2 pour le viewer T2A."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from ..config import (
|
||||
ANONYMIZED_DIR, STRUCTURED_DIR, CCAM_DICT_PATH, DossierMedical,
|
||||
CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — statistiques & données
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_group_stats(items: list[dict]) -> dict:
|
||||
das_count = 0
|
||||
alertes_count = 0
|
||||
actes_count = 0
|
||||
cma_count = 0
|
||||
for item in items:
|
||||
d = item["dossier"]
|
||||
das_count += len(d.diagnostics_associes)
|
||||
alertes_count += len(d.alertes_codage)
|
||||
actes_count += len(d.actes_ccam)
|
||||
for diag in d.diagnostics_associes:
|
||||
if diag.est_cma:
|
||||
cma_count += 1
|
||||
if d.diagnostic_principal and d.diagnostic_principal.est_cma:
|
||||
cma_count += 1
|
||||
return {"das_count": das_count, "alertes_count": alertes_count,
|
||||
"actes_count": actes_count, "cma_count": cma_count}
|
||||
|
||||
|
||||
def compute_dashboard_stats(groups: dict[str, list[dict]]) -> dict:
|
||||
total_dossiers = len(groups)
|
||||
total_fichiers = 0
|
||||
total_das = 0
|
||||
total_actes = 0
|
||||
total_alertes = 0
|
||||
total_cma = 0
|
||||
total_cpam = 0
|
||||
dp_confidence: Counter = Counter()
|
||||
dp_validity: Counter = Counter()
|
||||
code_counter: Counter = Counter()
|
||||
ghm_types: Counter = Counter()
|
||||
severity_dist: Counter = Counter()
|
||||
processing_times: list[float] = []
|
||||
|
||||
for items in groups.values():
|
||||
total_fichiers += len(items)
|
||||
for item in items:
|
||||
d = item["dossier"]
|
||||
total_das += len(d.diagnostics_associes)
|
||||
total_actes += len(d.actes_ccam)
|
||||
total_alertes += len(d.alertes_codage)
|
||||
total_cpam += len(d.controles_cpam)
|
||||
if d.processing_time_s is not None:
|
||||
processing_times.append(d.processing_time_s)
|
||||
dp = d.diagnostic_principal
|
||||
if dp:
|
||||
dp_confidence[dp.cim10_confidence or "none"] += 1
|
||||
if dp.cim10_suggestion:
|
||||
dp_validity["valide"] += 1
|
||||
code_counter[dp.cim10_suggestion] += 1
|
||||
else:
|
||||
dp_validity["absent"] += 1
|
||||
else:
|
||||
dp_confidence["none"] += 1
|
||||
dp_validity["absent"] += 1
|
||||
for das in d.diagnostics_associes:
|
||||
if das.cim10_suggestion:
|
||||
code_counter[das.cim10_suggestion] += 1
|
||||
if das.est_cma:
|
||||
total_cma += 1
|
||||
if dp and dp.est_cma:
|
||||
total_cma += 1
|
||||
ghm = d.ghm_estimation
|
||||
if ghm:
|
||||
if ghm.type_ghm:
|
||||
ghm_types[ghm.type_ghm] += 1
|
||||
severity_dist[ghm.severite] += 1
|
||||
|
||||
top_codes = code_counter.most_common(15)
|
||||
top_max = top_codes[0][1] if top_codes else 1
|
||||
|
||||
return {
|
||||
"total_dossiers": total_dossiers,
|
||||
"total_fichiers": total_fichiers,
|
||||
"total_das": total_das,
|
||||
"total_actes": total_actes,
|
||||
"total_alertes": total_alertes,
|
||||
"total_cma": total_cma,
|
||||
"total_cpam": total_cpam,
|
||||
"dp_confidence": dict(dp_confidence),
|
||||
"dp_validity": dict(dp_validity),
|
||||
"top_codes": top_codes,
|
||||
"top_max": top_max,
|
||||
"ghm_types": dict(ghm_types),
|
||||
"severity_dist": dict(severity_dist),
|
||||
"processing_time_total": sum(processing_times),
|
||||
"processing_time_avg": sum(processing_times) / len(processing_times) if processing_times else 0,
|
||||
}
|
||||
|
||||
|
||||
def compute_dim_synthesis(groups: dict[str, list[dict]]) -> dict:
|
||||
dp_total = 0
|
||||
dp_confirmed = 0
|
||||
dp_review = 0
|
||||
dp_modified = 0
|
||||
dp_conf_dist: Counter = Counter()
|
||||
dp_source_dist: Counter = Counter()
|
||||
das_total = 0
|
||||
das_kept = 0
|
||||
das_downgraded = 0
|
||||
das_removed = 0
|
||||
das_ruled_out = 0
|
||||
das_cma = 0
|
||||
das_no_code = 0
|
||||
veto_dist: Counter = Counter()
|
||||
veto_scores: list[int] = []
|
||||
top_vetos: Counter = Counter()
|
||||
completude_dist: Counter = Counter()
|
||||
completude_scores: list[int] = []
|
||||
cpam_total = 0
|
||||
cpam_impact_total = 0
|
||||
cpam_by_priority: Counter = Counter()
|
||||
cpam_by_status: Counter = Counter()
|
||||
dossiers_review: list[dict] = []
|
||||
dossiers_fail: list[dict] = []
|
||||
dossiers_indefendable: list[dict] = []
|
||||
|
||||
for group_name, items in groups.items():
|
||||
for item in items:
|
||||
d = item["dossier"]
|
||||
dname = format_dossier_name(group_name)
|
||||
dpath = item["path_rel"]
|
||||
|
||||
dp_final = d.dp_final
|
||||
dp_track = d.dp_trackare
|
||||
if dp_final:
|
||||
dp_total += 1
|
||||
dp_conf_dist[dp_final.confidence or "none"] += 1
|
||||
if dp_final.verdict == "CONFIRMED":
|
||||
dp_confirmed += 1
|
||||
else:
|
||||
dp_review += 1
|
||||
dossiers_review.append({"name": dname, "path": dpath,
|
||||
"reason": dp_final.reason or "DP à valider",
|
||||
"code": dp_final.chosen_code or "?"})
|
||||
if dp_track and dp_final.chosen_code and dp_track.chosen_code:
|
||||
if dp_final.chosen_code != dp_track.chosen_code:
|
||||
dp_modified += 1
|
||||
flags = d.quality_flags or {}
|
||||
if flags.get("trackare_only_mode"):
|
||||
dp_source_dist["trackare"] += 1
|
||||
elif flags.get("crh_only_mode"):
|
||||
dp_source_dist["crh"] += 1
|
||||
elif flags.get("override_trackare_by_crh_confirmed") or flags.get("trackare_symptom_overridden"):
|
||||
dp_source_dist["override_crh"] += 1
|
||||
elif flags.get("trackare_confirmed_by_crh"):
|
||||
dp_source_dist["confirmé"] += 1
|
||||
else:
|
||||
dp_source_dist["autre"] += 1
|
||||
elif d.diagnostic_principal:
|
||||
dp_total += 1
|
||||
dp_conf_dist[d.diagnostic_principal.cim10_confidence or "none"] += 1
|
||||
|
||||
for das in d.diagnostics_associes:
|
||||
das_total += 1
|
||||
dec = das.cim10_decision
|
||||
if dec:
|
||||
action = dec.action
|
||||
if action == "KEEP":
|
||||
das_kept += 1
|
||||
elif action == "DOWNGRADE":
|
||||
das_downgraded += 1
|
||||
elif action == "REMOVE":
|
||||
das_removed += 1
|
||||
elif action == "RULED_OUT":
|
||||
das_ruled_out += 1
|
||||
else:
|
||||
das_kept += 1
|
||||
else:
|
||||
das_kept += 1
|
||||
if das.est_cma:
|
||||
das_cma += 1
|
||||
if not das.cim10_final and not das.cim10_suggestion:
|
||||
das_no_code += 1
|
||||
|
||||
vr = d.veto_report
|
||||
if vr:
|
||||
veto_dist[vr.verdict] += 1
|
||||
veto_scores.append(vr.score_contestabilite)
|
||||
for issue in (vr.issues or []):
|
||||
top_vetos[issue.veto] += 1
|
||||
if vr.verdict == "FAIL":
|
||||
dossiers_fail.append({"name": dname, "path": dpath,
|
||||
"score": vr.score_contestabilite,
|
||||
"issues": len(vr.issues or [])})
|
||||
|
||||
comp = d.completude
|
||||
if comp:
|
||||
completude_dist[comp.verdict_global] += 1
|
||||
completude_scores.append(comp.score_global)
|
||||
if comp.verdict_global == "indefendable":
|
||||
dossiers_indefendable.append({"name": dname, "path": dpath,
|
||||
"score": comp.score_global,
|
||||
"manquants": len(comp.documents_manquants or [])})
|
||||
|
||||
for ctrl in d.controles_cpam:
|
||||
cpam_total += 1
|
||||
fi = ctrl.financial_impact
|
||||
if fi:
|
||||
cpam_impact_total += fi.impact_estime_euros or 0
|
||||
cpam_by_priority[fi.priorite or "normale"] += 1
|
||||
cpam_by_status[ctrl.validation_dim or "non_valide"] += 1
|
||||
|
||||
avg_veto = round(sum(veto_scores) / len(veto_scores)) if veto_scores else 0
|
||||
avg_completude = round(sum(completude_scores) / len(completude_scores)) if completude_scores else 0
|
||||
|
||||
return {
|
||||
"dp": {"total": dp_total, "confirmed": dp_confirmed, "review": dp_review,
|
||||
"modified": dp_modified, "confidence": dict(dp_conf_dist),
|
||||
"source": dict(dp_source_dist)},
|
||||
"das": {"total": das_total, "kept": das_kept, "downgraded": das_downgraded,
|
||||
"removed": das_removed, "ruled_out": das_ruled_out, "cma": das_cma,
|
||||
"no_code": das_no_code,
|
||||
"taux_modification": round((das_downgraded + das_removed + das_ruled_out) / das_total * 100, 1) if das_total else 0},
|
||||
"veto": {"distribution": dict(veto_dist), "avg_score": avg_veto,
|
||||
"top_issues": top_vetos.most_common(10)},
|
||||
"completude": {"distribution": dict(completude_dist), "avg_score": avg_completude},
|
||||
"cpam": {"total": cpam_total, "impact_total": cpam_impact_total,
|
||||
"by_priority": dict(cpam_by_priority), "by_status": dict(cpam_by_status)},
|
||||
"alertes": {"review": dossiers_review[:20], "fail": dossiers_fail[:20],
|
||||
"indefendable": dossiers_indefendable[:20]},
|
||||
}
|
||||
|
||||
|
||||
def _compute_jours_restants(ctrl) -> int | None:
|
||||
if not ctrl.date_limite_reponse:
|
||||
return None
|
||||
from datetime import datetime
|
||||
try:
|
||||
limite = datetime.strptime(ctrl.date_limite_reponse, "%d/%m/%Y")
|
||||
return (limite - datetime.now()).days
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def collect_cpam_controls(groups: dict[str, list[dict]]) -> list[dict]:
|
||||
from ..medical.ghm import estimate_financial_impact
|
||||
_PRIORITE_ORDER = {"critique": 0, "haute": 1, "normale": 2, "faible": 3}
|
||||
controls = []
|
||||
for group_name, items in groups.items():
|
||||
for item in items:
|
||||
d = item["dossier"]
|
||||
dp_code = d.diagnostic_principal.cim10_suggestion if d.diagnostic_principal else None
|
||||
for ctrl in d.controles_cpam:
|
||||
if ctrl.financial_impact is None and d.ghm_estimation:
|
||||
ctrl.financial_impact = estimate_financial_impact(d.ghm_estimation)
|
||||
controls.append({
|
||||
"group_name": group_name, "filepath": item["path_rel"],
|
||||
"ctrl": ctrl, "dp_code": dp_code,
|
||||
"jours_restants": _compute_jours_restants(ctrl),
|
||||
})
|
||||
controls.sort(key=lambda c: (
|
||||
_PRIORITE_ORDER.get(
|
||||
c["ctrl"].financial_impact.priorite if c["ctrl"].financial_impact else "normale", 2),
|
||||
0 if "confirme" in (c["ctrl"].decision_ucr or "").lower() else 1,
|
||||
c["ctrl"].numero_ogc,
|
||||
))
|
||||
return controls
|
||||
|
||||
|
||||
def get_builtin_referentiels() -> list[dict]:
|
||||
from ..config import BASE_DIR, REFERENTIELS_DIR
|
||||
import datetime as _dt
|
||||
rag_index_dir = BASE_DIR / "data" / "rag_index"
|
||||
|
||||
chunks_by_doc: dict[str, int] = {}
|
||||
for meta_file in rag_index_dir.glob("metadata*.json"):
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
for m in meta:
|
||||
doc = m.get("document", "")
|
||||
chunks_by_doc[doc] = chunks_by_doc.get(doc, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
refs = []
|
||||
builtin_sources = [
|
||||
("CIM-10 FR 2026", CIM10_PDF, ".pdf", ["cim10", "cim10_alpha"], "11/12/2025", "2026 (provisoire)"),
|
||||
("Guide Méthodologique MCO 2026", GUIDE_METHODO_PDF, ".pdf", ["guide_methodo"], "2025", "2026 (provisoire)"),
|
||||
("CCAM descriptive PMSI V4", CCAM_PDF, ".pdf", ["ccam"], "2025", "V4 2025"),
|
||||
("Dictionnaire CIM-10", CIM10_DICT_PATH, ".json", [], "", ""),
|
||||
("Suppléments CIM-10", CIM10_SUPPLEMENTS_PATH, ".json", [], "", ""),
|
||||
("Dictionnaire CCAM", CCAM_DICT_PATH, ".json", [], "", ""),
|
||||
]
|
||||
for name, path, ext, doc_keys, edition, validite in builtin_sources:
|
||||
size_mb = path.stat().st_size / (1024 * 1024) if path.exists() else 0
|
||||
mtime = ""
|
||||
if path.exists():
|
||||
mtime = _dt.datetime.fromtimestamp(path.stat().st_mtime).strftime("%d/%m/%Y")
|
||||
chunks = sum(chunks_by_doc.get(k, 0) for k in doc_keys)
|
||||
refs.append({"name": name, "filename": path.name, "extension": ext,
|
||||
"size_mb": size_mb, "chunks": chunks, "exists": path.exists(),
|
||||
"edition": edition, "validite": validite, "file_date": mtime})
|
||||
|
||||
pdfs_dir = REFERENTIELS_DIR / "pdfs"
|
||||
for doc_name, count in sorted(chunks_by_doc.items()):
|
||||
if doc_name.startswith("ref:") or doc_name.startswith("proc:"):
|
||||
prefix, fname = doc_name.split(":", 1)
|
||||
pdf_path = pdfs_dir / fname
|
||||
size_mb = pdf_path.stat().st_size / (1024 * 1024) if pdf_path.exists() else 0
|
||||
mtime = ""
|
||||
if pdf_path.exists():
|
||||
mtime = _dt.datetime.fromtimestamp(pdf_path.stat().st_mtime).strftime("%d/%m/%Y")
|
||||
refs.append({"name": fname.replace("_", " ").replace(".pdf", ""),
|
||||
"filename": fname, "extension": ".pdf", "size_mb": size_mb,
|
||||
"chunks": count, "exists": pdf_path.exists(), "edition": "",
|
||||
"validite": "", "file_date": mtime, "category": prefix})
|
||||
return refs
|
||||
|
||||
|
||||
def get_faiss_index_info() -> dict:
|
||||
from ..config import BASE_DIR
|
||||
from ..medical.rag_index import check_faiss_ready
|
||||
import datetime as _dt
|
||||
rag_dir = BASE_DIR / "data" / "rag_index"
|
||||
info = {"ok": False, "indexes": [], "total_vectors": 0, "last_build": ""}
|
||||
status = check_faiss_ready()
|
||||
info["ok"] = status["ok"]
|
||||
info["total_vectors"] = status["ref"] + status["proc"] + status["bio"] + status["legacy"]
|
||||
for kind, label in [("ref", "Référentiels CIM-10"), ("proc", "Procédures/Guides"),
|
||||
("bio", "Biologie"), ("all", "Legacy (combiné)")]:
|
||||
idx_file = rag_dir / f"faiss_{kind}.index" if kind != "all" else rag_dir / "faiss.index"
|
||||
count = status.get(kind, status.get("legacy", 0)) if kind == "all" else status.get(kind, 0)
|
||||
mtime = ""
|
||||
size_mb = 0
|
||||
if idx_file.exists():
|
||||
mtime = _dt.datetime.fromtimestamp(idx_file.stat().st_mtime).strftime("%d/%m/%Y %H:%M")
|
||||
size_mb = idx_file.stat().st_size / (1024 * 1024)
|
||||
info["indexes"].append({"kind": kind, "label": label, "vectors": count,
|
||||
"size_mb": round(size_mb, 1), "last_build": mtime,
|
||||
"exists": idx_file.exists()})
|
||||
if mtime and (not info["last_build"] or mtime > info["last_build"]):
|
||||
info["last_build"] = mtime
|
||||
return info
|
||||
|
||||
|
||||
def load_ccam_dict() -> dict[str, dict]:
|
||||
if CCAM_DICT_PATH.exists():
|
||||
try:
|
||||
return json.loads(CCAM_DICT_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
logger.warning("Impossible de charger le dictionnaire CCAM")
|
||||
return {}
|
||||
|
||||
|
||||
_scan_cache: dict[str, object] = {"data": None, "ts": 0.0}
|
||||
_SCAN_TTL = 30
|
||||
|
||||
|
||||
def scan_dossiers() -> dict[str, list[dict]]:
|
||||
now = time.monotonic()
|
||||
if _scan_cache["data"] is not None and (now - _scan_cache["ts"]) < _SCAN_TTL:
|
||||
return _scan_cache["data"]
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for json_path in sorted(STRUCTURED_DIR.rglob("*.json")):
|
||||
rel = json_path.relative_to(STRUCTURED_DIR)
|
||||
parts = rel.parts
|
||||
group_name = "racine" if len(parts) == 1 else str(Path(*parts[:-1]))
|
||||
try:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
dossier = DossierMedical.model_validate(data)
|
||||
except Exception:
|
||||
logger.warning("Impossible de charger %s", json_path)
|
||||
continue
|
||||
groups.setdefault(group_name, []).append({
|
||||
"name": json_path.stem, "path_rel": str(rel), "dossier": dossier,
|
||||
})
|
||||
_scan_cache["data"] = groups
|
||||
_scan_cache["ts"] = now
|
||||
return groups
|
||||
|
||||
|
||||
def load_dossier(path_rel: str) -> DossierMedical:
|
||||
from flask import abort
|
||||
safe_path = (STRUCTURED_DIR / path_rel).resolve()
|
||||
if not safe_path.is_relative_to(STRUCTURED_DIR.resolve()):
|
||||
abort(403)
|
||||
if not safe_path.exists():
|
||||
abort(404)
|
||||
data = json.loads(safe_path.read_text(encoding="utf-8"))
|
||||
return DossierMedical.model_validate(data)
|
||||
|
||||
|
||||
def fetch_ollama_models() -> list[str]:
|
||||
import requests
|
||||
from .. import config as cfg
|
||||
try:
|
||||
resp = requests.get(f"{cfg.OLLAMA_URL}/api/tags", timeout=5)
|
||||
resp.raise_for_status()
|
||||
return [m["name"] for m in resp.json().get("models", [])]
|
||||
except Exception:
|
||||
logger.warning("Impossible de contacter Ollama pour lister les modèles")
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filtres Jinja2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONFIDENCE_COLORS = {
|
||||
"high": ("#16a34a", "#dcfce7"),
|
||||
"medium": ("#ca8a04", "#fef9c3"),
|
||||
"low": ("#dc2626", "#fee2e2"),
|
||||
}
|
||||
_CONFIDENCE_LABELS = {"high": "Haute", "medium": "Moyenne", "low": "Basse"}
|
||||
|
||||
|
||||
_CONFIDENCE_TIPS = {
|
||||
"high": "Confiance haute — le pipeline est très sûr de ce code CIM-10",
|
||||
"medium": "Confiance moyenne — le code est probable mais mérite vérification",
|
||||
"low": "Confiance basse — code incertain, relecture médicale recommandée",
|
||||
}
|
||||
|
||||
|
||||
def confidence_badge(value: str | None) -> Markup:
|
||||
if not value:
|
||||
return Markup("")
|
||||
fg, bg = _CONFIDENCE_COLORS.get(value, ("#6b7280", "#f3f4f6"))
|
||||
label = _CONFIDENCE_LABELS.get(value, value)
|
||||
tip = _CONFIDENCE_TIPS.get(value, "Niveau de confiance du pipeline sur ce code")
|
||||
return Markup(
|
||||
f'<span title="{tip}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
|
||||
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">{label}</span>')
|
||||
|
||||
|
||||
def confidence_label(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return _CONFIDENCE_LABELS.get(value, value)
|
||||
|
||||
|
||||
_SEVERITY_STYLES = {
|
||||
"severe": ("Sévère", "#dc2626", "#fee2e2"),
|
||||
"modere": ("Modéré", "#92400e", "#fef3c7"),
|
||||
"leger": ("Léger", "#065f46", "#d1fae5"),
|
||||
}
|
||||
_CMA_LEVEL_STYLES = {
|
||||
1: ("1", "#6b7280", "#f3f4f6"),
|
||||
2: ("2", "#065f46", "#d1fae5"),
|
||||
3: ("3", "#92400e", "#fef3c7"),
|
||||
4: ("4", "#dc2626", "#fee2e2"),
|
||||
}
|
||||
|
||||
|
||||
def format_duration(seconds: float | None) -> str:
|
||||
if seconds is None:
|
||||
return ""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = int(seconds // 60)
|
||||
secs = int(seconds % 60)
|
||||
if secs == 0:
|
||||
return f"{minutes}min"
|
||||
return f"{minutes}min {secs:02d}s"
|
||||
|
||||
|
||||
_SEVERITY_TIPS = {
|
||||
"severe": "Impact clinique sévère — complication ou morbidité majeure augmentant significativement la valorisation T2A",
|
||||
"modere": "Impact clinique modéré — complication ou morbidité d'importance intermédiaire",
|
||||
"leger": "Impact clinique léger — séjour sans complication significative",
|
||||
}
|
||||
|
||||
|
||||
def severity_badge(value: str | None) -> Markup:
|
||||
if not value or value not in _SEVERITY_STYLES:
|
||||
return Markup("")
|
||||
label, fg, bg = _SEVERITY_STYLES[value]
|
||||
tip = _SEVERITY_TIPS.get(value, "")
|
||||
return Markup(
|
||||
f'<span title="{tip}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
|
||||
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">{label}</span>')
|
||||
|
||||
|
||||
def cma_level_badge(value: int | None) -> Markup:
|
||||
if value is None or value < 1:
|
||||
return Markup("")
|
||||
level = min(value, 4)
|
||||
label, fg, bg = _CMA_LEVEL_STYLES.get(level, _CMA_LEVEL_STYLES[1])
|
||||
title = {
|
||||
1: "Pas de CMA — ce diagnostic n'augmente pas la sévérité du GHM",
|
||||
2: "CMA niveau 2 — comorbidité mineure augmentant légèrement la sévérité",
|
||||
3: "CMA niveau 3 — comorbidité majeure augmentant significativement la sévérité",
|
||||
4: "CMA niveau 4 — comorbidité très sévère (réanimation, décès, etc.)",
|
||||
}.get(level, "")
|
||||
return Markup(
|
||||
f'<span title="{title}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
|
||||
f'font-size:0.75rem;font-weight:600;white-space:nowrap;color:{fg};background:{bg}">CMA {label}</span>')
|
||||
|
||||
|
||||
def format_dossier_name(name: str) -> str:
|
||||
if name == "racine":
|
||||
return "Non classés"
|
||||
return name
|
||||
|
||||
|
||||
def format_doc_name(name: str) -> str:
|
||||
n = name.lower()
|
||||
if "fusionne" in n:
|
||||
return "Fusionné"
|
||||
if n.startswith("cro") or n.startswith("crh"):
|
||||
return name.split("_")[0].upper()
|
||||
if "trackare" in n:
|
||||
return "Trackare"
|
||||
if "anapath" in n:
|
||||
return "Anapath"
|
||||
return name
|
||||
|
||||
|
||||
def decision_badge(decision) -> Markup:
|
||||
if not decision:
|
||||
return Markup("")
|
||||
action = decision.get("action", "KEEP") if isinstance(decision, dict) else getattr(decision, "action", "KEEP")
|
||||
if action == "KEEP":
|
||||
return Markup("")
|
||||
labels = {
|
||||
"DOWNGRADE": ("Rétrogradé", "#fef3c7", "#92400e", "Le niveau de confiance de ce diagnostic a été abaissé par le moteur de règles"),
|
||||
"REMOVE": ("Supprimé", "#fee2e2", "#dc2626", "Ce diagnostic a été retiré du codage car jugé non pertinent ou non étayé"),
|
||||
"RULED_OUT": ("Écarté (Contradiction)", "#f1f5f9", "#64748b", "Ce diagnostic a été écarté car il contredit une règle ATIH (exclusion, doublon, etc.)"),
|
||||
"NEED_INFO": ("Preuve manquante", "#fff7ed", "#c2410c", "Ce diagnostic nécessite des preuves cliniques supplémentaires pour être validé"),
|
||||
"PROMOTE_DP": ("Promu en DP", "#dbeafe", "#1d4ed8", "Ce diagnostic a été promu en Diagnostic Principal car plus pertinent que le DP initial"),
|
||||
}
|
||||
info = labels.get(action, (action, "#f1f5f9", "#64748b", ""))
|
||||
label, bg, fg = info[0], info[1], info[2]
|
||||
tip = info[3] if len(info) > 3 else ""
|
||||
return Markup(f'<span class="badge" style="background:{bg};color:{fg};font-size:0.7rem;" title="{tip}">{label}</span>')
|
||||
|
||||
|
||||
def format_cpam_text(text: str | None) -> Markup:
|
||||
if not text:
|
||||
return Markup("")
|
||||
from markupsafe import escape
|
||||
lines = str(text).split("\n")
|
||||
html_parts: list[str] = []
|
||||
in_list = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
if in_list:
|
||||
html_parts.append("</ul>")
|
||||
in_list = False
|
||||
html_parts.append("<br>")
|
||||
continue
|
||||
if stripped.startswith("- "):
|
||||
if not in_list:
|
||||
html_parts.append("<ul style='margin:0.3rem 0;padding-left:1.2rem;'>")
|
||||
in_list = True
|
||||
html_parts.append(f"<li>{escape(stripped[2:])}</li>")
|
||||
else:
|
||||
if in_list:
|
||||
html_parts.append("</ul>")
|
||||
in_list = False
|
||||
html_parts.append(f"<p style='margin:0.2rem 0;'>{escape(stripped)}</p>")
|
||||
if in_list:
|
||||
html_parts.append("</ul>")
|
||||
return Markup("\n".join(html_parts))
|
||||
|
||||
|
||||
def human_where(value: str | None) -> str:
|
||||
if not value:
|
||||
return "Global"
|
||||
if value == "diagnostic_principal":
|
||||
return "Diagnostic Principal"
|
||||
if value == "diagnostics_associes":
|
||||
return "Diagnostics Associés"
|
||||
if value == "sejour":
|
||||
return "Séjour"
|
||||
m = re.match(r"diagnostics_associes\[(\d+)\]", value)
|
||||
if m:
|
||||
return f"DAS n°{int(m.group(1)) + 1}"
|
||||
m = re.match(r"actes_ccam\[(\d+)\]", value)
|
||||
if m:
|
||||
return f"Acte n°{int(m.group(1)) + 1}"
|
||||
return value
|
||||
|
||||
|
||||
def _date_to_iso(date_fr: str) -> str:
|
||||
try:
|
||||
parts = date_fr.strip().split("/")
|
||||
if len(parts) == 3:
|
||||
return f"{parts[2]}-{parts[1]}-{parts[0]}"
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
_status_cache: dict[str, object] = {"data": None, "ts": 0.0}
|
||||
_STATUS_TTL = 120
|
||||
|
||||
|
||||
def _get_system_status() -> list[dict]:
|
||||
import os
|
||||
import requests
|
||||
now = time.monotonic()
|
||||
if _status_cache["data"] is not None and (now - _status_cache["ts"]) < _STATUS_TTL:
|
||||
return _status_cache["data"]
|
||||
from ..config import OLLAMA_URL, OLLAMA_MODELS
|
||||
components = []
|
||||
components.append({"name": "Moteur de règles (VetoEngine)", "status": True, "detail": "Actif"})
|
||||
ollama_ok = False
|
||||
ollama_detail = "Non disponible"
|
||||
try:
|
||||
r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=3)
|
||||
if r.status_code == 200:
|
||||
ollama_ok = True
|
||||
ollama_detail = ", ".join(f"{role}={model}" for role, model in OLLAMA_MODELS.items())
|
||||
except Exception:
|
||||
pass
|
||||
components.append({"name": "LLM Ollama", "status": ollama_ok, "detail": ollama_detail})
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
components.append({"name": "Fallback Anthropic (Haiku)", "status": bool(api_key),
|
||||
"detail": "Clé configurée" if api_key else "Clé absente"})
|
||||
try:
|
||||
from ..medical.rag_index import check_faiss_ready
|
||||
faiss_check = check_faiss_ready()
|
||||
if faiss_check["ok"]:
|
||||
total = faiss_check["ref"] + faiss_check["proc"] + faiss_check["bio"] + faiss_check["legacy"]
|
||||
parts = []
|
||||
if faiss_check["ref"]:
|
||||
parts.append(f"ref={faiss_check['ref']}")
|
||||
if faiss_check["proc"]:
|
||||
parts.append(f"proc={faiss_check['proc']}")
|
||||
if faiss_check["bio"]:
|
||||
parts.append(f"bio={faiss_check['bio']}")
|
||||
detail = f"{total} vecteurs ({', '.join(parts)})"
|
||||
else:
|
||||
detail = "; ".join(faiss_check["errors"][:2])
|
||||
components.append({"name": "Index FAISS (RAG)", "status": faiss_check["ok"], "detail": detail})
|
||||
except Exception as e:
|
||||
components.append({"name": "Index FAISS (RAG)", "status": False,
|
||||
"detail": f"Erreur vérification : {e}"})
|
||||
components.append({"name": "Extraction PDF (pdfplumber)", "status": True, "detail": "Actif"})
|
||||
ner_ok = False
|
||||
try:
|
||||
from transformers import AutoTokenizer
|
||||
AutoTokenizer.from_pretrained("Jean-Baptiste/camembert-ner", local_files_only=True)
|
||||
ner_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
components.append({"name": "Anonymisation NER (CamemBERT)", "status": ner_ok,
|
||||
"detail": "Modèle en cache" if ner_ok else "Modèle non trouvé"})
|
||||
emb_ok = False
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
result = try_to_load_from_cache("dangvantuan/sentence-camembert-large", "config.json")
|
||||
emb_ok = result is not None and isinstance(result, str)
|
||||
except Exception:
|
||||
pass
|
||||
components.append({"name": "Embeddings (sentence-camembert-large)", "status": emb_ok,
|
||||
"detail": "Modèle en cache" if emb_ok else "Modèle non trouvé"})
|
||||
_status_cache["data"] = components
|
||||
_status_cache["ts"] = now
|
||||
return components
|
||||
|
||||
|
||||
def _sort_qc_alerts(alerts: list[str]) -> list[str]:
|
||||
def _key(a: str) -> tuple[int, int]:
|
||||
text = a.lower()
|
||||
dp = 0 if " dp " in text or text.startswith("dp ") or "diagnostic principal" in text else 1
|
||||
critical = 0 if any(k in text for k in ("high→low", "high → low", "à reconsidérer", "reconsider")) else 1
|
||||
return (dp, critical)
|
||||
return sorted(alerts, key=_key)
|
||||
|
||||
|
||||
def register_filters(app):
|
||||
"""Enregistre tous les filtres Jinja2 sur l'application Flask."""
|
||||
app.jinja_env.filters["confidence_badge"] = confidence_badge
|
||||
app.jinja_env.filters["confidence_label"] = confidence_label
|
||||
app.jinja_env.filters["severity_badge"] = severity_badge
|
||||
app.jinja_env.filters["cma_level_badge"] = cma_level_badge
|
||||
app.jinja_env.filters["format_duration"] = format_duration
|
||||
app.jinja_env.filters["format_dossier_name"] = format_dossier_name
|
||||
app.jinja_env.filters["format_doc_name"] = format_doc_name
|
||||
app.jinja_env.filters["format_cpam_text"] = format_cpam_text
|
||||
app.jinja_env.filters["decision_badge"] = decision_badge
|
||||
app.jinja_env.filters["human_where"] = human_where
|
||||
app.jinja_env.filters["date_to_iso"] = _date_to_iso
|
||||
app.jinja_env.filters["sort_qc_alerts"] = _sort_qc_alerts
|
||||
218
src/viewer/rules_manager.py
Normal file
218
src/viewer/rules_manager.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Gestionnaire CRUD pour les fichiers de règles YAML."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ..config import CONFIG_DIR, RULES_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Fichiers de règles gérables via l'UI
|
||||
RULE_FILES: list[dict] = [
|
||||
{
|
||||
"id": "base",
|
||||
"path": RULES_DIR / "base.yaml",
|
||||
"label": "Vetos & Decisions (socle)",
|
||||
"description": "Packs de règles activables : vetos de contestabilité et décisions automatiques.",
|
||||
"structure": "packs",
|
||||
},
|
||||
{
|
||||
"id": "bio_rules",
|
||||
"path": CONFIG_DIR / "bio_rules.yaml",
|
||||
"label": "Règles biologiques",
|
||||
"description": "Contradiction bio → écartement automatique (ruled_out) ou alerte VETO-17.",
|
||||
"structure": "flat_rules",
|
||||
},
|
||||
{
|
||||
"id": "diagnostic_conflicts",
|
||||
"path": CONFIG_DIR / "diagnostic_conflicts.yaml",
|
||||
"label": "Conflits diagnostiques",
|
||||
"description": "Exclusions mutuelles et incompatibilités entre codes CIM-10.",
|
||||
"structure": "conflicts",
|
||||
},
|
||||
{
|
||||
"id": "demographic_rules",
|
||||
"path": CONFIG_DIR / "demographic_rules.yaml",
|
||||
"label": "Règles démographiques",
|
||||
"description": "Vérification cohérence âge/sexe avec les diagnostics codés.",
|
||||
"structure": "generic",
|
||||
},
|
||||
{
|
||||
"id": "temporal_rules",
|
||||
"path": CONFIG_DIR / "temporal_rules.yaml",
|
||||
"label": "Règles temporelles",
|
||||
"description": "Durée de séjour minimale/maximale pour certains diagnostics.",
|
||||
"structure": "generic",
|
||||
},
|
||||
{
|
||||
"id": "parcours_rules",
|
||||
"path": CONFIG_DIR / "parcours_rules.yaml",
|
||||
"label": "Règles de parcours",
|
||||
"description": "Vérification de la cohérence du parcours patient.",
|
||||
"structure": "generic",
|
||||
},
|
||||
{
|
||||
"id": "procedure_diagnosis_rules",
|
||||
"path": CONFIG_DIR / "procedure_diagnosis_rules.yaml",
|
||||
"label": "Règles actes-diagnostics",
|
||||
"description": "Cohérence entre actes CCAM et diagnostics CIM-10.",
|
||||
"structure": "generic",
|
||||
},
|
||||
{
|
||||
"id": "completude_rules",
|
||||
"path": CONFIG_DIR / "completude_rules.yaml",
|
||||
"label": "Règles de complétude",
|
||||
"description": "Vérification que le dossier contient les éléments requis pour le codage.",
|
||||
"structure": "generic",
|
||||
},
|
||||
{
|
||||
"id": "router",
|
||||
"path": RULES_DIR / "router.yaml",
|
||||
"label": "Routeur de règles",
|
||||
"description": "Activation conditionnelle de packs selon le contenu du dossier.",
|
||||
"structure": "generic",
|
||||
},
|
||||
{
|
||||
"id": "enabled",
|
||||
"path": RULES_DIR / "enabled.yaml",
|
||||
"label": "Overlays actifs",
|
||||
"description": "Sélection de spécialité, site, et overlays additionnels.",
|
||||
"structure": "generic",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def list_rule_files() -> list[dict]:
|
||||
"""Retourne la liste des fichiers de règles avec métadonnées."""
|
||||
result = []
|
||||
for rf in RULE_FILES:
|
||||
path = rf["path"]
|
||||
info = {**rf, "exists": path.exists(), "size": 0, "rules_count": 0}
|
||||
if path.exists():
|
||||
info["size"] = path.stat().st_size
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
info["rules_count"] = _count_rules(data, rf["structure"])
|
||||
except Exception:
|
||||
pass
|
||||
result.append(info)
|
||||
return result
|
||||
|
||||
|
||||
def load_rule_file(file_id: str) -> dict:
|
||||
"""Charge un fichier de règles YAML complet."""
|
||||
rf = _find_file(file_id)
|
||||
if not rf["path"].exists():
|
||||
return {}
|
||||
return yaml.safe_load(rf["path"].read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def save_rule_file(file_id: str, data: dict) -> None:
|
||||
"""Sauvegarde un fichier de règles YAML."""
|
||||
rf = _find_file(file_id)
|
||||
rf["path"].write_text(
|
||||
yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info("Fichier de règles sauvegardé : %s", rf["path"])
|
||||
|
||||
|
||||
def toggle_rule(file_id: str, rule_path: str, enabled: bool) -> dict:
|
||||
"""Active/désactive une règle identifiée par son chemin dans le YAML.
|
||||
|
||||
rule_path : chemin pointé séparé par des '.' (ex: 'packs.vetos_core.rules.VETO-02')
|
||||
"""
|
||||
data = load_rule_file(file_id)
|
||||
_set_nested(data, rule_path + ".enabled", enabled)
|
||||
save_rule_file(file_id, data)
|
||||
return data
|
||||
|
||||
|
||||
def update_rule_field(file_id: str, rule_path: str, field: str, value) -> dict:
|
||||
"""Met à jour un champ d'une règle."""
|
||||
data = load_rule_file(file_id)
|
||||
_set_nested(data, rule_path + "." + field, value)
|
||||
save_rule_file(file_id, data)
|
||||
return data
|
||||
|
||||
|
||||
def add_rule(file_id: str, parent_path: str, rule_id: str, rule_data: dict) -> dict:
|
||||
"""Ajoute une nouvelle règle sous parent_path."""
|
||||
data = load_rule_file(file_id)
|
||||
parent = _get_nested(data, parent_path)
|
||||
if not isinstance(parent, dict):
|
||||
raise ValueError(f"Chemin parent introuvable : {parent_path}")
|
||||
if rule_id in parent:
|
||||
raise ValueError(f"Règle '{rule_id}' existe déjà")
|
||||
parent[rule_id] = rule_data
|
||||
save_rule_file(file_id, data)
|
||||
return data
|
||||
|
||||
|
||||
def delete_rule(file_id: str, parent_path: str, rule_id: str) -> dict:
|
||||
"""Supprime une règle."""
|
||||
data = load_rule_file(file_id)
|
||||
parent = _get_nested(data, parent_path)
|
||||
if not isinstance(parent, dict) or rule_id not in parent:
|
||||
raise ValueError(f"Règle '{rule_id}' introuvable dans '{parent_path}'")
|
||||
del parent[rule_id]
|
||||
save_rule_file(file_id, data)
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers internes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_file(file_id: str) -> dict:
|
||||
for rf in RULE_FILES:
|
||||
if rf["id"] == file_id:
|
||||
return rf
|
||||
raise ValueError(f"Fichier de règles inconnu : {file_id}")
|
||||
|
||||
|
||||
def _count_rules(data: dict, structure: str) -> int:
|
||||
if structure == "packs":
|
||||
count = 0
|
||||
for pack in (data.get("packs") or {}).values():
|
||||
count += len(pack.get("rules") or {})
|
||||
return count
|
||||
if structure == "flat_rules":
|
||||
return len(data.get("rules") or {})
|
||||
if structure == "conflicts":
|
||||
return len(data.get("mutual_exclusions") or []) + len(data.get("incompatibilities") or [])
|
||||
# generic: count top-level keys that look like rule containers
|
||||
count = 0
|
||||
for v in data.values():
|
||||
if isinstance(v, dict):
|
||||
count += len(v)
|
||||
elif isinstance(v, list):
|
||||
count += len(v)
|
||||
return count
|
||||
|
||||
|
||||
def _get_nested(data: dict, path: str):
|
||||
"""Accède à un noeud du YAML via un chemin pointé."""
|
||||
parts = path.split(".")
|
||||
current = data
|
||||
for p in parts:
|
||||
if isinstance(current, dict) and p in current:
|
||||
current = current[p]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def _set_nested(data: dict, path: str, value) -> None:
|
||||
"""Définit une valeur dans un dict imbriqué via un chemin pointé."""
|
||||
parts = path.split(".")
|
||||
current = data
|
||||
for p in parts[:-1]:
|
||||
if p not in current or not isinstance(current[p], dict):
|
||||
current[p] = {}
|
||||
current = current[p]
|
||||
current[parts[-1]] = value
|
||||
379
src/viewer/templates/admin_rules.html
Normal file
379
src/viewer/templates/admin_rules.html
Normal file
@@ -0,0 +1,379 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Moteur de regles{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<div class="group-title">Admin</div>
|
||||
<a href="/admin/rules" style="color:#60a5fa;font-weight:600;border-left-color:#3b82f6;">Moteur de regles</a>
|
||||
<a href="/admin/referentiels">Referentiels RAG</a>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/">Retour aux dossiers</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a class="back" href="/dashboard">← Dashboard</a>
|
||||
<h2 style="margin-top:1rem;">Moteur de regles metier</h2>
|
||||
<p style="color:#64748b;font-size:0.85rem;margin-bottom:1.5rem;">
|
||||
Gerez les regles du pipeline T2A : activez/desactivez, modifiez les parametres, ajoutez ou supprimez des regles.
|
||||
Les modifications sont appliquees immediatement (fichiers YAML).
|
||||
</p>
|
||||
|
||||
{# ---- Cartes synthese ---- #}
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:1.5rem;">
|
||||
<div class="card" style="text-align:center;padding:1rem;">
|
||||
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Fichiers de regles</div>
|
||||
<div style="font-size:1.5rem;font-weight:700;color:#3b82f6;margin-top:0.25rem;">{{ rule_files|length }}</div>
|
||||
</div>
|
||||
<div class="card" style="text-align:center;padding:1rem;">
|
||||
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Regles totales</div>
|
||||
<div style="font-size:1.5rem;font-weight:700;color:#16a34a;margin-top:0.25rem;">{{ rule_files|sum(attribute='rules_count') }}</div>
|
||||
</div>
|
||||
<div class="card" style="text-align:center;padding:1rem;">
|
||||
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Type</div>
|
||||
<div style="font-size:1.5rem;font-weight:700;color:#8b5cf6;margin-top:0.25rem;">YAML</div>
|
||||
</div>
|
||||
<div class="card" style="text-align:center;padding:1rem;">
|
||||
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Mode</div>
|
||||
<div style="font-size:1.5rem;font-weight:700;color:#f59e0b;margin-top:0.25rem;">Strict</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Tabs par fichier ---- #}
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem;">
|
||||
{% for rf in rule_files %}
|
||||
<button class="tab-btn {% if loop.first %}active{% endif %}"
|
||||
onclick="switchTab('{{rf.id}}')" id="tab-btn-{{rf.id}}"
|
||||
style="padding:0.4rem 0.8rem;border:1px solid #e2e8f0;border-radius:0.375rem;
|
||||
background:{% if loop.first %}#3b82f6{% else %}#fff{% endif %};
|
||||
color:{% if loop.first %}#fff{% else %}#374151{% endif %};
|
||||
cursor:pointer;font-size:0.8rem;font-weight:500;">
|
||||
{{rf.label}}
|
||||
<span style="margin-left:0.3rem;font-size:0.7rem;opacity:0.7;">({{rf.rules_count}})</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ---- Contenu par fichier ---- #}
|
||||
{% for rf in rule_files %}
|
||||
<div class="rule-tab" id="tab-{{rf.id}}" style="{% if not loop.first %}display:none;{% endif %}">
|
||||
<div class="card" style="padding:1rem;margin-bottom:1rem;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<h3 style="margin:0 0 0.25rem;">{{rf.label}}</h3>
|
||||
<p style="color:#64748b;font-size:0.8rem;margin:0;">{{rf.description}}</p>
|
||||
</div>
|
||||
<div style="font-size:0.75rem;color:#94a3b8;">
|
||||
{{rf.rules_count}} regle(s)
|
||||
{% if rf.exists %}
|
||||
· {{ (rf.size / 1024)|round(1) }} Ko
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rf.exists and rf.data %}
|
||||
{% if rf.structure == 'packs' %}
|
||||
{# ---- Structure packs (base.yaml) ---- #}
|
||||
{% for pack_name, pack in rf.data.get('packs', {}).items() %}
|
||||
<div class="card" style="padding:1rem;margin-bottom:0.75rem;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
|
||||
<h4 style="margin:0;font-size:0.95rem;">
|
||||
Pack : <code style="background:#f1f5f9;padding:0.15rem 0.4rem;border-radius:0.25rem;">{{pack_name}}</code>
|
||||
</h4>
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;font-size:0.8rem;">
|
||||
<input type="checkbox" {% if pack.get('enabled', true) %}checked{% endif %}
|
||||
onchange="toggleRule('{{rf.id}}', 'packs.{{pack_name}}', this.checked)"
|
||||
style="width:1.1rem;height:1.1rem;accent-color:#3b82f6;">
|
||||
Actif
|
||||
</label>
|
||||
</div>
|
||||
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid #e2e8f0;">
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">ID</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Description</th>
|
||||
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Severite</th>
|
||||
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actif</th>
|
||||
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule_id, rule in (pack.get('rules') or {}).items() %}
|
||||
<tr style="border-bottom:1px solid #f1f5f9;" id="row-{{rf.id}}-{{pack_name}}-{{rule_id}}">
|
||||
<td style="padding:0.4rem 0.5rem;font-weight:600;font-family:monospace;">{{rule_id}}</td>
|
||||
<td style="padding:0.4rem 0.5rem;">
|
||||
<span class="desc-text" id="desc-{{rf.id}}-{{pack_name}}-{{rule_id}}">{{rule.get('description', '')}}</span>
|
||||
</td>
|
||||
<td style="padding:0.4rem 0.5rem;text-align:center;">
|
||||
{% if rule.get('force_severity') %}
|
||||
<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.7rem;">{{rule.force_severity}}</span>
|
||||
{% else %}
|
||||
<span style="color:#94a3b8;font-size:0.75rem;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;padding:0.4rem 0.5rem;">
|
||||
<input type="checkbox" {% if rule.get('enabled', true) %}checked{% endif %}
|
||||
onchange="toggleRule('{{rf.id}}', 'packs.{{pack_name}}.rules.{{rule_id}}', this.checked)"
|
||||
style="width:1rem;height:1rem;accent-color:#3b82f6;cursor:pointer;">
|
||||
</td>
|
||||
<td style="text-align:center;padding:0.4rem 0.5rem;">
|
||||
<button onclick="deleteRule('{{rf.id}}', 'packs.{{pack_name}}.rules', '{{rule_id}}')"
|
||||
style="border:none;background:none;color:#dc2626;cursor:pointer;font-size:0.85rem;"
|
||||
title="Supprimer">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<button onclick="showAddRule('{{rf.id}}', 'packs.{{pack_name}}.rules')"
|
||||
style="font-size:0.75rem;padding:0.3rem 0.6rem;border:1px dashed #94a3b8;
|
||||
border-radius:0.25rem;background:none;color:#64748b;cursor:pointer;">
|
||||
+ Ajouter une regle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif rf.structure == 'flat_rules' %}
|
||||
{# ---- Structure rules plates (bio_rules.yaml) ---- #}
|
||||
<div class="card" style="padding:1rem;">
|
||||
{% if rf.data.get('missing_evidence') %}
|
||||
<div style="margin-bottom:0.75rem;padding:0.5rem;background:#f8fafc;border-radius:0.25rem;">
|
||||
<span style="font-weight:600;font-size:0.8rem;">Preuve manquante :</span>
|
||||
<span style="font-size:0.8rem;">veto={{rf.data.missing_evidence.get('veto', '?')}},
|
||||
severite={{rf.data.missing_evidence.get('severity', '?')}},
|
||||
penalite={{rf.data.missing_evidence.get('score_penalty', '?')}}</span>
|
||||
<label style="float:right;font-size:0.8rem;cursor:pointer;">
|
||||
<input type="checkbox" {% if rf.data.missing_evidence.get('enabled', true) %}checked{% endif %}
|
||||
onchange="toggleRule('{{rf.id}}', 'missing_evidence', this.checked)"
|
||||
style="accent-color:#3b82f6;">
|
||||
Actif
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid #e2e8f0;">
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Regle</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Codes CIM-10</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Analyte</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Seuil</th>
|
||||
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actif</th>
|
||||
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule_id, rule in (rf.data.get('rules') or {}).items() %}
|
||||
<tr style="border-bottom:1px solid #f1f5f9;">
|
||||
<td style="padding:0.4rem 0.5rem;font-weight:600;font-family:monospace;">{{rule_id}}</td>
|
||||
<td style="padding:0.4rem 0.5rem;"><code>{{(rule.get('codes') or [])|join(', ')}}</code></td>
|
||||
<td style="padding:0.4rem 0.5rem;">{{rule.get('analyte', '')}}</td>
|
||||
<td style="padding:0.4rem 0.5rem;">
|
||||
{{rule.get('threshold_type', '')}}
|
||||
{% if rule.get('message') %}<span style="color:#94a3b8;"> ({{rule.message}})</span>{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;padding:0.4rem 0.5rem;">
|
||||
<input type="checkbox" {% if rule.get('enabled', true) %}checked{% endif %}
|
||||
onchange="toggleRule('{{rf.id}}', 'rules.{{rule_id}}', this.checked)"
|
||||
style="width:1rem;height:1rem;accent-color:#3b82f6;cursor:pointer;">
|
||||
</td>
|
||||
<td style="text-align:center;padding:0.4rem 0.5rem;">
|
||||
<button onclick="deleteRule('{{rf.id}}', 'rules', '{{rule_id}}')"
|
||||
style="border:none;background:none;color:#dc2626;cursor:pointer;font-size:0.85rem;"
|
||||
title="Supprimer">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<button onclick="showAddRule('{{rf.id}}', 'rules')"
|
||||
style="font-size:0.75rem;padding:0.3rem 0.6rem;border:1px dashed #94a3b8;
|
||||
border-radius:0.25rem;background:none;color:#64748b;cursor:pointer;">
|
||||
+ Ajouter une regle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif rf.structure == 'conflicts' %}
|
||||
{# ---- Conflits diagnostiques ---- #}
|
||||
<div class="card" style="padding:1rem;">
|
||||
<h4 style="margin:0 0 0.5rem;font-size:0.9rem;">Exclusions mutuelles</h4>
|
||||
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;margin-bottom:1rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid #e2e8f0;">
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Nom</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Codes</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Message</th>
|
||||
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Severite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for excl in (rf.data.get('mutual_exclusions') or []) %}
|
||||
<tr style="border-bottom:1px solid #f1f5f9;">
|
||||
<td style="padding:0.4rem 0.5rem;font-weight:600;">{{excl.get('name', '')}}</td>
|
||||
<td style="padding:0.4rem 0.5rem;"><code>{{(excl.get('codes') or [])|join(', ')}}</code></td>
|
||||
<td style="padding:0.4rem 0.5rem;font-size:0.75rem;">{{excl.get('message', '')}}</td>
|
||||
<td style="text-align:center;padding:0.4rem 0.5rem;">
|
||||
{% set sev = excl.get('severity', 'MEDIUM') %}
|
||||
<span class="badge" style="background:{% if sev == 'HARD' %}#fee2e2{% else %}#fef3c7{% endif %};
|
||||
color:{% if sev == 'HARD' %}#dc2626{% else %}#92400e{% endif %};font-size:0.7rem;">{{sev}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 style="margin:0 0 0.5rem;font-size:0.9rem;">Incompatibilites</h4>
|
||||
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid #e2e8f0;">
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Codes</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Ref ATIH</th>
|
||||
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Message</th>
|
||||
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Severite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inc in (rf.data.get('incompatibilities') or []) %}
|
||||
<tr style="border-bottom:1px solid #f1f5f9;">
|
||||
<td style="padding:0.4rem 0.5rem;"><code>{{(inc.get('pair') or [])|join(', ')}}</code></td>
|
||||
<td style="padding:0.4rem 0.5rem;font-size:0.75rem;">{{inc.get('atih_ref', '')}}</td>
|
||||
<td style="padding:0.4rem 0.5rem;font-size:0.75rem;">{{inc.get('message', '')}}</td>
|
||||
<td style="text-align:center;padding:0.4rem 0.5rem;">
|
||||
{% set sev = inc.get('severity', 'MEDIUM') %}
|
||||
<span class="badge" style="background:{% if sev == 'HARD' %}#fee2e2{% else %}#fef3c7{% endif %};
|
||||
color:{% if sev == 'HARD' %}#dc2626{% else %}#92400e{% endif %};font-size:0.7rem;">{{sev}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ---- Structure generique (YAML brut) ---- #}
|
||||
<div class="card" style="padding:1rem;">
|
||||
<pre style="background:#f8fafc;padding:0.75rem;border-radius:0.375rem;font-size:0.75rem;
|
||||
overflow-x:auto;max-height:500px;margin:0;white-space:pre-wrap;">{{ rf.data|tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="card" style="padding:1rem;color:#94a3b8;text-align:center;">
|
||||
Fichier non trouve : <code>{{rf.path}}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# ---- Modal ajout regle ---- #}
|
||||
<div id="add-rule-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:1000;
|
||||
display:none;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:0.5rem;padding:1.5rem;width:400px;max-width:90vw;box-shadow:0 25px 50px rgba(0,0,0,0.2);">
|
||||
<h3 style="margin:0 0 1rem;">Ajouter une regle</h3>
|
||||
<input id="add-rule-id" placeholder="Identifiant (ex: VETO-99)"
|
||||
style="width:100%;padding:0.5rem;border:1px solid #d1d5db;border-radius:0.25rem;margin-bottom:0.5rem;box-sizing:border-box;">
|
||||
<input id="add-rule-desc" placeholder="Description"
|
||||
style="width:100%;padding:0.5rem;border:1px solid #d1d5db;border-radius:0.25rem;margin-bottom:0.5rem;box-sizing:border-box;">
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
|
||||
<button onclick="closeAddRule()"
|
||||
style="padding:0.4rem 1rem;border:1px solid #d1d5db;border-radius:0.25rem;background:#fff;cursor:pointer;">Annuler</button>
|
||||
<button onclick="submitAddRule()"
|
||||
style="padding:0.4rem 1rem;border:none;border-radius:0.25rem;background:#3b82f6;color:#fff;cursor:pointer;">Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Toast notifications ---- #}
|
||||
<div id="toast" style="display:none;position:fixed;bottom:2rem;right:2rem;padding:0.75rem 1.25rem;
|
||||
border-radius:0.375rem;color:#fff;font-size:0.85rem;z-index:1001;box-shadow:0 4px 12px rgba(0,0,0,0.15);"></div>
|
||||
|
||||
<script>
|
||||
function switchTab(fileId) {
|
||||
document.querySelectorAll('.rule-tab').forEach(t => t.style.display = 'none');
|
||||
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||
b.style.background = '#fff';
|
||||
b.style.color = '#374151';
|
||||
});
|
||||
const tab = document.getElementById('tab-' + fileId);
|
||||
const btn = document.getElementById('tab-btn-' + fileId);
|
||||
if (tab) tab.style.display = 'block';
|
||||
if (btn) { btn.style.background = '#3b82f6'; btn.style.color = '#fff'; }
|
||||
}
|
||||
|
||||
function showToast(message, success) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.style.background = success ? '#16a34a' : '#dc2626';
|
||||
toast.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
||||
}
|
||||
|
||||
function toggleRule(fileId, rulePath, enabled) {
|
||||
fetch('/api/rules/' + fileId + '/toggle', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({rule_path: rulePath, enabled: enabled})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) showToast('Regle ' + (enabled ? 'activee' : 'desactivee'), true);
|
||||
else showToast('Erreur : ' + data.error, false);
|
||||
})
|
||||
.catch(err => showToast('Erreur reseau', false));
|
||||
}
|
||||
|
||||
function deleteRule(fileId, parentPath, ruleId) {
|
||||
if (!confirm('Supprimer la regle "' + ruleId + '" ?')) return;
|
||||
fetch('/api/rules/' + fileId + '/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({parent_path: parentPath, rule_id: ruleId})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
showToast('Regle supprimee', true);
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else showToast('Erreur : ' + data.error, false);
|
||||
})
|
||||
.catch(err => showToast('Erreur reseau', false));
|
||||
}
|
||||
|
||||
let _addFileId = '', _addParentPath = '';
|
||||
function showAddRule(fileId, parentPath) {
|
||||
_addFileId = fileId;
|
||||
_addParentPath = parentPath;
|
||||
document.getElementById('add-rule-id').value = '';
|
||||
document.getElementById('add-rule-desc').value = '';
|
||||
document.getElementById('add-rule-modal').style.display = 'flex';
|
||||
}
|
||||
function closeAddRule() {
|
||||
document.getElementById('add-rule-modal').style.display = 'none';
|
||||
}
|
||||
function submitAddRule() {
|
||||
const ruleId = document.getElementById('add-rule-id').value.trim();
|
||||
const desc = document.getElementById('add-rule-desc').value.trim();
|
||||
if (!ruleId) { alert('Identifiant requis'); return; }
|
||||
fetch('/api/rules/' + _addFileId + '/add', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
parent_path: _addParentPath,
|
||||
rule_id: ruleId,
|
||||
rule_data: {enabled: true, description: desc}
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
showToast('Regle ajoutee', true);
|
||||
closeAddRule();
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else showToast('Erreur : ' + data.error, false);
|
||||
})
|
||||
.catch(err => showToast('Erreur reseau', false));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user