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
+
+ p.image_id))"
+ >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