feat(gallery): default-hide presentation chrome (banner/editor screenshot) (#141 step 1)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Failing after 3m41s

The default gallery + facets now implicitly exclude images carrying a
presentation system tag (banner / editor screenshot), reusing the tag-scope
EXISTS machinery. Suppressed when the operator explicitly filters FOR a
presentation tag OR passes include_hidden (the Hidden view — step 2). `wip` is
NOT hidden (real, in-progress art). include_hidden threaded through
scroll/timeline/jump_cursor/facets + the gallery API _parse_filters. Test covers
default-hide, include_hidden, explicit-filter-shows, and wip-stays-visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 22:27:00 -04:00
parent 4f4ddecf75
commit e86b91dfe2
3 changed files with 98 additions and 5 deletions
+4
View File
@@ -77,6 +77,9 @@ def _parse_filters():
platform = request.args.get("platform") or None platform = request.args.get("platform") or None
untagged = request.args.get("untagged") in ("1", "true", "yes") untagged = request.args.get("untagged") in ("1", "true", "yes")
no_artist = request.args.get("no_artist") in ("1", "true", "yes") no_artist = request.args.get("no_artist") in ("1", "true", "yes")
# Show the presentation chrome (banner / editor screenshot) that the default
# gallery hides — the Hidden view sets this (milestone 141).
include_hidden = request.args.get("include_hidden") in ("1", "true", "yes")
date_from = _parse_date(request.args.get("date_from")) date_from = _parse_date(request.args.get("date_from"))
date_to = _parse_date(request.args.get("date_to")) date_to = _parse_date(request.args.get("date_to"))
if date_to is not None: if date_to is not None:
@@ -88,6 +91,7 @@ def _parse_filters():
"platform": platform, "platform": platform,
"untagged": untagged, "no_artist": no_artist, "untagged": untagged, "no_artist": no_artist,
"date_from": date_from, "date_to": date_to, "date_from": date_from, "date_to": date_to,
"include_hidden": include_hidden,
} }
return filters, sort return filters, sort
+52 -5
View File
@@ -187,7 +187,7 @@ def _apply_scope(
stmt, *, tag_ids, post_id, artist_id, media_type, stmt, *, tag_ids, post_id, artist_id, media_type,
tag_or_groups=None, tag_exclude=None, tag_or_groups=None, tag_exclude=None,
platform=None, untagged=False, no_artist=False, platform=None, untagged=False, no_artist=False,
date_from=None, date_to=None, date_from=None, date_to=None, hidden_tag_ids=None,
): ):
"""Apply the composable gallery filters to a statement. """Apply the composable gallery filters to a statement.
@@ -224,6 +224,12 @@ def _apply_scope(
stmt = stmt.where(image_in_any_tag_scope(group)) stmt = stmt.where(image_in_any_tag_scope(group))
if tag_exclude: if tag_exclude:
stmt = stmt.where(~image_in_any_tag_scope(tag_exclude)) stmt = stmt.where(~image_in_any_tag_scope(tag_exclude))
# Presentation chrome (banner / editor screenshot) is hidden from the default
# gallery — an implicit exclude the caller supplies unless the operator asked
# to include hidden or is explicitly filtering for a presentation tag
# (milestone 141). `wip` is NOT hidden. Resolved to ids by _hidden_tag_ids.
if hidden_tag_ids:
stmt = stmt.where(~image_in_any_tag_scope(hidden_tag_ids))
prov = _provenance_clause(post_id, artist_id) prov = _provenance_clause(post_id, artist_id)
if prov is not None: if prov is not None:
stmt = stmt.where(prov) stmt = stmt.where(prov)
@@ -410,6 +416,31 @@ class GalleryService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
async def _hidden_tag_ids(
self, include_hidden, tag_ids, tag_or_groups,
) -> list[int] | None:
"""Presentation-chrome tag ids to implicitly exclude from a gallery query,
or None. None when the caller asked to include hidden, when the operator
is explicitly filtering FOR a presentation tag (they clearly want to see
it), or when no presentation tags exist. (milestone 141)"""
if include_hidden:
return None
rows = await self.session.execute(
select(Tag.id).where(
Tag.is_system.is_(True),
Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
)
)
pres = [r[0] for r in rows]
if not pres:
return None
explicit = set(tag_ids or [])
for group in tag_or_groups or []:
explicit.update(group)
if explicit & set(pres):
return None
return pres
async def scroll( async def scroll(
self, self,
cursor: str | None, cursor: str | None,
@@ -426,12 +457,16 @@ class GalleryService:
no_artist: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
include_hidden: bool = False,
) -> GalleryPage: ) -> GalleryPage:
if limit < 1 or limit > 200: if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200") raise ValueError("limit must be between 1 and 200")
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
# eff is the ACTIVE sort column (effective_date or earliest_post_date); # eff is the ACTIVE sort column (effective_date or earliest_post_date);
# the cursor, ordering and year/month grouping all key off it, so the # the cursor, ordering and year/month grouping all key off it, so the
@@ -444,7 +479,7 @@ class GalleryService:
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist, platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to, date_from=date_from, date_to=date_to, hidden_tag_ids=hidden,
) )
descending = sort not in _ASCENDING_SORTS descending = sort not in _ASCENDING_SORTS
@@ -497,6 +532,7 @@ class GalleryService:
no_artist: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
include_hidden: bool = False,
) -> list[TimelineBucket]: ) -> list[TimelineBucket]:
eff = _effective_date_col() eff = _effective_date_col()
year_col = func.date_part("year", eff).label("yr") year_col = func.date_part("year", eff).label("yr")
@@ -508,12 +544,15 @@ class GalleryService:
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist, platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to, date_from=date_from, date_to=date_to, hidden_tag_ids=hidden,
) )
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc()) stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
@@ -527,7 +566,7 @@ class GalleryService:
tag_exclude: list[int] | None = None, tag_exclude: list[int] | None = None,
platform: str | None = None, untagged: bool = False, platform: str | None = None, untagged: bool = False,
no_artist: bool = False, date_from: datetime | None = None, no_artist: bool = False, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None, include_hidden: bool = False,
) -> str | None: ) -> str | None:
"""Returns a cursor that, when passed to scroll() with the same sort, """Returns a cursor that, when passed to scroll() with the same sort,
positions at the first image of the given year-month. None if the positions at the first image of the given year-month. None if the
@@ -544,12 +583,15 @@ class GalleryService:
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist, platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to, date_from=date_from, date_to=date_to, hidden_tag_ids=hidden,
) )
descending = sort != "oldest" descending = sort != "oldest"
if descending: if descending:
@@ -574,6 +616,7 @@ class GalleryService:
platform: str | None = None, platform: str | None = None,
untagged: bool = False, no_artist: bool = False, untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None, date_from: datetime | None = None, date_to: datetime | None = None,
include_hidden: bool = False,
) -> GalleryFacets: ) -> GalleryFacets:
"""Live facet counts scoped to the current filter. Each facet GROUP is """Live facet counts scoped to the current filter. Each facet GROUP is
computed with all OTHER active filters applied but its OWN selection computed with all OTHER active filters applied but its OWN selection
@@ -584,10 +627,14 @@ class GalleryService:
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
common = { common = {
"tag_ids": tag_ids, "post_id": post_id, "tag_ids": tag_ids, "post_id": post_id,
"artist_id": artist_id, "media_type": media_type, "artist_id": artist_id, "media_type": media_type,
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude, "tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
"hidden_tag_ids": hidden,
} }
# total — the full active filter (the headline result count). # total — the full active filter (the headline result count).
+42
View File
@@ -1,6 +1,7 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import pytest import pytest
from sqlalchemy import select
from backend.app.models import ImageRecord, Tag, TagKind from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
@@ -73,6 +74,47 @@ async def test_scroll_returns_newest_first(db):
assert page.images[0].created_at > page.images[-1].created_at assert page.images[0].created_at > page.images[-1].created_at
async def _system_tag(db, name):
return (await db.execute(
select(Tag).where(Tag.is_system.is_(True), Tag.name == name)
)).scalar_one()
@pytest.mark.asyncio
async def test_scroll_hides_presentation_chrome_by_default(db):
# banner / editor screenshot (presentation system tags) are hidden from the
# default gallery; wip (also a system tag) is NOT — it's real, in-progress
# art (milestone 141). Seeded system tags survive the harness TRUNCATE.
imgs = await _seed_images(db, 3, sha_prefix="p")
banner = await _system_tag(db, "banner")
wip = await _system_tag(db, "wip")
await db.execute(image_tag.insert().values(
image_record_id=imgs[0].id, tag_id=banner.id, source="manual"))
await db.execute(image_tag.insert().values(
image_record_id=imgs[1].id, tag_id=wip.id, source="manual"))
await db.flush()
svc = GalleryService(db)
# Default: the banner image is hidden; the wip image + the plain image stay.
default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images}
assert imgs[0].id not in default_ids # banner hidden
assert imgs[1].id in default_ids # wip visible
assert imgs[2].id in default_ids # plain visible
# include_hidden surfaces the banner image (the Hidden view).
shown = {i.id for i in (
await svc.scroll(cursor=None, limit=10, include_hidden=True)
).images}
assert imgs[0].id in shown
# Filtering explicitly FOR banner shows it (a view exclusively for a
# presentation tag suppresses the implicit hide).
only_banner = {i.id for i in (
await svc.scroll(cursor=None, limit=10, tag_ids=[banner.id])
).images}
assert only_banner == {imgs[0].id}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scroll_sorts_by_earliest_post_date(db): async def test_scroll_sorts_by_earliest_post_date(db):
"""posted_new/posted_old key off earliest_post_date (original publish across """posted_new/posted_old key off earliest_post_date (original publish across