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:
@@ -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")
|
||||
@@ -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) --------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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