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) 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) -------------------------------------------- # ---- 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 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 sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -32,7 +32,12 @@ class SeriesPage(Base):
nullable=False, nullable=False,
unique=True, 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) stated_page: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
+191 -21
View File
@@ -57,11 +57,18 @@ class SeriesService:
return out return out
async def _page_order(self, series_tag_id: int) -> list[int]: 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 = ( rows = (
await self.session.execute( await self.session.execute(
select(SeriesPage.image_id) 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()) .order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
) )
).scalars().all() ).scalars().all()
@@ -159,7 +166,12 @@ class SeriesService:
.select_from(SeriesPage) .select_from(SeriesPage)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(Post, Post.id == ImageRecord.primary_post_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()) .order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
) )
).all() ).all()
@@ -207,10 +219,66 @@ class SeriesService:
for r in drows 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 { return {
"series": {"id": tag.id, "name": tag.name}, "series": {"id": tag.id, "name": tag.name},
"pages": pages, "pages": pages,
"dividers": dividers, "dividers": dividers,
"pending": pending_groups,
"part_gaps": self._part_gaps(dividers), "part_gaps": self._part_gaps(dividers),
} }
@@ -243,13 +311,7 @@ class SeriesService:
await self.session.execute( await self.session.execute(
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add)) SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add))
) )
max_pn = ( max_pn = await self._max_placed_page_number(series_tag_id)
await self.session.scalar(
select(
func.coalesce(func.max(SeriesPage.page_number), 0)
).where(SeriesPage.series_tag_id == series_tag_id)
)
) or 0
sp = stated_pages or {} sp = stated_pages or {}
await self.session.execute( await self.session.execute(
pg_insert(SeriesPage).values( pg_insert(SeriesPage).values(
@@ -257,6 +319,7 @@ class SeriesService:
{ {
"series_tag_id": series_tag_id, "series_tag_id": series_tag_id,
"image_id": iid, "image_id": iid,
"status": "placed",
"page_number": max_pn + offset, "page_number": max_pn + offset,
"stated_page": sp.get(iid), "stated_page": sp.get(iid),
} }
@@ -266,6 +329,20 @@ class SeriesService:
) )
return len(to_add) 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( async def remove_images(
self, series_tag_id: int, image_ids: list[int] self, series_tag_id: int, image_ids: list[int]
) -> int: ) -> int:
@@ -454,10 +531,11 @@ class SeriesService:
} }
async def add_post(self, series_tag_id: int, post_id: int) -> dict: 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 """Case 2: STAGE a post's images as PENDING pages of an existing series
run (stated pages parsed from the post when present). No chapter wrapping (#789 P2). They don't enter the ordered run yet — the operator drops junk
— the operator orders/labels afterward. (Phase 2 will stage these as (text-free alts, bumpers) and places the keepers via place_pending, so
pending instead of appending directly.)""" the series-global numbering stays clean. Stated pages are parsed from the
post when present."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
post = await self.session.get(Post, post_id) post = await self.session.get(Post, post_id)
if post is None: if post is None:
@@ -465,13 +543,102 @@ class SeriesService:
image_ids = await self._post_images_ordered(post_id) image_ids = await self._post_images_ordered(post_id)
if not image_ids: if not image_ids:
raise SeriesError("post has no images to add") raise SeriesError("post has no images to add")
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}") # Skip images already in THIS series (placed or pending); move any in
start = rng[0] if rng else None # another series here as pending (image_id is UNIQUE).
added = await self.add_images( existing = dict(
series_tag_id, image_ids, (
stated_pages=self._stated_map(image_ids, start), 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) -------------------------------------------- # ---- browse list (FC-6.2) --------------------------------------------
@@ -488,7 +655,9 @@ class SeriesService:
SeriesPage.series_tag_id, SeriesPage.series_tag_id,
func.count().label("pages"), func.count().label("pages"),
func.max(SeriesPage.updated_at).label("updated_at"), 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() ).all()
page_count = {r.series_tag_id: r.pages for r in page_rows} page_count = {r.series_tag_id: r.pages for r in page_rows}
@@ -532,6 +701,7 @@ class SeriesService:
.label("rn"), .label("rn"),
) )
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesPage.status == "placed")
.subquery() .subquery()
) )
cover_rows = ( cover_rows = (
@@ -123,7 +123,9 @@ async function onAddExisting() {
body: { post_id: props.post.id } body: { post_id: props.post.id }
}) })
pickerOpen.value = false 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) { } catch (e) {
toast({ text: `Could not add to series: ${e.message}`, type: 'error' }) toast({ text: `Could not add to series: ${e.message}`, type: 'error' })
} finally { } finally {
+13 -2
View File
@@ -20,6 +20,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
const series = ref(null) const series = ref(null)
const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}] 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 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 partGaps = ref([]) // [{after_divider_id, start, end}]
const pageCount = ref(0) const pageCount = ref(0)
const picker = ref([]) // gallery scroll results const picker = ref([]) // gallery scroll results
@@ -35,6 +36,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
series.value = body.series series.value = body.series
pages.value = body.pages || [] pages.value = body.pages || []
dividers.value = body.dividers || [] dividers.value = body.dividers || []
pending.value = body.pending || []
partGaps.value = body.part_gaps || [] partGaps.value = body.part_gaps || []
pageCount.value = pages.value.length pageCount.value = pages.value.length
} finally { } finally {
@@ -105,6 +107,15 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
toast({ text: 'Chapter divider removed', type: 'success' }) 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 ---- // ---- picker / add pages ----
async function loadPicker(reset = false) { async function loadPicker(reset = false) {
if (reset) { picker.value = []; pickerCursor.value = null } if (reset) { picker.value = []; pickerCursor.value = null }
@@ -132,10 +143,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
} }
return { return {
tagId, series, pages, dividers, partGaps, pageCount, tagId, series, pages, dividers, pending, partGaps, pageCount,
picker, pickerCursor, pickerSelection, loading, picker, pickerCursor, pickerSelection, loading,
load, refresh, dividerAt, partGapAfter, load, refresh, dividerAt, partGapAfter,
reorder, remove, setCover, reorder, remove, setCover, placePending,
createDivider, renameDivider, setDividerPart, deleteDivider, createDivider, renameDivider, setDividerPart, deleteDivider,
loadPicker, togglePick, addSelected loadPicker, togglePick, addSelected
} }
+96
View File
@@ -32,6 +32,51 @@
be dragged into place. be dragged into place.
</p> </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 <!-- One continuous page run; chapter dividers render inline before their
anchor page. --> anchor page. -->
<div v-if="store.pages.length" class="fc-run"> <div v-if="store.pages.length" class="fc-run">
@@ -337,6 +382,57 @@ onMounted(async () => {
max-width: 1100px; 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 */ /* Picker slide-over */
.fc-picker__head { .fc-picker__head {
position: sticky; top: 0; z-index: 1; padding: 12px 14px; 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 @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) svc = SeriesService(db)
artist = await _artist(db, "Story Artist") artist = await _artist(db, "Story Artist")
sid = (await TagService(db).find_or_create("Story", TagKind.series)).id 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_post = await _post(db, artist, "seed", "pp3a")
seed = await _post_images(db, seed_post, artist, 2) seed = await _post_images(db, seed_post, artist, 2)
await svc.add_images(sid, seed) 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") post = await _post(db, artist, "Story pages 9-11", "pp3b")
imgs = await _post_images(db, post, artist, 3) imgs = await _post_images(db, post, artist, 3)
out = await svc.add_post(sid, post.id) out = await svc.add_post(sid, post.id)
assert out["added"] == 3 assert out["staged"] == 3
rows = ( data = await svc.list_pages(sid)
await db.execute( # placed run unchanged (just the seed); pending grouped under the post.
select(SeriesPage.image_id, SeriesPage.stated_page) assert [p["image_id"] for p in data["pages"]] == seed
.where(SeriesPage.series_tag_id == sid) assert len(data["pending"]) == 1
.order_by(SeriesPage.page_number) grp = data["pending"][0]
) assert grp["post"]["id"] == post.id
).all() assert grp["post"]["title"] == "Story pages 9-11"
assert [r.image_id for r in rows] == seed + imgs assert [pg["image_id"] for pg in grp["pages"]] == imgs
stated = {r.image_id: r.stated_page for r in rows} assert [pg["stated_page"] for pg in grp["pages"]] == [9, 10, 11]
assert [stated[i] for i in imgs] == [9, 10, 11]
@pytest.mark.asyncio @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"]) await svc.accept(sug["id"])
data = await SeriesService(db).list_pages(sid) data = await SeriesService(db).list_pages(sid)
# b's pages were appended to the series' flat run (3 from a + 3 from b). # b's pages are STAGED pending (3 from a placed + 3 from b pending),
assert len(data["pages"]) == 6 # 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( status = await db.scalar(
select(SeriesSuggestion.status).where(SeriesSuggestion.id == sug["id"]) 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