feat: offer the creator you already track as the one you subscribe to (388 E4)
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
**The verification the step asked for came back "not the schema".**
`Source.artist_id` is a plain FK so many sources per artist already works;
`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 — 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 FC OFFERING the link, and that
is all this adds.
**Accepting adds a SOURCE. It never merges two artists.** That asymmetry sets
the whole posture: adding a source is trivially undone, while a wrong merge
silently mixes two creators' work and corrupts tagging, series and provenance
downstream with nothing left to tell them apart by. A test asserts the artist
count is unchanged by accepting.
The weights encode the judgement rather than a code path doing it — name 0.65,
declared 0.35, cut at 0.60 — so that:
* an EXACT name match alone proposes (same slug on both sides is strong, and
demanding corroboration would propose almost nothing);
* a CONTAINMENT match alone does not ("art" sits inside "artgirl"), and short
slugs are excluded from containment entirely because a 3-character slug is
inside a great many longer ones;
* the declaration ALONE never proposes, because a creator may link another
creator's Patreon and a link is not a claim of identity.
A guard test pins all three against WEIGHTS directly and says not to fix a
failure by moving the numbers.
Two corrections carried forward from earlier steps rather than rediscovered:
* The declaration is NOT read from `ExternalLink`. `SUPPORTED_HOSTS` is file
hosts only and `host_for()` returns None for patreon.com, so no row is ever
written for one — the same trap that caught E5 for Discord invites. It reads
the raw body, because these links live in an `href` and `html_to_plain`
discards attributes.
* `vanity` is not a column: C1 modelled the roster before any platform was
characterised, which is exactly what `details` exists for. `vanity_or_none()`
reads it from there and falls back to the URL's last segment, so a row
written before the field was understood still resolves.
Two fixes during the writing. `accept()` first created a bare `Source()`,
skipping the platform/URL validation, duplicate check and #693 backfill-arming
that a hand-added source gets — a second, quieter way to create a source is how
two paths drift until one is subtly broken; it now goes through
`SourceService.create`. And the candidate query used a bare `exists().where()`,
which has no FROM to correlate against; now `select(...).exists()`.
Chained onto the roster sweep rather than given its own beat entry: a
suggestion can only be as good as the roster behind it, so any other cadence
would just propose from staler data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""artist_membership_suggestion — proposing that a creator and a membership match.
|
||||
|
||||
Milestone 388, step E4.
|
||||
|
||||
## What this migration deliberately does NOT add
|
||||
|
||||
No association table between Artist and Source, and no schema change to either.
|
||||
E4's first job was to verify what was actually missing, and the answer was
|
||||
neither the model nor the flows: `Source.artist_id` is a plain FK so many
|
||||
sources per artist already works, `POST /api/sources` already takes an
|
||||
`artist_id`, the add-source dialog already attaches to an EXISTING artist, and
|
||||
`SourceService.reassign` already moves a source between artists with post and
|
||||
image re-attribution. Building a parallel association table for a relationship
|
||||
the schema already expresses would have been the mistake rule 28 names.
|
||||
|
||||
What was missing is the SUGGESTION, and that is all this table holds.
|
||||
|
||||
Accepting a suggestion adds a SOURCE under the existing artist — it never
|
||||
merges two artists. Adding a source is trivially undone; a wrong merge silently
|
||||
mixes two creators' work and corrupts tagging, series and provenance with
|
||||
nothing left to separate them by.
|
||||
|
||||
Revision ID: 0096
|
||||
Revises: 0095
|
||||
Create Date: 2026-09-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0096"
|
||||
down_revision: Union[str, None] = "0095"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"artist_membership_suggestion",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("platform_membership_id", sa.Integer(), nullable=False),
|
||||
sa.Column("artist_id", sa.Integer(), nullable=False),
|
||||
sa.Column("score", sa.Float(), nullable=False),
|
||||
sa.Column("signals", sa.JSON(), nullable=True),
|
||||
# No CHECK on status (rule 36 considered and declined), matching
|
||||
# series_suggestion and post_association — the same review-queue
|
||||
# vocabulary and the same check-existing-enums lesson.
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), server_default="pending", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_artist_membership_suggestion")),
|
||||
# CASCADE both ways: a suggestion about a membership or an artist that
|
||||
# no longer exists is not a fact worth keeping, and a dangling proposal
|
||||
# would render as a broken row in the review queue.
|
||||
sa.ForeignKeyConstraint(
|
||||
["platform_membership_id"], ["platform_membership.id"],
|
||||
ondelete="CASCADE",
|
||||
name=op.f("fk_artist_membership_suggestion_membership"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"], ["artist.id"], ondelete="CASCADE",
|
||||
name=op.f("fk_artist_membership_suggestion_artist_id_artist"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"platform_membership_id", "artist_id",
|
||||
name="uq_artist_membership_suggestion_pair",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_artist_membership_suggestion_platform_membership_id"),
|
||||
"artist_membership_suggestion", ["platform_membership_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_artist_membership_suggestion_artist_id"),
|
||||
"artist_membership_suggestion", ["artist_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_artist_membership_suggestion_status"),
|
||||
"artist_membership_suggestion", ["status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_artist_membership_suggestion_status"),
|
||||
table_name="artist_membership_suggestion",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_artist_membership_suggestion_artist_id"),
|
||||
table_name="artist_membership_suggestion",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_artist_membership_suggestion_platform_membership_id"),
|
||||
table_name="artist_membership_suggestion",
|
||||
)
|
||||
op.drop_table("artist_membership_suggestion")
|
||||
@@ -5,6 +5,8 @@ from sqlalchemy import func, select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
||||
from ..services.artist_membership_service import ArtistMembershipService
|
||||
from ..services.artist_membership_service import rescan as membership_rescan
|
||||
from ..services.membership_roster import roster_is_fresh
|
||||
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
|
||||
from ..services.source_service import (
|
||||
@@ -343,3 +345,44 @@ async def trigger_membership_sync():
|
||||
|
||||
sync_memberships.delay()
|
||||
return jsonify({"queued": True})
|
||||
|
||||
|
||||
# --- #388 E4: creator/membership suggestions ------------------------------
|
||||
#
|
||||
# Confirm-only. Accepting ADDS A SOURCE under the existing artist — it never
|
||||
# merges two artists, because adding a source is trivially undone and a wrong
|
||||
# merge silently mixes two creators' work with nothing left to separate them by.
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions", methods=["GET"])
|
||||
async def list_membership_suggestions():
|
||||
async with get_session() as session:
|
||||
return jsonify({"items": await ArtistMembershipService(session).list_pending()})
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions/<int:sid>/accept", methods=["POST"])
|
||||
async def accept_membership_suggestion(sid: int):
|
||||
async with get_session() as session:
|
||||
result = await ArtistMembershipService(session).accept(sid)
|
||||
if result is None:
|
||||
return _bad("suggestion_not_found", status=404)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions/<int:sid>/dismiss", methods=["POST"])
|
||||
async def dismiss_membership_suggestion(sid: int):
|
||||
async with get_session() as session:
|
||||
result = await ArtistMembershipService(session).dismiss(sid)
|
||||
if result is None:
|
||||
return _bad("suggestion_not_found", status=404)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions/rescan", methods=["POST"])
|
||||
async def rescan_membership_suggestions():
|
||||
async with get_session() as session:
|
||||
result = await membership_rescan(session)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from .app_setting import AppSetting
|
||||
from .artist import Artist
|
||||
from .artist_membership_suggestion import ArtistMembershipSuggestion
|
||||
from .artist_visit import ArtistVisit
|
||||
from .backup_run import BackupRun
|
||||
from .base import Base
|
||||
@@ -50,6 +51,7 @@ __all__ = [
|
||||
"Base",
|
||||
"AppSetting",
|
||||
"Artist",
|
||||
"ArtistMembershipSuggestion",
|
||||
"ArtistVisit",
|
||||
"BackupRun",
|
||||
"Source",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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(),
|
||||
)
|
||||
@@ -128,3 +128,25 @@ class PlatformMembership(Base):
|
||||
# be answered without re-fetching — and so a field we did not think to
|
||||
# model is not lost. Displayed and never queried, like service_seen.details.
|
||||
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
def vanity_or_none(self) -> str | None:
|
||||
"""The platform's URL slug for this creator, if it can be known.
|
||||
|
||||
NOT a column, and that is C1's design working as intended rather than
|
||||
an omission: the roster was modelled before any platform had been
|
||||
characterised, so `details` exists precisely to carry the fields we did
|
||||
not know to model. The vanity turned out to be one of them (#3886), and
|
||||
it is reachable without a migration.
|
||||
|
||||
Falls back to the URL's last segment, which is what a vanity IS on
|
||||
every platform seen so far — but only as a fallback, because the
|
||||
platform's own word for it is the better answer when present.
|
||||
"""
|
||||
campaign = (self.details or {}).get("campaign") or {}
|
||||
vanity = campaign.get("vanity")
|
||||
if isinstance(vanity, str) and vanity:
|
||||
return vanity
|
||||
if self.url:
|
||||
tail = self.url.rstrip("/").rsplit("/", 1)[-1]
|
||||
return tail or None
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Proposing that a creator FC tracks and a membership it found are the same.
|
||||
|
||||
Milestone 388, step E4. An instance of the confirm-only matcher shape
|
||||
(snippet #3842), and a sibling of `post_association_service`.
|
||||
|
||||
## What E4 turned out NOT to need
|
||||
|
||||
The step's own first instruction was to verify before building, and the
|
||||
verification said: not the schema, not the flows. `Source.artist_id` is a plain
|
||||
FK so many sources per artist already works; `POST /api/sources` already takes
|
||||
an `artist_id`; the add-source dialog already has an artist autocomplete that
|
||||
attaches to an EXISTING artist; `SourceService.reassign` already moves a source
|
||||
between artists WITH post and image re-attribution; and a sweep for
|
||||
one-source-per-artist assumptions found only `func.count()` calls, which are
|
||||
the opposite of assuming one.
|
||||
|
||||
So the association a Discord source and a Patreon source share is already
|
||||
expressible today. What was missing is FC OFFERING it.
|
||||
|
||||
## Accept adds a SOURCE — it never merges artists
|
||||
|
||||
The asymmetry that sets the whole posture: adding a source is trivially undone.
|
||||
A wrong artist merge silently mixes two creators' work and corrupts tagging,
|
||||
series and provenance downstream, with nothing left to tell the two apart by.
|
||||
So the accepted action is "add the missing channel to this artist", and merging
|
||||
is not offered at all.
|
||||
|
||||
## The signals
|
||||
|
||||
1. **Name.** The roster's `display_name` and `vanity`, slugified, against the
|
||||
artist's `slug`. Graded rather than boolean — an exact match is strong
|
||||
evidence, a containment match is a hint.
|
||||
2. **Declared.** A post already under this artist whose body links to
|
||||
`patreon.com/<vanity>` for this exact membership. A creator pointing at
|
||||
their own Patreon from their own Discord is close to a statement.
|
||||
|
||||
Signal 2 is NOT read from `ExternalLink`, and that correction is worth keeping:
|
||||
`link_extract.SUPPORTED_HOSTS` is file hosts only (mega/gdrive/mediafire/
|
||||
dropbox/pixeldrain) and `host_for()` returns None for patreon.com, so no
|
||||
`ExternalLink` row is ever written for one. The same trap already caught E5 for
|
||||
Discord invites.
|
||||
|
||||
## Weights, and what they make impossible
|
||||
|
||||
name 0.65 · declared 0.35, cut at 0.60
|
||||
|
||||
Chosen so the arithmetic encodes the judgement rather than a code path doing it:
|
||||
|
||||
* an EXACT name match alone (0.65) proposes — same slug on both sides is
|
||||
strong, and requiring corroboration would mean proposing almost nothing;
|
||||
* a CONTAINMENT name match alone (0.6 * 0.65 = 0.39) does not — "art" inside
|
||||
"artgirl" is a coincidence generator, and it needs the declaration;
|
||||
* the declaration ALONE (0.35) never proposes, at any setting at or above
|
||||
0.60 — a creator may link another creator's Patreon, and a link is not a
|
||||
claim of identity.
|
||||
|
||||
A guard test pins all three against WEIGHTS directly, so they survive a
|
||||
refactor of the scorer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import (
|
||||
Artist,
|
||||
ArtistMembershipSuggestion,
|
||||
PlatformMembership,
|
||||
Post,
|
||||
Source,
|
||||
)
|
||||
from ..utils.slug import slugify
|
||||
from ..utils.text import html_to_plain
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WEIGHTS = {"name": 0.65, "declared": 0.35}
|
||||
DEFAULT_THRESHOLD = 0.60
|
||||
|
||||
NAME_EXACT = 1.0
|
||||
# Containment is a hint, not a match: "art" sits inside "artgirl", and slugs
|
||||
# are short enough that coincidental containment is common.
|
||||
NAME_CONTAINS = 0.6
|
||||
# Below this many characters, containment is noise rather than signal — a
|
||||
# 3-character slug is inside a great many longer ones.
|
||||
_MIN_CONTAINMENT_LEN = 5
|
||||
|
||||
MAX_CANDIDATES = 25
|
||||
|
||||
|
||||
def name_signal(membership: PlatformMembership, artist: Artist) -> float:
|
||||
"""Graded slug agreement between a membership and an artist.
|
||||
|
||||
Both the display name and the vanity are tried, because creators routinely
|
||||
differ between the two ("Team Melon Collie" vs "MelonCollieStudios") and
|
||||
either may be the one the operator typed when they created the artist.
|
||||
"""
|
||||
artist_slug = slugify(artist.name or "") if artist.name else ""
|
||||
if not artist_slug or artist_slug == "untitled":
|
||||
return 0.0
|
||||
candidates = {
|
||||
slugify(v) for v in (membership.display_name, membership.vanity_or_none())
|
||||
if v
|
||||
}
|
||||
candidates.discard("untitled")
|
||||
if not candidates:
|
||||
return 0.0
|
||||
if artist_slug in candidates:
|
||||
return NAME_EXACT
|
||||
for c in candidates:
|
||||
if len(c) < _MIN_CONTAINMENT_LEN or len(artist_slug) < _MIN_CONTAINMENT_LEN:
|
||||
continue
|
||||
if c in artist_slug or artist_slug in c:
|
||||
return NAME_CONTAINS
|
||||
return 0.0
|
||||
|
||||
|
||||
def declared_signal(body: str | None, vanity: str | None) -> float:
|
||||
"""Does this post body point at THIS membership's Patreon page?
|
||||
|
||||
Matched against the RAW body, not the stripped text: these links live in an
|
||||
anchor's `href`, and `html_to_plain` discards attributes — the same trap
|
||||
that caught E5's invite detection. The stripped text is checked too, for
|
||||
bodies that paste the URL as plain text.
|
||||
"""
|
||||
if not body or not vanity:
|
||||
return 0.0
|
||||
pattern = re.compile(
|
||||
r"patreon\.com/(?:c/|cw/|checkout/)?" + re.escape(vanity) + r"\b", re.I
|
||||
)
|
||||
if pattern.search(body):
|
||||
return 1.0
|
||||
return 1.0 if pattern.search(html_to_plain(body) or "") else 0.0
|
||||
|
||||
|
||||
def weighted_score(signals: dict) -> float:
|
||||
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
|
||||
|
||||
|
||||
class ArtistMembershipService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _decided(self, membership_id: int) -> set[int]:
|
||||
"""Artists already proposed for this membership, in ANY status.
|
||||
|
||||
Dismissed included: the row is what remembers the rejection, and
|
||||
re-proposing a rejected pair every scan is what makes a queue ignored.
|
||||
"""
|
||||
rows = (await self.session.execute(
|
||||
select(ArtistMembershipSuggestion.artist_id).where(
|
||||
ArtistMembershipSuggestion.platform_membership_id == membership_id
|
||||
)
|
||||
)).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
async def _candidate_artists(self, membership: PlatformMembership) -> list[Artist]:
|
||||
"""Artists that have SOME source but none for this membership's platform.
|
||||
|
||||
A hard filter, not a scored signal. An artist FC already tracks on this
|
||||
platform needs no suggestion — the link exists — and an artist with no
|
||||
sources at all is not a creator FC is following through another channel,
|
||||
which is the whole case this step is about.
|
||||
"""
|
||||
# `select(...).exists()` rather than a bare `exists().where(...)`: the
|
||||
# latter has no FROM to correlate against and does not reliably render.
|
||||
has_any = select(Source.id).where(Source.artist_id == Artist.id).exists()
|
||||
has_this = (
|
||||
select(Source.id)
|
||||
.where(
|
||||
Source.artist_id == Artist.id,
|
||||
Source.platform == membership.platform,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
return (await self.session.execute(
|
||||
select(Artist).where(has_any, ~has_this).limit(MAX_CANDIDATES)
|
||||
)).scalars().all()
|
||||
|
||||
async def _declared_for(self, artist_id: int, vanity: str | None) -> float:
|
||||
if not vanity:
|
||||
return 0.0
|
||||
# Bounded scan: the newest posts are where a creator's current links
|
||||
# live, and an unbounded body scan per (artist, membership) pair would
|
||||
# be the expensive part of this sweep.
|
||||
bodies = (await self.session.execute(
|
||||
select(Post.description)
|
||||
.where(Post.artist_id == artist_id, Post.description.is_not(None))
|
||||
.order_by(func.coalesce(Post.post_date, Post.downloaded_at).desc())
|
||||
.limit(50)
|
||||
)).scalars().all()
|
||||
for body in bodies:
|
||||
if declared_signal(body, vanity) > 0:
|
||||
return 1.0
|
||||
return 0.0
|
||||
|
||||
async def match_membership(
|
||||
self, membership_id: int, *, threshold: float = DEFAULT_THRESHOLD,
|
||||
) -> int:
|
||||
membership = await self.session.get(PlatformMembership, membership_id)
|
||||
if membership is None:
|
||||
return 0
|
||||
already = await self._decided(membership_id)
|
||||
|
||||
made = 0
|
||||
for artist in await self._candidate_artists(membership):
|
||||
if artist.id in already:
|
||||
continue
|
||||
signals = {
|
||||
"name": name_signal(membership, artist),
|
||||
"declared": await self._declared_for(
|
||||
artist.id, membership.vanity_or_none()
|
||||
),
|
||||
}
|
||||
score = weighted_score(signals)
|
||||
if score < threshold:
|
||||
continue
|
||||
self.session.add(ArtistMembershipSuggestion(
|
||||
platform_membership_id=membership.id,
|
||||
artist_id=artist.id,
|
||||
score=score,
|
||||
signals=signals,
|
||||
status="pending",
|
||||
))
|
||||
made += 1
|
||||
return made
|
||||
|
||||
async def list_pending(self) -> list[dict]:
|
||||
rows = (await self.session.execute(
|
||||
select(ArtistMembershipSuggestion, PlatformMembership, Artist)
|
||||
.join(
|
||||
PlatformMembership,
|
||||
PlatformMembership.id
|
||||
== ArtistMembershipSuggestion.platform_membership_id,
|
||||
)
|
||||
.join(Artist, Artist.id == ArtistMembershipSuggestion.artist_id)
|
||||
.where(ArtistMembershipSuggestion.status == "pending")
|
||||
.order_by(
|
||||
ArtistMembershipSuggestion.score.desc(),
|
||||
ArtistMembershipSuggestion.id.desc(),
|
||||
)
|
||||
)).all()
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"score": s.score,
|
||||
"signals": s.signals,
|
||||
"artist": {"id": a.id, "name": a.name, "slug": a.slug},
|
||||
"membership": {
|
||||
"id": m.id,
|
||||
"platform": m.platform,
|
||||
"display_name": m.display_name,
|
||||
"url": m.url,
|
||||
},
|
||||
}
|
||||
for s, m, a in rows
|
||||
]
|
||||
|
||||
async def accept(self, suggestion_id: int) -> dict | None:
|
||||
"""Add the missing channel to the artist. NEVER merges two artists.
|
||||
|
||||
Returns the created source's id, or `already_linked` when a source for
|
||||
that platform appeared between the proposal and the click — which is
|
||||
not an error, it is the operator having done it by hand.
|
||||
"""
|
||||
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
|
||||
if s is None:
|
||||
return None
|
||||
membership = await self.session.get(PlatformMembership, s.platform_membership_id)
|
||||
if membership is None or not membership.url:
|
||||
return None
|
||||
|
||||
existing = (await self.session.execute(
|
||||
select(Source.id).where(
|
||||
Source.artist_id == s.artist_id,
|
||||
Source.platform == membership.platform,
|
||||
)
|
||||
)).scalars().first()
|
||||
if existing is not None:
|
||||
s.status = "linked"
|
||||
return {"id": s.id, "status": s.status, "already_linked": existing}
|
||||
|
||||
# Through SourceService, NOT a bare Source() insert. It carries the
|
||||
# platform/URL validation, the duplicate check and the #693
|
||||
# backfill-arming that a hand-added source gets — building a second,
|
||||
# quieter way to create a source is how the two drift until one of them
|
||||
# is subtly broken (rule 28: repurpose the existing surface).
|
||||
from .source_service import DuplicateSourceError, SourceService
|
||||
|
||||
try:
|
||||
record = await SourceService(self.session).create(
|
||||
artist_id=s.artist_id,
|
||||
platform=membership.platform,
|
||||
url=membership.url,
|
||||
)
|
||||
except DuplicateSourceError as exc:
|
||||
# The same URL already exists for this artist — the operator got
|
||||
# there first by a different route. Not an error.
|
||||
s.status = "linked"
|
||||
return {"id": s.id, "status": s.status, "already_linked": exc.existing_id}
|
||||
s.status = "linked"
|
||||
return {"id": s.id, "status": s.status, "source_id": record.id}
|
||||
|
||||
async def dismiss(self, suggestion_id: int) -> dict | None:
|
||||
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
|
||||
if s is None:
|
||||
return None
|
||||
# Kept, not deleted — the row is what remembers the rejection.
|
||||
s.status = "dismissed"
|
||||
return {"id": s.id, "status": s.status}
|
||||
|
||||
|
||||
async def rescan(session: AsyncSession, *, threshold: float = DEFAULT_THRESHOLD) -> dict:
|
||||
"""Offer every known membership to the artists FC already tracks."""
|
||||
ids = (await session.execute(select(PlatformMembership.id))).scalars().all()
|
||||
svc = ArtistMembershipService(session)
|
||||
proposed = 0
|
||||
for mid in ids:
|
||||
proposed += await svc.match_membership(mid, threshold=threshold)
|
||||
log.info(
|
||||
"artist/membership matcher: scanned %d membership(s), proposed %d pair(s)",
|
||||
len(ids), proposed,
|
||||
)
|
||||
return {"scanned": len(ids), "proposed": proposed}
|
||||
@@ -1246,6 +1246,7 @@ def sync_memberships() -> str:
|
||||
|
||||
from ..services.credential_crypto import CredentialCrypto
|
||||
from ..services.credential_service import CredentialService
|
||||
from ..services.artist_membership_service import rescan as membership_rescan
|
||||
from ..services.membership_roster import sync_platform
|
||||
from ..services.patreon_client import PatreonClient
|
||||
from ._async_session import async_session_factory
|
||||
@@ -1298,7 +1299,17 @@ def sync_memberships() -> str:
|
||||
results.append(
|
||||
await sync_platform(session, platform=platform, fetch=fetch)
|
||||
)
|
||||
return {"results": results}
|
||||
|
||||
# #388 E4: offer the freshly-synced roster to the artists FC already
|
||||
# tracks. Chained here rather than given its own beat entry because
|
||||
# a suggestion can only be as good as the roster behind it — running
|
||||
# it on any other cadence would just propose from staler data.
|
||||
suggested = None
|
||||
if any(r.get("ok") for r in results):
|
||||
async with async_factory() as session:
|
||||
suggested = (await membership_rescan(session))["proposed"]
|
||||
await session.commit()
|
||||
return {"results": results, "suggested": suggested}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -1311,4 +1322,6 @@ def sync_memberships() -> str:
|
||||
parts.append(f"{r['platform']}={r['count']}")
|
||||
else:
|
||||
parts.append(f"{r['platform']}=FAILED({r['error']})")
|
||||
if res.get("suggested") is not None:
|
||||
parts.append(f"suggested={res['suggested']}")
|
||||
return " ".join(parts) or "no platforms"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<DiscordGroupingCard />
|
||||
<PostAssociationsCard />
|
||||
<MembershipRosterCard />
|
||||
<MembershipSuggestionsCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -85,6 +86,7 @@ import CropProposersCard from './CropProposersCard.vue'
|
||||
import HeadsCard from './HeadsCard.vue'
|
||||
import DiscordGroupingCard from './DiscordGroupingCard.vue'
|
||||
import MembershipRosterCard from './MembershipRosterCard.vue'
|
||||
import MembershipSuggestionsCard from './MembershipSuggestionsCard.vue'
|
||||
import PostAssociationsCard from './PostAssociationsCard.vue'
|
||||
import GpuAgentCard from './GpuAgentCard.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-account-multiple-plus-outline"
|
||||
title="Same creator, another channel"
|
||||
blurb="Creators you subscribe to who look like creators you already track."
|
||||
>
|
||||
<div class="text-caption fc-muted mb-3">
|
||||
When a membership in your roster looks like a creator you already follow
|
||||
elsewhere — a Discord server, say — FC offers to add the missing channel
|
||||
to that same creator. Accepting <strong>adds a source</strong>; it never
|
||||
merges two creators together.
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center mb-2" style="gap:12px">
|
||||
<strong class="text-body-2">
|
||||
{{ store.suggestions.length }} waiting for review
|
||||
</strong>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" :loading="store.loading" @click="store.rescan">
|
||||
Look again
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.suggestions.length" class="text-caption fc-muted">
|
||||
Nothing proposed. That is the usual state — a pair only appears when the
|
||||
names line up, or when one of your creator's posts links to the other
|
||||
channel.
|
||||
</div>
|
||||
|
||||
<div v-for="s in store.suggestions" :key="s.id" class="fc-sugg">
|
||||
<div class="fc-sugg__body">
|
||||
<div>
|
||||
<strong>{{ s.artist.name }}</strong>
|
||||
<span class="fc-sugg__arrow">and your</span>
|
||||
<strong>{{ s.membership.platform }}</strong>
|
||||
membership
|
||||
<em>{{ s.membership.display_name }}</em>
|
||||
</div>
|
||||
<!-- The per-signal breakdown, not just the total: "why did it suggest
|
||||
this" is the question the operator actually has, and a lone
|
||||
percentage cannot answer it. -->
|
||||
<div class="text-caption fc-muted">
|
||||
{{ Math.round(s.score * 100) }}% ·
|
||||
name {{ Math.round((s.signals?.name ?? 0) * 100) }}% ·
|
||||
links to it {{ (s.signals?.declared ?? 0) > 0 ? 'yes' : 'no' }}
|
||||
</div>
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="store.accept(s.id)">Add channel</v-btn>
|
||||
<v-btn size="small" variant="text" @click="store.dismiss(s.id)">Dismiss</v-btn>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { useMembershipSuggestionsStore } from '../../stores/membershipSuggestions.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
|
||||
const store = useMembershipSuggestionsStore()
|
||||
onMounted(() => { store.load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-sugg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
.fc-sugg__body { flex: 1; min-width: 0; }
|
||||
.fc-sugg__arrow { color: rgb(var(--v-theme-on-surface-variant)); margin: 0 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { toast } from '../utils/toast.js'
|
||||
|
||||
// Backs the creator/membership review queue (#388 E4). Confirm-only: accepting
|
||||
// ADDS A SOURCE to the artist that already has the other channel — it never
|
||||
// merges two artists. Adding a source is trivially undone; a wrong merge
|
||||
// silently mixes two creators' work with nothing left to separate them by.
|
||||
export const useMembershipSuggestionsStore = defineStore('membershipSuggestions', () => {
|
||||
const api = useApi()
|
||||
const suggestions = ref([])
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
|
||||
async function load () {
|
||||
await run(async () => {
|
||||
const body = await api.get('/api/sources/membership-suggestions')
|
||||
suggestions.value = body.items || []
|
||||
})
|
||||
}
|
||||
|
||||
async function accept (id) {
|
||||
try {
|
||||
const res = await api.post(`/api/sources/membership-suggestions/${id}/accept`, {})
|
||||
suggestions.value = suggestions.value.filter(s => s.id !== id)
|
||||
toast({
|
||||
text: res.already_linked ? 'Already linked' : 'Channel added to this creator',
|
||||
type: 'success'
|
||||
})
|
||||
} catch (e) {
|
||||
toast({ text: `Link failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function dismiss (id) {
|
||||
try {
|
||||
await api.post(`/api/sources/membership-suggestions/${id}/dismiss`, {})
|
||||
suggestions.value = suggestions.value.filter(s => s.id !== id)
|
||||
} catch (e) {
|
||||
toast({ text: `Dismiss failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function rescan () {
|
||||
await api.post('/api/sources/membership-suggestions/rescan', {})
|
||||
await load()
|
||||
}
|
||||
|
||||
return { suggestions, loading, error, load, accept, dismiss, rescan }
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Milestone 388 E4: proposing that a creator and a membership are the same.
|
||||
|
||||
E4's own first instruction was to verify before building, and the verification
|
||||
said the association ALREADY works — `Source.artist_id` is a plain FK, the API
|
||||
takes an `artist_id`, the add-source dialog attaches to an existing artist, and
|
||||
`reassign` moves a source with post/image re-attribution. So these test the
|
||||
SUGGESTION, which is what was actually missing.
|
||||
|
||||
The failure to avoid is a wrong link. Accepting adds a SOURCE rather than
|
||||
merging artists precisely because the first is trivially undone and the second
|
||||
silently mixes two creators' work — so most of what follows pins refusals, and
|
||||
the weights are asserted structurally so they survive a refactor of the scorer.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import (
|
||||
Artist,
|
||||
ArtistMembershipSuggestion,
|
||||
PlatformMembership,
|
||||
Post,
|
||||
Source,
|
||||
)
|
||||
from backend.app.services.artist_membership_service import (
|
||||
DEFAULT_THRESHOLD,
|
||||
WEIGHTS,
|
||||
ArtistMembershipService,
|
||||
declared_signal,
|
||||
name_signal,
|
||||
weighted_score,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# --- the structural guard --------------------------------------------------
|
||||
|
||||
|
||||
def test_the_weights_encode_the_judgement_rather_than_a_code_path():
|
||||
"""Three claims, asserted against WEIGHTS directly so they survive any
|
||||
refactor of the scorer. Do NOT fix a failure here by moving the numbers —
|
||||
the arithmetic IS the decision.
|
||||
|
||||
* an EXACT name match alone proposes (same slug is strong evidence, and
|
||||
demanding corroboration would propose almost nothing);
|
||||
* a CONTAINMENT name match alone does not ("art" sits inside "artgirl");
|
||||
* the declaration ALONE never proposes, because a creator may link
|
||||
another creator's Patreon and a link is not a claim of identity.
|
||||
"""
|
||||
assert sum(WEIGHTS.values()) == pytest.approx(1.0)
|
||||
assert weighted_score({"name": 1.0}) >= DEFAULT_THRESHOLD
|
||||
assert weighted_score({"name": 0.6}) < DEFAULT_THRESHOLD
|
||||
assert weighted_score({"declared": 1.0}) < DEFAULT_THRESHOLD
|
||||
assert weighted_score({"name": 0.6, "declared": 1.0}) >= DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
# --- the signals -----------------------------------------------------------
|
||||
|
||||
|
||||
def _m(**kw):
|
||||
kw.setdefault("platform", "patreon")
|
||||
kw.setdefault("external_campaign_id", "1")
|
||||
return PlatformMembership(**kw)
|
||||
|
||||
|
||||
def test_name_matches_on_either_the_display_name_or_the_vanity():
|
||||
"""Creators routinely differ between the two, and either may be what the
|
||||
operator typed when they created the artist."""
|
||||
m = _m(display_name="Team Melon Collie",
|
||||
details={"campaign": {"vanity": "MelonCollieStudios"}})
|
||||
assert name_signal(m, Artist(name="Team Melon Collie", slug="x")) == 1.0
|
||||
assert name_signal(m, Artist(name="meloncolliestudios", slug="x")) == 1.0
|
||||
|
||||
|
||||
def test_a_containment_match_is_a_hint_not_a_match():
|
||||
m = _m(display_name="Maewix Studios", details={})
|
||||
got = name_signal(m, Artist(name="Maewix", slug="x"))
|
||||
assert 0 < got < 1.0
|
||||
|
||||
|
||||
def test_a_short_slug_does_not_match_by_containment():
|
||||
"""A 3-character slug is inside a great many longer ones — containment
|
||||
there is a coincidence generator, not a signal."""
|
||||
m = _m(display_name="Artgirl Studios", details={})
|
||||
assert name_signal(m, Artist(name="art", slug="art")) == 0.0
|
||||
|
||||
|
||||
def test_unrelated_names_do_not_match():
|
||||
m = _m(display_name="Maewix", details={})
|
||||
assert name_signal(m, Artist(name="Floppystack", slug="x")) == 0.0
|
||||
|
||||
|
||||
def test_the_declaration_reads_through_an_anchor_href():
|
||||
"""These links live in an `href`, and html_to_plain discards attributes —
|
||||
the trap that already caught E5's invite detection."""
|
||||
body = '<p>Support me: <a href="https://www.patreon.com/maewix">here</a></p>'
|
||||
assert declared_signal(body, "maewix") == 1.0
|
||||
|
||||
|
||||
def test_the_declaration_is_specific_to_THIS_membership():
|
||||
"""A creator linking SOMEONE ELSE's Patreon must not link the two."""
|
||||
body = '<a href="https://www.patreon.com/someoneelse">a friend</a>'
|
||||
assert declared_signal(body, "maewix") == 0.0
|
||||
|
||||
|
||||
def test_the_declaration_tolerates_the_c_and_cw_url_variants():
|
||||
"""Patreon serves /c/<vanity> and /cw/<vanity> per campaign (#3886)."""
|
||||
assert declared_signal("see https://www.patreon.com/c/maewix", "maewix") == 1.0
|
||||
assert declared_signal("see https://www.patreon.com/cw/maewix", "maewix") == 1.0
|
||||
|
||||
|
||||
# --- end to end ------------------------------------------------------------
|
||||
|
||||
|
||||
async def _artist_with_discord(db, name, slug):
|
||||
a = Artist(name=name, slug=slug)
|
||||
db.add(a)
|
||||
await db.flush()
|
||||
db.add(Source(
|
||||
artist_id=a.id, platform="discord",
|
||||
url=f"https://discord.com/channels/1/{slug}", enabled=True,
|
||||
))
|
||||
await db.flush()
|
||||
return a
|
||||
|
||||
|
||||
async def _membership(db, **kw):
|
||||
kw.setdefault("platform", "patreon")
|
||||
kw.setdefault("external_campaign_id", "c1")
|
||||
kw.setdefault("url", "https://www.patreon.com/maewix")
|
||||
kw.setdefault("details", {"campaign": {"vanity": "maewix"}})
|
||||
m = PlatformMembership(**kw)
|
||||
db.add(m)
|
||||
await db.flush()
|
||||
return m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_matching_name_proposes_the_link(db):
|
||||
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||
m = await _membership(db, display_name="Maewix")
|
||||
await db.commit()
|
||||
|
||||
made = await ArtistMembershipService(db).match_membership(m.id)
|
||||
await db.commit()
|
||||
assert made == 1
|
||||
|
||||
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||
assert s.artist_id == artist.id
|
||||
assert s.status == "pending", "nothing is linked without the operator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_artist_already_on_that_platform_is_never_proposed(db):
|
||||
"""The link exists; a suggestion would be noise."""
|
||||
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||
db.add(Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://www.patreon.com/maewix", enabled=True,
|
||||
))
|
||||
m = await _membership(db, display_name="Maewix")
|
||||
await db.commit()
|
||||
|
||||
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_artist_with_no_sources_at_all_is_never_proposed(db):
|
||||
"""Not a creator FC follows through another channel, which is the whole
|
||||
case this step is about."""
|
||||
a = Artist(name="Maewix", slug="maewix")
|
||||
db.add(a)
|
||||
m = await _membership(db, display_name="Maewix")
|
||||
await db.commit()
|
||||
|
||||
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_weak_name_needs_the_declaration(db):
|
||||
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||
m = await _membership(db, display_name="Maewix Studios Official")
|
||||
await db.commit()
|
||||
assert await ArtistMembershipService(db).match_membership(m.id) == 0
|
||||
|
||||
db.add(Post(
|
||||
artist_id=artist.id, source_id=None, external_post_id="p1",
|
||||
description='<a href="https://www.patreon.com/maewix">my patreon</a>',
|
||||
post_date=datetime.now(UTC),
|
||||
))
|
||||
await db.commit()
|
||||
assert await ArtistMembershipService(db).match_membership(m.id) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepting_adds_a_source_and_never_merges_artists(db):
|
||||
"""THE safety property. Adding a source is trivially undone; a wrong merge
|
||||
mixes two creators' work with nothing left to separate them by."""
|
||||
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||
m = await _membership(db, display_name="Maewix")
|
||||
await db.commit()
|
||||
artists_before = len((await db.execute(select(Artist))).scalars().all())
|
||||
|
||||
svc = ArtistMembershipService(db)
|
||||
await svc.match_membership(m.id)
|
||||
await db.commit()
|
||||
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||
|
||||
result = await svc.accept(s.id)
|
||||
await db.commit()
|
||||
assert result["status"] == "linked"
|
||||
|
||||
sources = (await db.execute(
|
||||
select(Source).where(Source.artist_id == artist.id)
|
||||
)).scalars().all()
|
||||
assert {x.platform for x in sources} == {"discord", "patreon"}
|
||||
assert len((await db.execute(select(Artist))).scalars().all()) == artists_before, (
|
||||
"no artist may be created or destroyed by accepting"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepting_twice_does_not_add_a_second_source(db):
|
||||
"""A source appearing between the proposal and the click is the operator
|
||||
having done it by hand — not an error."""
|
||||
artist = await _artist_with_discord(db, "Maewix", "maewix")
|
||||
m = await _membership(db, display_name="Maewix")
|
||||
await db.commit()
|
||||
|
||||
svc = ArtistMembershipService(db)
|
||||
await svc.match_membership(m.id)
|
||||
await db.commit()
|
||||
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||
|
||||
await svc.accept(s.id)
|
||||
await db.commit()
|
||||
second = await svc.accept(s.id)
|
||||
await db.commit()
|
||||
assert "already_linked" in second
|
||||
|
||||
sources = (await db.execute(
|
||||
select(Source).where(
|
||||
Source.artist_id == artist.id, Source.platform == "patreon",
|
||||
)
|
||||
)).scalars().all()
|
||||
assert len(sources) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_dismissed_pair_is_never_proposed_again(db):
|
||||
await _artist_with_discord(db, "Maewix", "maewix")
|
||||
m = await _membership(db, display_name="Maewix")
|
||||
await db.commit()
|
||||
|
||||
svc = ArtistMembershipService(db)
|
||||
assert await svc.match_membership(m.id) == 1
|
||||
await db.commit()
|
||||
s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one()
|
||||
await svc.dismiss(s.id)
|
||||
await db.commit()
|
||||
|
||||
assert await svc.match_membership(m.id) == 0
|
||||
await db.commit()
|
||||
rows = (await db.execute(select(ArtistMembershipSuggestion))).scalars().all()
|
||||
assert len(rows) == 1 and rows[0].status == "dismissed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_vanity_falls_back_to_the_url_when_details_lack_it(db):
|
||||
"""C1 modelled the roster before any platform was characterised, so the
|
||||
vanity lives in `details` rather than a column — and must still be findable
|
||||
for a row written before that field was understood."""
|
||||
m = await _membership(
|
||||
db, display_name="Something Else", details={},
|
||||
url="https://www.patreon.com/maewix",
|
||||
)
|
||||
assert m.vanity_or_none() == "maewix"
|
||||
Reference in New Issue
Block a user