diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index df7b276..ef68534 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator from app.domain.enums import DataClassification from app.models.enums import TestResult, TestState @@ -173,9 +173,16 @@ class TestOut(BaseModel): model_config = ConfigDict(from_attributes=True) + @model_validator(mode="before") @classmethod - def model_validate(cls, obj, **kwargs): - """Override to populate technique and evidence fields from ORM relationships. + def _populate_derived_fields(cls, obj): + """Populate technique and evidence fields from ORM relationships. + + Uses ``@model_validator(mode='before')`` so it is called by Pydantic's + internal Rust validation pipeline, including FastAPI's TypeAdapter path. + A plain ``model_validate`` classmethod override is **not** invoked by + FastAPI's response serialisation in Pydantic v2 — only registered + validators are guaranteed to run. Evidences are only processed when the relationship was **explicitly loaded** (via joinedload or prior access). Accessing ``obj.evidences`` blindly on a @@ -185,12 +192,19 @@ class TestOut(BaseModel): the relationship is unloaded and we leave the lists empty (the frontend invalidates and refetches the detail endpoint, which *does* joinload). """ - if hasattr(obj, "technique") and obj.technique is not None: - obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id - obj.__dict__["technique_name"] = obj.technique.name + if not hasattr(obj, "__dict__"): + return obj + + # Technique info (lazy-load is fine here: session is still open on GET) + try: + if hasattr(obj, "technique") and obj.technique is not None: + obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id + obj.__dict__["technique_name"] = obj.technique.name + except Exception: + pass # DetachedInstanceError or similar — leave technique fields None # Only split evidences when they are already in memory (loaded via joinedload) - raw_evs = obj.__dict__.get("evidences") if hasattr(obj, "__dict__") else None + raw_evs = obj.__dict__.get("evidences") if raw_evs is not None: red_evs: list[EvidenceOut] = [] blue_evs: list[EvidenceOut] = [] @@ -213,4 +227,4 @@ class TestOut(BaseModel): obj.__dict__["red_evidences"] = red_evs obj.__dict__["blue_evidences"] = blue_evs - return super().model_validate(obj, **kwargs) + return obj diff --git a/backend/app/services/test_crud_service.py b/backend/app/services/test_crud_service.py index 3e8e65b..92027c4 100644 --- a/backend/app/services/test_crud_service.py +++ b/backend/app/services/test_crud_service.py @@ -137,13 +137,13 @@ def create_test_from_template( def get_test_detail(db: Session, test_id: uuid.UUID) -> Test: - """Fetch a test with evidences eager-loaded. + """Fetch a test with evidences and technique eager-loaded. Raises EntityNotFoundError if the test does not exist. """ test = ( db.query(Test) - .options(joinedload(Test.evidences)) + .options(joinedload(Test.evidences), joinedload(Test.technique)) .filter(Test.id == test_id) .first() )