From a74b313596d0ddeacb6de7b213faa5568d135ba0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 15 May 2026 15:49:25 -0400 Subject: [PATCH] feat(fc2c-i): showcase random-sample service and endpoint --- backend/app/api/__init__.py | 2 ++ backend/app/api/showcase.py | 23 ++++++++++++ backend/app/services/showcase_service.py | 40 +++++++++++++++++++++ tests/test_api_showcase.py | 35 ++++++++++++++++++ tests/test_showcase_service.py | 45 ++++++++++++++++++++++++ 5 files changed, 145 insertions(+) create mode 100644 backend/app/api/showcase.py create mode 100644 backend/app/services/showcase_service.py create mode 100644 tests/test_api_showcase.py create mode 100644 tests/test_showcase_service.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 1523f19..b68e475 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -20,12 +20,14 @@ def all_blueprints() -> list[Blueprint]: from .import_admin import import_admin_bp from .ml_admin import ml_admin_bp from .settings import settings_bp + from .showcase import showcase_bp from .suggestions import suggestions_bp from .tags import tags_bp return [ api_bp, gallery_bp, tags_bp, + showcase_bp, settings_bp, import_admin_bp, suggestions_bp, diff --git a/backend/app/api/showcase.py b/backend/app/api/showcase.py new file mode 100644 index 0000000..232cc86 --- /dev/null +++ b/backend/app/api/showcase.py @@ -0,0 +1,23 @@ +"""Showcase API: scalable random sample of images.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import get_session +from ..services.showcase_service import ShowcaseService + +showcase_bp = Blueprint("showcase", __name__, url_prefix="/api/showcase") + + +@showcase_bp.route("", methods=["GET"]) +async def random_showcase(): + try: + limit = int(request.args.get("limit", "60")) + except ValueError: + return jsonify({"error": "limit must be an integer"}), 400 + async with get_session() as session: + svc = ShowcaseService(session) + try: + images = await svc.random_sample(limit=limit) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"images": images}) diff --git a/backend/app/services/showcase_service.py b/backend/app/services/showcase_service.py new file mode 100644 index 0000000..b45e0b5 --- /dev/null +++ b/backend/app/services/showcase_service.py @@ -0,0 +1,40 @@ +"""Random-sample query for the showcase. + +Uses the tsm_system_rows TABLESAMPLE method (migration 0004) instead of +ORDER BY random(): sampling cost scales with the sample size, not the table, +so it stays fast as the collection grows. SYSTEM_ROWS(n) returns up to n +rows; an empty table yields none. +""" + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import ImageRecord +from .gallery_service import thumbnail_url + + +class ShowcaseService: + def __init__(self, session: AsyncSession): + self.session = session + + async def random_sample(self, limit: int = 60) -> list[dict]: + if limit < 1 or limit > 200: + raise ValueError("limit must be between 1 and 200") + stmt = select(ImageRecord).from_statement( + text( + "SELECT * FROM image_record " + "TABLESAMPLE SYSTEM_ROWS(:n)" + ).bindparams(n=limit) + ) + rows = (await self.session.execute(stmt)).scalars().all() + return [ + { + "id": r.id, + "sha256": r.sha256, + "mime": r.mime, + "width": r.width, + "height": r.height, + "thumbnail_url": thumbnail_url(r.sha256, r.mime), + } + for r in rows + ] diff --git a/tests/test_api_showcase.py b/tests/test_api_showcase.py new file mode 100644 index 0000000..d61e793 --- /dev/null +++ b/tests/test_api_showcase.py @@ -0,0 +1,35 @@ +import pytest + +from backend.app import create_app +from backend.app.models import ImageRecord + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def client(): + app = create_app() + async with app.test_client() as c: + yield c + + +@pytest.mark.asyncio +async def test_showcase_returns_images(client, db): + for i in range(8): + db.add(ImageRecord( + path=f"/images/sc/{i}.jpg", sha256=f"c{i:063d}", + size_bytes=1, mime="image/jpeg", width=10, height=10, + origin="imported_filesystem", integrity_status="unknown", + )) + await db.flush() + await db.commit() + resp = await client.get("/api/showcase?limit=4") + assert resp.status_code == 200 + body = await resp.get_json() + assert "images" in body and len(body["images"]) <= 4 + + +@pytest.mark.asyncio +async def test_showcase_rejects_bad_limit(client): + resp = await client.get("/api/showcase?limit=oops") + assert resp.status_code == 400 diff --git a/tests/test_showcase_service.py b/tests/test_showcase_service.py new file mode 100644 index 0000000..fac5fa9 --- /dev/null +++ b/tests/test_showcase_service.py @@ -0,0 +1,45 @@ +import pytest + +from backend.app.models import ImageRecord +from backend.app.services.showcase_service import ShowcaseService + +pytestmark = pytest.mark.integration + + +async def _seed(db, n): + for i in range(n): + db.add(ImageRecord( + path=f"/images/s/{i}.jpg", sha256=f"s{i:063d}", + size_bytes=1, mime="image/jpeg", width=100, height=200, + origin="imported_filesystem", integrity_status="unknown", + )) + await db.flush() + await db.commit() + + +@pytest.mark.asyncio +async def test_random_sample_returns_items_with_shape(db): + await _seed(db, 20) + svc = ShowcaseService(db) + items = await svc.random_sample(limit=5) + assert 1 <= len(items) <= 5 + first = items[0] + assert set(first.keys()) == { + "id", "sha256", "mime", "width", "height", "thumbnail_url" + } + assert first["thumbnail_url"].startswith("/images/thumbs/") + + +@pytest.mark.asyncio +async def test_random_sample_empty_table_returns_empty(db): + svc = ShowcaseService(db) + assert await svc.random_sample(limit=5) == [] + + +@pytest.mark.asyncio +async def test_random_sample_rejects_bad_limit(db): + svc = ShowcaseService(db) + with pytest.raises(ValueError): + await svc.random_sample(limit=0) + with pytest.raises(ValueError): + await svc.random_sample(limit=201)