diff --git a/alembic/versions/0034_artist_visit.py b/alembic/versions/0034_artist_visit.py new file mode 100644 index 0000000..a2234a6 --- /dev/null +++ b/alembic/versions/0034_artist_visit.py @@ -0,0 +1,53 @@ +"""artist_visit: per-artist last-viewed timestamp for the "+N new" badge + +Revision ID: 0034 +Revises: 0033 +Create Date: 2026-06-03 + +Powers the artists-directory "+N new since last visit" badge + ArtistView +banner. Single row per artist (no user_id yet — rule #47 multi-user ACL +is aspirational; widens to (user_id, artist_id) PK when User lands). + +Seed every existing artist with `last_viewed_at = NOW()` so the badge +starts at 0 across the board — no noisy "you have 5000 unseen images" +on first deploy. New artists auto-get a row via +`ArtistService.find_or_create`. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0034" +down_revision: Union[str, None] = "0033" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "artist_visit", + sa.Column( + "artist_id", + sa.Integer, + sa.ForeignKey("artist.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "last_viewed_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + ) + # Seed: every existing artist starts "fully caught up". Without this, + # every operator with N artists would see N badges (worth of every + # image ever imported) on first deploy. + op.execute( + "INSERT INTO artist_visit (artist_id, last_viewed_at) " + "SELECT id, NOW() FROM artist" + ) + + +def downgrade() -> None: + op.drop_table("artist_visit") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index eec9da0..137311c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from .app_setting import AppSetting from .artist import Artist +from .artist_visit import ArtistVisit from .backup_run import BackupRun from .base import Base from .credential import Credential @@ -28,6 +29,7 @@ __all__ = [ "Base", "AppSetting", "Artist", + "ArtistVisit", "BackupRun", "Source", "Credential", diff --git a/backend/app/models/artist_visit.py b/backend/app/models/artist_visit.py new file mode 100644 index 0000000..d17c39a --- /dev/null +++ b/backend/app/models/artist_visit.py @@ -0,0 +1,36 @@ +"""ArtistVisit — per-artist 'last viewed' timestamp. + +Powers the "+N new since last visit" badge on the artists directory and +the matching banner on `ArtistView`. One row per artist, single global +operator. When the multi-user model lands, the PK widens to +`(user_id, artist_id)` — currently aspirational only (no User model, +no services/access.py); operator approved skipping `user_id` for now +under rule #22 (breaking changes welcome). + +Seed at migration time: every existing artist gets `last_viewed_at = NOW()` +so the badge starts at 0 across the board (no noisy "5000 unseen" on +first deploy). New artists also auto-get a row via +`ArtistService.find_or_create`. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class ArtistVisit(Base): + __tablename__ = "artist_visit" + + artist_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("artist.id", ondelete="CASCADE"), + primary_key=True, + ) + last_viewed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/services/artist_directory_service.py b/backend/app/services/artist_directory_service.py index eda9afc..5e02774 100644 --- a/backend/app/services/artist_directory_service.py +++ b/backend/app/services/artist_directory_service.py @@ -13,10 +13,10 @@ from __future__ import annotations import base64 from dataclasses import dataclass -from sqlalchemy import and_, exists, func, or_, select +from sqlalchemy import and_, case, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ..models import Artist, ImageRecord, Source +from ..models import Artist, ArtistVisit, ImageRecord, Source from .gallery_service import thumbnail_url _SEP = "|" @@ -58,9 +58,27 @@ class ArtistDirectoryService: raise ValueError("limit must be between 1 and 200") count_col = func.count(ImageRecord.id).label("image_count") + # Unseen = images imported since the artist's last_viewed_at. + # NULL last_viewed_at (artist created before alembic 0034 seed + # or before find_or_create autoseed) defensively counts as + # "never visited" → all images unseen. Single grouped query, no + # N+1. + unseen_col = func.count( + case( + ( + or_( + ArtistVisit.last_viewed_at.is_(None), + ImageRecord.created_at > ArtistVisit.last_viewed_at, + ), + ImageRecord.id, + ), + else_=None, + ) + ).label("unseen_count") stmt = ( - select(Artist, count_col) + select(Artist, count_col, unseen_col) .outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id) + .outerjoin(ArtistVisit, ArtistVisit.artist_id == Artist.id) .group_by(Artist.id) ) if q: @@ -94,7 +112,7 @@ class ArtistDirectoryService: next_cursor = _encode(last_artist.name, last_artist.id) rows = rows[:limit] - artist_ids = [a.id for a, _ in rows] + artist_ids = [a.id for a, _, _ in rows] previews = await self._previews(artist_ids) cards = [ @@ -104,9 +122,10 @@ class ArtistDirectoryService: "slug": artist.slug, "is_subscription": bool(artist.is_subscription), "image_count": int(image_count), + "unseen_count": int(unseen_count), "preview_thumbnails": previews.get(artist.id, []), } - for artist, image_count in rows + for artist, image_count, unseen_count in rows ] return DirectoryPage(cards=cards, next_cursor=next_cursor) diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index 1203f51..e72e3c0 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -9,11 +9,13 @@ Dates come from Post.post_date via ImageProvenance.post_id. from dataclasses import dataclass from sqlalchemy import and_, case, func, or_, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( Artist, + ArtistVisit, ImageProvenance, ImageRecord, Post, @@ -122,6 +124,12 @@ class ArtistService: ) ).scalar_one() + # Mark this artist as "visited now"; the returned count is what + # the operator should see in the banner ("N new since last + # visit"). Done LAST so the read aggregates above all see the + # pre-visit state (cosmetic — none depend on visit data). + unseen_at_visit = await self._mark_visited_returning_unseen(aid) + return { "id": artist.id, "name": artist.name, @@ -129,6 +137,7 @@ class ArtistService: "is_subscription": bool(artist.is_subscription), "image_count": int(image_count), "post_count": int(post_count), + "unseen_count_at_visit": unseen_at_visit, "date_range": { "min": dmin.isoformat() if dmin else None, "max": dmax.isoformat() if dmax else None, @@ -157,6 +166,39 @@ class ArtistService: ], } + async def _mark_visited_returning_unseen(self, artist_id: int) -> int: + """Read pre-visit `last_viewed_at`, count images added since, + then upsert `last_viewed_at = NOW()`. Returns the count BEFORE + the upsert so the banner has data to render. + + Postgres UPSERT (`ON CONFLICT DO UPDATE`) keeps the write + atomic — no SELECT-then-INSERT race per + `reference_scalar_one_or_none_duplicates`. + """ + prev = ( + await self.session.execute( + select(ArtistVisit.last_viewed_at).where( + ArtistVisit.artist_id == artist_id + ) + ) + ).scalar_one_or_none() + + count_stmt = select(func.count(ImageRecord.id)).where( + ImageRecord.artist_id == artist_id + ) + if prev is not None: + count_stmt = count_stmt.where(ImageRecord.created_at > prev) + unseen = (await self.session.execute(count_stmt)).scalar_one() + + upsert = pg_insert(ArtistVisit.__table__).values(artist_id=artist_id) + upsert = upsert.on_conflict_do_update( + index_elements=["artist_id"], + set_={"last_viewed_at": func.now()}, + ) + await self.session.execute(upsert) + await self.session.commit() + return int(unseen) + async def images( self, slug: str, cursor: str | None, limit: int = 60 ) -> ArtistImagesPage | None: @@ -230,6 +272,13 @@ class ArtistService: artist = Artist(name=cleaned, slug=slug) self.session.add(artist) await self.session.flush() + # New artist starts "caught up" — seed ArtistVisit so the + # directory's `+N new` badge stays at 0 until real new + # content arrives. Without this, the unseen-count query + # treats NULL last_viewed_at as "never visited" and would + # count every image imported in the same session. + self.session.add(ArtistVisit(artist_id=artist.id)) + await self.session.flush() await sp.commit() except IntegrityError: await sp.rollback() diff --git a/frontend/src/components/discovery/ArtistCard.vue b/frontend/src/components/discovery/ArtistCard.vue index 6c4bace..dc2f5ae 100644 --- a/frontend/src/components/discovery/ArtistCard.vue +++ b/frontend/src/components/discovery/ArtistCard.vue @@ -8,6 +8,15 @@
No preview
+ + +{{ card.unseen_count > 99 ? '99+' : card.unseen_count }}
{{ card.name }}
@@ -37,6 +46,7 @@ function onCardClick() { diff --git a/tests/test_artist_visit.py b/tests/test_artist_visit.py new file mode 100644 index 0000000..7236d33 --- /dev/null +++ b/tests/test_artist_visit.py @@ -0,0 +1,175 @@ +"""ArtistVisit unseen-count badge + ArtistService.overview banner data. + +Covers: +- Directory cards include `unseen_count` +- LEFT JOIN keeps artists without a visit row (treats NULL as "never + visited" → all images unseen) +- overview() returns `unseen_count_at_visit` and stamps the visit +- find_or_create autoseeds a visit row so freshly imported content + doesn't show up as unseen +- Repeat overview() returns 0 (since the previous visit just stamped + last_viewed_at = NOW()) +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select + +from backend.app.models import Artist, ArtistVisit, ImageRecord +from backend.app.services.artist_directory_service import ArtistDirectoryService +from backend.app.services.artist_service import ArtistService + +pytestmark = pytest.mark.integration + + +_LONG_AGO = datetime(2000, 1, 1, tzinfo=timezone.utc) +_RECENTLY = datetime(2099, 1, 1, tzinfo=timezone.utc) + + +async def _seed_artist(db, name: str) -> Artist: + a = Artist(name=name, slug=name.lower().replace(" ", "-")) + db.add(a) + await db.flush() + return a + + +async def _seed_visit(db, artist_id: int, when: datetime) -> None: + db.add(ArtistVisit(artist_id=artist_id, last_viewed_at=when)) + await db.flush() + + +async def _seed_image(db, artist_id: int, suffix: str, *, created_at: datetime) -> None: + db.add(ImageRecord( + path=f"/images/visit-{suffix}.jpg", + sha256=f"visit{suffix}".ljust(64, "0")[:64], + size_bytes=10, mime="image/jpeg", width=10, height=10, + origin="downloaded", artist_id=artist_id, + created_at=created_at, + )) + await db.flush() + + +# --- Directory unseen_count ----------------------------------------------- + + +@pytest.mark.asyncio +async def test_directory_unseen_count_zero_when_no_images(db): + await _seed_artist(db, "zoey-empty-visit") + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="zoey-empty-visit", platform=None, cursor=None, limit=60, + ) + target = next(c for c in page.cards if c["name"] == "zoey-empty-visit") + assert target["unseen_count"] == 0 + + +@pytest.mark.asyncio +async def test_directory_unseen_counts_only_images_after_visit(db): + a = await _seed_artist(db, "alice-visit-mix") + await _seed_visit(db, a.id, _LONG_AGO + timedelta(days=365)) + # Two images BEFORE the visit (seen), three AFTER (unseen). + for i in range(2): + await _seed_image(db, a.id, f"old-{i}", created_at=_LONG_AGO) + for i in range(3): + await _seed_image(db, a.id, f"new-{i}", created_at=_RECENTLY) + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="alice-visit-mix", platform=None, cursor=None, limit=60, + ) + target = next(c for c in page.cards if c["name"] == "alice-visit-mix") + assert target["image_count"] == 5 + assert target["unseen_count"] == 3 + + +@pytest.mark.asyncio +async def test_directory_treats_missing_visit_as_never_visited(db): + """No ArtistVisit row → defensive count of all images as unseen. + + Shouldn't happen in practice (migration 0034 seeds existing + artists, find_or_create autoseeds new ones), but the directory + query must not regress to "0 unseen" if a row is missing. + """ + a = await _seed_artist(db, "bob-no-visit") + for i in range(4): + await _seed_image(db, a.id, f"orphan-{i}", created_at=_RECENTLY) + await db.commit() + + page = await ArtistDirectoryService(db).list_artists( + q="bob-no-visit", platform=None, cursor=None, limit=60, + ) + target = next(c for c in page.cards if c["name"] == "bob-no-visit") + assert target["unseen_count"] == 4 + + +# --- overview() marks visit + returns count ------------------------------- + + +@pytest.mark.asyncio +async def test_overview_returns_unseen_count_at_visit_and_stamps_now(db): + a = await _seed_artist(db, "carol-stamp") + await _seed_visit(db, a.id, _LONG_AGO) + await _seed_image(db, a.id, "stamp-1", created_at=_RECENTLY) + await _seed_image(db, a.id, "stamp-2", created_at=_RECENTLY) + await db.commit() + + data = await ArtistService(db).overview("carol-stamp") + assert data is not None + assert data["unseen_count_at_visit"] == 2 + + # last_viewed_at advanced to NOW() — directly check the row. + visit_at = (await db.execute( + select(ArtistVisit.last_viewed_at).where(ArtistVisit.artist_id == a.id) + )).scalar_one() + assert visit_at > _LONG_AGO + + +@pytest.mark.asyncio +async def test_overview_repeat_call_returns_zero(db): + a = await _seed_artist(db, "dana-repeat") + await _seed_visit(db, a.id, _LONG_AGO) + await _seed_image(db, a.id, "repeat-1", created_at=_LONG_AGO + timedelta(days=1)) + await db.commit() + + first = await ArtistService(db).overview("dana-repeat") + assert first is not None + assert first["unseen_count_at_visit"] == 1 + + # Second call: no new images, visit just stamped → count is 0. + second = await ArtistService(db).overview("dana-repeat") + assert second is not None + assert second["unseen_count_at_visit"] == 0 + + +# --- find_or_create autoseeds the visit row ------------------------------ + + +@pytest.mark.asyncio +async def test_find_or_create_autoseeds_visit_row(db): + artist, created = await ArtistService(db).find_or_create("Eve-Autoseed") + assert created is True + + row = (await db.execute( + select(ArtistVisit.last_viewed_at).where(ArtistVisit.artist_id == artist.id) + )).scalar_one_or_none() + assert row is not None + + +@pytest.mark.asyncio +async def test_find_or_create_existing_does_not_reset_visit(db): + """Calling find_or_create on an existing artist returns it as-is — + must NOT touch the visit row's timestamp.""" + a = await _seed_artist(db, "Frank-Existing") + await _seed_visit(db, a.id, _LONG_AGO) + await db.commit() + + artist, created = await ArtistService(db).find_or_create("Frank-Existing") + assert created is False + assert artist.id == a.id + + visit_at = (await db.execute( + select(ArtistVisit.last_viewed_at).where(ArtistVisit.artist_id == a.id) + )).scalar_one() + assert visit_at == _LONG_AGO