122 lines
4.4 KiB
Python
122 lines
4.4 KiB
Python
"""Paginated history without removing operation receipts used by other services."""
|
|
|
|
from datetime import UTC, date, datetime, time, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import Depends, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from pigway_frame_flow.core.auth import require
|
|
from pigway_frame_flow.errors import ErrorCode as E
|
|
from pigway_frame_flow.errors import FlowError
|
|
from pigway_frame_flow.models import Operation, OperationStatus
|
|
|
|
|
|
class HistoryPage(BaseModel):
|
|
items: list[Operation]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
|
|
|
|
class HistoryDelete(BaseModel):
|
|
operation_ids: list[str] = Field(min_length=1, max_length=200)
|
|
|
|
|
|
def install_history(app, service):
|
|
db = service.db
|
|
|
|
@app.get(
|
|
"/api/v1/operation-history",
|
|
response_model=HistoryPage,
|
|
tags=["Operations"],
|
|
dependencies=[Depends(require("history:read"))],
|
|
)
|
|
def list_history(
|
|
request: Request,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
status: OperationStatus | None = None,
|
|
action: str = Query("", max_length=80),
|
|
device_id: str = Query("", max_length=120),
|
|
q: str = Query("", max_length=200),
|
|
start: date | None = None,
|
|
end: date | None = None,
|
|
):
|
|
if start and end and start > end:
|
|
raise FlowError(
|
|
E.INVALID_REQUEST, "Start date must not follow end date", 422
|
|
)
|
|
clauses = [
|
|
"NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=o.id)"
|
|
]
|
|
args: list = []
|
|
if "admin" not in request.state.permissions:
|
|
clauses.append("o.client_id=?")
|
|
args.append(request.state.client_id)
|
|
for column, value in [
|
|
("status", status),
|
|
("action", action),
|
|
("device_id", device_id),
|
|
]:
|
|
if value:
|
|
clauses.append(f"o.{column}=?")
|
|
args.append(value)
|
|
if q.strip():
|
|
clauses.append(
|
|
"instr(lower(o.id||' '||o.request_id||' '||o.client_id||' '||coalesce(o.result,'')),lower(?))>0"
|
|
)
|
|
args.append(q.strip())
|
|
for day, operator, offset in [(start, ">=", 0), (end, "<", 1)]:
|
|
if day:
|
|
boundary = datetime.combine(
|
|
day, time(), ZoneInfo("Asia/Shanghai")
|
|
) + timedelta(days=offset)
|
|
clauses.append(f"o.created_at{operator}?")
|
|
args.append(boundary.astimezone(UTC).isoformat())
|
|
where = " AND ".join(clauses)
|
|
# Keep count and rows consistent if a worker finishes or a deletion occurs.
|
|
with db.lock:
|
|
total = db.one(
|
|
"SELECT count(*) AS total FROM operations o WHERE " + where, args
|
|
)["total"]
|
|
page = min(page, max(1, (total + page_size - 1) // page_size))
|
|
rows = db.all(
|
|
"SELECT o.id FROM operations o WHERE "
|
|
+ where
|
|
+ " ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?",
|
|
[*args, page_size, (page - 1) * page_size],
|
|
)
|
|
return HistoryPage(
|
|
items=[service.operation(row["id"]) for row in rows],
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|
|
|
|
@app.delete(
|
|
"/api/v1/operation-history",
|
|
tags=["Operations"],
|
|
dependencies=[Depends(require("admin"))],
|
|
)
|
|
def delete_history(payload: HistoryDelete):
|
|
ids = list(dict.fromkeys(payload.operation_ids))
|
|
placeholders = ",".join("?" for _ in ids)
|
|
with db.transaction() as connection:
|
|
rows = connection.execute(
|
|
f"SELECT id,status FROM operations WHERE id IN ({placeholders})", ids
|
|
).fetchall()
|
|
if len(rows) != len(ids):
|
|
raise FlowError(E.NOT_FOUND, "History record not found", 404)
|
|
if any(
|
|
row["status"] not in {"COMPLETED", "FAILED", "UNKNOWN"} for row in rows
|
|
):
|
|
raise FlowError(E.CONFLICT, "Running operations cannot be deleted", 409)
|
|
before = connection.total_changes
|
|
connection.executemany(
|
|
"INSERT OR IGNORE INTO hidden_history(operation_id) VALUES(?)",
|
|
[(key,) for key in ids],
|
|
)
|
|
deleted = connection.total_changes - before
|
|
return {"deleted": deleted}
|