diff --git a/alembic/versions/0077_artist_name_not_unique.py b/alembic/versions/0077_artist_name_not_unique.py new file mode 100644 index 0000000..6a09288 --- /dev/null +++ b/alembic/versions/0077_artist_name_not_unique.py @@ -0,0 +1,32 @@ +"""drop uq_artist_name — decouple display name from identity/storage + +Revision ID: 0077 +Revises: 0076 +Create Date: 2026-07-04 + +Artist model fragility fix (milestone #130). One `slug` column was doing +identity + storage-path + display, and BOTH `name` and `slug` were UNIQUE, so +the display name couldn't be edited freely and two genuinely different creators +collided. Decouple: `slug` stays the immutable, unique storage/identity key (the +on-disk path component — untouched here); `name` becomes freely editable, NON- +unique display text. This migration only drops the `uq_artist_name` constraint; +no data moves and no path changes. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0077" +down_revision: Union[str, None] = "0076" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint("uq_artist_name", "artist", type_="unique") + + +def downgrade() -> None: + # Re-adding the UNIQUE would fail if duplicate names now exist; callers that + # need to reverse this must dedupe names first. + op.create_unique_constraint("uq_artist_name", "artist", ["name"]) diff --git a/backend/app/api/artists.py b/backend/app/api/artists.py index fa6da74..939fe4f 100644 --- a/backend/app/api/artists.py +++ b/backend/app/api/artists.py @@ -31,6 +31,24 @@ async def create_or_get(): }), 201 +@artists_bp.route("/", methods=["PATCH"]) +async def rename(artist_id: int): + """Rename an artist's DISPLAY NAME (#130). Name only — the slug and every + on-disk path stay put, so this is instant and safe. Name is non-unique.""" + body = await request.get_json() + if not isinstance(body, dict) or not isinstance(body.get("name"), str): + return jsonify({"error": "invalid_body"}), 400 + async with get_session() as session: + svc = ArtistService(session) + try: + artist = await svc.rename(artist_id, body["name"]) + except ValueError as exc: + return jsonify({"error": "empty_name", "detail": str(exc)}), 400 + if artist is None: + return jsonify({"error": "not_found"}), 404 + return jsonify({"id": artist.id, "name": artist.name, "slug": artist.slug}) + + @artists_bp.route("/autocomplete", methods=["GET"]) async def autocomplete(): q = request.args.get("q") or "" diff --git a/backend/app/models/artist.py b/backend/app/models/artist.py index d6847f4..e7ca894 100644 --- a/backend/app/models/artist.py +++ b/backend/app/models/artist.py @@ -15,7 +15,14 @@ class Artist(Base): __tablename__ = "artist" id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + # Display name: freely editable, NON-unique (two real creators can share a + # name). Decoupled from identity/storage in migration 0077 (#130) — renaming + # touches ONLY this. Was unique until then. + name: Mapped[str] = mapped_column(String(255), nullable=False) + # Storage/identity key: IMMUTABLE + unique. This is the on-disk path + # component (download_service artist_slug = artist.slug → images_root// + # /…), so it is set once at creation (collision-suffixed) and NEVER + # changes — a rename must not move files. Existing artists keep their slug. slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index 20acf06..a4150c8 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -283,6 +283,23 @@ class ArtistService: await self.session.commit() return artist, created + async def rename(self, artist_id: int, name: str) -> Artist | None: + """Change the display NAME only (#130). The slug — and every on-disk path + keyed off it — is IMMUTABLE, so a rename never moves files or risks a path + collision. Name is free text and NON-unique (migration 0077). Returns the + updated Artist, None if not found; raises ValueError on empty name.""" + cleaned = (name or "").strip() + if not cleaned: + raise ValueError("artist name must not be empty") + artist = (await self.session.execute( + select(Artist).where(Artist.id == artist_id) + )).scalar_one_or_none() + if artist is None: + return None + artist.name = cleaned + await self.session.commit() + return artist + async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]: cleaned = (prefix or "").strip() if not cleaned: diff --git a/frontend/src/components/artist/ArtistHeader.vue b/frontend/src/components/artist/ArtistHeader.vue index a2b1348..2c63210 100644 --- a/frontend/src/components/artist/ArtistHeader.vue +++ b/frontend/src/components/artist/ArtistHeader.vue @@ -1,8 +1,21 @@