feat(series): /api/series pages/add/remove/reorder/cover routes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+85
-3
@@ -8,6 +8,7 @@ from ..extensions import get_session
|
|||||||
from ..models import Tag, TagKind
|
from ..models import Tag, TagKind
|
||||||
from ..models.tag_allowlist import TagAllowlist
|
from ..models.tag_allowlist import TagAllowlist
|
||||||
from ..services.bulk_tag_service import BulkTagService
|
from ..services.bulk_tag_service import BulkTagService
|
||||||
|
from ..services.series_service import SeriesError, SeriesService
|
||||||
from ..services.tag_directory_service import TagDirectoryService
|
from ..services.tag_directory_service import TagDirectoryService
|
||||||
from ..services.tag_service import (
|
from ..services.tag_service import (
|
||||||
TagMergeConflict,
|
TagMergeConflict,
|
||||||
@@ -27,7 +28,9 @@ def _coerce_kind(raw: str | None) -> TagKind | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_bulk_ids(body) -> tuple[list[int] | None, tuple | None]:
|
def _parse_bulk_ids(
|
||||||
|
body, *, max_ids: int = 200
|
||||||
|
) -> tuple[list[int] | None, tuple | None]:
|
||||||
"""Returns (image_ids, error). error is (json, status) or None."""
|
"""Returns (image_ids, error). error is (json, status) or None."""
|
||||||
if not body or "image_ids" not in body:
|
if not body or "image_ids" not in body:
|
||||||
return None, (jsonify({"error": "image_ids required"}), 400)
|
return None, (jsonify({"error": "image_ids required"}), 400)
|
||||||
@@ -44,9 +47,9 @@ def _parse_bulk_ids(body) -> tuple[list[int] | None, tuple | None]:
|
|||||||
jsonify({"error": "image_ids must be integers"}),
|
jsonify({"error": "image_ids must be integers"}),
|
||||||
400,
|
400,
|
||||||
)
|
)
|
||||||
if len(ids) > 200:
|
if len(ids) > max_ids:
|
||||||
return None, (
|
return None, (
|
||||||
jsonify({"error": "selection too large (max 200)"}),
|
jsonify({"error": f"selection too large (max {max_ids})"}),
|
||||||
400,
|
400,
|
||||||
)
|
)
|
||||||
return ids, None
|
return ids, None
|
||||||
@@ -282,3 +285,82 @@ async def bulk_remove_tag():
|
|||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify({"removed_count": removed})
|
return jsonify({"removed_count": removed})
|
||||||
|
|
||||||
|
|
||||||
|
def _series_err(exc: SeriesError):
|
||||||
|
msg = str(exc)
|
||||||
|
status = 404 if ("not found" in msg or "not a series" in msg) else 400
|
||||||
|
return jsonify({"error": msg}), status
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
|
||||||
|
async def series_pages(tag_id: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
data = await SeriesService(session).list_pages(tag_id)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/pages", methods=["POST"])
|
||||||
|
async def series_add(tag_id: int):
|
||||||
|
body = await request.get_json()
|
||||||
|
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
n = await SeriesService(session).add_images(tag_id, ids)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"added_count": n})
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/pages/remove", methods=["POST"])
|
||||||
|
async def series_remove(tag_id: int):
|
||||||
|
body = await request.get_json()
|
||||||
|
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
n = await SeriesService(session).remove_images(tag_id, ids)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"removed_count": n})
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/reorder", methods=["POST"])
|
||||||
|
async def series_reorder(tag_id: int):
|
||||||
|
body = await request.get_json()
|
||||||
|
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
await SeriesService(session).reorder(tag_id, ids)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/cover", methods=["POST"])
|
||||||
|
async def series_cover(tag_id: int):
|
||||||
|
body = await request.get_json()
|
||||||
|
if not body or "image_id" not in body:
|
||||||
|
return jsonify({"error": "image_id required"}), 400
|
||||||
|
try:
|
||||||
|
image_id = int(body["image_id"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "image_id must be an integer"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
await SeriesService(session).set_cover(tag_id, image_id)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app import create_app
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _series(client, name="Vol"):
|
||||||
|
r = await client.post("/api/tags", json={"name": name, "kind": "series"})
|
||||||
|
return (await r.get_json())["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pages_404_for_non_series(client):
|
||||||
|
r = await client.post("/api/tags", json={"name": "g", "kind": "general"})
|
||||||
|
gid = (await r.get_json())["id"]
|
||||||
|
resp = await client.get(f"/api/series/{gid}/pages")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pages_empty_shape(client):
|
||||||
|
sid = await _series(client, "Empty")
|
||||||
|
resp = await client.get(f"/api/series/{sid}/pages")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["series"]["id"] == sid
|
||||||
|
assert body["pages"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_requires_ids(client):
|
||||||
|
sid = await _series(client, "NoIds")
|
||||||
|
resp = await client.post(f"/api/series/{sid}/pages", json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_over_cap_400(client):
|
||||||
|
sid = await _series(client, "Cap")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/series/{sid}/pages",
|
||||||
|
json={"image_ids": list(range(1, 502))},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reorder_mismatch_400(client):
|
||||||
|
sid = await _series(client, "Re")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/series/{sid}/reorder", json={"image_ids": [1, 2, 3]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cover_requires_image_id(client):
|
||||||
|
sid = await _series(client, "Cov")
|
||||||
|
resp = await client.post(f"/api/series/{sid}/cover", json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
Reference in New Issue
Block a user