From fd8cf8300309f43117350c80d97bc2f68b11f718 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 12:09:44 -0400 Subject: [PATCH] feat(fc2a): add /api/gallery endpoints (scroll, timeline, jump, image detail) Thin Quart blueprint that delegates to GalleryService. Cursor-based scroll returns images + a date_groups summary so the SPA can render year-month headers without re-grouping client-side. Timeline returns year-month buckets for the sidebar jump nav. Jump returns a cursor positioned at a specific year-month so the gallery can scroll to historical periods. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/__init__.py | 6 ++- backend/app/api/__init__.py | 13 ++++- backend/app/api/gallery.py | 97 +++++++++++++++++++++++++++++++++++++ tests/test_api_gallery.py | 70 ++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/gallery.py create mode 100644 tests/test_api_gallery.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index cd295e9..390b783 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -4,7 +4,7 @@ import logging from quart import Quart -from .api import api_bp +from .api import all_blueprints from .config import get_config from .frontend import frontend_bp @@ -15,7 +15,9 @@ def create_app() -> Quart: app = Quart(__name__) app.secret_key = cfg.secret_key - app.register_blueprint(api_bp) + + for bp in all_blueprints(): + app.register_blueprint(bp) # Registered last so /api/* routes win over the SPA catch-all. app.register_blueprint(frontend_bp) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index c456a71..3a96e9d 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,4 +1,9 @@ -"""API blueprint registration.""" +"""API blueprint registration. + +This module is imported by the Quart app factory; it just exposes the +top-level `api_bp` for backward compatibility with FC-1's health route, +and ALL_BLUEPRINTS for the factory to register sibling blueprints. +""" from quart import Blueprint @@ -6,3 +11,9 @@ from . import health api_bp = Blueprint("api", __name__, url_prefix="/api") api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"]) + + +def all_blueprints() -> list[Blueprint]: + from .gallery import gallery_bp + # FC-2a additions: tags, settings, import_admin land in later tasks + return [api_bp, gallery_bp] diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py new file mode 100644 index 0000000..e824297 --- /dev/null +++ b/backend/app/api/gallery.py @@ -0,0 +1,97 @@ +"""Gallery API: cursor scroll, timeline, jump, image detail.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import make_engine, make_session_factory +from ..services.gallery_service import GalleryService + +gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery") + +_engine = None +_Session = None + + +def _session_factory(): + global _engine, _Session + if _engine is None: + _engine = make_engine() + _Session = make_session_factory(_engine) + return _Session + + +@gallery_bp.route("/scroll", methods=["GET"]) +async def scroll(): + cursor = request.args.get("cursor") or None + try: + limit = int(request.args.get("limit", "50")) + except ValueError: + return jsonify({"error": "limit must be an integer"}), 400 + tag_id_raw = request.args.get("tag_id") + tag_id = int(tag_id_raw) if tag_id_raw else None + + Session = _session_factory() + async with Session() as session: + svc = GalleryService(session) + try: + page = await svc.scroll(cursor=cursor, limit=limit, tag_id=tag_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + return jsonify( + { + "images": [ + { + "id": i.id, + "sha256": i.sha256, + "mime": i.mime, + "width": i.width, + "height": i.height, + "created_at": i.created_at.isoformat(), + "thumbnail_url": i.thumbnail_url, + } + for i in page.images + ], + "next_cursor": page.next_cursor, + "date_groups": [ + {"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups + ], + } + ) + + +@gallery_bp.route("/timeline", methods=["GET"]) +async def timeline(): + tag_id_raw = request.args.get("tag_id") + tag_id = int(tag_id_raw) if tag_id_raw else None + Session = _session_factory() + async with Session() as session: + svc = GalleryService(session) + buckets = await svc.timeline(tag_id=tag_id) + return jsonify([{"year": b.year, "month": b.month, "count": b.count} for b in buckets]) + + +@gallery_bp.route("/jump", methods=["GET"]) +async def jump(): + try: + year = int(request.args["year"]) + month = int(request.args["month"]) + except (KeyError, ValueError): + return jsonify({"error": "year and month query params required"}), 400 + tag_id_raw = request.args.get("tag_id") + tag_id = int(tag_id_raw) if tag_id_raw else None + Session = _session_factory() + async with Session() as session: + svc = GalleryService(session) + cursor = await svc.jump_cursor(year=year, month=month, tag_id=tag_id) + return jsonify({"cursor": cursor}) + + +@gallery_bp.route("/image/", methods=["GET"]) +async def image_detail(image_id: int): + Session = _session_factory() + async with Session() as session: + svc = GalleryService(session) + payload = await svc.get_image_with_tags(image_id) + if payload is None: + return jsonify({"error": "not found"}), 404 + return jsonify(payload) diff --git a/tests/test_api_gallery.py b/tests/test_api_gallery.py new file mode 100644 index 0000000..5293687 --- /dev/null +++ b/tests/test_api_gallery.py @@ -0,0 +1,70 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from backend.app import create_app +from backend.app.models import ImageRecord + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +async def _seed(db, count: int = 3): + base = datetime.now(timezone.utc) + for i in range(count): + r = ImageRecord( + path=f"/images/x/{i}.jpg", + sha256=f"x{i:063d}", + size_bytes=1, mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + ) + r.created_at = base - timedelta(minutes=i) + db.add(r) + await db.flush() + await db.commit() + + +@pytest.mark.asyncio +async def test_scroll_returns_images(client, db): + await _seed(db) + resp = await client.get("/api/gallery/scroll?limit=10") + assert resp.status_code == 200 + body = await resp.get_json() + assert len(body["images"]) == 3 + assert "next_cursor" in body + assert "date_groups" in body + + +@pytest.mark.asyncio +async def test_scroll_rejects_bad_limit(client): + resp = await client.get("/api/gallery/scroll?limit=notanumber") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_timeline_endpoint(client, db): + await _seed(db) + resp = await client.get("/api/gallery/timeline") + assert resp.status_code == 200 + body = await resp.get_json() + assert sum(b["count"] for b in body) == 3 + + +@pytest.mark.asyncio +async def test_jump_requires_year_month(client): + resp = await client.get("/api/gallery/jump") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_image_detail_404_when_missing(client): + resp = await client.get("/api/gallery/image/99999") + assert resp.status_code == 404