From 7bb765b6ed5daa5217c243b8d0748bac573b6f21 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 11 Jun 2026 21:47:58 -0400 Subject: [PATCH] feat(series): pending staging for add-from-post (#789 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add-from-post no longer appends straight into the run — it STAGES the post's pages as pending (per-page status; page_number NULL), grouped by source post, so the operator drops junk (text-free alts, bumpers) and places the keepers into the sequence with clean series-global numbering. - migration 0048: series_page.status ('placed' default | 'pending') + nullable page_number. - series_service: placed/pending split everywhere (list_pages returns the placed run + a `pending` section grouped by source post; reorder/cover/ list_series operate on placed only); add_post stages pending; new place_pending(image_ids, before_image_id=None) flips pending→placed spliced before a page (or appended) and renumbers; junk removal reuses remove_images. - api/tags: /add-post now returns staged count; new POST /series//pending/ place. - frontend: PostSeriesMenu navigates to the series after staging; seriesManage store surfaces `pending` + placePending; SeriesManageView gains a pending tray (per-post groups, place-all / place-one / drop-junk). - tests: pending staging, place (append + insert-before), ignore-already- placed, drop-junk, route guard; updated add_post + match-accept expectations. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0048_series_page_pending_status.py | 45 ++++ backend/app/api/tags.py | 22 ++ backend/app/models/series_page.py | 9 +- backend/app/services/series_service.py | 212 ++++++++++++++++-- .../src/components/posts/PostSeriesMenu.vue | 4 +- frontend/src/stores/seriesManage.js | 15 +- frontend/src/views/SeriesManageView.vue | 96 ++++++++ tests/test_series_from_post.py | 27 ++- tests/test_series_match_service.py | 6 +- tests/test_series_pending.py | 127 +++++++++++ 10 files changed, 521 insertions(+), 42 deletions(-) create mode 100644 alembic/versions/0048_series_page_pending_status.py create mode 100644 tests/test_series_pending.py diff --git a/alembic/versions/0048_series_page_pending_status.py b/alembic/versions/0048_series_page_pending_status.py new file mode 100644 index 0000000..25944a5 --- /dev/null +++ b/alembic/versions/0048_series_page_pending_status.py @@ -0,0 +1,45 @@ +"""series_page pending staging: status + nullable page_number (#789 Phase 2) + +Pages added from a post no longer append straight into the run — they land +'pending' with a NULL page_number, staged grouped by their source post so the +operator can drop junk (text-free alts, bumpers) and place the keepers into the +sequence. A page only gets a series-global page_number once it's 'placed'. + +Revision ID: 0048 +Revises: 0047 +Create Date: 2026-06-11 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0048" +down_revision: Union[str, None] = "0047" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "series_page", + sa.Column( + "status", sa.String(length=16), nullable=False, + server_default="placed", + ), + ) + op.alter_column( + "series_page", "page_number", + existing_type=sa.Integer(), nullable=True, + ) + + +def downgrade() -> None: + # Lossy: pending pages are unsorted staging rows with no order — drop them. + op.execute("DELETE FROM series_page WHERE status = 'pending'") + op.alter_column( + "series_page", "page_number", + existing_type=sa.Integer(), nullable=False, + ) + op.drop_column("series_page", "status") diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index acc1b1b..6751975 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -583,6 +583,28 @@ async def series_add_post(tag_id: int): return jsonify(out) +@tags_bp.route("/series//pending/place", methods=["POST"]) +async def series_place_pending(tag_id: int): + """Move staged (pending) pages into the placed run — before `before_image_id` + (a placed page) or appended when omitted (#789 P2).""" + body = await request.get_json() + ids, err = _parse_bulk_ids(body, max_ids=500) + if err: + return err + before, berr = _opt_int(body, "before_image_id") + if berr: + return berr + async with get_session() as session: + try: + n = await SeriesService(session).place_pending( + tag_id, ids, before_image_id=before + ) + except SeriesError as exc: + return _series_err(exc) + await session.commit() + return jsonify({"placed_count": n}) + + # ---- suggestion queue (FC-6.3) -------------------------------------------- diff --git a/backend/app/models/series_page.py b/backend/app/models/series_page.py index 41b9536..0bb0b40 100644 --- a/backend/app/models/series_page.py +++ b/backend/app/models/series_page.py @@ -14,7 +14,7 @@ number parsed from the source post, nullable when unknown. from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, func +from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -32,7 +32,12 @@ class SeriesPage(Base): nullable=False, unique=True, ) - page_number: Mapped[int] = mapped_column(Integer, nullable=False) + # 'placed' = in the series-global run (page_number set); 'pending' = staged + # from a post awaiting the operator's sort (page_number NULL). (#789 P2) + status: Mapped[str] = mapped_column( + String(16), nullable=False, server_default="placed" + ) + page_number: Mapped[int | None] = mapped_column(Integer, nullable=True) stated_page: Mapped[int | None] = mapped_column(Integer, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py index 5fb2bbe..f07bc9b 100644 --- a/backend/app/services/series_service.py +++ b/backend/app/services/series_service.py @@ -57,11 +57,18 @@ class SeriesService: return out async def _page_order(self, series_tag_id: int) -> list[int]: - """The series' image_ids in reading order (series-global page_number).""" + """The series' PLACED image_ids in reading order (series-global + page_number). Pending (staged) pages are excluded — they have no order + yet.""" rows = ( await self.session.execute( select(SeriesPage.image_id) - .where(SeriesPage.series_tag_id == series_tag_id) + .where( + and_( + SeriesPage.series_tag_id == series_tag_id, + SeriesPage.status == "placed", + ) + ) .order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc()) ) ).scalars().all() @@ -159,7 +166,12 @@ class SeriesService: .select_from(SeriesPage) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id) .outerjoin(Post, Post.id == ImageRecord.primary_post_id) - .where(SeriesPage.series_tag_id == series_tag_id) + .where( + and_( + SeriesPage.series_tag_id == series_tag_id, + SeriesPage.status == "placed", + ) + ) .order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc()) ) ).all() @@ -207,10 +219,66 @@ class SeriesService: for r in drows ] + # Pending (staged-from-post) pages, grouped by their source post so the + # operator can drop junk and place the keepers into the run. + prows = ( + await self.session.execute( + select( + SeriesPage.image_id, + SeriesPage.stated_page, + ImageRecord.sha256, + ImageRecord.mime, + ImageRecord.path, + ImageRecord.thumbnail_path, + ImageRecord.primary_post_id, + Post.post_title, + ) + .select_from(SeriesPage) + .join(ImageRecord, ImageRecord.id == SeriesPage.image_id) + .outerjoin(Post, Post.id == ImageRecord.primary_post_id) + .where( + and_( + SeriesPage.series_tag_id == series_tag_id, + SeriesPage.status == "pending", + ) + ) + .order_by( + ImageRecord.primary_post_id.asc().nulls_last(), + SeriesPage.id.asc(), + ) + ) + ).all() + pending_groups: list[dict] = [] + by_post: dict = {} + for r in prows: + grp = by_post.get(r.primary_post_id) + if grp is None: + grp = { + "post": ( + {"id": r.primary_post_id, "title": r.post_title} + if r.primary_post_id is not None + else None + ), + "pages": [], + } + by_post[r.primary_post_id] = grp + pending_groups.append(grp) + grp["pages"].append( + { + "image_id": r.image_id, + "stated_page": r.stated_page, + "thumbnail_url": thumbnail_url( + r.thumbnail_path, r.sha256, r.mime + ), + "image_url": f"/images/{r.path.split('/images/', 1)[-1]}", + } + ) + return { "series": {"id": tag.id, "name": tag.name}, "pages": pages, "dividers": dividers, + "pending": pending_groups, "part_gaps": self._part_gaps(dividers), } @@ -243,13 +311,7 @@ class SeriesService: 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 + max_pn = await self._max_placed_page_number(series_tag_id) sp = stated_pages or {} await self.session.execute( pg_insert(SeriesPage).values( @@ -257,6 +319,7 @@ class SeriesService: { "series_tag_id": series_tag_id, "image_id": iid, + "status": "placed", "page_number": max_pn + offset, "stated_page": sp.get(iid), } @@ -266,6 +329,20 @@ class SeriesService: ) return len(to_add) + async def _max_placed_page_number(self, series_tag_id: int) -> int: + return ( + await self.session.scalar( + select( + func.coalesce(func.max(SeriesPage.page_number), 0) + ).where( + and_( + SeriesPage.series_tag_id == series_tag_id, + SeriesPage.status == "placed", + ) + ) + ) + ) or 0 + async def remove_images( self, series_tag_id: int, image_ids: list[int] ) -> int: @@ -454,10 +531,11 @@ class SeriesService: } async def add_post(self, series_tag_id: int, post_id: int) -> dict: - """Case 2: append a post's images to the end of an existing series' flat - run (stated pages parsed from the post when present). No chapter wrapping - — the operator orders/labels afterward. (Phase 2 will stage these as - pending instead of appending directly.)""" + """Case 2: STAGE a post's images as PENDING pages of an existing series + (#789 P2). They don't enter the ordered run yet — the operator drops junk + (text-free alts, bumpers) and places the keepers via place_pending, so + the series-global numbering stays clean. Stated pages are parsed from the + post when present.""" await self._require_series(series_tag_id) post = await self.session.get(Post, post_id) if post is None: @@ -465,13 +543,102 @@ class SeriesService: image_ids = await self._post_images_ordered(post_id) if not image_ids: raise SeriesError("post has no images to add") - rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") - start = rng[0] if rng else None - added = await self.add_images( - series_tag_id, image_ids, - stated_pages=self._stated_map(image_ids, start), + # Skip images already in THIS series (placed or pending); move any in + # another series here as pending (image_id is UNIQUE). + existing = dict( + ( + await self.session.execute( + select(SeriesPage.image_id, SeriesPage.series_tag_id) + .where(SeriesPage.image_id.in_(image_ids)) + ) + ).all() ) - return {"series_tag_id": series_tag_id, "added": added} + to_stage = [i for i in image_ids if existing.get(i) != series_tag_id] + if not to_stage: + return {"series_tag_id": series_tag_id, "staged": 0} + await self.session.execute( + SeriesPage.__table__.delete().where( + SeriesPage.image_id.in_(to_stage) + ) + ) + rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") + sp = self._stated_map(image_ids, rng[0] if rng else None) or {} + await self.session.execute( + pg_insert(SeriesPage).values( + [ + { + "series_tag_id": series_tag_id, + "image_id": iid, + "status": "pending", + "page_number": None, + "stated_page": sp.get(iid), + } + for iid in to_stage + ] + ) + ) + return {"series_tag_id": series_tag_id, "staged": len(to_stage)} + + async def place_pending( + self, + series_tag_id: int, + image_ids: list[int], + before_image_id: int | None = None, + ) -> int: + """Move staged (pending) pages into the placed run — spliced in just + before `before_image_id` (a placed page), or appended at the end when + it's None. Page numbers are then rewritten 1..N across the run.""" + await self._require_series(series_tag_id) + ids = self._clean_ids(image_ids) + rows = dict( + ( + await self.session.execute( + select(SeriesPage.image_id, SeriesPage.status).where( + and_( + SeriesPage.series_tag_id == series_tag_id, + SeriesPage.image_id.in_(ids), + ) + ) + ) + ).all() + ) + staged = [i for i in ids if rows.get(i) == "pending"] + if not staged: + return 0 + placed = await self._page_order(series_tag_id) + if before_image_id is not None and before_image_id not in placed: + raise SeriesError( + "before_image_id is not a placed page of this series" + ) + new_order: list[int] = [] + for iid in placed: + if iid == before_image_id: + new_order.extend(staged) + new_order.append(iid) + if before_image_id is None: + new_order.extend(staged) + await self.session.execute( + update(SeriesPage) + .where( + and_( + SeriesPage.series_tag_id == series_tag_id, + SeriesPage.image_id.in_(staged), + ) + ) + .values(status="placed") + ) + for idx, iid in enumerate(new_order, 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) + ) + return len(staged) # ---- browse list (FC-6.2) -------------------------------------------- @@ -488,7 +655,9 @@ class SeriesService: SeriesPage.series_tag_id, func.count().label("pages"), func.max(SeriesPage.updated_at).label("updated_at"), - ).group_by(SeriesPage.series_tag_id) + ) + .where(SeriesPage.status == "placed") + .group_by(SeriesPage.series_tag_id) ) ).all() page_count = {r.series_tag_id: r.pages for r in page_rows} @@ -532,6 +701,7 @@ class SeriesService: .label("rn"), ) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id) + .where(SeriesPage.status == "placed") .subquery() ) cover_rows = ( diff --git a/frontend/src/components/posts/PostSeriesMenu.vue b/frontend/src/components/posts/PostSeriesMenu.vue index e1081fd..af708f0 100644 --- a/frontend/src/components/posts/PostSeriesMenu.vue +++ b/frontend/src/components/posts/PostSeriesMenu.vue @@ -123,7 +123,9 @@ async function onAddExisting() { body: { post_id: props.post.id } }) pickerOpen.value = false - toast({ text: 'Added to series', type: 'success' }) + toast({ text: 'Staged — sort the new pages into the series', type: 'success' }) + // Take the operator to the series so they can drop junk + place the pages. + router.push({ name: 'series-manage', params: { tagId: picked.value } }) } catch (e) { toast({ text: `Could not add to series: ${e.message}`, type: 'error' }) } finally { diff --git a/frontend/src/stores/seriesManage.js b/frontend/src/stores/seriesManage.js index 310ab1f..47f22e3 100644 --- a/frontend/src/stores/seriesManage.js +++ b/frontend/src/stores/seriesManage.js @@ -20,6 +20,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { const series = ref(null) const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}] const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}] + const pending = ref([]) // staged-from-post: [{post:{id,title}|null, pages:[{image_id, stated_page, thumbnail_url, image_url}]}] const partGaps = ref([]) // [{after_divider_id, start, end}] const pageCount = ref(0) const picker = ref([]) // gallery scroll results @@ -35,6 +36,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { series.value = body.series pages.value = body.pages || [] dividers.value = body.dividers || [] + pending.value = body.pending || [] partGaps.value = body.part_gaps || [] pageCount.value = pages.value.length } finally { @@ -105,6 +107,15 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { toast({ text: 'Chapter divider removed', type: 'success' }) } + // ---- pending staging (add-from-post) ---- + async function placePending(imageIds, beforeImageId = null) { + await api.post(`/api/series/${tagId.value}/pending/place`, { + body: { image_ids: imageIds, before_image_id: beforeImageId } + }) + await refresh() + toast({ text: 'Pages placed into the series', type: 'success' }) + } + // ---- picker / add pages ---- async function loadPicker(reset = false) { if (reset) { picker.value = []; pickerCursor.value = null } @@ -132,10 +143,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { } return { - tagId, series, pages, dividers, partGaps, pageCount, + tagId, series, pages, dividers, pending, partGaps, pageCount, picker, pickerCursor, pickerSelection, loading, load, refresh, dividerAt, partGapAfter, - reorder, remove, setCover, + reorder, remove, setCover, placePending, createDivider, renameDivider, setDividerPart, deleteDivider, loadPicker, togglePick, addSelected } diff --git a/frontend/src/views/SeriesManageView.vue b/frontend/src/views/SeriesManageView.vue index 3aa8c90..f16b43a 100644 --- a/frontend/src/views/SeriesManageView.vue +++ b/frontend/src/views/SeriesManageView.vue @@ -32,6 +32,51 @@ be dragged into place.

+ +
+
+ mdi-tray-arrow-down + Pending — sort into the series +
+

+ New pages from a post land here. Drop the ones that don't belong + (text-free alternates, bumpers), then place the rest into the run. +

+
+
+ + mdi-link-variant + {{ grp.post?.title || 'Unknown post' }} + + {{ grp.pages.length }} pending + + Place all into series +
+
+
+ +
+ + +
+
+
+
+
+
@@ -337,6 +382,57 @@ onMounted(async () => { max-width: 1100px; } +/* Pending tray */ +.fc-pending { + max-width: 1100px; margin-bottom: 18px; + border: 1px solid rgb(var(--v-theme-accent), 0.4); + border-radius: 10px; padding: 10px 14px; + background: rgb(var(--v-theme-accent), 0.06); +} +.fc-pending__head { + display: flex; align-items: center; gap: 8px; + font-family: 'Fraunces', Georgia, serif; font-size: 16px; + color: rgb(var(--v-theme-accent)); +} +.fc-pending__hint { + font-size: 12px; color: rgb(var(--v-theme-on-surface-variant)); + margin: 4px 0 10px; +} +.fc-pgroup { margin-bottom: 12px; } +.fc-pgroup__head { + display: flex; align-items: center; gap: 10px; margin-bottom: 6px; +} +.fc-pgroup__title { + display: inline-flex; align-items: center; gap: 4px; max-width: 360px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 13px; font-weight: 600; +} +.fc-pgroup__count { + font-size: 12px; color: rgb(var(--v-theme-on-surface-variant)); +} +.fc-pgroup__pages { + display: grid; gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); +} +.fc-ppage { + position: relative; border-radius: 6px; overflow: hidden; + background: rgb(var(--v-theme-surface-light)); + border: 1px dashed rgb(var(--v-theme-accent), 0.5); +} +.fc-ppage img { + width: 100%; aspect-ratio: 3 / 4; object-fit: contain; display: block; + background: rgb(var(--v-theme-background)); +} +.fc-ppage__actions { + position: absolute; top: 4px; right: 4px; display: flex; gap: 2px; + opacity: 0; transition: opacity 0.12s; +} +.fc-ppage:hover .fc-ppage__actions, +.fc-ppage:focus-within .fc-ppage__actions { opacity: 1; } +.fc-ppage__actions :deep(.v-btn) { + background: rgba(0, 0, 0, 0.55); color: #fff; +} + /* Picker slide-over */ .fc-picker__head { position: sticky; top: 0; z-index: 1; padding: 12px 14px; diff --git a/tests/test_series_from_post.py b/tests/test_series_from_post.py index 9b01017..11d0a26 100644 --- a/tests/test_series_from_post.py +++ b/tests/test_series_from_post.py @@ -71,30 +71,29 @@ async def test_promote_requires_images(db): @pytest.mark.asyncio -async def test_add_post_appends_pages_to_the_run(db): +async def test_add_post_stages_pending(db): svc = SeriesService(db) artist = await _artist(db, "Story Artist") sid = (await TagService(db).find_or_create("Story", TagKind.series)).id - # Seed the series with two existing pages. + # Seed the series with two PLACED pages. seed_post = await _post(db, artist, "seed", "pp3a") seed = await _post_images(db, seed_post, artist, 2) await svc.add_images(sid, seed) - # A later post's pages append to the END (operator orders afterward). + # A later post is STAGED as pending — not in the placed run yet. post = await _post(db, artist, "Story pages 9-11", "pp3b") imgs = await _post_images(db, post, artist, 3) out = await svc.add_post(sid, post.id) - assert out["added"] == 3 + assert out["staged"] == 3 - rows = ( - await db.execute( - select(SeriesPage.image_id, SeriesPage.stated_page) - .where(SeriesPage.series_tag_id == sid) - .order_by(SeriesPage.page_number) - ) - ).all() - assert [r.image_id for r in rows] == seed + imgs - stated = {r.image_id: r.stated_page for r in rows} - assert [stated[i] for i in imgs] == [9, 10, 11] + data = await svc.list_pages(sid) + # placed run unchanged (just the seed); pending grouped under the post. + assert [p["image_id"] for p in data["pages"]] == seed + assert len(data["pending"]) == 1 + grp = data["pending"][0] + assert grp["post"]["id"] == post.id + assert grp["post"]["title"] == "Story pages 9-11" + assert [pg["image_id"] for pg in grp["pages"]] == imgs + assert [pg["stated_page"] for pg in grp["pages"]] == [9, 10, 11] @pytest.mark.asyncio diff --git a/tests/test_series_match_service.py b/tests/test_series_match_service.py index 728342a..31560c8 100644 --- a/tests/test_series_match_service.py +++ b/tests/test_series_match_service.py @@ -90,8 +90,10 @@ async def test_accept_appends_pages_and_marks_added(db): await svc.accept(sug["id"]) data = await SeriesService(db).list_pages(sid) - # b's pages were appended to the series' flat run (3 from a + 3 from b). - assert len(data["pages"]) == 6 + # b's pages are STAGED pending (3 from a placed + 3 from b pending), + # awaiting the operator's sort. + assert len(data["pages"]) == 3 + assert sum(len(g["pages"]) for g in data["pending"]) == 3 status = await db.scalar( select(SeriesSuggestion.status).where(SeriesSuggestion.id == sug["id"]) ) diff --git a/tests/test_series_pending.py b/tests/test_series_pending.py new file mode 100644 index 0000000..dbab5b4 --- /dev/null +++ b/tests/test_series_pending.py @@ -0,0 +1,127 @@ +"""FC-6.x Phase 2 — pending staging for add-from-post.""" + +import pytest +from sqlalchemy import select + +from backend.app.models import Artist, ImageRecord, Post, TagKind +from backend.app.models.series_page import SeriesPage +from backend.app.services.series_service import SeriesService +from backend.app.services.tag_service import TagService + +pytestmark = pytest.mark.integration + +_SEQ = 0 + + +async def _artist(db, name): + a = Artist(name=name, slug=name.lower().replace(" ", "-")) + db.add(a) + await db.flush() + return a + + +async def _post(db, artist, title, ext): + p = Post(artist_id=artist.id, external_post_id=ext, post_title=title) + db.add(p) + await db.flush() + return p + + +async def _post_images(db, post, artist, n): + global _SEQ + ids = [] + for _ in range(n): + _SEQ += 1 + rec = ImageRecord( + path=f"/tmp/fc_pend_{_SEQ}.png", sha256=f"{_SEQ:064d}", + size_bytes=1, mime="image/png", origin="downloaded", + primary_post_id=post.id, artist_id=artist.id, + ) + db.add(rec) + await db.flush() + ids.append(rec.id) + return ids + + +async def _placed_order(db, sid): + rows = ( + await db.execute( + select(SeriesPage.image_id) + .where( + SeriesPage.series_tag_id == sid, + SeriesPage.status == "placed", + ) + .order_by(SeriesPage.page_number) + ) + ).scalars().all() + return list(rows) + + +async def _setup(db, name, seed_n=2): + svc = SeriesService(db) + artist = await _artist(db, name + " Artist") + sid = (await TagService(db).find_or_create(name, TagKind.series)).id + seed = [] + if seed_n: + seed_post = await _post(db, artist, name + " seed", name + "-s") + seed = await _post_images(db, seed_post, artist, seed_n) + await svc.add_images(sid, seed) + return svc, artist, sid, seed + + +@pytest.mark.asyncio +async def test_place_pending_appends_and_flips_status(db): + svc, artist, sid, seed = await _setup(db, "PA") + post = await _post(db, artist, "PA pages", "pa-p") + imgs = await _post_images(db, post, artist, 3) + await svc.add_post(sid, post.id) + + placed = await svc.place_pending(sid, imgs) + assert placed == 3 + assert await _placed_order(db, sid) == seed + imgs + # nothing left pending; page numbers compact 1..5 + data = await svc.list_pages(sid) + assert data["pending"] == [] + assert [p["page_number"] for p in data["pages"]] == [1, 2, 3, 4, 5] + + +@pytest.mark.asyncio +async def test_place_pending_before_a_placed_page(db): + svc, artist, sid, seed = await _setup(db, "PB") + a, b = seed + post = await _post(db, artist, "PB pages", "pb-p") + x, y = await _post_images(db, post, artist, 2) + await svc.add_post(sid, post.id) + + await svc.place_pending(sid, [x, y], before_image_id=b) + assert await _placed_order(db, sid) == [a, x, y, b] + + +@pytest.mark.asyncio +async def test_place_pending_ignores_already_placed(db): + svc, artist, sid, seed = await _setup(db, "PC", seed_n=1) + # the seed page is already placed — place_pending is a no-op for it. + assert await svc.place_pending(sid, [seed[0]]) == 0 + assert await _placed_order(db, sid) == seed + + +@pytest.mark.asyncio +async def test_remove_drops_a_pending_junk_page(db): + svc, artist, sid, _ = await _setup(db, "PD", seed_n=0) + post = await _post(db, artist, "PD pages", "pd-p") + x, y, z = await _post_images(db, post, artist, 3) + await svc.add_post(sid, post.id) + # y is junk (a text-free alt) — drop it before placing. + await svc.remove_images(sid, [y]) + data = await svc.list_pages(sid) + assert [pg["image_id"] for pg in data["pending"][0]["pages"]] == [x, z] + + +@pytest.mark.asyncio +async def test_place_pending_route_requires_ids(client): + r = await client.post( + "/api/tags", json={"name": "PReq", "kind": "series"} + ) + sid = (await r.get_json())["id"] + resp = await client.post(f"/api/series/{sid}/pending/place", json={}) + assert resp.status_code == 400