feat(series): pending staging for add-from-post (#789 Phase 2)
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:
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user