feat: M1 foundation packages + trip-cluster app #1
@@ -1,8 +1,14 @@
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, current_app, render_template
|
||||
from photoflow.core import Store
|
||||
|
||||
bp = Blueprint("nav", __name__)
|
||||
|
||||
|
||||
def _store():
|
||||
cfg = current_app.config["APP_CONFIG"]
|
||||
return Store(cfg.db_path).connect()
|
||||
|
||||
|
||||
@bp.route("/health")
|
||||
def health():
|
||||
return "ok"
|
||||
@@ -10,4 +16,9 @@ def health():
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
return "trip-cluster"
|
||||
s = _store()
|
||||
clusters = s.clusters_by_attention()
|
||||
stats = {"assets": len(s.all_assets()), "clusters": len(clusters),
|
||||
"pending": sum(1 for c in clusters if c.status == "pending")}
|
||||
s.close()
|
||||
return render_template("review.html", clusters=clusters, stats=stats)
|
||||
|
||||
@@ -1,3 +1,129 @@
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, current_app, render_template, request, jsonify, abort
|
||||
from photoflow.core import Store
|
||||
from app import review
|
||||
|
||||
bp = Blueprint("review", __name__)
|
||||
|
||||
|
||||
def _store():
|
||||
cfg = current_app.config["APP_CONFIG"]
|
||||
return Store(cfg.db_path).connect()
|
||||
|
||||
|
||||
def _client():
|
||||
factory = current_app.config.get("IMMICH_FACTORY")
|
||||
if factory:
|
||||
return factory()
|
||||
from photoflow.immich import ImmichClient
|
||||
cfg = current_app.config["APP_CONFIG"]
|
||||
return ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
|
||||
|
||||
def _body():
|
||||
return request.get_json(silent=True) or {}
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>")
|
||||
def detail(cid):
|
||||
s = _store()
|
||||
c = s.get_cluster(cid)
|
||||
if c is None:
|
||||
s.close()
|
||||
abort(404)
|
||||
members = s.cluster_members(cid)
|
||||
prev_id, next_id = s.chronological_neighbors(cid)
|
||||
s.close()
|
||||
return render_template("_detail.html", c=c, members=members,
|
||||
prev_id=prev_id, next_id=next_id)
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>/approve", methods=["POST"])
|
||||
def approve(cid):
|
||||
s = _store()
|
||||
review.approve(s, cid, _body().get("name"))
|
||||
c = s.get_cluster(cid)
|
||||
s.close()
|
||||
return jsonify({"ok": True, "status": c.status, "name": c.decided_name})
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>/non-trip", methods=["POST"])
|
||||
def non_trip(cid):
|
||||
s = _store()
|
||||
review.mark_non_trip(s, cid)
|
||||
s.close()
|
||||
return jsonify({"ok": True, "status": "non_trip"})
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>/skip", methods=["POST"])
|
||||
def skip(cid):
|
||||
s = _store()
|
||||
review.skip(s, cid)
|
||||
s.close()
|
||||
return jsonify({"ok": True, "status": "skipped"})
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>/split", methods=["POST"])
|
||||
def split(cid):
|
||||
s = _store()
|
||||
boundary = _body().get("boundary_asset_id")
|
||||
if not boundary:
|
||||
s.close()
|
||||
return jsonify({"error": "boundary_asset_id required"}), 400
|
||||
try:
|
||||
id1, id2 = review.split(s, cid, boundary)
|
||||
except ValueError as e:
|
||||
s.close()
|
||||
return jsonify({"error": str(e)}), 400
|
||||
s.close()
|
||||
return jsonify({"ok": True, "ids": [id1, id2]})
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>/merge", methods=["POST"])
|
||||
def merge(cid):
|
||||
s = _store()
|
||||
other = _body().get("other_id")
|
||||
if other is None:
|
||||
s.close()
|
||||
return jsonify({"error": "other_id required"}), 400
|
||||
new_id = review.merge(s, cid, int(other))
|
||||
s.close()
|
||||
return jsonify({"ok": True, "id": new_id})
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>/member", methods=["POST"])
|
||||
def member(cid):
|
||||
s = _store()
|
||||
body = _body()
|
||||
review.set_member(s, cid, body["asset_id"], bool(body.get("included")))
|
||||
s.close()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.route("/approve-high-confidence", methods=["POST"])
|
||||
def approve_high_confidence():
|
||||
s = _store()
|
||||
threshold = float(_body().get("threshold", 0.75))
|
||||
n = review.approve_high_confidence(s, threshold)
|
||||
s.close()
|
||||
return jsonify({"approved": n})
|
||||
|
||||
|
||||
@bp.route("/cluster/<int:cid>/apply", methods=["POST"])
|
||||
def apply_one(cid):
|
||||
# Lazy import: app.writeback is owned by a later task and may be absent
|
||||
# at app-startup; importing here keeps the blueprint importable regardless.
|
||||
from app.writeback import apply_cluster
|
||||
s = _store()
|
||||
res = apply_cluster(_client(), s, cid)
|
||||
s.close()
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
@bp.route("/apply-all", methods=["POST"])
|
||||
def apply_everything():
|
||||
# Lazy import: see apply_one above.
|
||||
from app.writeback import apply_all
|
||||
s = _store()
|
||||
results = apply_all(_client(), s)
|
||||
s.close()
|
||||
return jsonify({"results": results})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
function clusterReview() {
|
||||
return {
|
||||
...photoGrid(),
|
||||
selected: null,
|
||||
clusterIds: [],
|
||||
failures: [],
|
||||
|
||||
init() {
|
||||
this.clusterIds = [...document.querySelectorAll('.cluster-row')]
|
||||
.map(el => parseInt(el.dataset.clusterId, 10));
|
||||
this.selected = this.clusterIds.length ? this.clusterIds[0] : null;
|
||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||
if (e.target.id === 'detail') {
|
||||
this.hideIndicator();
|
||||
this.selectFirst();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// --- F16: loading indicator for programmatic swaps -----------------
|
||||
showIndicator() {
|
||||
const el = document.getElementById('detail-indicator');
|
||||
if (el) el.classList.add('htmx-request');
|
||||
},
|
||||
hideIndicator() {
|
||||
const el = document.getElementById('detail-indicator');
|
||||
if (el) el.classList.remove('htmx-request');
|
||||
},
|
||||
|
||||
selectCluster(id) {
|
||||
this.selected = id;
|
||||
this.showIndicator();
|
||||
htmx.ajax('GET', `/cluster/${id}`, { target: '#detail' });
|
||||
},
|
||||
|
||||
moveCluster(dir) {
|
||||
const n = this.clusterIds.length;
|
||||
if (!n) return;
|
||||
const i = this.clusterIds.indexOf(this.selected);
|
||||
const j = ((i + dir) % n + n) % n; // wrap around (mirrors nextPendingId)
|
||||
if (this.clusterIds[j] != null) this.selectCluster(this.clusterIds[j]);
|
||||
},
|
||||
|
||||
// --- F3: in-place rail status + advance to next pending ------------
|
||||
statusClass(status) {
|
||||
return ({
|
||||
pending: 'badge-ghost', approved: 'badge-success', non_trip: 'badge-neutral',
|
||||
skipped: 'badge-warning', merged: 'badge-info', split: 'badge-info',
|
||||
})[status] || 'badge-ghost';
|
||||
},
|
||||
railStatusEl(id) {
|
||||
return document.querySelector(`.cluster-status[data-cluster-id="${id}"]`);
|
||||
},
|
||||
railStatusText(id) {
|
||||
const el = this.railStatusEl(id);
|
||||
return el ? el.textContent.trim() : '';
|
||||
},
|
||||
updateRailStatus(id, status) {
|
||||
const el = this.railStatusEl(id);
|
||||
if (el) el.innerHTML =
|
||||
`<span class="badge badge-sm ${this.statusClass(status)}">${status}</span>`;
|
||||
},
|
||||
nextPendingId(fromId) {
|
||||
const n = this.clusterIds.length;
|
||||
const start = Math.max(0, this.clusterIds.indexOf(fromId));
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const id = this.clusterIds[(start + k) % n];
|
||||
if (id !== fromId && this.railStatusText(id) === 'pending') return id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// Update the acted cluster's rail badge in place (keeping the new status
|
||||
// text visible) and advance the detail pane to the next pending cluster
|
||||
// instead of bouncing back to the attention-queue head.
|
||||
afterDecision(id, status) {
|
||||
this.updateRailStatus(id, status);
|
||||
const next = this.nextPendingId(id);
|
||||
if (next != null) this.selectCluster(next);
|
||||
else this.selectCluster(id); // no pending left: refresh current detail
|
||||
},
|
||||
|
||||
nameValue() {
|
||||
const el = document.getElementById('cluster-name');
|
||||
return el ? el.value : '';
|
||||
},
|
||||
|
||||
async post(path, body) {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async approve() {
|
||||
const acted = this.selected;
|
||||
const r = await this.post(`/cluster/${acted}/approve`, { name: this.nameValue() });
|
||||
this.afterDecision(acted, r.status || 'approved');
|
||||
},
|
||||
async nonTrip() {
|
||||
const acted = this.selected;
|
||||
const r = await this.post(`/cluster/${acted}/non-trip`);
|
||||
this.afterDecision(acted, r.status || 'non_trip');
|
||||
},
|
||||
async skip() {
|
||||
const acted = this.selected;
|
||||
const r = await this.post(`/cluster/${acted}/skip`);
|
||||
this.afterDecision(acted, r.status || 'skipped');
|
||||
},
|
||||
async split() {
|
||||
// F7: require a focused boundary photo before splitting.
|
||||
if (!this.focused) { alert('Focus the first photo of the second trip, then Split.'); return; }
|
||||
await this.post(`/cluster/${this.selected}/split`, { boundary_asset_id: this.focused.dataset.assetId });
|
||||
location.reload(); // split restructures the rail: full refresh is correct
|
||||
},
|
||||
async merge(otherId) {
|
||||
// F17: merge terminally marks both clusters merged with no UI un-merge.
|
||||
if (!confirm('Merge these two clusters? This cannot be undone in the UI.')) return;
|
||||
await this.post(`/cluster/${this.selected}/merge`, { other_id: otherId });
|
||||
location.reload(); // merge restructures the rail: full refresh is correct
|
||||
},
|
||||
async setMember(assetId, included) {
|
||||
await this.post(`/cluster/${this.selected}/member`, { asset_id: assetId, included });
|
||||
this.selectCluster(this.selected);
|
||||
},
|
||||
async approveHighConfidence() {
|
||||
// F19: show a pre-action count and confirm.
|
||||
const threshold = 0.75;
|
||||
const n = this.clusterIds.filter(id => {
|
||||
const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`);
|
||||
return row && row.querySelector('.badge-success[title]'); // confidence "high"
|
||||
}).length;
|
||||
if (!confirm(`Approve ${n} high-confidence cluster(s) (>= ${threshold})?`)) return;
|
||||
const r = await this.post('/approve-high-confidence', { threshold });
|
||||
alert(`Approved ${r.approved} cluster(s).`);
|
||||
location.reload();
|
||||
},
|
||||
async apply() {
|
||||
const r = await this.post(`/cluster/${this.selected}/apply`, {});
|
||||
alert(`Applied ${r.succeeded.length}, failed ${r.failed.length}.`);
|
||||
},
|
||||
async applyAll() {
|
||||
if (!confirm('Apply all approved decisions to Immich?')) return;
|
||||
const r = await this.post('/apply-all', {});
|
||||
const results = r.results || [];
|
||||
let ok = 0, bad = 0;
|
||||
this.failures = [];
|
||||
for (const x of results) {
|
||||
const succeeded = x.succeeded || [];
|
||||
const failed = x.failed || [];
|
||||
ok += succeeded.length;
|
||||
bad += failed.length;
|
||||
if (failed.length) {
|
||||
const id = x.cluster_id != null ? x.cluster_id : x.id;
|
||||
const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`);
|
||||
if (row) row.classList.add('badge-error', 'ring-1', 'ring-error');
|
||||
this.failures.push({
|
||||
id,
|
||||
name: row ? row.dataset.clusterName : `cluster ${id}`,
|
||||
failed: failed.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
alert(`Applied ${ok} write(s), ${bad} failure(s).`);
|
||||
},
|
||||
|
||||
onKey(e) {
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (e.key === '[') { e.preventDefault(); this.moveCluster(-1); }
|
||||
else if (e.key === ']') { e.preventDefault(); this.moveCluster(1); }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); this.navigate(-1); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); this.navigate(1); }
|
||||
else if (e.key === 'Enter') { if (this.focused) this.openLightbox(this.focused); }
|
||||
else if (e.key === 'Escape') { this.closeLightbox(); }
|
||||
else if (k === 'a') { this.approve(); }
|
||||
else if (k === 'n') { this.nonTrip(); }
|
||||
else if (k === 's') { if (this.focused) this.split(); } // F7: suppress until focused
|
||||
else if (k === 'x') { this.skip(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% from "macros.html" import lightbox %}
|
||||
<div data-cluster-id="{{ c.id }}">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<input id="cluster-name" class="input input-bordered input-sm"
|
||||
value="{{ c.decided_name or c.suggested_name }}">
|
||||
<button class="btn btn-sm btn-success" @click="approve()">Approve (A)</button>
|
||||
<button class="btn btn-sm" @click="nonTrip()">Non-trip (N)</button>
|
||||
<!-- F7: Split disabled until a boundary photo is focused -->
|
||||
<button class="btn btn-sm" :disabled="!focused" @click="split()">Split (S)</button>
|
||||
<button class="btn btn-sm" @click="skip()">Skip (X)</button>
|
||||
<button class="btn btn-sm btn-ghost" @click="apply()">Apply</button>
|
||||
{% if prev_id %}<button class="btn btn-xs" @click="merge({{ prev_id }})">⤺ merge prev</button>{% endif %}
|
||||
{% if next_id %}<button class="btn btn-xs" @click="merge({{ next_id }})">merge next ⤻</button>{% endif %}
|
||||
</div>
|
||||
<!-- F7: visible instruction for Split -->
|
||||
<div class="text-xs opacity-60 mb-2" x-show="!focused">
|
||||
Focus the first photo of the second trip (click it or use ← →), then Split.
|
||||
</div>
|
||||
<div class="text-sm opacity-60 mb-2">
|
||||
{{ c.start_at[:10] }} → {{ c.end_at[:10] }} · {{ members | length }} assets · status {{ c.status }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{% for a, m in members %}
|
||||
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-2
|
||||
{% if m.flagged_coverage %}border-info{% elif m.is_outlier %}border-warning{% else %}border-transparent{% endif %}"
|
||||
data-asset-id="{{ a.immich_id }}" data-included="{{ '1' if m.included else '' }}"
|
||||
tabindex="0" @click="openLightbox($el)" @focus="select($el)">
|
||||
<img src="/thumb/{{ a.immich_id }}" class="w-full aspect-square object-cover" loading="lazy" alt="">
|
||||
{% if m.flagged_coverage %}
|
||||
<button class="absolute bottom-1 left-1 badge badge-xs badge-info"
|
||||
@click.stop="setMember('{{ a.immich_id }}', true)">+ include</button>
|
||||
{% elif m.is_outlier %}
|
||||
<button class="absolute bottom-1 left-1 badge badge-xs badge-warning"
|
||||
@click.stop="setMember('{{ a.immich_id }}', false)">– exclude</button>
|
||||
{% endif %}
|
||||
{% if not m.included %}<div class="absolute inset-0 bg-black/50 pointer-events-none"></div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ lightbox() }}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import confidence_badge, status_badge %}
|
||||
{% block title %}trip-cluster{% endblock %}
|
||||
{% block navbar_title %}trip-cluster{% endblock %}
|
||||
{% block content %}
|
||||
{% if stats.clusters == 0 %}
|
||||
<div class="hero py-20"><div class="hero-content text-center"><div>
|
||||
<h1 class="text-2xl font-bold">No clusters yet</h1>
|
||||
{% if stats.assets == 0 %}
|
||||
<p class="opacity-70 mt-2">Nothing ingested. Run
|
||||
<code>categorize ingest</code> then <code>categorize cluster</code>.</p>
|
||||
{% else %}
|
||||
<p class="opacity-70 mt-2">{{ stats.assets }} assets ingested, but this scope
|
||||
produced no clusters. Try a wider <code>categorize ingest</code> scope.</p>
|
||||
{% endif %}
|
||||
</div></div></div>
|
||||
{% else %}
|
||||
<div x-data="clusterReview()" @keydown.window="onKey($event)" class="flex gap-4">
|
||||
<div class="w-72 shrink-0 max-h-[85vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-bold">Clusters</span>
|
||||
<button class="btn btn-xs btn-primary" @click="approveHighConfidence()">Approve high-conf</button>
|
||||
</div>
|
||||
<!-- F8: explain the attention sort -->
|
||||
<p class="text-xs opacity-60 mb-2">Needs attention first — lowest-confidence pending.</p>
|
||||
{% if stats.pending == 0 %}
|
||||
<div class="alert alert-success text-xs mb-2">All reviewed — “Apply all” or <code>categorize apply</code>.</div>
|
||||
{% endif %}
|
||||
<!-- F4: write-failed clusters surfaced here after Apply all -->
|
||||
<div x-show="failures.length" class="alert alert-error text-xs mb-2 flex-col items-start">
|
||||
<span class="font-semibold">Write failures:</span>
|
||||
<ul class="list-disc ml-4">
|
||||
<template x-for="f in failures" :key="f.id">
|
||||
<li><span x-text="f.name"></span> — <span x-text="f.failed"></span> failed</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
{% for c in clusters %}
|
||||
<div class="cluster-row block p-2 rounded cursor-pointer hover:bg-base-300"
|
||||
:class="selected == {{ c.id }} && 'bg-base-300'"
|
||||
data-cluster-id="{{ c.id }}" data-cluster-name="{{ c.decided_name or c.suggested_name }}"
|
||||
@click="selectCluster({{ c.id }})">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<span class="truncate text-sm font-medium">{{ c.decided_name or c.suggested_name }}</span>
|
||||
{{ confidence_badge(c.confidence) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<span class="cluster-status" data-cluster-id="{{ c.id }}">{{ status_badge(c.status) }}</span>
|
||||
<span class="badge badge-xs">{{ c.count }}</span>
|
||||
<span class="text-xs opacity-50">{{ c.start_at[:10] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button class="btn btn-xs btn-block mt-3" @click="applyAll()">Apply all approved</button>
|
||||
</div>
|
||||
<div class="flex-1 relative">
|
||||
<!-- F16: loading indicator for declarative + programmatic swaps -->
|
||||
<div id="detail-indicator" class="htmx-indicator absolute top-2 right-2 z-10">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
<div id="detail" class="flex-1"
|
||||
hx-get="/cluster/{{ clusters[0].id }}" hx-trigger="load" hx-target="#detail"
|
||||
hx-indicator="#detail-indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-2 right-2 text-xs opacity-50">
|
||||
[ ] cluster · ← → grid · Enter open · A approve · N non-trip · S split (focus a photo) · X skip · Esc close
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}<script src="/static/app.js"></script>{% endblock %}
|
||||
@@ -1,6 +1,25 @@
|
||||
import os
|
||||
from app import create_app
|
||||
from app.config import Config
|
||||
from photoflow.core import Store
|
||||
from photoflow.core.models import Asset, Cluster, ClusterMember
|
||||
|
||||
|
||||
def _seed_store(cfg):
|
||||
s = Store(cfg.db_path).connect()
|
||||
for i, t in [("a", "2019-06-01"), ("b", "2019-06-02"), ("c", "2019-07-10")]:
|
||||
s.upsert_asset(Asset(immich_id=i, taken_at=t))
|
||||
cid = s.insert_cluster(
|
||||
Cluster(start_at="2019-06-01", end_at="2019-06-02", suggested_name="Venice",
|
||||
confidence=0.9, kind_guess="trip", status="pending"),
|
||||
[ClusterMember(cluster_id=0, immich_id="a"),
|
||||
ClusterMember(cluster_id=0, immich_id="b")])
|
||||
other = s.insert_cluster(
|
||||
Cluster(start_at="2019-07-10", end_at="2019-07-10", suggested_name="Rome",
|
||||
confidence=0.3, kind_guess="trip", status="pending"),
|
||||
[ClusterMember(cluster_id=0, immich_id="c")])
|
||||
s.close()
|
||||
return cid, other
|
||||
|
||||
|
||||
def _app(tmp_path):
|
||||
@@ -24,3 +43,58 @@ def test_thumb_served(tmp_path):
|
||||
r = client.get("/thumb/a")
|
||||
assert r.status_code == 200 and r.mimetype == "image/jpeg"
|
||||
assert _app(tmp_path).test_client().get("/thumb/missing").status_code == 404
|
||||
|
||||
|
||||
def test_index_lists_clusters(tmp_path):
|
||||
app = _app(tmp_path)
|
||||
_seed_store(app.config["APP_CONFIG"])
|
||||
r = app.test_client().get("/")
|
||||
assert r.status_code == 200 and b"Venice" in r.data and b"Rome" in r.data
|
||||
|
||||
|
||||
def test_index_empty_state(tmp_path):
|
||||
r = _app(tmp_path).test_client().get("/")
|
||||
assert r.status_code == 200 and b"No clusters yet" in r.data
|
||||
|
||||
|
||||
def test_detail_and_approve(tmp_path):
|
||||
app = _app(tmp_path)
|
||||
cid, _ = _seed_store(app.config["APP_CONFIG"])
|
||||
client = app.test_client()
|
||||
d = client.get(f"/cluster/{cid}")
|
||||
assert d.status_code == 200 and b"/thumb/a" in d.data
|
||||
r = client.post(f"/cluster/{cid}/approve", json={"name": "Venezia"})
|
||||
assert r.get_json()["status"] == "approved"
|
||||
s = Store(app.config["APP_CONFIG"].db_path).connect()
|
||||
assert s.get_cluster(cid).decided_name == "Venezia"
|
||||
s.close()
|
||||
|
||||
|
||||
def test_member_toggle_and_high_confidence(tmp_path):
|
||||
app = _app(tmp_path)
|
||||
cid, _ = _seed_store(app.config["APP_CONFIG"])
|
||||
client = app.test_client()
|
||||
client.post(f"/cluster/{cid}/member", json={"asset_id": "b", "included": False})
|
||||
s = Store(app.config["APP_CONFIG"].db_path).connect()
|
||||
assert {m.immich_id: m.included for _, m in s.cluster_members(cid)}["b"] is False
|
||||
s.close()
|
||||
r = client.post("/approve-high-confidence", json={"threshold": 0.75})
|
||||
assert r.get_json()["approved"] == 1 # only the 0.9 cluster
|
||||
|
||||
|
||||
def test_apply_all_with_injected_client(tmp_path):
|
||||
app = _app(tmp_path)
|
||||
cid, _ = _seed_store(app.config["APP_CONFIG"])
|
||||
|
||||
class FakeImmich:
|
||||
def __init__(self): self.tagged = []
|
||||
def upsert_tag(self, name): return f"id:{name}"
|
||||
def tag_assets(self, tid, ids): self.tagged.append((tid, list(ids)))
|
||||
|
||||
fake = FakeImmich()
|
||||
app.config["IMMICH_FACTORY"] = lambda: fake
|
||||
client = app.test_client()
|
||||
client.post(f"/cluster/{cid}/approve", json={"name": "Venice"})
|
||||
r = client.post("/apply-all", json={})
|
||||
assert r.status_code == 200
|
||||
assert any(tid == "id:Venice" for tid, _ in fake.tagged)
|
||||
|
||||
Reference in New Issue
Block a user