feat(series): SeriesService list/add(move)/remove/reorder/cover with guards

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 23:34:17 -04:00
parent b83baa7d7f
commit 0db76c70e8
2 changed files with 296 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
"""Series = a series-kind Tag + ordered series_page membership.
All mutations are Core/set-based and run in the request transaction.
"""
from sqlalchemy import and_, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, Tag, TagKind
from ..models.series_page import SeriesPage
from .gallery_service import thumbnail_url
class SeriesError(ValueError):
"""Series validation failure. The API maps 'not a series'/'not found'
to 404 and everything else to 400."""
class SeriesService:
def __init__(self, session: AsyncSession):
self.session = session
async def _require_series(self, series_tag_id: int) -> Tag:
tag = await self.session.get(Tag, series_tag_id)
if tag is None:
raise SeriesError(f"Tag {series_tag_id} not found")
if tag.kind != TagKind.series:
raise SeriesError(f"Tag {series_tag_id} is not a series")
return tag
@staticmethod
def _clean_ids(ids: list[int]) -> list[int]:
if not ids:
raise SeriesError("image_ids must be a non-empty list")
if len(ids) > 500:
raise SeriesError("selection too large (max 500)")
seen: set[int] = set()
out: list[int] = []
for x in ids:
if x not in seen:
seen.add(x)
out.append(int(x))
return out
async def _member_order(self, series_tag_id: int) -> list[int]:
rows = (
await self.session.execute(
select(SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
.order_by(SeriesPage.page_number.asc())
)
).scalars().all()
return list(rows)
async def list_pages(self, series_tag_id: int) -> dict:
tag = await self._require_series(series_tag_id)
rows = (
await self.session.execute(
select(
SeriesPage.image_id,
SeriesPage.page_number,
ImageRecord.sha256,
ImageRecord.mime,
)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
.order_by(SeriesPage.page_number.asc())
)
).all()
return {
"series": {"id": tag.id, "name": tag.name},
"pages": [
{
"image_id": r.image_id,
"page_number": r.page_number,
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
}
for r in rows
],
}
async def add_images(
self, series_tag_id: int, image_ids: list[int]
) -> int:
await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids)
existing = dict(
(
await self.session.execute(
select(SeriesPage.image_id, SeriesPage.series_tag_id)
.where(SeriesPage.image_id.in_(ids))
)
).all()
)
# Already in THIS series → leave untouched (no churn).
to_add = [i for i in ids if existing.get(i) != series_tag_id]
if not to_add:
return 0
# Remove any prior membership for the to_add images (the "move").
await self.session.execute(
SeriesPage.__table__.delete().where(
SeriesPage.image_id.in_(to_add)
)
)
max_pn = (
await self.session.scalar(
select(func.coalesce(func.max(SeriesPage.page_number), 0))
.where(SeriesPage.series_tag_id == series_tag_id)
)
) or 0
await self.session.execute(
pg_insert(SeriesPage).values(
[
{
"series_tag_id": series_tag_id,
"image_id": iid,
"page_number": max_pn + offset,
}
for offset, iid in enumerate(to_add, start=1)
]
)
)
return len(to_add)
async def remove_images(
self, series_tag_id: int, image_ids: list[int]
) -> int:
await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids)
res = await self.session.execute(
SeriesPage.__table__.delete().where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id.in_(ids),
)
)
)
return res.rowcount or 0
async def reorder(
self, series_tag_id: int, ordered_image_ids: list[int]
) -> None:
await self._require_series(series_tag_id)
ordered = self._clean_ids(ordered_image_ids)
current = set(await self._member_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError(
"ordered image_ids must exactly match series membership"
)
for idx, iid in enumerate(ordered, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == iid,
)
)
.values(page_number=idx)
)
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
await self._require_series(series_tag_id)
order = await self._member_order(series_tag_id)
if image_id not in order:
raise SeriesError(f"image {image_id} is not in this series")
new_order = [image_id] + [i for i in order if i != image_id]
await self.reorder(series_tag_id, new_order)
+127
View File
@@ -0,0 +1,127 @@
import pytest
from sqlalchemy import func, select
from backend.app.models import ImageRecord, TagKind
from backend.app.models.series_page import SeriesPage
from backend.app.services.series_service import SeriesError, SeriesService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
_SEQ = 0
async def _img(db):
global _SEQ
_SEQ += 1
rec = ImageRecord(
path=f"/tmp/fc_ss_{_SEQ}.png", sha256=f"{_SEQ:064d}",
size_bytes=1, mime="image/png", origin="uploaded",
)
db.add(rec)
await db.flush()
return rec.id
async def _order(db, sid):
rows = (
await db.execute(
select(SeriesPage.image_id, SeriesPage.page_number)
.where(SeriesPage.series_tag_id == sid)
.order_by(SeriesPage.page_number)
)
).all()
return [r.image_id for r in rows]
@pytest.mark.asyncio
async def test_add_appends_in_order(db):
s = await TagService(db).find_or_create("S", TagKind.series)
i1, i2 = await _img(db), await _img(db)
n = await SeriesService(db).add_images(s.id, [i1, i2])
assert n == 2
assert await _order(db, s.id) == [i1, i2]
@pytest.mark.asyncio
async def test_add_moves_from_other_series(db):
svc = SeriesService(db)
a = await TagService(db).find_or_create("A", TagKind.series)
b = await TagService(db).find_or_create("B", TagKind.series)
i1 = await _img(db)
await svc.add_images(a.id, [i1])
moved = await svc.add_images(b.id, [i1])
assert moved == 1
assert await _order(db, a.id) == []
assert await _order(db, b.id) == [i1]
cnt = await db.scalar(
select(func.count())
.select_from(SeriesPage).where(SeriesPage.image_id == i1)
)
assert cnt == 1
@pytest.mark.asyncio
async def test_add_idempotent_for_same_series(db):
s = await TagService(db).find_or_create("S2", TagKind.series)
i1 = await _img(db)
svc = SeriesService(db)
await svc.add_images(s.id, [i1])
again = await svc.add_images(s.id, [i1])
assert again == 0
assert await _order(db, s.id) == [i1]
@pytest.mark.asyncio
async def test_remove(db):
s = await TagService(db).find_or_create("S3", TagKind.series)
i1, i2 = await _img(db), await _img(db)
svc = SeriesService(db)
await svc.add_images(s.id, [i1, i2])
removed = await svc.remove_images(s.id, [i1])
assert removed == 1
assert await _order(db, s.id) == [i2]
@pytest.mark.asyncio
async def test_reorder_rewrites_and_rejects_mismatch(db):
s = await TagService(db).find_or_create("S4", TagKind.series)
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
svc = SeriesService(db)
await svc.add_images(s.id, [i1, i2, i3])
await svc.reorder(s.id, [i3, i1, i2])
assert await _order(db, s.id) == [i3, i1, i2]
with pytest.raises(SeriesError):
await svc.reorder(s.id, [i1, i2])
with pytest.raises(SeriesError):
await svc.reorder(s.id, [i1, i2, i3, 99999])
@pytest.mark.asyncio
async def test_set_cover_makes_page_one_preserving_rest(db):
s = await TagService(db).find_or_create("S5", TagKind.series)
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
svc = SeriesService(db)
await svc.add_images(s.id, [i1, i2, i3])
await svc.set_cover(s.id, i3)
assert await _order(db, s.id) == [i3, i1, i2]
@pytest.mark.asyncio
async def test_list_pages_shape_and_guards(db):
svc = SeriesService(db)
s = await TagService(db).find_or_create("S6", TagKind.series)
i1 = await _img(db)
await svc.add_images(s.id, [i1])
out = await svc.list_pages(s.id)
assert out["series"]["id"] == s.id
assert out["pages"][0]["image_id"] == i1
assert out["pages"][0]["page_number"] == 1
assert out["pages"][0]["thumbnail_url"]
g = await TagService(db).find_or_create("notser", TagKind.general)
with pytest.raises(SeriesError):
await svc.list_pages(g.id)
with pytest.raises(SeriesError):
await svc.add_images(g.id, [i1])
with pytest.raises(SeriesError):
await svc.add_images(s.id, list(range(1, 502)))