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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user