таски готовы
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Lazy loading of route modules to avoid import issues in tests
|
||||
# This allows tests to import routes without triggering all module imports
|
||||
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin']
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports']
|
||||
|
||||
def __getattr__(name):
|
||||
if name in __all__:
|
||||
|
||||
131
backend/src/api/routes/reports.py
Normal file
131
backend/src/api/routes/reports.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# [DEF:ReportsRouter:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: api, reports, list, detail, pagination, filters
|
||||
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.report_service.ReportsService
|
||||
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||
# @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from ...dependencies import get_task_manager, has_permission
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...core.logger import belief_scope
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
|
||||
from ...services.reports.report_service import ReportsService
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["Reports"])
|
||||
|
||||
|
||||
# [DEF:_parse_csv_enum_list:Function]
|
||||
# @PURPOSE: Parse comma-separated query value into enum list.
|
||||
# @PRE: raw may be None/empty or comma-separated values.
|
||||
# @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
|
||||
# @PARAM: raw (Optional[str]) - Comma-separated enum values.
|
||||
# @PARAM: enum_cls (type) - Enum class for validation.
|
||||
# @PARAM: field_name (str) - Query field name for diagnostics.
|
||||
# @RETURN: List - Parsed enum values.
|
||||
def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
|
||||
if raw is None or not raw.strip():
|
||||
return []
|
||||
values = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
parsed = []
|
||||
invalid = []
|
||||
for value in values:
|
||||
try:
|
||||
parsed.append(enum_cls(value))
|
||||
except ValueError:
|
||||
invalid.append(value)
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": f"Invalid values for '{field_name}'",
|
||||
"field": field_name,
|
||||
"invalid_values": invalid,
|
||||
"allowed_values": [item.value for item in enum_cls],
|
||||
},
|
||||
)
|
||||
return parsed
|
||||
# [/DEF:_parse_csv_enum_list:Function]
|
||||
|
||||
|
||||
# [DEF:list_reports:Function]
|
||||
# @PURPOSE: Return paginated unified reports list.
|
||||
# @PRE: authenticated/authorized request and validated query params.
|
||||
# @POST: returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @POST: deterministic error payload for invalid filters.
|
||||
@router.get("", response_model=ReportCollection)
|
||||
async def list_reports(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
task_types: Optional[str] = Query(None, description="Comma-separated task types"),
|
||||
statuses: Optional[str] = Query(None, description="Comma-separated statuses"),
|
||||
time_from: Optional[datetime] = Query(None),
|
||||
time_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None, max_length=200),
|
||||
sort_by: str = Query("updated_at"),
|
||||
sort_order: str = Query("desc"),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("list_reports"):
|
||||
try:
|
||||
parsed_task_types = _parse_csv_enum_list(task_types, TaskType, "task_types")
|
||||
parsed_statuses = _parse_csv_enum_list(statuses, ReportStatus, "statuses")
|
||||
query = ReportQuery(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
task_types=parsed_task_types,
|
||||
statuses=parsed_statuses,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": "Invalid query parameters",
|
||||
"code": "INVALID_REPORT_QUERY",
|
||||
"reason": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
service = ReportsService(task_manager)
|
||||
return service.list_reports(query)
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
|
||||
# [DEF:get_report_detail:Function]
|
||||
# @PURPOSE: Return one normalized report detail with diagnostics and next actions.
|
||||
# @PRE: authenticated/authorized request and existing report_id.
|
||||
# @POST: returns normalized detail envelope or 404 when report is not found.
|
||||
@router.get("/{report_id}", response_model=ReportDetailView)
|
||||
async def get_report_detail(
|
||||
report_id: str,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("get_report_detail", f"report_id={report_id}"):
|
||||
service = ReportsService(task_manager)
|
||||
detail = service.get_report_detail(report_id)
|
||||
if not detail:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"},
|
||||
)
|
||||
return detail
|
||||
# [/DEF:get_report_detail:Function]
|
||||
|
||||
# [/DEF:ReportsRouter:Module]
|
||||
@@ -21,7 +21,7 @@ import asyncio
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.utils.network import NetworkError
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports
|
||||
from .api import auth
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -123,6 +123,7 @@ app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
app.include_router(dashboards.router)
|
||||
app.include_router(datasets.router)
|
||||
app.include_router(reports.router)
|
||||
|
||||
|
||||
# [DEF:api.include_routers:Action]
|
||||
|
||||
128
backend/src/models/report.py
Normal file
128
backend/src/models/report.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# [DEF:backend.src.models.report:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, models, pydantic, normalization, pagination
|
||||
# @PURPOSE: Canonical report schemas for unified task reporting across heterogeneous task types.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models
|
||||
# @INVARIANT: Canonical report fields are always present for every report item.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:TaskType:Class]
|
||||
# @PURPOSE: Supported normalized task report types.
|
||||
class TaskType(str, Enum):
|
||||
LLM_VERIFICATION = "llm_verification"
|
||||
BACKUP = "backup"
|
||||
MIGRATION = "migration"
|
||||
DOCUMENTATION = "documentation"
|
||||
UNKNOWN = "unknown"
|
||||
# [/DEF:TaskType:Class]
|
||||
|
||||
|
||||
# [DEF:ReportStatus:Class]
|
||||
# @PURPOSE: Supported normalized report status values.
|
||||
class ReportStatus(str, Enum):
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
IN_PROGRESS = "in_progress"
|
||||
PARTIAL = "partial"
|
||||
# [/DEF:ReportStatus:Class]
|
||||
|
||||
|
||||
# [DEF:ErrorContext:Class]
|
||||
# @PURPOSE: Error and recovery context for failed/partial reports.
|
||||
class ErrorContext(BaseModel):
|
||||
code: Optional[str] = None
|
||||
message: str
|
||||
next_actions: List[str] = Field(default_factory=list)
|
||||
# [/DEF:ErrorContext:Class]
|
||||
|
||||
|
||||
# [DEF:TaskReport:Class]
|
||||
# @PURPOSE: Canonical normalized report envelope for one task execution.
|
||||
class TaskReport(BaseModel):
|
||||
report_id: str
|
||||
task_id: str
|
||||
task_type: TaskType
|
||||
status: ReportStatus
|
||||
started_at: Optional[datetime] = None
|
||||
updated_at: datetime
|
||||
summary: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
error_context: Optional[ErrorContext] = None
|
||||
source_ref: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("report_id", "task_id", "summary")
|
||||
@classmethod
|
||||
def _non_empty_str(cls, value: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("Value must be a non-empty string")
|
||||
return value.strip()
|
||||
# [/DEF:TaskReport:Class]
|
||||
|
||||
|
||||
# [DEF:ReportQuery:Class]
|
||||
# @PURPOSE: Query object for server-side report filtering, sorting, and pagination.
|
||||
class ReportQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=20, ge=1, le=100)
|
||||
task_types: List[TaskType] = Field(default_factory=list)
|
||||
statuses: List[ReportStatus] = Field(default_factory=list)
|
||||
time_from: Optional[datetime] = None
|
||||
time_to: Optional[datetime] = None
|
||||
search: Optional[str] = Field(default=None, max_length=200)
|
||||
sort_by: str = Field(default="updated_at")
|
||||
sort_order: str = Field(default="desc")
|
||||
|
||||
@field_validator("sort_by")
|
||||
@classmethod
|
||||
def _validate_sort_by(cls, value: str) -> str:
|
||||
allowed = {"updated_at", "status", "task_type"}
|
||||
if value not in allowed:
|
||||
raise ValueError(f"sort_by must be one of: {', '.join(sorted(allowed))}")
|
||||
return value
|
||||
|
||||
@field_validator("sort_order")
|
||||
@classmethod
|
||||
def _validate_sort_order(cls, value: str) -> str:
|
||||
if value not in {"asc", "desc"}:
|
||||
raise ValueError("sort_order must be 'asc' or 'desc'")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_time_range(self):
|
||||
if self.time_from and self.time_to and self.time_from > self.time_to:
|
||||
raise ValueError("time_from must be less than or equal to time_to")
|
||||
return self
|
||||
# [/DEF:ReportQuery:Class]
|
||||
|
||||
|
||||
# [DEF:ReportCollection:Class]
|
||||
# @PURPOSE: Paginated collection of normalized task reports.
|
||||
class ReportCollection(BaseModel):
|
||||
items: List[TaskReport]
|
||||
total: int = Field(ge=0)
|
||||
page: int = Field(ge=1)
|
||||
page_size: int = Field(ge=1)
|
||||
has_next: bool
|
||||
applied_filters: ReportQuery
|
||||
# [/DEF:ReportCollection:Class]
|
||||
|
||||
|
||||
# [DEF:ReportDetailView:Class]
|
||||
# @PURPOSE: Detailed report representation including diagnostics and recovery actions.
|
||||
class ReportDetailView(BaseModel):
|
||||
report: TaskReport
|
||||
timeline: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
diagnostics: Optional[Dict[str, Any]] = None
|
||||
next_actions: List[str] = Field(default_factory=list)
|
||||
# [/DEF:ReportDetailView:Class]
|
||||
|
||||
# [/DEF:backend.src.models.report:Module]
|
||||
152
backend/src/services/reports/normalizer.py
Normal file
152
backend/src/services/reports/normalizer.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# [DEF:backend.src.services.reports.normalizer:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, normalization, tasks, fallback
|
||||
# @PURPOSE: Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models.Task
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.type_profiles
|
||||
# @INVARIANT: Unknown task types and partial payloads remain visible via fallback mapping.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...core.task_manager.models import Task, TaskStatus
|
||||
from ...models.report import ErrorContext, ReportStatus, TaskReport
|
||||
from .type_profiles import get_type_profile, resolve_task_type
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:status_to_report_status:Function]
|
||||
# @PURPOSE: Normalize internal task status to canonical report status.
|
||||
# @PRE: status may be known or unknown string/enum value.
|
||||
# @POST: Always returns one of canonical ReportStatus values.
|
||||
# @PARAM: status (Any) - Internal task status value.
|
||||
# @RETURN: ReportStatus - Canonical report status.
|
||||
def status_to_report_status(status: Any) -> ReportStatus:
|
||||
raw = str(status.value if isinstance(status, TaskStatus) else status).upper()
|
||||
if raw == TaskStatus.SUCCESS.value:
|
||||
return ReportStatus.SUCCESS
|
||||
if raw == TaskStatus.FAILED.value:
|
||||
return ReportStatus.FAILED
|
||||
if raw in {TaskStatus.PENDING.value, TaskStatus.RUNNING.value, TaskStatus.AWAITING_INPUT.value, TaskStatus.AWAITING_MAPPING.value}:
|
||||
return ReportStatus.IN_PROGRESS
|
||||
return ReportStatus.PARTIAL
|
||||
# [/DEF:status_to_report_status:Function]
|
||||
|
||||
|
||||
# [DEF:build_summary:Function]
|
||||
# @PURPOSE: Build deterministic user-facing summary from task payload and status.
|
||||
# @PRE: report_status is canonical; plugin_id may be unknown.
|
||||
# @POST: Returns non-empty summary text.
|
||||
# @PARAM: task (Task) - Source task object.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: str - Normalized summary.
|
||||
def build_summary(task: Task, report_status: ReportStatus) -> str:
|
||||
result = task.result
|
||||
if isinstance(result, dict):
|
||||
for key in ("summary", "message", "status_message", "description"):
|
||||
value = result.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if report_status == ReportStatus.SUCCESS:
|
||||
return "Task completed successfully"
|
||||
if report_status == ReportStatus.FAILED:
|
||||
return "Task failed"
|
||||
if report_status == ReportStatus.IN_PROGRESS:
|
||||
return "Task is in progress"
|
||||
return "Task completed with partial data"
|
||||
# [/DEF:build_summary:Function]
|
||||
|
||||
|
||||
# [DEF:extract_error_context:Function]
|
||||
# @PURPOSE: Extract normalized error context and next actions for failed/partial reports.
|
||||
# @PRE: task is a valid Task object.
|
||||
# @POST: Returns ErrorContext for failed/partial when context exists; otherwise None.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: Optional[ErrorContext] - Error context block.
|
||||
def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[ErrorContext]:
|
||||
if report_status not in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
|
||||
return None
|
||||
|
||||
result = task.result if isinstance(task.result, dict) else {}
|
||||
message = None
|
||||
code = None
|
||||
next_actions = []
|
||||
|
||||
if isinstance(result.get("error"), dict):
|
||||
error_obj = result.get("error", {})
|
||||
message = error_obj.get("message") or message
|
||||
code = error_obj.get("code") or code
|
||||
actions = error_obj.get("next_actions")
|
||||
if isinstance(actions, list):
|
||||
next_actions = [str(action) for action in actions if str(action).strip()]
|
||||
|
||||
if not message:
|
||||
message = result.get("error_message") if isinstance(result.get("error_message"), str) else None
|
||||
|
||||
if not message:
|
||||
for log in reversed(task.logs):
|
||||
if str(log.level).upper() == "ERROR" and log.message:
|
||||
message = log.message
|
||||
break
|
||||
|
||||
if not message:
|
||||
message = "Not provided"
|
||||
|
||||
if not next_actions:
|
||||
next_actions = ["Review task diagnostics", "Retry the operation"]
|
||||
|
||||
return ErrorContext(code=code, message=message, next_actions=next_actions)
|
||||
# [/DEF:extract_error_context:Function]
|
||||
|
||||
|
||||
# [DEF:normalize_task_report:Function]
|
||||
# @PURPOSE: Convert one Task to canonical TaskReport envelope.
|
||||
# @PRE: task has valid id and plugin_id fields.
|
||||
# @POST: Returns TaskReport with required fields and deterministic fallback behavior.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @RETURN: TaskReport - Canonical normalized report.
|
||||
def normalize_task_report(task: Task) -> TaskReport:
|
||||
task_type = resolve_task_type(task.plugin_id)
|
||||
report_status = status_to_report_status(task.status)
|
||||
profile = get_type_profile(task_type)
|
||||
|
||||
started_at = task.started_at if isinstance(task.started_at, datetime) else None
|
||||
updated_at = task.finished_at if isinstance(task.finished_at, datetime) else None
|
||||
if not updated_at:
|
||||
updated_at = started_at or datetime.utcnow()
|
||||
|
||||
details: Dict[str, Any] = {
|
||||
"profile": {
|
||||
"display_label": profile.get("display_label"),
|
||||
"visual_variant": profile.get("visual_variant"),
|
||||
"icon_token": profile.get("icon_token"),
|
||||
"emphasis_rules": profile.get("emphasis_rules", []),
|
||||
},
|
||||
"result": task.result if task.result is not None else {"note": "Not provided"},
|
||||
}
|
||||
|
||||
source_ref: Dict[str, Any] = {}
|
||||
if isinstance(task.params, dict):
|
||||
for key in ("environment_id", "source_env_id", "target_env_id", "dashboard_id", "dataset_id", "resource_id"):
|
||||
if key in task.params:
|
||||
source_ref[key] = task.params.get(key)
|
||||
|
||||
return TaskReport(
|
||||
report_id=task.id,
|
||||
task_id=task.id,
|
||||
task_type=task_type,
|
||||
status=report_status,
|
||||
started_at=started_at,
|
||||
updated_at=updated_at,
|
||||
summary=build_summary(task, report_status),
|
||||
details=details,
|
||||
error_context=extract_error_context(task, report_status),
|
||||
source_ref=source_ref or None,
|
||||
)
|
||||
# [/DEF:normalize_task_report:Function]
|
||||
|
||||
# [/DEF:backend.src.services.reports.normalizer:Module]
|
||||
148
backend/src/services/reports/report_service.py
Normal file
148
backend/src/services/reports/report_service.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# [DEF:backend.src.services.reports.report_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, service, aggregation, filtering, pagination, detail
|
||||
# @PURPOSE: Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.manager.TaskManager
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.reports.normalizer
|
||||
# @INVARIANT: List responses are deterministic and include applied filter echo metadata.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskReport, TaskType
|
||||
from .normalizer import normalize_task_report
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:ReportsService:Class]
|
||||
# @PURPOSE: Service layer for list/detail report retrieval and normalization.
|
||||
# @TIER: CRITICAL
|
||||
# @PRE: TaskManager dependency is initialized.
|
||||
# @POST: Provides deterministic list/detail report responses.
|
||||
class ReportsService:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initialize service with TaskManager dependency.
|
||||
# @PARAM: task_manager (TaskManager) - Task manager providing source task history.
|
||||
def __init__(self, task_manager: TaskManager):
|
||||
self.task_manager = task_manager
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_load_normalized_reports:Function]
|
||||
# @PURPOSE: Build normalized reports from all available tasks.
|
||||
# @RETURN: List[TaskReport] - Reports sorted later by list logic.
|
||||
def _load_normalized_reports(self) -> List[TaskReport]:
|
||||
tasks = self.task_manager.get_all_tasks()
|
||||
reports = [normalize_task_report(task) for task in tasks]
|
||||
return reports
|
||||
# [/DEF:_load_normalized_reports:Function]
|
||||
|
||||
# [DEF:_matches_query:Function]
|
||||
# @PURPOSE: Apply query filtering to a report.
|
||||
# @PARAM: report (TaskReport) - Candidate report.
|
||||
# @PARAM: query (ReportQuery) - Applied query.
|
||||
# @RETURN: bool - True if report matches all filters.
|
||||
def _matches_query(self, report: TaskReport, query: ReportQuery) -> bool:
|
||||
if query.task_types and report.task_type not in query.task_types:
|
||||
return False
|
||||
if query.statuses and report.status not in query.statuses:
|
||||
return False
|
||||
if query.time_from and report.updated_at < query.time_from:
|
||||
return False
|
||||
if query.time_to and report.updated_at > query.time_to:
|
||||
return False
|
||||
if query.search:
|
||||
needle = query.search.lower()
|
||||
haystack = f"{report.summary} {report.task_type.value} {report.status.value}".lower()
|
||||
if needle not in haystack:
|
||||
return False
|
||||
return True
|
||||
# [/DEF:_matches_query:Function]
|
||||
|
||||
# [DEF:_sort_reports:Function]
|
||||
# @PURPOSE: Sort reports deterministically according to query settings.
|
||||
# @PARAM: reports (List[TaskReport]) - Filtered reports.
|
||||
# @PARAM: query (ReportQuery) - Sort config.
|
||||
# @RETURN: List[TaskReport] - Sorted reports.
|
||||
def _sort_reports(self, reports: List[TaskReport], query: ReportQuery) -> List[TaskReport]:
|
||||
reverse = query.sort_order == "desc"
|
||||
|
||||
if query.sort_by == "status":
|
||||
reports.sort(key=lambda item: item.status.value, reverse=reverse)
|
||||
elif query.sort_by == "task_type":
|
||||
reports.sort(key=lambda item: item.task_type.value, reverse=reverse)
|
||||
else:
|
||||
reports.sort(key=lambda item: item.updated_at, reverse=reverse)
|
||||
|
||||
return reports
|
||||
# [/DEF:_sort_reports:Function]
|
||||
|
||||
# [DEF:list_reports:Function]
|
||||
# @PURPOSE: Return filtered, sorted, paginated report collection.
|
||||
# @PRE: query has passed schema validation.
|
||||
# @POST: Returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @PARAM: query (ReportQuery) - List filters and pagination.
|
||||
# @RETURN: ReportCollection - Paginated unified reports payload.
|
||||
def list_reports(self, query: ReportQuery) -> ReportCollection:
|
||||
reports = self._load_normalized_reports()
|
||||
filtered = [report for report in reports if self._matches_query(report, query)]
|
||||
sorted_reports = self._sort_reports(filtered, query)
|
||||
|
||||
total = len(sorted_reports)
|
||||
start = (query.page - 1) * query.page_size
|
||||
end = start + query.page_size
|
||||
items = sorted_reports[start:end]
|
||||
has_next = end < total
|
||||
|
||||
return ReportCollection(
|
||||
items=items,
|
||||
total=total,
|
||||
page=query.page,
|
||||
page_size=query.page_size,
|
||||
has_next=has_next,
|
||||
applied_filters=query,
|
||||
)
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
# [DEF:get_report_detail:Function]
|
||||
# @PURPOSE: Return one normalized report with timeline/diagnostics/next actions.
|
||||
# @PRE: report_id exists in normalized report set.
|
||||
# @POST: Returns normalized detail envelope with diagnostics and next actions where applicable.
|
||||
# @PARAM: report_id (str) - Stable report identifier.
|
||||
# @RETURN: Optional[ReportDetailView] - Detailed report or None if not found.
|
||||
def get_report_detail(self, report_id: str) -> Optional[ReportDetailView]:
|
||||
reports = self._load_normalized_reports()
|
||||
target = next((report for report in reports if report.report_id == report_id), None)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
timeline = []
|
||||
if target.started_at:
|
||||
timeline.append({"event": "started", "at": target.started_at.isoformat()})
|
||||
timeline.append({"event": "updated", "at": target.updated_at.isoformat()})
|
||||
|
||||
diagnostics = target.details or {}
|
||||
if not diagnostics:
|
||||
diagnostics = {"note": "Not provided"}
|
||||
if target.error_context:
|
||||
diagnostics["error_context"] = target.error_context.model_dump()
|
||||
|
||||
next_actions = []
|
||||
if target.error_context and target.error_context.next_actions:
|
||||
next_actions = target.error_context.next_actions
|
||||
elif target.status in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
|
||||
next_actions = ["Review diagnostics", "Retry task if applicable"]
|
||||
|
||||
return ReportDetailView(
|
||||
report=target,
|
||||
timeline=timeline,
|
||||
diagnostics=diagnostics,
|
||||
next_actions=next_actions,
|
||||
)
|
||||
# [/DEF:get_report_detail:Function]
|
||||
# [/DEF:ReportsService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.reports.report_service:Module]
|
||||
91
backend/src/services/reports/type_profiles.py
Normal file
91
backend/src/services/reports/type_profiles.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# [DEF:backend.src.services.reports.type_profiles:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: reports, type_profiles, normalization, fallback
|
||||
# @PURPOSE: Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.report.TaskType
|
||||
# @INVARIANT: Unknown input always resolves to TaskType.UNKNOWN with a single fallback profile.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...models.report import TaskType
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:PLUGIN_TO_TASK_TYPE:Data]
|
||||
# @PURPOSE: Maps plugin identifiers to normalized report task types.
|
||||
PLUGIN_TO_TASK_TYPE: Dict[str, TaskType] = {
|
||||
"llm_dashboard_validation": TaskType.LLM_VERIFICATION,
|
||||
"superset-backup": TaskType.BACKUP,
|
||||
"superset-migration": TaskType.MIGRATION,
|
||||
"documentation": TaskType.DOCUMENTATION,
|
||||
}
|
||||
# [/DEF:PLUGIN_TO_TASK_TYPE:Data]
|
||||
|
||||
# [DEF:TASK_TYPE_PROFILES:Data]
|
||||
# @PURPOSE: Profile metadata registry for each normalized task type.
|
||||
TASK_TYPE_PROFILES: Dict[TaskType, Dict[str, Any]] = {
|
||||
TaskType.LLM_VERIFICATION: {
|
||||
"display_label": "LLM Verification",
|
||||
"visual_variant": "llm",
|
||||
"icon_token": "sparkles",
|
||||
"emphasis_rules": ["summary", "status", "next_actions"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.BACKUP: {
|
||||
"display_label": "Backup",
|
||||
"visual_variant": "backup",
|
||||
"icon_token": "archive",
|
||||
"emphasis_rules": ["summary", "status", "updated_at"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.MIGRATION: {
|
||||
"display_label": "Migration",
|
||||
"visual_variant": "migration",
|
||||
"icon_token": "shuffle",
|
||||
"emphasis_rules": ["summary", "status", "error_context"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.DOCUMENTATION: {
|
||||
"display_label": "Documentation",
|
||||
"visual_variant": "documentation",
|
||||
"icon_token": "file-text",
|
||||
"emphasis_rules": ["summary", "status", "details"],
|
||||
"fallback": False,
|
||||
},
|
||||
TaskType.UNKNOWN: {
|
||||
"display_label": "Other / Unknown",
|
||||
"visual_variant": "unknown",
|
||||
"icon_token": "help-circle",
|
||||
"emphasis_rules": ["summary", "status"],
|
||||
"fallback": True,
|
||||
},
|
||||
}
|
||||
# [/DEF:TASK_TYPE_PROFILES:Data]
|
||||
|
||||
|
||||
# [DEF:resolve_task_type:Function]
|
||||
# @PURPOSE: Resolve canonical task type from plugin/task identifier with guaranteed fallback.
|
||||
# @PRE: plugin_id may be None or unknown.
|
||||
# @POST: Always returns one of TaskType enum values.
|
||||
# @PARAM: plugin_id (Optional[str]) - Source plugin/task identifier from task record.
|
||||
# @RETURN: TaskType - Resolved canonical type or UNKNOWN fallback.
|
||||
def resolve_task_type(plugin_id: Optional[str]) -> TaskType:
|
||||
normalized = (plugin_id or "").strip()
|
||||
if not normalized:
|
||||
return TaskType.UNKNOWN
|
||||
return PLUGIN_TO_TASK_TYPE.get(normalized, TaskType.UNKNOWN)
|
||||
# [/DEF:resolve_task_type:Function]
|
||||
|
||||
|
||||
# [DEF:get_type_profile:Function]
|
||||
# @PURPOSE: Return deterministic profile metadata for a task type.
|
||||
# @PRE: task_type may be known or unknown.
|
||||
# @POST: Returns a profile dict and never raises for unknown types.
|
||||
# @PARAM: task_type (TaskType) - Canonical task type.
|
||||
# @RETURN: Dict[str, Any] - Profile metadata used by normalization and UI contracts.
|
||||
def get_type_profile(task_type: TaskType) -> Dict[str, Any]:
|
||||
return TASK_TYPE_PROFILES.get(task_type, TASK_TYPE_PROFILES[TaskType.UNKNOWN])
|
||||
# [/DEF:get_type_profile:Function]
|
||||
|
||||
# [/DEF:backend.src.services.reports.type_profiles:Module]
|
||||
81
backend/tests/fixtures/reports/fixtures_reports.json
vendored
Normal file
81
backend/tests/fixtures/reports/fixtures_reports.json
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"mixed_task_reports": {
|
||||
"description": "Mixed reports across all supported task types",
|
||||
"items": [
|
||||
{
|
||||
"report_id": "rep-001",
|
||||
"task_id": "task-001",
|
||||
"task_type": "llm_verification",
|
||||
"status": "success",
|
||||
"started_at": "2026-02-22T09:00:00Z",
|
||||
"updated_at": "2026-02-22T09:00:30Z",
|
||||
"summary": "LLM verification completed",
|
||||
"details": {
|
||||
"checks_performed": 12,
|
||||
"issues_found": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-002",
|
||||
"task_id": "task-002",
|
||||
"task_type": "backup",
|
||||
"status": "failed",
|
||||
"started_at": "2026-02-22T09:10:00Z",
|
||||
"updated_at": "2026-02-22T09:11:00Z",
|
||||
"summary": "Backup failed due to storage limit",
|
||||
"error_context": {
|
||||
"message": "Not enough disk space",
|
||||
"next_actions": ["Free storage", "Retry backup"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-003",
|
||||
"task_id": "task-003",
|
||||
"task_type": "migration",
|
||||
"status": "in_progress",
|
||||
"started_at": "2026-02-22T09:20:00Z",
|
||||
"updated_at": "2026-02-22T09:21:00Z",
|
||||
"summary": "Migration running",
|
||||
"details": {
|
||||
"progress_percent": 42
|
||||
}
|
||||
},
|
||||
{
|
||||
"report_id": "rep-004",
|
||||
"task_id": "task-004",
|
||||
"task_type": "documentation",
|
||||
"status": "partial",
|
||||
"started_at": "2026-02-22T09:30:00Z",
|
||||
"updated_at": "2026-02-22T09:31:00Z",
|
||||
"summary": "Documentation generated with partial coverage",
|
||||
"error_context": {
|
||||
"message": "Missing metadata for 3 columns",
|
||||
"next_actions": ["Review missing metadata"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"unknown_type_partial_payload": {
|
||||
"description": "Unknown type and partial payload fallback coverage",
|
||||
"items": [
|
||||
{
|
||||
"report_id": "rep-unknown-001",
|
||||
"task_id": "task-unknown-001",
|
||||
"task_type": "unknown",
|
||||
"status": "failed",
|
||||
"updated_at": "2026-02-22T10:00:00Z",
|
||||
"summary": "Unknown task type failed",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"report_id": "rep-partial-001",
|
||||
"task_id": "task-partial-001",
|
||||
"task_type": "backup",
|
||||
"status": "success",
|
||||
"updated_at": "2026-02-22T10:05:00Z",
|
||||
"summary": "Backup completed",
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
51
backend/tests/test_report_normalizer.py
Normal file
51
backend/tests/test_report_normalizer.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# [DEF:backend.tests.test_report_normalizer:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, normalizer, fallback
|
||||
# @PURPOSE: Validate unknown task type fallback and partial payload normalization behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.services.reports.normalizer
|
||||
# @INVARIANT: Unknown plugin types are mapped to canonical unknown task type.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.services.reports.normalizer import normalize_task_report
|
||||
|
||||
|
||||
def test_unknown_type_maps_to_unknown_profile():
|
||||
task = Task(
|
||||
id="unknown-1",
|
||||
plugin_id="custom-unmapped-plugin",
|
||||
status=TaskStatus.FAILED,
|
||||
started_at=datetime.utcnow(),
|
||||
finished_at=datetime.utcnow(),
|
||||
params={},
|
||||
result={"error_message": "Unexpected plugin payload"},
|
||||
)
|
||||
|
||||
report = normalize_task_report(task)
|
||||
|
||||
assert report.task_type.value == "unknown"
|
||||
assert report.summary
|
||||
assert report.error_context is not None
|
||||
|
||||
|
||||
def test_partial_payload_keeps_report_visible_with_placeholders():
|
||||
task = Task(
|
||||
id="partial-1",
|
||||
plugin_id="superset-backup",
|
||||
status=TaskStatus.SUCCESS,
|
||||
started_at=datetime.utcnow(),
|
||||
finished_at=datetime.utcnow(),
|
||||
params={},
|
||||
result=None,
|
||||
)
|
||||
|
||||
report = normalize_task_report(task)
|
||||
|
||||
assert report.task_type.value == "backup"
|
||||
assert report.details is not None
|
||||
assert "result" in report.details
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_report_normalizer:Module]
|
||||
117
backend/tests/test_reports_api.py
Normal file
117
backend/tests/test_reports_api.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# [DEF:backend.tests.test_reports_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
|
||||
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.api.routes.reports
|
||||
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
admin_role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[admin_role])
|
||||
|
||||
|
||||
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, started_at: datetime, finished_at: datetime = None, result=None):
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
params={"environment_id": "env-1"},
|
||||
result=result or {"summary": f"{plugin_id} {status.value.lower()}"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_reports_default_pagination_contract():
|
||||
now = datetime.utcnow()
|
||||
tasks = [
|
||||
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=10), now - timedelta(minutes=9)),
|
||||
_make_task("t-2", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=8), now - timedelta(minutes=7)),
|
||||
_make_task("t-3", "llm_dashboard_validation", TaskStatus.RUNNING, now - timedelta(minutes=6), None),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert set(["items", "total", "page", "page_size", "has_next", "applied_filters"]).issubset(data.keys())
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
assert data["total"] == 3
|
||||
assert isinstance(data["items"], list)
|
||||
assert data["applied_filters"]["sort_by"] == "updated_at"
|
||||
assert data["applied_filters"]["sort_order"] == "desc"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_filter_and_pagination():
|
||||
now = datetime.utcnow()
|
||||
tasks = [
|
||||
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=30), now - timedelta(minutes=29)),
|
||||
_make_task("t-2", "superset-backup", TaskStatus.FAILED, now - timedelta(minutes=20), now - timedelta(minutes=19)),
|
||||
_make_task("t-3", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=10), now - timedelta(minutes=9)),
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?task_types=backup&statuses=failed&page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 1
|
||||
assert data["has_next"] is False
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["task_type"] == "backup"
|
||||
assert data["items"][0]["status"] == "failed"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_reports_invalid_filter_returns_400():
|
||||
now = datetime.utcnow()
|
||||
tasks = [_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=5), now - timedelta(minutes=4))]
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports?task_types=bad_type")
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert "detail" in body
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_api:Module]
|
||||
83
backend/tests/test_reports_detail_api.py
Normal file
83
backend/tests/test_reports_detail_api.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# [DEF:backend.tests.test_reports_detail_api:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, api, detail, diagnostics
|
||||
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> backend.src.api.routes.reports
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[role])
|
||||
|
||||
|
||||
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
|
||||
now = datetime.utcnow()
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=now - timedelta(minutes=2),
|
||||
finished_at=now - timedelta(minutes=1) if status != TaskStatus.RUNNING else None,
|
||||
params={"environment_id": "env-1"},
|
||||
result=result or {"summary": f"{plugin_id} result"},
|
||||
)
|
||||
|
||||
|
||||
def test_get_report_detail_success():
|
||||
task = _make_task(
|
||||
"detail-1",
|
||||
"superset-migration",
|
||||
TaskStatus.FAILED,
|
||||
result={"error": {"message": "Step failed", "next_actions": ["Check mapping", "Retry"]}},
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/detail-1")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "report" in data
|
||||
assert data["report"]["report_id"] == "detail-1"
|
||||
assert "diagnostics" in data
|
||||
assert "next_actions" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_report_detail_not_found():
|
||||
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/unknown-id")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_detail_api:Module]
|
||||
81
backend/tests/test_reports_openapi_conformance.py
Normal file
81
backend/tests/test_reports_openapi_conformance.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# [DEF:backend.tests.test_reports_openapi_conformance:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: tests, reports, openapi, conformance
|
||||
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
|
||||
# @LAYER: Domain (Tests)
|
||||
# @RELATION: TESTS -> specs/020-task-reports-design/contracts/reports-api.openapi.yaml
|
||||
# @INVARIANT: List and detail payloads include required contract keys.
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, tasks):
|
||||
self._tasks = tasks
|
||||
|
||||
def get_all_tasks(self):
|
||||
return self._tasks
|
||||
|
||||
|
||||
def _admin_user():
|
||||
role = SimpleNamespace(name="Admin", permissions=[])
|
||||
return SimpleNamespace(username="test-admin", roles=[role])
|
||||
|
||||
|
||||
def _task(task_id: str, plugin_id: str, status: TaskStatus):
|
||||
now = datetime.utcnow()
|
||||
return Task(
|
||||
id=task_id,
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
started_at=now,
|
||||
finished_at=now if status != TaskStatus.RUNNING else None,
|
||||
params={"environment_id": "env-1"},
|
||||
result={"summary": f"{plugin_id} {status.value.lower()}"},
|
||||
)
|
||||
|
||||
|
||||
def test_reports_list_openapi_required_keys():
|
||||
tasks = [
|
||||
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
|
||||
_task("r-2", "superset-migration", TaskStatus.FAILED),
|
||||
]
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
required = {"items", "total", "page", "page_size", "has_next", "applied_filters"}
|
||||
assert required.issubset(body.keys())
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_reports_detail_openapi_required_keys():
|
||||
tasks = [_task("r-3", "llm_dashboard_validation", TaskStatus.SUCCESS)]
|
||||
app.dependency_overrides[get_current_user] = lambda: _admin_user()
|
||||
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
|
||||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/reports/r-3")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert "report" in body
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.test_reports_openapi_conformance:Module]
|
||||
Reference in New Issue
Block a user