Files

122 lines
4.4 KiB
Python
Raw Permalink Normal View History

"""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}