diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 1d290cf..5021c59 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -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 diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 94e949f..a05e6c7 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -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). diff --git a/tests/test_gallery_service.py b/tests/test_gallery_service.py index 9c4a54b..96266cc 100644 --- a/tests/test_gallery_service.py +++ b/tests/test_gallery_service.py @@ -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