feat(series): pending staging for add-from-post (#789 Phase 2)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m17s

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/<id>/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) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:47:58 -04:00
parent 59746d213d
commit 7bb765b6ed
10 changed files with 521 additions and 42 deletions
@@ -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")
+22
View File
@@ -583,6 +583,28 @@ async def series_add_post(tag_id: int):
return jsonify(out)
@tags_bp.route("/series/<int:tag_id>/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) --------------------------------------------
+7 -2
View File
@@ -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()
+191 -21
View File
@@ -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))
)
return {"series_tag_id": series_tag_id, "added": added}
).all()
)
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 = (
@@ -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 {
+13 -2
View File
@@ -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
}
+96
View File
@@ -32,6 +32,51 @@
be dragged into place.
</p>
<!-- Pending tray: pages staged from a post, grouped by source post, for the
operator to drop junk and place into the run. -->
<div v-if="store.pending.length" class="fc-pending">
<div class="fc-pending__head">
<v-icon size="small">mdi-tray-arrow-down</v-icon>
<span>Pending sort into the series</span>
</div>
<p class="fc-pending__hint">
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.
</p>
<section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup">
<header class="fc-pgroup__head">
<span class="fc-pgroup__title" :title="grp.post?.title">
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ grp.post?.title || 'Unknown post' }}
</span>
<span class="fc-pgroup__count">{{ grp.pages.length }} pending</span>
<v-spacer />
<v-btn
size="small" variant="tonal" color="accent"
prepend-icon="mdi-playlist-plus"
@click="store.placePending(grp.pages.map(p => p.image_id))"
>Place all into series</v-btn>
</header>
<div class="fc-pgroup__pages">
<div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage">
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-ppage__actions">
<v-btn
size="x-small" variant="flat" icon="mdi-playlist-plus"
title="Place this page into the series"
@click="store.placePending([p.image_id])"
/>
<v-btn
size="x-small" variant="flat" icon="mdi-close"
title="Drop — doesn't belong in the series"
@click="store.remove(p.image_id)"
/>
</div>
</div>
</div>
</section>
</div>
<!-- One continuous page run; chapter dividers render inline before their
anchor page. -->
<div v-if="store.pages.length" class="fc-run">
@@ -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;
+13 -14
View File
@@ -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
+4 -2
View File
@@ -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"])
)
+127
View File
@@ -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