Stamp 2.60.0 incomplete PCB exam and owner project delete.
Partial layouts still use MODE=pcb. Missing copper is INSUFFICIENT or
skipped, never Euclidean stand-in tracks. Via≠Pad unchanged. DELETE
/api/projects/{id} is owner-only with a dashboard confirm dialog.
This commit is contained in:
@@ -13,7 +13,7 @@ from backend.periscopex.finding_engine import complete_findings
|
|||||||
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
||||||
from backend.periscopex.hierarchy import check_hierarchy
|
from backend.periscopex.hierarchy import check_hierarchy
|
||||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||||
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
from backend.periscopex.pcb_net_match import check_pcb_net_match, kicad_nets_match
|
||||||
from backend.periscopex.pcb_power_thermal import (
|
from backend.periscopex.pcb_power_thermal import (
|
||||||
check_pcb_gnd_stitch,
|
check_pcb_gnd_stitch,
|
||||||
check_pcb_junction_temp,
|
check_pcb_junction_temp,
|
||||||
@@ -173,6 +173,54 @@ def merge_schema_pcb_reports(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _incomplete_layout_finding(layout: LayoutGraph | None) -> list[Finding]:
|
||||||
|
"""INFO when pads exist on nets that have no track copper."""
|
||||||
|
if layout is None or not layout.footprints:
|
||||||
|
return []
|
||||||
|
pad_nets = {
|
||||||
|
p.net
|
||||||
|
for fp in layout.footprints.values()
|
||||||
|
for p in fp.pads
|
||||||
|
if p.net
|
||||||
|
}
|
||||||
|
if not pad_nets:
|
||||||
|
return []
|
||||||
|
seg_nets = {s.net for s in layout.segments if s.net}
|
||||||
|
missing = sorted(
|
||||||
|
n for n in pad_nets
|
||||||
|
if not any(kicad_nets_match(n, s) for s in seg_nets)
|
||||||
|
)
|
||||||
|
if not missing:
|
||||||
|
return []
|
||||||
|
rec = (
|
||||||
|
"Finish routing (and placement) then re-run PCB review. "
|
||||||
|
"Checks that need copper skip or mark INSUFFICIENT; they do not invent tracks."
|
||||||
|
)
|
||||||
|
sample = ", ".join(missing[:6])
|
||||||
|
extra = f" (+{len(missing) - 6} more)" if len(missing) > 6 else ""
|
||||||
|
return [Finding(
|
||||||
|
designator="PCB",
|
||||||
|
mpn="",
|
||||||
|
aspect="pcb_match",
|
||||||
|
finding=(
|
||||||
|
f"Layout has {len(layout.footprints)} footprint(s) and "
|
||||||
|
f"{len(layout.segments)} track segment(s); {len(missing)} pad net(s) "
|
||||||
|
f"have no copper ({sample}{extra})."
|
||||||
|
),
|
||||||
|
facts=f"unrouted_pad_nets={len(missing)}; segments={len(layout.segments)}.",
|
||||||
|
requirement="Routing-dependent PCB checks need track geometry.",
|
||||||
|
inference="INSUFFICIENT — same MODE=pcb exam, no incomplete-board mode.",
|
||||||
|
why="Unfinished placing/routing is not a fabricated millimetre FAIL.",
|
||||||
|
status="INFO",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source="pcb_checks",
|
||||||
|
rule_id="PE-LAY-004",
|
||||||
|
evidence_status="INSUFFICIENT",
|
||||||
|
pins=[],
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
def run_pcb_checks(
|
def run_pcb_checks(
|
||||||
graph: DesignGraph,
|
graph: DesignGraph,
|
||||||
constraints_map: dict,
|
constraints_map: dict,
|
||||||
@@ -182,6 +230,7 @@ def run_pcb_checks(
|
|||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
"""Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD."""
|
"""Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD."""
|
||||||
out: list[Finding] = []
|
out: list[Finding] = []
|
||||||
|
out.extend(_incomplete_layout_finding(layout))
|
||||||
for name, fn in (
|
for name, fn in (
|
||||||
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
|
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
|
||||||
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
|
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ def _pin_number(cons: ComponentConstraints, token: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _dist(a: LayoutPad, b: LayoutPad) -> float:
|
|
||||||
return math.hypot(a.x - b.x, a.y - b.y)
|
|
||||||
|
|
||||||
|
|
||||||
def _xy_key(x: float, y: float) -> tuple[float, float]:
|
def _xy_key(x: float, y: float) -> tuple[float, float]:
|
||||||
return (round(x, 3), round(y, 3))
|
return (round(x, 3), round(y, 3))
|
||||||
|
|
||||||
@@ -86,11 +82,13 @@ def _path_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float:
|
def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float | None:
|
||||||
path = _path_mm(layout, net, a, b)
|
"""Copper path length, or None when the net is not routed between pads.
|
||||||
if path is None:
|
|
||||||
return _dist(a, b)
|
Do not fall back to Euclidean pad distance — that invents geometry on
|
||||||
return path
|
an unfinished board.
|
||||||
|
"""
|
||||||
|
return _path_mm(layout, net, a, b)
|
||||||
|
|
||||||
|
|
||||||
def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None:
|
def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None:
|
||||||
@@ -151,11 +149,41 @@ def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou
|
|||||||
cap_pads.append(pad)
|
cap_pads.append(pad)
|
||||||
if not cap_pads:
|
if not cap_pads:
|
||||||
return []
|
return []
|
||||||
nearest = min(_reach_mm(layout, net, ic_pad, p) for p in cap_pads)
|
|
||||||
extracted = rule.get("max_distance_mm")
|
extracted = rule.get("max_distance_mm")
|
||||||
if extracted is None:
|
if extracted is None:
|
||||||
return []
|
return []
|
||||||
limit = float(extracted)
|
limit = float(extracted)
|
||||||
|
reaches = [
|
||||||
|
d for d in (_reach_mm(layout, net, ic_pad, p) for p in cap_pads) if d is not None
|
||||||
|
]
|
||||||
|
if not reaches:
|
||||||
|
rec = (
|
||||||
|
f"Route {net} from {ref}.{pin_no} to the decoupling capacitor, "
|
||||||
|
"then re-run PCB review."
|
||||||
|
)
|
||||||
|
return [Finding(
|
||||||
|
designator=ref,
|
||||||
|
mpn=comp.mpn or cons.mpn,
|
||||||
|
aspect="placement",
|
||||||
|
finding=(
|
||||||
|
f"No routed copper on {net} between {ref}.{pin_no} and a "
|
||||||
|
"decoupling pad — proximity not measured."
|
||||||
|
),
|
||||||
|
facts="Track path missing; pad coordinates are not used as a stand-in length.",
|
||||||
|
requirement=f"layout_rules max_distance_mm={limit:g} needs a copper path.",
|
||||||
|
inference="INSUFFICIENT — unfinished routing, not a millimetre FAIL.",
|
||||||
|
why=f"layout_rules max_distance_mm={limit:g}.",
|
||||||
|
status="INFO",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source="placement_check",
|
||||||
|
rule_id="PE-PLC-005",
|
||||||
|
evidence_status="INSUFFICIENT",
|
||||||
|
net=net,
|
||||||
|
pins=[pin_no],
|
||||||
|
source_page=rule.get("source_page"),
|
||||||
|
)]
|
||||||
|
nearest = min(reaches)
|
||||||
if nearest <= limit:
|
if nearest <= limit:
|
||||||
return []
|
return []
|
||||||
return [Finding(
|
return [Finding(
|
||||||
@@ -171,6 +199,7 @@ def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou
|
|||||||
recommendation="Place the decoupling capacitor closer to the supply pin.",
|
recommendation="Place the decoupling capacitor closer to the supply pin.",
|
||||||
source="placement_check",
|
source="placement_check",
|
||||||
rule_id="PE-PLC-001",
|
rule_id="PE-PLC-001",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
net=net,
|
net=net,
|
||||||
pins=[pin_no],
|
pins=[pin_no],
|
||||||
source_page=rule.get("source_page"),
|
source_page=rule.get("source_page"),
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ def net_length_mm(layout: LayoutGraph, net: str) -> float:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _routed_length_mm(layout: LayoutGraph, net: str) -> float | None:
|
||||||
|
n = net_length_mm(layout, net)
|
||||||
|
return n if n > 0 else None
|
||||||
|
|
||||||
|
|
||||||
def partner_net(name: str) -> str | None:
|
def partner_net(name: str) -> str | None:
|
||||||
n = name or ""
|
n = name or ""
|
||||||
for a, b in _PAIR_SUFFIXES:
|
for a, b in _PAIR_SUFFIXES:
|
||||||
@@ -473,7 +478,10 @@ def check_si(
|
|||||||
layout: LayoutGraph | None,
|
layout: LayoutGraph | None,
|
||||||
impedance_nets: list[dict] | dict | None = None,
|
impedance_nets: list[dict] | dict | None = None,
|
||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
if layout is None or not layout.segments:
|
if layout is None:
|
||||||
|
return []
|
||||||
|
# Unrouted boards skip SI millimetre/Z checks (no invented 0 mm lengths).
|
||||||
|
if not layout.segments:
|
||||||
return []
|
return []
|
||||||
raw = impedance_nets
|
raw = impedance_nets
|
||||||
if isinstance(impedance_nets, dict):
|
if isinstance(impedance_nets, dict):
|
||||||
@@ -536,6 +544,8 @@ def check_si(
|
|||||||
if bc:
|
if bc:
|
||||||
z_covered.add(bc)
|
z_covered.add(bc)
|
||||||
if avg is None:
|
if avg is None:
|
||||||
|
if _routed_length_mm(layout, net) is None:
|
||||||
|
continue
|
||||||
findings.append(_si_finding(
|
findings.append(_si_finding(
|
||||||
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
|
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
|
||||||
verdict="FAIL",
|
verdict="FAIL",
|
||||||
@@ -593,9 +603,11 @@ def check_si(
|
|||||||
if key in done:
|
if key in done:
|
||||||
continue
|
continue
|
||||||
done.add(key)
|
done.add(key)
|
||||||
la = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
|
la = _z_field(zrow, "length_mm") or _routed_length_mm(layout, net)
|
||||||
zb = _z_row(rows, partner)
|
zb = _z_row(rows, partner)
|
||||||
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
|
lb = _z_field(zb, "length_mm") or _routed_length_mm(layout, partner)
|
||||||
|
if la is None or lb is None:
|
||||||
|
continue
|
||||||
skew = abs(la - lb)
|
skew = abs(la - lb)
|
||||||
verdict = "PASS" if skew <= lim else "FAIL"
|
verdict = "PASS" if skew <= lim else "FAIL"
|
||||||
rec = (
|
rec = (
|
||||||
@@ -620,7 +632,9 @@ def check_si(
|
|||||||
continue
|
continue
|
||||||
for net in nets:
|
for net in nets:
|
||||||
zrow = _z_row(rows, net)
|
zrow = _z_row(rows, net)
|
||||||
length = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
|
length = _z_field(zrow, "length_mm") or _routed_length_mm(layout, net)
|
||||||
|
if length is None:
|
||||||
|
continue
|
||||||
verdict = "PASS" if length <= lim else "FAIL"
|
verdict = "PASS" if length <= lim else "FAIL"
|
||||||
findings.append(_si_finding(
|
findings.append(_si_finding(
|
||||||
rule_id="PE-SI-003", net=net, mpn=mpn, designator=ic,
|
rule_id="PE-SI-003", net=net, mpn=mpn, designator=ic,
|
||||||
|
|||||||
@@ -289,9 +289,7 @@ async def delete_project(project_id: str, request: Request):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
hit = proj_svc.resolve_project_access(storage, uid, project_id)
|
hit = proj_svc.resolve_project_access(storage, uid, project_id)
|
||||||
if hit:
|
if hit:
|
||||||
owner_id, _ = hit
|
raise HTTPException(403, "Only the project owner can delete this project")
|
||||||
proj_svc.remove_collaborator(storage, owner_id, project_id, uid)
|
|
||||||
return {"ok": True, "removed_self": True}
|
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "Project not found")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 2.60.0 — 2026-09-20 — Incomplete PCB exam and owner project delete
|
||||||
|
|
||||||
|
Unfinished placing/routing still uses MODE=pcb. Parser and checks run on partial layouts; missing copper is INSUFFICIENT or skip, never invented millimetres. Via≠Pad unchanged. Dashboard owners can delete a project after a confirm dialog.
|
||||||
|
|
||||||
|
- [Changed] Decoupling proximity uses routed copper only (`PE-PLC-005` when unrouted).
|
||||||
|
- [New] `PE-LAY-004` INFO when pad nets have no tracks.
|
||||||
|
- [New] Owner-only `DELETE /api/projects/{id}` with confirm dialog; collaborators get 403.
|
||||||
|
|
||||||
## 2.59.0 — 2026-09-20 — Docker without unused PinScope product tree
|
## 2.59.0 — 2026-09-20 — Docker without unused PinScope product tree
|
||||||
|
|
||||||
Images copy `periscope/src` plus leftover seams (shadcn/Clerk/billing, unused LLM providers, GCS, landing png/gif) and `vendor/`. They do not copy the inherited PinScope product tree. `dependency/` stays in git. Audit: `periscope/src/docs/development/DEPENDENCY_AUDIT.md`. Pad≠Via unchanged.
|
Images copy `periscope/src` plus leftover seams (shadcn/Clerk/billing, unused LLM providers, GCS, landing png/gif) and `vendor/`. They do not copy the inherited PinScope product tree. `dependency/` stays in git. Audit: `periscope/src/docs/development/DEPENDENCY_AUDIT.md`. Pad≠Via unchanged.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.59.0",
|
"version": "2.60.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.59.0",
|
"version": "2.60.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@types/dagre": "^0.7.54",
|
"@types/dagre": "^0.7.54",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.59.0",
|
"version": "2.60.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-version": "node scripts/sync-version.mjs",
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { deleteProject } from "@/lib/api";
|
||||||
|
import type { Project } from "@/lib/types";
|
||||||
|
|
||||||
|
export function DeleteProjectButton({
|
||||||
|
project,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
function ask(e: React.MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitDelete(e: React.MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await deleteProject(project.id);
|
||||||
|
setOpen(false);
|
||||||
|
onDeleted?.();
|
||||||
|
} catch {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={ask}
|
||||||
|
disabled={busy}
|
||||||
|
title="Delete project"
|
||||||
|
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete {project.name}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the project files and metadata for you as owner. It
|
||||||
|
does not change other users' projects or login accounts.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={commitDelete}
|
||||||
|
>
|
||||||
|
{busy ? "Deleting…" : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { RotateCcw, Trash2, Users } from "lucide-react";
|
import { RotateCcw, Users } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { DeleteProjectButton } from "@/components/dashboard/delete-project-button";
|
||||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||||
import { useReviewedCount } from "@/hooks/use-reviewed-count";
|
import { useReviewedCount } from "@/hooks/use-reviewed-count";
|
||||||
import { deleteProject } from "@/lib/api";
|
|
||||||
import type { Project } from "@/lib/types";
|
import type { Project } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ export function ProjectCard({
|
|||||||
onRerun?: (project: Project) => void;
|
onRerun?: (project: Project) => void;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useOptionalUser();
|
const { user } = useOptionalUser();
|
||||||
const [busyDelete, setBusyDelete] = useState(false);
|
|
||||||
const reviewed = useReviewedCount(project.id);
|
const reviewed = useReviewedCount(project.id);
|
||||||
const { summary } = project;
|
const { summary } = project;
|
||||||
const findingTotal = summary?.total ?? 0;
|
const findingTotal = summary?.total ?? 0;
|
||||||
@@ -57,19 +55,6 @@ export function ProjectCard({
|
|||||||
project.status === "draft");
|
project.status === "draft");
|
||||||
const clickOpensWizard = draft && !foreign && onRerun != null;
|
const clickOpensWizard = draft && !foreign && onRerun != null;
|
||||||
|
|
||||||
async function remove(e: React.MouseEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!confirm(`Delete "${project.name}"?`)) return;
|
|
||||||
setBusyDelete(true);
|
|
||||||
try {
|
|
||||||
await deleteProject(project.id);
|
|
||||||
onDeleted?.();
|
|
||||||
} catch {
|
|
||||||
setBusyDelete(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replace(e: React.MouseEvent) {
|
function replace(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -116,13 +101,7 @@ export function ProjectCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!foreign && (
|
{!foreign && (
|
||||||
<button
|
<DeleteProjectButton project={project} onDeleted={onDeleted} />
|
||||||
onClick={remove}
|
|
||||||
disabled={busyDelete}
|
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { RotateCcw, Trash2, Users } from "lucide-react";
|
import { RotateCcw, Users } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DeleteProjectButton } from "@/components/dashboard/delete-project-button";
|
||||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||||
import { useReviewedCount } from "@/hooks/use-reviewed-count";
|
import { useReviewedCount } from "@/hooks/use-reviewed-count";
|
||||||
import { deleteProject } from "@/lib/api";
|
|
||||||
import type { Project } from "@/lib/types";
|
import type { Project } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -75,7 +74,6 @@ function TableRow({
|
|||||||
onRerun?: (project: Project) => void;
|
onRerun?: (project: Project) => void;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useOptionalUser();
|
const { user } = useOptionalUser();
|
||||||
const [busyDelete, setBusyDelete] = useState(false);
|
|
||||||
const reviewed = useReviewedCount(project.id);
|
const reviewed = useReviewedCount(project.id);
|
||||||
const { summary } = project;
|
const { summary } = project;
|
||||||
const findingTotal = summary?.total ?? 0;
|
const findingTotal = summary?.total ?? 0;
|
||||||
@@ -92,19 +90,6 @@ function TableRow({
|
|||||||
project.status === "draft");
|
project.status === "draft");
|
||||||
const clickOpensWizard = draft && !foreign && onRerun != null;
|
const clickOpensWizard = draft && !foreign && onRerun != null;
|
||||||
|
|
||||||
async function remove(e: React.MouseEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!confirm(`Delete "${project.name}"?`)) return;
|
|
||||||
setBusyDelete(true);
|
|
||||||
try {
|
|
||||||
await deleteProject(project.id);
|
|
||||||
onDeleted?.();
|
|
||||||
} catch {
|
|
||||||
setBusyDelete(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replace(e: React.MouseEvent) {
|
function replace(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -199,13 +184,7 @@ function TableRow({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!foreign && (
|
{!foreign && (
|
||||||
<button
|
<DeleteProjectButton project={project} onDeleted={onDeleted} />
|
||||||
onClick={remove}
|
|
||||||
disabled={busyDelete}
|
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-500/10 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Partial PCB: footprints with few tracks still parse and run MODE=pcb checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.periscopex.models import (
|
||||||
|
Component,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
)
|
||||||
|
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||||
|
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||||
|
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
||||||
|
from backend.periscopex.placement_check import check_placement
|
||||||
|
|
||||||
|
|
||||||
|
_PARTIAL = """(kicad_pcb (version 20240108) (generator pcbnew)
|
||||||
|
(net 0 "")
|
||||||
|
(net 1 "GND")
|
||||||
|
(net 2 "+3V3")
|
||||||
|
(net 3 "USB_DP")
|
||||||
|
(footprint "Package_SO:SOIC-8"
|
||||||
|
(layer "F.Cu")
|
||||||
|
(at 20 10 0)
|
||||||
|
(property "Reference" "U1" (at 0 0 0) (effects (font (size 1 1))))
|
||||||
|
(pad "1" smd rect (at -2 1) (size 1 0.6) (layers "F.Cu") (net 2 "+3V3"))
|
||||||
|
(pad "2" smd rect (at -2 -1) (size 1 0.6) (layers "F.Cu") (net 1 "GND"))
|
||||||
|
(pad "3" smd rect (at 2 1) (size 1 0.6) (layers "F.Cu") (net 3 "USB_DP"))
|
||||||
|
)
|
||||||
|
(footprint "Capacitor_SMD:C_0603"
|
||||||
|
(layer "F.Cu")
|
||||||
|
(at 40 30 0)
|
||||||
|
(property "Reference" "C1" (at 0 0 0) (effects (font (size 1 1))))
|
||||||
|
(pad "1" smd rect (at -0.5 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "+3V3"))
|
||||||
|
(pad "2" smd rect (at 0.5 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "GND"))
|
||||||
|
)
|
||||||
|
(segment (start 18 11) (end 19 11) (width 0.2) (layer "F.Cu") (net 2))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_footprints_with_few_tracks(tmp_path: Path):
|
||||||
|
p = tmp_path / "partial.kicad_pcb"
|
||||||
|
p.write_text(_PARTIAL)
|
||||||
|
g = parse_kicad_pcb(p)
|
||||||
|
assert "U1" in g.footprints and "C1" in g.footprints
|
||||||
|
assert len(g.segments) == 1
|
||||||
|
assert g.segments[0].net == "+3V3"
|
||||||
|
assert len(g.vias) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _partial_graph() -> DesignGraph:
|
||||||
|
return DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="MCU", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="X",
|
||||||
|
pins={"1": "+3V3", "2": "GND", "3": "USB_DP"},
|
||||||
|
),
|
||||||
|
"C1": Component(
|
||||||
|
reference="C1", value="100n", footprint="",
|
||||||
|
component_type=ComponentType.CAPACITOR, mpn="C",
|
||||||
|
pins={"1": "+3V3", "2": "GND"},
|
||||||
|
),
|
||||||
|
"R9": Component(
|
||||||
|
reference="R9", value="10k", footprint="",
|
||||||
|
component_type=ComponentType.RESISTOR, mpn="R",
|
||||||
|
pins={"1": "NRESET"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
|
||||||
|
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
|
||||||
|
"USB_DP": Net(name="USB_DP", net_type=NetType.SIGNAL, pins=[]),
|
||||||
|
"NRESET": Net(name="NRESET", net_type=NetType.SIGNAL, pins=[]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_pcb_checks_on_partial_layout_does_not_invent_tracks(tmp_path: Path):
|
||||||
|
p = tmp_path / "partial.kicad_pcb"
|
||||||
|
p.write_text(_PARTIAL)
|
||||||
|
layout = parse_kicad_pcb(p)
|
||||||
|
graph = _partial_graph()
|
||||||
|
findings = run_pcb_checks(graph, {}, layout)
|
||||||
|
lay = [f for f in findings if f.rule_id == "PE-LAY-004"]
|
||||||
|
assert lay, findings
|
||||||
|
assert lay[0].evidence_status == "INSUFFICIENT"
|
||||||
|
assert lay[0].status == "INFO"
|
||||||
|
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||||
|
unplaced = [f for f in findings if f.rule_id == "PE-LAY-002"]
|
||||||
|
assert any(f.designator == "R9" for f in unplaced)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_board_via_is_not_a_pad(tmp_path: Path):
|
||||||
|
from tests.test_pcb_via_not_pad import _qfn24_pcb
|
||||||
|
|
||||||
|
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, board_vias=[(80.0, 80.0, "GND", 1)]))
|
||||||
|
u1 = layout.footprints["U1"]
|
||||||
|
assert len(u1.pads) == 24
|
||||||
|
assert len(layout.vias) == 1
|
||||||
|
via = layout.vias[0]
|
||||||
|
assert all(abs(p.x - via.x) > 0.01 or abs(p.y - via.y) > 0.01 for p in u1.pads)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unplaced_ref_is_lay_002_not_invented_xy():
|
||||||
|
graph = _partial_graph()
|
||||||
|
from backend.periscopex.models import LayoutFootprint, LayoutGraph, LayoutPad
|
||||||
|
|
||||||
|
layout = LayoutGraph(
|
||||||
|
footprints={
|
||||||
|
"U1": LayoutFootprint(
|
||||||
|
reference="U1", x=0, y=0, layer="F.Cu",
|
||||||
|
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
findings = check_pcb_net_match(graph, layout)
|
||||||
|
assert any(f.rule_id == "PE-LAY-002" and f.designator == "C1" for f in findings)
|
||||||
|
assert check_placement(graph, {}, layout) == [] or all(
|
||||||
|
f.evidence_status == "INSUFFICIENT" or f.rule_id != "PE-PLC-001"
|
||||||
|
for f in check_placement(graph, {}, layout)
|
||||||
|
)
|
||||||
@@ -173,11 +173,15 @@ def test_missing_footprint_is_pe_lay_002():
|
|||||||
|
|
||||||
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
||||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||||
|
from backend.periscopex.models import LayoutSegment
|
||||||
|
|
||||||
|
segs = [
|
||||||
|
LayoutSegment(start=(0.0, 0.0), end=(10.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||||
|
]
|
||||||
findings = run_pcb_checks(
|
findings = run_pcb_checks(
|
||||||
_graph(),
|
_graph(),
|
||||||
_xtal_cons(max_distance_mm=2.0),
|
_xtal_cons(max_distance_mm=2.0),
|
||||||
_x1_c9_layout(cap_x=10.0),
|
_x1_c9_layout(cap_x=10.0, segments=segs),
|
||||||
)
|
)
|
||||||
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
||||||
assert plc
|
assert plc
|
||||||
@@ -187,11 +191,15 @@ def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
|||||||
|
|
||||||
def test_close_decoupling_has_no_plc_001():
|
def test_close_decoupling_has_no_plc_001():
|
||||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||||
|
from backend.periscopex.models import LayoutSegment
|
||||||
|
|
||||||
|
segs = [
|
||||||
|
LayoutSegment(start=(0.0, 0.0), end=(0.5, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||||
|
]
|
||||||
findings = run_pcb_checks(
|
findings = run_pcb_checks(
|
||||||
_graph(),
|
_graph(),
|
||||||
_xtal_cons(max_distance_mm=2.0),
|
_xtal_cons(max_distance_mm=2.0),
|
||||||
_x1_c9_layout(cap_x=0.5),
|
_x1_c9_layout(cap_x=0.5, segments=segs),
|
||||||
)
|
)
|
||||||
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ def test_legal_pages_are_operator_not_faradworks_controller():
|
|||||||
assert "This Service is **not** operated by Faradworks, Inc." in terms
|
assert "This Service is **not** operated by Faradworks, Inc." in terms
|
||||||
|
|
||||||
|
|
||||||
def test_changelog_stamp_is_2_59_0_and_src_wins():
|
def test_changelog_stamp_is_2_60_0_and_src_wins():
|
||||||
text = (SRC / "changelog.md").read_text(encoding="utf-8")
|
text = (SRC / "changelog.md").read_text(encoding="utf-8")
|
||||||
assert "## 2.59.0 — 2026-09-20" in text
|
assert "## 2.60.0 — 2026-09-20" in text
|
||||||
first = changelog_paths()[0]
|
first = changelog_paths()[0]
|
||||||
assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md"
|
assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md"
|
||||||
assert first == SRC / "changelog.md"
|
assert first == SRC / "changelog.md"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ DOCKERIGNORE = ROOT / ".dockerignore"
|
|||||||
def test_package_json_is_periscope_web():
|
def test_package_json_is_periscope_web():
|
||||||
text = PKG.read_text(encoding="utf-8")
|
text = PKG.read_text(encoding="utf-8")
|
||||||
assert '"name": "periscope-web"' in text
|
assert '"name": "periscope-web"' in text
|
||||||
assert '"version": "2.59.0"' in text
|
assert '"version": "2.60.0"' in text
|
||||||
assert "Native Periscope overlay" not in text[:400]
|
assert "Native Periscope overlay" not in text[:400]
|
||||||
assert LOCK.is_file()
|
assert LOCK.is_file()
|
||||||
lock = LOCK.read_text(encoding="utf-8")
|
lock = LOCK.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Dashboard owner delete uses a confirm dialog, not window.confirm."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BTN = (
|
||||||
|
ROOT
|
||||||
|
/ "periscope"
|
||||||
|
/ "src"
|
||||||
|
/ "frontend"
|
||||||
|
/ "src"
|
||||||
|
/ "components"
|
||||||
|
/ "dashboard"
|
||||||
|
/ "delete-project-button.tsx"
|
||||||
|
)
|
||||||
|
CARD = (
|
||||||
|
ROOT
|
||||||
|
/ "periscope"
|
||||||
|
/ "src"
|
||||||
|
/ "frontend"
|
||||||
|
/ "src"
|
||||||
|
/ "components"
|
||||||
|
/ "dashboard"
|
||||||
|
/ "project-card.tsx"
|
||||||
|
)
|
||||||
|
TABLE = (
|
||||||
|
ROOT
|
||||||
|
/ "periscope"
|
||||||
|
/ "src"
|
||||||
|
/ "frontend"
|
||||||
|
/ "src"
|
||||||
|
/ "components"
|
||||||
|
/ "dashboard"
|
||||||
|
/ "projects-table.tsx"
|
||||||
|
)
|
||||||
|
API = ROOT / "periscope" / "src" / "frontend" / "src" / "lib" / "api.ts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_dialog_is_src_and_owner_only_copy():
|
||||||
|
text = BTN.read_text(encoding="utf-8")
|
||||||
|
assert "Native Periscope overlay" not in text[:400]
|
||||||
|
assert "export function DeleteProjectButton" in text
|
||||||
|
assert "AlertDialog" in text
|
||||||
|
assert "deleteProject" in text
|
||||||
|
assert "window.confirm" not in text
|
||||||
|
assert "confirm(`" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_cards_and_table_use_delete_dialog():
|
||||||
|
card = CARD.read_text(encoding="utf-8")
|
||||||
|
table = TABLE.read_text(encoding="utf-8")
|
||||||
|
assert "DeleteProjectButton" in card
|
||||||
|
assert "DeleteProjectButton" in table
|
||||||
|
assert "confirm(" not in card
|
||||||
|
assert "confirm(" not in table
|
||||||
|
assert "!foreign" in card
|
||||||
|
assert "!foreign" in table
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_delete_is_http_delete():
|
||||||
|
text = API.read_text(encoding="utf-8")
|
||||||
|
assert "export async function deleteProject" in text
|
||||||
|
assert 'method: "DELETE"' in text
|
||||||
@@ -186,10 +186,13 @@ def _x1_c9_layout(*, segments=None, cap_x: float = 0.5):
|
|||||||
|
|
||||||
def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001():
|
def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001():
|
||||||
limit = 2.0
|
limit = 2.0
|
||||||
|
segs = [
|
||||||
|
LayoutSegment(start=(0.0, 0.0), end=(10.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||||
|
]
|
||||||
findings = check_placement(
|
findings = check_placement(
|
||||||
_graph(),
|
_graph(),
|
||||||
_xtal_cons(max_distance_mm=limit),
|
_xtal_cons(max_distance_mm=limit),
|
||||||
_x1_c9_layout(cap_x=10.0),
|
_x1_c9_layout(cap_x=10.0, segments=segs),
|
||||||
)
|
)
|
||||||
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
||||||
assert len(plc) == 1
|
assert len(plc) == 1
|
||||||
@@ -198,13 +201,29 @@ def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001():
|
|||||||
|
|
||||||
|
|
||||||
def test_crystal_load_cap_within_max_distance_mm_is_silent():
|
def test_crystal_load_cap_within_max_distance_mm_is_silent():
|
||||||
|
segs = [
|
||||||
|
LayoutSegment(start=(0.0, 0.0), end=(0.5, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||||
|
]
|
||||||
assert check_placement(
|
assert check_placement(
|
||||||
_graph(),
|
_graph(),
|
||||||
_xtal_cons(max_distance_mm=2.0),
|
_xtal_cons(max_distance_mm=2.0),
|
||||||
_x1_c9_layout(cap_x=0.5),
|
_x1_c9_layout(cap_x=0.5, segments=segs),
|
||||||
) == []
|
) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrouted_crystal_cap_is_insufficient_not_euclidean():
|
||||||
|
findings = check_placement(
|
||||||
|
_graph(),
|
||||||
|
_xtal_cons(max_distance_mm=2.0),
|
||||||
|
_x1_c9_layout(cap_x=10.0),
|
||||||
|
)
|
||||||
|
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||||
|
insuf = [f for f in findings if f.rule_id == "PE-PLC-005"]
|
||||||
|
assert len(insuf) == 1
|
||||||
|
assert insuf[0].evidence_status == "INSUFFICIENT"
|
||||||
|
assert insuf[0].status == "INFO"
|
||||||
|
|
||||||
|
|
||||||
def test_track_path_longer_than_max_distance_mm_is_ps_plc_001():
|
def test_track_path_longer_than_max_distance_mm_is_ps_plc_001():
|
||||||
limit = 2.0
|
limit = 2.0
|
||||||
segs = [
|
segs = [
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Owner-only project delete: files+meta gone; other owners and auth users untouched."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.services import projects as proj_svc
|
||||||
|
from backend.services.storage import LocalStorageBackend
|
||||||
|
|
||||||
|
|
||||||
|
def _client(tmp_path: Path) -> TestClient:
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
app.state.storage = LocalStorageBackend(tmp_path)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_delete_removes_project_files_not_auth(tmp_path: Path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
auth_users = tmp_path / "auth" / "users.json"
|
||||||
|
auth_users.parent.mkdir(parents=True)
|
||||||
|
auth_users.write_text('{"users":[{"email":"keep@example.com"}]}\n')
|
||||||
|
|
||||||
|
meta = client.post("/api/projects", json={"name": "mine"}).json()
|
||||||
|
pid = meta["id"]
|
||||||
|
prefix = f"users/local/projects/{pid}"
|
||||||
|
storage = client.app.state.storage
|
||||||
|
storage.write_json(f"{prefix}/report.json", {"findings": []})
|
||||||
|
storage.write_text(f"{prefix}/uploads/netlist.asc", "*PADS-PCB*\n")
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/projects/{pid}")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
assert not storage.exists(f"{prefix}/project.json")
|
||||||
|
assert not storage.exists(f"{prefix}/report.json")
|
||||||
|
assert auth_users.is_file()
|
||||||
|
assert "keep@example.com" in auth_users.read_text()
|
||||||
|
listed = client.get("/api/projects").json()
|
||||||
|
assert all(p["id"] != pid for p in listed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_another_owners_project_is_404(tmp_path: Path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
storage = client.app.state.storage
|
||||||
|
other = proj_svc.create_project(storage, "alice", "secret-board")
|
||||||
|
key = f"users/alice/projects/{other.id}/project.json"
|
||||||
|
report = f"users/alice/projects/{other.id}/report.json"
|
||||||
|
storage.write_json(report, {"findings": [{"id": 1}]})
|
||||||
|
alice_users = tmp_path / "users" / "alice" / "profile.json"
|
||||||
|
alice_users.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
alice_users.write_text('{"user_id":"alice"}\n')
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/projects/{other.id}")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert storage.exists(key)
|
||||||
|
assert storage.exists(report)
|
||||||
|
assert alice_users.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_collaborator_cannot_delete_owner_project(tmp_path: Path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
storage = client.app.state.storage
|
||||||
|
other = proj_svc.create_project(storage, "alice", "shared")
|
||||||
|
proj_svc.add_collaborator(storage, "alice", other.id, "local")
|
||||||
|
key = f"users/alice/projects/{other.id}/project.json"
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/projects/{other.id}")
|
||||||
|
assert resp.status_code == 403, resp.text
|
||||||
|
assert storage.exists(key)
|
||||||
|
meta = proj_svc.get_project(storage, "alice", other.id)
|
||||||
|
assert meta is not None
|
||||||
|
assert "local" in meta.collaborators
|
||||||
Reference in New Issue
Block a user