Rewrite leftover HTTP routers except auth.
Project ingest, pipeline enqueue/SSE, reports, admin, contact, survey, and feedback now resolve from src. Auth router is unchanged.
This commit is contained in:
@@ -0,0 +1,499 @@
|
|||||||
|
"""Admin library, users, usage, sweeper, and report overrides."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
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, resolve_or_404
|
||||||
|
from backend.services import admin_settings as settings_svc
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
from backend.services.billing_hook import get_billing
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
_CLERK_USERS = "https://api.clerk.com/v1/users"
|
||||||
|
|
||||||
|
|
||||||
|
async def is_admin(request: Request) -> bool:
|
||||||
|
cached = getattr(request.state, "_is_admin", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
user_id: str = request.state.user_id
|
||||||
|
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)
|
||||||
|
ok = bool(user and user.is_admin)
|
||||||
|
request.state._is_admin = ok
|
||||||
|
return ok
|
||||||
|
|
||||||
|
ok = False
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{_CLERK_USERS}/{user_id}",
|
||||||
|
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
ok = resp.json().get("public_metadata", {}).get("role") == "admin"
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
request.state._is_admin = ok
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_admin(request: Request) -> str:
|
||||||
|
if not await is_admin(request):
|
||||||
|
raise HTTPException(403, "Admin access required")
|
||||||
|
return request.state.user_id
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(name: str) -> str:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _component_key(storage, component_type: str, safe: str) -> str:
|
||||||
|
if component_type == "ic":
|
||||||
|
return f"library/extracted/{safe}.json"
|
||||||
|
if component_type == "passive":
|
||||||
|
return f"library/patterns/{safe}.json"
|
||||||
|
if component_type == "simple":
|
||||||
|
key = f"library/passives/{safe}.json"
|
||||||
|
if storage.exists(key):
|
||||||
|
return key
|
||||||
|
return f"library/models/{safe}.json"
|
||||||
|
raise HTTPException(400, f"Unknown component type: {component_type}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/components")
|
||||||
|
async def list_components(request: Request):
|
||||||
|
await _require_admin(request)
|
||||||
|
catalog = proj_svc.list_library_catalog(get_storage(request))
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"ics": catalog["ics"],
|
||||||
|
"passives": catalog["passives"],
|
||||||
|
"simple": catalog["simple"],
|
||||||
|
},
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/components/{component_type}/{name:path}")
|
||||||
|
async def get_component(component_type: str, name: str, request: Request):
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
key = _component_key(storage, component_type, _safe_name(name))
|
||||||
|
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):
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
key = _component_key(storage, component_type, _safe_name(name))
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, f"Component not found: {name}")
|
||||||
|
storage.delete_key(key)
|
||||||
|
from backend.services.datasheet_store import delete_datasheet_ref
|
||||||
|
|
||||||
|
deleted_datasheets = 0
|
||||||
|
if delete_datasheet_ref(storage, name):
|
||||||
|
deleted_datasheets += 1
|
||||||
|
ds_key = f"library/datasheets/{_safe_name(name)}.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:
|
||||||
|
first = clerk.get("first_name") or ""
|
||||||
|
last = clerk.get("last_name") or ""
|
||||||
|
emails = clerk.get("email_addresses") or []
|
||||||
|
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:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_ids_from_prefix(storage) -> set[str]:
|
||||||
|
ids: set[str] = set()
|
||||||
|
for entry in storage.list_prefix("users/"):
|
||||||
|
parts = entry.split("/")
|
||||||
|
if len(parts) >= 2 and parts[1]:
|
||||||
|
ids.add(parts[1])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_clerk_profiles(users: dict[str, dict]) -> None:
|
||||||
|
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"{_CLERK_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:
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.gather(*(_one(uid) for uid in users))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(request: Request):
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
user_ids = _user_ids_from_prefix(storage)
|
||||||
|
user_ids.update(get_billing().list_user_ids(storage))
|
||||||
|
users = {uid: _base_admin_user(storage, uid) for uid in user_ids}
|
||||||
|
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):
|
||||||
|
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(
|
||||||
|
_CLERK_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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage")
|
||||||
|
async def get_usage(request: Request):
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
seen: set[str] = set()
|
||||||
|
user_rows: list[dict] = []
|
||||||
|
grand_total = 0.0
|
||||||
|
for uid in _user_ids_from_prefix(storage):
|
||||||
|
if uid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(uid)
|
||||||
|
projects = proj_svc.list_projects(storage, uid)
|
||||||
|
user_cost = 0.0
|
||||||
|
details = []
|
||||||
|
for p in projects:
|
||||||
|
cost = p.total_cost_usd or 0.0
|
||||||
|
user_cost += cost
|
||||||
|
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": details,
|
||||||
|
"name": None,
|
||||||
|
"email": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
grand_total += user_cost
|
||||||
|
if settings.use_auth and user_rows:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for row in user_rows:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{_CLERK_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") or []
|
||||||
|
row["email"] = emails[0].get("email_address") if emails else None
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {"grand_total_usd": round(grand_total, 4), "users": user_rows}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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"{_CLERK_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") or []
|
||||||
|
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):
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
all_projects: list[dict] = []
|
||||||
|
for uid in _user_ids_from_prefix(storage):
|
||||||
|
for p in proj_svc.list_projects(storage, uid):
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs")
|
||||||
|
async def list_running_pipelines(request: Request):
|
||||||
|
from backend.services import job_runner
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
runs: list[dict] = []
|
||||||
|
for uid in _user_ids_from_prefix(storage):
|
||||||
|
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
|
||||||
|
if proj_svc.heal_if_pipeline_finished(storage, uid, meta.id) 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():
|
||||||
|
exec_state = job_runner.get_execution_state(f"local/projects/{meta.id}")
|
||||||
|
if exec_state in ("succeeded", "failed", "cancelled"):
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateMinVersionRequest(BaseModel):
|
||||||
|
min_model_version: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def get_settings(request: Request):
|
||||||
|
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):
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailRequest(BaseModel):
|
||||||
|
to_email: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-email")
|
||||||
|
async def test_email(req: TestEmailRequest, request: Request):
|
||||||
|
await _require_admin(request)
|
||||||
|
from backend.services.email import send_test_email
|
||||||
|
|
||||||
|
return await send_test_email(req.to_email)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/mark-complete")
|
||||||
|
async def mark_project_complete(project_id: str, request: Request):
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, 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,
|
||||||
|
project_id,
|
||||||
|
status="complete",
|
||||||
|
pause_checkpoint=None,
|
||||||
|
pause_reason=None,
|
||||||
|
)
|
||||||
|
return {"status": "complete", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{project_id}/findings/{finding_id}")
|
||||||
|
async def delete_finding(project_id: str, finding_id: str, request: Request):
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{proj_svc.project_prefix(owner, 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, project_id, summary=summary)
|
||||||
|
return {
|
||||||
|
"deleted": finding_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"remaining": len(remaining),
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Unauthenticated contact mailbox. Honeypot and per-IP delay live here."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.services.email import _send_raw
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_COOLDOWN_S = 60
|
||||||
|
_seen: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
email: EmailStr = Field(..., max_length=254)
|
||||||
|
message: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
company: str = Field("", max_length=200)
|
||||||
|
subject: str = Field("", max_length=200)
|
||||||
|
honeypot: str = Field("", alias="_honey")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||||
|
if forwarded:
|
||||||
|
return forwarded
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _compose(data: ContactRequest) -> MIMEMultipart:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||||
|
msg["To"] = settings.contact_recipient
|
||||||
|
msg["Reply-To"] = data.email
|
||||||
|
title = data.subject or "New message"
|
||||||
|
msg["Subject"] = f"[Periscope Contact] {title} from {data.name}"
|
||||||
|
|
||||||
|
plain = [f"Name: {data.name}", f"Email: {data.email}"]
|
||||||
|
if data.company:
|
||||||
|
plain.append(f"Company: {data.company}")
|
||||||
|
if data.subject:
|
||||||
|
plain.append(f"Subject: {data.subject}")
|
||||||
|
plain.extend(["", data.message, "", "— Sent from the Periscope contact form"])
|
||||||
|
msg.attach(MIMEText("\n".join(plain), "plain"))
|
||||||
|
|
||||||
|
def cell(label: str, value: str) -> str:
|
||||||
|
return (
|
||||||
|
"<tr>"
|
||||||
|
f'<td style="padding: 8px 12px; border: 1px solid #e5e5e5; font-weight: 600; width: 100px;">{html.escape(label)}</td>'
|
||||||
|
f'<td style="padding: 8px 12px; border: 1px solid #e5e5e5;">{value}</td>'
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = cell("Name", html.escape(data.name))
|
||||||
|
email = html.escape(data.email)
|
||||||
|
rows += (
|
||||||
|
"<tr>"
|
||||||
|
'<td style="padding: 8px 12px; border: 1px solid #e5e5e5; font-weight: 600;">Email</td>'
|
||||||
|
f'<td style="padding: 8px 12px; border: 1px solid #e5e5e5;"><a href="mailto:{email}">{email}</a></td>'
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
if data.company:
|
||||||
|
rows += cell("Company", html.escape(data.company))
|
||||||
|
if data.subject:
|
||||||
|
rows += cell("Subject", html.escape(data.subject))
|
||||||
|
body = html.escape(data.message)
|
||||||
|
html_body = f"""\
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||||
|
<h2 style="font-size: 18px; margin: 0 0 16px;">New contact form submission</h2>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">{rows}</table>
|
||||||
|
<div style="margin-top: 16px; padding: 16px; background: #f9fafb; border-radius: 8px; font-size: 14px; line-height: 1.6; white-space: pre-wrap;">{body}</div>
|
||||||
|
<p style="margin-top: 24px; font-size: 12px; color: #888;">Sent from the Periscope contact form</p>
|
||||||
|
</div>"""
|
||||||
|
msg.attach(MIMEText(html_body, "html"))
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contact", response_model=ContactResponse)
|
||||||
|
async def submit_contact(data: ContactRequest, request: Request):
|
||||||
|
ok = "Message sent! We'll get back to you soon."
|
||||||
|
if data.honeypot:
|
||||||
|
return ContactResponse(success=True, message=ok)
|
||||||
|
|
||||||
|
ip = _client_ip(request)
|
||||||
|
now = time.time()
|
||||||
|
last = _seen.get(ip)
|
||||||
|
if last is not None and now - last < _COOLDOWN_S:
|
||||||
|
return ContactResponse(
|
||||||
|
success=False, message="Please wait a minute before submitting again."
|
||||||
|
)
|
||||||
|
_seen[ip] = now
|
||||||
|
if len(_seen) > 1000:
|
||||||
|
stale = now - _COOLDOWN_S
|
||||||
|
for key in [k for k, ts in _seen.items() if ts < stale]:
|
||||||
|
del _seen[key]
|
||||||
|
|
||||||
|
if not settings.use_email or not settings.contact_recipient:
|
||||||
|
logger.warning("Contact form submitted but email is not configured")
|
||||||
|
return ContactResponse(
|
||||||
|
success=False, message="Email is not configured on this server."
|
||||||
|
)
|
||||||
|
|
||||||
|
await _send_raw(settings.contact_recipient, _compose(data), "Contact form")
|
||||||
|
return ContactResponse(success=True, message=ok)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Request helpers shared by HTTP routers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
from backend.services.storage import StorageBackend
|
||||||
|
|
||||||
|
|
||||||
|
def get_storage(request: Request) -> StorageBackend:
|
||||||
|
return request.app.state.storage
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_id(request: Request) -> str:
|
||||||
|
return request.state.user_id
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_or_404(request: Request, project_id: str) -> tuple[str, proj_svc.ProjectMeta]:
|
||||||
|
"""Owner, collaborator, or admin; otherwise 404 (never 403 for missing projects)."""
|
||||||
|
storage = get_storage(request)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
hit = proj_svc.resolve_project_access(storage, user_id, project_id)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
|
||||||
|
from backend.routers.admin import is_admin
|
||||||
|
|
||||||
|
if await is_admin(request):
|
||||||
|
found = proj_svc.find_project_any_user(storage, project_id)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Feedback tickets: per-user JSON plus JSONL indexes for listing."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from backend.routers.deps import get_storage, get_user_id
|
||||||
|
from backend.services.storage import StorageBackend
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_TICKETS = "admin/feedback/tickets/"
|
||||||
|
_BY_USER = "admin/feedback/index/by_user/"
|
||||||
|
_BY_PROJECT = "admin/feedback/index/by_project/"
|
||||||
|
_ALL = "admin/feedback/index/all.jsonl"
|
||||||
|
|
||||||
|
FeedbackType = Literal["bug", "rule_feedback", "feature_request"]
|
||||||
|
FeedbackStatus = Literal["open", "acknowledged", "resolved"]
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_key(ticket_id: str) -> str:
|
||||||
|
return f"{_TICKETS}{ticket_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _user_index_key(user_id: str) -> str:
|
||||||
|
return f"{_BY_USER}{user_id}.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def _project_index_key(project_id: str) -> str:
|
||||||
|
return f"{_BY_PROJECT}{project_id}.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackTicket(BaseModel):
|
||||||
|
ticket_id: str
|
||||||
|
user_id: str
|
||||||
|
user_name: str | None = None
|
||||||
|
user_email: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
type: FeedbackType
|
||||||
|
status: FeedbackStatus = "open"
|
||||||
|
finding_id: str | None = None
|
||||||
|
finding_text: str | None = None
|
||||||
|
finding_designator: str | None = None
|
||||||
|
finding_mpn: str | None = None
|
||||||
|
finding_status: str | None = None
|
||||||
|
message: str
|
||||||
|
admin_notes: str | None = None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateFeedbackRequest(BaseModel):
|
||||||
|
type: FeedbackType
|
||||||
|
message: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
project_id: str | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
user_name: str | None = None
|
||||||
|
user_email: str | None = None
|
||||||
|
finding_id: str | None = None
|
||||||
|
finding_text: str | None = None
|
||||||
|
finding_designator: str | None = None
|
||||||
|
finding_mpn: str | None = None
|
||||||
|
finding_status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFeedbackRequest(BaseModel):
|
||||||
|
status: FeedbackStatus | None = None
|
||||||
|
admin_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _append_index(storage: StorageBackend, key: str, entry: dict) -> None:
|
||||||
|
prior = storage.read_text(key) if storage.exists(key) else ""
|
||||||
|
storage.write_text(key, prior + json.dumps(entry) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_index(storage: StorageBackend, key: str) -> list[dict]:
|
||||||
|
if not storage.exists(key):
|
||||||
|
return []
|
||||||
|
rows: list[dict] = []
|
||||||
|
for line in storage.read_text(key).splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Skipping malformed feedback index line in %s", key)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ticket(storage: StorageBackend, ticket_id: str) -> FeedbackTicket | None:
|
||||||
|
key = _ticket_key(ticket_id)
|
||||||
|
if not storage.exists(key):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return FeedbackTicket(**storage.read_json(key))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to read ticket %s", ticket_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _tickets_from_index(
|
||||||
|
storage: StorageBackend,
|
||||||
|
index_key: str,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
ticket_type: str | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> list[FeedbackTicket]:
|
||||||
|
out: list[FeedbackTicket] = []
|
||||||
|
for entry in reversed(_read_index(storage, index_key)):
|
||||||
|
tid = entry.get("ticket_id")
|
||||||
|
if not tid:
|
||||||
|
continue
|
||||||
|
ticket = _read_ticket(storage, tid)
|
||||||
|
if ticket is None:
|
||||||
|
continue
|
||||||
|
if status and ticket.status != status:
|
||||||
|
continue
|
||||||
|
if ticket_type and ticket.type != ticket_type:
|
||||||
|
continue
|
||||||
|
if project_id and ticket.project_id != project_id:
|
||||||
|
continue
|
||||||
|
out.append(ticket)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/feedback", response_model=FeedbackTicket)
|
||||||
|
async def create_feedback(body: CreateFeedbackRequest, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
ticket_id = uuid.uuid4().hex[:12]
|
||||||
|
ticket = FeedbackTicket(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=body.user_name,
|
||||||
|
user_email=body.user_email,
|
||||||
|
project_id=body.project_id,
|
||||||
|
project_name=body.project_name,
|
||||||
|
type=body.type,
|
||||||
|
status="open",
|
||||||
|
finding_id=body.finding_id,
|
||||||
|
finding_text=body.finding_text,
|
||||||
|
finding_designator=body.finding_designator,
|
||||||
|
finding_mpn=body.finding_mpn,
|
||||||
|
finding_status=body.finding_status,
|
||||||
|
message=body.message,
|
||||||
|
admin_notes=None,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
storage.write_json(_ticket_key(ticket_id), ticket.model_dump())
|
||||||
|
index = {"ticket_id": ticket_id, "created_at": now}
|
||||||
|
_append_index(storage, _user_index_key(user_id), index)
|
||||||
|
_append_index(storage, _ALL, index)
|
||||||
|
if body.project_id:
|
||||||
|
_append_index(storage, _project_index_key(body.project_id), index)
|
||||||
|
try:
|
||||||
|
from backend.services.email import send_feedback_received_email
|
||||||
|
|
||||||
|
await send_feedback_received_email(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
user_id=user_id,
|
||||||
|
feedback_type=body.type,
|
||||||
|
message=body.message,
|
||||||
|
submitter_name=body.user_name,
|
||||||
|
submitter_email=body.user_email,
|
||||||
|
project_name=body.project_name,
|
||||||
|
project_id=body.project_id,
|
||||||
|
finding_designator=body.finding_designator,
|
||||||
|
finding_mpn=body.finding_mpn,
|
||||||
|
finding_status=body.finding_status,
|
||||||
|
finding_text=body.finding_text,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to enqueue feedback-received email for %s", ticket_id)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feedback", response_model=list[FeedbackTicket])
|
||||||
|
async def list_my_feedback(request: Request, status: str | None = None):
|
||||||
|
return _tickets_from_index(
|
||||||
|
get_storage(request), _user_index_key(get_user_id(request)), status=status
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/feedback", response_model=list[FeedbackTicket])
|
||||||
|
async def list_all_feedback(
|
||||||
|
request: Request,
|
||||||
|
status: str | None = None,
|
||||||
|
type: str | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
):
|
||||||
|
from backend.routers.admin import _require_admin
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
return _tickets_from_index(
|
||||||
|
get_storage(request),
|
||||||
|
_ALL,
|
||||||
|
status=status,
|
||||||
|
ticket_type=type,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/feedback/{ticket_id}", response_model=FeedbackTicket)
|
||||||
|
async def update_feedback(ticket_id: str, body: UpdateFeedbackRequest, request: Request):
|
||||||
|
from backend.routers.admin import _require_admin
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
ticket = _read_ticket(storage, ticket_id)
|
||||||
|
if ticket is None:
|
||||||
|
raise HTTPException(404, "Ticket not found")
|
||||||
|
previous = (ticket.admin_notes or "").strip()
|
||||||
|
if body.status is not None:
|
||||||
|
ticket.status = body.status
|
||||||
|
if body.admin_notes is not None:
|
||||||
|
ticket.admin_notes = body.admin_notes
|
||||||
|
ticket.updated_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
storage.write_json(_ticket_key(ticket_id), ticket.model_dump())
|
||||||
|
notes = (ticket.admin_notes or "").strip()
|
||||||
|
if notes and notes != previous:
|
||||||
|
try:
|
||||||
|
from backend.services.email import send_feedback_reply_email
|
||||||
|
|
||||||
|
await send_feedback_reply_email(
|
||||||
|
user_id=ticket.user_id,
|
||||||
|
reply_text=notes,
|
||||||
|
original_message=ticket.message,
|
||||||
|
recipient_name=ticket.user_name,
|
||||||
|
recipient_email=ticket.user_email,
|
||||||
|
project_name=ticket.project_name,
|
||||||
|
finding_designator=ticket.finding_designator,
|
||||||
|
finding_mpn=ticket.finding_mpn,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to enqueue feedback-reply email for ticket %s", ticket_id
|
||||||
|
)
|
||||||
|
return ticket
|
||||||
@@ -0,0 +1,783 @@
|
|||||||
|
"""HTTP surface for analysis, placement, and PCB jobs.
|
||||||
|
|
||||||
|
The API process never runs extract/review itself: it CAS-transitions
|
||||||
|
project meta, asks ``job_runner`` to spawn a worker, and SSE-tails the
|
||||||
|
storage event log (plus the in-memory ``job_workspace`` broker).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator, Callable
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
|
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
||||||
|
from backend.services import event_bridge, job_runner, job_workspace
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
from backend.services.billing_hook import get_billing
|
||||||
|
from backend.services.cost_estimator import estimate_pipeline_cost
|
||||||
|
from backend.services.pcb_pipeline import (
|
||||||
|
analysis_busy as pcb_analysis_busy,
|
||||||
|
pcb_busy,
|
||||||
|
pcb_sse_terminal_from_status,
|
||||||
|
placement_busy as pcb_placement_busy,
|
||||||
|
)
|
||||||
|
from backend.services.placement_pipeline import (
|
||||||
|
analysis_busy as placement_analysis_busy,
|
||||||
|
placement_busy,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["pipeline"])
|
||||||
|
|
||||||
|
_REGEN_STAGES = {"derating"}
|
||||||
|
_ANALYSIS_IDLE = frozenset({
|
||||||
|
proj_svc.STATUS_DRAFT,
|
||||||
|
proj_svc.STATUS_COMPLETE,
|
||||||
|
proj_svc.STATUS_ERROR,
|
||||||
|
proj_svc.STATUS_CANCELLED,
|
||||||
|
})
|
||||||
|
_REPROCESS_IDLE = frozenset({
|
||||||
|
proj_svc.STATUS_COMPLETE,
|
||||||
|
proj_svc.STATUS_ERROR,
|
||||||
|
proj_svc.STATUS_CANCELLED,
|
||||||
|
})
|
||||||
|
_WORKER_DONE = frozenset({"succeeded", "failed", "cancelled"})
|
||||||
|
_ANALYSIS_SSE_DONE = frozenset({
|
||||||
|
"pipeline_complete",
|
||||||
|
"pipeline_error",
|
||||||
|
"pipeline_cancelled",
|
||||||
|
"pipeline_paused",
|
||||||
|
})
|
||||||
|
_PLACEMENT_IDLE = frozenset({"draft", "complete", "error", "cancelled"})
|
||||||
|
_PLACEMENT_SSE_DONE = frozenset({
|
||||||
|
"placement_complete",
|
||||||
|
"placement_error",
|
||||||
|
"placement_cancelled",
|
||||||
|
})
|
||||||
|
_PCB_IDLE = frozenset({"draft", "complete", "error", "cancelled"})
|
||||||
|
_PCB_SSE_DONE = frozenset({
|
||||||
|
"pcb_complete",
|
||||||
|
"pcb_error",
|
||||||
|
"pcb_cancelled",
|
||||||
|
})
|
||||||
|
_SSE_HEADERS = {
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
}
|
||||||
|
_ENQUEUE_FAIL = "Failed to enqueue pipeline worker; please retry"
|
||||||
|
|
||||||
|
|
||||||
|
class RegenRequest(BaseModel):
|
||||||
|
stages: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ReprocessRequest(BaseModel):
|
||||||
|
mode: Literal["failed", "all"] = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _project_active(meta: proj_svc.ProjectMeta) -> bool:
|
||||||
|
"""True while analysis is queued or running (meta only; no Cloud Run poll)."""
|
||||||
|
return meta.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING)
|
||||||
|
|
||||||
|
|
||||||
|
def _need_bom_netlist(meta: proj_svc.ProjectMeta, detail: str) -> None:
|
||||||
|
if not meta.has_bom or not meta.has_netlist:
|
||||||
|
raise HTTPException(400, detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_event_history(storage, owner: str, project_id: str) -> None:
|
||||||
|
"""Drop GCS log objects and any in-process broker history for this project."""
|
||||||
|
try:
|
||||||
|
event_bridge.GCSEventBroker(storage, owner).clear_history(project_id)
|
||||||
|
except Exception:
|
||||||
|
log.exception("event log clear failed for %s", project_id)
|
||||||
|
job_workspace.broker.clear_history(project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_exec(storage, owner: str, project_id: str, execution_name: str, **extra: Any) -> None:
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner, project_id, execution_name=execution_name, **extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue_or_503(
|
||||||
|
storage,
|
||||||
|
owner: str,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
enqueue: Callable[[], str],
|
||||||
|
fail_status: dict[str, Any],
|
||||||
|
http_detail: str,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
return enqueue()
|
||||||
|
except Exception:
|
||||||
|
log.exception("worker enqueue failed for %s", project_id)
|
||||||
|
proj_svc.update_project(storage, owner, project_id, **fail_status)
|
||||||
|
raise HTTPException(503, http_detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _cas_queued(
|
||||||
|
storage,
|
||||||
|
owner: str,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
from_status: frozenset[str],
|
||||||
|
conflict: str,
|
||||||
|
**fields: Any,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
proj_svc.transition_status(
|
||||||
|
storage, owner, project_id,
|
||||||
|
from_status=from_status,
|
||||||
|
to_status=proj_svc.STATUS_QUEUED,
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
|
except proj_svc.StatusConflict:
|
||||||
|
raise HTTPException(409, conflict)
|
||||||
|
|
||||||
|
|
||||||
|
async def _await_terminal(
|
||||||
|
storage, user_id: str, project_id: str, *, timeout_s: float,
|
||||||
|
) -> None:
|
||||||
|
"""Poll until analysis meta is terminal, or ``timeout_s`` elapses."""
|
||||||
|
step = 0.5
|
||||||
|
spent = 0.0
|
||||||
|
while spent < timeout_s:
|
||||||
|
try:
|
||||||
|
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||||
|
except Exception:
|
||||||
|
meta = None
|
||||||
|
if meta is None or meta.status in proj_svc.TERMINAL_STATUSES:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(step)
|
||||||
|
spent += step
|
||||||
|
|
||||||
|
|
||||||
|
async def _interrupt_active_pipeline(storage, user_id: str, project_id: str) -> None:
|
||||||
|
"""Soft-cancel, then hard-cancel, then force-cancel leftover running meta."""
|
||||||
|
proj_svc.request_cancel(storage, user_id, project_id)
|
||||||
|
await _await_terminal(storage, user_id, project_id, timeout_s=10.0)
|
||||||
|
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||||
|
if meta is None:
|
||||||
|
return
|
||||||
|
if _project_active(meta) and meta.execution_name:
|
||||||
|
job_runner.cancel_execution(meta.execution_name)
|
||||||
|
await _await_terminal(storage, user_id, project_id, timeout_s=5.0)
|
||||||
|
meta = proj_svc.get_project(storage, user_id, project_id) or meta
|
||||||
|
if _project_active(meta):
|
||||||
|
try:
|
||||||
|
proj_svc.transition_status(
|
||||||
|
storage, user_id, project_id,
|
||||||
|
from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED},
|
||||||
|
to_status=proj_svc.STATUS_CANCELLED,
|
||||||
|
pipeline_state={"error": "Superseded by reprocess"},
|
||||||
|
cancel_requested=False,
|
||||||
|
)
|
||||||
|
except proj_svc.StatusConflict:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_then_wait(storage, owner: str, project_id: str, meta: proj_svc.ProjectMeta) -> None:
|
||||||
|
if not _project_active(meta):
|
||||||
|
return
|
||||||
|
proj_svc.request_cancel(storage, owner, project_id)
|
||||||
|
await _await_terminal(storage, owner, project_id, timeout_s=10.0)
|
||||||
|
latest = proj_svc.get_project(storage, owner, project_id) or meta
|
||||||
|
if _project_active(latest) and latest.execution_name:
|
||||||
|
job_runner.cancel_execution(latest.execution_name)
|
||||||
|
await _await_terminal(storage, owner, project_id, timeout_s=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(gen: AsyncIterator[dict]) -> EventSourceResponse:
|
||||||
|
return EventSourceResponse(gen, ping=15, headers=_SSE_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _yield_sse(ev: str, data: dict) -> dict:
|
||||||
|
return {"event": ev, "data": json.dumps(data)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_until_crash(
|
||||||
|
storage,
|
||||||
|
owner: str,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
execution_name: str | None,
|
||||||
|
live: Callable[[proj_svc.ProjectMeta], bool],
|
||||||
|
finished: Callable[[proj_svc.ProjectMeta], bool],
|
||||||
|
reason_of: Callable[[proj_svc.ProjectMeta], str],
|
||||||
|
crash: dict[str, str | None],
|
||||||
|
initially_live: bool,
|
||||||
|
) -> None:
|
||||||
|
saw = initially_live
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
try:
|
||||||
|
cur = proj_svc.get_project(storage, owner, project_id)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if cur is None:
|
||||||
|
continue
|
||||||
|
if live(cur):
|
||||||
|
saw = True
|
||||||
|
elif saw and finished(cur):
|
||||||
|
crash["reason"] = reason_of(cur)
|
||||||
|
return
|
||||||
|
if execution_name and (saw or live(cur)):
|
||||||
|
try:
|
||||||
|
state = job_runner.get_execution_state(execution_name)
|
||||||
|
except Exception:
|
||||||
|
state = "unknown"
|
||||||
|
if state in _WORKER_DONE:
|
||||||
|
crash["reason"] = f"execution state={state}"
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Analysis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/start", status_code=202)
|
||||||
|
async def start(project_id: str, request: Request):
|
||||||
|
from backend._version import PERISCOPE_VERSION
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
_need_bom_netlist(meta, "Upload BOM and netlist before starting pipeline")
|
||||||
|
get_billing().ensure_trial_grant(storage, get_user_id(request))
|
||||||
|
_cas_queued(
|
||||||
|
storage, owner, project_id,
|
||||||
|
from_status=_ANALYSIS_IDLE,
|
||||||
|
conflict="Pipeline already running or queued",
|
||||||
|
cancel_requested=False,
|
||||||
|
execution_name=None,
|
||||||
|
periscope_version=PERISCOPE_VERSION,
|
||||||
|
)
|
||||||
|
name = _enqueue_or_503(
|
||||||
|
storage, owner, project_id,
|
||||||
|
enqueue=lambda: job_runner.enqueue_pipeline(
|
||||||
|
project_id, owner, resume=False, free=False,
|
||||||
|
),
|
||||||
|
fail_status={
|
||||||
|
"status": proj_svc.STATUS_ERROR,
|
||||||
|
"pipeline_state": {"error": "Failed to enqueue worker"},
|
||||||
|
},
|
||||||
|
http_detail=_ENQUEUE_FAIL,
|
||||||
|
)
|
||||||
|
_record_exec(storage, owner, project_id, name)
|
||||||
|
return {"status": "started", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/cancel")
|
||||||
|
async def cancel(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
if not _project_active(meta):
|
||||||
|
raise HTTPException(409, f"Pipeline is not running (status={meta.status})")
|
||||||
|
proj_svc.request_cancel(storage, owner, project_id)
|
||||||
|
return {"status": "cancel_requested", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/estimate")
|
||||||
|
async def estimate(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
if not meta.has_bom:
|
||||||
|
raise HTTPException(400, "Upload a BOM before requesting an estimate")
|
||||||
|
try:
|
||||||
|
est = estimate_pipeline_cost(storage, owner, project_id)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return est.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/resume", status_code=202)
|
||||||
|
async def resume(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
if meta.status != proj_svc.STATUS_PAUSED:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Project is not paused (status={meta.status}); nothing to resume.",
|
||||||
|
)
|
||||||
|
_need_bom_netlist(meta, "Project is missing BOM or netlist")
|
||||||
|
_cas_queued(
|
||||||
|
storage, owner, project_id,
|
||||||
|
from_status=frozenset({proj_svc.STATUS_PAUSED}),
|
||||||
|
conflict="Project state changed; refresh and retry",
|
||||||
|
cancel_requested=False,
|
||||||
|
)
|
||||||
|
name = _enqueue_or_503(
|
||||||
|
storage, owner, project_id,
|
||||||
|
enqueue=lambda: job_runner.enqueue_pipeline(
|
||||||
|
project_id, owner, resume=True, free=False,
|
||||||
|
),
|
||||||
|
fail_status={
|
||||||
|
"status": proj_svc.STATUS_ERROR,
|
||||||
|
"pipeline_state": {"error": "Failed to enqueue worker"},
|
||||||
|
},
|
||||||
|
http_detail=_ENQUEUE_FAIL,
|
||||||
|
)
|
||||||
|
_record_exec(storage, owner, project_id, name)
|
||||||
|
return {"status": "resumed", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/reprocess", status_code=202)
|
||||||
|
async def reprocess(
|
||||||
|
project_id: str, request: Request, req: ReprocessRequest | None = None,
|
||||||
|
):
|
||||||
|
from backend._version import PERISCOPE_VERSION
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
_need_bom_netlist(meta, "Upload BOM and netlist before reprocessing")
|
||||||
|
if _project_active(meta):
|
||||||
|
await _interrupt_active_pipeline(storage, owner, project_id)
|
||||||
|
meta = proj_svc.get_project(storage, owner, project_id) or meta
|
||||||
|
|
||||||
|
allowed = _REPROCESS_IDLE | {proj_svc.STATUS_PAUSED} if meta.status == proj_svc.STATUS_PAUSED else _REPROCESS_IDLE
|
||||||
|
if meta.status not in allowed and not _project_active(meta):
|
||||||
|
raise HTTPException(409, f"Cannot reprocess from status={meta.status}.")
|
||||||
|
|
||||||
|
body = req or ReprocessRequest()
|
||||||
|
retry_failed = body.mode == "failed"
|
||||||
|
keep_refs = (
|
||||||
|
proj_svc.completed_review_refs_for_retry(storage, owner, project_id)
|
||||||
|
if retry_failed else []
|
||||||
|
)
|
||||||
|
_cas_queued(
|
||||||
|
storage, owner, project_id,
|
||||||
|
from_status=allowed | {proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING},
|
||||||
|
conflict="Pipeline already running or queued",
|
||||||
|
cancel_requested=False,
|
||||||
|
execution_name=None,
|
||||||
|
pipeline_state=None,
|
||||||
|
pause_checkpoint=None,
|
||||||
|
pause_reason=None,
|
||||||
|
completed_review_refs=keep_refs,
|
||||||
|
periscope_version=PERISCOPE_VERSION,
|
||||||
|
)
|
||||||
|
name = _enqueue_or_503(
|
||||||
|
storage, owner, project_id,
|
||||||
|
enqueue=lambda: job_runner.enqueue_pipeline(
|
||||||
|
project_id, owner, resume=retry_failed, free=False,
|
||||||
|
),
|
||||||
|
fail_status={
|
||||||
|
"status": proj_svc.STATUS_ERROR,
|
||||||
|
"pipeline_state": {"error": "Failed to enqueue worker"},
|
||||||
|
},
|
||||||
|
http_detail=_ENQUEUE_FAIL,
|
||||||
|
)
|
||||||
|
_record_exec(storage, owner, project_id, name)
|
||||||
|
return {
|
||||||
|
"status": "reprocess_started",
|
||||||
|
"project_id": project_id,
|
||||||
|
"mode": body.mode,
|
||||||
|
"resume": retry_failed,
|
||||||
|
"kept_review_refs": keep_refs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/restart", status_code=202)
|
||||||
|
async def restart(project_id: str, request: Request):
|
||||||
|
from backend.routers.admin import _require_admin
|
||||||
|
|
||||||
|
await _require_admin(request)
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
_need_bom_netlist(meta, "Upload BOM and netlist before starting pipeline")
|
||||||
|
await _stop_then_wait(storage, owner, project_id, meta)
|
||||||
|
proj_svc.clear_project_extractions(storage, owner, project_id)
|
||||||
|
_cas_queued(
|
||||||
|
storage, owner, project_id,
|
||||||
|
from_status=_ANALYSIS_IDLE | {proj_svc.STATUS_PAUSED},
|
||||||
|
conflict="Pipeline is busy; cancel first then retry",
|
||||||
|
cancel_requested=False,
|
||||||
|
execution_name=None,
|
||||||
|
)
|
||||||
|
name = _enqueue_or_503(
|
||||||
|
storage, owner, project_id,
|
||||||
|
enqueue=lambda: job_runner.enqueue_pipeline(
|
||||||
|
project_id, owner, resume=False, free=True,
|
||||||
|
),
|
||||||
|
fail_status={
|
||||||
|
"status": proj_svc.STATUS_ERROR,
|
||||||
|
"pipeline_state": {"error": "Failed to enqueue worker"},
|
||||||
|
},
|
||||||
|
http_detail=_ENQUEUE_FAIL,
|
||||||
|
)
|
||||||
|
_record_exec(storage, owner, project_id, name)
|
||||||
|
return {"status": "restarted", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/regen", status_code=202)
|
||||||
|
async def regen(project_id: str, req: RegenRequest, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
_need_bom_netlist(meta, "Upload BOM and netlist before running regen")
|
||||||
|
unknown = set(req.stages) - _REGEN_STAGES
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Invalid regen stages: {sorted(unknown)}. Valid: {sorted(_REGEN_STAGES)}",
|
||||||
|
)
|
||||||
|
if not req.stages:
|
||||||
|
raise HTTPException(400, "At least one stage is required")
|
||||||
|
await _stop_then_wait(storage, owner, project_id, meta)
|
||||||
|
_cas_queued(
|
||||||
|
storage, owner, project_id,
|
||||||
|
from_status=_ANALYSIS_IDLE | {proj_svc.STATUS_PAUSED},
|
||||||
|
conflict="Pipeline is busy; cancel first then retry",
|
||||||
|
cancel_requested=False,
|
||||||
|
execution_name=None,
|
||||||
|
)
|
||||||
|
name = _enqueue_or_503(
|
||||||
|
storage, owner, project_id,
|
||||||
|
enqueue=lambda: job_runner.enqueue_pipeline_regen(
|
||||||
|
project_id, owner, stages=req.stages,
|
||||||
|
),
|
||||||
|
fail_status={
|
||||||
|
"status": proj_svc.STATUS_ERROR,
|
||||||
|
"pipeline_state": {"error": "Failed to enqueue worker"},
|
||||||
|
},
|
||||||
|
http_detail=_ENQUEUE_FAIL,
|
||||||
|
)
|
||||||
|
_record_exec(storage, owner, project_id, name)
|
||||||
|
return {"status": "regen_started", "project_id": project_id, "stages": req.stages}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/events")
|
||||||
|
async def events(project_id: str, request: Request):
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
storage = get_storage(request)
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
crash: dict[str, str | None] = {"reason": None}
|
||||||
|
watcher = asyncio.create_task(_poll_until_crash(
|
||||||
|
storage, owner, project_id,
|
||||||
|
execution_name=meta.execution_name,
|
||||||
|
live=lambda m: m.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING),
|
||||||
|
finished=lambda m: m.status in proj_svc.TERMINAL_STATUSES,
|
||||||
|
reason_of=lambda m: f"project status={m.status} (terminal)",
|
||||||
|
crash=crash,
|
||||||
|
initially_live=meta.status in (
|
||||||
|
proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
async for msg in event_bridge.tail_events(
|
||||||
|
storage, owner, project_id,
|
||||||
|
terminal_events=_ANALYSIS_SSE_DONE,
|
||||||
|
):
|
||||||
|
if crash["reason"] is not None:
|
||||||
|
break
|
||||||
|
ev = msg["event"]
|
||||||
|
if ev.startswith("placement_") or ev.startswith("pcb_"):
|
||||||
|
continue
|
||||||
|
yield _yield_sse(ev, msg.get("data") or {})
|
||||||
|
if ev in _ANALYSIS_SSE_DONE:
|
||||||
|
return
|
||||||
|
if crash["reason"] is not None:
|
||||||
|
cur = proj_svc.get_project(storage, owner, project_id)
|
||||||
|
err = (
|
||||||
|
(cur.pipeline_state or {}).get("error")
|
||||||
|
if cur and cur.pipeline_state
|
||||||
|
else crash["reason"]
|
||||||
|
)
|
||||||
|
yield _yield_sse("pipeline_error", {
|
||||||
|
"error": err or "worker terminated without writing a terminal event",
|
||||||
|
"synthetic": True,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
watcher.cancel()
|
||||||
|
try:
|
||||||
|
await watcher
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _sse(gen())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/status")
|
||||||
|
async def status(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
healed = proj_svc.heal_if_pipeline_finished(storage, owner, project_id)
|
||||||
|
if healed is not None:
|
||||||
|
meta = healed
|
||||||
|
place = meta.placement_status or "draft"
|
||||||
|
pcb = meta.pcb_status or "draft"
|
||||||
|
return {
|
||||||
|
"status": meta.status,
|
||||||
|
"summary": meta.summary,
|
||||||
|
"pipeline_state": meta.pipeline_state,
|
||||||
|
"running": meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED),
|
||||||
|
"placement_status": meta.placement_status,
|
||||||
|
"placement_state": meta.placement_state,
|
||||||
|
"placement_running": place in ("queued", "running"),
|
||||||
|
"pcb_status": meta.pcb_status,
|
||||||
|
"pcb_state": meta.pcb_state,
|
||||||
|
"pcb_running": pcb in ("queued", "running"),
|
||||||
|
"healed": healed is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Placement (topology; no credits)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/placement/start", status_code=202)
|
||||||
|
async def start_placement(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
_need_bom_netlist(meta, "Upload BOM and netlist before starting placement")
|
||||||
|
if placement_analysis_busy(meta):
|
||||||
|
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||||
|
if placement_busy(meta):
|
||||||
|
raise HTTPException(409, "Placement pipeline already running or queued")
|
||||||
|
if pcb_busy(meta):
|
||||||
|
raise HTTPException(409, "PCB review is running; wait or cancel it first")
|
||||||
|
if (meta.placement_status or "draft") not in _PLACEMENT_IDLE:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Cannot start placement from placement_status={meta.placement_status}",
|
||||||
|
)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner, project_id,
|
||||||
|
placement_status="queued",
|
||||||
|
placement_cancel_requested=False,
|
||||||
|
placement_state=None,
|
||||||
|
placement_execution_name=None,
|
||||||
|
)
|
||||||
|
_reset_event_history(storage, owner, project_id)
|
||||||
|
name = _enqueue_or_503(
|
||||||
|
storage, owner, project_id,
|
||||||
|
enqueue=lambda: job_runner.enqueue_placement_pipeline(project_id, owner),
|
||||||
|
fail_status={
|
||||||
|
"placement_status": "error",
|
||||||
|
"placement_state": {"error": "Failed to enqueue placement worker"},
|
||||||
|
},
|
||||||
|
http_detail="Failed to enqueue placement worker; please retry",
|
||||||
|
)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner, project_id, placement_execution_name=name,
|
||||||
|
)
|
||||||
|
return {"status": "started", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/placement/cancel")
|
||||||
|
async def cancel_placement(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
if not placement_busy(meta):
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Placement is not running (placement_status={meta.placement_status})",
|
||||||
|
)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner, project_id, placement_cancel_requested=True,
|
||||||
|
)
|
||||||
|
return {"status": "cancel_requested", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/placement/plan")
|
||||||
|
async def get_placement_plan(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
prefix = proj_svc.project_prefix(owner, project_id)
|
||||||
|
for name in ("placement_plan.json", "functional_groups.json"):
|
||||||
|
key = f"{prefix}/{name}"
|
||||||
|
if storage.exists(key):
|
||||||
|
return storage.read_json(key)
|
||||||
|
raise HTTPException(404, "Placement plan not found — run placement first")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/placement/pack")
|
||||||
|
async def get_placement_pack(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{proj_svc.project_prefix(owner, project_id)}/placement_pack.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "Placement pack not found — run placement first")
|
||||||
|
return storage.read_json(key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/placement/events")
|
||||||
|
async def placement_events(project_id: str, request: Request):
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
storage = get_storage(request)
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
crash: dict[str, str | None] = {"reason": None}
|
||||||
|
watcher = asyncio.create_task(_poll_until_crash(
|
||||||
|
storage, owner, project_id,
|
||||||
|
execution_name=meta.placement_execution_name,
|
||||||
|
live=lambda m: (m.placement_status or "draft") in ("queued", "running"),
|
||||||
|
finished=lambda m: (m.placement_status or "draft") in (
|
||||||
|
"complete", "error", "cancelled",
|
||||||
|
),
|
||||||
|
reason_of=lambda m: f"placement_status={m.placement_status or 'draft'} (terminal)",
|
||||||
|
crash=crash,
|
||||||
|
initially_live=(meta.placement_status or "draft") in ("queued", "running"),
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
async for msg in event_bridge.tail_events(
|
||||||
|
storage, owner, project_id,
|
||||||
|
terminal_events=_PLACEMENT_SSE_DONE,
|
||||||
|
):
|
||||||
|
if crash["reason"] is not None:
|
||||||
|
break
|
||||||
|
ev = msg["event"]
|
||||||
|
keep = ev.startswith("placement_") or ev == "heartbeat"
|
||||||
|
if not keep or ev.startswith("pcb_"):
|
||||||
|
continue
|
||||||
|
yield _yield_sse(ev, msg.get("data") or {})
|
||||||
|
if ev in _PLACEMENT_SSE_DONE:
|
||||||
|
return
|
||||||
|
if crash["reason"] is not None:
|
||||||
|
cur = proj_svc.get_project(storage, owner, project_id)
|
||||||
|
err = cur.placement_state.get("error") if cur and cur.placement_state else None
|
||||||
|
yield _yield_sse("placement_error", {
|
||||||
|
"error": err or crash["reason"]
|
||||||
|
or "placement worker terminated without a terminal event",
|
||||||
|
"synthetic": True,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
watcher.cancel()
|
||||||
|
try:
|
||||||
|
await watcher
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _sse(gen())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PCB review
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/pcb/start", status_code=202)
|
||||||
|
async def start_pcb(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
if not meta.has_pcb:
|
||||||
|
raise HTTPException(400, "Upload a .kicad_pcb before starting PCB review")
|
||||||
|
_need_bom_netlist(meta, "Upload BOM and netlist before starting PCB review")
|
||||||
|
if pcb_analysis_busy(meta):
|
||||||
|
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||||
|
if pcb_placement_busy(meta):
|
||||||
|
raise HTTPException(409, "Placement pipeline is running; wait or cancel it first")
|
||||||
|
if pcb_busy(meta):
|
||||||
|
raise HTTPException(409, "PCB review already running or queued")
|
||||||
|
if (meta.pcb_status or "draft") not in _PCB_IDLE:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Cannot start PCB review from pcb_status={meta.pcb_status}",
|
||||||
|
)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner, project_id,
|
||||||
|
pcb_status="queued",
|
||||||
|
pcb_cancel_requested=False,
|
||||||
|
pcb_state=None,
|
||||||
|
pcb_execution_name=None,
|
||||||
|
)
|
||||||
|
_reset_event_history(storage, owner, project_id)
|
||||||
|
name = _enqueue_or_503(
|
||||||
|
storage, owner, project_id,
|
||||||
|
enqueue=lambda: job_runner.enqueue_pcb_pipeline(project_id, owner),
|
||||||
|
fail_status={
|
||||||
|
"pcb_status": "error",
|
||||||
|
"pcb_state": {"error": "Failed to enqueue PCB worker"},
|
||||||
|
},
|
||||||
|
http_detail="Failed to enqueue PCB worker; please retry",
|
||||||
|
)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner, project_id, pcb_execution_name=name,
|
||||||
|
)
|
||||||
|
return {"status": "started", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/pcb/cancel")
|
||||||
|
async def cancel_pcb(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
if not pcb_busy(meta):
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"PCB review is not running (pcb_status={meta.pcb_status})",
|
||||||
|
)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner, project_id, pcb_cancel_requested=True,
|
||||||
|
)
|
||||||
|
return {"status": "cancel_requested", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/pcb/inventory")
|
||||||
|
async def get_pcb_inventory(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{proj_svc.project_prefix(owner, project_id)}/pcb_inventory.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "PCB inventory not found — run PCB review first")
|
||||||
|
return storage.read_json(key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/pcb/events")
|
||||||
|
async def pcb_events(project_id: str, request: Request):
|
||||||
|
owner, meta = await resolve_or_404(request, project_id)
|
||||||
|
storage = get_storage(request)
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
crash: dict[str, str | None] = {"reason": None}
|
||||||
|
watcher = asyncio.create_task(_poll_until_crash(
|
||||||
|
storage, owner, project_id,
|
||||||
|
execution_name=meta.pcb_execution_name,
|
||||||
|
live=lambda m: (m.pcb_status or "draft") in ("queued", "running"),
|
||||||
|
finished=lambda m: (m.pcb_status or "draft") in (
|
||||||
|
"complete", "error", "cancelled",
|
||||||
|
),
|
||||||
|
reason_of=lambda m: f"pcb_status={m.pcb_status or 'draft'} (terminal)",
|
||||||
|
crash=crash,
|
||||||
|
initially_live=(meta.pcb_status or "draft") in ("queued", "running"),
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
async for msg in event_bridge.tail_events(
|
||||||
|
storage, owner, project_id,
|
||||||
|
terminal_events=_PCB_SSE_DONE,
|
||||||
|
):
|
||||||
|
if crash["reason"] is not None:
|
||||||
|
break
|
||||||
|
ev = msg["event"]
|
||||||
|
if not (ev.startswith("pcb_") or ev == "heartbeat"):
|
||||||
|
continue
|
||||||
|
yield _yield_sse(ev, msg.get("data") or {})
|
||||||
|
if ev in _PCB_SSE_DONE:
|
||||||
|
return
|
||||||
|
if crash["reason"] is not None:
|
||||||
|
cur = proj_svc.get_project(storage, owner, project_id)
|
||||||
|
ev, payload = pcb_sse_terminal_from_status(
|
||||||
|
cur.pcb_status if cur else None,
|
||||||
|
cur.pcb_state if cur else None,
|
||||||
|
crash["reason"],
|
||||||
|
)
|
||||||
|
yield _yield_sse(ev, payload)
|
||||||
|
finally:
|
||||||
|
watcher.cancel()
|
||||||
|
try:
|
||||||
|
await watcher
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _sse(gen())
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
"""Project CRUD, library lookup, and file ingestion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.periscopex.utils import natural_sort_key, safe_mpn
|
||||||
|
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||||
|
_MAX_MB = MAX_UPLOAD_BYTES // 1024 // 1024
|
||||||
|
|
||||||
|
router = APIRouter(tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
def _xlsx_to_csv(raw: bytes) -> bytes:
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
sheet = wb.active
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
for row in sheet.iter_rows(values_only=True):
|
||||||
|
writer.writerow(["" if cell is None else str(cell) for cell in row])
|
||||||
|
return buf.getvalue().encode("utf-8")
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _bom_file_to_csv_bytes(path: Path) -> bytes | None:
|
||||||
|
"""CSV as-is; xlsx flattened to CSV; anything else ignored (KiCad zip)."""
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
payload = path.read_bytes()
|
||||||
|
if suffix == ".csv":
|
||||||
|
return payload
|
||||||
|
if suffix == ".xlsx":
|
||||||
|
try:
|
||||||
|
return _xlsx_to_csv(payload)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_oversize(nbytes: int, *, label: str | None = None) -> None:
|
||||||
|
if nbytes <= MAX_UPLOAD_BYTES:
|
||||||
|
return
|
||||||
|
if label:
|
||||||
|
mb = nbytes / 1024 / 1024
|
||||||
|
raise HTTPException(413, f"{label} is {mb:.1f} MB — exceeds {_MAX_MB} MB limit")
|
||||||
|
raise HTTPException(413, f"File too large (max {_MAX_MB} MB)")
|
||||||
|
|
||||||
|
|
||||||
|
def _access_or_404(request: Request, project_id: str):
|
||||||
|
hit = proj_svc.resolve_project_access(
|
||||||
|
get_storage(request), get_user_id(request), project_id,
|
||||||
|
)
|
||||||
|
if not hit:
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
return hit
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bom_csv(
|
||||||
|
data: bytes, *, reference_column: str, mpn_column: str,
|
||||||
|
) -> dict:
|
||||||
|
from backend.periscopex.parsers import parse_bom
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
|
||||||
|
tmp.write(data)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
return parse_bom(tmp_path, reference_col=reference_column, mpn_col=mpn_column)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_mpns(bom: dict) -> tuple[list[str], list[str], list[str]]:
|
||||||
|
from backend.periscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||||
|
|
||||||
|
ic: list[str] = []
|
||||||
|
passive: list[str] = []
|
||||||
|
simple: list[str] = []
|
||||||
|
seen_ic: set[str] = set()
|
||||||
|
seen_passive: set[str] = set()
|
||||||
|
seen_simple: set[str] = set()
|
||||||
|
for ref, info in sorted(bom.items()):
|
||||||
|
mpn = info.get("mpn")
|
||||||
|
if not mpn:
|
||||||
|
continue
|
||||||
|
kind = type_for_ref(ref)
|
||||||
|
if kind == "ic" and mpn not in seen_ic:
|
||||||
|
seen_ic.add(mpn)
|
||||||
|
ic.append(mpn)
|
||||||
|
elif kind == "passive" and mpn not in seen_passive:
|
||||||
|
seen_passive.add(mpn)
|
||||||
|
passive.append(mpn)
|
||||||
|
elif kind and kind in SIMPLE_TYPES and mpn not in seen_simple:
|
||||||
|
seen_simple.add(mpn)
|
||||||
|
simple.append(mpn)
|
||||||
|
return ic, passive, simple
|
||||||
|
|
||||||
|
|
||||||
|
def _pins_by_ref(
|
||||||
|
parts: dict[str, str],
|
||||||
|
nets: dict[str, list[tuple[str, str]]],
|
||||||
|
) -> list[dict]:
|
||||||
|
by_ref: dict[str, dict[str, str]] = {ref: {} for ref in parts}
|
||||||
|
for net_name, pins in nets.items():
|
||||||
|
for ref, pin in pins:
|
||||||
|
by_ref.setdefault(ref, {}).setdefault(pin, net_name)
|
||||||
|
rows: list[dict] = []
|
||||||
|
for ref in sorted(by_ref, key=natural_sort_key):
|
||||||
|
pin_map = by_ref[ref]
|
||||||
|
rows.append({
|
||||||
|
"ref": ref,
|
||||||
|
"pins": [
|
||||||
|
{"number": num, "net_name": pin_map[num]}
|
||||||
|
for num in sorted(pin_map, key=natural_sort_key)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _netlist_ext(key: str) -> str:
|
||||||
|
leaf = key.rsplit("/", 1)[-1]
|
||||||
|
return leaf.rsplit(".", 1)[-1] if "." in leaf else "asc"
|
||||||
|
|
||||||
|
|
||||||
|
async def _gather_netlist_blobs(
|
||||||
|
file: UploadFile | None,
|
||||||
|
files: list[UploadFile] | None,
|
||||||
|
paths: str | None,
|
||||||
|
) -> list[tuple[str, bytes]]:
|
||||||
|
uploads = list(files or []) if files else ([file] if file is not None else [])
|
||||||
|
rels: list[str] | None = None
|
||||||
|
if paths:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(paths)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, list) and all(isinstance(x, str) for x in parsed):
|
||||||
|
rels = parsed
|
||||||
|
blobs: list[tuple[str, bytes]] = []
|
||||||
|
seen: set[tuple[str, int]] = set()
|
||||||
|
for i, uf in enumerate(uploads):
|
||||||
|
data = await uf.read()
|
||||||
|
_reject_oversize(len(data))
|
||||||
|
name = rels[i] if rels is not None and i < len(rels) else (uf.filename or "netlist")
|
||||||
|
mark = (name, len(data))
|
||||||
|
if mark in seen:
|
||||||
|
continue
|
||||||
|
seen.add(mark)
|
||||||
|
blobs.append((name, data))
|
||||||
|
if not blobs:
|
||||||
|
raise HTTPException(400, "No netlist file uploaded")
|
||||||
|
return blobs
|
||||||
|
|
||||||
|
|
||||||
|
def _passive_hit_json(mpn: str, safe: str, model_data: dict, *, cached: bool, lcsc_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"mpn": mpn,
|
||||||
|
"safe_mpn": safe,
|
||||||
|
"model": model_data,
|
||||||
|
"cached": cached,
|
||||||
|
"lcsc_id": lcsc_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Library ---
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryCheckRequest(BaseModel):
|
||||||
|
ic_mpns: list[str] = []
|
||||||
|
passive_mpns: list[str] = []
|
||||||
|
simple_mpns: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/check")
|
||||||
|
async def check_library(body: LibraryCheckRequest, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
ics = [mpn for mpn in body.ic_mpns if proj_svc.library_has_extraction(storage, mpn)]
|
||||||
|
patterns = proj_svc.load_library_patterns(storage) if body.passive_mpns else []
|
||||||
|
passives: list[str] = []
|
||||||
|
if body.passive_mpns:
|
||||||
|
from backend.periscopex.resolve_passives import resolve_mpn
|
||||||
|
|
||||||
|
passives = [
|
||||||
|
mpn for mpn in body.passive_mpns
|
||||||
|
if resolve_mpn(mpn, patterns) is not None
|
||||||
|
or proj_svc.library_has_passive_model(storage, mpn) is not None
|
||||||
|
]
|
||||||
|
simples = [mpn for mpn in body.simple_mpns if proj_svc.library_has_model(storage, mpn)]
|
||||||
|
wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns)
|
||||||
|
sheets = [
|
||||||
|
mpn for mpn in wanted
|
||||||
|
if proj_svc.library_has_datasheet(storage, mpn, patterns=patterns)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"ic_resolved": ics,
|
||||||
|
"passive_resolved": passives,
|
||||||
|
"simple_resolved": simples,
|
||||||
|
"datasheets_available": sheets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library")
|
||||||
|
async def get_library(request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
return JSONResponse(
|
||||||
|
content=proj_svc.list_library_catalog(storage),
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/datasheet/{mpn:path}")
|
||||||
|
async def get_library_datasheet(mpn: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
key = proj_svc.library_has_datasheet(storage, mpn)
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(404, f"Datasheet not in library: {mpn}")
|
||||||
|
return Response(
|
||||||
|
content=storage.read_bytes(key),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- CRUD ---
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProjectRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class RenameRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects")
|
||||||
|
async def create_project(body: CreateProjectRequest, request: Request):
|
||||||
|
meta = proj_svc.create_project(get_storage(request), get_user_id(request), body.name)
|
||||||
|
return meta.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects")
|
||||||
|
async def list_projects(request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
uid = get_user_id(request)
|
||||||
|
owned = proj_svc.list_projects(storage, uid)
|
||||||
|
shared = proj_svc.list_shared_projects(storage, uid)
|
||||||
|
return [m.model_dump() for m in owned + shared]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}")
|
||||||
|
async def get_project(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, meta = await resolve_or_404(request, project_id)
|
||||||
|
for healer in (
|
||||||
|
proj_svc.heal_if_pipeline_finished,
|
||||||
|
proj_svc.heal_if_placement_stuck,
|
||||||
|
proj_svc.heal_if_pcb_stuck,
|
||||||
|
):
|
||||||
|
healed = healer(storage, owner_id, project_id)
|
||||||
|
if healed is not None:
|
||||||
|
meta = healed
|
||||||
|
return meta.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{project_id}")
|
||||||
|
async def delete_project(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
uid = get_user_id(request)
|
||||||
|
if proj_svc.delete_project(storage, uid, project_id):
|
||||||
|
return {"ok": True}
|
||||||
|
hit = proj_svc.resolve_project_access(storage, uid, project_id)
|
||||||
|
if hit:
|
||||||
|
owner_id, _ = hit
|
||||||
|
proj_svc.remove_collaborator(storage, owner_id, project_id, uid)
|
||||||
|
return {"ok": True, "removed_self": True}
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/projects/{project_id}")
|
||||||
|
async def rename_project(project_id: str, body: RenameRequest, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = _access_or_404(request, project_id)
|
||||||
|
name = body.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "Name must be non-empty")
|
||||||
|
meta = proj_svc.update_project(storage, owner_id, project_id, name=name)
|
||||||
|
return meta.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/reopen")
|
||||||
|
async def reopen_project(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, meta = _access_or_404(request, project_id)
|
||||||
|
if meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED):
|
||||||
|
raise HTTPException(409, "Pipeline is running; cancel it before reopening")
|
||||||
|
meta = proj_svc.reopen_project(storage, owner_id, project_id)
|
||||||
|
return meta.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Downloads ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/files/bom")
|
||||||
|
async def download_bom(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = proj_svc.get_bom_key(storage, owner_id, project_id)
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(404, "BOM not uploaded")
|
||||||
|
return Response(
|
||||||
|
content=storage.read_bytes(key),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="bom.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/files/netlist")
|
||||||
|
async def download_netlist(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = proj_svc.get_netlist_key(storage, owner_id, project_id)
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(404, "Netlist not uploaded")
|
||||||
|
ext = _netlist_ext(key)
|
||||||
|
return Response(
|
||||||
|
content=storage.read_bytes(key),
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="netlist.{ext}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/netlist/subdesigns")
|
||||||
|
async def get_netlist_subdesigns(project_id: str, request: Request):
|
||||||
|
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, meta = await resolve_or_404(request, project_id)
|
||||||
|
empty = {"sub_designs": [], "selected": None}
|
||||||
|
if meta.netlist_format != "edif":
|
||||||
|
return empty
|
||||||
|
key = proj_svc.get_netlist_key(storage, owner_id, project_id)
|
||||||
|
if not key:
|
||||||
|
return empty
|
||||||
|
data = storage.read_bytes(key)
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".edn") as tmp:
|
||||||
|
tmp.write(data)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
subs = list_edif_subdesigns(tmp_path)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
return {"sub_designs": subs, "selected": meta.netlist_subdesigns}
|
||||||
|
|
||||||
|
|
||||||
|
class NetlistSubdesignsUpdate(BaseModel):
|
||||||
|
selected: list[str] | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/projects/{project_id}/netlist/subdesigns")
|
||||||
|
async def set_netlist_subdesigns(
|
||||||
|
project_id: str, payload: NetlistSubdesignsUpdate, request: Request,
|
||||||
|
):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = _access_or_404(request, project_id)
|
||||||
|
cleaned = [s.strip() for s in (payload.selected or []) if s and s.strip()]
|
||||||
|
meta = proj_svc.update_project(
|
||||||
|
storage, owner_id, project_id,
|
||||||
|
netlist_subdesigns=cleaned if payload.selected is not None else None,
|
||||||
|
)
|
||||||
|
return meta.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/files/datasheets")
|
||||||
|
async def list_datasheets(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = await resolve_or_404(request, project_id)
|
||||||
|
stems = proj_svc.list_project_datasheets(storage, owner_id, project_id)
|
||||||
|
return {"stems": sorted(stems)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Uploads ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/upload/bom")
|
||||||
|
async def upload_bom(
|
||||||
|
project_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
request: Request,
|
||||||
|
reference_column: str = "Reference",
|
||||||
|
mpn_column: str = "Manufacturer Part Number",
|
||||||
|
column_is_lcsc: bool | None = None,
|
||||||
|
):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = _access_or_404(request, project_id)
|
||||||
|
data = await file.read()
|
||||||
|
_reject_oversize(len(data))
|
||||||
|
filename = file.filename or ""
|
||||||
|
if filename.lower().endswith(".xlsx"):
|
||||||
|
try:
|
||||||
|
data = _xlsx_to_csv(data)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, f"Invalid Excel file: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
bom = _parse_bom_csv(
|
||||||
|
data, reference_column=reference_column, mpn_column=mpn_column,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, f"Invalid BOM file: {exc}") from exc
|
||||||
|
|
||||||
|
lcsc_resolved = 0
|
||||||
|
lcsc_detected = False
|
||||||
|
lcsc_map: dict[str, str] = {}
|
||||||
|
lcsc_payloads: dict[str, dict] = {}
|
||||||
|
try:
|
||||||
|
from backend.services.purple_parts import (
|
||||||
|
detect_lcsc_column,
|
||||||
|
resolve_lcsc_column_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
lcsc_detected = detect_lcsc_column(data, mpn_column)
|
||||||
|
if column_is_lcsc or lcsc_detected:
|
||||||
|
data, lcsc_resolved, lcsc_map, lcsc_payloads = await resolve_lcsc_column_bytes(
|
||||||
|
data, mpn_col=mpn_column,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning("purple-parts BOM resolve failed", exc_info=True)
|
||||||
|
|
||||||
|
if lcsc_resolved:
|
||||||
|
try:
|
||||||
|
bom = _parse_bom_csv(
|
||||||
|
data, reference_column=reference_column, mpn_column=mpn_column,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning(
|
||||||
|
"Re-parse after LCSC rewrite failed; using pre-rewrite BOM for classification",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ic_mpns, passive_mpns, simple_mpns = _bucket_mpns(bom)
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.use_purple_parts
|
||||||
|
and not lcsc_detected
|
||||||
|
and not column_is_lcsc
|
||||||
|
and passive_mpns
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from backend.services.purple_parts import lookup_mpn_batch
|
||||||
|
|
||||||
|
parts = await lookup_mpn_batch(passive_mpns)
|
||||||
|
for pmpn, part in parts.items():
|
||||||
|
if part and part.get("lcsc") and part.get("description"):
|
||||||
|
lcsc_map[part["lcsc"]] = pmpn
|
||||||
|
lcsc_payloads[part["lcsc"]] = dict(part)
|
||||||
|
except Exception:
|
||||||
|
log.warning("purple-parts by-mpn passive enrich failed", exc_info=True)
|
||||||
|
|
||||||
|
key = proj_svc.save_bom(storage, owner_id, project_id, data)
|
||||||
|
fields: dict = {
|
||||||
|
"bom_columns": {"reference": reference_column, "mpn": mpn_column},
|
||||||
|
"component_mpns": {
|
||||||
|
"ic": ic_mpns,
|
||||||
|
"passive": passive_mpns,
|
||||||
|
"simple": simple_mpns,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if lcsc_map:
|
||||||
|
fields["lcsc_to_mpn"] = lcsc_map
|
||||||
|
if lcsc_payloads:
|
||||||
|
fields["lcsc_payloads"] = lcsc_payloads
|
||||||
|
proj_svc.update_project(storage, owner_id, project_id, **fields)
|
||||||
|
return {
|
||||||
|
"path": key,
|
||||||
|
"components": len(bom),
|
||||||
|
"lcsc_resolved": lcsc_resolved,
|
||||||
|
"lcsc_detected": lcsc_detected,
|
||||||
|
"lcsc_to_mpn": lcsc_map,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/upload/netlist")
|
||||||
|
async def upload_netlist(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile | None = File(default=None),
|
||||||
|
files: list[UploadFile] | None = File(default=None),
|
||||||
|
paths: str | None = Form(default=None),
|
||||||
|
):
|
||||||
|
from backend.periscopex.netlist_bundle import materialize_netlist_upload
|
||||||
|
from backend.periscopex.parsers import parse_netlist_any, validate_netlist
|
||||||
|
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = _access_or_404(request, project_id)
|
||||||
|
blobs = await _gather_netlist_blobs(file, files, paths)
|
||||||
|
|
||||||
|
sub_designs: list[dict] = []
|
||||||
|
bom_saved = False
|
||||||
|
pcb_saved = False
|
||||||
|
sheets = 1
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
|
||||||
|
parts, nets, fmt = parse_netlist_any(parsed.root)
|
||||||
|
if fmt == "edif":
|
||||||
|
sub_designs = list_edif_subdesigns(parsed.root)
|
||||||
|
issues = validate_netlist(parts, nets)
|
||||||
|
if issues:
|
||||||
|
raise ValueError("; ".join(issues))
|
||||||
|
root_bytes = parsed.root.read_bytes()
|
||||||
|
key = proj_svc.save_netlist(storage, owner_id, project_id, root_bytes, fmt=fmt)
|
||||||
|
if fmt == "kicad_sch":
|
||||||
|
proj_svc.save_companion_sheets(
|
||||||
|
storage, owner_id, project_id, parsed.root, parsed.extra_sch,
|
||||||
|
)
|
||||||
|
sheets = 1 + len(parsed.extra_sch)
|
||||||
|
else:
|
||||||
|
proj_svc.clear_companion_sheets(storage, owner_id, project_id)
|
||||||
|
if parsed.pcb is not None:
|
||||||
|
proj_svc.save_pcb(storage, owner_id, project_id, parsed.pcb.read_bytes())
|
||||||
|
pcb_saved = True
|
||||||
|
if parsed.bom is not None:
|
||||||
|
bom_bytes = _bom_file_to_csv_bytes(parsed.bom)
|
||||||
|
if bom_bytes:
|
||||||
|
proj_svc.save_bom(storage, owner_id, project_id, bom_bytes)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner_id, project_id,
|
||||||
|
bom_columns={
|
||||||
|
"reference": "Reference",
|
||||||
|
"mpn": "Manufacturer Part Number",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bom_saved = True
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, f"Netlist failed sanity check: {exc}") from exc
|
||||||
|
|
||||||
|
designator_pins: list[dict] = []
|
||||||
|
if fmt != "pads":
|
||||||
|
designator_pins = _pins_by_ref(parts, nets)
|
||||||
|
return {
|
||||||
|
"path": key,
|
||||||
|
"parts": len(parts),
|
||||||
|
"nets": len(nets),
|
||||||
|
"format": fmt,
|
||||||
|
"sub_designs": sub_designs,
|
||||||
|
"designator_pins": designator_pins,
|
||||||
|
"pcb_saved": pcb_saved,
|
||||||
|
"bom_saved": bom_saved,
|
||||||
|
"sheets": sheets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/upload/pcb")
|
||||||
|
async def upload_pcb(project_id: str, file: UploadFile, request: Request):
|
||||||
|
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = _access_or_404(request, project_id)
|
||||||
|
data = await file.read()
|
||||||
|
_reject_oversize(len(data))
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb") as tmp:
|
||||||
|
tmp.write(data)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
layout = parse_kicad_pcb(tmp_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, f"Invalid KiCad PCB: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
key = proj_svc.save_pcb(storage, owner_id, project_id, data)
|
||||||
|
return {
|
||||||
|
"path": key,
|
||||||
|
"footprints": len(layout.footprints),
|
||||||
|
"nets": len(layout.nets),
|
||||||
|
"segments": len(layout.segments),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/upload/datasheets")
|
||||||
|
async def upload_datasheets(
|
||||||
|
project_id: str, file: UploadFile, mpn: str,
|
||||||
|
request: Request, also_for: str | None = None,
|
||||||
|
):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, _ = _access_or_404(request, project_id)
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".pdf"):
|
||||||
|
raise HTTPException(400, "File must be a PDF")
|
||||||
|
data = await file.read()
|
||||||
|
_reject_oversize(len(data), label=file.filename or mpn)
|
||||||
|
key = proj_svc.save_datasheet(storage, owner_id, project_id, mpn, data)
|
||||||
|
extra_mpns: list[str] = []
|
||||||
|
if also_for:
|
||||||
|
for extra in also_for.split(","):
|
||||||
|
extra = extra.strip()
|
||||||
|
if extra:
|
||||||
|
proj_svc.save_datasheet(storage, owner_id, project_id, extra, data)
|
||||||
|
extra_mpns.append(extra)
|
||||||
|
return {"path": key, "mpn": mpn, "also_for": extra_mpns}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Collaborators ---
|
||||||
|
|
||||||
|
|
||||||
|
class AddCollaboratorRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
def _local_member(uid: str, owner_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"user_id": uid,
|
||||||
|
"name": None,
|
||||||
|
"email": None,
|
||||||
|
"image_url": None,
|
||||||
|
"role": "owner" if uid == owner_id else "collaborator",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/collaborators")
|
||||||
|
async def list_collaborators(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, meta = _access_or_404(request, project_id)
|
||||||
|
ids = [owner_id] + [c for c in meta.collaborators if c != owner_id]
|
||||||
|
if settings.use_auth:
|
||||||
|
from backend.services.user_directory import get_user_profile
|
||||||
|
|
||||||
|
members = []
|
||||||
|
for uid in ids:
|
||||||
|
profile = await get_user_profile(uid)
|
||||||
|
members.append({
|
||||||
|
"user_id": uid,
|
||||||
|
"name": profile.get("name"),
|
||||||
|
"email": profile.get("email"),
|
||||||
|
"image_url": profile.get("image_url"),
|
||||||
|
"role": "owner" if uid == owner_id else "collaborator",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
members = [_local_member(uid, owner_id) for uid in ids]
|
||||||
|
return {"owner_user_id": owner_id, "collaborators": members}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/collaborators")
|
||||||
|
async def add_collaborator(project_id: str, body: AddCollaboratorRequest, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
uid = get_user_id(request)
|
||||||
|
meta = proj_svc.get_project(storage, uid, project_id)
|
||||||
|
if not meta:
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
if not settings.use_auth:
|
||||||
|
raise HTTPException(400, "Collaboration requires authentication to be enabled")
|
||||||
|
|
||||||
|
from backend.services.user_directory import find_user_id_by_email, get_user_profile
|
||||||
|
|
||||||
|
collab_id = await find_user_id_by_email(body.email)
|
||||||
|
if not collab_id:
|
||||||
|
raise HTTPException(404, "No user found with that email")
|
||||||
|
if collab_id == uid:
|
||||||
|
raise HTTPException(400, "Cannot add yourself as a collaborator")
|
||||||
|
if collab_id in meta.collaborators:
|
||||||
|
raise HTTPException(409, "User is already a collaborator")
|
||||||
|
proj_svc.add_collaborator(storage, uid, project_id, collab_id)
|
||||||
|
profile = await get_user_profile(collab_id)
|
||||||
|
return {
|
||||||
|
"user_id": collab_id,
|
||||||
|
"name": profile.get("name"),
|
||||||
|
"email": profile.get("email"),
|
||||||
|
"image_url": profile.get("image_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{project_id}/collaborators/{collaborator_user_id}")
|
||||||
|
async def remove_collaborator(project_id: str, collaborator_user_id: str, request: Request):
|
||||||
|
from backend.routers.admin import is_admin
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
uid = get_user_id(request)
|
||||||
|
meta = proj_svc.get_project(storage, uid, project_id)
|
||||||
|
owner_id = uid
|
||||||
|
if not meta:
|
||||||
|
if not await is_admin(request):
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
found = proj_svc.find_project_any_user(storage, project_id)
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
owner_id, meta = found
|
||||||
|
if collaborator_user_id not in meta.collaborators:
|
||||||
|
raise HTTPException(404, "User is not a collaborator")
|
||||||
|
proj_svc.remove_collaborator(storage, owner_id, project_id, collaborator_user_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/collaborators/{collaborator_user_id}/make-owner")
|
||||||
|
async def make_collaborator_owner(
|
||||||
|
project_id: str, collaborator_user_id: str, request: Request,
|
||||||
|
):
|
||||||
|
from backend.routers.admin import is_admin
|
||||||
|
|
||||||
|
if not await is_admin(request):
|
||||||
|
raise HTTPException(403, "Admin access required")
|
||||||
|
storage = get_storage(request)
|
||||||
|
found = proj_svc.find_project_any_user(storage, project_id)
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
current_owner, _ = found
|
||||||
|
try:
|
||||||
|
proj_svc.transfer_ownership(
|
||||||
|
storage, current_owner, project_id, collaborator_user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"ok": True, "owner_user_id": collaborator_user_id}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Datasheet fetch + DigiKey ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/digikey/datasheet")
|
||||||
|
@router.get("/datasheets/fetch")
|
||||||
|
async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = None):
|
||||||
|
from backend.services.datasheet_finder import find_datasheet
|
||||||
|
|
||||||
|
result = await find_datasheet(mpn, lcsc_id=lcsc)
|
||||||
|
if not result.ok:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={
|
||||||
|
"detail": result.error or "Failed to fetch datasheet",
|
||||||
|
"url": result.url,
|
||||||
|
"urls": result.suggested_urls or ([result.url] if result.url else []),
|
||||||
|
"source": result.source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
headers = {"Content-Disposition": f'attachment; filename="{mpn}.pdf"'}
|
||||||
|
if result.url:
|
||||||
|
headers["X-Datasheet-Url"] = result.url
|
||||||
|
if result.source:
|
||||||
|
headers["X-Datasheet-Source"] = result.source
|
||||||
|
try:
|
||||||
|
proj_svc.remember_datasheet(
|
||||||
|
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoResolveItem(BaseModel):
|
||||||
|
mpn: str
|
||||||
|
component_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class AutoResolveRequest(BaseModel):
|
||||||
|
items: list[AutoResolveItem]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/digikey/auto-resolve")
|
||||||
|
async def auto_resolve(body: AutoResolveRequest, request: Request):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from backend.services.datasheet_extract import CatalogResolveMiss, auto_resolve_specs
|
||||||
|
from backend.services.digikey import fetch_params
|
||||||
|
|
||||||
|
if not settings.use_digikey:
|
||||||
|
raise HTTPException(400, "DigiKey API not configured")
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
sem = asyncio.Semaphore(10)
|
||||||
|
|
||||||
|
async def resolve_one(item: AutoResolveItem) -> dict:
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
safe = safe_mpn(item.mpn)
|
||||||
|
if item.component_type == "passive":
|
||||||
|
lib_key = f"library/passives/{safe}.json"
|
||||||
|
if not storage.exists(lib_key):
|
||||||
|
legacy = f"library/models/{safe}.json"
|
||||||
|
if storage.exists(legacy):
|
||||||
|
return {"mpn": item.mpn, "status": "resolved"}
|
||||||
|
else:
|
||||||
|
lib_key = f"library/models/{safe}.json"
|
||||||
|
if storage.exists(lib_key):
|
||||||
|
return {"mpn": item.mpn, "status": "resolved"}
|
||||||
|
|
||||||
|
result = await fetch_params(item.mpn)
|
||||||
|
if not result.ok or not result.params:
|
||||||
|
return {
|
||||||
|
"mpn": item.mpn,
|
||||||
|
"status": "failed",
|
||||||
|
"error": result.error or "No parameters",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
model = await auto_resolve_specs(
|
||||||
|
mpn=item.mpn,
|
||||||
|
digikey_params=result.params.parameters,
|
||||||
|
digikey_category=result.params.category,
|
||||||
|
digikey_description=result.params.description,
|
||||||
|
component_type=item.component_type,
|
||||||
|
use_llm=settings.has_llm_credentials(),
|
||||||
|
)
|
||||||
|
except CatalogResolveMiss as exc:
|
||||||
|
return {"mpn": item.mpn, "status": "failed", "error": str(exc)}
|
||||||
|
storage.write_json(lib_key, model.model_dump())
|
||||||
|
return {"mpn": item.mpn, "status": "resolved"}
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Auto-resolve failed for %s: %s", item.mpn, exc, exc_info=True)
|
||||||
|
return {
|
||||||
|
"mpn": item.mpn,
|
||||||
|
"status": "failed",
|
||||||
|
"error": str(exc) or type(exc).__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
results = await asyncio.gather(*(resolve_one(item) for item in body.items))
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# --- LCSC wizard resolve ---
|
||||||
|
|
||||||
|
|
||||||
|
class LcscResolvePassiveRequest(BaseModel):
|
||||||
|
lcsc_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_taxonomy_dir(storage, tax_dir: Path) -> None:
|
||||||
|
for key in storage.list_prefix("taxonomy/"):
|
||||||
|
if key.endswith(".json"):
|
||||||
|
storage.download_to_local(key, tax_dir / key.rsplit("/", 1)[-1])
|
||||||
|
if any(tax_dir.glob("*.json")):
|
||||||
|
return
|
||||||
|
repo_tax = settings.taxonomy_dir
|
||||||
|
if repo_tax.is_dir():
|
||||||
|
for path in repo_tax.glob("*.json"):
|
||||||
|
shutil.copy2(path, tax_dir / path.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/lcsc/resolve-passive")
|
||||||
|
async def lcsc_resolve_passive(
|
||||||
|
project_id: str, body: LcscResolvePassiveRequest, request: Request,
|
||||||
|
):
|
||||||
|
from backend.services.api_logs import ApiLogger
|
||||||
|
from backend.services.billing_hook import InsufficientCredits, get_billing
|
||||||
|
from backend.services.datasheet_extract import auto_resolve_specs
|
||||||
|
from backend.services.passive_from_distributor import specs_from_lcsc_payload
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_id, meta = _access_or_404(request, project_id)
|
||||||
|
payload = (meta.lcsc_payloads or {}).get(body.lcsc_id)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(404, f"No cached payload for LCSC id {body.lcsc_id!r}")
|
||||||
|
mpn = (payload.get("mpn") or "").strip()
|
||||||
|
if not mpn:
|
||||||
|
raise HTTPException(404, f"Cached payload for {body.lcsc_id!r} has no MPN")
|
||||||
|
|
||||||
|
safe = safe_mpn(mpn)
|
||||||
|
project_model_key = (
|
||||||
|
f"{proj_svc.project_prefix(owner_id, project_id)}/models/{safe}.json"
|
||||||
|
)
|
||||||
|
if storage.exists(project_model_key):
|
||||||
|
return _passive_hit_json(
|
||||||
|
mpn, safe, storage.read_json(project_model_key),
|
||||||
|
cached=True, lcsc_id=body.lcsc_id,
|
||||||
|
)
|
||||||
|
lib_key = proj_svc.library_has_passive_model(storage, mpn)
|
||||||
|
if lib_key:
|
||||||
|
storage.copy_object(lib_key, project_model_key)
|
||||||
|
return _passive_hit_json(
|
||||||
|
mpn, safe, storage.read_json(project_model_key),
|
||||||
|
cached=True, lcsc_id=body.lcsc_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
synth_category = " / ".join(
|
||||||
|
p for p in (payload.get("category"), payload.get("subcategory")) if p
|
||||||
|
) or None
|
||||||
|
synth_params: list[dict[str, str]] = []
|
||||||
|
if payload.get("package"):
|
||||||
|
synth_params.append({"name": "Package / Case", "value": payload["package"]})
|
||||||
|
if payload.get("manufacturer"):
|
||||||
|
synth_params.append({"name": "Manufacturer", "value": payload["manufacturer"]})
|
||||||
|
description = payload.get("description") or ""
|
||||||
|
if not description:
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
f"Cached LCSC payload for {body.lcsc_id!r} has no description — "
|
||||||
|
"cannot auto-resolve",
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog_model = specs_from_lcsc_payload(mpn, payload)
|
||||||
|
if catalog_model is not None:
|
||||||
|
storage.write_json(project_model_key, catalog_model.model_dump())
|
||||||
|
proj_svc.save_to_library(
|
||||||
|
storage, project_model_key, "passives", f"{safe}.json",
|
||||||
|
)
|
||||||
|
return _passive_hit_json(
|
||||||
|
mpn, safe, catalog_model.model_dump(),
|
||||||
|
cached=False, lcsc_id=body.lcsc_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_logger = ApiLogger()
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tax_dir = Path(tmpdir) / "taxonomy"
|
||||||
|
tax_dir.mkdir()
|
||||||
|
_seed_taxonomy_dir(storage, tax_dir)
|
||||||
|
try:
|
||||||
|
model = await auto_resolve_specs(
|
||||||
|
mpn=mpn,
|
||||||
|
digikey_params=synth_params,
|
||||||
|
digikey_category=synth_category or "",
|
||||||
|
digikey_description=description,
|
||||||
|
component_type="passive",
|
||||||
|
taxonomy_dir=tax_dir,
|
||||||
|
api_logger=api_logger,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(
|
||||||
|
"lcsc_resolve_passive: auto_resolve_specs failed for %s (lcsc=%s)",
|
||||||
|
mpn, body.lcsc_id, exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(502, f"Auto-resolve failed: {exc}") from exc
|
||||||
|
|
||||||
|
total_credits = sum(float(e.get("credits_charged") or 0) for e in api_logger.entries)
|
||||||
|
if total_credits > 0:
|
||||||
|
try:
|
||||||
|
get_billing().charge(
|
||||||
|
storage, owner_id, round(total_credits, 4),
|
||||||
|
reason="pipeline_charge",
|
||||||
|
run_id=project_id,
|
||||||
|
unit_id=f"lcsc_resolve_passive:{mpn}",
|
||||||
|
allow_overdraft=False,
|
||||||
|
)
|
||||||
|
except InsufficientCredits as short:
|
||||||
|
raise HTTPException(
|
||||||
|
402,
|
||||||
|
detail={
|
||||||
|
"reason": "insufficient_credits",
|
||||||
|
"required": short.required,
|
||||||
|
"available": short.available,
|
||||||
|
},
|
||||||
|
) from short
|
||||||
|
|
||||||
|
storage.write_json(project_model_key, model.model_dump())
|
||||||
|
proj_svc.save_to_library(
|
||||||
|
storage, project_model_key, "passives", f"{safe}.json",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
logs_key = f"{proj_svc.project_prefix(owner_id, project_id)}/api_logs.jsonl"
|
||||||
|
existing = storage.read_text(logs_key) if storage.exists(logs_key) else ""
|
||||||
|
appended = existing + api_logger.to_jsonl()
|
||||||
|
if appended:
|
||||||
|
storage.write_text(logs_key, appended)
|
||||||
|
except Exception:
|
||||||
|
log.warning("lcsc_resolve_passive: failed to append api_logs.jsonl", exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.services.api_logs import total_cost as _total_cost
|
||||||
|
|
||||||
|
added_cost = _total_cost(api_logger.entries)
|
||||||
|
if added_cost > 0:
|
||||||
|
current_total = float(meta.total_cost_usd or 0)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner_id, project_id,
|
||||||
|
total_cost_usd=round(current_total + added_cost, 6),
|
||||||
|
credits_spent=round(float(meta.credits_spent or 0) + total_credits, 4),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning("lcsc_resolve_passive: failed to update total_cost_usd", exc_info=True)
|
||||||
|
|
||||||
|
return _passive_hit_json(
|
||||||
|
mpn, safe, model.model_dump(),
|
||||||
|
cached=False, lcsc_id=body.lcsc_id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""Serve reports, graphs, datasheets, and finding review mutations."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.periscopex.finding_engine import (
|
||||||
|
apply_decisions,
|
||||||
|
complete_findings,
|
||||||
|
decision_from_review,
|
||||||
|
sort_findings,
|
||||||
|
upsert_decision,
|
||||||
|
)
|
||||||
|
from backend.periscopex.models import Finding
|
||||||
|
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
|
||||||
|
from backend.periscopex.review_workflow import (
|
||||||
|
ReviewError,
|
||||||
|
apply_review_state,
|
||||||
|
build_eco,
|
||||||
|
eco_csv,
|
||||||
|
sign_report,
|
||||||
|
)
|
||||||
|
from backend.periscopex.utils import safe_mpn
|
||||||
|
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["reports"])
|
||||||
|
|
||||||
|
_MPN_OK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.:/ +#,()]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mpn(mpn: str) -> None:
|
||||||
|
if not _MPN_OK.match(mpn) or ".." in mpn:
|
||||||
|
raise HTTPException(400, "Invalid MPN format")
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix(owner: str, project_id: str) -> str:
|
||||||
|
return proj_svc.project_prefix(owner, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_report(storage, owner: str, project_id: str) -> tuple[str, dict]:
|
||||||
|
key = f"{_prefix(owner, project_id)}/report.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "Report not found")
|
||||||
|
return key, storage.read_json(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _findings_from_report(report_data: dict) -> list[Finding]:
|
||||||
|
out: list[Finding] = []
|
||||||
|
for raw in report_data.get("findings") or []:
|
||||||
|
try:
|
||||||
|
out.append(Finding.model_validate(raw))
|
||||||
|
except Exception:
|
||||||
|
log.warning("Skipping malformed finding in report", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/{project_id}")
|
||||||
|
async def get_report(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
prefix = _prefix(owner, project_id)
|
||||||
|
schema_key = f"{prefix}/report.json"
|
||||||
|
pcb_key = f"{prefix}/pcb_report.json"
|
||||||
|
schema = storage.read_json(schema_key) if storage.exists(schema_key) else None
|
||||||
|
pcb = storage.read_json(pcb_key) if storage.exists(pcb_key) else None
|
||||||
|
merged = merge_schema_pcb_reports(schema, pcb)
|
||||||
|
if merged is None:
|
||||||
|
raise HTTPException(404, "Report not found — run the pipeline first")
|
||||||
|
findings = _findings_from_report(merged)
|
||||||
|
try:
|
||||||
|
complete_findings(findings)
|
||||||
|
except Exception:
|
||||||
|
log.exception("complete_findings failed while serving report %s", project_id)
|
||||||
|
sort_findings(findings)
|
||||||
|
dec_key = f"{prefix}/decisions.json"
|
||||||
|
if storage.exists(dec_key):
|
||||||
|
try:
|
||||||
|
apply_decisions(findings, storage.read_json(dec_key) or [])
|
||||||
|
except Exception:
|
||||||
|
log.exception("apply_decisions failed for report %s", project_id)
|
||||||
|
merged["findings"] = [json.loads(f.model_dump_json()) for f in findings]
|
||||||
|
summary = {"ERROR": 0, "WARNING": 0, "INFO": 0, "total": len(findings)}
|
||||||
|
for finding in findings:
|
||||||
|
if finding.status in summary:
|
||||||
|
summary[finding.status] += 1
|
||||||
|
merged["summary"] = summary
|
||||||
|
return JSONResponse(merged)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/{project_id}/cad-bridge")
|
||||||
|
async def get_cad_bridge(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{_prefix(owner, project_id)}/periscope-findings.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "CAD bridge not found — run the pipeline first")
|
||||||
|
return JSONResponse(storage.read_json(key))
|
||||||
|
|
||||||
|
|
||||||
|
class AddCommentBody(BaseModel):
|
||||||
|
finding_id: str
|
||||||
|
text: str
|
||||||
|
user_name: str
|
||||||
|
mentions: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/report/{project_id}/comments")
|
||||||
|
async def add_comment(project_id: str, body: AddCommentBody, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
key = f"{_prefix(owner, project_id)}/report.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "Report not found")
|
||||||
|
report = storage.read_json(key)
|
||||||
|
comment = {
|
||||||
|
"comment_id": str(uuid.uuid4()),
|
||||||
|
"finding_id": body.finding_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"user_name": body.user_name,
|
||||||
|
"text": body.text,
|
||||||
|
"mentions": body.mentions,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
bucket = report.setdefault("comments", {})
|
||||||
|
bucket.setdefault(body.finding_id, []).append(comment)
|
||||||
|
storage.write_json(key, report)
|
||||||
|
return JSONResponse(comment, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/report/{project_id}/comments/{comment_id}")
|
||||||
|
async def delete_comment(project_id: str, comment_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
key = f"{_prefix(owner, project_id)}/report.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "Report not found")
|
||||||
|
report = storage.read_json(key)
|
||||||
|
comments = report.get("comments", {})
|
||||||
|
found = False
|
||||||
|
for finding_id, rows in list(comments.items()):
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if row.get("comment_id") != comment_id:
|
||||||
|
continue
|
||||||
|
found = True
|
||||||
|
if row.get("user_id") != user_id and user_id != owner:
|
||||||
|
raise HTTPException(403, "Cannot delete another user's comment")
|
||||||
|
rows.pop(i)
|
||||||
|
if not rows:
|
||||||
|
del comments[finding_id]
|
||||||
|
storage.write_json(key, report)
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(404, "Comment not found")
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewBody(BaseModel):
|
||||||
|
state: str
|
||||||
|
reason: str = ""
|
||||||
|
user_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/report/{project_id}/findings/{finding_id}/review")
|
||||||
|
async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
key, report = _load_report(storage, owner, project_id)
|
||||||
|
prefix = _prefix(owner, project_id)
|
||||||
|
findings = _findings_from_report(report)
|
||||||
|
ids = {f.finding_id for f in findings if f.finding_id}
|
||||||
|
if finding_id not in ids:
|
||||||
|
pcb_key = f"{prefix}/pcb_report.json"
|
||||||
|
if storage.exists(pcb_key):
|
||||||
|
pcb = storage.read_json(pcb_key)
|
||||||
|
pcb_findings = _findings_from_report(pcb)
|
||||||
|
if finding_id in {f.finding_id for f in pcb_findings if f.finding_id}:
|
||||||
|
key, report, findings = pcb_key, pcb, pcb_findings
|
||||||
|
ids = {f.finding_id for f in findings if f.finding_id}
|
||||||
|
if finding_id not in ids:
|
||||||
|
raise HTTPException(404, "Finding not found")
|
||||||
|
try:
|
||||||
|
states = apply_review_state(
|
||||||
|
report.get("review_states") or {},
|
||||||
|
finding_id,
|
||||||
|
state=body.state,
|
||||||
|
reason=body.reason,
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=body.user_name,
|
||||||
|
)
|
||||||
|
except ReviewError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
report["review_states"] = states
|
||||||
|
storage.write_json(key, report)
|
||||||
|
if body.state in {"wontfix", "false_positive"}:
|
||||||
|
found = next(
|
||||||
|
(f for f in _findings_from_report(report) if f.finding_id == finding_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if found is not None:
|
||||||
|
dec = decision_from_review(
|
||||||
|
found, state=body.state, reason=body.reason, user_id=user_id
|
||||||
|
)
|
||||||
|
if dec is not None:
|
||||||
|
dkey = f"{prefix}/decisions.json"
|
||||||
|
existing = storage.read_json(dkey) if storage.exists(dkey) else []
|
||||||
|
if not isinstance(existing, list):
|
||||||
|
existing = []
|
||||||
|
storage.write_json(dkey, upsert_decision(existing, dec))
|
||||||
|
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/{project_id}/eco.json")
|
||||||
|
async def get_eco_json(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
_, report = _load_report(storage, owner, project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
{"items": build_eco(_findings_from_report(report), report.get("review_states") or {})}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/{project_id}/eco.csv")
|
||||||
|
async def get_eco_csv(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
_, report = _load_report(storage, owner, project_id)
|
||||||
|
items = build_eco(_findings_from_report(report), report.get("review_states") or {})
|
||||||
|
return Response(eco_csv(items), media_type="text/csv")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/report/{project_id}/sign")
|
||||||
|
async def post_sign_report(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
key, report = _load_report(storage, owner, project_id)
|
||||||
|
release = sign_report(report, user_id=user_id)
|
||||||
|
report["release"] = release
|
||||||
|
storage.write_json(key, report)
|
||||||
|
return JSONResponse(release)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bom/{project_id}")
|
||||||
|
async def get_bom_summary(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{_prefix(owner, project_id)}/bom_summary.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "BOM summary not found — run the pipeline first")
|
||||||
|
return JSONResponse(storage.read_json(key))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/derating/{project_id}")
|
||||||
|
async def get_derating(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{_prefix(owner, project_id)}/derating.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "Derating data not found — run the pipeline first")
|
||||||
|
return JSONResponse(storage.read_json(key))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/graph/{project_id}")
|
||||||
|
async def get_graph(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{_prefix(owner, project_id)}/design_graph.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "Design graph not found — run the pipeline first")
|
||||||
|
return JSONResponse(storage.read_json(key))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/logs")
|
||||||
|
async def get_project_logs(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{_prefix(owner, project_id)}/api_logs.jsonl"
|
||||||
|
if not storage.exists(key):
|
||||||
|
return JSONResponse([])
|
||||||
|
text = storage.read_text(key)
|
||||||
|
entries = [json.loads(line) for line in text.strip().split("\n") if line.strip()]
|
||||||
|
from backend.services.llm.pricing import cost_for_entry
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if any(
|
||||||
|
entry.get(k)
|
||||||
|
for k in (
|
||||||
|
"input_tokens",
|
||||||
|
"output_tokens",
|
||||||
|
"cache_read_input_tokens",
|
||||||
|
"cache_creation_input_tokens",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
entry["cost_usd"] = round(cost_for_entry(entry), 6)
|
||||||
|
return JSONResponse(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_datasheet_key(storage, owner: str, project_id: str, safe: str, mpn: str | None = None) -> str | None:
|
||||||
|
from backend.services.datasheet_store import resolve_datasheet
|
||||||
|
|
||||||
|
uploaded = f"{_prefix(owner, project_id)}/uploads/datasheets/{safe}.pdf"
|
||||||
|
if storage.exists(uploaded):
|
||||||
|
return uploaded
|
||||||
|
resolved = resolve_datasheet(storage, safe)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
legacy = f"library/datasheets/{safe}.pdf"
|
||||||
|
if storage.exists(legacy):
|
||||||
|
return legacy
|
||||||
|
if mpn:
|
||||||
|
return proj_svc.library_has_datasheet(storage, mpn)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/datasheet-url/{mpn:path}")
|
||||||
|
async def get_datasheet_url(project_id: str, mpn: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
_require_mpn(mpn)
|
||||||
|
key = _find_datasheet_key(storage, owner, project_id, safe_mpn(mpn), mpn=mpn)
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(404, f"Datasheet not found for MPN: {mpn}")
|
||||||
|
proxy = f"/api/projects/{project_id}/datasheet/{mpn}"
|
||||||
|
return {"url": f"{str(request.base_url).rstrip('/')}{proxy}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/datasheet/{mpn:path}")
|
||||||
|
async def get_datasheet_proxy(project_id: str, mpn: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner, _ = await resolve_or_404(request, project_id)
|
||||||
|
_require_mpn(mpn)
|
||||||
|
safe = safe_mpn(mpn)
|
||||||
|
key = _find_datasheet_key(storage, owner, project_id, safe, mpn=mpn)
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(404, f"Datasheet not found for MPN: {mpn}")
|
||||||
|
return Response(
|
||||||
|
content=storage.read_bytes(key),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{safe}.pdf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/datasheets/{mpn}")
|
||||||
|
async def get_datasheet(mpn: str, request: Request):
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from backend.services.storage import LocalStorageBackend
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
_require_mpn(mpn)
|
||||||
|
safe = safe_mpn(mpn)
|
||||||
|
if not isinstance(storage, LocalStorageBackend):
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
"Use GET /projects/{project_id}/datasheet-url/{mpn} for cloud storage",
|
||||||
|
)
|
||||||
|
for entry in storage.list_prefix(f"users/{user_id}/projects/"):
|
||||||
|
pdf_key = f"{entry}/uploads/datasheets/{safe}.pdf"
|
||||||
|
if storage.exists(pdf_key):
|
||||||
|
return FileResponse(
|
||||||
|
storage._path(pdf_key),
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=f"{safe}.pdf",
|
||||||
|
)
|
||||||
|
raise HTTPException(404, f"Datasheet not found for MPN: {mpn}")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Onboarding survey: status flag plus one-shot Google Sheet append."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.routers.deps import get_storage, get_user_id
|
||||||
|
from backend.services import survey as survey_svc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/survey", tags=["survey"])
|
||||||
|
|
||||||
|
|
||||||
|
class SurveySubmission(BaseModel):
|
||||||
|
referral_source: str
|
||||||
|
user_profile: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def survey_status(request: Request):
|
||||||
|
return {
|
||||||
|
"completed": survey_svc.is_completed(get_storage(request), get_user_id(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _identity(user_id: str) -> tuple[str, str]:
|
||||||
|
email, name = "unknown", "unknown"
|
||||||
|
if not settings.use_auth:
|
||||||
|
return email, name
|
||||||
|
try:
|
||||||
|
from backend.services.email import _resolve_clerk_user
|
||||||
|
|
||||||
|
clerk_user = await _resolve_clerk_user(user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to resolve Clerk user %s for survey", user_id)
|
||||||
|
return email, name
|
||||||
|
if not clerk_user:
|
||||||
|
return email, name
|
||||||
|
emails = clerk_user.get("email_addresses") or []
|
||||||
|
if emails:
|
||||||
|
email = emails[0].get("email_address") or "unknown"
|
||||||
|
first = clerk_user.get("first_name") or ""
|
||||||
|
last = clerk_user.get("last_name") or ""
|
||||||
|
name = f"{first} {last}".strip() or "unknown"
|
||||||
|
return email, name
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def submit_survey(request: Request, body: SurveySubmission):
|
||||||
|
storage = get_storage(request)
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
if survey_svc.is_completed(storage, user_id):
|
||||||
|
return {"ok": True, "detail": "already_submitted"}
|
||||||
|
|
||||||
|
email, name = await _identity(user_id)
|
||||||
|
sheet_ok = await survey_svc.append_to_sheet(
|
||||||
|
user_id=user_id,
|
||||||
|
email=email,
|
||||||
|
name=name,
|
||||||
|
referral_source=body.referral_source,
|
||||||
|
user_profile=body.user_profile,
|
||||||
|
)
|
||||||
|
if sheet_ok or not settings.survey_sheet_id:
|
||||||
|
survey_svc._mark_completed(storage, user_id)
|
||||||
|
return {"ok": True}
|
||||||
|
return {"ok": False, "detail": "sheet_write_failed"}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""HTTP routers resolve from periscope/src (auth stays leftover)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import backend.routers.admin as admin
|
||||||
|
import backend.routers.contact as contact
|
||||||
|
import backend.routers.deps as deps
|
||||||
|
import backend.routers.feedback as feedback
|
||||||
|
import backend.routers.pipeline as pipeline
|
||||||
|
import backend.routers.projects as projects
|
||||||
|
import backend.routers.reports as reports
|
||||||
|
import backend.routers.survey as survey
|
||||||
|
|
||||||
|
|
||||||
|
def _src(mod, name: str) -> None:
|
||||||
|
path = Path(mod.__file__).resolve()
|
||||||
|
assert path.name == name
|
||||||
|
assert "src" in path.parts
|
||||||
|
assert "Native Periscope overlay" not in path.read_text(encoding="utf-8")[:400]
|
||||||
|
|
||||||
|
|
||||||
|
def test_inherited_routers_are_src():
|
||||||
|
for mod, name in (
|
||||||
|
(admin, "admin.py"),
|
||||||
|
(contact, "contact.py"),
|
||||||
|
(deps, "deps.py"),
|
||||||
|
(feedback, "feedback.py"),
|
||||||
|
(pipeline, "pipeline.py"),
|
||||||
|
(projects, "projects.py"),
|
||||||
|
(reports, "reports.py"),
|
||||||
|
(survey, "survey.py"),
|
||||||
|
):
|
||||||
|
_src(mod, name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_router_is_not_rewritten_this_slice():
|
||||||
|
import backend.routers.auth as auth
|
||||||
|
|
||||||
|
path = Path(auth.__file__).resolve()
|
||||||
|
assert path.name == "auth.py"
|
||||||
|
assert "src" in path.parts
|
||||||
|
|
||||||
|
|
||||||
|
def test_reprocess_helper_names():
|
||||||
|
assert callable(pipeline._await_terminal)
|
||||||
|
assert callable(pipeline._project_active)
|
||||||
|
assert callable(projects._bom_file_to_csv_bytes)
|
||||||
Reference in New Issue
Block a user