Files
ss-tools/backend/src/api/routes/dashboard_testing/scenarios.py

522 lines
22 KiB
Python

# #region Api.DashboardTesting.Scenarios [C:4] [TYPE Module] [SEMANTICS scenario,registry,api,list,detail]
# @defgroup Api Scenario Registry REST surface — GET list + GET detail (feature 042, fixes getScenarioDraft 404).
# @LAYER API
# @RELATION DEPENDS_ON -> [ScenarioRegistry.List]
# @RELATION DEPENDS_ON -> [ScenarioRegistry.Get]
# @RELATION DEPENDS_ON -> [ScenarioRegistry.Schemas]
# @RATIONALE The registry is the queryable source of truth for the 043 editor and 044 runner; the
# missing GET routes are the P1 fix for the frontend 404. Read permission uses the existing
# dashboard:testing READ scope — the dedicated scenario:view RBAC scope lands with T020.
# @REJECTED Expanding the existing scenario.py module was rejected — the registry is a distinct
# contract surface (042) and module discipline (INV_7) keeps route files < 400 lines.
# @INVARIANT List/detail are read-only; no request body is accepted.
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.core.database import get_db
from src.dependencies import get_current_user, has_permission
from src.schemas.dashboard_testing import (
ScenarioCloneResponse,
ScenarioCreateRequest,
ScenarioCreateResponse,
ScenarioDetailResponse,
ScenarioEditorAgentProposeRequest,
ScenarioEditorAgentProposeResponse,
ScenarioEditorApplyRequest,
ScenarioEditorApplyResponse,
ScenarioEditorLoadResponse,
ScenarioEditorProposalSaveRequest,
ScenarioEditorSaveRequest,
ScenarioHealthResponse,
ScenarioListResponse,
ScenarioReasonRequest,
ScenarioRevisionCreateRequest,
ScenarioRevisionResponse,
ScenarioTransitionRequest,
)
from src.services.dashboard_testing.editor.agent import agent_propose, save_proposal
from src.services.dashboard_testing.editor.load import load_editor
from src.services.dashboard_testing.editor.revalidate import revalidate
from src.services.dashboard_testing.editor.save import create_working_draft, save_revision
from src.services.dashboard_testing.registry.clone import clone_scenario
from src.services.dashboard_testing.registry.create import create_scenario
from src.services.dashboard_testing.registry.get import get_scenario
from src.services.dashboard_testing.registry.health import get_health_badge
from src.services.dashboard_testing.registry.lifecycle import (
archive_scenario,
restore_scenario,
transition,
)
from src.services.dashboard_testing.registry.list import list_scenarios
from src.services.dashboard_testing.registry.revisions import (
checkout_revision,
create_revision,
diff_revisions,
list_revisions,
)
router = APIRouter(prefix="/api/dashboard-testing/scenarios", tags=["Dashboard-Testing"])
_READ_PERMISSION = Depends(has_permission("dashboard:testing", "READ"))
_WRITE_PERMISSION = Depends(has_permission("dashboard:testing", "WRITE"))
_DB_SESSION = Depends(get_db)
_CURRENT_USER = Depends(get_current_user)
_SCENARIO_CREATE_PERMISSION = Depends(has_permission("scenario", "CREATE"))
_SCENARIO_EDIT_PERMISSION = Depends(has_permission("scenario", "EDIT"))
_SCENARIO_ARCHIVE_PERMISSION = Depends(has_permission("scenario", "ARCHIVE"))
# #region Api.DashboardTesting.Scenarios.List [C:3] [TYPE Function] [SEMANTICS scenario,registry,api,list]
# @ingroup Api
# @BRIEF List/search/filter persisted scenarios with pagination (operationId scenarioRegistry.list).
# @PRE Caller has dashboard:testing READ.
# @POST Returns {items: ScenarioRegistryEntry[], total: int}; 422 VALIDATION_ERROR on bad paging.
@router.get("", response_model=ScenarioListResponse)
def api_list_scenarios(
q: str | None = Query(default=None, description="Search by name or scenario_key"),
dashboard_id: int | None = Query(default=None, description="Superset dashboard id"),
status: str | None = Query(
default=None,
description="Lifecycle status (DRAFT/READY/STALE/NEEDS_REVALIDATION/BLOCKED/DISABLED/DEPRECATED/ARCHIVED)",
),
tag: str | None = Query(default=None, description="Tag filter"),
owner: str | None = Query(default=None, description="owner_username filter"),
page: int = Query(default=1, ge=1, description="1-based page"),
page_size: int = Query(default=25, ge=1, le=100, description="Rows per page"),
_db: object = _DB_SESSION,
_perm: object = _READ_PERMISSION,
):
try:
return list_scenarios(
_db,
q=q,
dashboard_id=dashboard_id,
status=status,
tag=tag,
owner=owner,
page=page,
page_size=page_size,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"code": "VALIDATION_ERROR", "detail": str(exc)},
) from exc
# #endregion Api.DashboardTesting.Scenarios.List
# #region Api.DashboardTesting.Scenarios.Create [C:4] [TYPE Function] [SEMANTICS scenario,registry,api,create,transaction]
# @ingroup Api
# @BRIEF Atomically register a validated draft pack as a candidate revision.
# @PRE Caller has dashboard:testing WRITE; handles identify server-owned artifacts.
# @POST Returns scenario/revision ids with materialization_status=materialized; failures roll back both rows.
@router.post("", response_model=ScenarioCreateResponse, status_code=status.HTTP_201_CREATED)
def api_create_scenario(
body: ScenarioCreateRequest,
_db: object = _DB_SESSION,
_perm: object = _WRITE_PERMISSION,
current_user=_CURRENT_USER,
):
user_id = str(getattr(current_user, "id", None) or getattr(current_user, "username", ""))
try:
entry, revision = create_scenario(
_db,
compiled_handle_id=body.compiled_handle_id,
draft_pack_id=body.draft_pack_id,
draft_pack_digest=body.draft_pack_digest,
user_id=user_id,
owner_username=str(getattr(current_user, "username", "") or user_id),
)
_db.commit()
except ValueError as exc:
_db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "REGISTRATION_CONFLICT", "detail": str(exc)},
) from exc
except Exception:
_db.rollback()
raise
return ScenarioCreateResponse(
scenario_id=entry.scenario_id,
revision_id=revision.revision_id,
materialization_status="materialized",
)
# #endregion Api.DashboardTesting.Scenarios.Create
# #region Api.DashboardTesting.Scenarios.RevisionsCreate [C:4] [TYPE Function] [SEMANTICS scenario,registry,api,revision,create]
# @ingroup Api
# @BRIEF Append an immutable candidate revision from a validated graph snapshot.
@router.post("/{scenario_id}/revisions", response_model=ScenarioRevisionResponse, status_code=status.HTTP_201_CREATED)
def api_create_revision(
scenario_id: str,
body: ScenarioRevisionCreateRequest,
_db: object = _DB_SESSION,
_perm: object = _WRITE_PERMISSION,
current_user=_CURRENT_USER,
):
try:
revision = create_revision(
_db,
scenario_id,
body.graph_snapshot,
base_revision_id=body.base_revision_id,
created_by=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
agent_action_id=body.agent_action_id,
)
_db.commit()
return revision
except ValueError as exc:
_db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "STALE_REVISION", "detail": str(exc)},
) from exc
# #endregion Api.DashboardTesting.Scenarios.RevisionsCreate
# #region Api.DashboardTesting.Scenarios.RevisionsList [C:3] [TYPE Function] [SEMANTICS scenario,registry,api,revision,list]
# @ingroup Api
# @BRIEF List an immutable scenario revision chain.
@router.get("/{scenario_id}/revisions", response_model=list[ScenarioRevisionResponse])
def api_list_revisions(
scenario_id: str,
_db: object = _DB_SESSION,
_perm: object = _READ_PERMISSION,
):
return list_revisions(_db, scenario_id)
# #endregion Api.DashboardTesting.Scenarios.RevisionsList
# #region Api.DashboardTesting.Scenarios.RevisionCheckout [C:3] [TYPE Function] [SEMANTICS scenario,registry,api,revision,checkout]
# @ingroup Api
# @BRIEF Read an immutable revision snapshot for checkout.
@router.get("/{scenario_id}/revisions/{revision_id}", response_model=ScenarioRevisionResponse)
def api_checkout_revision(
scenario_id: str,
revision_id: str,
_db: object = _DB_SESSION,
_perm: object = _READ_PERMISSION,
):
revision = checkout_revision(_db, scenario_id, revision_id)
if revision is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "REVISION_NOT_FOUND"})
return revision
# #endregion Api.DashboardTesting.Scenarios.RevisionCheckout
# #region Api.DashboardTesting.Scenarios.RevisionDiff [C:3] [TYPE Function] [SEMANTICS scenario,registry,api,revision,diff]
# @ingroup Api
# @BRIEF Return deterministic added/changed/removed graph changes between revisions.
@router.get("/{scenario_id}/revisions/{rev_a}/diff/{rev_b}")
def api_diff_revisions(
scenario_id: str,
rev_a: str,
rev_b: str,
_db: object = _DB_SESSION,
_perm: object = _READ_PERMISSION,
):
try:
return diff_revisions(_db, scenario_id, rev_a, rev_b)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "REVISION_NOT_FOUND"}) from exc
# #endregion Api.DashboardTesting.Scenarios.RevisionDiff
# #region Api.DashboardTesting.Scenarios.Transition [C:4] [TYPE Function] [SEMANTICS scenario,registry,api,lifecycle,transition]
# @ingroup Api
# @BRIEF Apply a validated lifecycle transition and append its audit record.
@router.post("/{scenario_id}/transition", status_code=status.HTTP_200_OK)
def api_transition_scenario(
scenario_id: str,
body: ScenarioTransitionRequest,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_EDIT_PERMISSION,
current_user=_CURRENT_USER,
):
try:
entry = transition(
_db,
scenario_id,
body.target_state,
actor_id=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
reason=body.reason,
expected_metadata_version=body.metadata_version,
)
_db.commit()
return {"scenario_id": entry.scenario_id, "lifecycle_status": entry.lifecycle_status, "metadata_version": entry.metadata_version}
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "INVALID_TRANSITION", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.Transition
# #region Api.DashboardTesting.Scenarios.Clone [C:4] [TYPE Function] [SEMANTICS scenario,registry,api,clone]
# @ingroup Api
# @BRIEF Clone a scenario into a new owner-owned draft.
@router.post("/{scenario_id}/clone", response_model=ScenarioCloneResponse, status_code=status.HTTP_201_CREATED)
def api_clone_scenario(
scenario_id: str,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_CREATE_PERMISSION,
current_user=_CURRENT_USER,
):
try:
clone, revision = clone_scenario(
_db,
scenario_id,
actor_id=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
owner_username=str(getattr(current_user, "username", "") or getattr(current_user, "id", "")),
)
_db.commit()
return {"scenario_id": clone.scenario_id, "revision_id": revision.revision_id}
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "SCENARIO_NOT_FOUND", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.Clone
# #region Api.DashboardTesting.Scenarios.Archive [C:4] [TYPE Function] [SEMANTICS scenario,registry,api,archive]
# @ingroup Api
# @BRIEF Archive a scenario without deleting revisions or run history.
@router.post("/{scenario_id}/archive", status_code=status.HTTP_200_OK)
def api_archive_scenario(
scenario_id: str,
body: ScenarioReasonRequest,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_ARCHIVE_PERMISSION,
current_user=_CURRENT_USER,
):
try:
entry = archive_scenario(
_db,
scenario_id,
actor_id=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
reason=body.reason,
)
_db.commit()
return {"scenario_id": entry.scenario_id, "lifecycle_status": entry.lifecycle_status}
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "ARCHIVE_CONFLICT", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.Archive
# #region Api.DashboardTesting.Scenarios.Restore [C:4] [TYPE Function] [SEMANTICS scenario,registry,api,restore]
# @ingroup Api
# @BRIEF Restore an archived scenario to DRAFT for revalidation.
@router.post("/{scenario_id}/restore", status_code=status.HTTP_200_OK)
def api_restore_scenario(
scenario_id: str,
body: ScenarioReasonRequest,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_EDIT_PERMISSION,
current_user=_CURRENT_USER,
):
try:
entry = restore_scenario(
_db,
scenario_id,
actor_id=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
reason=body.reason,
)
_db.commit()
return {"scenario_id": entry.scenario_id, "lifecycle_status": entry.lifecycle_status}
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "RESTORE_CONFLICT", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.Restore
# #region Api.DashboardTesting.Scenarios.Health [C:3] [TYPE Function] [SEMANTICS scenario,registry,api,health,read]
# @ingroup Api
# @BRIEF Return analytics-owned scenario health projection.
@router.get("/{scenario_id}/health", response_model=ScenarioHealthResponse)
def api_get_scenario_health(
scenario_id: str,
_db: object = _DB_SESSION,
_perm: object = _READ_PERMISSION,
):
projection = get_health_badge(_db, scenario_id)
if projection is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "SCENARIO_NOT_FOUND"})
return projection
# #endregion Api.DashboardTesting.Scenarios.Health
# #region Api.DashboardTesting.Scenarios.EditorLoad [C:3] [TYPE Function] [SEMANTICS scenario,editor,api,load,readonly]
# @ingroup Api
# @BRIEF Load a clean revision-bound editor projection.
@router.get("/{scenario_id}/edit", response_model=ScenarioEditorLoadResponse)
def api_load_editor(
scenario_id: str,
revision_id: str | None = None,
_db: object = _DB_SESSION,
_perm: object = _READ_PERMISSION,
):
editor = load_editor(_db, scenario_id, revision_id)
if editor is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "EDITOR_SCENARIO_NOT_FOUND"})
return editor
# #endregion Api.DashboardTesting.Scenarios.EditorLoad
# #region Api.DashboardTesting.Scenarios.EditorApply [C:4] [TYPE Function] [SEMANTICS scenario,editor,api,apply,draft]
# @ingroup Api
# @BRIEF Apply typed editor operations into a server-owned WorkingDraft.
@router.post("/{scenario_id}/edits/apply", response_model=ScenarioEditorApplyResponse)
def api_apply_editor_ops(
scenario_id: str,
body: ScenarioEditorApplyRequest,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_EDIT_PERMISSION,
current_user=_CURRENT_USER,
):
try:
result = create_working_draft(
_db, scenario_id, body.base_revision_id, body.ops,
created_by=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
)
_db.commit()
return result
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"code": "INVALID_EDIT", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.EditorApply
# #region Api.DashboardTesting.Scenarios.EditorSave [C:4] [TYPE Function] [SEMANTICS scenario,editor,api,save,revision]
# @ingroup Api
# @BRIEF Save a server-owned WorkingDraft after explicit policy authorization.
@router.post("/{scenario_id}/edits/save")
def api_save_editor_revision(
scenario_id: str,
body: ScenarioEditorSaveRequest,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_EDIT_PERMISSION,
current_user=_CURRENT_USER,
):
try:
result = save_revision(
_db, body.draft_id, body.digest, scenario_id=scenario_id,
actor=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
authorized=True,
agent_action_id=body.agent_action_id,
)
_db.commit()
return result
except PermissionError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"code": "POLICY_DENIED"}) from exc
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "EDITOR_SAVE_CONFLICT", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.EditorSave
# #region Api.DashboardTesting.Scenarios.EditorAgentPropose [C:4] [TYPE Function] [SEMANTICS scenario,editor,api,agent,propose]
# @ingroup Api
# @BRIEF Store a validated agent edit proposal with a deterministic diff (043 US5).
# @PRE Caller has scenario:edit; ops parse as the closed EditOperation union.
# @POST Returns proposal_id/digest/diff; 422 INVALID_EDIT on unsafe or cyclic ops.
@router.post("/{scenario_id}/edits/agent-propose", response_model=ScenarioEditorAgentProposeResponse)
def api_agent_propose(
scenario_id: str,
body: ScenarioEditorAgentProposeRequest,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_EDIT_PERMISSION,
current_user=_CURRENT_USER,
):
try:
result = agent_propose(
_db,
scenario_id,
body.base_revision_id,
body.request_text,
body.ops,
created_by=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
agent_action_id=body.agent_action_id,
)
_db.commit()
return result
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"code": "INVALID_EDIT", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.EditorAgentPropose
# #region Api.DashboardTesting.Scenarios.EditorProposalSave [C:4] [TYPE Function] [SEMANTICS scenario,editor,api,agent,save,attributed]
# @ingroup Api
# @BRIEF Save an accepted proposal through the guarded WorkingDraft path with attribution.
# @PRE Caller has scenario:edit; digest matches the server-owned proposal; base is current.
# @POST Creates one candidate revision; 409 on stale/digest conflict, 403 when policy denies.
@router.post("/{scenario_id}/edits/proposals/{proposal_id}/save")
def api_save_editor_proposal(
scenario_id: str,
proposal_id: str,
body: ScenarioEditorProposalSaveRequest,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_EDIT_PERMISSION,
current_user=_CURRENT_USER,
):
try:
result = save_proposal(
_db,
proposal_id,
body.digest,
scenario_id=scenario_id,
actor=str(getattr(current_user, "id", None) or getattr(current_user, "username", "")),
authorized=True,
agent_action_id=body.agent_action_id,
)
_db.commit()
return result
except PermissionError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"code": "POLICY_DENIED"}) from exc
except ValueError as exc:
_db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "EDITOR_SAVE_CONFLICT", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.EditorProposalSave
# #region Api.DashboardTesting.Scenarios.Revalidate [C:4] [TYPE Function] [SEMANTICS scenario,editor,api,revalidate,migration]
# @ingroup Api
# @BRIEF Build a read-only stale-scenario migration proposal.
@router.post("/{scenario_id}/revalidate")
def api_revalidate_scenario(
scenario_id: str,
base_revision_id: str,
_db: object = _DB_SESSION,
_perm: object = _SCENARIO_EDIT_PERMISSION,
):
try:
return revalidate(_db, scenario_id, base_revision_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "REVALIDATION_CONFLICT", "detail": str(exc)}) from exc
# #endregion Api.DashboardTesting.Scenarios.Revalidate
# #region Api.DashboardTesting.Scenarios.Detail [C:3] [TYPE Function] [SEMANTICS scenario,registry,api,detail]
# @ingroup Api
# @BRIEF Load a scenario detail by id (operationId scenarioRegistry.detail; fixes getScenarioDraft 404).
# @PRE Caller has dashboard:testing READ.
# @POST Returns ScenarioDetailResponse; 404 NOT_FOUND when the scenario is not in the registry.
@router.get("/{scenario_id}", response_model=ScenarioDetailResponse)
def api_get_scenario(
scenario_id: str,
_db: object = _DB_SESSION,
_perm: object = _READ_PERMISSION,
):
detail = get_scenario(_db, scenario_id)
if detail is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "detail": f"Scenario '{scenario_id}' not found"},
)
return detail
# #endregion Api.DashboardTesting.Scenarios.Detail
# #endregion Api.DashboardTesting.Scenarios