Files
periscope/backend/routers/admin.py
micheleandCursor 8d2b85600f Rebrand Pinscope to Periscope across product and codebase.
Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 20:02:04 +02:00

682 lines
24 KiB
Python

"""Admin endpoints — library components, user management, and limits.
All endpoints require the requesting user to have role: "admin" in their
Clerk public metadata. In local dev (no auth), all requests are treated as admin.
"""
from __future__ import annotations
import asyncio
import re
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from backend.config import settings
from backend.periscopex.utils import safe_mpn
from backend.routers.deps import get_storage
from backend.services import admin_settings as settings_svc
from backend.services.billing_hook import get_billing
from backend.services import projects as proj_svc
router = APIRouter(prefix="/admin", tags=["admin"])
# ---------------------------------------------------------------------------
# Admin verification
# ---------------------------------------------------------------------------
async def is_admin(request: Request) -> bool:
"""Check if the caller is an admin. Result is cached on request.state."""
cached = getattr(request.state, "_is_admin", None)
if cached is not None:
return cached
user_id: str = request.state.user_id
# Local dev — no auth, treat as admin
if not settings.use_auth:
request.state._is_admin = True
return True
if settings.use_local_auth:
from backend.services import local_users
user = local_users.get_user(user_id)
result = bool(user and user.is_admin)
request.state._is_admin = result
return result
# Fetch user from Clerk Backend API and check public_metadata.role
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.clerk.com/v1/users/{user_id}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
data = resp.json()
role = data.get("public_metadata", {}).get("role")
result = role == "admin"
else:
result = False
except Exception:
result = False
request.state._is_admin = result
return result
async def _require_admin(request: Request) -> str:
"""Return user_id if the caller is an admin, else raise 403."""
if not await is_admin(request):
raise HTTPException(403, "Admin access required")
return request.state.user_id
# ---------------------------------------------------------------------------
# Library components
# ---------------------------------------------------------------------------
@router.get("/components")
async def list_components(request: Request):
"""List all extracted IC components and passive patterns in the library."""
await _require_admin(request)
storage = get_storage(request)
catalog = proj_svc.list_library_catalog(storage)
return JSONResponse(
content={
"ics": catalog["ics"],
"passives": catalog["passives"],
"simple": catalog["simple"],
},
headers={"Cache-Control": "no-store"},
)
def _safe_name(name: str) -> str:
"""Sanitize MPN to safe filename (same logic as pipeline)."""
safe = safe_mpn(name)
if ".." in safe or not re.match(r"^[A-Za-z0-9]", safe):
raise HTTPException(400, "Invalid component name")
return safe
@router.get("/components/{component_type}/{name:path}")
async def get_component(component_type: str, name: str, request: Request):
"""Return the raw JSON for an IC extraction or passive pattern."""
await _require_admin(request)
safe = _safe_name(name)
storage = get_storage(request)
if component_type == "ic":
key = f"library/extracted/{safe}.json"
elif component_type == "passive":
key = f"library/patterns/{safe}.json"
elif component_type == "simple":
# Check library/passives/ first, then library/models/
key = f"library/passives/{safe}.json"
if not storage.exists(key):
key = f"library/models/{safe}.json"
else:
raise HTTPException(400, f"Unknown component type: {component_type}")
if not storage.exists(key):
raise HTTPException(404, f"Component not found: {name}")
return JSONResponse(content=storage.read_json(key))
@router.delete("/components/{component_type}/{name:path}")
async def delete_component(component_type: str, name: str, request: Request):
"""Delete an IC extraction or passive pattern from the shared library."""
await _require_admin(request)
safe = _safe_name(name)
storage = get_storage(request)
if component_type == "ic":
key = f"library/extracted/{safe}.json"
elif component_type == "passive":
key = f"library/patterns/{safe}.json"
elif component_type == "simple":
# Check library/passives/ first, then library/models/
key = f"library/passives/{safe}.json"
if not storage.exists(key):
key = f"library/models/{safe}.json"
else:
raise HTTPException(400, f"Unknown component type: {component_type}")
if not storage.exists(key):
raise HTTPException(404, f"Component not found: {name}")
storage.delete_key(key)
# Delete datasheet ref (blob preserved for other refs; GC cleans orphans)
from backend.services.datasheet_store import delete_datasheet_ref
deleted_datasheets = 0
old_blob = delete_datasheet_ref(storage, name)
if old_blob:
deleted_datasheets += 1
# Legacy flat file cleanup (remove after migration confirmed)
ds_key = f"library/datasheets/{safe}.pdf"
if storage.exists(ds_key):
storage.delete_key(ds_key)
deleted_datasheets += 1
return {"deleted": key, "deleted_datasheets": deleted_datasheets}
def _clerk_profile_fields(clerk: dict) -> dict:
"""Pull display name / email / avatar out of a Clerk user object."""
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
emails = clerk.get("email_addresses", [])
return {
"name": f"{first} {last}".strip() or None,
"email": emails[0].get("email_address") if emails else None,
"image_url": clerk.get("image_url"),
}
def _base_admin_user(storage, uid: str) -> dict:
"""Build the project-count + balance record for a single user_id."""
try:
project_count = len(proj_svc.list_projects(storage, uid))
except Exception:
project_count = 0
try:
balance = get_billing().get_balance(storage, uid)
except Exception:
balance = 0.0
return {
"user_id": uid,
"project_count": project_count,
"balance": round(balance, 4),
"name": None,
"email": None,
"image_url": None,
}
async def _enrich_clerk_profiles(users: dict[str, dict]) -> None:
"""Fill name/email/avatar for each user via the Clerk Backend API.
Fetches in parallel (bounded) so the list stays fast even with many
users. Failures per-user are swallowed — the row still renders with
the user_id as a fallback label.
"""
sem = asyncio.Semaphore(10)
async with httpx.AsyncClient(timeout=10.0) as client:
async def _one(uid: str) -> None:
async with sem:
try:
resp = await client.get(
f"https://api.clerk.com/v1/users/{uid}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
users[uid].update(_clerk_profile_fields(resp.json()))
except Exception:
pass
await asyncio.gather(*(_one(uid) for uid in users))
@router.get("/users")
async def list_users(request: Request):
"""List every user with a project or any credit activity.
The balance file is written on a user's first ``GET /api/credits``
(trial grant), so this includes everyone who has ever opened the
authenticated app — not only project creators. To find a user who has
never opened the app, use ``GET /api/admin/users/search?email=``.
"""
await _require_admin(request)
storage = get_storage(request)
user_ids: set[str] = set()
# Project creators (users/{user_id}/...)
for entry in storage.list_prefix("users/"):
parts = entry.split("/")
if len(parts) >= 2 and parts[1]:
user_ids.add(parts[1])
# Anyone with credit activity (covers the trial grant on first app open)
user_ids.update(get_billing().list_user_ids(storage))
users: dict[str, dict] = {uid: _base_admin_user(storage, uid) for uid in user_ids}
# Enrich with Clerk user info when auth is enabled
if settings.use_auth and users:
await _enrich_clerk_profiles(users)
return list(users.values())
@router.get("/users/search")
async def search_users(request: Request, email: str):
"""Find users by email via Clerk so any account can be topped up (admin).
Resolves even users with no project and no credit activity yet — useful
for granting credits to someone who has just signed up. Requires auth
to be enabled (no Clerk directory exists in local dev).
"""
await _require_admin(request)
storage = get_storage(request)
email = email.strip()
if not email:
return []
if not settings.use_auth:
raise HTTPException(400, "User search requires authentication to be enabled")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
"https://api.clerk.com/v1/users",
params={"email_address": [email]},
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
except Exception as exc:
raise HTTPException(502, "Failed to look up user") from exc
if resp.status_code != 200:
raise HTTPException(502, "Failed to look up user")
results: list[dict] = []
for clerk in resp.json():
uid = clerk.get("id")
if not uid:
continue
entry = _base_admin_user(storage, uid)
entry.update(_clerk_profile_fields(clerk))
results.append(entry)
return results
# ---------------------------------------------------------------------------
# Usage / cost tracking
# ---------------------------------------------------------------------------
@router.get("/usage")
async def get_usage(request: Request):
"""Aggregate API token usage and cost across all users and projects."""
await _require_admin(request)
storage = get_storage(request)
user_entries = storage.list_prefix("users/")
seen_uids: set[str] = set()
user_rows: list[dict] = []
grand_total = 0.0
for entry in user_entries:
parts = entry.split("/")
if len(parts) >= 2:
uid = parts[1]
if uid in seen_uids:
continue
seen_uids.add(uid)
projects = proj_svc.list_projects(storage, uid)
user_cost = 0.0
project_details = []
for p in projects:
cost = p.total_cost_usd or 0.0
user_cost += cost
project_details.append({
"id": p.id,
"name": p.name,
"status": p.status,
"cost_usd": cost,
"created": p.created,
})
user_rows.append({
"user_id": uid,
"project_count": len(projects),
"total_cost_usd": round(user_cost, 4),
"projects": project_details,
"name": None,
"email": None,
})
grand_total += user_cost
# Enrich with Clerk user info
if settings.use_auth and user_rows:
async with httpx.AsyncClient() as client:
for row in user_rows:
try:
resp = await client.get(
f"https://api.clerk.com/v1/users/{row['user_id']}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
clerk = resp.json()
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
row["name"] = f"{first} {last}".strip() or None
emails = clerk.get("email_addresses", [])
row["email"] = emails[0].get("email_address") if emails else None
except Exception:
pass
return {
"grand_total_usd": round(grand_total, 4),
"users": user_rows,
}
# ---------------------------------------------------------------------------
# All projects (cross-user)
# ---------------------------------------------------------------------------
async def _enrich_with_clerk_info(
items: list[dict], uid_key: str = "user_id",
name_key: str = "owner_name", email_key: str = "owner_email",
) -> None:
"""Enrich a list of dicts with Clerk user info, deduplicating API calls."""
if not settings.use_auth or not items:
return
cache: dict[str, dict] = {}
async with httpx.AsyncClient() as client:
for item in items:
uid = item[uid_key]
if uid not in cache:
try:
resp = await client.get(
f"https://api.clerk.com/v1/users/{uid}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
clerk = resp.json()
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
emails = clerk.get("email_addresses", [])
cache[uid] = {
name_key: f"{first} {last}".strip() or None,
email_key: emails[0].get("email_address") if emails else None,
}
else:
cache[uid] = {name_key: None, email_key: None}
except Exception:
cache[uid] = {name_key: None, email_key: None}
item.update(cache[uid])
@router.get("/projects")
async def list_all_projects(request: Request):
"""List all projects across all users with metadata."""
await _require_admin(request)
storage = get_storage(request)
user_entries = storage.list_prefix("users/")
seen_uids: set[str] = set()
all_projects: list[dict] = []
for entry in user_entries:
parts = entry.split("/")
if len(parts) >= 2:
uid = parts[1]
if uid in seen_uids:
continue
seen_uids.add(uid)
projects = proj_svc.list_projects(storage, uid)
for p in projects:
all_projects.append({
"id": p.id,
"name": p.name,
"user_id": p.user_id,
"status": p.status,
"created": p.created,
"updated": p.updated,
"has_bom": p.has_bom,
"has_netlist": p.has_netlist,
"datasheet_count": p.datasheet_count,
"total_cost_usd": p.total_cost_usd,
"pipeline_state": p.pipeline_state,
"summary": p.summary,
"owner_name": None,
"owner_email": None,
})
await _enrich_with_clerk_info(all_projects)
return all_projects
# ---------------------------------------------------------------------------
# Running pipelines
# ---------------------------------------------------------------------------
@router.get("/runs")
async def list_running_pipelines(request: Request):
"""List queued and running pipelines, plus drive the stale-running sweeper.
Source of truth is ``project.json`` (``status`` ∈ {queued, running}); we
cross-check with the Cloud Run Job execution. Any project whose
execution is in a terminal Cloud Run state but whose status is still
queued/running is flipped to ``error`` here — this is the sweeper that
keeps zombie projects from showing "running" forever in the UI.
"""
from datetime import datetime, timezone
from backend.services import job_runner
await _require_admin(request)
storage = get_storage(request)
now = datetime.now(timezone.utc)
runs: list[dict] = []
seen_uids: set[str] = set()
for entry in storage.list_prefix("users/"):
parts = entry.split("/")
if len(parts) < 2:
continue
uid = parts[1]
if uid in seen_uids:
continue
seen_uids.add(uid)
prefix = f"users/{uid}/projects/"
for proj_entry in storage.list_prefix(prefix):
meta_key = (
proj_entry if proj_entry.endswith("/project.json")
else f"{proj_entry}/project.json"
)
if not storage.exists(meta_key):
continue
try:
meta = proj_svc.ProjectMeta.model_validate(storage.read_json(meta_key))
except Exception:
continue
if meta.status not in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING):
continue
# Sweeper: if the execution is in a terminal Cloud Run / local
# state, the worker is already gone. Flip status → error so the
# UI stops lying. Also heal projects whose event log already
# ends with pipeline_complete (finished, meta never flipped).
healed = proj_svc.heal_if_pipeline_finished(storage, uid, meta.id)
if healed is not None:
continue
exec_state = "unknown"
if meta.execution_name:
exec_state = job_runner.get_execution_state(meta.execution_name)
elif not job_runner.use_cloud_run_jobs():
# Local zombie: no execution_name but a dead pid file, or
# no live proc — treat as failed after the stale window.
exec_state = job_runner.get_execution_state(
f"local/projects/{meta.id}"
)
if exec_state in ("succeeded", "failed", "cancelled"):
# Allow a short grace period so we don't race the worker
# writing its own terminal status. updated may be stale
# if the worker died before any status write.
try:
last_update = datetime.fromisoformat(meta.updated)
age = (now - last_update).total_seconds()
except Exception:
age = settings.pipeline_sweeper_stale_seconds + 1
if age >= settings.pipeline_sweeper_stale_seconds:
proj_svc.mark_stale_running(
storage, uid, meta.id,
f"Worker terminated (execution state={exec_state}); please restart.",
)
continue
try:
started_at = datetime.fromisoformat(meta.updated)
except Exception:
started_at = now
runs.append({
"project_id": meta.id,
"project_name": meta.name,
"user_id": uid,
"status": meta.status,
"execution_name": meta.execution_name,
"execution_state": exec_state,
"started_at": started_at.isoformat(),
"duration_seconds": int((now - started_at).total_seconds()),
"owner_name": None,
"owner_email": None,
})
await _enrich_with_clerk_info(runs)
return runs
# ---------------------------------------------------------------------------
# Global settings
# ---------------------------------------------------------------------------
class UpdateMinVersionRequest(BaseModel):
min_model_version: str
@router.get("/settings")
async def get_settings(request: Request):
"""Get global admin settings (model version threshold, etc.)."""
await _require_admin(request)
storage = get_storage(request)
data = settings_svc.get_admin_settings(storage)
data["default_model_version"] = settings.get_default_model_version()
return data
@router.put("/settings/min-model-version")
async def set_min_model_version(req: UpdateMinVersionRequest, request: Request):
"""Set the minimum model version for library reuse."""
await _require_admin(request)
storage = get_storage(request)
try:
settings_svc.set_min_model_version(storage, req.min_model_version)
except Exception as e:
raise HTTPException(400, f"Invalid version: {e}")
return {"min_model_version": req.min_model_version}
# ---------------------------------------------------------------------------
# Email test
# ---------------------------------------------------------------------------
class TestEmailRequest(BaseModel):
to_email: str
@router.post("/test-email")
async def test_email(req: TestEmailRequest, request: Request):
"""Send a test email to verify Gmail API setup. Admin only."""
await _require_admin(request)
from backend.services.email import send_test_email
result = await send_test_email(req.to_email)
return result
# ---------------------------------------------------------------------------
# Project state overrides
# ---------------------------------------------------------------------------
@router.post("/projects/{project_id}/mark-complete")
async def mark_project_complete(project_id: str, request: Request):
"""Admin-only: force a paused project to ``complete`` status.
Intended for projects stuck at ``paused_insufficient_credits`` that the
admin has decided to finalize rather than resume. Clears the pause
checkpoint/reason; does not touch credits, cost totals, or artifacts.
"""
from backend.routers.deps import resolve_or_404
from backend.services import projects as proj_svc
await _require_admin(request)
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED):
raise HTTPException(409, "Cannot mark a running pipeline complete; cancel it first")
if meta.status == "complete":
return {"status": "complete", "project_id": project_id}
proj_svc.update_project(
storage,
owner_user_id,
project_id,
status="complete",
pause_checkpoint=None,
pause_reason=None,
)
return {"status": "complete", "project_id": project_id}
# ---------------------------------------------------------------------------
# Report overrides
# ---------------------------------------------------------------------------
@router.delete("/projects/{project_id}/findings/{finding_id}")
async def delete_finding(project_id: str, finding_id: str, request: Request):
"""Admin-only: delete a single finding (rule violation) from a report.
Rewrites ``report.json`` without the matching finding, recomputes summary
counts, and mirrors the summary onto ``ProjectMeta`` so dashboard totals
stay consistent. Returns 404 if the project, report, or finding is missing.
"""
from backend.routers.deps import resolve_or_404
await _require_admin(request)
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/report.json"
if not storage.exists(key):
raise HTTPException(404, "Report not found")
report = storage.read_json(key)
findings = report.get("findings", []) or []
remaining = [f for f in findings if f.get("finding_id") != finding_id]
if len(remaining) == len(findings):
raise HTTPException(404, f"Finding not found: {finding_id}")
summary = {"total": len(remaining), "ERROR": 0, "WARNING": 0, "INFO": 0}
for f in remaining:
status = f.get("status")
if status in summary:
summary[status] += 1
report["findings"] = remaining
report["summary"] = summary
storage.write_json(key, report)
proj_svc.update_project(storage, owner_user_id, project_id, summary=summary)
return {
"deleted": finding_id,
"project_id": project_id,
"remaining": len(remaining),
"summary": summary,
}