"""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.exploration_listing import EXPLORATION_RUNS_CTE 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_extensions, 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 | None = Field(default=None, 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) reviewed = task_extensions.reviewed_batch_sessions(batch_id, store) for job in jobs: job["human_reviewed"] = job["session_id"] in reviewed 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 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, label = await asyncio.to_thread(report_export.session_download, session_id, fmt) 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( *(["batch:" + batch_id] if batch_id else []), "session:" + session_id ): 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 " + EXPLORATION_RUNS_CTE + """, history_base 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, COALESCE(CASE WHEN json_valid(s.device_info) THEN json_extract(s.device_info,'$.profile') END, json_extract(j.payload,'$.task_config.profile'),json_extract(j.payload,'$.profile')) profile 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, COALESCE(json_extract(j.payload,'$.task_config.profile'),json_extract(j.payload,'$.profile')) FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id) ), history AS ( SELECT h.*, e.exploration_id, e.exploration_kind, e.case_version, e.exploration_status, e.exploration_latest_run_id, e.exploration_run_count, e.exploration_case_name, e.exploration_profile FROM history_base h LEFT JOIN exploration_runs e ON e.run_id=h.session_id WHERE COALESCE(e.exploration_kind,'') != 'exploration' OR (e.run_id=e.exploration_latest_run_id AND COALESCE(e.exploration_status,'completed') NOT IN ('draft','reserved','queued','running','paused','waiting_user','demonstrating','analyzing_demo','finalizing','interrupted')) ) """ ) where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')" args = [] if batch_id == "__exploration__": where += " AND exploration_kind = 'exploration'" elif batch_id == "__batch__": where += " AND batch_id IS NOT NULL" elif batch_id == "__single__": where += " AND batch_id IS NULL AND COALESCE(exploration_kind,'') != 'exploration'" 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 (COALESCE(exploration_case_name,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["profile"] = ( "explore" if item.get("exploration_kind") == "exploration" else item.get("exploration_profile") or item.get("profile") ) 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) if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"): item["name"] = item["exploration_case_name"] return {"items": items, "total": total, "page": page, "size": size}