522 lines
22 KiB
Python
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
|