"""Reports, validated spreadsheet batches and indexed history.""" import asyncio import os from typing import Literal from fastapi import APIRouter, HTTPException, Query, UploadFile from fastapi.responses import FileResponse, Response from pydantic import BaseModel, Field from apps.admin_console.services.automation_store import identifier, store from apps.admin_console.services import automation_service as service from apps.admin_console.services import batch_archive as archive_service from apps.admin_console.services import report_export, task_import from artemis.config.settings import settings from artemis.runtime.case_result import read_case_result router = APIRouter(prefix="/api/automation", tags=["automation"]) class ReportPreferences(BaseModel): formats: list[Literal["docx", "xlsx"]] = Field( default=["docx", "xlsx"], min_length=1, max_length=2 ) class BatchRequest(ReportPreferences): request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$") name: str = Field(min_length=1, max_length=200) cases: list[task_import.TaskCase] = Field(min_length=1, max_length=task_import.MAX_ROWS) device_serial: str = Field(min_length=1, max_length=200) profile: Literal["flash", "pro"] = "flash" stop_on_failure: bool = False repeat_count: int = Field(default=1, ge=1, strict=True) timeout_seconds: int = Field( default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS, ge=30, le=86400, ) def require(kind, key): try: value = store.get(kind, identifier(key)) except ValueError as exc: raise HTTPException(400, str(exc)) from exc if value is None: raise HTTPException(404, "记录不存在") return value @router.get("/preferences") def preferences(): return { **store.preferences(), "batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS, "batch_max_executions": settings.ARTEMIS_BATCH_MAX_EXECUTIONS, } @router.put("/preferences") def save_preferences(value: ReportPreferences): return store.put("settings", "reports", value.model_dump()) @router.get("/template") def template(): return Response( task_import.template(), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": 'attachment; filename="artemis-tasks.xlsx"'}, ) @router.post("/import/preview") async def preview(file: UploadFile): data = await file.read(task_import.MAX_BYTES + 1) try: return await asyncio.to_thread(task_import.parse_workbook, data) except (ValueError, OSError) as exc: raise HTTPException(400, str(exc)) from exc @router.post("/batches") def create_batch(value: BatchRequest): try: payload = value.model_dump() if "formats" not in value.model_fields_set: payload["formats"] = store.preferences()["formats"] return service.create_batch(payload) except ValueError as exc: raise HTTPException(409, str(exc)) from exc @router.get("/batches") def batches(): return [ { **b, "counts": { s: sum(j["status"] == s for j in store.jobs(b["id"])) for s in ( "pending", "queued", "running", "completed", "failed", "interrupted", "skipped", "cancelled", ) }, } for b in store.records("batch") ] @router.get("/batches/by-request/{request_id}") def batch_by_request(request_id: str): record = require("batch_request", request_id) return batch_detail(record["batch_id"]) @router.get("/batches/{batch_id}") def batch_detail(batch_id: str): jobs = store.jobs(batch_id) for job in jobs: job["test_result"] = read_case_result( job["session_id"], job["status"], job.get("error") or "" ) return {**require("batch", batch_id), "jobs": jobs} @router.delete("/batches/{batch_id}") def delete_batch(batch_id: str): try: key = identifier(batch_id) except ValueError as exc: raise HTTPException(400, str(exc)) from exc try: if archive_service.status(key, require("batch", key))["status"] == "preparing": raise HTTPException(409, "报告正在打包,请完成后再删除批次") if not service.delete_batch(key): raise HTTPException(404, "批次不存在") except ValueError as exc: raise HTTPException(409, str(exc)) from exc archive_service.forget(key) return {"batch_id": key, "status": "deleted"} @router.post("/batches/{batch_id}/{action}") def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]): require("batch", batch_id) try: return service.batch_action(batch_id, action) except ValueError as exc: raise HTTPException(409, str(exc)) from exc @router.post("/batches/{batch_id}/archive/prepare") def prepare_batch_archive(batch_id: str): batch = require("batch", batch_id) return archive_service.prepare(batch_id, batch) @router.get("/batches/{batch_id}/archive/status") def batch_archive_status(batch_id: str): batch = require("batch", batch_id) return archive_service.status(batch_id, batch) @router.get("/batches/{batch_id}/archive") async def batch_archive(batch_id: str): batch = require("batch", batch_id) if archive_service.status(batch_id, batch)["status"] == "preparing": raise HTTPException(409, "报告正在打包,请等待准备完成") path = archive_service.archive_path(batch_id) if ( not path.is_file() or not batch.get("report_ready") and archive_service.status(batch_id, batch)["status"] != "ready" ): path = await asyncio.to_thread(report_export.export_batch, batch_id) return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip") @router.get("/reports/{session_id}") def report(session_id: str): try: return report_export.session_report(session_id) except ValueError as exc: raise HTTPException(404, str(exc)) from exc @router.get("/reports/{session_id}/{fmt}") async def download_report( session_id: str, fmt: Literal["docx", "xlsx", "screenshots", "video", "all"] ): try: if fmt in {"screenshots", "video", "all"}: path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt) label = report_export.archive_case_name(report_export.session_report(session_id)) return FileResponse(path, filename=f"{label}-{fmt}.zip") paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt]) return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}") except ValueError as exc: raise HTTPException(404, str(exc)) from exc @router.get("/history") def history( q: str = Query(default="", max_length=200), status: str = "", start: float | None = None, end: float | None = None, page: int = Query(default=1, ge=1), size: int = Query(default=30, ge=1, le=100), ): # SQL filtering/pagination searches the entire persisted history, not a UI cache. query = """WITH history AS ( SELECT s.session_id, s.initial_goal, s.start_time, COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time, COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name, COALESCE(json_extract(j.payload,'$.case.tags'),'') tags, COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id, COALESCE(json_extract(j.payload,'$.case.category'),'') category, COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory, COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions, COALESCE(json_extract(j.payload,'$.error'),'') error FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id UNION ALL SELECT j.session_id,json_extract(j.payload,'$.case.goal'), COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')), json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'), COALESCE(json_extract(j.payload,'$.case.tags'),''), COALESCE(json_extract(j.payload,'$.case.case_id'),''), COALESCE(json_extract(j.payload,'$.case.category'),''), COALESCE(json_extract(j.payload,'$.case.subcategory'),''), COALESCE(json_extract(j.payload,'$.case.preconditions'),''), COALESCE(json_extract(j.payload,'$.error'),'') FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id) ) """ where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')" args = [] if q: where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')" escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" args.extend([escaped] * 8) for condition, value in [ ("status = ?", status or None), ("start_time >= ?", start), ("start_time < ?", end), ]: if value is not None: where += " AND " + condition args.append(value) with store.connection() as conn: total = conn.execute(query + "SELECT count(*) FROM history" + where, args).fetchone()[0] rows = conn.execute( query + "SELECT * FROM history" + where + " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?", [*args, size, (page - 1) * size], ).fetchall() items = [dict(row) for row in rows] jobs = store.jobs_for_sessions([item["session_id"] for item in items]) for item in items: item["test_result"] = read_case_result( item["session_id"], item["status"], item.get("error") or "" ) if job := jobs.get(item["session_id"]): item["name"] = task_import.execution_title(job) item["iteration"] = job.get("iteration", 1) item["repeat_count"] = job.get("repeat_count", 1) elif item["case_id"]: item["name"] = task_import.case_title(item) return {"items": items, "total": total, "page": page, "size": size}