333 lines
13 KiB
Python
333 lines
13 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
|
|
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)
|
|
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}
|