feat(gallery): default-hide presentation chrome (banner/editor screenshot) (#141 step 1)
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:
@@ -77,6 +77,9 @@ def _parse_filters():
|
||||
platform = request.args.get("platform") or None
|
||||
untagged = request.args.get("untagged") 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_to = _parse_date(request.args.get("date_to"))
|
||||
if date_to is not None:
|
||||
@@ -88,6 +91,7 @@ def _parse_filters():
|
||||
"platform": platform,
|
||||
"untagged": untagged, "no_artist": no_artist,
|
||||
"date_from": date_from, "date_to": date_to,
|
||||
"include_hidden": include_hidden,
|
||||
}
|
||||
return filters, sort
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ def _apply_scope(
|
||||
stmt, *, tag_ids, post_id, artist_id, media_type,
|
||||
tag_or_groups=None, tag_exclude=None,
|
||||
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.
|
||||
|
||||
@@ -224,6 +224,12 @@ def _apply_scope(
|
||||
stmt = stmt.where(image_in_any_tag_scope(group))
|
||||
if 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)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
@@ -410,6 +416,31 @@ class GalleryService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
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(
|
||||
self,
|
||||
cursor: str | None,
|
||||
@@ -426,12 +457,16 @@ class GalleryService:
|
||||
no_artist: bool = False,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
include_hidden: bool = False,
|
||||
) -> GalleryPage:
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
_require_single_filter(
|
||||
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);
|
||||
# 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,
|
||||
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
||||
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
|
||||
@@ -497,6 +532,7 @@ class GalleryService:
|
||||
no_artist: bool = False,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
include_hidden: bool = False,
|
||||
) -> list[TimelineBucket]:
|
||||
eff = _effective_date_col()
|
||||
year_col = func.date_part("year", eff).label("yr")
|
||||
@@ -508,12 +544,15 @@ class GalleryService:
|
||||
_require_single_filter(
|
||||
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, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
||||
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())
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
@@ -527,7 +566,7 @@ class GalleryService:
|
||||
tag_exclude: list[int] | None = None,
|
||||
platform: str | None = None, untagged: bool = False,
|
||||
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:
|
||||
"""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
|
||||
@@ -544,12 +583,15 @@ class GalleryService:
|
||||
_require_single_filter(
|
||||
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, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
||||
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"
|
||||
if descending:
|
||||
@@ -574,6 +616,7 @@ class GalleryService:
|
||||
platform: str | None = None,
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
include_hidden: bool = False,
|
||||
) -> GalleryFacets:
|
||||
"""Live facet counts scoped to the current filter. Each facet GROUP is
|
||||
computed with all OTHER active filters applied but its OWN selection
|
||||
@@ -584,10 +627,14 @@ class GalleryService:
|
||||
_require_single_filter(
|
||||
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 = {
|
||||
"tag_ids": tag_ids, "post_id": post_id,
|
||||
"artist_id": artist_id, "media_type": media_type,
|
||||
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
|
||||
"hidden_tag_ids": hidden,
|
||||
}
|
||||
|
||||
# total — the full active filter (the headline result count).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import ImageRecord, Tag, TagKind
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
async def test_scroll_sorts_by_earliest_post_date(db):
|
||||
"""posted_new/posted_old key off earliest_post_date (original publish across
|
||||
|
||||
Reference in New Issue
Block a user