"""Project CRUD and file upload endpoints.""" import json from pathlib import Path import httpx from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import JSONResponse, Response from pydantic import BaseModel MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # 30 MB from backend.config import settings 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=["projects"]) def _bom_file_to_csv_bytes(path: Path) -> bytes | None: """Normalize a BOM found inside a KiCad zip/folder to CSV bytes.""" suffix = path.suffix.lower() raw = path.read_bytes() if suffix == ".csv": return raw if suffix == ".xlsx": try: import csv as csv_mod import io import openpyxl wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True) ws = wb.active out = io.StringIO() writer = csv_mod.writer(out) for row in ws.iter_rows(values_only=True): writer.writerow([("" if c is None else str(c)) for c in row]) wb.close() return out.getvalue().encode("utf-8") except Exception: return None return None # --- Library check --- class LibraryCheckRequest(BaseModel): ic_mpns: list[str] = [] passive_mpns: list[str] = [] simple_mpns: list[str] = [] @router.post("/library/check") async def check_library(req: LibraryCheckRequest, request: Request): """Check which MPNs are already resolved in the global library.""" storage = get_storage(request) ic_resolved = [mpn for mpn in req.ic_mpns if proj_svc.library_has_extraction(storage, mpn)] patterns = proj_svc.load_library_patterns(storage) if req.passive_mpns else [] passive_resolved: list[str] = [] if req.passive_mpns: from backend.pinscopex.resolve_passives import resolve_mpn passive_resolved = [ mpn for mpn in req.passive_mpns if resolve_mpn(mpn, patterns) is not None or proj_svc.library_has_passive_model(storage, mpn) is not None ] simple_resolved = [mpn for mpn in req.simple_mpns if proj_svc.library_has_model(storage, mpn)] # Check which MPNs already have datasheets in the library all_mpns = set(req.ic_mpns + req.passive_mpns + req.simple_mpns) datasheets_available = [ mpn for mpn in all_mpns if proj_svc.library_has_datasheet(storage, mpn, patterns=patterns) ] return { "ic_resolved": ic_resolved, "passive_resolved": passive_resolved, "simple_resolved": simple_resolved, "datasheets_available": datasheets_available, } @router.get("/library") async def get_library(request: Request): """List chips, passives, discrete specs, and datasheets in the shared library.""" 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): """Stream a datasheet PDF from the shared library.""" storage = get_storage(request) key = proj_svc.library_has_datasheet(storage, mpn) if not key: raise HTTPException(404, f"Datasheet not in library: {mpn}") data = storage.read_bytes(key) return Response( content=data, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'}, ) class CreateProjectRequest(BaseModel): name: str # --- CRUD --- @router.post("/projects") async def create_project(req: CreateProjectRequest, request: Request): """Create a new project. No per-user project cap — credits are the rate limiter.""" storage = get_storage(request) user_id = get_user_id(request) meta = proj_svc.create_project(storage, user_id, req.name) return meta.model_dump() @router.get("/projects") async def list_projects(request: Request): storage = get_storage(request) user_id = get_user_id(request) owned = proj_svc.list_projects(storage, user_id) shared = proj_svc.list_shared_projects(storage, user_id) 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_user_id, meta = await resolve_or_404(request, project_id) healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id) return (healed or meta).model_dump() @router.delete("/projects/{project_id}") async def delete_project(project_id: str, request: Request): storage = get_storage(request) user_id = get_user_id(request) # If user is the owner, delete the project if proj_svc.delete_project(storage, user_id, project_id): return {"ok": True} # If user is a collaborator, remove themselves instead of deleting result = proj_svc.resolve_project_access(storage, user_id, project_id) if result: owner_user_id, _ = result proj_svc.remove_collaborator(storage, owner_user_id, project_id, user_id) return {"ok": True, "removed_self": True} raise HTTPException(404, "Project not found") # --- Reopen (cancelled / error / complete → draft, for rerun) --- class RenameRequest(BaseModel): name: str @router.patch("/projects/{project_id}") async def rename_project(project_id: str, req: RenameRequest, request: Request): """Update a project's display name.""" storage = get_storage(request) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") owner_user_id, _ = result name = req.name.strip() if not name: raise HTTPException(400, "Name must be non-empty") meta = proj_svc.update_project(storage, owner_user_id, project_id, name=name) return meta.model_dump() @router.post("/projects/{project_id}/reopen") async def reopen_project(project_id: str, request: Request): """Flip a finished-state project back to draft so the user can rerun it. Preserves uploads, column mappings, power-source hints, extraction cache, and historical spend. Clears pipeline artifacts (graph, report, etc.) and the pause/review bookkeeping so the next run starts clean. """ storage = get_storage(request) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") owner_user_id, meta = result 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_user_id, project_id) return meta.model_dump() # --- File downloads + datasheet inventory (for rerun prefill) --- @router.get("/projects/{project_id}/files/bom") async def download_bom(project_id: str, request: Request): storage = get_storage(request) owner_user_id, _ = await resolve_or_404(request, project_id) key = proj_svc.get_bom_key(storage, owner_user_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_user_id, _ = await resolve_or_404(request, project_id) key = proj_svc.get_netlist_key(storage, owner_user_id, project_id) if not key: raise HTTPException(404, "Netlist not uploaded") # Reflect the stored extension (.asc for PADS, .edn for EDIF) in the # download filename so the user gets back what they uploaded. ext = key.rsplit(".", 1)[-1] if "." in key.rsplit("/", 1)[-1] else "asc" 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): """Return the sub-design layout of an uploaded EDIF netlist. Re-parses the stored ``.edn`` file. For PADS netlists or single-sub-design EDIF, returns an empty list. Also returns the currently-saved ``selected`` list (None = "include everything") so the wizard can render the picker pre-populated. """ from backend.pinscopex.parsers_edif import list_edif_subdesigns import tempfile, os storage = get_storage(request) owner_user_id, meta = await resolve_or_404(request, project_id) if meta.netlist_format != "edif": return {"sub_designs": [], "selected": None} key = proj_svc.get_netlist_key(storage, owner_user_id, project_id) if not key: return {"sub_designs": [], "selected": None} data = storage.read_bytes(key) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".edn") try: tmp.write(data) tmp.close() subs = list_edif_subdesigns(tmp.name) finally: os.unlink(tmp.name) return {"sub_designs": subs, "selected": meta.netlist_subdesigns} class NetlistSubdesignsUpdate(BaseModel): selected: list[str] | None # null = include every sub-design @router.put("/projects/{project_id}/netlist/subdesigns") async def set_netlist_subdesigns( project_id: str, payload: NetlistSubdesignsUpdate, request: Request, ): """Persist the user's sub-design selection for an EDIF netlist. ``selected = null`` means "include every sub-design" (the default and only meaningful value for PADS / single-sub-design EDIF). Pipeline runs pass this list to the parser to filter instances/nets. """ storage = get_storage(request) user_id = get_user_id(request) result = proj_svc.resolve_project_access(storage, user_id, project_id) if not result: raise HTTPException(404, "Project not found") owner_user_id = result[0] cleaned = [s.strip() for s in (payload.selected or []) if s and s.strip()] meta = proj_svc.update_project( storage, owner_user_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): """List safe-MPN stems for datasheets uploaded to this project. Returned stems are the filename prefix (filename without ``.pdf``). The frontend classifies the BOM to recover MPNs and matches each against these stems via its own safe_mpn() mirror. """ storage = get_storage(request) owner_user_id, _ = await resolve_or_404(request, project_id) stems = proj_svc.list_project_datasheets(storage, owner_user_id, project_id) return {"stems": sorted(stems)} # --- File 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) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") user_id = result[0] # owner_user_id for storage paths data = await file.read() if len(data) > MAX_UPLOAD_BYTES: raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)") # Convert xlsx to CSV if needed filename = file.filename or "" if filename.lower().endswith(".xlsx"): try: import io, openpyxl, csv as csv_mod wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) ws = wb.active out = io.StringIO() writer = csv_mod.writer(out) for row in ws.iter_rows(values_only=True): writer.writerow([("" if c is None else str(c)) for c in row]) wb.close() data = out.getvalue().encode("utf-8") except Exception as e: raise HTTPException(400, f"Invalid Excel file: {e}") # Validate by attempting to parse import os import tempfile from backend.pinscopex.parsers import parse_bom try: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv") tmp.write(data) tmp.close() bom = parse_bom(tmp.name, reference_col=reference_column, mpn_col=mpn_column) os.unlink(tmp.name) except Exception as e: raise HTTPException(400, f"Invalid BOM file: {e}") # If the chosen MPN column is entirely LCSC ids, resolve them all to # real manufacturer part numbers before storing the BOM. Column-level # only: every non-empty cell must match ^C\d+$, or the column is left # alone. Mixed BOMs (some real MPNs, some LCSC ids) are out of scope — # users must pick a single representation per column. The resolved # mapping is also stashed in project metadata so the wizard UI can # surface "C12044 → STM32F103C8T6" to the user. 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, ) force_lcsc = column_is_lcsc lcsc_detected = detect_lcsc_column(data, mpn_column) if force_lcsc or lcsc_detected: data, lcsc_resolved, lcsc_map, lcsc_payloads = await resolve_lcsc_column_bytes( data, mpn_col=mpn_column, ) except Exception: # Resolver failures must not block uploads — pipeline-stage resolver # is still a backstop, and the user can manually upload datasheets. import logging logging.getLogger(__name__).warning("purple-parts BOM resolve failed", exc_info=True) # If the LCSC rewrite ran, reparse the BOM so component classification # below sees the resolved MPNs (and so the count we return matches what # the pipeline will see). if lcsc_resolved: try: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv") tmp.write(data) tmp.close() bom = parse_bom(tmp.name, reference_col=reference_column, mpn_col=mpn_column) os.unlink(tmp.name) except Exception: import logging logging.getLogger(__name__).warning( "Re-parse after LCSC rewrite failed; using pre-rewrite BOM for classification", exc_info=True, ) # Classify components at upload time so the wizard can render the right # per-row resolution UI (ic → datasheet upload, passive → lcsc-resolve, # simple → datasheet upload). Mirrors the bucket logic in # services/pipeline.py:_stage_bom_parse so the field is correct after # either path runs. from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref ic_mpns: list[str] = [] passive_mpns: list[str] = [] simple_mpns: 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 typ = type_for_ref(ref) if typ == "ic": if mpn not in _seen_ic: _seen_ic.add(mpn) ic_mpns.append(mpn) elif typ == "passive": if mpn not in _seen_passive: _seen_passive.add(mpn) passive_mpns.append(mpn) elif typ and typ in SIMPLE_TYPES: if mpn not in _seen_simple: _seen_simple.add(mpn) simple_mpns.append(mpn) # Real-MPN BOMs: enrich passives from the LCSC catalogue by reverse # MPN lookup so the wizard can pre-resolve their specs through the exact # same machinery as the LCSC-column path. We populate the LCSC maps keyed # by the catalogue's LCSC id, so the wizard's mpn→lcsc map lights up the # "Resolving Passive Specs" step and /lcsc/resolve-passive handles them. # Genuine MPN columns only — skip when the column was LCSC ids (handled # above) so we never feed raw LCSC ids into by-mpn. 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: import logging logging.getLogger(__name__).warning( "purple-parts by-mpn passive enrich failed", exc_info=True, ) key = proj_svc.save_bom(storage, user_id, project_id, data) # Store column mappings for the pipeline to use. # Stash the LCSC → MPN map (if any) so the wizard UI can render it. update_kwargs: dict = { "bom_columns": {"reference": reference_column, "mpn": mpn_column}, "component_mpns": { "ic": ic_mpns, "passive": passive_mpns, "simple": simple_mpns, }, } if lcsc_map: update_kwargs["lcsc_to_mpn"] = lcsc_map if lcsc_payloads: update_kwargs["lcsc_payloads"] = lcsc_payloads proj_svc.update_project(storage, user_id, project_id, **update_kwargs) 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), ): storage = get_storage(request) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") user_id = result[0] # owner_user_id for storage paths from backend.pinscopex.netlist_bundle import materialize_netlist_upload from backend.pinscopex.parsers import parse_netlist_any, validate_netlist from backend.pinscopex.parsers_edif import list_edif_subdesigns import tempfile blobs: list[tuple[str, bytes]] = [] seen: set[tuple[str, int]] = set() uploads = list(files or []) if files else ([file] if file is not None else []) rels: list[str] | None = None if paths: try: parsed_paths = json.loads(paths) except json.JSONDecodeError: parsed_paths = None if isinstance(parsed_paths, list) and all(isinstance(x, str) for x in parsed_paths): rels = parsed_paths for i, uf in enumerate(uploads): data = await uf.read() if len(data) > MAX_UPLOAD_BYTES: raise HTTPException( 413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)", ) 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") 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, user_id, project_id, root_bytes, fmt=fmt) if fmt == "kicad_sch": proj_svc.save_companion_sheets( storage, user_id, project_id, parsed.root, parsed.extra_sch, ) sheets = 1 + len(parsed.extra_sch) else: proj_svc.clear_companion_sheets(storage, user_id, project_id) if parsed.pcb is not None: proj_svc.save_pcb(storage, user_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, user_id, project_id, bom_bytes) proj_svc.update_project( storage, user_id, project_id, bom_columns={ "reference": "Reference", "mpn": "Manufacturer Part Number", }, ) bom_saved = True except HTTPException: raise except Exception as e: raise HTTPException(400, f"Netlist failed sanity check: {e}") from e designator_pins: list[dict] = [] if fmt != "pads": designator_pins = _build_designator_pins(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): storage = get_storage(request) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") user_id = result[0] data = await file.read() if len(data) > MAX_UPLOAD_BYTES: raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)") import tempfile, os from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb") try: tmp.write(data) tmp.close() layout = parse_kicad_pcb(tmp.name) except Exception as e: raise HTTPException(400, f"Invalid KiCad PCB: {e}") finally: os.unlink(tmp.name) key = proj_svc.save_pcb(storage, user_id, project_id, data) return { "path": key, "footprints": len(layout.footprints), "nets": len(layout.nets), "segments": len(layout.segments), } def _build_designator_pins( parts: dict[str, str], nets: dict[str, list[tuple[str, str]]], ) -> list[dict]: """Flatten parsed netlist into [{ref, pins:[{number, net_name}]}]. Inverts the net→[(ref, pin)] adjacency from ``parse_netlist_any`` into a per-designator list. Output order matches the PADS browser preview (natural sort on refs and on pin numbers) so the wizard's dropdowns look identical regardless of netlist format. """ from backend.pinscopex.utils import natural_sort_key by_ref: dict[str, dict[str, str]] = {ref: {} for ref in parts} for net_name, pins in nets.items(): for ref, pin in pins: ref_pins = by_ref.setdefault(ref, {}) ref_pins.setdefault(pin, net_name) out: list[dict] = [] for ref in sorted(by_ref, key=natural_sort_key): pin_map = by_ref[ref] sorted_pins = [ {"number": num, "net_name": pin_map[num]} for num in sorted(pin_map, key=natural_sort_key) ] out.append({"ref": ref, "pins": sorted_pins}) return out class DatasheetUploadMeta(BaseModel): mpn: str @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, ): """Upload a datasheet PDF for a specific MPN, optionally saving for additional MPNs.""" storage = get_storage(request) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") user_id = result[0] # owner_user_id for storage paths if not file.filename or not file.filename.lower().endswith(".pdf"): raise HTTPException(400, "File must be a PDF") data = await file.read() if len(data) > MAX_UPLOAD_BYTES: size_mb = len(data) / 1024 / 1024 raise HTTPException( 413, f"{file.filename or mpn} is {size_mb:.1f} MB — exceeds {MAX_UPLOAD_BYTES // 1024 // 1024} MB limit", ) key = proj_svc.save_datasheet(storage, user_id, project_id, mpn, data) # Save same file under additional MPN names (for shared passive datasheets) extra_mpns: list[str] = [] if also_for: for extra_mpn in also_for.split(","): extra_mpn = extra_mpn.strip() if extra_mpn: proj_svc.save_datasheet(storage, user_id, project_id, extra_mpn, data) extra_mpns.append(extra_mpn) return {"path": key, "mpn": mpn, "also_for": extra_mpns} # --- Collaborators --- class AddCollaboratorRequest(BaseModel): email: str @router.get("/projects/{project_id}/collaborators") async def list_collaborators(project_id: str, request: Request): """List collaborators for a project. Accessible by owner and collaborators.""" storage = get_storage(request) user_id = get_user_id(request) result = proj_svc.resolve_project_access(storage, user_id, project_id) if not result: raise HTTPException(404, "Project not found") owner_user_id, meta = result # Build member list: owner first, then collaborators all_user_ids = [owner_user_id] + [c for c in meta.collaborators if c != owner_user_id] collaborators = [] if settings.use_auth: from backend.services.user_directory import get_user_profile for uid in all_user_ids: profile = await get_user_profile(uid) collaborators.append({ "user_id": uid, "name": profile.get("name"), "email": profile.get("email"), "image_url": profile.get("image_url"), "role": "owner" if uid == owner_user_id else "collaborator", }) else: # Local dev — just return user_ids without enrichment collaborators = [ {"user_id": uid, "name": None, "email": None, "image_url": None, "role": "owner" if uid == owner_user_id else "collaborator"} for uid in all_user_ids ] return {"owner_user_id": owner_user_id, "collaborators": collaborators} @router.post("/projects/{project_id}/collaborators") async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request: Request): """Add a collaborator by email. Owner only.""" storage = get_storage(request) user_id = get_user_id(request) # Only the owner can add collaborators meta = proj_svc.get_project(storage, user_id, 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_user_id = await find_user_id_by_email(req.email) if not collab_user_id: raise HTTPException(404, "No user found with that email") # Can't add yourself if collab_user_id == user_id: raise HTTPException(400, "Cannot add yourself as a collaborator") # Check if already a collaborator if collab_user_id in meta.collaborators: raise HTTPException(409, "User is already a collaborator") proj_svc.add_collaborator(storage, user_id, project_id, collab_user_id) profile = await get_user_profile(collab_user_id) return { "user_id": collab_user_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): """Remove a collaborator. Owner or admin.""" from backend.routers.admin import is_admin storage = get_storage(request) user_id = get_user_id(request) meta = proj_svc.get_project(storage, user_id, project_id) owner_user_id = user_id if not meta: # Admin can remove a collaborator from a project they don't own. if not await is_admin(request): raise HTTPException(404, "Project not found") result = proj_svc.find_project_any_user(storage, project_id) if not result: raise HTTPException(404, "Project not found") owner_user_id, meta = result if collaborator_user_id not in meta.collaborators: raise HTTPException(404, "User is not a collaborator") proj_svc.remove_collaborator(storage, owner_user_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, ): """Promote a collaborator to owner. Admin only. Used when Sid creates a project on behalf of another user and needs to hand it off cleanly. The current owner is demoted to a collaborator; Sid (or any admin) can then remove themselves via DELETE in a second action. """ from backend.routers.admin import is_admin if not await is_admin(request): raise HTTPException(403, "Admin access required") storage = get_storage(request) result = proj_svc.find_project_any_user(storage, project_id) if not result: raise HTTPException(404, "Project not found") current_owner_user_id, _ = result try: proj_svc.transfer_ownership( storage, current_owner_user_id, 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 auto-fetch --- @router.get("/digikey/datasheet") @router.get("/datasheets/fetch") async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = None): """Fetch a datasheet PDF for the given MPN. Tries LCSC (no API key), manufacturer PDF URLs, optional Mouser, then DigiKey if configured. ``/api/digikey/datasheet`` is kept as an alias. """ 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) # --- DigiKey auto-resolve --- class AutoResolveItem(BaseModel): mpn: str component_type: str # "discrete", "connector", "crystal", etc. class AutoResolveRequest(BaseModel): items: list[AutoResolveItem] @router.post("/digikey/auto-resolve") async def auto_resolve(req: AutoResolveRequest, request: Request): """Auto-resolve simple component specs via DigiKey params + Haiku mapping. Fetches structured parameters from DigiKey for each MPN, maps them to taxonomy specs using a lightweight Claude model, and saves results to the shared library. Batches up to 10 DigiKey calls in parallel. """ import asyncio from backend.services.digikey import fetch_params from backend.services.extraction import CatalogResolveMiss, auto_resolve_specs 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: # Skip if already in library safe = safe_mpn(item.mpn) if item.component_type == "passive": lib_key = f"library/passives/{safe}.json" # Also check legacy location for pre-migration data if not storage.exists(lib_key): legacy_key = f"library/models/{safe}.json" if storage.exists(legacy_key): 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"} # Fetch params from DigiKey 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"} # Map params: catalog parse first, LLM only if needed. 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 e: return {"mpn": item.mpn, "status": "failed", "error": str(e)} # Save to library storage.write_json(lib_key, model.model_dump()) return {"mpn": item.mpn, "status": "resolved"} except Exception as e: import logging logging.getLogger(__name__).warning( "Auto-resolve failed for %s: %s", item.mpn, e, exc_info=True, ) msg = str(e) or type(e).__name__ return {"mpn": item.mpn, "status": "failed", "error": msg} results = await asyncio.gather(*(resolve_one(item) for item in req.items)) return {"results": results} # --- LCSC per-row passive resolve (wizard-driven) --- class LcscResolvePassiveRequest(BaseModel): lcsc_id: str @router.post("/projects/{project_id}/lcsc/resolve-passive") async def lcsc_resolve_passive( project_id: str, req: LcscResolvePassiveRequest, request: Request, ): """Resolve a single passive component to specs using its cached LCSC payload. Called by the wizard frontend per-row. The LCSC payload (mpn, manufacturer, package, description, category, subcategory) was cached on the project at BOM upload time. We synthesize a DigiKey-shaped payload from it and reuse ``auto_resolve_specs`` — the same path the pipeline takes during the passive extraction stage. Returns ``{mpn, safe_mpn, model, cached, lcsc_id}``. Errors: - 404 if ``lcsc_id`` is not in the project's ``lcsc_payloads`` cache - 402 with ``{reason, required, available}`` on insufficient credits - 502 on extraction failure (with the underlying error) """ import tempfile from pathlib import Path from backend.services.api_logs import ApiLogger from backend.services.billing_hook import InsufficientCredits, get_billing from backend.services.extraction import auto_resolve_specs storage = get_storage(request) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") owner_user_id, meta = result payloads = meta.lcsc_payloads or {} payload = payloads.get(req.lcsc_id) if not payload: raise HTTPException(404, f"No cached payload for LCSC id {req.lcsc_id!r}") mpn = (payload.get("mpn") or "").strip() if not mpn: raise HTTPException(404, f"Cached payload for {req.lcsc_id!r} has no MPN") safe = safe_mpn(mpn) project_model_key = ( f"{proj_svc.project_prefix(owner_user_id, project_id)}/models/{safe}.json" ) # Short-circuit: if the per-project model file already exists, return it # without re-charging. The pipeline's passive stage already short-circuits # the same file, so re-running the pipeline after this won't double-charge. if storage.exists(project_model_key): model_data = storage.read_json(project_model_key) return { "mpn": mpn, "safe_mpn": safe, "model": model_data, "cached": True, "lcsc_id": req.lcsc_id, } # Library hit: copy into project storage and return without charging. lib_key = proj_svc.library_has_passive_model(storage, mpn) if lib_key: storage.copy_object(lib_key, project_model_key) model_data = storage.read_json(project_model_key) return { "mpn": mpn, "safe_mpn": safe, "model": model_data, "cached": True, "lcsc_id": req.lcsc_id, } # No cache. Synthesize a DigiKey-shaped payload and call auto_resolve_specs. # Same shape used by the pipeline's LCSC-first branch in # services/pipeline.py:_stage_passive_extraction. 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 {req.lcsc_id!r} has no description — " "cannot auto-resolve", ) from backend.services.passive_from_distributor import specs_from_lcsc_payload 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 { "mpn": mpn, "safe_mpn": safe, "model": catalog_model.model_dump(), "cached": False, "lcsc_id": req.lcsc_id, } # Catalog miss (ferrite, odd text): LLM path, charged if the logger has tokens. # Download taxonomy to a temp dir so auto_resolve_specs can read/write it. # Mirrors the PipelineWorkspace pattern: pinscopex operates on local paths. api_logger = ApiLogger() with tempfile.TemporaryDirectory() as tmpdir: tax_dir = Path(tmpdir) / "taxonomy" tax_dir.mkdir() for key in storage.list_prefix("taxonomy/"): if key.endswith(".json"): filename = key.rsplit("/", 1)[-1] storage.download_to_local(key, tax_dir / filename) # Seed from repo taxonomy if storage had no taxonomy files yet if not any(tax_dir.glob("*.json")): repo_tax = settings.taxonomy_dir if repo_tax.is_dir(): import shutil for f in repo_tax.glob("*.json"): shutil.copy2(f, tax_dir / f.name) 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: import logging logging.getLogger(__name__).warning( "lcsc_resolve_passive: auto_resolve_specs failed for %s (lcsc=%s)", mpn, req.lcsc_id, exc_info=True, ) raise HTTPException(502, f"Auto-resolve failed: {exc}") from exc # Charge the caller for the API work. The general-purpose primitive is # billing charge — we don't have a PipelineContext here, so this # skips the pipeline's pause/resume machinery. allow_overdraft=False # gives the caller a clean 402 if their balance is too low. The work has # already been done; on shortage we refuse to persist the resolved model # so the user doesn't get the spec for free, and return 402 so the UI can # prompt for top-up. Top-up + retry will redo the resolve (one API call), # which is cheap. total_credits = sum(float(e.get("credits_charged") or 0) for e in api_logger.entries) insufficient_exc: InsufficientCredits | None = None if total_credits > 0: try: get_billing().charge( storage, owner_user_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 exc: insufficient_exc = exc if insufficient_exc is not None: # Work already done but we refuse to persist when the caller can't # afford it — otherwise we'd give resolved specs away for free. raise HTTPException( 402, detail={ "reason": "insufficient_credits", "required": insufficient_exc.required, "available": insufficient_exc.available, }, ) # Persist to project storage and the shared library (MPN-backed, mirrors # the pipeline LCSC branch). storage.write_json(project_model_key, model.model_dump()) proj_svc.save_to_library( storage, project_model_key, "passives", f"{safe}.json", ) # Append the api logger entries to the project's api_logs.jsonl so # the cost shows up in admin and per-project reporting. Mirrors # ApiLogger.flush but appends instead of overwriting. try: logs_key = ( f"{proj_svc.project_prefix(owner_user_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: import logging logging.getLogger(__name__).warning( "lcsc_resolve_passive: failed to append api_logs.jsonl", exc_info=True, ) # Bump the project's recorded total cost so admin/usage reflects this work. 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_user_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: import logging logging.getLogger(__name__).warning( "lcsc_resolve_passive: failed to update total_cost_usd", exc_info=True, ) return { "mpn": mpn, "safe_mpn": safe, "model": model.model_dump(), "cached": False, "lcsc_id": req.lcsc_id, }