Write project.json to the storage owner prefix.
update_project used meta.user_id for the path, so JWT projects that still have user_id=local never persisted pcb_status=queued and the PCB worker exited as draft. Point writes at the prefix used to read the project.
This commit is contained in:
@@ -201,10 +201,24 @@ def _read_meta_with_generation(
|
|||||||
return ProjectMeta.model_validate(data), gen
|
return ProjectMeta.model_validate(data), gen
|
||||||
|
|
||||||
|
|
||||||
def _write_meta(storage: StorageBackend, meta: ProjectMeta) -> None:
|
def _write_meta(
|
||||||
|
storage: StorageBackend,
|
||||||
|
meta: ProjectMeta,
|
||||||
|
*,
|
||||||
|
owner_user_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist meta under ``users/{owner}/projects/{id}/``.
|
||||||
|
|
||||||
|
``meta.user_id`` can lag the storage prefix (local-auth accounts that
|
||||||
|
still have ``user_id: "local"`` in JSON while files live under
|
||||||
|
``users/usr_…/``). Writes must follow the prefix used to *read* the
|
||||||
|
project, not the stale field — otherwise ``update_project(pcb_status=…)``
|
||||||
|
lands in a different tree and the PCB worker still sees ``draft``.
|
||||||
|
"""
|
||||||
|
uid = owner_user_id or meta.user_id
|
||||||
meta.updated = datetime.now(timezone.utc).isoformat()
|
meta.updated = datetime.now(timezone.utc).isoformat()
|
||||||
storage.write_json(
|
storage.write_json(
|
||||||
_meta_key(meta.user_id, meta.id),
|
_meta_key(uid, meta.id),
|
||||||
meta.model_dump(),
|
meta.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -516,7 +530,7 @@ def update_project(
|
|||||||
meta = _read_meta(storage, user_id, project_id)
|
meta = _read_meta(storage, user_id, project_id)
|
||||||
for k, v in fields.items():
|
for k, v in fields.items():
|
||||||
setattr(meta, k, v)
|
setattr(meta, k, v)
|
||||||
_write_meta(storage, meta)
|
_write_meta(storage, meta, owner_user_id=user_id)
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
|
||||||
@@ -691,7 +705,7 @@ def add_collaborator(
|
|||||||
meta = _read_meta(storage, owner_user_id, project_id)
|
meta = _read_meta(storage, owner_user_id, project_id)
|
||||||
if collaborator_user_id not in meta.collaborators:
|
if collaborator_user_id not in meta.collaborators:
|
||||||
meta.collaborators.append(collaborator_user_id)
|
meta.collaborators.append(collaborator_user_id)
|
||||||
_write_meta(storage, meta)
|
_write_meta(storage, meta, owner_user_id=owner_user_id)
|
||||||
# Write reverse reference for the collaborator
|
# Write reverse reference for the collaborator
|
||||||
ref_key = _shared_ref_key(collaborator_user_id, project_id)
|
ref_key = _shared_ref_key(collaborator_user_id, project_id)
|
||||||
storage.write_json(ref_key, {"owner_user_id": owner_user_id})
|
storage.write_json(ref_key, {"owner_user_id": owner_user_id})
|
||||||
@@ -704,7 +718,7 @@ def remove_collaborator(
|
|||||||
"""Remove a collaborator from a project and delete the shared reference."""
|
"""Remove a collaborator from a project and delete the shared reference."""
|
||||||
meta = _read_meta(storage, owner_user_id, project_id)
|
meta = _read_meta(storage, owner_user_id, project_id)
|
||||||
meta.collaborators = [c for c in meta.collaborators if c != collaborator_user_id]
|
meta.collaborators = [c for c in meta.collaborators if c != collaborator_user_id]
|
||||||
_write_meta(storage, meta)
|
_write_meta(storage, meta, owner_user_id=owner_user_id)
|
||||||
# Delete reverse reference
|
# Delete reverse reference
|
||||||
ref_key = _shared_ref_key(collaborator_user_id, project_id)
|
ref_key = _shared_ref_key(collaborator_user_id, project_id)
|
||||||
if storage.exists(ref_key):
|
if storage.exists(ref_key):
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function PcbReviewPage({
|
|||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const [projectName, setProjectName] = useState("");
|
const [projectName, setProjectName] = useState("");
|
||||||
const [pcbStatus, setPcbStatus] = useState<string>("draft");
|
const [pcbStatus, setPcbStatus] = useState<string>("draft");
|
||||||
|
const [analysisBusy, setAnalysisBusy] = useState(false);
|
||||||
const [inventory, setInventory] = useState<{
|
const [inventory, setInventory] = useState<{
|
||||||
nets: Array<Record<string, unknown>>;
|
nets: Array<Record<string, unknown>>;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
@@ -49,6 +50,7 @@ export default function PcbReviewPage({
|
|||||||
.then((p) => {
|
.then((p) => {
|
||||||
setProjectName(p.name);
|
setProjectName(p.name);
|
||||||
setPcbStatus(p.pcbStatus ?? "draft");
|
setPcbStatus(p.pcbStatus ?? "draft");
|
||||||
|
setAnalysisBusy(p.status === "running" || p.status === "queued");
|
||||||
setStatusLoaded(true);
|
setStatusLoaded(true);
|
||||||
})
|
})
|
||||||
.catch(() => setStatusLoaded(true));
|
.catch(() => setStatusLoaded(true));
|
||||||
@@ -198,8 +200,19 @@ export default function PcbReviewPage({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{statusLoaded && pcbStatus === "draft" && analysisBusy && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Schematic review is still running. Wait for it to finish (or cancel
|
||||||
|
it), then start PCB review — the two jobs cannot run at the same time.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{statusLoaded && pcbStatus === "draft" && (
|
{statusLoaded && pcbStatus === "draft" && (
|
||||||
<Button size="sm" disabled={starting} onClick={handleStart}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={starting || analysisBusy}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
<CircuitBoard className="h-4 w-4 mr-1" />
|
<CircuitBoard className="h-4 w-4 mr-1" />
|
||||||
{starting ? "Starting…" : "Run PCB review"}
|
{starting ? "Starting…" : "Run PCB review"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -181,3 +181,33 @@ def test_pcb_start_requires_board(tmp_path: Path):
|
|||||||
resp = client.post(f"/api/pipeline/{pid}/pcb/start")
|
resp = client.post(f"/api/pipeline/{pid}/pcb/start")
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "kicad_pcb" in resp.json()["detail"]
|
assert "kicad_pcb" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_project_writes_storage_prefix_not_stale_user_id(tmp_path: Path):
|
||||||
|
"""JWT owner path with JSON still saying user_id=local (live Emmaforo)."""
|
||||||
|
from backend.services.storage import LocalStorageBackend
|
||||||
|
|
||||||
|
storage = LocalStorageBackend(tmp_path)
|
||||||
|
pid = "759a4dea9612"
|
||||||
|
owner = "usr_jwt_owner"
|
||||||
|
storage.write_json(
|
||||||
|
f"users/{owner}/projects/{pid}/project.json",
|
||||||
|
{
|
||||||
|
"id": pid,
|
||||||
|
"name": "Emmaforo",
|
||||||
|
"user_id": "local",
|
||||||
|
"created": "2026-01-01T00:00:00Z",
|
||||||
|
"status": "complete",
|
||||||
|
"has_pcb": True,
|
||||||
|
"has_bom": True,
|
||||||
|
"has_netlist": True,
|
||||||
|
"pcb_status": "draft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
|
||||||
|
proj_svc.update_project(storage, owner, pid, pcb_status="queued")
|
||||||
|
jwt_meta = proj_svc.get_project(storage, owner, pid)
|
||||||
|
assert jwt_meta is not None
|
||||||
|
assert jwt_meta.pcb_status == "queued"
|
||||||
|
assert proj_svc.get_project(storage, "local", pid) is None
|
||||||
|
|||||||
Reference in New Issue
Block a user