The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Quota usage attribution: a user's own consumption + an admin breakdown across users.
|
|
|
|
The aggregate daily budget lives in ApiQuotaUsage; here we read the per-spend audit log
|
|
(QuotaEvent) to attribute units to the user who triggered them (NULL actor = system).
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import quota
|
|
from app.auth import admin_user, current_user
|
|
from app.db import get_db
|
|
from app.models import QuotaEvent, User
|
|
from app.routes._keyset import utc
|
|
|
|
router = APIRouter(prefix="/api/quota", tags=["quota"])
|
|
|
|
|
|
def _sum(db: Session, *conds) -> int:
|
|
return int(db.scalar(select(func.coalesce(func.sum(QuotaEvent.units), 0)).where(*conds)) or 0)
|
|
|
|
|
|
@router.get("/my-usage")
|
|
def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
|
now = datetime.now(timezone.utc)
|
|
mine = QuotaEvent.user_id == user.id
|
|
by_action = {
|
|
action: int(units)
|
|
for action, units in db.execute(
|
|
select(QuotaEvent.action, func.sum(QuotaEvent.units))
|
|
.where(mine, QuotaEvent.created_at >= now - timedelta(days=30))
|
|
.group_by(QuotaEvent.action)
|
|
).all()
|
|
}
|
|
return {
|
|
"today": _sum(db, mine, QuotaEvent.created_at >= quota.pacific_day_start_utc()),
|
|
"last_7d": _sum(db, mine, QuotaEvent.created_at >= now - timedelta(days=7)),
|
|
"last_30d": _sum(db, mine, QuotaEvent.created_at >= now - timedelta(days=30)),
|
|
"all_time": _sum(db, mine),
|
|
"by_action": by_action,
|
|
}
|
|
|
|
|
|
@router.get("/admin")
|
|
def admin_usage(
|
|
since: datetime | None = None,
|
|
until: datetime | None = None,
|
|
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Spend per account (and per action) and per day, between `since` (default: 30 days back) and
|
|
`until` (default: now), optionally for one account (`none` = the background)."""
|
|
now = datetime.now(timezone.utc)
|
|
lo = utc(since) or now - timedelta(days=30)
|
|
window = [QuotaEvent.created_at >= lo]
|
|
if until is not None:
|
|
window.append(QuotaEvent.created_at < utc(until))
|
|
if user is not None:
|
|
window.append(
|
|
QuotaEvent.user_id.is_(None) if user == "none" else QuotaEvent.user_id == int(user)
|
|
)
|
|
|
|
totals = db.execute(
|
|
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
|
|
.where(*window)
|
|
.group_by(QuotaEvent.user_id)
|
|
).all()
|
|
|
|
per_action: dict[int | None, dict[str, int]] = {}
|
|
for uid, action, units in db.execute(
|
|
select(QuotaEvent.user_id, QuotaEvent.action, func.sum(QuotaEvent.units))
|
|
.where(*window)
|
|
.group_by(QuotaEvent.user_id, QuotaEvent.action)
|
|
).all():
|
|
per_action.setdefault(uid, {})[action] = int(units)
|
|
|
|
uids = [uid for uid, _ in totals if uid is not None]
|
|
emails = (
|
|
{u.id: u.email for u in db.execute(select(User).where(User.id.in_(uids))).scalars()}
|
|
if uids
|
|
else {}
|
|
)
|
|
|
|
rows = [
|
|
{
|
|
"user_id": uid,
|
|
"email": emails.get(uid, "(deleted user)") if uid is not None else "System",
|
|
"total": int(total),
|
|
"by_action": per_action.get(uid, {}),
|
|
}
|
|
for uid, total in totals
|
|
]
|
|
rows.sort(key=lambda r: r["total"], reverse=True)
|
|
|
|
# The daily series in Pacific days (matches the budget reset), of the one account when filtered.
|
|
day_expr = func.date(func.timezone("America/Los_Angeles", QuotaEvent.created_at))
|
|
daily = [
|
|
{"day": str(day), "total": int(units)}
|
|
for day, units in db.execute(
|
|
select(day_expr, func.sum(QuotaEvent.units))
|
|
.where(*window)
|
|
.group_by(day_expr)
|
|
.order_by(day_expr)
|
|
).all()
|
|
]
|
|
|
|
return {"rows": rows, "daily": daily}
|