Files
Artemis-Eapil/apps/admin_console/routers/automation.py
T

263 lines
9.7 KiB
Python

"""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
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
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,
}
@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):
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
@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]
for item in items:
if item["case_id"]:
item["name"] = task_import.case_title(item)
return {"items": items, "total": total, "page": page, "size": size}