- Auto-expiry: expired events auto-end on GET /events via task manager - Height: _estimate_markdown_height adapted to unit=8px scale with padding detection - CRITICAL-1: removed dead import remove_chart_from_position (QA finding) - CRITICAL-2: added chart alive check in ensure_banner_chart (QA finding) - CRITICAL-3: fixed test assertions update_markdown_chart → update_banner_on_dashboard - @POST contract: fixed return range [2,12] → [19,200] - Tests: 8 new tests (auto-expiry + layout height)
281 lines
14 KiB
Python
281 lines
14 KiB
Python
# #region ValidationRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, validation, api, task, run]
|
|
# @BRIEF API routes for validation task management and run history.
|
|
# @LAYER API
|
|
# @RELATION DEPENDS_ON -> [ValidationTaskService]
|
|
# @RELATION DEPENDS_ON -> [ValidationRunService]
|
|
# @RELATION DEPENDS_ON -> [TaskManager]
|
|
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...core.config_manager import ConfigManager
|
|
from ...core.database import get_db
|
|
from ...core.logger import logger
|
|
from ...core.scheduler import SchedulerService
|
|
from ...core.task_manager import TaskManager
|
|
from ...dependencies import get_config_manager, get_current_user, get_scheduler_service, get_task_manager, has_permission
|
|
from ...schemas.auth import User
|
|
from ...schemas.validation import (
|
|
TriggerRunResponse,
|
|
ValidationRunDetailResponse,
|
|
ValidationRunListResponse,
|
|
ValidationTaskCreate,
|
|
ValidationTaskListResponse,
|
|
ValidationTaskResponse,
|
|
ValidationTaskUpdate,
|
|
)
|
|
from ...services.validation_run_service import ValidationRunService
|
|
from ...services.validation_service import ValidationTaskService
|
|
|
|
# #region router [C:1] [TYPE Global]
|
|
router = APIRouter(tags=["Validation"])
|
|
# #endregion router
|
|
|
|
|
|
# #region _get_task_service [C:1] [TYPE Function]
|
|
def _get_task_service(
|
|
db: Session = Depends(get_db),
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> ValidationTaskService:
|
|
return ValidationTaskService(db=db, config_manager=config_manager, username=current_user.username)
|
|
# #endregion _get_task_service
|
|
|
|
|
|
# #region _get_run_service [C:1] [TYPE Function]
|
|
def _get_run_service(
|
|
db: Session = Depends(get_db),
|
|
) -> ValidationRunService:
|
|
return ValidationRunService(db=db)
|
|
# #endregion _get_run_service
|
|
|
|
|
|
# ============================================================
|
|
# Tasks CRUD
|
|
# ============================================================
|
|
|
|
|
|
# #region list_tasks [C:4] [TYPE Function]
|
|
# @RATIONALE Route handler delegates to service with permission check — keeps API layer thin and testable; auth gate happens before any business logic.
|
|
# @REJECTED Business logic in route handler rejected — would duplicate validation logic and make permission checks harder to audit.
|
|
@router.get("/tasks", response_model=ValidationTaskListResponse)
|
|
async def list_tasks(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
is_active: bool | None = Query(None),
|
|
environment_id: str | None = Query(None),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.task", "VIEW")),
|
|
service: ValidationTaskService = Depends(_get_task_service),
|
|
):
|
|
"""List all validation tasks with pagination and optional filters."""
|
|
logger.reason(f"list_tasks — User: {current_user.username}", extra={"src": "validation_routes"})
|
|
try:
|
|
total, items = service.list_tasks(is_active=is_active, environment_id=environment_id, page=page, page_size=page_size)
|
|
return ValidationTaskListResponse(items=items, total=total, page=page, page_size=page_size)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
# #endregion list_tasks
|
|
|
|
|
|
# #region create_task [C:4] [TYPE Function]
|
|
# @RATIONALE Returns 201 with created resource — follows REST conventions for POST resource creation; signals to clients that a new entity was created.
|
|
# @REJECTED Returning 200 with wrapped response rejected — non-standard for POST create; 201 Created signals the correct semantics to HTTP clients and code generators.
|
|
@router.post("/tasks", response_model=ValidationTaskResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_task(
|
|
payload: ValidationTaskCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.task", "CREATE")),
|
|
service: ValidationTaskService = Depends(_get_task_service),
|
|
scheduler: SchedulerService = Depends(get_scheduler_service),
|
|
):
|
|
"""Create a new validation task with provider and environment validation."""
|
|
logger.reason(f"create_task — User: {current_user.username}, name: {payload.name}", extra={"src": "validation_routes"})
|
|
try:
|
|
result = service.create_task(payload)
|
|
scheduler.reload_validation_policy(result.id)
|
|
return result
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
|
|
# #endregion create_task
|
|
|
|
|
|
# #region get_task [C:4] [TYPE Function]
|
|
# @RATIONALE Joins task detail with recent runs in a single endpoint — reduces frontend API calls from 2 to 1, improving page load performance.
|
|
# @REJECTED Separate /tasks/{id}/runs endpoint for recent runs rejected — would require frontend to make 2 sequential API calls, doubling load time on the config page.
|
|
@router.get("/tasks/{task_id}", response_model=dict)
|
|
async def get_task(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.task", "VIEW")),
|
|
task_service: ValidationTaskService = Depends(_get_task_service),
|
|
run_service: ValidationRunService = Depends(_get_run_service),
|
|
):
|
|
"""Get a validation task with its recent runs."""
|
|
logger.reason(f"get_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
|
try:
|
|
task = task_service.get_task(task_id)
|
|
from ...schemas.validation import ValidationRunResponse
|
|
# Find recent runs for this specific task via task_id
|
|
recent = run_service.list_runs(
|
|
task_id=task_id,
|
|
dashboard_id=task.dashboard_ids[0] if task.dashboard_ids else None,
|
|
environment_id=task.environment_id,
|
|
page=1,
|
|
page_size=10,
|
|
)
|
|
return {
|
|
"task": task.model_dump(),
|
|
"recent_runs": [r.model_dump() for r in recent[1]],
|
|
}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
# #endregion get_task
|
|
|
|
|
|
# #region update_task [C:4] [TYPE Function]
|
|
# @RATIONALE PUT semantics for full resource update — consistent with existing API patterns; Pydantic's exclude_unset handles partial updates while keeping PUT as the endpoint verb.
|
|
# @REJECTED PATCH semantics rejected — using PUT with exclude_unset provides the same partial-update flexibility without introducing a second HTTP method for the same resource.
|
|
@router.put("/tasks/{task_id}", response_model=ValidationTaskResponse)
|
|
async def update_task(
|
|
task_id: str,
|
|
payload: ValidationTaskUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.task", "EDIT")),
|
|
service: ValidationTaskService = Depends(_get_task_service),
|
|
scheduler: SchedulerService = Depends(get_scheduler_service),
|
|
):
|
|
"""Update a validation task."""
|
|
logger.reason(f"update_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
|
try:
|
|
result = service.update_task(task_id, payload)
|
|
scheduler.reload_validation_policy(result.id)
|
|
return result
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
# #endregion update_task
|
|
|
|
|
|
# #region delete_task [C:4] [TYPE Function]
|
|
# @RATIONALE DELETE returns 204 No Content per REST convention — minimizes response payload for delete operations; supports optional delete_runs query param for cascade control.
|
|
# @REJECTED Returning deleted resource in response body rejected — unnecessary data transfer; 204 with no body is the REST standard for delete operations.
|
|
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_task(
|
|
task_id: str,
|
|
delete_runs: bool = Query(False, description="Also delete associated validation runs"),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.task", "DELETE")),
|
|
service: ValidationTaskService = Depends(_get_task_service),
|
|
scheduler: SchedulerService = Depends(get_scheduler_service),
|
|
):
|
|
"""Delete a validation task."""
|
|
logger.reason(f"delete_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
|
try:
|
|
service.delete_task(task_id, delete_runs=delete_runs)
|
|
scheduler.remove_validation_job(task_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
# #endregion delete_task
|
|
|
|
|
|
# ============================================================
|
|
# Run — trigger immediate validation
|
|
# ============================================================
|
|
|
|
|
|
# #region trigger_run [C:4] [TYPE Function]
|
|
# @RATIONALE Returns spawned task ID so frontend can poll for completion — enables progress tracking without WebSocket. Async spawn is essential since LLM validation can take 30+ seconds.
|
|
# @REJECTED Blocking until run completes rejected — LLM-based validation is slow (30-120s); synchronous wait would timeout HTTP requests and create terrible UX.
|
|
@router.post("/tasks/{task_id}/run", response_model=TriggerRunResponse)
|
|
async def trigger_run(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.task", "EXECUTE")),
|
|
service: ValidationTaskService = Depends(_get_task_service),
|
|
task_manager: TaskManager = Depends(get_task_manager),
|
|
):
|
|
"""Trigger immediate validation for a task."""
|
|
logger.reason(f"trigger_run — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
|
try:
|
|
spawned_task_id = await service.trigger_run(task_id, task_manager)
|
|
return TriggerRunResponse(task_id=task_id, spawned_task_id=spawned_task_id, status="PENDING", message="Validation task spawned")
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
|
|
# #endregion trigger_run
|
|
|
|
|
|
# ============================================================
|
|
# Runs — history listing and detail
|
|
# ============================================================
|
|
|
|
|
|
# #region list_runs [C:4] [TYPE Function]
|
|
# @RATIONALE Exposes 6 query parameters for cross-task run filtering — matches the run service's filter capabilities exactly, enabling flexible history exploration.
|
|
# @REJECTED Fixed filter set rejected — would limit the history page's debugging use cases; users need to filter by task, status, dashboard, environment, and date range.
|
|
@router.get("/runs", response_model=ValidationRunListResponse)
|
|
async def list_runs(
|
|
task_id: str | None = Query(None, description="Filter by spawned task ID"),
|
|
dashboard_id: str | None = Query(None, description="Filter by dashboard ID"),
|
|
environment_id: str | None = Query(None, description="Filter by environment ID"),
|
|
status: str | None = Query(None, description="Filter by status: PASS, WARN, FAIL, UNKNOWN"),
|
|
date_from: date | None = Query(None, description="Start date (ISO format)"),
|
|
date_to: date | None = Query(None, description="End date (ISO format)"),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.run", "VIEW")),
|
|
service: ValidationRunService = Depends(_get_run_service),
|
|
):
|
|
"""List validation runs with cross-task filtering and pagination."""
|
|
logger.reason(f"list_runs — User: {current_user.username}", extra={"src": "validation_routes"})
|
|
try:
|
|
total, items = service.list_runs(
|
|
task_id=task_id, dashboard_id=dashboard_id, environment_id=environment_id,
|
|
status=status, date_from=date_from, date_to=date_to, page=page, page_size=page_size,
|
|
)
|
|
return ValidationRunListResponse(items=items, total=total, page=page, page_size=page_size)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
# #endregion list_runs
|
|
|
|
|
|
# #region get_run_detail [C:4] [TYPE Function]
|
|
# @RATIONALE Returns full run detail including issues list and raw response for the report view — single endpoint serves the complete detail page without multiple fetches.
|
|
# @REJECTED Paginated issues endpoint rejected — issues per run are typically < 20 items; pagination overhead is unnecessary for such small collections.
|
|
@router.get("/runs/{run_id}", response_model=ValidationRunDetailResponse)
|
|
async def get_run_detail(
|
|
run_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.run", "VIEW")),
|
|
service: ValidationRunService = Depends(_get_run_service),
|
|
):
|
|
"""Get detailed validation run info including issues and raw response."""
|
|
logger.reason(f"get_run_detail — Run: {run_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
|
try:
|
|
return service.get_run_detail(run_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
# #endregion get_run_detail
|
|
|
|
|
|
# #region delete_run [C:3] [TYPE Function]
|
|
# @RATIONALE Returns 404 when run not found — explicit error handling rather than silent success; helps frontend distinguish between successful delete and missing resource.
|
|
# @REJECTED Silent success on missing run rejected — would mask bugs in the UI that reference already-deleted runs; explicit 404 helps debugging.
|
|
@router.delete("/runs/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_run(
|
|
run_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(has_permission("validation.run", "DELETE")),
|
|
service: ValidationRunService = Depends(_get_run_service),
|
|
):
|
|
"""Delete a validation run record."""
|
|
logger.reason(f"delete_run — Run: {run_id}, User: {current_user.username}", extra={"src": "validation_routes"})
|
|
if not service.delete_run(run_id):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found")
|
|
# #endregion delete_run
|
|
|
|
|
|
# #endregion ValidationRoutes
|