Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 62 additions & 20 deletions backend/app/features/simulation/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SimulationOut,
SimulationSummaryCapabilitiesOut,
SimulationSummaryOut,
SimulationUpdate,
)
from app.features.user.manager import current_active_user
from app.features.user.models import User
Expand All @@ -24,6 +25,15 @@
case_router = APIRouter(prefix="/cases", tags=["Cases"])


def _simulation_detail_query(db: Session):
return db.query(Simulation).options(
joinedload(Simulation.case),
joinedload(Simulation.machine),
selectinload(Simulation.artifacts),
selectinload(Simulation.links),
)


@case_router.get(
"",
response_model=list[CaseOut],
Expand Down Expand Up @@ -256,15 +266,7 @@ def create_simulation(

# Re-query with relationships loaded
sim_loaded = (
db.query(Simulation)
.options(
joinedload(Simulation.case),
joinedload(Simulation.machine),
selectinload(Simulation.artifacts),
selectinload(Simulation.links),
)
.filter(Simulation.id == sim.id)
.one_or_none()
_simulation_detail_query(db).filter(Simulation.id == sim.id).one_or_none()
)

if sim_loaded is None:
Expand Down Expand Up @@ -336,6 +338,56 @@ def list_simulations(
return [_simulation_to_out(s) for s in sims]


@simulation_router.patch(
"/{sim_id}",
response_model=SimulationOut,
responses={
200: {"description": "Simulation updated successfully."},
401: {"description": "Unauthorized."},
404: {"description": "Simulation not found."},
422: {"description": "Validation error."},
500: {"description": "Internal server error."},
},
)
def update_simulation(
sim_id: UUID,
payload: SimulationUpdate,
db: Session = Depends(get_database_session),
user: User = Depends(current_active_user),
) -> SimulationOut:
"""Partially update allowed user-managed simulation fields."""
sim = db.query(Simulation).filter(Simulation.id == sim_id).one_or_none()

if sim is None:
raise HTTPException(status_code=404, detail="Simulation not found")

now = datetime.now(timezone.utc)
updates = payload.model_dump(by_alias=False, exclude_unset=True)

for field, value in updates.items():
setattr(sim, field, value)

sim.last_updated_by = user.id
sim.updated_at = now

with transaction(db):
db.add(sim)
db.flush()

db.expire_all()
sim_loaded = (
_simulation_detail_query(db).filter(Simulation.id == sim_id).one_or_none()
)

if sim_loaded is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load updated simulation.",
)

return _simulation_to_out(sim_loaded)


@simulation_router.get(
"/{sim_id}",
response_model=SimulationOut,
Expand Down Expand Up @@ -367,17 +419,7 @@ def get_simulation(sim_id: UUID, db: Session = Depends(get_database_session)):
HTTPException
If the simulation with the given ID is not found, raises a 404 HTTP exception.
"""
sim = (
db.query(Simulation)
.options(
joinedload(Simulation.case),
joinedload(Simulation.machine),
selectinload(Simulation.artifacts),
selectinload(Simulation.links),
)
.filter(Simulation.id == sim_id)
.one_or_none()
)
sim = _simulation_detail_query(db).filter(Simulation.id == sim_id).one_or_none()

if not sim:
raise HTTPException(status_code=404, detail="Simulation not found")
Expand Down
60 changes: 59 additions & 1 deletion backend/app/features/simulation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Annotated, Any
from uuid import UUID

from pydantic import Field, HttpUrl, computed_field
from pydantic import ConfigDict, Field, HttpUrl, computed_field, field_validator

from app.common.schemas.base import CamelInBaseModel, CamelOutBaseModel
from app.features.machine.schemas import MachineOut
Expand Down Expand Up @@ -274,6 +274,64 @@ class SimulationCreate(CamelInBaseModel):
]


class SimulationUpdate(CamelInBaseModel):
"""Schema for narrow v1 simulation metadata updates."""

model_config = ConfigDict(extra="forbid")

@field_validator("simulation_type", "status", mode="before")
@classmethod
def reject_null_enum_updates(cls, value: Any) -> Any:
if value is None:
msg = "Field may be omitted for PATCH requests, but cannot be null."
raise ValueError(msg)
return value

simulation_type: Annotated[
SimulationType | None, Field(None, description="Type of the simulation")
]
status: Annotated[
SimulationStatus | None,
Field(None, description="Current status of the simulation"),
Comment thread
tomvothecoder marked this conversation as resolved.
]
description: Annotated[
str | None, Field(None, description="Optional description of the simulation")
]
campaign: Annotated[
str | None,
Field(
None, description="Campaign or run grouping (e.g. historical, amip, tuning)"
),
]
experiment_type: Annotated[
ExperimentType | str | None,
Field(
None,
description=(
"High-level experiment category (e.g. historical, amip, piControl). "
"Often aligned with CMIP experiment identifiers."
),
),
]
hpc_username: Annotated[
str | None,
Field(
None,
description="HPC username for provenance (trusted, informational only)",
),
]
key_features: Annotated[
str | None, Field(None, description="Optional key features of the simulation")
]
known_issues: Annotated[
str | None, Field(None, description="Optional known issues with the simulation")
]
notes_markdown: Annotated[
str | None,
Field(None, description="Optional additional notes in markdown format"),
]
Comment thread
tomvothecoder marked this conversation as resolved.


class SimulationSummaryOut(CamelOutBaseModel):
"""Lightweight schema for simulation summaries nested inside CaseOut.

Expand Down
Loading