From ff28f29dbb21fbd3e746ec7550134ba590a0a33e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 12:10:57 -0400 Subject: [PATCH] feat(fc2a): add /api/settings/import and /api/system/stats endpoints Settings GET returns the single-row ImportSettings DB record; PATCH does selective field updates (only known editable fields are applied; unknown keys silently ignored). System stats returns image/tag/storage counts, task status histogram, and the active batch summary in one shot for the Settings overview tab. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/settings.py | 123 ++++++++++++++++++++++++++++++++++++ tests/test_api_settings.py | 56 ++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 backend/app/api/settings.py create mode 100644 tests/test_api_settings.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..7d2d842 --- /dev/null +++ b/backend/app/api/settings.py @@ -0,0 +1,123 @@ +"""Settings API: import filters, system stats.""" + +from quart import Blueprint, jsonify, request +from sqlalchemy import func, select + +from ..extensions import make_engine, make_session_factory +from ..models import ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag + +settings_bp = Blueprint("settings", __name__, url_prefix="/api") + +_engine = None +_Session = None + + +def _session_factory(): + global _engine, _Session + if _engine is None: + _engine = make_engine() + _Session = make_session_factory(_engine) + return _Session + + +_EDITABLE_FIELDS = ( + "min_width", + "min_height", + "skip_transparent", + "transparency_threshold", + "skip_single_color", + "single_color_threshold", + "single_color_tolerance", +) + + +@settings_bp.route("/settings/import", methods=["GET"]) +async def get_import_settings(): + Session = _session_factory() + async with Session() as session: + row = ( + await session.execute(select(ImportSettings).where(ImportSettings.id == 1)) + ).scalar_one() + return jsonify({ + "min_width": row.min_width, + "min_height": row.min_height, + "skip_transparent": row.skip_transparent, + "transparency_threshold": row.transparency_threshold, + "skip_single_color": row.skip_single_color, + "single_color_threshold": row.single_color_threshold, + "single_color_tolerance": row.single_color_tolerance, + }) + + +@settings_bp.route("/settings/import", methods=["PATCH"]) +async def update_import_settings(): + body = await request.get_json() + if not isinstance(body, dict): + return jsonify({"error": "body must be a JSON object"}), 400 + + Session = _session_factory() + async with Session() as session: + row = ( + await session.execute(select(ImportSettings).where(ImportSettings.id == 1)) + ).scalar_one() + for field in _EDITABLE_FIELDS: + if field in body: + setattr(row, field, body[field]) + await session.commit() + + return await get_import_settings() + + +@settings_bp.route("/system/stats", methods=["GET"]) +async def system_stats(): + Session = _session_factory() + async with Session() as session: + total_images = (await session.execute(select(func.count(ImageRecord.id)))).scalar_one() + total_tags = (await session.execute(select(func.count(Tag.id)))).scalar_one() + storage_bytes = ( + (await session.execute(select(func.coalesce(func.sum(ImageRecord.size_bytes), 0)))).scalar_one() + ) + + # Task counts grouped by status + status_rows = ( + await session.execute( + select(ImportTask.status, func.count(ImportTask.id)).group_by(ImportTask.status) + ) + ).all() + status_counts = {row[0]: row[1] for row in status_rows} + + # Active batch (most recent running) + active_batch_row = ( + await session.execute( + select(ImportBatch) + .where(ImportBatch.status == "running") + .order_by(ImportBatch.started_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + active_batch = None + if active_batch_row: + active_batch = { + "id": active_batch_row.id, + "source_path": active_batch_row.source_path, + "started_at": active_batch_row.started_at.isoformat(), + "total_files": active_batch_row.total_files, + "imported": active_batch_row.imported, + "skipped": active_batch_row.skipped, + "failed": active_batch_row.failed, + } + + return jsonify({ + "total_images": total_images, + "total_tags": total_tags, + "storage_bytes": storage_bytes, + "tasks": { + "pending": status_counts.get("pending", 0), + "queued": status_counts.get("queued", 0), + "processing": status_counts.get("processing", 0), + "complete": status_counts.get("complete", 0), + "skipped": status_counts.get("skipped", 0), + "failed": status_counts.get("failed", 0), + }, + "active_batch": active_batch, + }) diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py new file mode 100644 index 0000000..bcdb46b --- /dev/null +++ b/tests/test_api_settings.py @@ -0,0 +1,56 @@ +import pytest + +from backend.app import create_app + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +@pytest.mark.asyncio +async def test_get_import_settings_defaults(client): + resp = await client.get("/api/settings/import") + assert resp.status_code == 200 + body = await resp.get_json() + # Defaults from the migration: + assert body["min_width"] == 0 + assert body["min_height"] == 0 + assert body["skip_transparent"] is False + assert body["transparency_threshold"] == 0.9 + + +@pytest.mark.asyncio +async def test_patch_import_settings(client): + resp = await client.patch( + "/api/settings/import", + json={"min_width": 500, "skip_transparent": True, "transparency_threshold": 0.7}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["min_width"] == 500 + assert body["skip_transparent"] is True + assert body["transparency_threshold"] == 0.7 + + +@pytest.mark.asyncio +async def test_patch_rejects_non_object(client): + resp = await client.patch("/api/settings/import", json=[1, 2, 3]) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_system_stats_shape(client): + resp = await client.get("/api/system/stats") + assert resp.status_code == 200 + body = await resp.get_json() + for key in ("total_images", "total_tags", "storage_bytes", "tasks", "active_batch"): + assert key in body + for status in ("pending", "queued", "processing", "complete", "skipped", "failed"): + assert status in body["tasks"]