Add finding review states, ECO export, and a report release signature.
Accepted findings become ECO rows with a required reason; false-positive and wontfix stay off the change list. Pipeline re-runs keep dispositions by finding_id like comments. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""Finding review disposition, ECO export, and release signature.
|
||||
|
||||
Review state lives beside comments on the report JSON — it is not a
|
||||
Finding field, so a pipeline re-run can keep dispositions by finding_id.
|
||||
Empty reason is invalid. false_positive / wontfix / open are not ECO rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Literal
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
|
||||
ReviewState = Literal["open", "false_positive", "accepted", "wontfix"]
|
||||
VALID_STATES: frozenset[str] = frozenset({"open", "false_positive", "accepted", "wontfix"})
|
||||
|
||||
|
||||
class ReviewError(ValueError):
|
||||
"""Invalid review payload; do not store a silent default."""
|
||||
|
||||
|
||||
def apply_review_state(
|
||||
current: dict[str, dict[str, Any]],
|
||||
finding_id: str,
|
||||
*,
|
||||
state: str,
|
||||
reason: str,
|
||||
user_id: str,
|
||||
user_name: str = "",
|
||||
updated_at: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
if not finding_id:
|
||||
raise ReviewError("finding_id is required")
|
||||
if state not in VALID_STATES:
|
||||
raise ReviewError(f"invalid review state {state!r}")
|
||||
text = (reason or "").strip()
|
||||
if state != "open" and not text:
|
||||
raise ReviewError("reason is required")
|
||||
rec = {
|
||||
"state": state,
|
||||
"reason": text,
|
||||
"user_id": user_id,
|
||||
"user_name": user_name,
|
||||
"updated_at": updated_at or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
next_states = dict(current)
|
||||
if state == "open":
|
||||
next_states.pop(finding_id, None)
|
||||
return next_states
|
||||
next_states[finding_id] = rec
|
||||
return next_states
|
||||
|
||||
|
||||
def _state_of(states: dict[str, dict[str, Any]], finding_id: str | None) -> str:
|
||||
if not finding_id:
|
||||
return "open"
|
||||
rec = states.get(finding_id)
|
||||
if not rec:
|
||||
return "open"
|
||||
return rec.get("state") or "open"
|
||||
|
||||
|
||||
def build_eco(
|
||||
findings: Iterable[Finding],
|
||||
review_states: dict[str, dict[str, Any]],
|
||||
) -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
for f in findings:
|
||||
fid = f.finding_id
|
||||
if _state_of(review_states, fid) != "accepted":
|
||||
continue
|
||||
rec = review_states.get(fid or "", {})
|
||||
items.append({
|
||||
"finding_id": fid or "",
|
||||
"rule_id": f.rule_id or "",
|
||||
"ref": f.designator,
|
||||
"before": f.finding,
|
||||
"after": f.recommendation or "",
|
||||
"reason": rec.get("reason") or "",
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def eco_csv(items: list[dict[str, str]]) -> str:
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(
|
||||
buf,
|
||||
fieldnames=["finding_id", "rule_id", "ref", "before", "after", "reason"],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(items)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def sign_report(report: dict[str, Any], *, user_id: str, timestamp: str | None = None) -> dict[str, str]:
|
||||
payload = json.dumps(report.get("findings") or [], sort_keys=True, default=str)
|
||||
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
return {
|
||||
"sha256": digest,
|
||||
"user_id": user_id,
|
||||
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -8,9 +8,17 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.pinscopex.review_workflow import (
|
||||
ReviewError,
|
||||
apply_review_state,
|
||||
build_eco,
|
||||
eco_csv,
|
||||
sign_report,
|
||||
)
|
||||
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
|
||||
@@ -100,8 +108,87 @@ async def delete_comment(project_id: str, comment_id: str, request: Request):
|
||||
comment_list.pop(i)
|
||||
if not comment_list:
|
||||
del comments[finding_id]
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse({"ok": True})
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
class ReviewBody(BaseModel):
|
||||
state: str
|
||||
reason: str = ""
|
||||
user_name: str = ""
|
||||
|
||||
|
||||
def _load_report(storage, owner_user_id: str, project_id: str) -> tuple[str, dict]:
|
||||
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")
|
||||
return key, storage.read_json(key)
|
||||
|
||||
|
||||
def _findings_from_report(report_data: dict) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
for raw in report_data.get("findings") or []:
|
||||
try:
|
||||
out.append(Finding.model_validate(raw))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/report/{project_id}/findings/{finding_id}/review")
|
||||
async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
user_id = get_user_id(request)
|
||||
key, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
ids = {f.finding_id for f in _findings_from_report(report_data) if f.finding_id}
|
||||
if finding_id not in ids:
|
||||
raise HTTPException(404, "Finding not found")
|
||||
try:
|
||||
states = apply_review_state(
|
||||
report_data.get("review_states") or {},
|
||||
finding_id,
|
||||
state=body.state,
|
||||
reason=body.reason,
|
||||
user_id=user_id,
|
||||
user_name=body.user_name,
|
||||
)
|
||||
except ReviewError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
report_data["review_states"] = states
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/eco.json")
|
||||
async def get_eco_json(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
_, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
|
||||
return JSONResponse({"items": items})
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/eco.csv")
|
||||
async def get_eco_csv(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
_, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
|
||||
return Response(eco_csv(items), media_type="text/csv")
|
||||
|
||||
|
||||
@router.post("/report/{project_id}/sign")
|
||||
async def post_sign_report(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
user_id = get_user_id(request)
|
||||
key, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
release = sign_report(report_data, user_id=user_id)
|
||||
report_data["release"] = release
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse(release)
|
||||
raise HTTPException(404, "Comment not found")
|
||||
|
||||
|
||||
|
||||
@@ -740,10 +740,12 @@ async def validate_design_async(
|
||||
preserved_findings: list[Finding] = []
|
||||
preserved_coverage: dict[str, list[str]] = {}
|
||||
preserved_comments = None
|
||||
preserved_review_states = None
|
||||
if existing_path.is_file():
|
||||
try:
|
||||
existing = json.loads(existing_path.read_text())
|
||||
preserved_comments = existing.get("comments")
|
||||
preserved_review_states = existing.get("review_states")
|
||||
if before_ic is not None:
|
||||
# Resume mode — keep findings for refs we're about to skip
|
||||
for f in existing.get("findings", []):
|
||||
@@ -805,6 +807,8 @@ async def validate_design_async(
|
||||
report_dict = json.loads(report.model_dump_json(indent=2))
|
||||
if preserved_comments is not None:
|
||||
report_dict["comments"] = preserved_comments
|
||||
if preserved_review_states is not None:
|
||||
report_dict["review_states"] = preserved_review_states
|
||||
if paused:
|
||||
report_dict["partial"] = True
|
||||
existing_path.write_text(json.dumps(report_dict, indent=2))
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
What's new in Pinscope.
|
||||
|
||||
## 2.19.0 — 2026-09-10 — Finding review and ECO
|
||||
|
||||
Findings can be accepted, marked false-positive, or wontfix with a required reason. Accepted rows export as ECO; OpenEMS-style layout SI is still not this.
|
||||
|
||||
- [New] Review state on the report (`open` / `accepted` / `false_positive` / `wontfix`). Empty reason is rejected except when returning to open.
|
||||
- [New] ECO CSV/JSON of accepted findings only. False-positive and wontfix stay off the ECO.
|
||||
- [New] Release signature: SHA-256 of findings + user + timestamp. Needs-review filter `?review=open`.
|
||||
|
||||
## 2.18.0 — 2026-09-10 — ImpedenceFinder calculator
|
||||
|
||||
The Impedance tab uses the closed-form engine from ImpedenceFinder (Hammerstad–Jensen / Cohn), not a second formula set and not OpenEMS.
|
||||
|
||||
@@ -13,10 +13,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Toast, useToast } from "@/components/ui/toast";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { fetchCollaborators, fetchProject, fetchMyFeedback, reprocessPipeline } from "@/lib/api";
|
||||
import { fetchCollaborators, fetchProject, fetchMyFeedback, reprocessPipeline, signReport, downloadEcoCsv } from "@/lib/api";
|
||||
import { exportReportToExcel } from "@/lib/report-export";
|
||||
import { cn, getFindingKey } from "@/lib/utils";
|
||||
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
|
||||
|
||||
interface FocusState {
|
||||
key: string;
|
||||
@@ -36,6 +36,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
const prevInFocusRef = useRef(false);
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
|
||||
const [comments, setComments] = useState<Record<string, FindingComment[]>>({});
|
||||
const [reviews, setReviews] = useState<Record<string, FindingReview>>({});
|
||||
const [creditsSpent, setCreditsSpent] = useState<number | undefined>();
|
||||
const [totalCostUsd, setTotalCostUsd] = useState<number | null>(null);
|
||||
const [projectName, setProjectName] = useState<string>("");
|
||||
@@ -121,6 +122,9 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
if (report?.comments) {
|
||||
setComments(report.comments);
|
||||
}
|
||||
if (report?.review_states) {
|
||||
setReviews(report.review_states);
|
||||
}
|
||||
}, [report]);
|
||||
|
||||
const handleCommentAdded = useCallback((comment: FindingComment) => {
|
||||
@@ -130,6 +134,15 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleReviewSaved = useCallback((findingId: string, review: FindingReview) => {
|
||||
setReviews((prev) => {
|
||||
const next = { ...prev };
|
||||
if (review.state === "open") delete next[findingId];
|
||||
else next[findingId] = review;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCommentDeleted = useCallback((commentId: string, findingId: string) => {
|
||||
setComments((prev) => {
|
||||
const list = (prev[findingId] ?? []).filter((c) => c.comment_id !== commentId);
|
||||
@@ -245,6 +258,33 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
>
|
||||
<Download /> Export Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await downloadEcoCsv(projectId);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "ECO export failed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download /> ECO CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const rel = await signReport(projectId);
|
||||
showToast(`Signed ${rel.sha256.slice(0, 12)}…`);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Sign failed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign release
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ReportSummary
|
||||
@@ -314,6 +354,8 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
onCommentDeleted={handleCommentDeleted}
|
||||
onReportFinding={handleReportFinding}
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={handleReviewSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -338,6 +380,8 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
onCommentDeleted={handleCommentDeleted}
|
||||
onReportFinding={handleReportFinding}
|
||||
isReported={!!(focus.finding.finding_id && reportedFindingIds.has(focus.finding.finding_id))}
|
||||
review={focus.finding.finding_id ? reviews[focus.finding.finding_id] : undefined}
|
||||
onReviewSaved={handleReviewSaved}
|
||||
/>
|
||||
)}
|
||||
<Toast toast={toast} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FindingCard } from "./finding-card";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
|
||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||
|
||||
interface ComponentGroupProps {
|
||||
@@ -26,9 +26,11 @@ interface ComponentGroupProps {
|
||||
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: ComponentGroupProps) {
|
||||
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: ComponentGroupProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const sorted = sortFindings(findings);
|
||||
|
||||
@@ -82,6 +84,8 @@ export function ComponentGroup({ designator, findings, component, onViewReferenc
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onReportFinding={onReportFinding}
|
||||
isReported={!!(f.finding_id && reportedFindingIds?.has(f.finding_id))}
|
||||
review={f.finding_id ? reviews?.[f.finding_id] : undefined}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
|
||||
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import { FindingComments } from "./finding-comments";
|
||||
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
|
||||
import { FindingReviewControls } from "./finding-review-controls";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BORDER_COLOR: Record<string, string> = {
|
||||
@@ -31,6 +32,8 @@ interface FindingCardProps {
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
isReported?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
review?: FindingReview;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function FindingCard({
|
||||
@@ -48,11 +51,16 @@ export function FindingCard({
|
||||
onReportFinding,
|
||||
isReported,
|
||||
defaultOpen,
|
||||
review,
|
||||
onReviewSaved,
|
||||
}: FindingCardProps) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
const commentCount = comments?.length ?? 0;
|
||||
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
||||
const expandable = !!finding.recommendation || (hasCommentSupport && !!finding.finding_id);
|
||||
const expandable =
|
||||
!!finding.recommendation ||
|
||||
(hasCommentSupport && !!finding.finding_id) ||
|
||||
!!(projectId && finding.finding_id && onReviewSaved);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -189,6 +197,15 @@ export function FindingCard({
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
/>
|
||||
)}
|
||||
{projectId && finding.finding_id && onReviewSaved && (
|
||||
<FindingReviewControls
|
||||
projectId={projectId}
|
||||
findingId={finding.finding_id}
|
||||
review={review}
|
||||
userName={currentUserName ?? "User"}
|
||||
onSaved={onReviewSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PdfViewerPanel } from "@/components/pdf/pdf-viewer-panel";
|
||||
import { FindingCard } from "./finding-card";
|
||||
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
|
||||
import { subtypeLabel } from "@/lib/utils";
|
||||
|
||||
interface FindingFocusViewProps {
|
||||
@@ -29,6 +29,8 @@ interface FindingFocusViewProps {
|
||||
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
isReported?: boolean;
|
||||
review?: FindingReview;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function FindingFocusView({
|
||||
@@ -51,6 +53,8 @@ export function FindingFocusView({
|
||||
onCommentDeleted,
|
||||
onReportFinding,
|
||||
isReported,
|
||||
review,
|
||||
onReviewSaved,
|
||||
}: FindingFocusViewProps) {
|
||||
const backRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -110,6 +114,8 @@ export function FindingFocusView({
|
||||
onReportFinding={onReportFinding}
|
||||
isReported={isReported}
|
||||
defaultOpen
|
||||
review={review}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-auto lg:shrink-0 lg:sticky lg:top-6 h-[70vh] lg:h-[calc(100vh-3rem)] rounded-lg border border-border bg-card overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { setFindingReview } from "@/lib/api";
|
||||
import type { FindingReview, FindingReviewState } from "@/lib/types";
|
||||
|
||||
const STATES: { value: FindingReviewState; label: string }[] = [
|
||||
{ value: "open", label: "Open" },
|
||||
{ value: "accepted", label: "Accepted (ECO)" },
|
||||
{ value: "false_positive", label: "False positive" },
|
||||
{ value: "wontfix", label: "Won't fix" },
|
||||
];
|
||||
|
||||
export function FindingReviewControls({
|
||||
projectId,
|
||||
findingId,
|
||||
review,
|
||||
userName,
|
||||
onSaved,
|
||||
}: {
|
||||
projectId: string;
|
||||
findingId: string;
|
||||
review?: FindingReview;
|
||||
userName: string;
|
||||
onSaved: (findingId: string, review: FindingReview) => void;
|
||||
}) {
|
||||
const [state, setState] = useState<FindingReviewState>(review?.state ?? "open");
|
||||
const [reason, setReason] = useState(review?.reason ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await setFindingReview(projectId, findingId, state, reason, userName);
|
||||
onSaved(findingId, saved.state === "open" ? { ...saved, state: "open", reason: "" } : saved);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-xs"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value as FindingReviewState)}
|
||||
>
|
||||
{STATES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
className="h-8 min-w-[160px] flex-1 text-xs"
|
||||
placeholder={state === "open" ? "Reason optional when open" : "Reason (required)"}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={save} disabled={busy}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useCallback, useMemo } from "react";
|
||||
import { ComponentGroup } from "./component-group";
|
||||
import { ReportFilters } from "./report-filters";
|
||||
import { ReviewedSection } from "./reviewed-section";
|
||||
import type { Finding, FindingComment, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
||||
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||
|
||||
interface FindingsListProps {
|
||||
@@ -23,9 +23,11 @@ interface FindingsListProps {
|
||||
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: FindingsListProps) {
|
||||
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: FindingsListProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -38,6 +40,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
|
||||
const statusParam = searchParams.get("status");
|
||||
const componentParam = searchParams.get("component");
|
||||
const reviewParam = searchParams.get("review");
|
||||
const searchParam = searchParams.get("q") ?? "";
|
||||
|
||||
const statusFilters = useMemo(() => {
|
||||
@@ -77,9 +80,13 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
const q = searchParam.toLowerCase();
|
||||
if (q && !f.finding.toLowerCase().includes(q) && !(f.why ?? "").toLowerCase().includes(q))
|
||||
return false;
|
||||
if (reviewParam === "open") {
|
||||
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
|
||||
if (st && st !== "open") return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[statusFilters, componentParam, searchParam]
|
||||
[statusFilters, componentParam, searchParam, reviewParam, reviews]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -120,6 +127,10 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
onComponentChange={(v) => updateParams({ component: v === "all" ? null : v })}
|
||||
search={searchParam}
|
||||
onSearchChange={(v) => updateParams({ q: v || null })}
|
||||
needsReview={reviewParam === "open"}
|
||||
onToggleNeedsReview={() =>
|
||||
updateParams({ review: reviewParam === "open" ? null : "open" })
|
||||
}
|
||||
designators={designators}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
@@ -144,6 +155,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onReportFinding={onReportFinding}
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
|
||||
|
||||
@@ -21,6 +21,8 @@ interface ReportFiltersProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
designators: string[];
|
||||
needsReview?: boolean;
|
||||
onToggleNeedsReview?: () => void;
|
||||
}
|
||||
|
||||
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
|
||||
@@ -37,6 +39,8 @@ export function ReportFilters({
|
||||
search,
|
||||
onSearchChange,
|
||||
designators,
|
||||
needsReview,
|
||||
onToggleNeedsReview,
|
||||
}: ReportFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -56,6 +60,19 @@ export function ReportFilters({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{onToggleNeedsReview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
needsReview && "bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/40",
|
||||
)}
|
||||
onClick={onToggleNeedsReview}
|
||||
>
|
||||
Needs review
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
ImpedanceTraceResult,
|
||||
EdifSubDesign,
|
||||
FindingComment,
|
||||
FindingReview,
|
||||
FindingReviewState,
|
||||
LcscPayload,
|
||||
NetlistPreviewDesignator,
|
||||
PauseCheckpoint,
|
||||
@@ -615,6 +617,46 @@ export async function fetchReport(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function setFindingReview(
|
||||
projectId: string,
|
||||
findingId: string,
|
||||
state: FindingReviewState,
|
||||
reason: string,
|
||||
userName: string,
|
||||
): Promise<FindingReview> {
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/report/${projectId}/findings/${encodeURIComponent(findingId)}/review`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state, reason, user_name: userName }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : "Review update failed");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function signReport(projectId: string): Promise<{ sha256: string; user_id: string; timestamp: string }> {
|
||||
const res = await authFetch(`${BASE}/api/report/${projectId}/sign`, { method: "POST" });
|
||||
if (!res.ok) throw new Error("Failed to sign report");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function downloadEcoCsv(projectId: string): Promise<void> {
|
||||
const res = await authFetch(`${BASE}/api/report/${projectId}/eco.csv`);
|
||||
if (!res.ok) throw new Error("Failed to export ECO");
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "pinscope-eco.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function addComment(
|
||||
projectId: string,
|
||||
findingId: string,
|
||||
|
||||
@@ -41,6 +41,24 @@ export interface ValidationReport {
|
||||
review_errors?: Record<string, string>;
|
||||
not_reviewed?: { designator: string; reason: string }[];
|
||||
comments?: Record<string, FindingComment[]>;
|
||||
review_states?: Record<string, FindingReview>;
|
||||
release?: ReportRelease;
|
||||
}
|
||||
|
||||
export type FindingReviewState = "open" | "false_positive" | "accepted" | "wontfix";
|
||||
|
||||
export interface FindingReview {
|
||||
state: FindingReviewState;
|
||||
reason: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReportRelease {
|
||||
sha256: string;
|
||||
user_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type NetType = "power" | "ground" | "signal" | "unknown";
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Wave H — finding review state, ECO, release signature.
|
||||
|
||||
Favor: false_positive/accepted/wontfix with a reason persist; accepted
|
||||
rows land in eco.json; signature hashes the findings payload.
|
||||
Against: empty reason; unknown state; false_positive is not an ECO row;
|
||||
unsigned report has no release block.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.pinscopex.review_workflow import (
|
||||
ReviewError,
|
||||
apply_review_state,
|
||||
build_eco,
|
||||
eco_csv,
|
||||
sign_report,
|
||||
)
|
||||
|
||||
|
||||
def _finding(**kwargs):
|
||||
defaults = dict(
|
||||
finding_id="U1-001",
|
||||
designator="U1",
|
||||
mpn="PART",
|
||||
aspect="decoupling",
|
||||
finding="missing cap",
|
||||
why="no 100nF on VDD",
|
||||
status="ERROR",
|
||||
recommendation="add 100nF",
|
||||
rule_id="PS-DEC-001",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Finding(**defaults)
|
||||
|
||||
|
||||
def test_false_positive_requires_reason():
|
||||
with pytest.raises(ReviewError):
|
||||
apply_review_state({}, "U1-001", state="false_positive", reason=" ", user_id="local")
|
||||
|
||||
|
||||
def test_unknown_state_is_rejected():
|
||||
with pytest.raises(ReviewError):
|
||||
apply_review_state({}, "U1-001", state="fixed", reason="ok", user_id="local")
|
||||
|
||||
|
||||
def test_accepted_with_reason_is_stored():
|
||||
states = apply_review_state(
|
||||
{}, "U1-001", state="accepted", reason="will spin ECO-12", user_id="local",
|
||||
user_name="Michele",
|
||||
)
|
||||
assert states["U1-001"]["state"] == "accepted"
|
||||
assert states["U1-001"]["reason"] == "will spin ECO-12"
|
||||
assert states["U1-001"]["user_id"] == "local"
|
||||
|
||||
|
||||
def test_eco_includes_accepted_not_false_positive():
|
||||
findings = [
|
||||
_finding(finding_id="U1-001"),
|
||||
_finding(finding_id="U2-001", designator="U2", finding="noise"),
|
||||
]
|
||||
states = apply_review_state({}, "U1-001", state="accepted", reason="add cap", user_id="a")
|
||||
states = apply_review_state(states, "U2-001", state="false_positive", reason="ok in app", user_id="a")
|
||||
eco = build_eco(findings, states)
|
||||
assert [row["finding_id"] for row in eco] == ["U1-001"]
|
||||
assert eco[0]["rule_id"] == "PS-DEC-001"
|
||||
assert eco[0]["ref"] == "U1"
|
||||
assert "100nF" in eco[0]["after"]
|
||||
csv = eco_csv(eco)
|
||||
assert "U1-001" in csv
|
||||
assert "U2-001" not in csv
|
||||
|
||||
|
||||
def test_open_and_wontfix_are_not_eco_rows():
|
||||
findings = [_finding()]
|
||||
states = apply_review_state({}, "U1-001", state="wontfix", reason="wont ship", user_id="a")
|
||||
assert build_eco(findings, states) == []
|
||||
assert build_eco(findings, {}) == []
|
||||
|
||||
|
||||
def test_signature_changes_when_findings_change():
|
||||
a = sign_report({"findings": [{"finding_id": "U1-001"}]}, user_id="local")
|
||||
b = sign_report({"findings": [{"finding_id": "U1-002"}]}, user_id="local")
|
||||
assert a["user_id"] == "local"
|
||||
assert a["sha256"] != b["sha256"]
|
||||
assert a["timestamp"]
|
||||
|
||||
|
||||
def _client(tmp_path):
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_report(client, findings):
|
||||
meta = client.post("/api/projects", json={"name": "board"}).json()
|
||||
pid = meta["id"]
|
||||
storage = client.app.state.storage
|
||||
prefix = f"users/local/projects/{pid}"
|
||||
storage.write_json(f"{prefix}/report.json", {
|
||||
"project": "board",
|
||||
"timestamp": "2026-01-01T00:00:00+00:00",
|
||||
"findings": [f.model_dump() for f in findings],
|
||||
"summary": {"total": len(findings), "ERROR": 1, "WARNING": 0, "INFO": 0},
|
||||
})
|
||||
return pid
|
||||
|
||||
|
||||
def test_api_review_without_reason_is_400(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
|
||||
"state": "accepted", "reason": "",
|
||||
})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_api_review_and_eco(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
|
||||
"state": "accepted", "reason": "add 100nF near U1.3",
|
||||
})
|
||||
assert res.status_code == 200
|
||||
report = client.get(f"/api/report/{pid}").json()
|
||||
assert report["review_states"]["U1-001"]["state"] == "accepted"
|
||||
eco = client.get(f"/api/report/{pid}/eco.json").json()
|
||||
assert eco["items"][0]["finding_id"] == "U1-001"
|
||||
csv = client.get(f"/api/report/{pid}/eco.csv")
|
||||
assert csv.status_code == 200
|
||||
assert "U1-001" in csv.text
|
||||
|
||||
|
||||
def test_api_unknown_finding_is_404(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.put(f"/api/report/{pid}/findings/NOPE/review", json={
|
||||
"state": "accepted", "reason": "x",
|
||||
})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
def test_api_sign_release(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.post(f"/api/report/{pid}/sign")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert len(body["sha256"]) == 64
|
||||
report = client.get(f"/api/report/{pid}").json()
|
||||
assert report["release"]["sha256"] == body["sha256"]
|
||||
assert report["release"]["user_id"] == "local"
|
||||
Reference in New Issue
Block a user