From 71e6114f491d5bf0fc918e6a10499cf02c9a0387 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 20 May 2026 20:44:28 -0400 Subject: [PATCH] feat(fc3c): /api/downloads blueprint (list + detail w/ keyset pagination) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 2 + backend/app/api/downloads.py | 110 +++++++++++++++++++++++++++++++++++ tests/test_api_downloads.py | 109 ++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 backend/app/api/downloads.py create mode 100644 tests/test_api_downloads.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index b773727..ffb291a 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -20,6 +20,7 @@ def all_blueprints() -> list[Blueprint]: from .artists import artists_bp from .attachments import attachments_bp from .credentials import credentials_bp + from .downloads import downloads_bp from .gallery import gallery_bp from .import_admin import import_admin_bp from .ml_admin import ml_admin_bp @@ -48,4 +49,5 @@ def all_blueprints() -> list[Blueprint]: sources_bp, platforms_bp, credentials_bp, + downloads_bp, ] diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py new file mode 100644 index 0000000..cac9015 --- /dev/null +++ b/backend/app/api/downloads.py @@ -0,0 +1,110 @@ +"""FC-3c: GET /api/downloads (list) + GET /api/downloads/ (detail). + +List view: keyset-paginated, newest first, optional filtering by +status/source/artist. Returns slim records. +Detail view: full DownloadEvent including the metadata JSONB. +""" + +from quart import Blueprint, jsonify, request +from sqlalchemy import select + +from ..extensions import get_session +from ..models import Artist, DownloadEvent, Source + +downloads_bp = Blueprint("downloads", __name__, url_prefix="/api/downloads") + +_ALLOWED_STATUSES = frozenset({"pending", "running", "ok", "error", "skipped"}) + + +def _summary_from_metadata(metadata: dict | None) -> dict: + metadata = metadata or {} + run_stats = metadata.get("run_stats") or {} + return { + "downloaded": int(run_stats.get("downloaded_count", 0)), + "skipped": int(run_stats.get("skipped_count", 0)), + "quarantined": int(run_stats.get("quarantined_count", 0)), + "duration_seconds": metadata.get("duration_seconds"), + "error_type": metadata.get("error_type"), + } + + +def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | None) -> dict: + return { + "id": event.id, + "source_id": event.source_id, + "artist_name": artist.name if artist else None, + "artist_slug": artist.slug if artist else None, + "platform": source.platform if source else None, + "status": event.status, + "started_at": event.started_at.isoformat() if event.started_at else None, + "finished_at": event.finished_at.isoformat() if event.finished_at else None, + "files_count": event.files_count, + "bytes_downloaded": event.bytes_downloaded, + "error": event.error, + "summary": _summary_from_metadata(event.metadata_), + } + + +def _detail_record(event: DownloadEvent, source: Source | None, artist: Artist | None) -> dict: + base = _list_record(event, source, artist) + base["metadata"] = event.metadata_ or {} + return base + + +@downloads_bp.route("", methods=["GET"]) +async def list_downloads(): + status = request.args.get("status") + if status and status not in _ALLOWED_STATUSES: + return jsonify({"error": "invalid_status"}), 400 + try: + limit = int(request.args.get("limit", "50")) + except ValueError: + return jsonify({"error": "invalid_limit"}), 400 + limit = max(1, min(200, limit)) + before_raw = request.args.get("before") + before = None + if before_raw is not None: + try: + before = int(before_raw) + except ValueError: + return jsonify({"error": "invalid_before"}), 400 + try: + source_id = int(request.args["source_id"]) if "source_id" in request.args else None + artist_id = int(request.args["artist_id"]) if "artist_id" in request.args else None + except (ValueError, KeyError): + return jsonify({"error": "invalid_filter"}), 400 + + async with get_session() as session: + stmt = ( + select(DownloadEvent, Source, Artist) + .outerjoin(Source, Source.id == DownloadEvent.source_id) + .outerjoin(Artist, Artist.id == Source.artist_id) + .order_by(DownloadEvent.id.desc()) + .limit(limit) + ) + if status: + stmt = stmt.where(DownloadEvent.status == status) + if source_id is not None: + stmt = stmt.where(DownloadEvent.source_id == source_id) + if artist_id is not None: + stmt = stmt.where(Source.artist_id == artist_id) + if before is not None: + stmt = stmt.where(DownloadEvent.id < before) + rows = (await session.execute(stmt)).all() + + return jsonify([_list_record(e, s, a) for e, s, a in rows]) + + +@downloads_bp.route("/", methods=["GET"]) +async def get_download(event_id: int): + async with get_session() as session: + row = (await session.execute( + select(DownloadEvent, Source, Artist) + .outerjoin(Source, Source.id == DownloadEvent.source_id) + .outerjoin(Artist, Artist.id == Source.artist_id) + .where(DownloadEvent.id == event_id) + )).first() + if row is None: + return jsonify({"error": "not_found"}), 404 + event, source, artist = row + return jsonify(_detail_record(event, source, artist)) diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py new file mode 100644 index 0000000..d64e649 --- /dev/null +++ b/tests/test_api_downloads.py @@ -0,0 +1,109 @@ +import pytest + +from backend.app import create_app +from backend.app.models import Artist, DownloadEvent, Source + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +@pytest.fixture +async def seed(db): + artist = Artist(name="Alice", slug="alice") + db.add(artist) + await db.flush() + source = Source( + artist_id=artist.id, platform="patreon", + url="https://patreon.com/alice", enabled=True, + config_overrides={}, + ) + db.add(source) + await db.flush() + events = [ + DownloadEvent( + source_id=source.id, status="ok", + files_count=3, bytes_downloaded=100000, + metadata_={ + "run_stats": {"downloaded_count": 3, "skipped_count": 1, "quarantined_count": 0}, + "duration_seconds": 10.5, "error_type": None, + }, + ), + DownloadEvent( + source_id=source.id, status="error", error="auth failed", + metadata_={ + "run_stats": {"downloaded_count": 0, "skipped_count": 0, "quarantined_count": 0}, + "duration_seconds": 2.1, "error_type": "auth_error", + }, + ), + ] + db.add_all(events) + await db.commit() + return artist, source, events + + +@pytest.mark.asyncio +async def test_list_returns_newest_first(client, seed): + resp = await client.get("/api/downloads") + assert resp.status_code == 200 + body = await resp.get_json() + assert len(body) == 2 + assert body[0]["id"] > body[1]["id"] + assert body[0]["summary"]["error_type"] == "auth_error" + assert body[0]["artist_name"] == "Alice" + + +@pytest.mark.asyncio +async def test_list_filter_by_status(client, seed): + resp = await client.get("/api/downloads?status=ok") + body = await resp.get_json() + assert all(r["status"] == "ok" for r in body) + + +@pytest.mark.asyncio +async def test_list_filter_by_source_id(client, seed): + _, source, _ = seed + resp = await client.get(f"/api/downloads?source_id={source.id}") + body = await resp.get_json() + assert all(r["source_id"] == source.id for r in body) + + +@pytest.mark.asyncio +async def test_list_rejects_invalid_status(client): + resp = await client.get("/api/downloads?status=bogus") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_list_before_keyset(client, seed): + _, _, events = seed + middle_id = events[0].id + resp = await client.get(f"/api/downloads?before={middle_id}") + body = await resp.get_json() + assert all(r["id"] < middle_id for r in body) + + +@pytest.mark.asyncio +async def test_detail_returns_full_metadata(client, seed): + _, _, events = seed + target = events[1] + resp = await client.get(f"/api/downloads/{target.id}") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["metadata"]["error_type"] == "auth_error" + assert body["metadata"]["duration_seconds"] == 2.1 + + +@pytest.mark.asyncio +async def test_detail_404(client): + resp = await client.get("/api/downloads/99999") + assert resp.status_code == 404