feat(artist): move a source into another artist (#130 step 4)
Operator ask: a surface to merge new sources into existing artists (consolidate the singleton artist a fresh add spins up). Enabled by the #130 slug decoupling — the storage path is immutable, so re-attribution moves NO files. SourceService .reassign moves the source, re-points its posts (Post.source_id==S) and the images it contributed (ImageProvenance via S, scoped to the old artist so shared images aren't stolen), and deletes the old artist if it's left fully empty (else clears its subscription flag). POST /api/sources/<id>/reassign. Frontend: a 'Move…' action per source on the artist Management tab → artist-autocomplete picker → confirm → routes to the target (whose slug is stable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -186,6 +186,94 @@ async def test_update_while_enabled_keeps_failure_state(db):
|
||||
assert refetched.consecutive_failures == 3
|
||||
|
||||
|
||||
async def _source_with_content(db, svc, artist):
|
||||
"""A source under `artist` with one post + one image it contributed."""
|
||||
from backend.app.models import ImageProvenance, ImageRecord, Post
|
||||
rec = await svc.create(
|
||||
artist_id=artist.id, platform="pixiv",
|
||||
url=f"https://www.pixiv.net/users/{artist.id}",
|
||||
)
|
||||
post = Post(source_id=rec.id, artist_id=artist.id, external_post_id="p1")
|
||||
db.add(post)
|
||||
img = ImageRecord(
|
||||
path=f"/images/{artist.slug}/pixiv/pixiv/1_a_00.jpg",
|
||||
sha256=str(artist.id).rjust(64, "0"), size_bytes=1, mime="image/jpeg",
|
||||
width=1, height=1, origin="imported_filesystem",
|
||||
integrity_status="unknown", artist_id=artist.id,
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
db.add(ImageProvenance(image_record_id=img.id, post_id=post.id, source_id=rec.id))
|
||||
await db.commit()
|
||||
return rec, post, img
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reassign_moves_source_posts_images(db):
|
||||
from backend.app.models import ImageRecord, Post
|
||||
old = await _artist(db, "OldOwner")
|
||||
new = await _artist(db, "NewOwner")
|
||||
svc = SourceService(db)
|
||||
rec, post, img = await _source_with_content(db, svc, old)
|
||||
|
||||
await svc.reassign(rec.id, new.id)
|
||||
|
||||
assert (await db.execute(
|
||||
select(Source.artist_id).where(Source.id == rec.id)
|
||||
)).scalar_one() == new.id
|
||||
assert (await db.execute(
|
||||
select(Post.artist_id).where(Post.id == post.id)
|
||||
)).scalar_one() == new.id
|
||||
assert (await db.execute(
|
||||
select(ImageRecord.artist_id).where(ImageRecord.id == img.id)
|
||||
)).scalar_one() == new.id
|
||||
# Old artist is now empty → deleted.
|
||||
assert (await db.execute(
|
||||
select(Artist).where(Artist.id == old.id)
|
||||
)).scalar_one_or_none() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reassign_keeps_nonempty_old_artist(db):
|
||||
from backend.app.models import ImageRecord
|
||||
old = await _artist(db, "OldMulti")
|
||||
new = await _artist(db, "NewMulti")
|
||||
svc = SourceService(db)
|
||||
rec, _post, _img = await _source_with_content(db, svc, old)
|
||||
# A second, unrelated image keeps `old` non-empty after the move.
|
||||
db.add(ImageRecord(
|
||||
path="/images/oldmulti/loose.jpg", sha256="e" * 64, size_bytes=1,
|
||||
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
|
||||
integrity_status="unknown", artist_id=old.id,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
await svc.reassign(rec.id, new.id)
|
||||
still = (await db.execute(
|
||||
select(Artist).where(Artist.id == old.id)
|
||||
)).scalar_one()
|
||||
assert still.is_subscription is False # lost its last source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reassign_same_artist_is_noop(db):
|
||||
a = await _artist(db, "Solo")
|
||||
svc = SourceService(db)
|
||||
rec, _post, _img = await _source_with_content(db, svc, a)
|
||||
out = await svc.reassign(rec.id, a.id)
|
||||
assert out.artist_id == a.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reassign_unknown_target_raises(db):
|
||||
from backend.app.services.source_service import ArtistNotFoundError
|
||||
a = await _artist(db, "Whom")
|
||||
svc = SourceService(db)
|
||||
rec, _post, _img = await _source_with_content(db, svc, a)
|
||||
with pytest.raises(ArtistNotFoundError):
|
||||
await svc.reassign(rec.id, 999999)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_hides_sidecar_synthetic_anchors(db):
|
||||
"""Filesystem-import synthetic Sources (url='sidecar:<platform>:<slug>',
|
||||
|
||||
Reference in New Issue
Block a user