US01-06: Review Inventory and Duplicates in the Browser
This commit is contained in:
130
frontend/css/app.css
Normal file
130
frontend/css/app.css
Normal file
@@ -0,0 +1,130 @@
|
||||
:root {
|
||||
--bg: #000;
|
||||
--surface: #0e0e11;
|
||||
--surface-2: #17171c;
|
||||
--border: #26262e;
|
||||
--text: #f4f4f6;
|
||||
--muted: #9a9aa6;
|
||||
--accent: #5b8cff;
|
||||
--danger: #ff5b6e;
|
||||
--ok: #3ddc97;
|
||||
--warn: #ffcf5b;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand { font-weight: 700; letter-spacing: 0.2px; }
|
||||
|
||||
nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
margin-right: 16px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
nav a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); }
|
||||
nav a:hover { color: var(--text); }
|
||||
|
||||
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
input, select, button { font: inherit; }
|
||||
input[type="search"], select {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #06122e; font-weight: 600; }
|
||||
button.danger { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
||||
tbody tr { outline: none; }
|
||||
tbody tr[aria-selected="true"] { background: rgba(91, 140, 255, 0.16); }
|
||||
tbody tr:focus { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.exact, .badge.pixel { color: var(--ok); border-color: var(--ok); }
|
||||
.badge.perceptual { color: var(--warn); border-color: var(--warn); }
|
||||
.badge.decided { color: var(--ok); border-color: var(--ok); }
|
||||
.badge.reopened, .badge.dismissed { color: var(--warn); border-color: var(--warn); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.pager { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
|
||||
|
||||
.cluster-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||
.member {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
.member.canonical { border-color: var(--accent); }
|
||||
.member img { width: 100%; height: auto; background: var(--surface-2); border-radius: 8px; display: block; transform-origin: top left; }
|
||||
.member dl { margin: 10px 0 0; font-size: 13px; }
|
||||
.member dt { color: var(--muted); }
|
||||
.member dd { margin: 0 0 6px; word-break: break-all; }
|
||||
|
||||
.decision-bar { display: flex; gap: 12px; margin: 20px 0; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
background: rgba(255, 91, 110, 0.1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--warn);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; }
|
||||
a.link { color: var(--accent); text-decoration: none; }
|
||||
a.link:hover { text-decoration: underline; }
|
||||
20
frontend/index.html
Normal file
20
frontend/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Photo Pipeline — Review</title>
|
||||
<link rel="stylesheet" href="./css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<span class="brand">Photo Pipeline</span>
|
||||
<nav aria-label="Primary">
|
||||
<a href="#/inventory" data-nav="inventory">Inventory</a>
|
||||
<a href="#/duplicates" data-nav="duplicates">Duplicates</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="app" aria-live="polite"><!-- views render here --></main>
|
||||
<script type="module" src="./js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/js/api.js
Normal file
38
frontend/js/api.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Single API client: one place for fetch, JSON, and the error envelope.
|
||||
const BASE = "/api/v1";
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(BASE + path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (_) {
|
||||
body = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const envelope = body && body.error ? body.error : {};
|
||||
const error = new Error(envelope.message || response.statusText);
|
||||
error.status = response.status;
|
||||
error.code = envelope.code || "error";
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listAssets: (params = {}) =>
|
||||
request("/inventory/assets?" + new URLSearchParams(params).toString()),
|
||||
listClusters: (params = {}) =>
|
||||
request("/duplicates/clusters?" + new URLSearchParams(params).toString()),
|
||||
getCluster: (id) => request(`/duplicates/clusters/${encodeURIComponent(id)}`),
|
||||
decide: (id, payload) =>
|
||||
request(`/duplicates/clusters/${encodeURIComponent(id)}/decision`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
thumbnailUrl: (assetId, size = 512) =>
|
||||
`${BASE}/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`,
|
||||
};
|
||||
355
frontend/js/app.js
Normal file
355
frontend/js/app.js
Normal file
@@ -0,0 +1,355 @@
|
||||
import { api } from "./api.js";
|
||||
import { navigate, onRouteChange, parseHash } from "./router.js";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
|
||||
// Tiny DOM helper — builds nodes without innerHTML injection.
|
||||
function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (value == null || value === false) continue;
|
||||
if (key === "class") node.className = value;
|
||||
else if (key === "dataset") Object.assign(node.dataset, value);
|
||||
else if (key.startsWith("on") && typeof value === "function")
|
||||
node.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
else node.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child == null || child === false) continue;
|
||||
node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function show(...nodes) {
|
||||
root.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
function setActiveNav(view) {
|
||||
document.querySelectorAll("nav a[data-nav]").forEach((a) => {
|
||||
if (a.dataset.nav === view) a.setAttribute("aria-current", "page");
|
||||
else a.removeAttribute("aria-current");
|
||||
});
|
||||
}
|
||||
|
||||
function errorBanner(message) {
|
||||
return el("div", { class: "alert", role: "alert" }, message);
|
||||
}
|
||||
|
||||
// ── Inventory view ──────────────────────────────────────────────────────────
|
||||
async function renderInventory(params) {
|
||||
setActiveNav("inventory");
|
||||
const limit = Number(params.limit || 50);
|
||||
const offset = Number(params.offset || 0);
|
||||
let data;
|
||||
try {
|
||||
data = await api.listAssets({
|
||||
limit,
|
||||
offset,
|
||||
availability: params.availability || "",
|
||||
q: params.q || "",
|
||||
});
|
||||
} catch (error) {
|
||||
show(errorBanner(`Failed to load inventory: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const search = el("input", {
|
||||
type: "search",
|
||||
placeholder: "Filter by path…",
|
||||
value: params.q || "",
|
||||
"aria-label": "Filter by path",
|
||||
onchange: (e) => navigate("/inventory", { ...params, q: e.target.value, offset: 0 }),
|
||||
});
|
||||
const availability = el(
|
||||
"select",
|
||||
{
|
||||
"aria-label": "Availability",
|
||||
onchange: (e) =>
|
||||
navigate("/inventory", { ...params, availability: e.target.value, offset: 0 }),
|
||||
},
|
||||
el("option", { value: "" }, "All availability"),
|
||||
...["active", "archived_offline", "archived_online"].map((v) =>
|
||||
el("option", { value: v, selected: params.availability === v ? "selected" : false }, v)
|
||||
)
|
||||
);
|
||||
|
||||
const rows = data.items.map((asset, index) =>
|
||||
el(
|
||||
"tr",
|
||||
{
|
||||
"data-testid": "asset-row",
|
||||
"data-asset-id": asset.id,
|
||||
tabindex: "-1",
|
||||
role: "row",
|
||||
onclick: () => selectRow(index),
|
||||
},
|
||||
el("td", {}, asset.current_path || "(no path)"),
|
||||
el("td", {}, asset.availability_state),
|
||||
el("td", {}, asset.missing ? "missing" : "present"),
|
||||
el("td", { class: "muted" }, asset.byte_size != null ? String(asset.byte_size) : "—")
|
||||
)
|
||||
);
|
||||
|
||||
const tbody = el("tbody", {}, ...rows);
|
||||
let selected = -1;
|
||||
function selectRow(index) {
|
||||
if (index < 0 || index >= rows.length) return;
|
||||
if (selected >= 0) rows[selected].setAttribute("aria-selected", "false");
|
||||
selected = index;
|
||||
rows[selected].setAttribute("aria-selected", "true");
|
||||
rows[selected].focus();
|
||||
}
|
||||
|
||||
tbody.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectRow(Math.min(selected + 1, rows.length - 1) || 0);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
selectRow(Math.max(selected - 1, 0));
|
||||
}
|
||||
});
|
||||
|
||||
const table = el(
|
||||
"table",
|
||||
{ "aria-label": "Inventory" },
|
||||
el(
|
||||
"thead",
|
||||
{},
|
||||
el("tr", {}, el("th", {}, "Path"), el("th", {}, "Availability"), el("th", {}, "State"), el("th", {}, "Bytes"))
|
||||
),
|
||||
tbody
|
||||
);
|
||||
|
||||
const start = data.total === 0 ? 0 : offset + 1;
|
||||
const end = Math.min(offset + limit, data.total);
|
||||
const pager = el(
|
||||
"div",
|
||||
{ class: "pager" },
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "prev-page",
|
||||
disabled: offset === 0 ? "disabled" : false,
|
||||
onclick: () => navigate("/inventory", { ...params, offset: Math.max(offset - limit, 0) }),
|
||||
},
|
||||
"Previous"
|
||||
),
|
||||
el("span", { class: "muted", "data-testid": "page-info" }, `${start}–${end} of ${data.total}`),
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "next-page",
|
||||
disabled: end >= data.total ? "disabled" : false,
|
||||
onclick: () => navigate("/inventory", { ...params, offset: offset + limit }),
|
||||
},
|
||||
"Next"
|
||||
)
|
||||
);
|
||||
|
||||
show(
|
||||
el("h1", {}, "Inventory"),
|
||||
el("div", { class: "toolbar" }, search, availability),
|
||||
table,
|
||||
pager
|
||||
);
|
||||
if (rows.length) selectRow(0);
|
||||
}
|
||||
|
||||
// ── Duplicate cluster list ───────────────────────────────────────────────────
|
||||
async function renderClusters(params) {
|
||||
setActiveNav("duplicates");
|
||||
let data;
|
||||
try {
|
||||
data = await api.listClusters({ state: params.state || "", limit: 100 });
|
||||
} catch (error) {
|
||||
show(errorBanner(`Failed to load clusters: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = data.items.map((cluster) =>
|
||||
el(
|
||||
"div",
|
||||
{ class: "card", "data-testid": "cluster-card" },
|
||||
el(
|
||||
"div",
|
||||
{},
|
||||
el("a", { class: "link", href: `#/duplicates/${cluster.id}` }, `Cluster ${cluster.id.slice(0, 8)}`),
|
||||
" ",
|
||||
el("span", { class: `badge ${cluster.method}` }, cluster.method),
|
||||
" ",
|
||||
el("span", { class: `badge ${cluster.state}` }, cluster.state)
|
||||
),
|
||||
el("div", { class: "muted" }, `${cluster.members.length} members · confidence ${cluster.confidence}`)
|
||||
)
|
||||
);
|
||||
|
||||
show(
|
||||
el("h1", {}, "Duplicate clusters"),
|
||||
data.items.length ? el("div", { class: "cluster-grid" }, ...items) : el("p", { class: "muted" }, "No clusters detected.")
|
||||
);
|
||||
}
|
||||
|
||||
// ── Duplicate comparison ─────────────────────────────────────────────────────
|
||||
async function renderClusterDetail(id, extra = {}) {
|
||||
setActiveNav("duplicates");
|
||||
let cluster;
|
||||
try {
|
||||
cluster = await api.getCluster(id);
|
||||
} catch (error) {
|
||||
show(errorBanner(`Failed to load cluster: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const zoom = el("input", {
|
||||
type: "range",
|
||||
min: "1",
|
||||
max: "3",
|
||||
step: "0.1",
|
||||
value: "1",
|
||||
"aria-label": "Synchronized zoom",
|
||||
"data-testid": "zoom",
|
||||
});
|
||||
|
||||
const images = [];
|
||||
const members = cluster.members.map((member) => {
|
||||
const isCanonical = cluster.canonical_asset_id === member.asset_id;
|
||||
const img = el("img", {
|
||||
src: api.thumbnailUrl(member.asset_id, 512),
|
||||
alt: member.current_path || member.asset_id,
|
||||
loading: "lazy",
|
||||
});
|
||||
images.push(img);
|
||||
const actions =
|
||||
cluster.state === "decided" || cluster.state === "dismissed"
|
||||
? []
|
||||
: [
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "make-canonical",
|
||||
onclick: () => decide(cluster, "canonical", member.asset_id),
|
||||
},
|
||||
"Keep this as canonical"
|
||||
),
|
||||
];
|
||||
return el(
|
||||
"div",
|
||||
{ class: `member ${isCanonical ? "canonical" : ""}`, "data-testid": "member" },
|
||||
img,
|
||||
el(
|
||||
"dl",
|
||||
{},
|
||||
el("dt", {}, "Path"),
|
||||
el("dd", {}, member.current_path || "—"),
|
||||
el("dt", {}, "Role"),
|
||||
el("dd", {}, member.role),
|
||||
el("dt", {}, "pHash distance"),
|
||||
el("dd", {}, member.distance == null ? "—" : String(member.distance)),
|
||||
el("dt", {}, "Bytes"),
|
||||
el("dd", {}, member.byte_size == null ? "—" : String(member.byte_size))
|
||||
),
|
||||
...actions
|
||||
);
|
||||
});
|
||||
|
||||
zoom.addEventListener("input", () => {
|
||||
const scale = zoom.value;
|
||||
images.forEach((img) => (img.style.transform = `scale(${scale})`));
|
||||
});
|
||||
|
||||
const decisionBar = el(
|
||||
"div",
|
||||
{ class: "decision-bar" },
|
||||
el("span", { "data-testid": "cluster-state", class: `badge ${cluster.state}` }, `state: ${cluster.state}`),
|
||||
el("span", { class: `badge ${cluster.confidence}` }, `confidence: ${cluster.confidence}`),
|
||||
cluster.requires_confirmation ? el("span", { class: "muted" }, "fuzzy — confirmation required") : null,
|
||||
el(
|
||||
"button",
|
||||
{ class: "danger", "data-testid": "not-duplicate", onclick: () => decide(cluster, "not_duplicate") },
|
||||
"Not a duplicate"
|
||||
),
|
||||
el("button", { "data-testid": "defer", onclick: () => decide(cluster, "deferred") }, "Defer")
|
||||
);
|
||||
|
||||
const nodes = [
|
||||
el("p", {}, el("a", { class: "link", href: "#/duplicates" }, "← All clusters")),
|
||||
el("h1", {}, `Compare cluster ${cluster.id.slice(0, 8)}`),
|
||||
];
|
||||
if (extra.conflict) {
|
||||
nodes.push(
|
||||
errorBanner("This cluster changed since you loaded it. Nothing was modified; showing the latest state.")
|
||||
);
|
||||
}
|
||||
nodes.push(el("div", { class: "toolbar" }, el("label", {}, "Zoom "), zoom));
|
||||
nodes.push(decisionBar);
|
||||
if (extra.pending) nodes.push(confirmPanel(cluster, extra.pending));
|
||||
nodes.push(el("div", { class: "cluster-grid" }, ...members));
|
||||
show(...nodes);
|
||||
}
|
||||
|
||||
function confirmPanel(cluster, pending) {
|
||||
return el(
|
||||
"div",
|
||||
{ class: "confirm", role: "dialog", "aria-label": "Confirm decision", "data-testid": "confirm" },
|
||||
el("p", {}, `Confirm "${pending.decision.replace("_", " ")}" for this fuzzy cluster?`),
|
||||
el(
|
||||
"div",
|
||||
{ class: "decision-bar" },
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
class: "primary",
|
||||
"data-testid": "confirm-yes",
|
||||
onclick: () => applyDecision(cluster, pending.decision, pending.canonicalId),
|
||||
},
|
||||
"Confirm"
|
||||
),
|
||||
el(
|
||||
"button",
|
||||
{ "data-testid": "confirm-no", onclick: () => renderClusterDetail(cluster.id) },
|
||||
"Cancel"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// A fuzzy cluster needs explicit confirmation; exact/pixel apply immediately.
|
||||
function decide(cluster, decision, canonicalId) {
|
||||
if (cluster.requires_confirmation) {
|
||||
renderClusterDetail(cluster.id, { pending: { decision, canonicalId } });
|
||||
} else {
|
||||
applyDecision(cluster, decision, canonicalId);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDecision(cluster, decision, canonicalId) {
|
||||
try {
|
||||
await api.decide(cluster.id, {
|
||||
decision,
|
||||
expected_version: cluster.version,
|
||||
canonical_asset_id: canonicalId || null,
|
||||
});
|
||||
renderClusterDetail(cluster.id);
|
||||
} catch (error) {
|
||||
if (error.status === 409) {
|
||||
renderClusterDetail(cluster.id, { conflict: true });
|
||||
} else {
|
||||
show(errorBanner(`Decision failed: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Router ───────────────────────────────────────────────────────────────────
|
||||
function render() {
|
||||
const { path, params } = parseHash();
|
||||
const clusterMatch = path.match(/^\/duplicates\/(.+)$/);
|
||||
if (path === "/inventory" || path === "/") renderInventory(params);
|
||||
else if (path === "/duplicates") renderClusters(params);
|
||||
else if (clusterMatch) renderClusterDetail(clusterMatch[1]);
|
||||
else show(errorBanner("Unknown view"));
|
||||
}
|
||||
|
||||
onRouteChange(render);
|
||||
render();
|
||||
20
frontend/js/router.js
Normal file
20
frontend/js/router.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Hash router. URLs are stable and shareable: the view, its filters, and paging
|
||||
// all live in the hash, so a reload restores exactly what was on screen.
|
||||
export function parseHash() {
|
||||
const raw = location.hash.slice(1) || "/inventory";
|
||||
const [path, queryString = ""] = raw.split("?");
|
||||
return { path, params: Object.fromEntries(new URLSearchParams(queryString)) };
|
||||
}
|
||||
|
||||
export function navigate(path, params = {}) {
|
||||
const clean = Object.fromEntries(
|
||||
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== "")
|
||||
);
|
||||
const query = Object.keys(clean).length ? "?" + new URLSearchParams(clean) : "";
|
||||
location.hash = path + query;
|
||||
}
|
||||
|
||||
export function onRouteChange(handler) {
|
||||
window.addEventListener("hashchange", handler);
|
||||
window.addEventListener("DOMContentLoaded", handler);
|
||||
}
|
||||
@@ -9,14 +9,18 @@ and exposes the versioned ``/api/v1`` surface; US01-02 ships only health.
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from photo_pipeline.api.routes import health, thumbnails
|
||||
from photo_pipeline.api.routes import duplicates, health, inventory, thumbnails
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||
from photo_pipeline.logging import configure_logging
|
||||
|
||||
FRONTEND_DIR = Path(__file__).resolve().parents[2] / "frontend"
|
||||
|
||||
|
||||
def create_app(config: Config | None = None) -> FastAPI:
|
||||
config = config or Config.from_env()
|
||||
@@ -38,5 +42,10 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
|
||||
app = FastAPI(title="Photo Pipeline", version="0.1.0", lifespan=lifespan)
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(inventory.router, prefix="/api/v1")
|
||||
app.include_router(duplicates.router, prefix="/api/v1")
|
||||
app.include_router(thumbnails.router, prefix="/api/v1")
|
||||
# Static single-page app (hash-routed). Mounted last so /api/v1 wins.
|
||||
if FRONTEND_DIR.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="app")
|
||||
return app
|
||||
|
||||
61
photo_pipeline/api/routes/duplicates.py
Normal file
61
photo_pipeline/api/routes/duplicates.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Duplicate cluster review API: list, detail, and decision.
|
||||
|
||||
Decisions carry an ``expected_version``; a stale version returns 409 without
|
||||
mutating anything, so a browser acting on out-of-date state cannot overwrite a
|
||||
newer decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from photo_pipeline.schemas import DecisionRequest
|
||||
from photo_pipeline.services.duplicates import ConflictError, DuplicateError, DuplicateService
|
||||
|
||||
router = APIRouter(tags=["duplicates"])
|
||||
|
||||
|
||||
def _service(request: Request) -> DuplicateService:
|
||||
return DuplicateService(request.app.state.session_factory)
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
|
||||
|
||||
|
||||
@router.get("/duplicates/clusters")
|
||||
def list_clusters(
|
||||
request: Request,
|
||||
state: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict:
|
||||
return _service(request).list_clusters(state=state, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/duplicates/clusters/{cluster_id}")
|
||||
def get_cluster(cluster_id: str, request: Request):
|
||||
detail = _service(request).get_cluster(cluster_id)
|
||||
if detail is None:
|
||||
return _error(404, "not_found", f"unknown cluster {cluster_id}")
|
||||
return detail
|
||||
|
||||
|
||||
@router.post("/duplicates/clusters/{cluster_id}/decision")
|
||||
def decide(cluster_id: str, body: DecisionRequest, request: Request):
|
||||
service = _service(request)
|
||||
if service.get_cluster(cluster_id) is None:
|
||||
return _error(404, "not_found", f"unknown cluster {cluster_id}")
|
||||
try:
|
||||
return service.decide(
|
||||
cluster_id,
|
||||
body.decision,
|
||||
canonical_asset_id=body.canonical_asset_id,
|
||||
expected_version=body.expected_version,
|
||||
reason=body.reason,
|
||||
)
|
||||
except ConflictError as error:
|
||||
return _error(409, "version_conflict", str(error))
|
||||
except DuplicateError as error:
|
||||
return _error(422, "invalid_decision", str(error))
|
||||
21
photo_pipeline/api/routes/inventory.py
Normal file
21
photo_pipeline/api/routes/inventory.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Inventory listing API for the review UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from photo_pipeline.services.inventory import InventoryService
|
||||
|
||||
router = APIRouter(tags=["inventory"])
|
||||
|
||||
|
||||
@router.get("/inventory/assets")
|
||||
def list_assets(
|
||||
request: Request,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
availability: str | None = None,
|
||||
q: str | None = None,
|
||||
) -> dict:
|
||||
service = InventoryService(request.app.state.session_factory)
|
||||
return service.list_assets(limit=limit, offset=offset, availability=availability, query=q)
|
||||
5
photo_pipeline/schemas/__init__.py
Normal file
5
photo_pipeline/schemas/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Pydantic API request/response contracts."""
|
||||
|
||||
from photo_pipeline.schemas.duplicates import DecisionRequest
|
||||
|
||||
__all__ = ["DecisionRequest"]
|
||||
14
photo_pipeline/schemas/duplicates.py
Normal file
14
photo_pipeline/schemas/duplicates.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Request contracts for duplicate decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DecisionRequest(BaseModel):
|
||||
decision: Literal["canonical", "not_duplicate", "deferred"]
|
||||
expected_version: int
|
||||
canonical_asset_id: str | None = None
|
||||
reason: str | None = None
|
||||
@@ -30,7 +30,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from photo_pipeline.models import (
|
||||
@@ -466,6 +466,62 @@ class DuplicateService:
|
||||
asset = session.get(Asset, cur)
|
||||
cur = asset.canonical_asset_id if asset else None
|
||||
|
||||
# ── reads for the review UI ────────────────────────────────────────────────
|
||||
def list_clusters(
|
||||
self, *, state: str | None = None, limit: int = 50, offset: int = 0
|
||||
) -> dict:
|
||||
limit = max(1, min(limit, 200))
|
||||
offset = max(0, offset)
|
||||
with self._session_factory() as session:
|
||||
stmt = select(DuplicateCluster)
|
||||
if state:
|
||||
stmt = stmt.where(DuplicateCluster.state == state)
|
||||
total = session.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
rows = session.execute(
|
||||
stmt.order_by(DuplicateCluster.created_at).limit(limit).offset(offset)
|
||||
).scalars()
|
||||
items = [self._snapshot(session, c.id) for c in rows]
|
||||
return {"items": items, "total": int(total or 0), "limit": limit, "offset": offset}
|
||||
|
||||
def get_cluster(self, cluster_id: str) -> dict | None:
|
||||
"""Cluster detail enriched with per-member asset evidence for comparison."""
|
||||
with self._session_factory() as session:
|
||||
cluster = session.get(DuplicateCluster, cluster_id)
|
||||
if cluster is None:
|
||||
return None
|
||||
members = []
|
||||
for member in session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster_id)
|
||||
).scalars():
|
||||
asset = session.get(Asset, member.asset_id)
|
||||
try:
|
||||
evidence = json.loads(member.evidence) if member.evidence else {}
|
||||
except json.JSONDecodeError:
|
||||
evidence = {}
|
||||
members.append(
|
||||
{
|
||||
"asset_id": member.asset_id,
|
||||
"role": member.role,
|
||||
"distance": member.distance,
|
||||
"evidence": evidence,
|
||||
"current_path": asset.current_path if asset else None,
|
||||
"byte_size": asset.byte_size if asset else None,
|
||||
"phash": asset.phash if asset else None,
|
||||
}
|
||||
)
|
||||
members.sort(key=lambda m: m["asset_id"])
|
||||
return {
|
||||
"id": cluster.id,
|
||||
"method": cluster.method,
|
||||
"confidence": cluster.confidence,
|
||||
"state": cluster.state,
|
||||
"decision": cluster.decision,
|
||||
"canonical_asset_id": cluster.canonical_asset_id,
|
||||
"version": cluster.version,
|
||||
"requires_confirmation": cluster.method == Method.PERCEPTUAL.value,
|
||||
"members": members,
|
||||
}
|
||||
|
||||
# ── decisions ────────────────────────────────────────────────────────────
|
||||
def decide(
|
||||
self,
|
||||
|
||||
@@ -30,7 +30,7 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from photo_pipeline import path_policy
|
||||
@@ -55,6 +55,21 @@ class ScanResult:
|
||||
asset_ids: dict[str, str] = field(default_factory=dict) # path -> asset id
|
||||
|
||||
|
||||
def _asset_dict(asset: Asset) -> dict:
|
||||
return {
|
||||
"id": asset.id,
|
||||
"current_path": asset.current_path,
|
||||
"availability_state": asset.availability_state,
|
||||
"byte_size": asset.byte_size,
|
||||
"current_sha256": asset.current_sha256,
|
||||
"pixel_sha256": asset.pixel_sha256,
|
||||
"phash": asset.phash,
|
||||
"canonical_asset_id": asset.canonical_asset_id,
|
||||
"missing": asset.missing_at is not None,
|
||||
"state_version": asset.state_version,
|
||||
}
|
||||
|
||||
|
||||
class InventoryService:
|
||||
def __init__(self, session_factory: sessionmaker) -> None:
|
||||
self._session_factory = session_factory
|
||||
@@ -101,6 +116,30 @@ class InventoryService:
|
||||
result.counts = dict(Counter(result.occurrences.values()))
|
||||
return result
|
||||
|
||||
def list_assets(
|
||||
self,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
availability: str | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict:
|
||||
"""Paged, filterable asset listing for the inventory UI (read-only)."""
|
||||
limit = max(1, min(limit, 200))
|
||||
offset = max(0, offset)
|
||||
with self._session_factory() as session:
|
||||
stmt = select(Asset)
|
||||
if availability:
|
||||
stmt = stmt.where(Asset.availability_state == availability)
|
||||
if query:
|
||||
stmt = stmt.where(Asset.current_path.like(f"%{query}%"))
|
||||
total = session.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
rows = session.execute(
|
||||
stmt.order_by(Asset.current_path).limit(limit).offset(offset)
|
||||
).scalars()
|
||||
items = [_asset_dict(a) for a in rows]
|
||||
return {"items": items, "total": int(total or 0), "limit": limit, "offset": offset}
|
||||
|
||||
def _reconcile_one(
|
||||
self,
|
||||
session: Session,
|
||||
|
||||
@@ -11,7 +11,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8", "httpx>=0.27"]
|
||||
test = [
|
||||
"pytest>=8",
|
||||
"httpx>=0.27",
|
||||
"pillow>=10",
|
||||
"numpy>=1.26",
|
||||
"scipy>=1.11",
|
||||
"playwright>=1.40",
|
||||
"pytest-playwright>=0.4",
|
||||
]
|
||||
# Browser end-to-end tests also require: python -m playwright install chromium
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
184
tests/e2e/test_review_ui.py
Normal file
184
tests/e2e/test_review_ui.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Browser end-to-end review journeys against the real server + static frontend.
|
||||
|
||||
Covers keyboard inventory review, fuzzy-decision confirmation, stale-version
|
||||
conflict handling, and decision persistence across reload. The server runs as a
|
||||
real child process; the browser talks to it over HTTP like production.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _structured(path, seed, size=(256, 192)):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rng = np.random.default_rng(seed)
|
||||
w, h = size
|
||||
base = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
for _ in range(6):
|
||||
x0, y0 = int(rng.integers(0, w - 60)), int(rng.integers(0, h - 60))
|
||||
base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3)
|
||||
grad = np.linspace(0, 120, w, dtype=np.uint8)
|
||||
base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255)
|
||||
Image.fromarray(base).save(path, quality=95)
|
||||
return path
|
||||
|
||||
|
||||
def _resized(src, dst, scale=0.5):
|
||||
with Image.open(src) as image:
|
||||
image.resize(
|
||||
(int(image.width * scale), int(image.height * scale)), Image.LANCZOS
|
||||
).save(dst, quality=95)
|
||||
|
||||
|
||||
def _free_port():
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def _seed(data_dir, lib):
|
||||
# In-process seed, then dispose so the server process owns the database freely.
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||
from photo_pipeline.services.duplicates import DuplicateService
|
||||
from photo_pipeline.services.inventory import InventoryService
|
||||
|
||||
a = _structured(lib / "a.jpg", 1)
|
||||
shutil.copy2(a, lib / "a_copy.jpg") # exact pair
|
||||
b = _structured(lib / "b.jpg", 2)
|
||||
_resized(b, lib / "b_small.jpg") # perceptual pair
|
||||
|
||||
config = Config.from_env(
|
||||
{"PHOTO_PIPELINE_DATA_DIR": str(data_dir), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
|
||||
)
|
||||
run_migrations(config.database_url)
|
||||
engine = create_db_engine(config.database_url)
|
||||
sf = create_session_factory(engine)
|
||||
InventoryService(sf).scan(lib)
|
||||
DuplicateService(sf).detect()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path):
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir()
|
||||
_seed(data, lib)
|
||||
|
||||
port = _free_port()
|
||||
env = {
|
||||
"PATH": __import__("os").environ.get("PATH", ""),
|
||||
"PHOTO_PIPELINE_DATA_DIR": str(data),
|
||||
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
|
||||
"PHOTO_PIPELINE_HOST": "127.0.0.1",
|
||||
"PHOTO_PIPELINE_PORT": str(port),
|
||||
}
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "photo_pipeline", "serve"],
|
||||
cwd=str(REPO),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
deadline = time.monotonic() + 30
|
||||
ready = False
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
out, err = proc.communicate()
|
||||
pytest.fail(f"server exited: {err.decode(errors='replace')}")
|
||||
try:
|
||||
if httpx.get(f"{base}/api/v1/health/ready", timeout=1).status_code == 200:
|
||||
ready = True
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(0.2)
|
||||
if not ready:
|
||||
proc.terminate()
|
||||
pytest.fail("server never became ready")
|
||||
|
||||
client = httpx.Client(base_url=base, timeout=5)
|
||||
|
||||
def cluster_by_method(method):
|
||||
clusters = client.get("/api/v1/duplicates/clusters").json()["items"]
|
||||
return next(c for c in clusters if c["method"] == method)
|
||||
|
||||
try:
|
||||
yield SimpleNamespace(base=base, client=client, cluster_by_method=cluster_by_method)
|
||||
finally:
|
||||
client.close()
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def test_inventory_keyboard_review(page, server):
|
||||
page.goto(f"{server.base}/app/#/inventory")
|
||||
rows = page.get_by_test_id("asset-row")
|
||||
rows.first.wait_for()
|
||||
assert rows.count() == 4
|
||||
# First row auto-selected; arrow-down moves the selection.
|
||||
assert rows.nth(0).get_attribute("aria-selected") == "true"
|
||||
page.keyboard.press("ArrowDown")
|
||||
assert rows.nth(1).get_attribute("aria-selected") == "true"
|
||||
assert rows.nth(0).get_attribute("aria-selected") == "false"
|
||||
|
||||
|
||||
def test_fuzzy_decision_requires_confirmation(page, server):
|
||||
cluster = server.cluster_by_method("perceptual")
|
||||
page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}")
|
||||
page.get_by_test_id("not-duplicate").click()
|
||||
# A confirmation appears before anything is persisted.
|
||||
page.get_by_test_id("confirm").wait_for()
|
||||
assert "open" in page.get_by_test_id("cluster-state").inner_text()
|
||||
page.get_by_test_id("confirm-yes").click()
|
||||
page.wait_for_function(
|
||||
"document.querySelector('[data-testid=cluster-state]').innerText.includes('dismissed')"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_version_shows_conflict(page, server):
|
||||
cluster = server.cluster_by_method("perceptual")
|
||||
page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}")
|
||||
# Open the confirmation for a fuzzy decision (this refetches the current version).
|
||||
page.get_by_test_id("defer").click()
|
||||
page.get_by_test_id("confirm").wait_for()
|
||||
# While the dialog is open, another actor decides — bumping the version.
|
||||
resp = server.client.post(
|
||||
f"/api/v1/duplicates/clusters/{cluster['id']}/decision",
|
||||
json={"decision": "deferred", "expected_version": cluster["version"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Confirming now carries the stale version; the server refuses it.
|
||||
page.get_by_test_id("confirm-yes").click()
|
||||
page.get_by_role("alert").wait_for()
|
||||
assert "changed" in page.get_by_role("alert").inner_text()
|
||||
|
||||
|
||||
def test_decision_persists_after_reload(page, server):
|
||||
cluster = server.cluster_by_method("perceptual")
|
||||
page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}")
|
||||
page.get_by_test_id("not-duplicate").click()
|
||||
page.get_by_test_id("confirm-yes").click()
|
||||
page.wait_for_function(
|
||||
"document.querySelector('[data-testid=cluster-state]').innerText.includes('dismissed')"
|
||||
)
|
||||
page.reload()
|
||||
page.get_by_test_id("cluster-state").wait_for()
|
||||
assert "dismissed" in page.get_by_test_id("cluster-state").inner_text()
|
||||
145
tests/integration/test_review_api.py
Normal file
145
tests/integration/test_review_api.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Review API contract: inventory paging/filtering, cluster list/detail, and the
|
||||
decision endpoint's success and error (404/409/422) behavior.
|
||||
|
||||
Fixtures inlined to avoid shadowing the characterization suite's ``conftest``.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from photo_pipeline.api.app import create_app
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.db import run_migrations
|
||||
from photo_pipeline.services.duplicates import DuplicateService, Method
|
||||
from photo_pipeline.services.inventory import InventoryService
|
||||
|
||||
|
||||
def structured(path, seed, size=(256, 192)):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rng = np.random.default_rng(seed)
|
||||
w, h = size
|
||||
base = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
for _ in range(6):
|
||||
x0, y0 = int(rng.integers(0, w - 60)), int(rng.integers(0, h - 60))
|
||||
base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3)
|
||||
grad = np.linspace(0, 120, w, dtype=np.uint8)
|
||||
base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255)
|
||||
Image.fromarray(base).save(path, quality=95)
|
||||
return path
|
||||
|
||||
|
||||
def resized_copy(src, dst, scale=0.5):
|
||||
with Image.open(src) as image:
|
||||
image.resize(
|
||||
(int(image.width * scale), int(image.height * scale)), Image.LANCZOS
|
||||
).save(dst, quality=95)
|
||||
return dst
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded(tmp_path):
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir()
|
||||
# exact pair + a perceptual pair
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
shutil.copy2(a, lib / "a_copy.jpg")
|
||||
b = structured(lib / "b.jpg", 2)
|
||||
resized_copy(b, lib / "b_small.jpg")
|
||||
config = Config.from_env(
|
||||
{"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
|
||||
)
|
||||
run_migrations(config.database_url)
|
||||
app = create_app(config)
|
||||
client = TestClient(app)
|
||||
client.__enter__()
|
||||
InventoryService(app.state.session_factory).scan(lib)
|
||||
DuplicateService(app.state.session_factory).detect()
|
||||
yield SimpleNamespace(client=client, app=app)
|
||||
client.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_inventory_lists_and_pages(seeded):
|
||||
client = seeded.client
|
||||
full = client.get("/api/v1/inventory/assets").json()
|
||||
assert full["total"] == 4
|
||||
page = client.get("/api/v1/inventory/assets?limit=2&offset=0").json()
|
||||
assert len(page["items"]) == 2
|
||||
assert page["limit"] == 2
|
||||
page2 = client.get("/api/v1/inventory/assets?limit=2&offset=2").json()
|
||||
assert page2["items"][0]["id"] != page["items"][0]["id"]
|
||||
|
||||
|
||||
def test_inventory_filters_by_path(seeded):
|
||||
result = seeded.client.get("/api/v1/inventory/assets?q=a_copy").json()
|
||||
assert result["total"] == 1
|
||||
assert "a_copy" in result["items"][0]["current_path"]
|
||||
|
||||
|
||||
def test_clusters_list_and_detail(seeded):
|
||||
clusters = seeded.client.get("/api/v1/duplicates/clusters").json()
|
||||
methods = {c["method"] for c in clusters["items"]}
|
||||
assert {Method.EXACT.value, Method.PERCEPTUAL.value} <= methods
|
||||
|
||||
perceptual = next(c for c in clusters["items"] if c["method"] == Method.PERCEPTUAL.value)
|
||||
detail = seeded.client.get(f"/api/v1/duplicates/clusters/{perceptual['id']}").json()
|
||||
assert detail["requires_confirmation"] is True
|
||||
assert len(detail["members"]) == 2
|
||||
assert "evidence" in detail["members"][0]
|
||||
|
||||
|
||||
def test_decision_success_and_persists(seeded):
|
||||
clusters = seeded.client.get("/api/v1/duplicates/clusters?state=open").json()
|
||||
cluster = clusters["items"][0]
|
||||
resp = seeded.client.post(
|
||||
f"/api/v1/duplicates/clusters/{cluster['id']}/decision",
|
||||
json={"decision": "deferred", "expected_version": cluster["version"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["state"] == "deferred"
|
||||
again = seeded.client.get(f"/api/v1/duplicates/clusters/{cluster['id']}").json()
|
||||
assert again["state"] == "deferred"
|
||||
|
||||
|
||||
def test_decision_stale_version_conflicts_without_mutation(seeded):
|
||||
clusters = seeded.client.get("/api/v1/duplicates/clusters?state=open").json()
|
||||
cluster = clusters["items"][0]
|
||||
stale = seeded.client.post(
|
||||
f"/api/v1/duplicates/clusters/{cluster['id']}/decision",
|
||||
json={"decision": "deferred", "expected_version": cluster["version"] + 99},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["error"]["code"] == "version_conflict"
|
||||
unchanged = seeded.client.get(f"/api/v1/duplicates/clusters/{cluster['id']}").json()
|
||||
assert unchanged["state"] == cluster["state"] # nothing mutated
|
||||
|
||||
|
||||
def test_decision_unknown_and_invalid(seeded):
|
||||
assert (
|
||||
seeded.client.post(
|
||||
"/api/v1/duplicates/clusters/ghost/decision",
|
||||
json={"decision": "deferred", "expected_version": 1},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
clusters = seeded.client.get("/api/v1/duplicates/clusters?state=open").json()
|
||||
cluster = clusters["items"][0]
|
||||
invalid = seeded.client.post(
|
||||
f"/api/v1/duplicates/clusters/{cluster['id']}/decision",
|
||||
json={
|
||||
"decision": "canonical",
|
||||
"expected_version": cluster["version"],
|
||||
"canonical_asset_id": "not-a-member",
|
||||
},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
|
||||
|
||||
def test_cluster_detail_unknown_is_404(seeded):
|
||||
assert seeded.client.get("/api/v1/duplicates/clusters/ghost").status_code == 404
|
||||
Reference in New Issue
Block a user