From 6c34f86477a1f4b18b2249e4214d18e76a8b5fa7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 6 Jul 2026 23:34:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(gallery):=20hidden-view=20review=20endpoin?= =?UTF-8?q?ts=20=E2=80=94=20list=20+=20keep=20+=20un-hide=20(#141=20step?= =?UTF-8?q?=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/gallery/hidden-review lists unresolved presentation auto-hide flags (image + presentation tag + conflict tag/score), most-concerning first. POST .../keep resolves the flag (the tag stays). POST .../unhide removes the presentation tag (image returns to the gallery), records a TagSuggestionRejection so the head learns it misfired, and resolves the flag. Tests for list/keep/unhide. Frontend review strip (shown when Show-hidden is on) next. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- backend/app/api/gallery.py | 102 +++++++++++++++++++++++++++++++++++- tests/test_hidden_review.py | 89 +++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/test_hidden_review.py diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 678637f..6bf91fe 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -3,9 +3,18 @@ from datetime import UTC, datetime, timedelta from quart import Blueprint, jsonify, request +from sqlalchemy import delete, select, update +from sqlalchemy.orm import aliased from ..extensions import get_session -from ..services.gallery_service import GalleryService +from ..models import ( + ImageRecord, + PresentationReview, + Tag, + TagSuggestionRejection, +) +from ..models.tag import image_tag +from ..services.gallery_service import GalleryService, image_url, thumbnail_url gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery") @@ -219,6 +228,97 @@ async def jump(): return jsonify({"cursor": cursor}) +# -- Hidden-view review (#141): auto-hidden chrome flagged "also looks like +# content", surfaced in the gallery's Show-hidden review strip. ----------- +@gallery_bp.route("/hidden-review", methods=["GET"]) +async def hidden_review(): + """Unresolved presentation auto-hide flags, most-concerning first (highest + content score) — for the gallery's Hidden-view review strip.""" + ptag = aliased(Tag) + ctag = aliased(Tag) + async with get_session() as session: + rows = (await session.execute( + select( + PresentationReview.image_record_id, + PresentationReview.tag_id, + PresentationReview.conflict_tag_id, + PresentationReview.conflict_score, + ImageRecord.path, ImageRecord.thumbnail_path, + ImageRecord.sha256, ImageRecord.mime, + ptag.name.label("tag_name"), + ctag.name.label("conflict_name"), + ) + .join(ImageRecord, ImageRecord.id == PresentationReview.image_record_id) + .join(ptag, ptag.id == PresentationReview.tag_id) + .outerjoin(ctag, ctag.id == PresentationReview.conflict_tag_id) + .where(PresentationReview.resolved_at.is_(None)) + .order_by(PresentationReview.conflict_score.desc()) + )).all() + return jsonify({"items": [ + { + "image_id": r.image_record_id, + "tag_id": r.tag_id, + "tag_name": r.tag_name, + "conflict_tag_id": r.conflict_tag_id, + "conflict_name": r.conflict_name, + "conflict_score": r.conflict_score, + "thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime), + "image_url": image_url(r.path), + } + for r in rows + ]}) + + +@gallery_bp.route( + "/hidden-review///keep", methods=["POST"] +) +async def hidden_review_keep(image_id, tag_id): + """Keep the auto-hide: resolve the flag; the tag stays applied (#141).""" + async with get_session() as session: + await session.execute( + update(PresentationReview) + .where( + PresentationReview.image_record_id == image_id, + PresentationReview.tag_id == tag_id, + ) + .values(resolved_at=datetime.now(UTC)) + ) + await session.commit() + return "", 204 + + +@gallery_bp.route( + "/hidden-review///unhide", methods=["POST"] +) +async def hidden_review_unhide(image_id, tag_id): + """Un-hide: remove the presentation tag (image returns to the gallery), record + a rejection so the head LEARNS it misfired, and resolve the flag (#141).""" + from sqlalchemy.dialects.postgresql import insert as pg_insert + + async with get_session() as session: + await session.execute( + delete(image_tag).where( + image_tag.c.image_record_id == image_id, + image_tag.c.tag_id == tag_id, + ) + ) + await session.execute( + pg_insert(TagSuggestionRejection) + .values(image_record_id=image_id, tag_id=tag_id) + .on_conflict_do_nothing() + ) + await session.execute( + update(PresentationReview) + .where( + PresentationReview.image_record_id == image_id, + PresentationReview.tag_id == tag_id, + ) + .values(resolved_at=datetime.now(UTC)) + ) + await session.commit() + return "", 204 + + @gallery_bp.route("/image/", methods=["GET"]) async def image_detail(image_id: int): async with get_session() as session: diff --git a/tests/test_hidden_review.py b/tests/test_hidden_review.py new file mode 100644 index 0000000..5e15359 --- /dev/null +++ b/tests/test_hidden_review.py @@ -0,0 +1,89 @@ +"""Hidden-view review endpoints (#141): list flagged auto-hides + keep / un-hide.""" +import pytest +from sqlalchemy import select + +from backend.app.models import ( + ImageRecord, + PresentationReview, + Tag, + TagKind, + TagSuggestionRejection, +) +from backend.app.models.tag import image_tag + +pytestmark = pytest.mark.integration + + +async def _setup_flag(db): + banner = (await db.execute( + select(Tag).where(Tag.is_system.is_(True), Tag.name == "banner") + )).scalar_one() + content = Tag(name="looksreal", kind=TagKind.general) + db.add(content) + img = ImageRecord( + path="/images/rv.jpg", sha256="rv" * 32, size_bytes=1, mime="image/jpeg", + width=1, height=1, origin="imported_filesystem", + integrity_status="unknown", + ) + db.add(img) + await db.flush() + await db.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=banner.id, source="presentation_auto")) + db.add(PresentationReview( + image_record_id=img.id, tag_id=banner.id, + conflict_tag_id=content.id, conflict_score=0.8, + )) + await db.commit() + return img, banner, content + + +async def _source(db, image_id, tag_id): + return (await db.execute( + select(image_tag.c.source).where( + image_tag.c.image_record_id == image_id, + image_tag.c.tag_id == tag_id, + ) + )).scalar_one_or_none() + + +@pytest.mark.asyncio +async def test_hidden_review_lists_flag(client, db): + img, banner, content = await _setup_flag(db) + body = await (await client.get("/api/gallery/hidden-review")).get_json() + assert len(body["items"]) == 1 + it = body["items"][0] + assert it["image_id"] == img.id + assert it["tag_id"] == banner.id + assert it["conflict_tag_id"] == content.id + assert it["conflict_name"] == "looksreal" + assert it["conflict_score"] == 0.8 + assert it["thumbnail_url"] + + +@pytest.mark.asyncio +async def test_hidden_review_keep_resolves_but_keeps_tag(client, db): + img, banner, _ = await _setup_flag(db) + resp = await client.post( + f"/api/gallery/hidden-review/{img.id}/{banner.id}/keep") + assert resp.status_code == 204 + body = await (await client.get("/api/gallery/hidden-review")).get_json() + assert body["items"] == [] # flag resolved + assert await _source(db, img.id, banner.id) == "presentation_auto" # tag stays + + +@pytest.mark.asyncio +async def test_hidden_review_unhide_removes_tag_and_records_rejection(client, db): + img, banner, _ = await _setup_flag(db) + resp = await client.post( + f"/api/gallery/hidden-review/{img.id}/{banner.id}/unhide") + assert resp.status_code == 204 + assert await _source(db, img.id, banner.id) is None # back in the gallery + rej = (await db.execute( + select(TagSuggestionRejection).where( + TagSuggestionRejection.image_record_id == img.id, + TagSuggestionRejection.tag_id == banner.id, + ) + )).scalar_one_or_none() + assert rej is not None # head learns it misfired + body = await (await client.get("/api/gallery/hidden-review")).get_json() + assert body["items"] == []