"""artist_membership_suggestion — "this creator and that membership are the same". Milestone 388, step E4. ## What was NOT needed here E4's first job was to check what is actually missing, and the answer was: not the schema, and not the flows. `Source.artist_id` is a plain FK, so many sources per artist is already the data model; `POST /api/sources` already takes an `artist_id`; the add-source dialog already has an artist autocomplete that attaches to an EXISTING artist; and `SourceService.reassign` already moves a source between artists WITH post and image re-attribution. A sweep for one-source-per-artist assumptions found only `func.count()` calls, which are the opposite of assuming one. So no parallel association table was built for a relationship the schema already expresses (rule 28). What was missing is the SUGGESTION — FC proposing the link from the roster instead of waiting to be told. ## Confirm-only, and what "accept" actually does Accepting adds a SOURCE for the membership's platform under the artist that already has the other channel. It does NOT merge two artists. That distinction is the whole safety margin: adding a source is trivially undone, whereas a wrong artist merge silently mixes two creators' work and corrupts tagging, series and provenance downstream — with nothing left to tell them apart by. Dismissed rows are kept, not deleted, for the same reason as every other review queue here: the row is what remembers the rejection, and re-proposing a rejected pair on every scan is what makes a queue get ignored. """ from datetime import datetime from sqlalchemy import ( JSON, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from .base import Base class ArtistMembershipSuggestion(Base): __tablename__ = "artist_membership_suggestion" __table_args__ = ( UniqueConstraint( "platform_membership_id", "artist_id", name="uq_artist_membership_suggestion_pair", ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) platform_membership_id: Mapped[int] = mapped_column( ForeignKey("platform_membership.id", ondelete="CASCADE"), nullable=False, index=True, ) artist_id: Mapped[int] = mapped_column( ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True ) score: Mapped[float] = mapped_column(Float, nullable=False) # Per-signal strengths as scored. Without it, "why was this suggested" is # unanswerable the moment a weight or the threshold moves. signals: Mapped[dict | None] = mapped_column(JSON, nullable=True) # pending | linked | dismissed. Plain String, no CHECK — same call as # series_suggestion.status and post_association.status. status: Mapped[str] = mapped_column( String(16), nullable=False, server_default="pending", index=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), )