diff --git a/backend/pinscopex/review_workflow.py b/backend/pinscopex/review_workflow.py new file mode 100644 index 0000000..f32d506 --- /dev/null +++ b/backend/pinscopex/review_workflow.py @@ -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(), + } diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 62eb774..04e050a 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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") diff --git a/backend/services/validation.py b/backend/services/validation.py index 3c39418..c12ea27 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -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)) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index cedcc6e..ed32dbd 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -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. diff --git a/frontend/src/app/(app)/project/[id]/report/page.tsx b/frontend/src/app/(app)/project/[id]/report/page.tsx index 1fd6bbc..a367567 100644 --- a/frontend/src/app/(app)/project/[id]/report/page.tsx +++ b/frontend/src/app/(app)/project/[id]/report/page.tsx @@ -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([]); const [comments, setComments] = useState>({}); + const [reviews, setReviews] = useState>({}); const [creditsSpent, setCreditsSpent] = useState(); const [totalCostUsd, setTotalCostUsd] = useState(null); const [projectName, setProjectName] = useState(""); @@ -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 }) { > Export Excel + + )} @@ -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} /> )} diff --git a/frontend/src/components/report/component-group.tsx b/frontend/src/components/report/component-group.tsx index 87320f1..ec1b1ca 100644 --- a/frontend/src/components/report/component-group.tsx +++ b/frontend/src/components/report/component-group.tsx @@ -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; + reviews?: Record; + 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} /> ); })} diff --git a/frontend/src/components/report/finding-card.tsx b/frontend/src/components/report/finding-card.tsx index 31b81b6..4eb83ba 100644 --- a/frontend/src/components/report/finding-card.tsx +++ b/frontend/src/components/report/finding-card.tsx @@ -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 = { @@ -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 (
)} + {projectId && finding.finding_id && onReviewSaved && ( + + )}
)} diff --git a/frontend/src/components/report/finding-focus-view.tsx b/frontend/src/components/report/finding-focus-view.tsx index 474e0fb..06b5b40 100644 --- a/frontend/src/components/report/finding-focus-view.tsx +++ b/frontend/src/components/report/finding-focus-view.tsx @@ -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(null); @@ -110,6 +114,8 @@ export function FindingFocusView({ onReportFinding={onReportFinding} isReported={isReported} defaultOpen + review={review} + onReviewSaved={onReviewSaved} />
diff --git a/frontend/src/components/report/finding-review-controls.tsx b/frontend/src/components/report/finding-review-controls.tsx new file mode 100644 index 0000000..18f5dff --- /dev/null +++ b/frontend/src/components/report/finding-review-controls.tsx @@ -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(review?.state ?? "open"); + const [reason, setReason] = useState(review?.reason ?? ""); + const [error, setError] = useState(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 ( +
e.stopPropagation()}> +
+ + setReason(e.target.value)} + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/report/findings-list.tsx b/frontend/src/components/report/findings-list.tsx index 9d32e11..7ef4562 100644 --- a/frontend/src/components/report/findings-list.tsx +++ b/frontend/src/components/report/findings-list.tsx @@ -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; + reviews?: Record; + 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} />
@@ -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 && ( diff --git a/frontend/src/components/report/report-filters.tsx b/frontend/src/components/report/report-filters.tsx index 0a541af..95de677 100644 --- a/frontend/src/components/report/report-filters.tsx +++ b/frontend/src/components/report/report-filters.tsx @@ -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 (
@@ -56,6 +60,19 @@ export function ReportFilters({ ))}
+ {onToggleNeedsReview && ( + + )}