141 lines
3.8 KiB
Python
141 lines
3.8 KiB
Python
|
|
"""Detection-level taxonomy.
|
||
|
|
|
||
|
|
The 4 default levels are seeded at boot. M7 exposes read-only access so the
|
||
|
|
blue side of a mission test can pick a level; M8 will add CRUD.
|
||
|
|
|
||
|
|
The seed is idempotent and additive: rows whose `key` already exists are left
|
||
|
|
alone (operators may have renamed labels). Only missing keys are inserted.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import uuid
|
||
|
|
from dataclasses import dataclass
|
||
|
|
|
||
|
|
from sqlalchemy import select
|
||
|
|
|
||
|
|
from app.db.session import session_scope
|
||
|
|
from app.models.setting import DetectionLevel
|
||
|
|
|
||
|
|
log = logging.getLogger("metamorph.detection_levels")
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class DetectionLevelView:
|
||
|
|
id: uuid.UUID
|
||
|
|
key: str
|
||
|
|
label_fr: str
|
||
|
|
label_en: str
|
||
|
|
color_token: str
|
||
|
|
position: int
|
||
|
|
is_default: bool
|
||
|
|
is_system: bool
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class _DefaultLevel:
|
||
|
|
key: str
|
||
|
|
label_fr: str
|
||
|
|
label_en: str
|
||
|
|
color_token: str
|
||
|
|
position: int
|
||
|
|
is_default: bool
|
||
|
|
|
||
|
|
|
||
|
|
# Seed catalogue. Colors map onto the design-system accents (cf. tasks/design.md).
|
||
|
|
DEFAULT_LEVELS: tuple[_DefaultLevel, ...] = (
|
||
|
|
_DefaultLevel(
|
||
|
|
key="detected_blocked",
|
||
|
|
label_fr="Bloqué",
|
||
|
|
label_en="Blocked",
|
||
|
|
color_token="red",
|
||
|
|
position=0,
|
||
|
|
is_default=False,
|
||
|
|
),
|
||
|
|
_DefaultLevel(
|
||
|
|
key="detected_alert",
|
||
|
|
label_fr="Alerte détectée",
|
||
|
|
label_en="Alert detected",
|
||
|
|
color_token="orange",
|
||
|
|
position=1,
|
||
|
|
is_default=False,
|
||
|
|
),
|
||
|
|
_DefaultLevel(
|
||
|
|
key="logged_only",
|
||
|
|
label_fr="Loggé uniquement",
|
||
|
|
label_en="Logged only",
|
||
|
|
color_token="yellow",
|
||
|
|
position=2,
|
||
|
|
is_default=False,
|
||
|
|
),
|
||
|
|
_DefaultLevel(
|
||
|
|
key="not_detected",
|
||
|
|
label_fr="Non détecté",
|
||
|
|
label_en="Not detected",
|
||
|
|
color_token="rose",
|
||
|
|
position=3,
|
||
|
|
is_default=True,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _to_view(r: DetectionLevel) -> DetectionLevelView:
|
||
|
|
return DetectionLevelView(
|
||
|
|
id=r.id,
|
||
|
|
key=r.key,
|
||
|
|
label_fr=r.label_fr,
|
||
|
|
label_en=r.label_en,
|
||
|
|
color_token=r.color_token,
|
||
|
|
position=r.position,
|
||
|
|
is_default=r.is_default,
|
||
|
|
is_system=r.is_system,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def seed_detection_levels() -> dict[str, int]:
|
||
|
|
"""Insert any default level whose `key` is missing. Idempotent.
|
||
|
|
|
||
|
|
We never mutate existing rows here — operators are free to rename labels
|
||
|
|
or change the default flag. Adding a new entry to `DEFAULT_LEVELS` in a
|
||
|
|
future release will surface it on the next boot.
|
||
|
|
"""
|
||
|
|
created = 0
|
||
|
|
with session_scope() as s:
|
||
|
|
existing_keys = set(s.scalars(select(DetectionLevel.key)).all())
|
||
|
|
for lvl in DEFAULT_LEVELS:
|
||
|
|
if lvl.key in existing_keys:
|
||
|
|
continue
|
||
|
|
s.add(
|
||
|
|
DetectionLevel(
|
||
|
|
key=lvl.key,
|
||
|
|
label_fr=lvl.label_fr,
|
||
|
|
label_en=lvl.label_en,
|
||
|
|
color_token=lvl.color_token,
|
||
|
|
position=lvl.position,
|
||
|
|
is_default=lvl.is_default,
|
||
|
|
is_system=True,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
created += 1
|
||
|
|
# `created` is a reserved LogRecord attribute (timestamp) — use a prefixed key.
|
||
|
|
log.info(
|
||
|
|
"metamorph.detection_levels.seeded",
|
||
|
|
extra={"rows_created": created, "total": len(DEFAULT_LEVELS)},
|
||
|
|
)
|
||
|
|
return {"created": created, "total": len(DEFAULT_LEVELS)}
|
||
|
|
|
||
|
|
|
||
|
|
def list_detection_levels() -> list[DetectionLevelView]:
|
||
|
|
with session_scope() as s:
|
||
|
|
rows = s.scalars(
|
||
|
|
select(DetectionLevel).order_by(DetectionLevel.position, DetectionLevel.key)
|
||
|
|
).all()
|
||
|
|
return [_to_view(r) for r in rows]
|
||
|
|
|
||
|
|
|
||
|
|
def get_detection_level(level_id: uuid.UUID) -> DetectionLevelView | None:
|
||
|
|
with session_scope() as s:
|
||
|
|
r = s.get(DetectionLevel, level_id)
|
||
|
|
return _to_view(r) if r is not None else None
|