Files
PacketStation/server/packetstation/api/admin.py
T
spencermandClaude Fable 5.1 39a4eb5213
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s
Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board
(waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag.
Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the
Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with
receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays
blocked until every line is pulled or short and every pulled line has a heat.

Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short
reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull
counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind,
optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events
table (migration 0005) shown on the Admin Storage tab.

Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat
pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board
and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI
stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 17:13:31 -06:00

411 lines
14 KiB
Python

"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts, storage
and alerts.
Every route needs the admin role and a password session. Roles cannot be changed here: they come from AD.
"""
from __future__ import annotations
import time
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from psycopg.rows import dict_row
from pydantic import BaseModel, Field
from .. import audit
from ..auth import devices, sessions
from ..auth import directory as userdir
from ..auth import groups as groupmap
from ..auth import pin as pins
from ..auth.ldap import LdapUnavailable
from ..auth.signin import ldap_down
from ..domain import assets, scan
from .account import active_sessions
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
from .errors import ApiError
router = APIRouter(prefix="/api/admin", tags=["admin"])
GROUPS_CACHE_SECONDS = 300
ROLES_NOTE = "Roles come from AD groups. Ask IT to change them."
class ReasonBody(BaseModel):
reason: str = Field(max_length=500)
def _like(q: str) -> str:
return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
def _roles_by_user(conn: Any, ids: list[Any]) -> dict[Any, list[str]]:
out: dict[Any, list[str]] = {}
if not ids:
return out
rows = conn.execute(
"SELECT user_id, role::text, dept_code FROM user_roles WHERE user_id = ANY(%s)", (ids,)
).fetchall()
for uid, kind, dept in rows:
out.setdefault(uid, []).append(groupmap.role_from_row(kind, dept))
gm = groupmap.current()
return {k: gm.sort_roles(v) for k, v in out.items()}
USER_SELECT = (
"SELECT u.*, p.user_id IS NOT NULL AS pin_set, p.disabled_at IS NOT NULL AS pin_disabled,"
" (SELECT count(*) FROM users x WHERE x.emp_no = u.emp_no) AS emp_count"
" FROM users u LEFT JOIN user_pins p ON p.user_id = u.id"
)
def _user_row(r: dict[str, Any], roles: list[str]) -> dict[str, Any]:
flags = r.get("directory_flags") or {}
return {
"id": str(r["id"]),
"sam_account": r["sam_account"],
"display_name": r["display_name"],
"emp_no": r["emp_no"],
"email": r["email"],
"active": r["active"],
"roles": roles,
"pin": {"set": bool(r["pin_set"]), "disabled": bool(r["pin_disabled"])},
"flags": {
"emp_missing": not r["emp_no"],
"emp_duplicate": bool(r["emp_no"]) and int(r["emp_count"] or 0) > 1,
"disabled": bool(flags.get("disabled")) or bool(flags.get("gone")),
},
"last_signin_at": r["last_signin_at"],
"ad_synced_at": r["ad_synced_at"],
}
@router.get("/users")
def users(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
q: str = Query("", max_length=100),
) -> list[dict[str, Any]]:
conn = d.conn
q = q.strip()
with conn.cursor(row_factory=dict_row) as cur:
if q:
pat = _like(q)
rows = cur.execute(
USER_SELECT + " WHERE u.sam_account ILIKE %s OR u.display_name ILIKE %s OR u.email ILIKE %s"
" OR u.emp_no = %s ORDER BY lower(u.display_name) LIMIT 500",
(pat, pat, pat, q),
).fetchall()
else:
rows = cur.execute(USER_SELECT + " ORDER BY lower(u.display_name) LIMIT 500").fetchall()
roles = _roles_by_user(conn, [r["id"] for r in rows])
return [_user_row(r, roles.get(r["id"], [])) for r in rows]
@router.get("/users/{user_id}")
def user_detail(
user_id: uuid.UUID,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(USER_SELECT + " WHERE u.id = %s", (user_id,)).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such user.")
out = _user_row(r, _roles_by_user(conn, [user_id]).get(user_id, []))
directory = directory_of(request)
try:
u = directory.get_user_by_guid(user_id)
out["groups"] = sorted(directory.groups_of(u.dn)) if u is not None else []
out["groups_source"] = "ad"
out["in_ad"] = u is not None
except LdapUnavailable:
out["groups"] = list((r.get("directory_flags") or {}).get("groups", []))
out["groups_source"] = "cache"
out["in_ad"] = None
out["sessions"] = active_sessions(conn, user_id)
out["note"] = ROLES_NOTE
return out
@router.get("/ad/lookup")
def ad_lookup(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
login: str = Query(..., min_length=1, max_length=256),
) -> dict[str, Any]:
directory = directory_of(request)
try:
u = directory.find_user(login)
if u is None:
return {"found": False, "user": None, "groups": [], "roles": [], "disabled": False}
found = sorted(directory.groups_of(u.dn))
except LdapUnavailable as e:
raise ldap_down(e) from None
return {
"found": True,
"user": userdir.ad_user_public(u),
"groups": found,
"roles": groupmap.roles_from_groups(found),
"disabled": u.disabled,
"note": ROLES_NOTE,
}
@router.delete("/users/{user_id}/pin", status_code=204)
def clear_pin(
user_id: uuid.UUID,
body: ReasonBody,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
reason = body.reason.strip()
if not reason:
raise ApiError(400, "reason_required", "Give a reason for clearing the PIN.")
conn = d.conn
with conn.transaction():
if not pins.clear(conn, user_id):
raise ApiError(404, "not_found", "That user has no PIN.")
audit.append(
conn,
action="pin.cleared",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="user",
subject_id=user_id,
it_reason=reason,
ip=p.ip,
)
return Response(status_code=204)
@router.get("/devices")
def device_list(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008
with d.conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT d.id, d.name, d.registered_by, u.display_name AS registered_by_name, d.registered_at,"
" d.last_seen_at, d.revoked_at,"
" (SELECT count(*) FROM sessions s WHERE s.device_id = d.id AND s.revoked_at IS NULL"
" AND s.expires_at > now()) AS active_sessions"
" FROM devices d LEFT JOIN users u ON u.id = d.registered_by"
" ORDER BY d.revoked_at IS NOT NULL, lower(d.name)"
).fetchall()
return [
{
"id": str(r["id"]),
"name": r["name"],
"registered_by": (
{"id": str(r["registered_by"]), "display_name": r["registered_by_name"]} if r["registered_by"] else None
),
"registered_at": r["registered_at"],
"last_seen_at": r["last_seen_at"],
"revoked_at": r["revoked_at"],
"active_sessions": int(r["active_sessions"]),
}
for r in rows
]
@router.delete("/devices/{device_id}", status_code=204)
def revoke_device(
device_id: uuid.UUID,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
conn = d.conn
found = devices.get(conn, device_id)
if found is None:
raise ApiError(404, "not_found", "No such device.")
with conn.transaction():
if devices.revoke(conn, device_id, p.user_id):
ended = sessions.revoke_where(conn, "device_id = %s AND kind = 'pin'", (device_id,), "device_revoked")
audit.append(
conn,
action="device.revoked",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="device",
subject_id=device_id,
detail={"name": found["name"], "sessions_ended": len(ended)},
ip=p.ip,
)
return Response(status_code=204)
def _group_map_view(request: Request) -> dict[str, Any]:
cache: dict[str, Any] | None = getattr(request.app.state, "groups_cache", None)
if cache is None or time.monotonic() - cache["t"] > GROUPS_CACHE_SECONDS:
try:
in_ad: list[str] | None = directory_of(request).list_groups()
except LdapUnavailable:
in_ad = None
cache = {"t": time.monotonic(), "checked_at": datetime.now(UTC), "in_ad": in_ad}
request.app.state.groups_cache = cache
names: list[str] | None = cache["in_ad"]
lower = {n.lower() for n in names} if names is not None else None
gm = groupmap.current()
out = []
for name, role in gm.entries:
out.append(
{
"name": name,
"role": str(role),
"dept_code": role.dept_code,
"in_ad": None if lower is None else name.lower() in lower,
}
)
extra = sorted(n for n in names or [] if gm.role_for(n) is None)
return {"checked_at": cache["checked_at"], "ad_reachable": names is not None, "groups": out, "extra_in_ou": extra}
@router.get("/groups")
def group_map(request: Request, p: Principal = Depends(admin_principal)) -> dict[str, Any]: # noqa: B008
return _group_map_view(request)
@router.post("/ad/refresh")
def ad_refresh(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
try:
res = userdir.refresh_from_ad(d.conn, directory_of(request), actor=p.actor, ip=p.ip)
except LdapUnavailable as e:
raise ldap_down(e) from None
request.app.state.groups_cache = None
return res.as_dict()
@router.get("/signin-attempts")
def signin_attempts(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
limit: int = Query(100, ge=1, le=1000),
) -> list[dict[str, Any]]:
with d.conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT a.id, a.at, a.account, host(a.ip) AS ip, a.kind, a.reason, a.user_id, a.session_id,"
" u.display_name FROM signin_attempts a LEFT JOIN users u ON u.id = a.user_id"
" WHERE NOT a.ok ORDER BY a.id DESC LIMIT %s",
(limit,),
).fetchall()
return [
{
"id": r["id"],
"at": r["at"],
"account": r["account"],
"ip": r["ip"],
"kind": r["kind"],
"reason": r["reason"],
"user_id": str(r["user_id"]) if r["user_id"] else None,
"display_name": r["display_name"],
}
for r in rows
]
# ---------------------------------------------------------------- storage and alerts
PING_MAX_SECONDS = 5.0
ALERT_SELECT = (
"SELECT n.*, u.display_name AS acknowledged_name FROM notification_events n"
" LEFT JOIN users u ON u.id = n.acknowledged_by"
)
def _alert_row(r: dict[str, Any]) -> dict[str, Any]:
by = r.get("acknowledged_by")
return {
"id": r["id"],
"kind": r["kind"],
"severity": r["severity"],
"subject": r["subject"],
"detail": r["detail"] or {},
"created_at": r["created_at"],
"resolved_at": r["resolved_at"],
"acknowledged_at": r["acknowledged_at"],
"acknowledged_by": {"id": str(by), "display_name": r.get("acknowledged_name")} if by else None,
}
@router.get("/storage")
def storage(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
s = settings_of(request)
usage = assets.usage_for(d.conn, s)
with d.conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
ALERT_SELECT + " WHERE n.kind = %s AND coalesce(n.subject, '') = %s AND n.resolved_at IS NULL"
" ORDER BY n.id LIMIT 1",
(assets.QUOTA_KIND, assets.QUOTA_SUBJECT),
).fetchone()
scanner = scan.scanner_for(s)
clamav: dict[str, Any] = {"configured": scanner is not None, "host": None, "port": None, "reachable": None}
if scanner is not None:
clamav.update(
host=scanner.host,
port=scanner.port,
reachable=scanner.ping(timeout=min(scanner.timeout, PING_MAX_SECONDS)),
)
return {
"usage": usage,
"quota_warn_pct": int(s.assets_quota_warn_pct),
"quota_bytes": int(s.assets_quota_bytes),
"alert": _alert_row(dict(r)) if r else None,
"clamav": clamav,
}
@router.get("/alerts")
def alerts(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
with d.conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
ALERT_SELECT + " WHERE n.resolved_at IS NULL ORDER BY n.created_at DESC, n.id DESC"
).fetchall()
return {"alerts": [_alert_row(dict(r)) for r in rows]}
@router.post("/alerts/{alert_id}/ack")
def ack_alert(
alert_id: int,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
with conn.transaction():
done = conn.execute(
"UPDATE notification_events SET acknowledged_at = now(), acknowledged_by = %s"
" WHERE id = %s AND acknowledged_at IS NULL RETURNING kind, subject",
(p.user_id, alert_id),
).fetchone()
if done is not None:
audit.append(
conn,
action="alert.acknowledged",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="notification",
subject_id=str(alert_id),
detail={"kind": done[0], "subject": done[1]},
ip=p.ip,
)
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(ALERT_SELECT + " WHERE n.id = %s", (alert_id,)).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such alert.")
return _alert_row(dict(r))