"""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 apps.admin_console.services.case_review import read_reviewed_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 CaseReviewRequest(BaseModel): model_config = {"extra": "forbid"} status: Literal["passed", "failed"] reason: str = Field(default="", max_length=2000) 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_reviewed_result( job["session_id"], job["status"], job.get("error") or "", storage=store ) 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.put("/reports/{session_id}/review") def review_case(session_id: str, value: CaseReviewRequest): report(session_id) job = store.job(session_id) or {} batch_id = job.get("batch_id") if ( batch_id and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing" ): raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验") with report_export.EXPORT_LOCK: current = report(session_id) if current["status"] not in { "completed", "success", "failed", "cancelled", "interrupted", "skipped", }: raise HTTPException(409, "请在任务结束后进行人工复验") try: store.save_case_review(session_id, value.status, value.reason) except ValueError as exc: raise HTTPException(409, str(exc)) from exc if batch_id: archive_service.forget(batch_id) # A finishing worker can publish report_ready just after export; # removing only the generated ZIP also prevents that stale cache hit. try: archive_service.archive_path(batch_id).unlink(missing_ok=True) except OSError: pass # An open download may hold the file; report_ready is already invalidated. return report(session_id) @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), batch_id: str = Query(default="", max_length=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, j.batch_id 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'),''), j.batch_id 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 batch_id == "__batch__": where += " AND batch_id IS NOT NULL" elif batch_id == "__single__": where += " AND batch_id IS NULL" elif batch_id: try: identifier(batch_id) except ValueError as exc: raise HTTPException(400, str(exc)) from exc where += " AND batch_id = ?" args.append(batch_id) 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_reviewed_result( item["session_id"], item["status"], item.get("error") or "", storage=store ) 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}