feat(provenance): capture which archive an extracted image came from (#87)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Failing after 3m20s

Images pulled out of a .zip/.rar previously kept no record of WHICH archive
they came from — the member->archive link was computed during extraction and
discarded, leaving only image->post. So the provenance modal could only scope
attachments to the whole post, showing every archive a 'High Resolution Files'
bundle carried instead of the one a given file lives in.

- ImageProvenance.from_attachment_id: nullable FK -> post_attachment.id
  (SET NULL), migration 0055.
- importer: _import_archive stamps from_attachment_id on every member's
  provenance row for the post (new + superseded + deduped members), resolving
  the archive's own PostAttachment by (post, sha). Post-pass UPDATE, NULL-only
  and idempotent, so it doesn't touch the dedup/supersede branches and the
  backfill is safe to re-run. Nested members link to the outer stored archive.
- provenance_service.for_image: when the originating post's provenance row
  records from_attachment_id, return ONLY that archive; else fall back to the
  primary-post scoping from 068def2.
- ProvenancePanel: heading pluralizes ('Attachment' for a single file).
- Backfill: re-running reextract_archive_attachments (ArchiveReextractCard)
  routes through _import_archive and stamps existing rows — no new code.

Tests: capture stamps on fresh import, nested-archive attribution, per-post
archive on dedup; for_image filters to the containing archive; reextract
backfill stamps the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:22:03 -04:00
parent 068def2f24
commit 5269cd0709
8 changed files with 273 additions and 19 deletions
+31
View File
@@ -165,6 +165,37 @@ async def test_for_image_attachments_scoped_to_primary_post(db):
assert names == ["keep.zip"]
@pytest.mark.asyncio
async def test_for_image_attachments_filtered_to_containing_archive(db):
# Milestone #87: when the originating post's provenance row records which
# archive the file came from, show ONLY that archive — not the post's other
# attachments.
rec = await _seed_image(db)
a1, s1, post = await _seed_post(db, artist_name="Arc", slug="arc",
platform="patreon", ext_id="500")
rec.primary_post_id = post.id
att_in = PostAttachment(
post_id=post.id, artist_id=a1.id, sha256="e" + "0" * 63,
path="/images/attachments/e00/in.cbz", original_filename="in.cbz",
ext=".cbz", mime="application/zip", size_bytes=9,
)
att_other = PostAttachment(
post_id=post.id, artist_id=a1.id, sha256="f" + "0" * 63,
path="/images/attachments/f00/other.rar", original_filename="other.rar",
ext=".rar", mime="application/x-rar", size_bytes=9,
)
db.add(att_in)
db.add(att_other)
await db.flush()
db.add(ImageProvenance(image_record_id=rec.id, post_id=post.id,
source_id=s1.id, from_attachment_id=att_in.id))
await db.flush()
payload = await ProvenanceService(db).for_image(rec.id)
names = [a["original_filename"] for a in payload["attachments"]]
assert names == ["in.cbz"]
@pytest.mark.asyncio
async def test_for_image_attachments_fallback_to_all_posts_when_no_primary(db):
# No primary_post_id (older rows / filesystem imports) → preserve the