Based on manvalan/pinscope main. Default LLM is DeepSeek with local skills and PDF ingest. Datasheets are fetched from LCSC/TI, stored in the component library, and review extracts abs-max with a deeper checklist. Adds scripts/update-pinscope.sh for the production host.
664 lines
24 KiB
Python
664 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.pinscopex.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
|
|
|
|
# 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 state,
|
|
# the worker is already gone. Flip status → error so the UI
|
|
# stops lying. Skip the sweep when execution_name is missing
|
|
# (worker may still be enqueueing).
|
|
exec_state = "unknown"
|
|
if meta.execution_name:
|
|
exec_state = job_runner.get_execution_state(meta.execution_name)
|
|
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,
|
|
}
|