fix(evidence): use @model_validator(mode='before') so evidences appear in API responses
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
FastAPI 0.136.1 + Pydantic 2.13.4 serialises responses via TypeAdapter which calls the compiled Rust validator directly, bypassing any Python-level `model_validate` classmethod override. The @model_validator(mode='before') decorator IS invoked by the Rust pipeline, so the evidence red/blue split and technique field population now run on every serialisation path. Also eager-load technique in get_test_detail to avoid lazy-load surprises. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
|
|
||||||
from app.domain.enums import DataClassification
|
from app.domain.enums import DataClassification
|
||||||
from app.models.enums import TestResult, TestState
|
from app.models.enums import TestResult, TestState
|
||||||
@@ -173,9 +173,16 @@ class TestOut(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def model_validate(cls, obj, **kwargs):
|
def _populate_derived_fields(cls, obj):
|
||||||
"""Override to populate technique and evidence fields from ORM relationships.
|
"""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**
|
Evidences are only processed when the relationship was **explicitly loaded**
|
||||||
(via joinedload or prior access). Accessing ``obj.evidences`` blindly on a
|
(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
|
the relationship is unloaded and we leave the lists empty (the frontend
|
||||||
invalidates and refetches the detail endpoint, which *does* joinload).
|
invalidates and refetches the detail endpoint, which *does* joinload).
|
||||||
"""
|
"""
|
||||||
|
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:
|
if hasattr(obj, "technique") and obj.technique is not None:
|
||||||
obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id
|
obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id
|
||||||
obj.__dict__["technique_name"] = obj.technique.name
|
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)
|
# 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:
|
if raw_evs is not None:
|
||||||
red_evs: list[EvidenceOut] = []
|
red_evs: list[EvidenceOut] = []
|
||||||
blue_evs: list[EvidenceOut] = []
|
blue_evs: list[EvidenceOut] = []
|
||||||
@@ -213,4 +227,4 @@ class TestOut(BaseModel):
|
|||||||
obj.__dict__["red_evidences"] = red_evs
|
obj.__dict__["red_evidences"] = red_evs
|
||||||
obj.__dict__["blue_evidences"] = blue_evs
|
obj.__dict__["blue_evidences"] = blue_evs
|
||||||
|
|
||||||
return super().model_validate(obj, **kwargs)
|
return obj
|
||||||
|
|||||||
@@ -137,13 +137,13 @@ def create_test_from_template(
|
|||||||
|
|
||||||
|
|
||||||
def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
|
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.
|
Raises EntityNotFoundError if the test does not exist.
|
||||||
"""
|
"""
|
||||||
test = (
|
test = (
|
||||||
db.query(Test)
|
db.query(Test)
|
||||||
.options(joinedload(Test.evidences))
|
.options(joinedload(Test.evidences), joinedload(Test.technique))
|
||||||
.filter(Test.id == test_id)
|
.filter(Test.id == test_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user