Merge pull request 'feat(maintenance): reconcile duplicate posts (gallery-dl→native unify)' (#124) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
This commit was merged in pull request #124.
This commit is contained in:
@@ -226,6 +226,33 @@ async def posts_prune_bare():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/posts/reconcile-duplicates", methods=["POST"])
|
||||||
|
async def posts_reconcile_duplicates():
|
||||||
|
"""Tier-A: unify duplicate post rows for the same real post — the gallery-dl
|
||||||
|
(attachment-id) + native (post-id) duplicates — onto ONE post-id-keyed keeper,
|
||||||
|
moving image/provenance/attachment/link rows over. Images are untouched.
|
||||||
|
dry_run=true returns {groups, posts_to_merge, sample}; dry_run=false applies
|
||||||
|
and returns {groups, merged, sample}. Optional source_id scopes to one source.
|
||||||
|
Same find_duplicate_post_groups predicate drives preview + apply (rule 93)."""
|
||||||
|
from ..services.cleanup_service import reconcile_duplicate_posts
|
||||||
|
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
dry_run = bool(body.get("dry_run", False))
|
||||||
|
raw_source = body.get("source_id")
|
||||||
|
try:
|
||||||
|
source_id = int(raw_source) if raw_source is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _bad("invalid_source_id", detail="source_id must be an integer")
|
||||||
|
|
||||||
|
async with get_session() as session:
|
||||||
|
result = await session.run_sync(
|
||||||
|
lambda sync_sess: reconcile_duplicate_posts(
|
||||||
|
sync_sess, source_id=source_id, dry_run=dry_run,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
|
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
|
||||||
async def tags_purge_legacy():
|
async def tags_purge_legacy():
|
||||||
"""Tier-A: delete legacy IR-migration tags — archive/post/artist
|
"""Tier-A: delete legacy IR-migration tags — archive/post/artist
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ExternalLink,
|
||||||
ImageProvenance,
|
ImageProvenance,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
LibraryAuditRun,
|
LibraryAuditRun,
|
||||||
@@ -36,6 +37,7 @@ from ..models.series_page import SeriesPage
|
|||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
from ..utils import safe_probe
|
from ..utils import safe_probe
|
||||||
from .importer import _VIDEO_DUP_ASPECT_TOL, _VIDEO_DUP_DURATION_TOL_SECONDS
|
from .importer import _VIDEO_DUP_ASPECT_TOL, _VIDEO_DUP_DURATION_TOL_SECONDS
|
||||||
|
from .platforms import PLATFORMS
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -507,6 +509,182 @@ def prune_bare_posts(session: Session, *, dry_run: bool = False) -> dict:
|
|||||||
return {"deleted": result.rowcount or 0, "sample_names": sample}
|
return {"deleted": result.rowcount or 0, "sample_names": sample}
|
||||||
|
|
||||||
|
|
||||||
|
# -- duplicate-post reconciliation (gallery-dl → native migration) ----------
|
||||||
|
# An artist first downloaded by gallery-dl gets Post rows keyed by the per-
|
||||||
|
# ATTACHMENT id (gallery-dl's `id`); a later native walk keys the SAME real post
|
||||||
|
# by the post id. The two never dedup (uq_post_source_external_id is on
|
||||||
|
# external_post_id) → duplicate post rows. The real post id is recoverable in-DB
|
||||||
|
# from raw_metadata["post_id"] (both eras store the sidecar there). We unify each
|
||||||
|
# group onto ONE post row keyed the way the CURRENT native downloader keys it
|
||||||
|
# (post id), so future native walks match and the dup can't recur. Images are
|
||||||
|
# untouched (content-addressed/deduped already); only post rows + their link
|
||||||
|
# rows move. Milestone #73 / note #917.
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_post_id(post: Post) -> str | None:
|
||||||
|
"""The real platform post id used to group duplicate rows: raw_metadata
|
||||||
|
['post_id'] when present (the true id, stored by both gallery-dl and native
|
||||||
|
imports), else external_post_id. None when neither is usable."""
|
||||||
|
rm = post.raw_metadata or {}
|
||||||
|
pid = rm.get("post_id")
|
||||||
|
if pid is not None and str(pid).strip():
|
||||||
|
return str(pid)
|
||||||
|
return post.external_post_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def find_duplicate_post_groups(
|
||||||
|
session: Session, *, source_id: int | None = None,
|
||||||
|
) -> list[list[Post]]:
|
||||||
|
"""Groups of >1 Post that are the SAME real post (same source_id + canonical
|
||||||
|
post id) — the gallery-dl(attachment-id) / native(post-id) duplicates. Shared
|
||||||
|
by the dry-run preview and the live reconcile (preview/apply parity)."""
|
||||||
|
stmt = select(Post)
|
||||||
|
if source_id is not None:
|
||||||
|
stmt = stmt.where(Post.source_id == source_id)
|
||||||
|
groups: dict[tuple, list[Post]] = {}
|
||||||
|
for post in session.execute(stmt.order_by(Post.id)).scalars().all():
|
||||||
|
cpid = _canonical_post_id(post)
|
||||||
|
if not cpid:
|
||||||
|
continue
|
||||||
|
groups.setdefault((post.source_id, cpid), []).append(post)
|
||||||
|
return [posts for posts in groups.values() if len(posts) > 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_keeper(posts: list[Post], cpid: str) -> Post:
|
||||||
|
"""The surviving row for a dup group: prefer one already keyed by the
|
||||||
|
canonical post id (the native format we keep), then the most complete
|
||||||
|
(has description, has date), then the lowest id for stability."""
|
||||||
|
native = [p for p in posts if p.external_post_id == cpid]
|
||||||
|
pool = native or posts
|
||||||
|
return min(pool, key=lambda p: (not p.description, not p.post_date, p.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _repoint_post_links(session: Session, loser_id: int, keeper_id: int) -> None:
|
||||||
|
"""Move every link row from a loser post to the keeper, conflict-safe against
|
||||||
|
each table's uniqueness (drop the loser's row when the keeper already has the
|
||||||
|
equivalent, else re-point). Images themselves are never touched."""
|
||||||
|
# ImageRecord.primary_post_id — no uniqueness; straight re-point.
|
||||||
|
session.execute(
|
||||||
|
update(ImageRecord)
|
||||||
|
.where(ImageRecord.primary_post_id == loser_id)
|
||||||
|
.values(primary_post_id=keeper_id)
|
||||||
|
)
|
||||||
|
# ImageProvenance — unique (image_record_id, post_id).
|
||||||
|
dup_imgs = select(ImageProvenance.image_record_id).where(
|
||||||
|
ImageProvenance.post_id == keeper_id
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
delete(ImageProvenance).where(
|
||||||
|
ImageProvenance.post_id == loser_id,
|
||||||
|
ImageProvenance.image_record_id.in_(dup_imgs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
update(ImageProvenance)
|
||||||
|
.where(ImageProvenance.post_id == loser_id)
|
||||||
|
.values(post_id=keeper_id)
|
||||||
|
)
|
||||||
|
# PostAttachment — partial unique (post_id, sha256) where post_id NOT NULL.
|
||||||
|
dup_shas = select(PostAttachment.sha256).where(PostAttachment.post_id == keeper_id)
|
||||||
|
session.execute(
|
||||||
|
delete(PostAttachment).where(
|
||||||
|
PostAttachment.post_id == loser_id,
|
||||||
|
PostAttachment.sha256.in_(dup_shas),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
update(PostAttachment)
|
||||||
|
.where(PostAttachment.post_id == loser_id)
|
||||||
|
.values(post_id=keeper_id)
|
||||||
|
)
|
||||||
|
# ExternalLink — unique (post_id, url).
|
||||||
|
dup_urls = select(ExternalLink.url).where(ExternalLink.post_id == keeper_id)
|
||||||
|
session.execute(
|
||||||
|
delete(ExternalLink).where(
|
||||||
|
ExternalLink.post_id == loser_id,
|
||||||
|
ExternalLink.url.in_(dup_urls),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
update(ExternalLink)
|
||||||
|
.where(ExternalLink.post_id == loser_id)
|
||||||
|
.values(post_id=keeper_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_missing_post_fields(keeper: Post, loser: Post) -> None:
|
||||||
|
"""Backfill the keeper's empty metadata from a loser that has it (the native
|
||||||
|
stub is often bare; the gallery-dl row carries date/title/body/raw_metadata)."""
|
||||||
|
if not keeper.post_date and loser.post_date:
|
||||||
|
keeper.post_date = loser.post_date
|
||||||
|
if not keeper.post_title and loser.post_title:
|
||||||
|
keeper.post_title = loser.post_title
|
||||||
|
if not keeper.description and loser.description:
|
||||||
|
keeper.description = loser.description
|
||||||
|
if not keeper.raw_metadata and loser.raw_metadata:
|
||||||
|
keeper.raw_metadata = loser.raw_metadata
|
||||||
|
if not keeper.attachment_count and loser.attachment_count:
|
||||||
|
keeper.attachment_count = loser.attachment_count
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_post_url(post: Post, cpid: str) -> str | None:
|
||||||
|
"""Permalink for the unified post, via the platform's derive_post_url hook
|
||||||
|
(subscribestar/patreon synthesize `…/posts/<id>`). Falls back to the keeper's
|
||||||
|
existing url when no hook applies."""
|
||||||
|
platform = (post.raw_metadata or {}).get("category")
|
||||||
|
info = PLATFORMS.get(platform) if platform else None
|
||||||
|
if info is not None and info.derive_post_url is not None:
|
||||||
|
derived = info.derive_post_url({"post_id": cpid})
|
||||||
|
if derived:
|
||||||
|
return derived
|
||||||
|
return post.post_url
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_duplicate_posts(
|
||||||
|
session: Session, *, source_id: int | None = None, dry_run: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Unify duplicate post rows (gallery-dl attachment-id + native post-id) onto
|
||||||
|
one keeper per real post, re-keyed to the post id. Images untouched.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dry_run=True: {"groups": G, "posts_to_merge": L, "sample": [...]}
|
||||||
|
dry_run=False: {"groups": G, "merged": L, "sample": [...]}
|
||||||
|
where L = rows that would be (were) deleted after merging into keepers. The
|
||||||
|
SAME find_duplicate_post_groups predicate drives preview and apply (rule 93).
|
||||||
|
"""
|
||||||
|
groups = find_duplicate_post_groups(session, source_id=source_id)
|
||||||
|
sample: list[dict] = []
|
||||||
|
losers_total = 0
|
||||||
|
for posts in groups:
|
||||||
|
cpid = _canonical_post_id(posts[0])
|
||||||
|
keeper = _choose_keeper(posts, cpid)
|
||||||
|
losers = [p for p in posts if p.id != keeper.id]
|
||||||
|
losers_total += len(losers)
|
||||||
|
if len(sample) < 50:
|
||||||
|
sample.append({
|
||||||
|
"post_id": cpid,
|
||||||
|
"rows": len(posts),
|
||||||
|
"keeper_id": keeper.id,
|
||||||
|
"title": keeper.post_title or f"Post {cpid}",
|
||||||
|
})
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
for loser in losers:
|
||||||
|
_repoint_post_links(session, loser.id, keeper.id)
|
||||||
|
_fill_missing_post_fields(keeper, loser)
|
||||||
|
keeper.external_post_id = cpid
|
||||||
|
new_url = _canonical_post_url(keeper, cpid)
|
||||||
|
if new_url:
|
||||||
|
keeper.post_url = new_url
|
||||||
|
session.flush()
|
||||||
|
for loser in losers:
|
||||||
|
session.delete(loser)
|
||||||
|
if dry_run:
|
||||||
|
return {"groups": len(groups), "posts_to_merge": losers_total, "sample": sample}
|
||||||
|
session.commit()
|
||||||
|
return {"groups": len(groups), "merged": losers_total, "sample": sample}
|
||||||
|
|
||||||
|
|
||||||
# Legacy tags FC no longer uses, in two shapes:
|
# Legacy tags FC no longer uses, in two shapes:
|
||||||
# (1) kinds the tag input never produces — archive/post/artist.
|
# (1) kinds the tag input never produces — archive/post/artist.
|
||||||
# provenance (post grouping) + archive membership are their own
|
# provenance (post grouping) + archive membership are their own
|
||||||
|
|||||||
@@ -49,12 +49,56 @@
|
|||||||
class="ml-3 text-caption text-success"
|
class="ml-3 text-caption text-success"
|
||||||
>Deleted {{ deleted }} ✓</span>
|
>Deleted {{ deleted }} ✓</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-5" />
|
||||||
|
|
||||||
|
<p class="fc-muted text-body-2 mb-4">
|
||||||
|
Unify <strong>duplicate posts</strong> — when an artist was first
|
||||||
|
downloaded by gallery-dl and later re-walked by the native ingester, the
|
||||||
|
same post can exist twice (once keyed by the file's attachment id, once by
|
||||||
|
the real post id). This merges each pair onto one canonical post (keeping
|
||||||
|
the native post-id key), moving images, attachments and links onto the
|
||||||
|
survivor. Images themselves are never touched.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
:loading="loadingDupPreview"
|
||||||
|
class="mb-3"
|
||||||
|
@click="onPreviewDupes"
|
||||||
|
>Preview duplicate posts</v-btn>
|
||||||
|
|
||||||
|
<div v-if="dupPreview">
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
<strong>{{ dupPreview.groups }}</strong> duplicate group(s),
|
||||||
|
<strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to
|
||||||
|
merge.
|
||||||
|
<span v-if="dupPreview.groups > 50" class="fc-muted">Showing first 50.</span>
|
||||||
|
<span v-else-if="!dupPreview.groups" class="fc-muted">No duplicates found.</span>
|
||||||
|
</p>
|
||||||
|
<SampleNameGrid
|
||||||
|
v-if="dupSampleNames.length"
|
||||||
|
:names="dupSampleNames" class="mb-3"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-merge"
|
||||||
|
:disabled="!dupPreview.groups"
|
||||||
|
:loading="merging"
|
||||||
|
@click="onMergeDupes"
|
||||||
|
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
|
||||||
|
<span
|
||||||
|
v-if="merged != null"
|
||||||
|
class="ml-3 text-caption text-success"
|
||||||
|
>Merged {{ merged }} ✓</span>
|
||||||
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { useAdminStore } from '../../stores/admin.js'
|
import { useAdminStore } from '../../stores/admin.js'
|
||||||
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
||||||
@@ -66,6 +110,16 @@ const loadingPreview = ref(false)
|
|||||||
const committing = ref(false)
|
const committing = ref(false)
|
||||||
const deleted = ref(null)
|
const deleted = ref(null)
|
||||||
|
|
||||||
|
const dupPreview = ref(null)
|
||||||
|
const loadingDupPreview = ref(false)
|
||||||
|
const merging = ref(false)
|
||||||
|
const merged = ref(null)
|
||||||
|
const dupSampleNames = computed(() =>
|
||||||
|
(dupPreview.value?.sample ?? []).map(
|
||||||
|
(g) => `${g.title} — ${g.rows} rows`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async function onPreview() {
|
async function onPreview() {
|
||||||
loadingPreview.value = true
|
loadingPreview.value = true
|
||||||
deleted.value = null
|
deleted.value = null
|
||||||
@@ -88,6 +142,28 @@ async function onCommit() {
|
|||||||
committing.value = false
|
committing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onPreviewDupes() {
|
||||||
|
loadingDupPreview.value = true
|
||||||
|
merged.value = null
|
||||||
|
try {
|
||||||
|
dupPreview.value = await store.reconcileDuplicatePosts({ dryRun: true })
|
||||||
|
} finally {
|
||||||
|
loadingDupPreview.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMergeDupes() {
|
||||||
|
merging.value = true
|
||||||
|
try {
|
||||||
|
const result = await store.reconcileDuplicatePosts({ dryRun: false })
|
||||||
|
merged.value = result.merged ?? 0
|
||||||
|
// Same predicate as the preview — after the merge there are no dup groups left.
|
||||||
|
dupPreview.value = { groups: 0, posts_to_merge: 0, sample: [] }
|
||||||
|
} finally {
|
||||||
|
merging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -129,6 +129,22 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tier-A: unify duplicate post rows (gallery-dl attachment-id + native post-id
|
||||||
|
// for the same real post) onto one post-id-keyed keeper. Images untouched.
|
||||||
|
// Preview/apply parity on the backend; UI previews then confirms.
|
||||||
|
async function reconcileDuplicatePosts({ dryRun = true, sourceId = null } = {}) {
|
||||||
|
lastError.value = null
|
||||||
|
try {
|
||||||
|
return await api.post(
|
||||||
|
'/api/admin/posts/reconcile-duplicates',
|
||||||
|
{ body: { dry_run: dryRun, source_id: sourceId } },
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
lastError.value = e.message
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function purgeLegacyTags({ dryRun = true } = {}) {
|
async function purgeLegacyTags({ dryRun = true } = {}) {
|
||||||
lastError.value = null
|
lastError.value = null
|
||||||
try {
|
try {
|
||||||
@@ -208,6 +224,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
tagUsageCount,
|
tagUsageCount,
|
||||||
pruneUnusedTags,
|
pruneUnusedTags,
|
||||||
pruneBarePosts,
|
pruneBarePosts,
|
||||||
|
reconcileDuplicatePosts,
|
||||||
purgeLegacyTags,
|
purgeLegacyTags,
|
||||||
resetContentTagging,
|
resetContentTagging,
|
||||||
normalizeTags,
|
normalizeTags,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ side effects use tmp_path. Assertions on mutated rows use COLUMN
|
|||||||
SELECTS per reference_async_coredml_test_assertions — never
|
SELECTS per reference_async_coredml_test_assertions — never
|
||||||
re-read ORM attributes after a service mutates and re-fetches.
|
re-read ORM attributes after a service mutates and re-fetches.
|
||||||
"""
|
"""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
@@ -767,3 +769,87 @@ def test_purge_gated_previews_no_gated_posts_is_noop(db_sync, tmp_path):
|
|||||||
assert out["matched"] == 0
|
assert out["matched"] == 0
|
||||||
assert out["deleted"] == 0
|
assert out["deleted"] == 0
|
||||||
assert out["gated_posts"] == 0
|
assert out["gated_posts"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- duplicate-post reconciliation (gallery-dl → native, milestone #73) ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_merges_attachmentid_and_postid_dupes(db_sync, tmp_path):
|
||||||
|
# gallery-dl row (keyed by attachment id, holds the image + body/date) +
|
||||||
|
# native row (keyed by the real post id, bare) → unified onto ONE post-id-
|
||||||
|
# keyed keeper, image moved over, keeper backfilled from the legacy row.
|
||||||
|
a = _make_artist(db_sync, slug="rc1")
|
||||||
|
img = _make_image(db_sync, artist=a, path=str(tmp_path / "x.jpg"), sha256="c" * 64)
|
||||||
|
legacy = Post(
|
||||||
|
artist_id=a.id, external_post_id="711509",
|
||||||
|
raw_metadata={"post_id": 1923726, "category": "subscribestar"},
|
||||||
|
description="real body", post_title="T",
|
||||||
|
post_date=datetime(2025, 6, 20, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
native = Post(
|
||||||
|
artist_id=a.id, external_post_id="1923726",
|
||||||
|
raw_metadata={"post_id": 1923726, "category": "subscribestar"},
|
||||||
|
)
|
||||||
|
db_sync.add_all([legacy, native])
|
||||||
|
db_sync.flush()
|
||||||
|
img.primary_post_id = legacy.id
|
||||||
|
img_id, native_id, legacy_id = img.id, native.id, legacy.id
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
prev = cleanup_service.reconcile_duplicate_posts(db_sync, dry_run=True)
|
||||||
|
assert prev["groups"] == 1
|
||||||
|
assert prev["posts_to_merge"] == 1
|
||||||
|
res = cleanup_service.reconcile_duplicate_posts(db_sync, dry_run=False)
|
||||||
|
assert res["merged"] == 1
|
||||||
|
|
||||||
|
rows = db_sync.execute(select(Post.id, Post.external_post_id)).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
keeper_id, keeper_epid = rows[0]
|
||||||
|
assert keeper_id == native_id # native (post-id-keyed) row survives
|
||||||
|
assert keeper_epid == "1923726"
|
||||||
|
# image moved to keeper; legacy row gone; keeper backfilled.
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(ImageRecord.primary_post_id).where(ImageRecord.id == img_id)
|
||||||
|
).scalar_one() == native_id
|
||||||
|
assert db_sync.execute(
|
||||||
|
select(func.count()).select_from(Post).where(Post.id == legacy_id)
|
||||||
|
).scalar_one() == 0
|
||||||
|
desc, pdate = db_sync.execute(
|
||||||
|
select(Post.description, Post.post_date).where(Post.id == native_id)
|
||||||
|
).one()
|
||||||
|
assert desc == "real body"
|
||||||
|
assert pdate is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_noop_when_posts_unique(db_sync):
|
||||||
|
a = _make_artist(db_sync, slug="rc2")
|
||||||
|
db_sync.add_all([
|
||||||
|
Post(artist_id=a.id, external_post_id="100", raw_metadata={"post_id": 100}),
|
||||||
|
Post(artist_id=a.id, external_post_id="200", raw_metadata={"post_id": 200}),
|
||||||
|
])
|
||||||
|
db_sync.commit()
|
||||||
|
assert cleanup_service.reconcile_duplicate_posts(db_sync, dry_run=True)["groups"] == 0
|
||||||
|
res = cleanup_service.reconcile_duplicate_posts(db_sync, dry_run=False)
|
||||||
|
assert res["merged"] == 0
|
||||||
|
assert db_sync.execute(select(func.count()).select_from(Post)).scalar_one() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_dedups_provenance_collision(db_sync, tmp_path):
|
||||||
|
# Both dup rows link the SAME image via provenance; merging must DROP the
|
||||||
|
# colliding loser row (unique image_record_id+post_id), not raise.
|
||||||
|
a = _make_artist(db_sync, slug="rc3")
|
||||||
|
img = _make_image(db_sync, artist=a, path=str(tmp_path / "y.jpg"), sha256="d" * 64)
|
||||||
|
legacy = Post(artist_id=a.id, external_post_id="9", raw_metadata={"post_id": 55})
|
||||||
|
native = Post(artist_id=a.id, external_post_id="55", raw_metadata={"post_id": 55})
|
||||||
|
db_sync.add_all([legacy, native])
|
||||||
|
db_sync.flush()
|
||||||
|
db_sync.add(ImageProvenance(image_record_id=img.id, post_id=legacy.id))
|
||||||
|
db_sync.add(ImageProvenance(image_record_id=img.id, post_id=native.id))
|
||||||
|
img_id, native_id = img.id, native.id
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
cleanup_service.reconcile_duplicate_posts(db_sync, dry_run=False)
|
||||||
|
prov = db_sync.execute(
|
||||||
|
select(ImageProvenance.post_id).where(ImageProvenance.image_record_id == img_id)
|
||||||
|
).scalars().all()
|
||||||
|
assert prov == [native_id]
|
||||||
|
|||||||
Reference in New Issue
Block a user