Pinscope open-source core

Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
This commit is contained in:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
View File
+736
View File
@@ -0,0 +1,736 @@
"""Admin endpoints — library components, user management, and limits.
All endpoints require the requesting user to have role: "admin" in their
Clerk public metadata. In local dev (no auth), all requests are treated as admin.
"""
from __future__ import annotations
import asyncio
import re
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from backend.config import settings
from backend.pinscopex.utils import safe_mpn
from backend.routers.deps import get_storage
from backend.services import admin_settings as settings_svc
from backend.services.billing_hook import get_billing
from backend.services import projects as proj_svc
router = APIRouter(prefix="/admin", tags=["admin"])
# ---------------------------------------------------------------------------
# Admin verification
# ---------------------------------------------------------------------------
async def is_admin(request: Request) -> bool:
"""Check if the caller is an admin. Result is cached on request.state."""
cached = getattr(request.state, "_is_admin", None)
if cached is not None:
return cached
user_id: str = request.state.user_id
# Local dev — no auth, treat as admin
if not settings.use_auth:
request.state._is_admin = True
return True
# Fetch user from Clerk Backend API and check public_metadata.role
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.clerk.com/v1/users/{user_id}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
data = resp.json()
role = data.get("public_metadata", {}).get("role")
result = role == "admin"
else:
result = False
except Exception:
result = False
request.state._is_admin = result
return result
async def _require_admin(request: Request) -> str:
"""Return user_id if the caller is an admin, else raise 403."""
if not await is_admin(request):
raise HTTPException(403, "Admin access required")
return request.state.user_id
# ---------------------------------------------------------------------------
# Library components
# ---------------------------------------------------------------------------
@router.get("/components")
async def list_components(request: Request):
"""List all extracted IC components and passive patterns in the library."""
await _require_admin(request)
storage = get_storage(request)
# IC extractions (deduplicate by MPN)
ic_keys = [
k for k in storage.list_prefix("library/extracted/")
if k.endswith(".json")
]
ics = []
seen_ic_mpns: set[str] = set()
for key in ic_keys:
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_ic_mpns:
continue
seen_ic_mpns.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype", ""),
"pin_count": len(data.get("pintable", [])),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
})
except Exception:
continue
# Passive patterns
pattern_keys = [
k for k in storage.list_prefix("library/patterns/")
if k.endswith(".json")
]
passives = []
seen_passive_names: set[str] = set()
for key in pattern_keys:
try:
data = storage.read_json(key)
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
if name in seen_passive_names:
continue
seen_passive_names.add(name)
passives.append({
"mpn": name,
"type": "passive",
"subtype": data.get("component_type", ""),
"description": data.get("description", ""),
"regex": data.get("regex", ""),
})
except Exception:
continue
# Simple component models (library/models/) + passive models (library/passives/)
model_keys = [
k for k in storage.list_prefix("library/models/")
if k.endswith(".json")
]
passive_model_keys = [
k for k in storage.list_prefix("library/passives/")
if k.endswith(".json")
]
simple_models = []
seen_model_mpns: set[str] = set()
for key in model_keys + passive_model_keys:
try:
data = storage.read_json(key)
mpn = data.get("mpn", "")
if mpn in seen_model_mpns:
continue
seen_model_mpns.add(mpn)
specs = data.get("specs", {})
simple_models.append({
"mpn": mpn,
"type": "simple",
"specs_type": specs.get("specs_type", ""),
"subtype": specs.get("component_subtype", ""),
"param_count": len(specs.get("values", {})),
})
except Exception:
continue
return JSONResponse(
content={"ics": ics, "passives": passives, "simple": simple_models},
headers={"Cache-Control": "no-store"},
)
def _safe_name(name: str) -> str:
"""Sanitize MPN to safe filename (same logic as pipeline)."""
safe = safe_mpn(name)
if ".." in safe or not re.match(r"^[A-Za-z0-9]", safe):
raise HTTPException(400, "Invalid component name")
return safe
@router.get("/components/{component_type}/{name:path}")
async def get_component(component_type: str, name: str, request: Request):
"""Return the raw JSON for an IC extraction or passive pattern."""
await _require_admin(request)
safe = _safe_name(name)
storage = get_storage(request)
if component_type == "ic":
key = f"library/extracted/{safe}.json"
elif component_type == "passive":
key = f"library/patterns/{safe}.json"
elif component_type == "simple":
# Check library/passives/ first, then library/models/
key = f"library/passives/{safe}.json"
if not storage.exists(key):
key = f"library/models/{safe}.json"
else:
raise HTTPException(400, f"Unknown component type: {component_type}")
if not storage.exists(key):
raise HTTPException(404, f"Component not found: {name}")
return JSONResponse(content=storage.read_json(key))
@router.delete("/components/{component_type}/{name:path}")
async def delete_component(component_type: str, name: str, request: Request):
"""Delete an IC extraction or passive pattern from the shared library."""
await _require_admin(request)
safe = _safe_name(name)
storage = get_storage(request)
if component_type == "ic":
key = f"library/extracted/{safe}.json"
elif component_type == "passive":
key = f"library/patterns/{safe}.json"
elif component_type == "simple":
# Check library/passives/ first, then library/models/
key = f"library/passives/{safe}.json"
if not storage.exists(key):
key = f"library/models/{safe}.json"
else:
raise HTTPException(400, f"Unknown component type: {component_type}")
if not storage.exists(key):
raise HTTPException(404, f"Component not found: {name}")
storage.delete_key(key)
# Delete datasheet ref (blob preserved for other refs; GC cleans orphans)
from backend.services.datasheet_store import delete_datasheet_ref
deleted_datasheets = 0
old_blob = delete_datasheet_ref(storage, name)
if old_blob:
deleted_datasheets += 1
# Legacy flat file cleanup (remove after migration confirmed)
ds_key = f"library/datasheets/{safe}.pdf"
if storage.exists(ds_key):
storage.delete_key(ds_key)
deleted_datasheets += 1
return {"deleted": key, "deleted_datasheets": deleted_datasheets}
def _clerk_profile_fields(clerk: dict) -> dict:
"""Pull display name / email / avatar out of a Clerk user object."""
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
emails = clerk.get("email_addresses", [])
return {
"name": f"{first} {last}".strip() or None,
"email": emails[0].get("email_address") if emails else None,
"image_url": clerk.get("image_url"),
}
def _base_admin_user(storage, uid: str) -> dict:
"""Build the project-count + balance record for a single user_id."""
try:
project_count = len(proj_svc.list_projects(storage, uid))
except Exception:
project_count = 0
try:
balance = get_billing().get_balance(storage, uid)
except Exception:
balance = 0.0
return {
"user_id": uid,
"project_count": project_count,
"balance": round(balance, 4),
"name": None,
"email": None,
"image_url": None,
}
async def _enrich_clerk_profiles(users: dict[str, dict]) -> None:
"""Fill name/email/avatar for each user via the Clerk Backend API.
Fetches in parallel (bounded) so the list stays fast even with many
users. Failures per-user are swallowed — the row still renders with
the user_id as a fallback label.
"""
sem = asyncio.Semaphore(10)
async with httpx.AsyncClient(timeout=10.0) as client:
async def _one(uid: str) -> None:
async with sem:
try:
resp = await client.get(
f"https://api.clerk.com/v1/users/{uid}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
users[uid].update(_clerk_profile_fields(resp.json()))
except Exception:
pass
await asyncio.gather(*(_one(uid) for uid in users))
@router.get("/users")
async def list_users(request: Request):
"""List every user with a project or any credit activity.
The balance file is written on a user's first ``GET /api/credits``
(trial grant), so this includes everyone who has ever opened the
authenticated app — not only project creators. To find a user who has
never opened the app, use ``GET /api/admin/users/search?email=``.
"""
await _require_admin(request)
storage = get_storage(request)
user_ids: set[str] = set()
# Project creators (users/{user_id}/...)
for entry in storage.list_prefix("users/"):
parts = entry.split("/")
if len(parts) >= 2 and parts[1]:
user_ids.add(parts[1])
# Anyone with credit activity (covers the trial grant on first app open)
user_ids.update(get_billing().list_user_ids(storage))
users: dict[str, dict] = {uid: _base_admin_user(storage, uid) for uid in user_ids}
# Enrich with Clerk user info when auth is enabled
if settings.use_auth and users:
await _enrich_clerk_profiles(users)
return list(users.values())
@router.get("/users/search")
async def search_users(request: Request, email: str):
"""Find users by email via Clerk so any account can be topped up (admin).
Resolves even users with no project and no credit activity yet — useful
for granting credits to someone who has just signed up. Requires auth
to be enabled (no Clerk directory exists in local dev).
"""
await _require_admin(request)
storage = get_storage(request)
email = email.strip()
if not email:
return []
if not settings.use_auth:
raise HTTPException(400, "User search requires authentication to be enabled")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
"https://api.clerk.com/v1/users",
params={"email_address": [email]},
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
except Exception as exc:
raise HTTPException(502, "Failed to look up user") from exc
if resp.status_code != 200:
raise HTTPException(502, "Failed to look up user")
results: list[dict] = []
for clerk in resp.json():
uid = clerk.get("id")
if not uid:
continue
entry = _base_admin_user(storage, uid)
entry.update(_clerk_profile_fields(clerk))
results.append(entry)
return results
# ---------------------------------------------------------------------------
# Usage / cost tracking
# ---------------------------------------------------------------------------
@router.get("/usage")
async def get_usage(request: Request):
"""Aggregate API token usage and cost across all users and projects."""
await _require_admin(request)
storage = get_storage(request)
user_entries = storage.list_prefix("users/")
seen_uids: set[str] = set()
user_rows: list[dict] = []
grand_total = 0.0
for entry in user_entries:
parts = entry.split("/")
if len(parts) >= 2:
uid = parts[1]
if uid in seen_uids:
continue
seen_uids.add(uid)
projects = proj_svc.list_projects(storage, uid)
user_cost = 0.0
project_details = []
for p in projects:
cost = p.total_cost_usd or 0.0
user_cost += cost
project_details.append({
"id": p.id,
"name": p.name,
"status": p.status,
"cost_usd": cost,
"created": p.created,
})
user_rows.append({
"user_id": uid,
"project_count": len(projects),
"total_cost_usd": round(user_cost, 4),
"projects": project_details,
"name": None,
"email": None,
})
grand_total += user_cost
# Enrich with Clerk user info
if settings.use_auth and user_rows:
async with httpx.AsyncClient() as client:
for row in user_rows:
try:
resp = await client.get(
f"https://api.clerk.com/v1/users/{row['user_id']}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
clerk = resp.json()
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
row["name"] = f"{first} {last}".strip() or None
emails = clerk.get("email_addresses", [])
row["email"] = emails[0].get("email_address") if emails else None
except Exception:
pass
return {
"grand_total_usd": round(grand_total, 4),
"users": user_rows,
}
# ---------------------------------------------------------------------------
# All projects (cross-user)
# ---------------------------------------------------------------------------
async def _enrich_with_clerk_info(
items: list[dict], uid_key: str = "user_id",
name_key: str = "owner_name", email_key: str = "owner_email",
) -> None:
"""Enrich a list of dicts with Clerk user info, deduplicating API calls."""
if not settings.use_auth or not items:
return
cache: dict[str, dict] = {}
async with httpx.AsyncClient() as client:
for item in items:
uid = item[uid_key]
if uid not in cache:
try:
resp = await client.get(
f"https://api.clerk.com/v1/users/{uid}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
clerk = resp.json()
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
emails = clerk.get("email_addresses", [])
cache[uid] = {
name_key: f"{first} {last}".strip() or None,
email_key: emails[0].get("email_address") if emails else None,
}
else:
cache[uid] = {name_key: None, email_key: None}
except Exception:
cache[uid] = {name_key: None, email_key: None}
item.update(cache[uid])
@router.get("/projects")
async def list_all_projects(request: Request):
"""List all projects across all users with metadata."""
await _require_admin(request)
storage = get_storage(request)
user_entries = storage.list_prefix("users/")
seen_uids: set[str] = set()
all_projects: list[dict] = []
for entry in user_entries:
parts = entry.split("/")
if len(parts) >= 2:
uid = parts[1]
if uid in seen_uids:
continue
seen_uids.add(uid)
projects = proj_svc.list_projects(storage, uid)
for p in projects:
all_projects.append({
"id": p.id,
"name": p.name,
"user_id": p.user_id,
"status": p.status,
"created": p.created,
"updated": p.updated,
"has_bom": p.has_bom,
"has_netlist": p.has_netlist,
"datasheet_count": p.datasheet_count,
"total_cost_usd": p.total_cost_usd,
"pipeline_state": p.pipeline_state,
"summary": p.summary,
"owner_name": None,
"owner_email": None,
})
await _enrich_with_clerk_info(all_projects)
return all_projects
# ---------------------------------------------------------------------------
# Running pipelines
# ---------------------------------------------------------------------------
@router.get("/runs")
async def list_running_pipelines(request: Request):
"""List queued and running pipelines, plus drive the stale-running sweeper.
Source of truth is ``project.json`` (``status`` ∈ {queued, running}); we
cross-check with the Cloud Run Job execution. Any project whose
execution is in a terminal Cloud Run state but whose status is still
queued/running is flipped to ``error`` here — this is the sweeper that
keeps zombie projects from showing "running" forever in the UI.
"""
from datetime import datetime, timezone
from backend.services import job_runner
await _require_admin(request)
storage = get_storage(request)
now = datetime.now(timezone.utc)
runs: list[dict] = []
seen_uids: set[str] = set()
for entry in storage.list_prefix("users/"):
parts = entry.split("/")
if len(parts) < 2:
continue
uid = parts[1]
if uid in seen_uids:
continue
seen_uids.add(uid)
prefix = f"users/{uid}/projects/"
for proj_entry in storage.list_prefix(prefix):
meta_key = (
proj_entry if proj_entry.endswith("/project.json")
else f"{proj_entry}/project.json"
)
if not storage.exists(meta_key):
continue
try:
meta = proj_svc.ProjectMeta.model_validate(storage.read_json(meta_key))
except Exception:
continue
if meta.status not in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING):
continue
# Sweeper: if the execution is in a terminal Cloud Run state,
# the worker is already gone. Flip status → error so the UI
# stops lying. Skip the sweep when execution_name is missing
# (worker may still be enqueueing).
exec_state = "unknown"
if meta.execution_name:
exec_state = job_runner.get_execution_state(meta.execution_name)
if exec_state in ("succeeded", "failed", "cancelled"):
# Allow a short grace period so we don't race the worker
# writing its own terminal status. updated may be stale
# if the worker died before any status write.
try:
last_update = datetime.fromisoformat(meta.updated)
age = (now - last_update).total_seconds()
except Exception:
age = settings.pipeline_sweeper_stale_seconds + 1
if age >= settings.pipeline_sweeper_stale_seconds:
proj_svc.mark_stale_running(
storage, uid, meta.id,
f"Worker terminated (execution state={exec_state}); please restart.",
)
continue
try:
started_at = datetime.fromisoformat(meta.updated)
except Exception:
started_at = now
runs.append({
"project_id": meta.id,
"project_name": meta.name,
"user_id": uid,
"status": meta.status,
"execution_name": meta.execution_name,
"execution_state": exec_state,
"started_at": started_at.isoformat(),
"duration_seconds": int((now - started_at).total_seconds()),
"owner_name": None,
"owner_email": None,
})
await _enrich_with_clerk_info(runs)
return runs
# ---------------------------------------------------------------------------
# Global settings
# ---------------------------------------------------------------------------
class UpdateMinVersionRequest(BaseModel):
min_model_version: str
@router.get("/settings")
async def get_settings(request: Request):
"""Get global admin settings (model version threshold, etc.)."""
await _require_admin(request)
storage = get_storage(request)
data = settings_svc.get_admin_settings(storage)
data["default_model_version"] = settings.get_default_model_version()
return data
@router.put("/settings/min-model-version")
async def set_min_model_version(req: UpdateMinVersionRequest, request: Request):
"""Set the minimum model version for library reuse."""
await _require_admin(request)
storage = get_storage(request)
try:
settings_svc.set_min_model_version(storage, req.min_model_version)
except Exception as e:
raise HTTPException(400, f"Invalid version: {e}")
return {"min_model_version": req.min_model_version}
# ---------------------------------------------------------------------------
# Email test
# ---------------------------------------------------------------------------
class TestEmailRequest(BaseModel):
to_email: str
@router.post("/test-email")
async def test_email(req: TestEmailRequest, request: Request):
"""Send a test email to verify Gmail API setup. Admin only."""
await _require_admin(request)
from backend.services.email import send_test_email
result = await send_test_email(req.to_email)
return result
# ---------------------------------------------------------------------------
# Project state overrides
# ---------------------------------------------------------------------------
@router.post("/projects/{project_id}/mark-complete")
async def mark_project_complete(project_id: str, request: Request):
"""Admin-only: force a paused project to ``complete`` status.
Intended for projects stuck at ``paused_insufficient_credits`` that the
admin has decided to finalize rather than resume. Clears the pause
checkpoint/reason; does not touch credits, cost totals, or artifacts.
"""
from backend.routers.deps import resolve_or_404
from backend.services import projects as proj_svc
await _require_admin(request)
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED):
raise HTTPException(409, "Cannot mark a running pipeline complete; cancel it first")
if meta.status == "complete":
return {"status": "complete", "project_id": project_id}
proj_svc.update_project(
storage,
owner_user_id,
project_id,
status="complete",
pause_checkpoint=None,
pause_reason=None,
)
return {"status": "complete", "project_id": project_id}
# ---------------------------------------------------------------------------
# Report overrides
# ---------------------------------------------------------------------------
@router.delete("/projects/{project_id}/findings/{finding_id}")
async def delete_finding(project_id: str, finding_id: str, request: Request):
"""Admin-only: delete a single finding (rule violation) from a report.
Rewrites ``report.json`` without the matching finding, recomputes summary
counts, and mirrors the summary onto ``ProjectMeta`` so dashboard totals
stay consistent. Returns 404 if the project, report, or finding is missing.
"""
from backend.routers.deps import resolve_or_404
await _require_admin(request)
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/report.json"
if not storage.exists(key):
raise HTTPException(404, "Report not found")
report = storage.read_json(key)
findings = report.get("findings", []) or []
remaining = [f for f in findings if f.get("finding_id") != finding_id]
if len(remaining) == len(findings):
raise HTTPException(404, f"Finding not found: {finding_id}")
summary = {"total": len(remaining), "ERROR": 0, "WARNING": 0, "INFO": 0}
for f in remaining:
status = f.get("status")
if status in summary:
summary[status] += 1
report["findings"] = remaining
report["summary"] = summary
storage.write_json(key, report)
proj_svc.update_project(storage, owner_user_id, project_id, summary=summary)
return {
"deleted": finding_id,
"project_id": project_id,
"remaining": len(remaining),
"summary": summary,
}
+135
View File
@@ -0,0 +1,135 @@
"""Public contact form endpoint — no authentication required."""
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()
# Simple in-memory rate limiting (per-instance, resets on deploy)
_recent: dict[str, float] = {}
_RATE_LIMIT_SECONDS = 60
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 _build_contact_message(data: ContactRequest) -> MIMEMultipart:
"""Build the contact form email."""
msg = MIMEMultipart("alternative")
msg["From"] = f"Pinscope <{settings.email_sender}>"
msg["To"] = settings.contact_recipient
msg["Reply-To"] = data.email
msg["Subject"] = f"[Pinscope Contact] {data.subject or 'New message'} from {data.name}"
# Plain text
lines = [
f"Name: {data.name}",
f"Email: {data.email}",
]
if data.company:
lines.append(f"Company: {data.company}")
if data.subject:
lines.append(f"Subject: {data.subject}")
lines += ["", data.message, "", "— Sent from the Pinscope contact form"]
msg.attach(MIMEText("\n".join(lines), "plain"))
# HTML
name = html.escape(data.name)
email = html.escape(data.email)
company = html.escape(data.company)
subject = html.escape(data.subject)
message = html.escape(data.message)
rows = f"""\
<tr>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5; font-weight: 600; width: 100px;">Name</td>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5;">{name}</td>
</tr>
<tr>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5; font-weight: 600;">Email</td>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5;"><a href="mailto:{email}">{email}</a></td>
</tr>"""
if data.company:
rows += f"""\
<tr>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5; font-weight: 600;">Company</td>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5;">{company}</td>
</tr>"""
if data.subject:
rows += f"""\
<tr>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5; font-weight: 600;">Subject</td>
<td style="padding: 8px 12px; border: 1px solid #e5e5e5;">{subject}</td>
</tr>"""
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;">{message}</div>
<p style="margin-top: 24px; font-size: 12px; color: #888;">Sent from the Pinscope 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):
# Honeypot check — bots fill hidden fields
if data.honeypot:
return ContactResponse(success=True, message="Message sent! We'll get back to you soon.")
# Rate limiting by IP
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
now = time.time()
last = _recent.get(ip)
if last and now - last < _RATE_LIMIT_SECONDS:
return ContactResponse(success=False, message="Please wait a minute before submitting again.")
_recent[ip] = now
# Clean up old entries
if len(_recent) > 1000:
cutoff = now - _RATE_LIMIT_SECONDS
for key in [k for k, v in _recent.items() if v < cutoff]:
del _recent[key]
# Check email is configured
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.",
)
msg = _build_contact_message(data)
await _send_raw(settings.contact_recipient, msg, "Contact form")
return ContactResponse(success=True, message="Message sent! We'll get back to you soon.")
+37
View File
@@ -0,0 +1,37 @@
"""Shared dependencies for FastAPI 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]:
"""Resolve project access (owner, collaborator, or admin) or raise 404."""
storage = get_storage(request)
user_id = get_user_id(request)
# 1. Try normal access — cheap, no external API call
result = proj_svc.resolve_project_access(storage, user_id, project_id)
if result:
return result
# 2. Admin fallback — Clerk API call only when normal access fails
from backend.routers.admin import is_admin
if await is_admin(request):
result = proj_svc.find_project_any_user(storage, project_id)
if result:
return result
raise HTTPException(404, "Project not found")
+303
View File
@@ -0,0 +1,303 @@
"""User feedback / ticket system.
Users can submit feedback tickets (bugs, rule reports, feature requests).
Tickets are stored as individual JSON files with JSONL indexes for fast listing.
Storage layout:
admin/feedback/tickets/{ticket_id}.json
admin/feedback/index/by_user/{user_id}.jsonl
admin/feedback/index/by_project/{project_id}.jsonl
admin/feedback/index/all.jsonl
"""
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()
# ---------------------------------------------------------------------------
# Storage key helpers
# ---------------------------------------------------------------------------
_TICKETS_PREFIX = "admin/feedback/tickets/"
_INDEX_BY_USER = "admin/feedback/index/by_user/"
_INDEX_BY_PROJECT = "admin/feedback/index/by_project/"
_INDEX_ALL = "admin/feedback/index/all.jsonl"
def _ticket_key(ticket_id: str) -> str:
return f"{_TICKETS_PREFIX}{ticket_id}.json"
def _user_index_key(user_id: str) -> str:
return f"{_INDEX_BY_USER}{user_id}.jsonl"
def _project_index_key(project_id: str) -> str:
return f"{_INDEX_BY_PROJECT}{project_id}.jsonl"
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
FeedbackType = Literal["bug", "rule_feedback", "feature_request"]
FeedbackStatus = Literal["open", "acknowledged", "resolved"]
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
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _append_index(storage: StorageBackend, key: str, entry: dict) -> None:
existing = ""
if storage.exists(key):
existing = storage.read_text(key)
line = json.dumps(entry) + "\n"
storage.write_text(key, existing + line)
def _read_index(storage: StorageBackend, key: str) -> list[dict]:
if not storage.exists(key):
return []
text = storage.read_text(key)
entries: list[dict] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def _read_ticket(storage: StorageBackend, ticket_id: str) -> FeedbackTicket | None:
key = _ticket_key(ticket_id)
if not storage.exists(key):
return None
try:
data = storage.read_json(key)
return FeedbackTicket(**data)
except Exception:
logger.warning("Failed to read ticket %s", ticket_id)
return None
def _read_tickets_from_index(
storage: StorageBackend,
index_key: str,
*,
status: str | None = None,
ticket_type: str | None = None,
project_id: str | None = None,
) -> list[FeedbackTicket]:
index_entries = _read_index(storage, index_key)
tickets: list[FeedbackTicket] = []
for entry in reversed(index_entries):
tid = entry.get("ticket_id")
if not tid:
continue
ticket = _read_ticket(storage, tid)
if not ticket:
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
tickets.append(ticket)
return tickets
# ---------------------------------------------------------------------------
# User endpoints
# ---------------------------------------------------------------------------
@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_entry = {"ticket_id": ticket_id, "created_at": now}
_append_index(storage, _user_index_key(user_id), index_entry)
_append_index(storage, _INDEX_ALL, index_entry)
if body.project_id:
_append_index(storage, _project_index_key(body.project_id), index_entry)
# Notify the admin inbox (fire-and-forget, identical pattern to
# pipeline-started). Errors are swallowed inside the email service.
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):
storage = get_storage(request)
user_id = get_user_id(request)
return _read_tickets_from_index(
storage, _user_index_key(user_id), status=status,
)
# ---------------------------------------------------------------------------
# Admin endpoints
# ---------------------------------------------------------------------------
@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)
storage = get_storage(request)
return _read_tickets_from_index(
storage, _INDEX_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 not ticket:
raise HTTPException(404, "Ticket not found")
prev_admin_notes = (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())
# If admin_notes changed to a new, non-empty value, notify the submitter.
new_admin_notes = (ticket.admin_notes or "").strip()
if new_admin_notes and new_admin_notes != prev_admin_notes:
try:
from backend.services.email import send_feedback_reply_email
await send_feedback_reply_email(
user_id=ticket.user_id,
reply_text=new_admin_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
+421
View File
@@ -0,0 +1,421 @@
"""Pipeline start, SSE events, and status endpoints.
Pipelines run in a Cloud Run Job worker (or, in local dev, a child
subprocess). The API only enqueues, transitions status with
``if-generation-match`` for idempotency, and tails the GCS-backed event
log for SSE.
"""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from sse_starlette.sse import EventSourceResponse
from pydantic import BaseModel
from backend.routers.deps import get_storage, resolve_or_404
from backend.services import event_bridge, job_runner
from backend.services import projects as proj_svc
logger = logging.getLogger(__name__)
VALID_REGEN_STAGES = {"derating"}
class RegenRequest(BaseModel):
stages: list[str]
router = APIRouter(tags=["pipeline"])
# Statuses from which a fresh ``/start`` is allowed to transition into queued.
_START_OK_FROM = frozenset({
proj_svc.STATUS_DRAFT,
proj_svc.STATUS_COMPLETE,
proj_svc.STATUS_ERROR,
proj_svc.STATUS_CANCELLED,
})
def _project_active(meta: proj_svc.ProjectMeta) -> bool:
"""A project is "active" if a worker is or could be running for it.
Used as the running-guard. We trust the meta status as the primary
signal, and only fall back to the Cloud Run execution state when the
status is one we expect a worker to be touching. This deliberately
does NOT call get_execution_state on every request — it's an admin
API call. The stale-running sweeper is responsible for clearing
zombie ``running`` projects.
"""
return meta.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING)
@router.post("/pipeline/{project_id}/start", status_code=202)
async def start(project_id: str, request: Request):
from backend.routers.deps import get_user_id
from backend.services.billing_hook import get_billing
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not meta.has_bom or not meta.has_netlist:
raise HTTPException(400, "Upload BOM and netlist before starting pipeline")
# Ensure the caller has at least their trial credits allocated. The
# pipeline itself enforces pause-on-empty — this just makes sure a
# brand-new user isn't blocked before their grant is issued.
get_billing().ensure_trial_grant(storage, get_user_id(request))
# Idempotent enqueue: only one ``draft|complete|error|cancelled`` ->
# ``queued`` transition can win. Concurrent /start clicks => 409.
from backend._version import PINSCOPE_VERSION
try:
proj_svc.transition_status(
storage, owner_user_id, project_id,
from_status=_START_OK_FROM,
to_status=proj_svc.STATUS_QUEUED,
cancel_requested=False,
execution_name=None,
pinscope_version=PINSCOPE_VERSION,
)
except proj_svc.StatusConflict:
raise HTTPException(409, "Pipeline already running or queued")
try:
execution_name = job_runner.enqueue_pipeline(
project_id, owner_user_id, resume=False, free=False,
)
except Exception:
logger.exception("enqueue_pipeline failed for %s", project_id)
# Roll the meta back so the user can retry.
proj_svc.update_project(
storage, owner_user_id, project_id,
status=proj_svc.STATUS_ERROR,
pipeline_state={"error": "Failed to enqueue worker"},
)
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
proj_svc.update_project(
storage, owner_user_id, project_id, execution_name=execution_name,
)
return {"status": "started", "project_id": project_id}
@router.post("/pipeline/{project_id}/cancel")
async def cancel(project_id: str, request: Request):
"""Soft-cancel: set ``cancel_requested`` so the worker exits cleanly.
The worker re-reads this flag inside ``_charge_for_logs`` after every
Claude API call (throttled). Cancellation latency is bounded by the
in-flight call's duration, typ 160s.
"""
storage = get_storage(request)
owner_user_id, 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_user_id, project_id)
return {"status": "cancel_requested", "project_id": project_id}
@router.post("/pipeline/{project_id}/estimate")
async def estimate(project_id: str, request: Request):
"""Pre-flight cost estimate — read-only, no side effects."""
from backend.services.cost_estimator import estimate_pipeline_cost
storage = get_storage(request)
owner_user_id, 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_user_id, 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):
"""Resume a pipeline that was paused for insufficient credits."""
storage = get_storage(request)
owner_user_id, 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.",
)
if not meta.has_bom or not meta.has_netlist:
raise HTTPException(400, "Project is missing BOM or netlist")
try:
proj_svc.transition_status(
storage, owner_user_id, project_id,
from_status=proj_svc.STATUS_PAUSED,
to_status=proj_svc.STATUS_QUEUED,
cancel_requested=False,
)
except proj_svc.StatusConflict:
raise HTTPException(409, "Project state changed; refresh and retry")
try:
execution_name = job_runner.enqueue_pipeline(
project_id, owner_user_id, resume=True, free=False,
)
except Exception:
logger.exception("enqueue_pipeline (resume) failed for %s", project_id)
proj_svc.update_project(
storage, owner_user_id, project_id,
status=proj_svc.STATUS_ERROR,
pipeline_state={"error": "Failed to enqueue worker"},
)
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
proj_svc.update_project(
storage, owner_user_id, project_id, execution_name=execution_name,
)
return {"status": "resumed", "project_id": project_id}
@router.post("/pipeline/{project_id}/restart", status_code=202)
async def restart(project_id: str, request: Request):
"""Admin-only: wipe per-project extractions and run the pipeline free."""
from backend.routers.admin import _require_admin
await _require_admin(request)
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not meta.has_bom or not meta.has_netlist:
raise HTTPException(400, "Upload BOM and netlist before starting pipeline")
# If something is currently running/queued, request cancel and wait
# briefly for the worker to honour it (or exit on its own). Hard-kill
# the execution as a last resort.
if _project_active(meta):
proj_svc.request_cancel(storage, owner_user_id, project_id)
await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0)
# If still active, hard-kill via Cloud Run cancel.
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
if _project_active(meta) and meta.execution_name:
job_runner.cancel_execution(meta.execution_name)
await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0)
proj_svc.clear_project_extractions(storage, owner_user_id, project_id)
# After clear_project_extractions the project is left in whatever
# status it was; the transition below enforces queued.
try:
proj_svc.transition_status(
storage, owner_user_id, project_id,
from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED},
to_status=proj_svc.STATUS_QUEUED,
cancel_requested=False,
execution_name=None,
)
except proj_svc.StatusConflict:
raise HTTPException(409, "Pipeline is busy; cancel first then retry")
try:
execution_name = job_runner.enqueue_pipeline(
project_id, owner_user_id, resume=False, free=True,
)
except Exception:
logger.exception("enqueue_pipeline (restart) failed for %s", project_id)
proj_svc.update_project(
storage, owner_user_id, project_id,
status=proj_svc.STATUS_ERROR,
pipeline_state={"error": "Failed to enqueue worker"},
)
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
proj_svc.update_project(
storage, owner_user_id, project_id, execution_name=execution_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):
"""Rebuild graph and regenerate only the requested stages."""
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not meta.has_bom or not meta.has_netlist:
raise HTTPException(400, "Upload BOM and netlist before running regen")
invalid = set(req.stages) - VALID_REGEN_STAGES
if invalid:
raise HTTPException(400, f"Invalid regen stages: {sorted(invalid)}. Valid: {sorted(VALID_REGEN_STAGES)}")
if not req.stages:
raise HTTPException(400, "At least one stage is required")
if _project_active(meta):
proj_svc.request_cancel(storage, owner_user_id, project_id)
await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0)
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
if _project_active(meta) and meta.execution_name:
job_runner.cancel_execution(meta.execution_name)
await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0)
try:
proj_svc.transition_status(
storage, owner_user_id, project_id,
from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED},
to_status=proj_svc.STATUS_QUEUED,
cancel_requested=False,
execution_name=None,
)
except proj_svc.StatusConflict:
raise HTTPException(409, "Pipeline is busy; cancel first then retry")
try:
execution_name = job_runner.enqueue_pipeline_regen(
project_id, owner_user_id, stages=req.stages,
)
except Exception:
logger.exception("enqueue_pipeline_regen failed for %s", project_id)
proj_svc.update_project(
storage, owner_user_id, project_id,
status=proj_svc.STATUS_ERROR,
pipeline_state={"error": "Failed to enqueue worker"},
)
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
proj_svc.update_project(
storage, owner_user_id, project_id, execution_name=execution_name,
)
return {"status": "regen_started", "project_id": project_id, "stages": req.stages}
# ---------------------------------------------------------------------------
# SSE events
# ---------------------------------------------------------------------------
_EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
@router.get("/pipeline/{project_id}/events")
async def events(project_id: str, request: Request):
"""SSE stream of pipeline progress events.
Tails the GCS-backed event log written by the worker. Stops on
terminal events as today, but also has two hard-crash escape
hatches: the project's status reaching a terminal value, and the
Cloud Run execution reaching a terminal state. Either of those
triggers a synthetic ``pipeline_error`` so the SSE doesn't hang
forever when the worker dies without writing its terminal event.
"""
owner_user_id, meta = await resolve_or_404(request, project_id)
storage = get_storage(request)
async def event_generator():
execution_name = meta.execution_name
# Drive the GCS tail and the escape-hatch poll concurrently. The
# tail yields events; the escape hatch flips a flag.
crash_detected: dict[str, str | None] = {"reason": None}
async def watch_status() -> None:
poll_interval = 2.0
while True:
await asyncio.sleep(poll_interval)
try:
cur = proj_svc.get_project(storage, owner_user_id, project_id)
except Exception:
continue
if cur is None:
continue
if cur.status in proj_svc.TERMINAL_STATUSES:
crash_detected["reason"] = (
f"project status={cur.status} (terminal)"
)
return
# Cloud Run hard-crash detection
if execution_name:
try:
state = job_runner.get_execution_state(execution_name)
except Exception:
state = "unknown"
if state in _EXEC_TERMINAL:
crash_detected["reason"] = (
f"execution state={state}"
)
return
watcher = asyncio.create_task(watch_status())
try:
async for msg in event_bridge.tail_events(
storage, owner_user_id, project_id,
):
if crash_detected["reason"] is not None:
break
yield {
"event": msg["event"],
"data": json.dumps(msg.get("data", {})),
}
if msg["event"] in event_bridge.TERMINAL_EVENTS:
return
# tail_events exited without a terminal event — escape hatch
if crash_detected["reason"] is not None:
# Re-read the current meta so the synthetic event has
# the most up-to-date error information.
cur = proj_svc.get_project(storage, owner_user_id, project_id)
err = (
(cur.pipeline_state or {}).get("error")
if cur and cur.pipeline_state
else crash_detected["reason"]
)
yield {
"event": "pipeline_error",
"data": json.dumps({
"error": err or "worker terminated without writing a terminal event",
"synthetic": True,
}),
}
finally:
watcher.cancel()
try:
await watcher
except (asyncio.CancelledError, Exception):
pass
return EventSourceResponse(event_generator())
@router.get("/pipeline/{project_id}/status")
async def status(project_id: str, request: Request):
"""Polling fallback — returns current project state."""
_, meta = await resolve_or_404(request, project_id)
return {
"status": meta.status,
"summary": meta.summary,
"pipeline_state": meta.pipeline_state,
"running": meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED),
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _await_terminal(
storage, user_id: str, project_id: str, *, timeout_s: float,
) -> None:
"""Poll project status until it reaches a terminal state or the timeout
elapses. Used by /restart and /regen between cancel and re-enqueue.
"""
poll = 0.5
elapsed = 0.0
while elapsed < timeout_s:
try:
meta = proj_svc.get_project(storage, user_id, project_id)
except Exception:
meta = None
if meta is None:
return
if meta.status in proj_svc.TERMINAL_STATUSES:
return
await asyncio.sleep(poll)
elapsed += poll
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
"""Report, graph, datasheet, and API log serving endpoints."""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from backend.pinscopex.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
router = APIRouter(tags=["reports"])
# Allow alphanumeric, dash, underscore, dot, colon, forward-slash, plus, hash, space
_SAFE_MPN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.:/ +#,()]*$")
def _validate_mpn(mpn: str) -> None:
"""Reject MPN values that could cause path traversal."""
if not _SAFE_MPN.match(mpn) or ".." in mpn:
raise HTTPException(400, "Invalid MPN format")
@router.get("/report/{project_id}")
async def get_report(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json"
if not storage.exists(key):
raise HTTPException(404, "Report 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_user_id, _ = await resolve_or_404(request, project_id)
user_id = get_user_id(request)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json"
if not storage.exists(key):
raise HTTPException(404, "Report not found")
report_data = 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(),
}
comments = report_data.setdefault("comments", {})
comments.setdefault(body.finding_id, []).append(comment)
storage.write_json(key, report_data)
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_user_id, meta = await resolve_or_404(request, project_id)
user_id = get_user_id(request)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json"
if not storage.exists(key):
raise HTTPException(404, "Report not found")
report_data = storage.read_json(key)
comments = report_data.get("comments", {})
for finding_id, comment_list in comments.items():
for i, c in enumerate(comment_list):
if c["comment_id"] == comment_id:
if c["user_id"] != user_id and user_id != owner_user_id:
raise HTTPException(403, "Cannot delete another user's comment")
comment_list.pop(i)
if not comment_list:
del comments[finding_id]
storage.write_json(key, report_data)
return JSONResponse({"ok": True})
raise HTTPException(404, "Comment not found")
@router.get("/bom/{project_id}")
async def get_bom_summary(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/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_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/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_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/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):
"""Return API call logs for a project pipeline run."""
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/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()]
return JSONResponse(entries)
def _find_datasheet_key(
storage, owner_user_id: str, project_id: str, safe: str,
mpn: str | None = None,
) -> str | None:
"""Return the storage key for a datasheet PDF, or None."""
from backend.services.datasheet_store import resolve_datasheet
# 1. Project uploads
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/uploads/datasheets/{safe}.pdf"
if storage.exists(key):
return key
# 2. Content-addressed ref lookup
resolved = resolve_datasheet(storage, safe)
if resolved:
return resolved
# 3. Legacy flat file fallback (remove after migration confirmed)
key = f"library/datasheets/{safe}.pdf"
if storage.exists(key):
return key
# 4. Pattern-based fallback (passives with shared datasheets)
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):
"""Return a URL for accessing a datasheet PDF.
Returns a backend proxy URL that streams the PDF through Cloud Run.
This avoids GCS signed-URL issues (IAM signBlob scope problems) and
works identically for local and cloud storage.
"""
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
_validate_mpn(mpn)
safe = safe_mpn(mpn)
key = _find_datasheet_key(storage, owner_user_id, project_id, safe, mpn=mpn)
if key is None:
raise HTTPException(404, f"Datasheet not found for MPN: {mpn}")
# Return a proxy URL that points back to this backend
proxy_path = f"/api/projects/{project_id}/datasheet/{mpn}"
base = str(request.base_url).rstrip("/")
return {"url": f"{base}{proxy_path}"}
@router.get("/projects/{project_id}/datasheet/{mpn:path}")
async def get_datasheet_proxy(project_id: str, mpn: str, request: Request):
"""Stream a datasheet PDF from storage (GCS or local).
This is the proxy endpoint returned by get_datasheet_url.
"""
from fastapi.responses import Response
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
_validate_mpn(mpn)
safe = safe_mpn(mpn)
key = _find_datasheet_key(storage, owner_user_id, project_id, safe, mpn=mpn)
if key is None:
raise HTTPException(404, f"Datasheet not found for MPN: {mpn}")
data = storage.read_bytes(key)
return Response(
content=data,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{safe}.pdf"'},
)
@router.get("/datasheets/{mpn}")
async def get_datasheet(mpn: str, request: Request):
"""Serve a datasheet PDF (legacy local-dev endpoint)."""
from fastapi.responses import FileResponse
from backend.services.storage import LocalStorageBackend
storage = get_storage(request)
user_id = get_user_id(request)
_validate_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",
)
user_prefix = f"users/{user_id}/projects/"
for entry in storage.list_prefix(user_prefix):
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}")
+69
View File
@@ -0,0 +1,69 @@
"""Onboarding survey endpoints."""
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):
storage = get_storage(request)
user_id = get_user_id(request)
return {"completed": survey_svc.is_completed(storage, user_id)}
@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"}
# Resolve user email/name from Clerk if available
email = "unknown"
name = "unknown"
if settings.use_auth:
try:
from backend.services.email import _resolve_clerk_user
clerk_user = await _resolve_clerk_user(user_id)
if clerk_user:
emails = clerk_user.get("email_addresses", [])
email = emails[0].get("email_address", "unknown") if emails else "unknown"
first = clerk_user.get("first_name") or ""
last = clerk_user.get("last_name") or ""
name = f"{first} {last}".strip() or "unknown"
except Exception:
logger.warning("Failed to resolve Clerk user %s for survey", 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}
# Sheet write failed — don't mark completed so the user can retry
return {"ok": False, "detail": "sheet_write_failed"}