diff --git a/MPCAutofill/MPCAutofill/settings.py b/MPCAutofill/MPCAutofill/settings.py index ce690d1ff..ae508a3bf 100755 --- a/MPCAutofill/MPCAutofill/settings.py +++ b/MPCAutofill/MPCAutofill/settings.py @@ -60,6 +60,10 @@ PRINTING_TAG_MIN_SHARE = env.float("PRINTING_TAG_MIN_SHARE", default=0.6) PRINTING_TAG_ADMIN_WEIGHT = env.float("PRINTING_TAG_ADMIN_WEIGHT", default=5) PRINTING_TAG_AI_WEIGHT = env.float("PRINTING_TAG_AI_WEIGHT", default=0.5) +# federation-readiness stub (see docs/federation-v1.md) - no import path creates federated +# votes yet, so this setting is currently inert, but it's wired into vote_consensus._SOURCE_WEIGHTS +# alongside the weights above. +VOTE_FEDERATED_WEIGHT = env.float("VOTE_FEDERATED_WEIGHT", default=1.0) # django-ratelimit rate string (see cardpicker.views.post_submit_printing_tag), keyed by the # client-generated anonymous ID (IP as a fallback if that header is somehow missing). PRINTING_TAG_SUBMISSION_RATE = env("PRINTING_TAG_SUBMISSION_RATE", default="20/h") diff --git a/MPCAutofill/cardpicker/admin.py b/MPCAutofill/cardpicker/admin.py index a75cea631..5df2ca559 100644 --- a/MPCAutofill/cardpicker/admin.py +++ b/MPCAutofill/cardpicker/admin.py @@ -1,14 +1,20 @@ +from functools import reduce +from operator import or_ + from django.contrib import admin -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from django.http import HttpRequest +from .artist_consensus import get_contested_artist_card_ids from .models import ( CanonicalArtist, CanonicalCard, CanonicalExpansion, CanonicalPrintingMetadata, Card, + CardArtistVote, CardPrintingTag, + CardTagVote, DFCPair, Project, ProjectMember, @@ -19,6 +25,7 @@ ) from .printing_consensus import get_contested_card_ids from .sources.update_database import update_database +from .tag_consensus import get_contested_tag_pairs # Register your models here. @@ -131,6 +138,68 @@ class AdminCardPrintingTag(admin.ModelAdmin[CardPrintingTag]): raw_id_fields = ["card", "printing"] +class ContestedArtistFilter(admin.SimpleListFilter): + """Admin-triage wrapper around `cardpicker.artist_consensus.get_contested_artist_card_ids` - + mirrors `ContestedCardFilter` exactly, generalized to artist votes.""" + + title = "contested" + parameter_name = "contested" + + def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin[CardArtistVote]) -> list[tuple[str, str]]: + return [("yes", "Yes")] + + def queryset(self, request: HttpRequest, queryset: QuerySet[CardArtistVote]) -> QuerySet[CardArtistVote]: + if self.value() != "yes": + return queryset + return queryset.filter(card_id__in=get_contested_artist_card_ids()) + + +class ContestedTagFilter(admin.SimpleListFilter): + """Admin-triage wrapper around `cardpicker.tag_consensus.get_contested_tag_pairs` - same + idea as `ContestedCardFilter`, but the unit is a (card, tag) pair rather than just a card, + so the queryset filter is an OR of per-pair conditions rather than a plain `card_id__in`.""" + + title = "contested" + parameter_name = "contested" + + def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin[CardTagVote]) -> list[tuple[str, str]]: + return [("yes", "Yes")] + + def queryset(self, request: HttpRequest, queryset: QuerySet[CardTagVote]) -> QuerySet[CardTagVote]: + if self.value() != "yes": + return queryset + pairs = get_contested_tag_pairs() + if not pairs: + return queryset.none() + condition = reduce(or_, (Q(card_id=card_id, tag_id=tag_id) for card_id, tag_id in pairs)) + return queryset.filter(condition) + + +@admin.register(CardArtistVote) +class AdminCardArtistVote(admin.ModelAdmin[CardArtistVote]): + list_display = ( + "card", + "artist", + "is_unknown", + "source", + "peer", + "confidence", + "anonymous_id", + "created_at", + ) + list_filter = ("source", "is_unknown", "peer", ContestedArtistFilter) + search_fields = ("card__name",) + raw_id_fields = ["card", "artist"] + + +@admin.register(CardTagVote) +class AdminCardTagVote(admin.ModelAdmin[CardTagVote]): + list_display = ("card", "tag", "polarity", "source", "peer", "confidence", "anonymous_id", "created_at") + list_filter = ("source", "polarity", "peer", ContestedTagFilter) + search_fields = ("card__name", "tag__name") + raw_id_fields = ["card", "tag"] + + @admin.register(TagAliasSuggestion) class AdminTagAliasSuggestion(admin.ModelAdmin[TagAliasSuggestion]): list_display = ("raw_text", "suggested_tag", "confidence", "occurrence_count", "status") diff --git a/MPCAutofill/cardpicker/artist_consensus.py b/MPCAutofill/cardpicker/artist_consensus.py new file mode 100644 index 000000000..92e6b0e2d --- /dev/null +++ b/MPCAutofill/cardpicker/artist_consensus.py @@ -0,0 +1,148 @@ +from typing import Literal, TypedDict + +from django.conf import settings + +from cardpicker.models import ( + ArtistVoteStatus, + CanonicalArtist, + Card, + CardArtistVote, + VoteSource, +) +from cardpicker.vote_consensus import ( + _SOURCE_WEIGHTS, + VoteTuple, + contested_queryset, + resolve_weighted_consensus, +) + +UNKNOWN: Literal["UNKNOWN"] = "UNKNOWN" + + +def resolve_artist(card: Card) -> CanonicalArtist | Literal["UNKNOWN"] | None: + """ + Reconciles all `CardArtistVote` votes cast against `card` into a single resolved outcome: + a specific `CanonicalArtist`, the `UNKNOWN` sentinel (consensus is that the artist is + unlisted/unidentifiable), or `None` if there isn't yet enough signal. Mirrors + `cardpicker.printing_consensus.resolve_printing` exactly, built on the same shared + `resolve_weighted_consensus` core. + + Note this outcome is only ever surfaced to a viewer when the card's printing-tag + consensus *hasn't* resolved a printing - see the artist fallback chain in + `Card.serialise()`, where a resolved printing's own artist always takes precedence. + """ + votes = list(card.artist_votes.all()) + if not votes: + return None + + artists_by_id: dict[int, CanonicalArtist] = {} + vote_tuples: list[VoteTuple] = [] + for vote in votes: + key: int | Literal["UNKNOWN"] + if vote.is_unknown: + key = UNKNOWN + else: + # guaranteed non-null here by the model's artist_xor_unknown CheckConstraint + assert vote.artist_id is not None + assert vote.artist is not None + key = vote.artist_id + artists_by_id[vote.artist_id] = vote.artist + vote_tuples.append( + VoteTuple( + outcome_key=key, + weight=_SOURCE_WEIGHTS[vote.source], + is_human_backed=vote.source != VoteSource.AI, + ) + ) + + winning_key = resolve_weighted_consensus( + vote_tuples, min_weight=settings.PRINTING_TAG_MIN_VOTES, min_share=settings.PRINTING_TAG_MIN_SHARE + ) + if winning_key is None: + return None + if winning_key == UNKNOWN: + return UNKNOWN + assert isinstance(winning_key, int) + return artists_by_id[winning_key] + + +def resolve_and_persist_artist(card: Card) -> CanonicalArtist | Literal["UNKNOWN"] | None: + """ + Runs `resolve_artist(card)` and writes the outcome onto `card.inferred_canonical_artist` + and `card.artist_vote_status` together - same pattern as + `cardpicker.printing_consensus.resolve_and_persist_printing`. Deliberately doesn't consult + `card.printing_tag_status` at all: the precedence rule ("a resolved printing's artist wins") + is enforced entirely by `Card.serialise()`'s fallback chain, not here, so this function + stays decoupled from printing-tag state. + + When unresolved, additionally distinguishes `CONTESTED` (more than one distinct outcome + has votes) from plain `UNRESOLVED` (not enough votes yet to conclude anything) - a second, + lightweight query, only taken on this branch, so the common resolved case pays nothing + extra for it. + """ + result = resolve_artist(card) + if result is None: + distinct_outcomes = { + UNKNOWN if is_unknown else artist_id + for is_unknown, artist_id in card.artist_votes.values_list("is_unknown", "artist_id") + } + card.inferred_canonical_artist = None + card.artist_vote_status = ( + ArtistVoteStatus.CONTESTED if len(distinct_outcomes) > 1 else ArtistVoteStatus.UNRESOLVED + ) + elif result == UNKNOWN: + card.inferred_canonical_artist = None + card.artist_vote_status = ArtistVoteStatus.UNKNOWN + else: + card.inferred_canonical_artist = result + card.artist_vote_status = ArtistVoteStatus.RESOLVED + card.save(update_fields=["inferred_canonical_artist", "artist_vote_status"]) + return result + + +class ArtistVoteTallyEntry(TypedDict): + artist: CanonicalArtist | None + is_unknown: bool + count: int + + +def get_artist_vote_tally(card: Card) -> list[ArtistVoteTallyEntry]: + """ + Plain, unweighted per-outcome vote count for `card` - mirrors + `cardpicker.printing_consensus.get_vote_tally`, for showing a voter what's already been + said before they confirm or dispute it. + """ + tally: dict[int | Literal["UNKNOWN"], ArtistVoteTallyEntry] = {} + for vote in card.artist_votes.all(): + key: int | Literal["UNKNOWN"] + if vote.is_unknown: + key = UNKNOWN + else: + assert vote.artist_id is not None + key = vote.artist_id + if key not in tally: + tally[key] = ArtistVoteTallyEntry(artist=vote.artist, is_unknown=vote.is_unknown, count=0) + tally[key]["count"] += 1 + return sorted(tally.values(), key=lambda entry: entry["count"], reverse=True) + + +def get_contested_artist_card_ids() -> list[int]: + """ + IDs of cards with conflicting artist votes on record - mirrors + `cardpicker.printing_consensus.get_contested_card_ids` exactly, generalized via + `vote_consensus.contested_queryset`. See that function's docstring for what "contested" + means here and why this is a cheap proxy, not a full consensus recomputation. + """ + return contested_queryset( + CardArtistVote.objects.all(), group_by="card_id", outcome_field="artist_id", sentinel_field="is_unknown" + ) + + +__all__ = [ + "UNKNOWN", + "resolve_artist", + "resolve_and_persist_artist", + "get_artist_vote_tally", + "get_contested_artist_card_ids", + "ArtistVoteTallyEntry", +] diff --git a/MPCAutofill/cardpicker/migrations/0053_card_artist_vote_status_and_more.py b/MPCAutofill/cardpicker/migrations/0053_card_artist_vote_status_and_more.py new file mode 100644 index 000000000..2a7402618 --- /dev/null +++ b/MPCAutofill/cardpicker/migrations/0053_card_artist_vote_status_and_more.py @@ -0,0 +1,126 @@ +# Generated by Django 4.2.30 on 2026-07-12 18:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cardpicker", "0052_tagaliassuggestion_card_expansion_hint"), + ] + + operations = [ + migrations.AddField( + model_name="card", + name="artist_vote_status", + field=models.CharField( + choices=[("unresolved", "Unresolved"), ("resolved", "Resolved"), ("unknown", "Unknown")], + db_index=True, + default="unresolved", + max_length=10, + ), + ), + migrations.AddField( + model_name="card", + name="inferred_canonical_artist", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="cardpicker.canonicalartist", + ), + ), + migrations.CreateModel( + name="CardTagVote", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("anonymous_id", models.CharField(max_length=40)), + ( + "source", + models.CharField( + choices=[("user", "User"), ("admin", "Admin"), ("ai", "AI")], default="user", max_length=10 + ), + ), + ("confidence", models.FloatField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("polarity", models.SmallIntegerField(choices=[(1, "Apply"), (-1, "Not applicable")])), + ( + "card", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="tag_votes", to="cardpicker.card" + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="votes", to="cardpicker.tag" + ), + ), + ], + ), + migrations.CreateModel( + name="CardArtistVote", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("anonymous_id", models.CharField(max_length=40)), + ( + "source", + models.CharField( + choices=[("user", "User"), ("admin", "Admin"), ("ai", "AI")], default="user", max_length=10 + ), + ), + ("confidence", models.FloatField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("is_unknown", models.BooleanField(default=False)), + ( + "artist", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + to="cardpicker.canonicalartist", + ), + ), + ( + "card", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="artist_votes", to="cardpicker.card" + ), + ), + ], + ), + migrations.AddConstraint( + model_name="cardtagvote", + constraint=models.UniqueConstraint(fields=("card", "tag", "anonymous_id"), name="cardtagvote_unique_vote"), + ), + migrations.AddConstraint( + model_name="cardartistvote", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("artist__isnull", False), ("is_unknown", False)), + models.Q(("artist__isnull", True), ("is_unknown", True)), + _connector="OR", + ), + name="cardartistvote_artist_xor_unknown", + ), + ), + migrations.AddConstraint( + model_name="cardartistvote", + constraint=models.UniqueConstraint( + condition=models.Q(("is_unknown", False)), + fields=("card", "artist", "anonymous_id"), + name="cardartistvote_unique_artist_vote", + ), + ), + migrations.AddConstraint( + model_name="cardartistvote", + constraint=models.UniqueConstraint( + condition=models.Q(("is_unknown", True)), + fields=("card", "anonymous_id"), + name="cardartistvote_unique_unknown_vote", + ), + ), + ] diff --git a/MPCAutofill/cardpicker/migrations/0054_cardartistvote_peer_cardprintingtag_peer_and_more.py b/MPCAutofill/cardpicker/migrations/0054_cardartistvote_peer_cardprintingtag_peer_and_more.py new file mode 100644 index 000000000..249850ca3 --- /dev/null +++ b/MPCAutofill/cardpicker/migrations/0054_cardartistvote_peer_cardprintingtag_peer_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.30 on 2026-07-12 20:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cardpicker", "0053_card_artist_vote_status_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="cardartistvote", + name="peer", + field=models.CharField( + blank=True, help_text="Federation peer name; set only when source='federated'", max_length=64, null=True + ), + ), + migrations.AddField( + model_name="cardprintingtag", + name="peer", + field=models.CharField( + blank=True, help_text="Federation peer name; set only when source='federated'", max_length=64, null=True + ), + ), + migrations.AddField( + model_name="cardtagvote", + name="peer", + field=models.CharField( + blank=True, help_text="Federation peer name; set only when source='federated'", max_length=64, null=True + ), + ), + migrations.AlterField( + model_name="cardartistvote", + name="source", + field=models.CharField( + choices=[("user", "User"), ("admin", "Admin"), ("ai", "AI"), ("federated", "Federated")], + default="user", + max_length=10, + ), + ), + migrations.AlterField( + model_name="cardprintingtag", + name="source", + field=models.CharField( + choices=[("user", "User"), ("admin", "Admin"), ("ai", "AI"), ("federated", "Federated")], + default="user", + max_length=10, + ), + ), + migrations.AlterField( + model_name="cardtagvote", + name="source", + field=models.CharField( + choices=[("user", "User"), ("admin", "Admin"), ("ai", "AI"), ("federated", "Federated")], + default="user", + max_length=10, + ), + ), + ] diff --git a/MPCAutofill/cardpicker/migrations/0055_card_tag_vote_statuses_alter_card_artist_vote_status.py b/MPCAutofill/cardpicker/migrations/0055_card_tag_vote_statuses_alter_card_artist_vote_status.py new file mode 100644 index 000000000..26de96bc6 --- /dev/null +++ b/MPCAutofill/cardpicker/migrations/0055_card_tag_vote_statuses_alter_card_artist_vote_status.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.30 on 2026-07-12 20:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cardpicker", "0054_cardartistvote_peer_cardprintingtag_peer_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="card", + name="tag_vote_statuses", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name="card", + name="artist_vote_status", + field=models.CharField( + choices=[ + ("unresolved", "Unresolved"), + ("resolved", "Resolved"), + ("unknown", "Unknown"), + ("contested", "Contested"), + ], + db_index=True, + default="unresolved", + max_length=10, + ), + ), + ] diff --git a/MPCAutofill/cardpicker/models.py b/MPCAutofill/cardpicker/models.py index 9f2ba3f63..7cb0852c0 100755 --- a/MPCAutofill/cardpicker/models.py +++ b/MPCAutofill/cardpicker/models.py @@ -308,6 +308,33 @@ class PrintingTagStatus(models.TextChoices): NO_MATCH = "no_match", gettext_lazy("No Match") +class ArtistVoteStatus(models.TextChoices): + """ + Denormalised cache of `cardpicker.artist_consensus.resolve_artist`'s outcome for a `Card`, + kept in lockstep with `Card.inferred_canonical_artist` by `resolve_and_persist_artist` - same + purpose as `PrintingTagStatus` above. + """ + + UNRESOLVED = "unresolved", gettext_lazy("Unresolved") + RESOLVED = "resolved", gettext_lazy("Resolved") + UNKNOWN = "unknown", gettext_lazy("Unknown") + CONTESTED = "contested", gettext_lazy("Contested") + + +class TagVoteStatus(models.TextChoices): + """ + Per-tag status stored in `Card.tag_vote_statuses` (a JSONField, not a plain model field - + see that field's own comment for why - so this isn't wired up as a `choices=` kwarg + anywhere, just symbolic constants for `cardpicker.tag_consensus` to use instead of raw + strings). Written by `resolve_and_persist_tag_votes`. + """ + + RESOLVED_APPLY = "resolved_apply", gettext_lazy("Resolved (apply)") + RESOLVED_REJECT = "resolved_reject", gettext_lazy("Resolved (reject)") + CONTESTED = "contested", gettext_lazy("Contested") + UNRESOLVED = "unresolved", gettext_lazy("Unresolved") + + class Card(models.Model): card_type = models.CharField(max_length=20, choices=CardTypes.choices, default=CardTypes.CARD) identifier = models.CharField(max_length=200, unique=True) @@ -334,6 +361,25 @@ class Card(models.Model): printing_tag_status = models.CharField( max_length=10, choices=PrintingTagStatus.choices, default=PrintingTagStatus.UNRESOLVED, db_index=True ) + # artist-vote consensus outcome - only ever surfaced in `serialise()` when neither + # `canonical_card`/`canonical_artist` (confirmed indexing match) nor + # `inferred_canonical_card` (a resolved printing-tag vote, which carries its own artist) + # are set - see the fallback chain in `serialise()` below. + inferred_canonical_artist = models.ForeignKey( + to=CanonicalArtist, on_delete=models.SET_NULL, blank=True, null=True, related_name="+" + ) + artist_vote_status = models.CharField( + max_length=10, choices=ArtistVoteStatus.choices, default=ArtistVoteStatus.UNRESOLVED, db_index=True + ) + # Per-tag vote status, written by cardpicker.tag_consensus.resolve_and_persist_tag_votes: + # {tag.name: "resolved_apply" | "resolved_reject" | "contested" | "unresolved"}. An absent + # key means no votes at all for that tag on this card - entries are never written for a + # tag with zero votes. Bookkeeping alongside the existing `tags` array/overlay-merge logic + # above, not a replacement for it. INVARIANT: keys are `Tag.name` values, which must stay + # stable - renaming a Tag orphans its entries here and (per docs/federation-v1.md) breaks + # cross-instance verdict portability, since tags travel by name in that format too. A Tag + # rename is a data migration, not a plain edit. + tag_vote_statuses = models.JSONField(default=dict, blank=True) # a lowercase CanonicalExpansion.code guessed from a lone set-code bracket token in the # source filename (e.g. "[MH3]") - not resolved to a specific printing (no collector # number was present to pair with it), just a ranking hint for get_ranked_printing_candidates @@ -352,6 +398,26 @@ def __str__(self) -> str: ) def serialise(self) -> SerialisedCard: + # Explicit if/elif chain (rather than a nested-ternary fallback) so the rung that + # actually supplied the artist is captured as it's found, not re-derived afterwards by + # checking which other fields are empty - that "all others empty" style of check would + # silently misclassify if this chain ever grows a fifth rung. `canonicalArtistIsFromVoteOnly` + # (used by the frontend's "wrong?" affordance to distinguish a confidently-known artist + # from a vote-derived one) and the debug-only `canonicalArtistSource` field both derive + # directly from `artist_source`, so they can never drift out of sync with this chain. + artist_source: str | None + resolved_artist: CanonicalArtist | None + if self.canonical_artist is not None: + artist_source, resolved_artist = "canonical_artist", self.canonical_artist + elif self.canonical_card is not None: + artist_source, resolved_artist = "canonical_card", self.canonical_card.artist + elif self.inferred_canonical_card is not None: + artist_source, resolved_artist = "inferred_canonical_card", self.inferred_canonical_card.artist + elif self.inferred_canonical_artist is not None: + artist_source, resolved_artist = "inferred_canonical_artist", self.inferred_canonical_artist + else: + artist_source, resolved_artist = None, None + return SerialisedCard( identifier=self.identifier, cardType=CardType(self.card_type), @@ -379,11 +445,9 @@ def serialise(self) -> SerialisedCard: if self.canonical_card else (self.inferred_canonical_card.serialise() if self.inferred_canonical_card else None) ), - canonicalArtist=( - self.canonical_artist.serialise() - if self.canonical_artist - else (self.canonical_card.artist.serialise() if self.canonical_card else None) - ), + canonicalArtist=resolved_artist.serialise() if resolved_artist is not None else None, + canonicalArtistIsFromVoteOnly=artist_source == "inferred_canonical_artist", + canonicalArtistSource=artist_source, ) def to_dict(self) -> dict[str, Any]: @@ -424,13 +488,45 @@ class Meta: ordering = ["-priority"] -class CardPrintingTagSource(models.TextChoices): +class VoteSource(models.TextChoices): + """ + Shared `source` enum for every `AbstractWeightedVote` subclass (`CardPrintingTag`, + `CardArtistVote`, `CardTagVote`) - not printing-tag-specific despite the historical name + this replaced (`CardPrintingTagSource`). The stored string values are unchanged. + """ + USER = "user", gettext_lazy("User") ADMIN = "admin", gettext_lazy("Admin") AI = "ai", gettext_lazy("AI") + FEDERATED = "federated", gettext_lazy("Federated") + + +class AbstractWeightedVote(models.Model): + """ + Shared fields for every weighted-consensus vote model in this app (`CardPrintingTag`, + `CardArtistVote`, `CardTagVote`) - see `cardpicker.vote_consensus.resolve_weighted_consensus` + for how these are reconciled into a single resolved outcome per card. Purely a field + container (no DB table of its own - `abstract = True`), so adding a field here changes + the schema of every subclass's own table simultaneously; a comment here is the only thing + that makes that non-obvious fact visible from any single subclass's own definition. + """ + + # a client-generated identifier (see `frontend/src/common/anonymousId.ts`), not a real Django + # session key - cross-origin frontend/backend means a session cookie never round-trips here. + anonymous_id = models.CharField(max_length=40) + source = models.CharField(max_length=10, choices=VoteSource.choices, default=VoteSource.USER) + confidence = models.FloatField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + # federation-readiness stub (see docs/federation-v1.md) - no import path sets this yet. + peer = models.CharField( + max_length=64, null=True, blank=True, help_text="Federation peer name; set only when source='federated'" + ) + + class Meta: + abstract = True -class CardPrintingTag(models.Model): +class CardPrintingTag(AbstractWeightedVote): """ A vote that a given `Card` (an image in this fork's catalogue) depicts a specific Scryfall printing (`CanonicalCard`), or definitively depicts no known printing @@ -441,12 +537,6 @@ class CardPrintingTag(models.Model): card = models.ForeignKey(to=Card, on_delete=models.CASCADE, related_name="printing_tags") printing = models.ForeignKey(to=CanonicalCard, on_delete=models.CASCADE, null=True, blank=True, related_name="tags") is_no_match = models.BooleanField(default=False) - # a client-generated identifier (see `frontend/src/common/anonymousId.ts`), not a real Django - # session key - cross-origin frontend/backend means a session cookie never round-trips here. - anonymous_id = models.CharField(max_length=40) - source = models.CharField(max_length=10, choices=CardPrintingTagSource.choices, default=CardPrintingTagSource.USER) - confidence = models.FloatField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ @@ -474,6 +564,50 @@ def __str__(self) -> str: return f"[{self.source}] {self.card.name} -> {outcome}" +class CardArtistVote(AbstractWeightedVote): + """ + A vote that a given `Card` was illustrated by a specific `CanonicalArtist`, or + definitively by an unknown/unlisted artist (`is_unknown=True`). Only meaningful once a + card's printing-tag consensus hasn't already resolved a printing - see + `cardpicker.artist_consensus` and the artist fallback chain in `Card.serialise()`, where a + resolved printing's own artist always takes precedence over this vote's outcome. + """ + + card = models.ForeignKey(to=Card, on_delete=models.CASCADE, related_name="artist_votes") + artist = models.ForeignKey( + to=CanonicalArtist, on_delete=models.CASCADE, null=True, blank=True, related_name="votes" + ) + is_unknown = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.CheckConstraint( + check=( + models.Q(artist__isnull=False, is_unknown=False) | models.Q(artist__isnull=True, is_unknown=True) + ), + name="cardartistvote_artist_xor_unknown", + ), + # not the sole enforcement of "one active vote per (card, anonymous_id)" - the + # submit view deletes any existing vote for this (card, anonymous_id) before + # creating the new one (same pattern as CardPrintingTag). This constraint is a + # safety net against a double-submit race, not the primary mechanism. + models.UniqueConstraint( + fields=["card", "artist", "anonymous_id"], + condition=models.Q(is_unknown=False), + name="cardartistvote_unique_artist_vote", + ), + models.UniqueConstraint( + fields=["card", "anonymous_id"], + condition=models.Q(is_unknown=True), + name="cardartistvote_unique_unknown_vote", + ), + ] + + def __str__(self) -> str: + outcome = "UNKNOWN" if self.is_unknown else str(self.artist) + return f"[{self.source}] {self.card.name} -> {outcome}" + + class Tag(models.Model): name = models.CharField(unique=True) # null=True is just for admin panel @@ -506,6 +640,35 @@ def get_tags(cls) -> dict[str, list[str]]: return {tag.name: tag.aliases for tag in Tag.objects.all()} +class VotePolarity(models.IntegerChoices): + APPLY = 1, gettext_lazy("Apply") + NOT_APPLICABLE = -1, gettext_lazy("Not applicable") + + +class CardTagVote(AbstractWeightedVote): + """ + A vote on whether a given descriptor `Tag` applies to a `Card` (`polarity=APPLY`) or not + (`polarity=NOT_APPLICABLE`). Unlike `CardPrintingTag`/`CardArtistVote` (mutually exclusive + outcomes - a card has exactly one real printing/artist), a card can carry independent, + simultaneous votes across many different tags at once, so uniqueness here is scoped to + (card, tag, anonymous_id) rather than just (card, anonymous_id) - changing your mind about + one tag is an update to that one row (`update_or_create` in the submit view), not a + delete-and-recreate of every vote this person has cast on this card. + """ + + card = models.ForeignKey(to=Card, on_delete=models.CASCADE, related_name="tag_votes") + tag = models.ForeignKey(to=Tag, on_delete=models.CASCADE, related_name="votes") + polarity = models.SmallIntegerField(choices=VotePolarity.choices) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["card", "tag", "anonymous_id"], name="cardtagvote_unique_vote"), + ] + + def __str__(self) -> str: + return f"[{self.source}] {self.card.name} -> {self.tag} ({VotePolarity(self.polarity).label})" + + class TagSuggestionStatus(models.TextChoices): PENDING = "pending", "Pending" AUTO_ACCEPTED = "auto_accepted", "Auto-accepted" diff --git a/MPCAutofill/cardpicker/printing_consensus.py b/MPCAutofill/cardpicker/printing_consensus.py index bbb489d05..0c98ee735 100644 --- a/MPCAutofill/cardpicker/printing_consensus.py +++ b/MPCAutofill/cardpicker/printing_consensus.py @@ -1,79 +1,67 @@ -from collections import defaultdict from typing import Literal, TypedDict from django.conf import settings -from django.db.models import Case, Count, IntegerField, Q, When from cardpicker.models import ( CanonicalCard, Card, CardPrintingTag, - CardPrintingTagSource, PrintingTagStatus, + VoteSource, +) +from cardpicker.vote_consensus import ( + _SOURCE_WEIGHTS, + VoteTuple, + contested_queryset, + resolve_weighted_consensus, ) NO_MATCH: Literal["NO_MATCH"] = "NO_MATCH" -_SOURCE_WEIGHTS: dict[str, float] = { - CardPrintingTagSource.USER: 1.0, - CardPrintingTagSource.ADMIN: settings.PRINTING_TAG_ADMIN_WEIGHT, - CardPrintingTagSource.AI: settings.PRINTING_TAG_AI_WEIGHT, -} - - -class _VoteGroup(TypedDict): - weight: float - has_non_ai: bool - printing: CanonicalCard | None - def resolve_printing(card: Card) -> CanonicalCard | Literal["NO_MATCH"] | None: """ Reconciles all `CardPrintingTag` votes cast against `card` into a single resolved outcome: a specific `CanonicalCard` printing, the `NO_MATCH` sentinel (consensus is that no printing matches), or `None` if there isn't yet enough signal to conclude - anything (no votes, a tie, or a genuinely contested set of votes). - - Votes are weighted by their `source` (`PRINTING_TAG_ADMIN_WEIGHT`/`PRINTING_TAG_AI_WEIGHT` - settings; user votes always weigh 1). Votes are grouped by outcome, and the - highest-weighted group wins if, and only if, ALL of the following hold: - - its summed weight is >= `PRINTING_TAG_MIN_VOTES` (this is compared against the - summed weight, not a raw row count — a single admin vote, at the default weight - of 5, already clears the default threshold of 2 on its own, which is what - produces "admin override" behaviour from this one unified formula, with no - special-cased branch for admin votes); - - its share of the total weight across all groups is >= `PRINTING_TAG_MIN_SHARE`; - - it contains at least one non-AI vote (a hard gate, independent of the weight - math above, so that no volume of AI-only votes can ever resolve consensus on - their own). + anything. See `cardpicker.vote_consensus.resolve_weighted_consensus` for the shared + weighting/threshold rules (votes weighted by `source`, `PRINTING_TAG_MIN_VOTES`/ + `MIN_SHARE` gates, non-AI gate) - this is a thin wrapper translating `CardPrintingTag` + rows into `VoteTuple`s and the winning outcome key back into a `CanonicalCard`. """ votes = list(card.printing_tags.all()) if not votes: return None - groups: dict[int | Literal["NO_MATCH"] | None, _VoteGroup] = defaultdict( - lambda: _VoteGroup(weight=0.0, has_non_ai=False, printing=None) - ) + printings_by_id: dict[int, CanonicalCard] = {} + vote_tuples: list[VoteTuple] = [] for vote in votes: - key: int | Literal["NO_MATCH"] | None = NO_MATCH if vote.is_no_match else vote.printing_id - group = groups[key] - group["weight"] += _SOURCE_WEIGHTS[vote.source] - if vote.source != CardPrintingTagSource.AI: - group["has_non_ai"] = True - if not vote.is_no_match: - group["printing"] = vote.printing - - total_weight = sum(group["weight"] for group in groups.values()) - winning_key, winner = max(groups.items(), key=lambda item: item[1]["weight"]) - share = winner["weight"] / total_weight - - if ( - winner["weight"] >= settings.PRINTING_TAG_MIN_VOTES - and share >= settings.PRINTING_TAG_MIN_SHARE - and winner["has_non_ai"] - ): - return NO_MATCH if winning_key == NO_MATCH else winner["printing"] - return None + key: int | Literal["NO_MATCH"] + if vote.is_no_match: + key = NO_MATCH + else: + # guaranteed non-null here by the model's printing_xor_no_match CheckConstraint + assert vote.printing_id is not None + assert vote.printing is not None + key = vote.printing_id + printings_by_id[vote.printing_id] = vote.printing + vote_tuples.append( + VoteTuple( + outcome_key=key, + weight=_SOURCE_WEIGHTS[vote.source], + is_human_backed=vote.source != VoteSource.AI, + ) + ) + + winning_key = resolve_weighted_consensus( + vote_tuples, min_weight=settings.PRINTING_TAG_MIN_VOTES, min_share=settings.PRINTING_TAG_MIN_SHARE + ) + if winning_key is None: + return None + if winning_key == NO_MATCH: + return NO_MATCH + assert isinstance(winning_key, int) + return printings_by_id[winning_key] def resolve_and_persist_printing(card: Card) -> CanonicalCard | Literal["NO_MATCH"] | None: @@ -118,15 +106,13 @@ def get_contested_card_ids() -> list[int]: Materialized to a plain list (rather than returning the lazy QuerySet) since the set of actually-contested cards is always a small fraction of the total - cheap to evaluate eagerly, and sidesteps django-stubs' QuerySet generic entirely for callers. + + Delegates to the shared `vote_consensus.contested_queryset` - this function's name, + signature, and behavior are unchanged; it's the reference point that function's own + docstring calls "behavior-preserving". """ - return list( - CardPrintingTag.objects.values("card_id") - .annotate( - distinct_printings=Count("printing_id", distinct=True), - has_no_match=Count(Case(When(is_no_match=True, then=1), output_field=IntegerField())), - ) - .filter(Q(distinct_printings__gt=1) | (Q(distinct_printings__gte=1) & Q(has_no_match__gt=0))) - .values_list("card_id", flat=True) + return contested_queryset( + CardPrintingTag.objects.all(), group_by="card_id", outcome_field="printing_id", sentinel_field="is_no_match" ) diff --git a/MPCAutofill/cardpicker/schema_types.py b/MPCAutofill/cardpicker/schema_types.py index fea82dae9..53fb6446b 100644 --- a/MPCAutofill/cardpicker/schema_types.py +++ b/MPCAutofill/cardpicker/schema_types.py @@ -13,23 +13,13 @@ EnumT = TypeVar("EnumT", bound=Enum) -def from_list(f: Callable[[Any], T], x: Any) -> List[T]: - assert isinstance(x, list) - return [f(y) for y in x] - - def from_str(x: Any) -> str: assert isinstance(x, str) return x -def from_int(x: Any) -> int: - assert isinstance(x, int) and not isinstance(x, bool) - return x - - -def from_bool(x: Any) -> bool: - assert isinstance(x, bool) +def from_none(x: Any) -> Any: + assert x is None return x @@ -42,13 +32,23 @@ def from_union(fs, x): assert False +def from_list(f: Callable[[Any], T], x: Any) -> List[T]: + assert isinstance(x, list) + return [f(y) for y in x] + + def to_class(c: Type[T], x: Any) -> dict: assert isinstance(x, c) return cast(Any, x).to_dict() -def from_none(x: Any) -> Any: - assert x is None +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +def from_bool(x: Any) -> bool: + assert isinstance(x, bool) return x @@ -76,6 +76,118 @@ class Game(str, Enum): MTG = "MTG" +class ArtistCandidatesRequest(BaseModel): + identifier: str + query: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> "ArtistCandidatesRequest": + assert isinstance(obj, dict) + identifier = from_str(obj.get("identifier")) + query = from_union([from_none, from_str], obj.get("query")) + return ArtistCandidatesRequest(identifier, query) + + def to_dict(self) -> dict: + result: dict = {} + result["identifier"] = from_str(self.identifier) + if self.query is not None: + result["query"] = from_union([from_none, from_str], self.query) + return result + + +class CanonicalArtistClass(BaseModel): + name: str + + @staticmethod + def from_dict(obj: Any) -> "CanonicalArtistClass": + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + return CanonicalArtistClass(name) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + return result + + +class ArtistCandidatesResponse(BaseModel): + results: List[Optional[CanonicalArtistClass]] + + @staticmethod + def from_dict(obj: Any) -> "ArtistCandidatesResponse": + assert isinstance(obj, dict) + results = from_list(lambda x: from_union([from_none, CanonicalArtistClass.from_dict], x), obj.get("results")) + return ArtistCandidatesResponse(results) + + def to_dict(self) -> dict: + result: dict = {} + result["results"] = from_list( + lambda x: from_union([from_none, lambda x: to_class(CanonicalArtistClass, x)], x), self.results + ) + return result + + +class ArtistConsensusRequest(BaseModel): + identifier: str + + @staticmethod + def from_dict(obj: Any) -> "ArtistConsensusRequest": + assert isinstance(obj, dict) + identifier = from_str(obj.get("identifier")) + return ArtistConsensusRequest(identifier) + + def to_dict(self) -> dict: + result: dict = {} + result["identifier"] = from_str(self.identifier) + return result + + +class ArtistVoteTallyEntry(BaseModel): + count: int + isUnknown: bool + artist: Optional[CanonicalArtistClass] = None + + @staticmethod + def from_dict(obj: Any) -> "ArtistVoteTallyEntry": + assert isinstance(obj, dict) + count = from_int(obj.get("count")) + isUnknown = from_bool(obj.get("isUnknown")) + artist = from_union([from_none, CanonicalArtistClass.from_dict], obj.get("artist")) + return ArtistVoteTallyEntry(count, isUnknown, artist) + + def to_dict(self) -> dict: + result: dict = {} + result["count"] = from_int(self.count) + result["isUnknown"] = from_bool(self.isUnknown) + if self.artist is not None: + result["artist"] = from_union([from_none, lambda x: to_class(CanonicalArtistClass, x)], self.artist) + return result + + +class ArtistConsensusResponse(BaseModel): + isUnknown: bool + voteTally: List[ArtistVoteTallyEntry] + resolvedArtist: Optional[CanonicalArtistClass] = None + + @staticmethod + def from_dict(obj: Any) -> "ArtistConsensusResponse": + assert isinstance(obj, dict) + isUnknown = from_bool(obj.get("isUnknown")) + voteTally = from_list(ArtistVoteTallyEntry.from_dict, obj.get("voteTally")) + resolvedArtist = from_union([from_none, CanonicalArtistClass.from_dict], obj.get("resolvedArtist")) + return ArtistConsensusResponse(isUnknown, voteTally, resolvedArtist) + + def to_dict(self) -> dict: + result: dict = {} + result["isUnknown"] = from_bool(self.isUnknown) + result["voteTally"] = from_list(lambda x: to_class(ArtistVoteTallyEntry, x), self.voteTally) + if self.resolvedArtist is not None: + result["resolvedArtist"] = from_union( + [from_none, lambda x: to_class(CanonicalArtistClass, x)], self.resolvedArtist + ) + return result + + class FilterSettings(BaseModel): excludesTags: List[str] """The tags which the cards must *not* have to be included in search results""" @@ -222,21 +334,6 @@ def to_dict(self) -> dict: return result -class CanonicalArtistClass(BaseModel): - name: str - - @staticmethod - def from_dict(obj: Any) -> "CanonicalArtistClass": - assert isinstance(obj, dict) - name = from_str(obj.get("name")) - return CanonicalArtistClass(name) - - def to_dict(self) -> dict: - result: dict = {} - result["name"] = from_str(self.name) - return result - - class CanonicalCardClass(BaseModel): collectorNumber: str expansionCode: str @@ -320,6 +417,16 @@ class Card(BaseModel): sourceVerbose: str tags: List[str] canonicalArtist: Optional[CanonicalArtistClass] = None + canonicalArtistIsFromVoteOnly: Optional[bool] = None + """True only when canonicalArtist was supplied by artist-vote consensus alone, with no + confirmed indexing match or resolved printing backing it - lets the frontend distinguish + a confidently-known artist from a vote-derived one (e.g. for the ArtistVotePicker + 'wrong?' affordance) without needing to know serialise()'s fallback chain itself. + """ + canonicalArtistSource: Optional[str] = None + """Which rung of the artist fallback chain actually supplied canonicalArtist - + debug/introspection field, not load-bearing for any current frontend logic. + """ canonicalCard: Optional[CanonicalCardClass] = None sourceExternalLink: Optional[str] = None sourceType: Optional[SourceType] = None @@ -346,6 +453,8 @@ def from_dict(obj: Any) -> "Card": sourceVerbose = from_str(obj.get("sourceVerbose")) tags = from_list(from_str, obj.get("tags")) canonicalArtist = from_union([from_none, CanonicalArtistClass.from_dict], obj.get("canonicalArtist")) + canonicalArtistIsFromVoteOnly = from_union([from_bool, from_none], obj.get("canonicalArtistIsFromVoteOnly")) + canonicalArtistSource = from_union([from_none, from_str], obj.get("canonicalArtistSource")) canonicalCard = from_union([from_none, CanonicalCardClass.from_dict], obj.get("canonicalCard")) sourceExternalLink = from_union([from_str, from_none], obj.get("sourceExternalLink")) sourceType = from_union([SourceType, from_none], obj.get("sourceType")) @@ -369,6 +478,8 @@ def from_dict(obj: Any) -> "Card": sourceVerbose, tags, canonicalArtist, + canonicalArtistIsFromVoteOnly, + canonicalArtistSource, canonicalCard, sourceExternalLink, sourceType, @@ -398,6 +509,12 @@ def to_dict(self) -> dict: result["canonicalArtist"] = from_union( [from_none, lambda x: to_class(CanonicalArtistClass, x)], self.canonicalArtist ) + if self.canonicalArtistIsFromVoteOnly is not None: + result["canonicalArtistIsFromVoteOnly"] = from_union( + [from_bool, from_none], self.canonicalArtistIsFromVoteOnly + ) + if self.canonicalArtistSource is not None: + result["canonicalArtistSource"] = from_union([from_none, from_str], self.canonicalArtistSource) if self.canonicalCard is not None: result["canonicalCard"] = from_union( [from_none, lambda x: to_class(CanonicalCardClass, x)], self.canonicalCard @@ -1245,6 +1362,31 @@ def to_dict(self) -> dict: return result +class SubmitArtistVoteRequest(BaseModel): + anonymousId: str + identifier: str + isUnknown: bool + artistName: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> "SubmitArtistVoteRequest": + assert isinstance(obj, dict) + anonymousId = from_str(obj.get("anonymousId")) + identifier = from_str(obj.get("identifier")) + isUnknown = from_bool(obj.get("isUnknown")) + artistName = from_union([from_none, from_str], obj.get("artistName")) + return SubmitArtistVoteRequest(anonymousId, identifier, isUnknown, artistName) + + def to_dict(self) -> dict: + result: dict = {} + result["anonymousId"] = from_str(self.anonymousId) + result["identifier"] = from_str(self.identifier) + result["isUnknown"] = from_bool(self.isUnknown) + if self.artistName is not None: + result["artistName"] = from_union([from_none, from_str], self.artistName) + return result + + class SubmitPrintingTagRequest(BaseModel): anonymousId: str identifier: str @@ -1270,6 +1412,100 @@ def to_dict(self) -> dict: return result +class SubmitTagVoteRequest(BaseModel): + anonymousId: str + identifier: str + polarity: int + tagName: str + + @staticmethod + def from_dict(obj: Any) -> "SubmitTagVoteRequest": + assert isinstance(obj, dict) + anonymousId = from_str(obj.get("anonymousId")) + identifier = from_str(obj.get("identifier")) + polarity = from_int(obj.get("polarity")) + tagName = from_str(obj.get("tagName")) + return SubmitTagVoteRequest(anonymousId, identifier, polarity, tagName) + + def to_dict(self) -> dict: + result: dict = {} + result["anonymousId"] = from_str(self.anonymousId) + result["identifier"] = from_str(self.identifier) + result["polarity"] = from_int(self.polarity) + result["tagName"] = from_str(self.tagName) + return result + + +class TagConsensusRequest(BaseModel): + identifier: str + + @staticmethod + def from_dict(obj: Any) -> "TagConsensusRequest": + assert isinstance(obj, dict) + identifier = from_str(obj.get("identifier")) + return TagConsensusRequest(identifier) + + def to_dict(self) -> dict: + result: dict = {} + result["identifier"] = from_str(self.identifier) + return result + + +class TagVoteTallyEntry(BaseModel): + count: int + polarity: int + + @staticmethod + def from_dict(obj: Any) -> "TagVoteTallyEntry": + assert isinstance(obj, dict) + count = from_int(obj.get("count")) + polarity = from_int(obj.get("polarity")) + return TagVoteTallyEntry(count, polarity) + + def to_dict(self) -> dict: + result: dict = {} + result["count"] = from_int(self.count) + result["polarity"] = from_int(self.polarity) + return result + + +class TagConsensusEntry(BaseModel): + tagName: str + tally: List[TagVoteTallyEntry] + resolvedPolarity: Optional[int] = None + + @staticmethod + def from_dict(obj: Any) -> "TagConsensusEntry": + assert isinstance(obj, dict) + tagName = from_str(obj.get("tagName")) + tally = from_list(TagVoteTallyEntry.from_dict, obj.get("tally")) + resolvedPolarity = from_union([from_none, from_int], obj.get("resolvedPolarity")) + return TagConsensusEntry(tagName, tally, resolvedPolarity) + + def to_dict(self) -> dict: + result: dict = {} + result["tagName"] = from_str(self.tagName) + result["tally"] = from_list(lambda x: to_class(TagVoteTallyEntry, x), self.tally) + if self.resolvedPolarity is not None: + result["resolvedPolarity"] = from_union([from_none, from_int], self.resolvedPolarity) + return result + + +class TagConsensusResponse(BaseModel): + tags: List[TagConsensusEntry] + + @staticmethod + def from_dict(obj: Any) -> "TagConsensusResponse": + assert isinstance(obj, dict) + tags = from_list(TagConsensusEntry.from_dict, obj.get("tags")) + return TagConsensusResponse(tags) + + def to_dict(self) -> dict: + result: dict = {} + result["tags"] = from_list(lambda x: to_class(TagConsensusEntry, x), self.tags) + return result + + class ChildElement(BaseModel): children: List["ChildElement"] name: str @@ -1343,6 +1579,78 @@ def to_dict(self) -> dict: return result +class Kind(str, Enum): + artist = "artist" + printing = "printing" + tag = "tag" + + +class VoteQueueRequest(BaseModel): + kind: Kind + page: int + + @staticmethod + def from_dict(obj: Any) -> "VoteQueueRequest": + assert isinstance(obj, dict) + kind = Kind(obj.get("kind")) + page = from_int(obj.get("page")) + return VoteQueueRequest(kind, page) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = to_enum(Kind, self.kind) + result["page"] = from_int(self.page) + return result + + +class VoteQueueItem(BaseModel): + card: Card + tagName: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> "VoteQueueItem": + assert isinstance(obj, dict) + card = Card.from_dict(obj.get("card")) + tagName = from_union([from_none, from_str], obj.get("tagName")) + return VoteQueueItem(card, tagName) + + def to_dict(self) -> dict: + result: dict = {} + result["card"] = to_class(Card, self.card) + if self.tagName is not None: + result["tagName"] = from_union([from_none, from_str], self.tagName) + return result + + +class VoteQueueResponse(BaseModel): + hits: int + items: List[VoteQueueItem] + pages: int + + @staticmethod + def from_dict(obj: Any) -> "VoteQueueResponse": + assert isinstance(obj, dict) + hits = from_int(obj.get("hits")) + items = from_list(VoteQueueItem.from_dict, obj.get("items")) + pages = from_int(obj.get("pages")) + return VoteQueueResponse(hits, items, pages) + + def to_dict(self) -> dict: + result: dict = {} + result["hits"] = from_int(self.hits) + result["items"] = from_list(lambda x: to_class(VoteQueueItem, x), self.items) + result["pages"] = from_int(self.pages) + return result + + +def ArtistVoteTallyEntryfromdict(s: Any) -> ArtistVoteTallyEntry: + return ArtistVoteTallyEntry.from_dict(s) + + +def ArtistVoteTallyEntrytodict(x: ArtistVoteTallyEntry) -> Any: + return to_class(ArtistVoteTallyEntry, x) + + def Campaignfromdict(s: Any) -> Optional[CampaignClass]: return from_union([from_none, CampaignClass.from_dict], s) @@ -1527,6 +1835,30 @@ def Tagtodict(x: Tag) -> Any: return to_class(Tag, x) +def TagConsensusEntryfromdict(s: Any) -> TagConsensusEntry: + return TagConsensusEntry.from_dict(s) + + +def TagConsensusEntrytodict(x: TagConsensusEntry) -> Any: + return to_class(TagConsensusEntry, x) + + +def TagVoteTallyEntryfromdict(s: Any) -> TagVoteTallyEntry: + return TagVoteTallyEntry.from_dict(s) + + +def TagVoteTallyEntrytodict(x: TagVoteTallyEntry) -> Any: + return to_class(TagVoteTallyEntry, x) + + +def VoteQueueItemfromdict(s: Any) -> VoteQueueItem: + return VoteQueueItem.from_dict(s) + + +def VoteQueueItemtodict(x: VoteQueueItem) -> Any: + return to_class(VoteQueueItem, x) + + def VoteTallyEntryfromdict(s: Any) -> VoteTallyEntry: return VoteTallyEntry.from_dict(s) @@ -1535,6 +1867,38 @@ def VoteTallyEntrytodict(x: VoteTallyEntry) -> Any: return to_class(VoteTallyEntry, x) +def ArtistCandidatesRequestfromdict(s: Any) -> ArtistCandidatesRequest: + return ArtistCandidatesRequest.from_dict(s) + + +def ArtistCandidatesRequesttodict(x: ArtistCandidatesRequest) -> Any: + return to_class(ArtistCandidatesRequest, x) + + +def ArtistCandidatesResponsefromdict(s: Any) -> ArtistCandidatesResponse: + return ArtistCandidatesResponse.from_dict(s) + + +def ArtistCandidatesResponsetodict(x: ArtistCandidatesResponse) -> Any: + return to_class(ArtistCandidatesResponse, x) + + +def ArtistConsensusRequestfromdict(s: Any) -> ArtistConsensusRequest: + return ArtistConsensusRequest.from_dict(s) + + +def ArtistConsensusRequesttodict(x: ArtistConsensusRequest) -> Any: + return to_class(ArtistConsensusRequest, x) + + +def ArtistConsensusResponsefromdict(s: Any) -> ArtistConsensusResponse: + return ArtistConsensusResponse.from_dict(s) + + +def ArtistConsensusResponsetodict(x: ArtistConsensusResponse) -> Any: + return to_class(ArtistConsensusResponse, x) + + def CardbacksRequestfromdict(s: Any) -> CardbacksRequest: return CardbacksRequest.from_dict(s) @@ -1767,6 +2131,14 @@ def SourcesResponsetodict(x: SourcesResponse) -> Any: return to_class(SourcesResponse, x) +def SubmitArtistVoteRequestfromdict(s: Any) -> SubmitArtistVoteRequest: + return SubmitArtistVoteRequest.from_dict(s) + + +def SubmitArtistVoteRequesttodict(x: SubmitArtistVoteRequest) -> Any: + return to_class(SubmitArtistVoteRequest, x) + + def SubmitPrintingTagRequestfromdict(s: Any) -> SubmitPrintingTagRequest: return SubmitPrintingTagRequest.from_dict(s) @@ -1775,9 +2147,49 @@ def SubmitPrintingTagRequesttodict(x: SubmitPrintingTagRequest) -> Any: return to_class(SubmitPrintingTagRequest, x) +def SubmitTagVoteRequestfromdict(s: Any) -> SubmitTagVoteRequest: + return SubmitTagVoteRequest.from_dict(s) + + +def SubmitTagVoteRequesttodict(x: SubmitTagVoteRequest) -> Any: + return to_class(SubmitTagVoteRequest, x) + + +def TagConsensusRequestfromdict(s: Any) -> TagConsensusRequest: + return TagConsensusRequest.from_dict(s) + + +def TagConsensusRequesttodict(x: TagConsensusRequest) -> Any: + return to_class(TagConsensusRequest, x) + + +def TagConsensusResponsefromdict(s: Any) -> TagConsensusResponse: + return TagConsensusResponse.from_dict(s) + + +def TagConsensusResponsetodict(x: TagConsensusResponse) -> Any: + return to_class(TagConsensusResponse, x) + + def TagsResponsefromdict(s: Any) -> TagsResponse: return TagsResponse.from_dict(s) def TagsResponsetodict(x: TagsResponse) -> Any: return to_class(TagsResponse, x) + + +def VoteQueueRequestfromdict(s: Any) -> VoteQueueRequest: + return VoteQueueRequest.from_dict(s) + + +def VoteQueueRequesttodict(x: VoteQueueRequest) -> Any: + return to_class(VoteQueueRequest, x) + + +def VoteQueueResponsefromdict(s: Any) -> VoteQueueResponse: + return VoteQueueResponse.from_dict(s) + + +def VoteQueueResponsetodict(x: VoteQueueResponse) -> Any: + return to_class(VoteQueueResponse, x) diff --git a/MPCAutofill/cardpicker/sources/update_database.py b/MPCAutofill/cardpicker/sources/update_database.py index c48e1ffb1..11ae1c1af 100644 --- a/MPCAutofill/cardpicker/sources/update_database.py +++ b/MPCAutofill/cardpicker/sources/update_database.py @@ -10,10 +10,11 @@ from cardpicker.constants import DEFAULT_LANGUAGE, MAX_SIZE_MB from cardpicker.documents import CardSearch -from cardpicker.models import Card, CardTypes, Source +from cardpicker.models import Card, CardTypes, Source, VotePolarity from cardpicker.search.sanitisation import to_searchable from cardpicker.sources.api import Folder, Image from cardpicker.sources.source_types import SourceType, SourceTypeChoices +from cardpicker.tag_consensus import get_resolved_tag_overlay from cardpicker.tags import Tags from cardpicker.utils import TEXT_BOLD, TEXT_END @@ -167,10 +168,29 @@ def bulk_sync_objects(source: Source, cards: list[Card]) -> None: incoming_ids = set(incoming.keys()) existing = {card.identifier: card for card in Card.objects.filter(source=source)} existing_ids = set(existing.keys()) + common_ids = incoming_ids & existing_ids + + # Merge any resolved tag-vote consensus into the freshly re-extracted tags *before* the + # change-detection check below runs, so a scheduled re-scan can never silently revert a + # community-resolved tag correction back to whatever the filename currently says (only + # `common_ids` can have prior votes at all - a vote's `card` FK requires an existing PK, + # so a brand-new card being `bulk_create`d can't yet have any). + tag_overlay = get_resolved_tag_overlay(existing[identifier].pk for identifier in common_ids) + for identifier in common_ids: + overlay = tag_overlay.get(existing[identifier].pk) + if not overlay: + continue + tags = set(incoming[identifier].tags) + for tag_name, polarity in overlay.items(): + if polarity == VotePolarity.APPLY: + tags.add(tag_name) + else: + tags.discard(tag_name) + incoming[identifier].tags = sorted(tags) created = [incoming[identifier] for identifier in incoming_ids - existing_ids] updated: list[Card] = [] - for identifier in incoming_ids & existing_ids: + for identifier in common_ids: if ( # if an update has been recorded on the source's end... (incoming[identifier].date_modified > existing[identifier].date_modified) diff --git a/MPCAutofill/cardpicker/tag_consensus.py b/MPCAutofill/cardpicker/tag_consensus.py new file mode 100644 index 000000000..0f19e30fc --- /dev/null +++ b/MPCAutofill/cardpicker/tag_consensus.py @@ -0,0 +1,236 @@ +from collections import defaultdict, deque +from typing import Iterable, TypedDict + +from django.conf import settings + +from cardpicker.models import ( + Card, + CardTagVote, + Tag, + TagVoteStatus, + VotePolarity, + VoteSource, +) +from cardpicker.vote_consensus import ( + _SOURCE_WEIGHTS, + VoteTuple, + contested_queryset, + resolve_weighted_consensus, +) + + +def resolve_tag(card: Card, tag: Tag) -> int | None: + """ + Reconciles all `CardTagVote` votes cast for (card, tag) into a single resolved polarity + (`VotePolarity.APPLY` or `VotePolarity.NOT_APPLICABLE`), or `None` if unresolved. Built on + the same shared `resolve_weighted_consensus` core as printing/artist consensus - the only + difference is the outcome space is the two `VotePolarity` values rather than a printing or + artist id. + """ + votes = list(card.tag_votes.filter(tag=tag)) + if not votes: + return None + vote_tuples = [ + VoteTuple( + outcome_key=vote.polarity, + weight=_SOURCE_WEIGHTS[vote.source], + is_human_backed=vote.source != VoteSource.AI, + ) + for vote in votes + ] + resolved = resolve_weighted_consensus( + vote_tuples, min_weight=settings.PRINTING_TAG_MIN_VOTES, min_share=settings.PRINTING_TAG_MIN_SHARE + ) + if resolved is None: + return None + assert isinstance(resolved, int) + return resolved + + +def resolve_and_persist_tag_votes(card: Card) -> None: + """ + Resolves consensus for every tag that has at least one vote cast against `card` (tags are + multi-valued per card, unlike printing/artist, so this resolves all of them in one pass + rather than a single outcome), and merges the result directly into `card.tags`: a resolved + APPLY adds the tag name if not already present; a resolved NOT_APPLICABLE removes it if + present. Saves `card.tags` and pushes the change into Elasticsearch immediately - unlike + printing/artist consensus (whose denormalised fields aren't ES-indexed), `tags` *is* an + ES-indexed field (`documents.py`'s `KeywordField`), so a vote-triggered change has to reach + the search index directly rather than waiting for the next scheduled re-scan. + + Also writes `card.tag_vote_statuses` (a JSONField, not ES-indexed, so no re-index needed + for this part alone): for every voted tag, one of RESOLVED_APPLY/RESOLVED_REJECT/CONTESTED/ + UNRESOLVED. CONTESTED vs. UNRESOLVED is distinguished locally from the polarities already + fetched below (no extra query) - contested means both polarities are present with votes; + unresolved means only one side has voted so far, or thresholds simply aren't cleared yet. + """ + from cardpicker.documents import ( + CardSearch, # local import - avoids a top-level ES dependency in this module + ) + + votes_by_tag_id: dict[int, set[int]] = defaultdict(set) + for tag_id, polarity in card.tag_votes.values_list("tag_id", "polarity"): + votes_by_tag_id[tag_id].add(polarity) + if not votes_by_tag_id: + return + + tags_by_id = {tag.pk: tag for tag in Tag.objects.filter(pk__in=votes_by_tag_id.keys())} + current_tags = set(card.tags) + statuses = dict(card.tag_vote_statuses) + tags_changed = False + statuses_changed = False + for tag_id, tag in tags_by_id.items(): + resolved = resolve_tag(card, tag) + if resolved == VotePolarity.APPLY: + new_status = TagVoteStatus.RESOLVED_APPLY + if tag.name not in current_tags: + current_tags.add(tag.name) + tags_changed = True + elif resolved == VotePolarity.NOT_APPLICABLE: + new_status = TagVoteStatus.RESOLVED_REJECT + if tag.name in current_tags: + current_tags.discard(tag.name) + tags_changed = True + else: + new_status = TagVoteStatus.CONTESTED if len(votes_by_tag_id[tag_id]) > 1 else TagVoteStatus.UNRESOLVED + if statuses.get(tag.name) != new_status: + statuses[tag.name] = new_status + statuses_changed = True + + update_fields = [] + if tags_changed: + card.tags = sorted(current_tags) + update_fields.append("tags") + if statuses_changed: + card.tag_vote_statuses = statuses + update_fields.append("tag_vote_statuses") + if update_fields: + card.save(update_fields=update_fields) + if tags_changed: + CardSearch().update([card], action="index") + + +class TagVoteTallyEntry(TypedDict): + polarity: int + count: int + + +def get_tag_vote_tally(card: Card, tag: Tag) -> list[TagVoteTallyEntry]: + """Plain, unweighted per-polarity vote count for (card, tag) - mirrors `get_vote_tally`.""" + tally: dict[int, int] = defaultdict(int) + for vote in card.tag_votes.filter(tag=tag): + tally[vote.polarity] += 1 + return sorted( + (TagVoteTallyEntry(polarity=polarity, count=count) for polarity, count in tally.items()), + key=lambda entry: entry["count"], + reverse=True, + ) + + +def get_resolved_tag_overlay(card_ids: Iterable[int]) -> dict[int, dict[str, int]]: + """ + Batched version of `resolve_tag`, computed for every (card, tag) pair with at least one + vote among `card_ids` in a single query - returns `{card_id: {tag_name: resolved_polarity}}`. + + Used by `cardpicker.sources.update_database.bulk_sync_objects` to merge consensus + corrections into freshly re-scanned `Card.tags` before they're written, so a scheduled + re-scan can never silently revert a resolved tag-vote correction back to whatever the + filename currently says. + """ + rows = CardTagVote.objects.filter(card_id__in=card_ids).values( + "card_id", "tag_id", "tag__name", "source", "polarity" + ) + grouped: dict[tuple[int, int], list[VoteTuple]] = defaultdict(list) + tag_names: dict[int, str] = {} + for row in rows: + tag_names[row["tag_id"]] = row["tag__name"] + grouped[(row["card_id"], row["tag_id"])].append( + VoteTuple( + outcome_key=row["polarity"], + weight=_SOURCE_WEIGHTS[row["source"]], + is_human_backed=row["source"] != VoteSource.AI, + ) + ) + + overlay: dict[int, dict[str, int]] = defaultdict(dict) + for (card_id, tag_id), vote_tuples in grouped.items(): + resolved = resolve_weighted_consensus( + vote_tuples, min_weight=settings.PRINTING_TAG_MIN_VOTES, min_share=settings.PRINTING_TAG_MIN_SHARE + ) + if resolved is not None: + assert isinstance(resolved, int) + overlay[card_id][tag_names[tag_id]] = resolved + return dict(overlay) + + +def get_contested_tag_pairs() -> list[tuple[int, int]]: + """ + (card_id, tag_id) pairs with conflicting tag votes on record - both polarities present. + Mirrors `cardpicker.printing_consensus.get_contested_card_ids`'s shape, generalized via + `vote_consensus.contested_queryset`. Unlike printing/artist, tags have no sentinel outcome + (polarity only ever takes two values), so "contested" here just means both are present. + """ + return contested_queryset(CardTagVote.objects.all(), group_by=["card_id", "tag_id"], outcome_field="polarity") + + +def get_tag_review_queue_pairs() -> list[tuple[int, str]]: + """ + (card_id, tag_name) pairs still needing review for the tag-mode vote queue, backing + `POST 2/voteQueue/?kind=tag`. + + Candidate set is the *persisted* `Card.tag_vote_statuses` state (CONTESTED/UNRESOLVED + entries), not raw `CardTagVote` existence - so a pair that's already resolved + (RESOLVED_APPLY/RESOLVED_REJECT) stays out of the queue even as unrelated new votes trickle + in on the same card afterwards. `tag_vote_statuses` is a small per-card JSON dict with no + native per-key DB filter for "any key has value X", so this materializes candidates eagerly + in Python - same rationale as `vote_consensus.contested_queryset`: the candidate set is + always a small fraction of the catalogue. + + Ordering: primarily by ascending absolute net polarity weight (a 5-vs-4 split outranks a + 6-vs-1 split - closest contests first), then interleaved by card_id (round-robin across + each card's own items, preserving each card's internal relative order) so the same card + isn't served back-to-back for different tags when a different card's item could go in + between instead. + """ + candidates: list[tuple[int, str]] = [ + (card_id, tag_name) + for card_id, statuses in Card.objects.exclude(tag_vote_statuses={}).values_list("id", "tag_vote_statuses") + for tag_name, status in statuses.items() + if status in (TagVoteStatus.CONTESTED, TagVoteStatus.UNRESOLVED) + ] + if not candidates: + return [] + + card_ids = {card_id for card_id, _ in candidates} + rows = CardTagVote.objects.filter(card_id__in=card_ids).values("card_id", "tag__name", "source", "polarity") + net_weight: dict[tuple[int, str], float] = defaultdict(float) + for row in rows: + net_weight[(row["card_id"], row["tag__name"])] += row["polarity"] * _SOURCE_WEIGHTS[row["source"]] + candidates.sort(key=lambda pair: abs(net_weight.get(pair, 0.0))) + + grouped: dict[int, deque[tuple[int, str]]] = defaultdict(deque) + group_order: list[int] = [] + for pair in candidates: + card_id = pair[0] + if card_id not in grouped: + group_order.append(card_id) + grouped[card_id].append(pair) + interleaved: list[tuple[int, str]] = [] + remaining = list(group_order) + while remaining: + for card_id in list(remaining): + interleaved.append(grouped[card_id].popleft()) + if not grouped[card_id]: + remaining.remove(card_id) + return interleaved + + +__all__ = [ + "resolve_tag", + "resolve_and_persist_tag_votes", + "get_tag_vote_tally", + "get_resolved_tag_overlay", + "get_contested_tag_pairs", + "get_tag_review_queue_pairs", + "TagVoteTallyEntry", +] diff --git a/MPCAutofill/cardpicker/tests/__snapshots__/test_views.ambr b/MPCAutofill/cardpicker/tests/__snapshots__/test_views.ambr index ac2ac5559..b4252746a 100644 --- a/MPCAutofill/cardpicker/tests/__snapshots__/test_views.ambr +++ b/MPCAutofill/cardpicker/tests/__snapshots__/test_views.ambr @@ -195,6 +195,8 @@ 'canonicalArtist': dict({ 'name': 'Artist 102', }), + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': 'canonical_card', 'canonicalCard': dict({ 'artist': None, 'canonicalId': '36cd2364-d113-47d1-b2c4-b088d9eb88dd', @@ -229,6 +231,8 @@ }), 'Island': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -254,6 +258,8 @@ }), 'Island (William Bradford)': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -279,6 +285,8 @@ }), 'Pást in Flames': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -309,6 +317,8 @@ 'TOKEN': dict({ 'Goblin': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -418,6 +428,8 @@ 'canonicalArtist': dict({ 'name': 'Artist 107', }), + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': 'canonical_card', 'canonicalCard': dict({ 'artist': None, 'canonicalId': '36cd2364-d113-47d1-b2c4-b088d9eb88dd', @@ -452,6 +464,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -477,6 +491,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -502,6 +518,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -527,6 +545,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -552,6 +572,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -591,6 +613,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -618,6 +642,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -704,6 +730,8 @@ 'canonicalArtist': dict({ 'name': 'Artist 109', }), + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': 'canonical_card', 'canonicalCard': dict({ 'artist': None, 'canonicalId': '36cd2364-d113-47d1-b2c4-b088d9eb88dd', @@ -738,6 +766,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -763,6 +793,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -788,6 +820,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -813,6 +847,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -838,6 +874,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -872,6 +910,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -897,6 +937,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -922,6 +964,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -948,6 +992,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -973,6 +1019,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -1009,6 +1057,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -1036,6 +1086,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -1412,6 +1464,8 @@ 'results': dict({ '17fopRCNRge72U8Hac8pApHZtEalx5kHy': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -1437,6 +1491,8 @@ }), '1V5E0avDmNyEUuFfYwx3nA05aj-1HY0rA': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -1481,6 +1537,8 @@ 'results': dict({ '1V5E0avDmNyEUuFfYwx3nA05aj-1HY0rA': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -1515,6 +1573,8 @@ 'results': dict({ '17fopRCNRge72U8Hac8pApHZtEalx5kHy': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -1540,6 +1600,8 @@ }), '1991MWCur9NdAFi-tQQD5YbQj2oqV_WRy': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -1565,6 +1627,8 @@ }), '1V5E0avDmNyEUuFfYwx3nA05aj-1HY0rA': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -1609,6 +1673,8 @@ 'results': dict({ '1V5E0avDmNyEUuFfYwx3nA05aj-1HY0rA': dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -2311,6 +2377,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -2336,6 +2404,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -2363,6 +2433,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -2401,6 +2473,8 @@ 'canonicalArtist': dict({ 'name': 'Artist 67', }), + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': 'canonical_card', 'canonicalCard': dict({ 'artist': None, 'canonicalId': '36cd2364-d113-47d1-b2c4-b088d9eb88dd', @@ -2435,6 +2509,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2460,6 +2536,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2485,6 +2563,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2510,6 +2590,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2535,6 +2617,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2560,6 +2644,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2585,6 +2671,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2612,6 +2700,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2638,6 +2728,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2675,6 +2767,8 @@ 'canonicalArtist': dict({ 'name': 'Artist 66', }), + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': 'canonical_card', 'canonicalCard': dict({ 'artist': None, 'canonicalId': '36cd2364-d113-47d1-b2c4-b088d9eb88dd', @@ -2709,6 +2803,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2734,6 +2830,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -2759,6 +2857,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2784,6 +2884,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2809,6 +2911,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2834,6 +2938,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2859,6 +2965,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2884,6 +2992,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2911,6 +3021,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2937,6 +3049,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARD', 'dateCreated': '1st January, 2023', @@ -2962,6 +3076,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -2989,6 +3105,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -3027,6 +3145,8 @@ 'canonicalArtist': dict({ 'name': 'Artist 68', }), + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': 'canonical_card', 'canonicalCard': dict({ 'artist': None, 'canonicalId': '36cd2364-d113-47d1-b2c4-b088d9eb88dd', @@ -3071,6 +3191,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -3107,6 +3229,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'TOKEN', 'dateCreated': '1st January, 2023', @@ -3142,6 +3266,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -3169,6 +3295,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -3205,6 +3333,8 @@ 'cards': list([ dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', @@ -3231,6 +3361,8 @@ }), dict({ 'canonicalArtist': None, + 'canonicalArtistIsFromVoteOnly': False, + 'canonicalArtistSource': None, 'canonicalCard': None, 'cardType': 'CARDBACK', 'dateCreated': '1st January, 2023', diff --git a/MPCAutofill/cardpicker/tests/factories.py b/MPCAutofill/cardpicker/tests/factories.py index b9a0a6858..9604f44ad 100644 --- a/MPCAutofill/cardpicker/tests/factories.py +++ b/MPCAutofill/cardpicker/tests/factories.py @@ -117,5 +117,29 @@ class Meta: printing = factory.SubFactory(CanonicalCardFactory) is_no_match = False anonymous_id = factory.Sequence(lambda n: f"anonymous_{n}") - source = models.CardPrintingTagSource.USER + source = models.VoteSource.USER + confidence = None + + +class CardArtistVoteFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.CardArtistVote + + card = factory.SubFactory(CardFactory) + artist = factory.SubFactory(CanonicalArtistFactory) + is_unknown = False + anonymous_id = factory.Sequence(lambda n: f"anonymous_{n}") + source = models.VoteSource.USER + confidence = None + + +class CardTagVoteFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.CardTagVote + + card = factory.SubFactory(CardFactory) + tag = factory.SubFactory(TagFactory) + polarity = models.VotePolarity.APPLY + anonymous_id = factory.Sequence(lambda n: f"anonymous_{n}") + source = models.VoteSource.USER confidence = None diff --git a/MPCAutofill/cardpicker/tests/test_artist_votes.py b/MPCAutofill/cardpicker/tests/test_artist_votes.py new file mode 100644 index 000000000..65c8e52bf --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_artist_votes.py @@ -0,0 +1,400 @@ +import pytest + +from django.core.cache import cache +from django.urls import reverse + +from cardpicker import views +from cardpicker.artist_consensus import ( + UNKNOWN, + get_artist_vote_tally, + get_contested_artist_card_ids, + resolve_and_persist_artist, + resolve_artist, +) +from cardpicker.models import ArtistVoteStatus, CardArtistVote, VoteSource +from cardpicker.tests.factories import ( + CanonicalArtistFactory, + CanonicalCardFactory, + CanonicalExpansionFactory, + CardArtistVoteFactory, + CardFactory, + SourceFactory, +) + +# see test_printing_consensus.py for why this capture-and-restore fixture exists +_SHARED_FACTORIES = [ + CardFactory, + SourceFactory, + CanonicalArtistFactory, + CanonicalExpansionFactory, + CanonicalCardFactory, +] + + +@pytest.fixture(autouse=True) +def _preserve_shared_factory_sequences(): + before = {f: f._meta.next_sequence() for f in _SHARED_FACTORIES} + for f, n in before.items(): + f.reset_sequence(n, force=True) + yield + for f, n in before.items(): + f.reset_sequence(n, force=True) + + +@pytest.fixture(autouse=True) +def _clear_rate_limit_cache(): + cache.clear() + yield + cache.clear() + + +class TestResolveArtist: + def test_no_votes_returns_none(self, db): + card = CardFactory() + assert resolve_artist(card) is None + + def test_consensus(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist, source=VoteSource.USER) + CardArtistVoteFactory(card=card, artist=artist, source=VoteSource.USER) + assert resolve_artist(card) == artist + + def test_unknown_wins_consensus(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=None, is_unknown=True, source=VoteSource.USER) + CardArtistVoteFactory(card=card, artist=None, is_unknown=True, source=VoteSource.USER) + CardArtistVoteFactory(card=card, artist=artist, source=VoteSource.USER) + assert resolve_artist(card) == UNKNOWN + + def test_admin_override(self, db): + card = CardFactory() + artist_a = CanonicalArtistFactory() + artist_b = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist_a, source=VoteSource.ADMIN) + CardArtistVoteFactory(card=card, artist=artist_b, source=VoteSource.USER) + CardArtistVoteFactory(card=card, artist=artist_b, source=VoteSource.USER) + assert resolve_artist(card) == artist_a + + def test_ai_only_insufficient(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + for _ in range(4): + CardArtistVoteFactory(card=card, artist=artist, source=VoteSource.AI) + assert resolve_artist(card) is None + + +class TestResolveAndPersistArtist: + def test_persists_resolved_artist(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist, source=VoteSource.ADMIN) + + result = resolve_and_persist_artist(card) + + assert result == artist + card.refresh_from_db() + assert card.inferred_canonical_artist == artist + assert card.artist_vote_status == ArtistVoteStatus.RESOLVED + + def test_persists_unknown(self, db): + card = CardFactory() + CardArtistVoteFactory(card=card, artist=None, is_unknown=True, source=VoteSource.ADMIN) + + result = resolve_and_persist_artist(card) + + assert result == UNKNOWN + card.refresh_from_db() + assert card.inferred_canonical_artist is None + assert card.artist_vote_status == ArtistVoteStatus.UNKNOWN + + def test_persists_unresolved(self, db): + card = CardFactory() + CardArtistVoteFactory(card=card, source=VoteSource.USER) + + result = resolve_and_persist_artist(card) + + assert result is None + card.refresh_from_db() + assert card.inferred_canonical_artist is None + assert card.artist_vote_status == ArtistVoteStatus.UNRESOLVED + + def test_persists_contested_when_multiple_outcomes_have_votes(self, db): + card = CardFactory() + artist_a = CanonicalArtistFactory() + artist_b = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist_a, source=VoteSource.USER) + CardArtistVoteFactory(card=card, artist=artist_b, source=VoteSource.USER) + + result = resolve_and_persist_artist(card) + + assert result is None + card.refresh_from_db() + assert card.artist_vote_status == ArtistVoteStatus.CONTESTED + + def test_persists_unresolved_not_contested_for_a_single_outcome_below_threshold(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist, source=VoteSource.USER) + + result = resolve_and_persist_artist(card) + + assert result is None + card.refresh_from_db() + assert card.artist_vote_status == ArtistVoteStatus.UNRESOLVED + + def test_does_not_consult_printing_tag_status(self, db): + # resolve_and_persist_artist is deliberately decoupled from printing_tag_status - the + # precedence rule (a resolved printing's artist wins) lives entirely in + # Card.serialise()'s fallback chain, not here. + from cardpicker.models import PrintingTagStatus + + card = CardFactory(printing_tag_status=PrintingTagStatus.RESOLVED) + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist, source=VoteSource.ADMIN) + + result = resolve_and_persist_artist(card) + + assert result == artist + card.refresh_from_db() + assert card.inferred_canonical_artist == artist + + +class TestGetArtistVoteTally: + def test_tally_groups_by_outcome(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist) + CardArtistVoteFactory(card=card, artist=artist) + CardArtistVoteFactory(card=card, artist=None, is_unknown=True) + + tally = get_artist_vote_tally(card) + + assert {(entry["count"], entry["is_unknown"]) for entry in tally} == {(2, False), (1, True)} + + +class TestGetContestedArtistCardIds: + def test_multiple_distinct_artists_is_contested(self, db): + card = CardFactory() + artist_a = CanonicalArtistFactory() + artist_b = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist_a) + CardArtistVoteFactory(card=card, artist=artist_b) + + assert card.pk in get_contested_artist_card_ids() + + def test_an_artist_vote_alongside_an_unknown_vote_is_contested(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist) + CardArtistVoteFactory(card=card, artist=None, is_unknown=True) + + assert card.pk in get_contested_artist_card_ids() + + def test_agreeing_votes_are_not_contested(self, db): + card = CardFactory() + artist = CanonicalArtistFactory() + CardArtistVoteFactory(card=card, artist=artist) + CardArtistVoteFactory(card=card, artist=artist) + + assert card.pk not in get_contested_artist_card_ids() + + +class TestSerialisePrecedenceChain: + def test_falls_back_to_inferred_canonical_artist(self, db): + card = CardFactory() + artist = CanonicalArtistFactory(name="Vote Artist") + card.inferred_canonical_artist = artist + card.save(update_fields=["inferred_canonical_artist"]) + + assert card.serialise().canonicalArtist.name == "Vote Artist" + + def test_resolved_printing_artist_beats_inferred_canonical_artist(self, db): + printing = CanonicalCardFactory() + card = CardFactory(inferred_canonical_card=printing) + vote_artist = CanonicalArtistFactory(name="Vote Artist") + card.inferred_canonical_artist = vote_artist + card.save(update_fields=["inferred_canonical_artist"]) + + assert card.serialise().canonicalArtist.name == printing.artist.name + assert card.serialise().canonicalArtist.name != "Vote Artist" + + def test_confirmed_canonical_card_artist_beats_everything_inferred(self, db): + confirmed = CanonicalCardFactory() + inferred = CanonicalCardFactory() + card = CardFactory(canonical_card=confirmed, inferred_canonical_card=inferred) + + assert card.serialise().canonicalArtist.name == confirmed.artist.name + + +class TestPostArtistCandidates: + def test_unknown_card_identifier_is_a_bad_request(self, client, django_settings): + response = client.post( + reverse(views.post_artist_candidates), + {"identifier": "does-not-exist"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_defaults_to_deduped_artists_of_ranked_printing_candidates(self, client, django_settings): + card = CardFactory(name="Brainstorm") + artist = CanonicalArtistFactory() + CanonicalCardFactory(name="Brainstorm", artist=artist) + CanonicalCardFactory(name="Brainstorm", artist=artist) # same artist, shouldn't duplicate + + response = client.post( + reverse(views.post_artist_candidates), + {"identifier": card.identifier}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert [result["name"] for result in response.json()["results"]] == [artist.name] + + def test_query_switches_to_typeahead_search(self, client, django_settings): + card = CardFactory() + matching = CanonicalArtistFactory(name="John Avon") + CanonicalArtistFactory(name="Someone Else") + + response = client.post( + reverse(views.post_artist_candidates), + {"identifier": card.identifier, "query": "Avon"}, + content_type="application/json", + ) + + result_names = {result["name"] for result in response.json()["results"]} + assert result_names == {matching.name} + + +class TestPostArtistConsensus: + def test_unknown_card_identifier_is_a_bad_request(self, client, django_settings): + response = client.post( + reverse(views.post_artist_consensus), + {"identifier": "does-not-exist"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_no_votes_yet(self, client, django_settings): + card = CardFactory() + response = client.post( + reverse(views.post_artist_consensus), + {"identifier": card.identifier}, + content_type="application/json", + ) + body = response.json() + assert body["resolvedArtist"] is None + assert body["isUnknown"] is False + assert body["voteTally"] == [] + + +class TestPostSubmitArtistVote: + def test_unknown_card_identifier_is_a_bad_request(self, client, django_settings): + response = client.post( + reverse(views.post_submit_artist_vote), + {"identifier": "does-not-exist", "isUnknown": True, "anonymousId": "anon-1"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_missing_artist_name_without_unknown_is_a_bad_request(self, client, django_settings): + card = CardFactory() + response = client.post( + reverse(views.post_submit_artist_vote), + {"identifier": card.identifier, "isUnknown": False, "anonymousId": "anon-1"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_unknown_artist_name_is_a_bad_request(self, client, django_settings): + card = CardFactory() + response = client.post( + reverse(views.post_submit_artist_vote), + { + "identifier": card.identifier, + "artistName": "Nobody By This Name", + "isUnknown": False, + "anonymousId": "anon-1", + }, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_creates_a_vote_and_persists_consensus(self, client, django_settings, settings): + settings.PRINTING_TAG_MIN_VOTES = 1 + settings.PRINTING_TAG_MIN_SHARE = 0.5 + card = CardFactory() + artist = CanonicalArtistFactory() + + response = client.post( + reverse(views.post_submit_artist_vote), + { + "identifier": card.identifier, + "artistName": artist.name, + "isUnknown": False, + "anonymousId": "anon-1", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["resolvedArtist"]["name"] == artist.name + card.refresh_from_db() + assert card.inferred_canonical_artist_id == artist.id + assert card.artist_vote_status == ArtistVoteStatus.RESOLVED + assert CardArtistVote.objects.filter(card=card, anonymous_id="anon-1").count() == 1 + + def test_resubmitting_replaces_the_previous_vote_from_the_same_anonymous_id(self, client, django_settings): + card = CardFactory() + artist_a = CanonicalArtistFactory() + artist_b = CanonicalArtistFactory() + + for artist in (artist_a, artist_b): + client.post( + reverse(views.post_submit_artist_vote), + { + "identifier": card.identifier, + "artistName": artist.name, + "isUnknown": False, + "anonymousId": "anon-1", + }, + content_type="application/json", + ) + + votes = CardArtistVote.objects.filter(card=card, anonymous_id="anon-1") + assert votes.count() == 1 + assert votes.get().artist_id == artist_b.id + + def test_unknown_vote(self, client, django_settings, settings): + settings.PRINTING_TAG_MIN_VOTES = 1 + settings.PRINTING_TAG_MIN_SHARE = 0.5 + card = CardFactory() + + response = client.post( + reverse(views.post_submit_artist_vote), + {"identifier": card.identifier, "isUnknown": True, "anonymousId": "anon-1"}, + content_type="application/json", + ) + + assert response.json()["isUnknown"] is True + card.refresh_from_db() + assert card.inferred_canonical_artist is None + assert card.artist_vote_status == ArtistVoteStatus.UNKNOWN + + def test_rate_limited_after_exceeding_the_configured_rate(self, client, django_settings, settings): + settings.PRINTING_TAG_SUBMISSION_RATE = "1/m" + card = CardFactory() + artist = CanonicalArtistFactory() + body = { + "identifier": card.identifier, + "artistName": artist.name, + "isUnknown": False, + "anonymousId": "anon-rate-limited", + } + + first = client.post(reverse(views.post_submit_artist_vote), body, content_type="application/json") + second = client.post(reverse(views.post_submit_artist_vote), body, content_type="application/json") + + assert first.status_code == 200 + assert second.status_code == 429 diff --git a/MPCAutofill/cardpicker/tests/test_printing_consensus.py b/MPCAutofill/cardpicker/tests/test_printing_consensus.py index 001fb60bf..937e8815a 100644 --- a/MPCAutofill/cardpicker/tests/test_printing_consensus.py +++ b/MPCAutofill/cardpicker/tests/test_printing_consensus.py @@ -1,6 +1,6 @@ import pytest -from cardpicker.models import CardPrintingTagSource +from cardpicker.models import VoteSource from cardpicker.printing_consensus import NO_MATCH, resolve_printing from cardpicker.tests.factories import ( CanonicalArtistFactory, @@ -43,8 +43,8 @@ def test_consensus(self, db): # two user votes agreeing on the same printing clears both thresholds outright card = CardFactory() printing = CanonicalCardFactory() - CardPrintingTagFactory(card=card, printing=printing, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing, source=CardPrintingTagSource.USER) + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER) assert resolve_printing(card) == printing def test_tie_returns_none(self, db): @@ -53,10 +53,10 @@ def test_tie_returns_none(self, db): card = CardFactory() printing_a = CanonicalCardFactory() printing_b = CanonicalCardFactory() - CardPrintingTagFactory(card=card, printing=printing_a, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing_a, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing_b, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing_b, source=CardPrintingTagSource.USER) + CardPrintingTagFactory(card=card, printing=printing_a, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing_a, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing_b, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing_b, source=VoteSource.USER) assert resolve_printing(card) is None def test_contested_returns_none(self, db): @@ -66,19 +66,19 @@ def test_contested_returns_none(self, db): printing_a = CanonicalCardFactory() printing_b = CanonicalCardFactory() printing_c = CanonicalCardFactory() - CardPrintingTagFactory(card=card, printing=printing_a, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing_a, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing_b, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing_c, source=CardPrintingTagSource.USER) + CardPrintingTagFactory(card=card, printing=printing_a, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing_a, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing_b, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing_c, source=VoteSource.USER) assert resolve_printing(card) is None def test_no_match_wins_consensus(self, db): # two no-match votes outweigh a single vote for a specific printing card = CardFactory() printing = CanonicalCardFactory() - CardPrintingTagFactory(card=card, printing=None, is_no_match=True, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=None, is_no_match=True, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing, source=CardPrintingTagSource.USER) + CardPrintingTagFactory(card=card, printing=None, is_no_match=True, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=None, is_no_match=True, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER) assert resolve_printing(card) == NO_MATCH def test_admin_override(self, db): @@ -88,9 +88,9 @@ def test_admin_override(self, db): card = CardFactory() printing_a = CanonicalCardFactory() printing_b = CanonicalCardFactory() - CardPrintingTagFactory(card=card, printing=printing_a, source=CardPrintingTagSource.ADMIN) - CardPrintingTagFactory(card=card, printing=printing_b, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing_b, source=CardPrintingTagSource.USER) + CardPrintingTagFactory(card=card, printing=printing_a, source=VoteSource.ADMIN) + CardPrintingTagFactory(card=card, printing=printing_b, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing_b, source=VoteSource.USER) assert resolve_printing(card) == printing_a def test_ai_only_insufficient(self, db): @@ -99,5 +99,5 @@ def test_ai_only_insufficient(self, db): card = CardFactory() printing = CanonicalCardFactory() for _ in range(4): - CardPrintingTagFactory(card=card, printing=printing, source=CardPrintingTagSource.AI) + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.AI) assert resolve_printing(card) is None diff --git a/MPCAutofill/cardpicker/tests/test_printing_tags_views.py b/MPCAutofill/cardpicker/tests/test_printing_tags_views.py index d64f458f2..690e86888 100644 --- a/MPCAutofill/cardpicker/tests/test_printing_tags_views.py +++ b/MPCAutofill/cardpicker/tests/test_printing_tags_views.py @@ -4,7 +4,7 @@ from django.urls import reverse from cardpicker import views -from cardpicker.models import CardPrintingTag, CardPrintingTagSource, PrintingTagStatus +from cardpicker.models import CardPrintingTag, PrintingTagStatus, VoteSource from cardpicker.tests.factories import ( CanonicalArtistFactory, CanonicalCardFactory, @@ -171,8 +171,8 @@ def test_no_votes_yet(self, client, django_settings): def test_resolved_consensus_and_tally(self, client, django_settings): card = CardFactory() printing = CanonicalCardFactory() - CardPrintingTagFactory(card=card, printing=printing, source=CardPrintingTagSource.USER) - CardPrintingTagFactory(card=card, printing=printing, source=CardPrintingTagSource.USER) + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER) response = client.post( reverse(views.post_printing_consensus), diff --git a/MPCAutofill/cardpicker/tests/test_sources.py b/MPCAutofill/cardpicker/tests/test_sources.py index 21de6b14a..aedb3f674 100644 --- a/MPCAutofill/cardpicker/tests/test_sources.py +++ b/MPCAutofill/cardpicker/tests/test_sources.py @@ -7,7 +7,7 @@ from django.utils.timezone import make_aware, make_naive from cardpicker.documents import CardSearch -from cardpicker.models import CanonicalArtist, CanonicalCard, Card +from cardpicker.models import CanonicalArtist, CanonicalCard, Card, VotePolarity from cardpicker.sources.api import Folder, Image from cardpicker.sources.update_database import bulk_sync_objects, update_database from cardpicker.tags import Tags @@ -506,6 +506,50 @@ def test_bulk_sync_objects_persists_expansion_hint_on_update(self, django_settin assert Card.objects.get(identifier="existing").expansion_hint == "mh3" + @freezegun.freeze_time(DEFAULT_DATE) + def test_bulk_sync_objects_does_not_revert_a_resolved_tag_vote_on_reindex(self, django_settings, elasticsearch): + """ + Regression test for the tag-vote reindex-durability hazard identified when designing + artist/tag voting: `bulk_sync_objects` computes each incoming card's `tags` purely from + fresh filename extraction and writes it straight to Postgres + Elasticsearch. Without a + merge step, a scheduled re-scan would silently revert a consensus-resolved tag-vote + correction back to whatever the filename currently says. A resolved `CardTagVote` for a + tag the filename *doesn't* mention must survive a re-scan whose incoming tags are empty. + """ + source = factories.SourceFactory() + card = factories.CardFactory( + identifier="existing", + searchq="mountain", + date_created=make_aware(DEFAULT_DATE), + date_modified=make_aware(DEFAULT_DATE), + source=source, + tags=[], + ) + tag = factories.TagFactory(name="Borderless") + factories.CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source="admin") + management.call_command("search_index", "--rebuild", "-f") + + # a re-scan whose freshly-extracted tags are empty (the filename mentions nothing) - + # date_modified is bumped so the pre-existing change-detection condition alone would + # otherwise recognise this as an update and blindly overwrite `tags` with `[]`. + bulk_sync_objects( + source=source, + cards=[ + Card( + identifier="existing", + searchq="mountain", + date_created=make_aware(DEFAULT_DATE), + date_modified=make_aware(DEFAULT_DATE) + dt.timedelta(days=1), + source=source, + tags=[], + size=0, + image_hash=0, + ) + ], + ) + + assert Card.objects.get(identifier="existing").tags == ["Borderless"] + @pytest.mark.parametrize( "canonical_cards, new_card, expected_expansion, expected_collector_number", [ diff --git a/MPCAutofill/cardpicker/tests/test_tag_votes.py b/MPCAutofill/cardpicker/tests/test_tag_votes.py new file mode 100644 index 000000000..f2e09c5a8 --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_tag_votes.py @@ -0,0 +1,429 @@ +import pytest + +from django.core.cache import cache +from django.urls import reverse + +from cardpicker import views +from cardpicker.models import CardTagVote, TagVoteStatus, VotePolarity, VoteSource +from cardpicker.tag_consensus import ( + get_contested_tag_pairs, + get_resolved_tag_overlay, + get_tag_review_queue_pairs, + get_tag_vote_tally, + resolve_and_persist_tag_votes, + resolve_tag, +) +from cardpicker.tests.factories import ( + CanonicalArtistFactory, + CanonicalCardFactory, + CanonicalExpansionFactory, + CardFactory, + CardTagVoteFactory, + SourceFactory, + TagFactory, +) + +# see test_printing_consensus.py for why this capture-and-restore fixture exists +_SHARED_FACTORIES = [ + CardFactory, + SourceFactory, + CanonicalArtistFactory, + CanonicalExpansionFactory, + CanonicalCardFactory, +] + + +@pytest.fixture(autouse=True) +def _preserve_shared_factory_sequences(): + before = {f: f._meta.next_sequence() for f in _SHARED_FACTORIES} + for f, n in before.items(): + f.reset_sequence(n, force=True) + yield + for f, n in before.items(): + f.reset_sequence(n, force=True) + + +@pytest.fixture(autouse=True) +def _clear_rate_limit_cache(): + cache.clear() + yield + cache.clear() + + +class TestResolveTag: + def test_no_votes_returns_none(self, db): + card = CardFactory() + tag = TagFactory() + assert resolve_tag(card, tag) is None + + def test_apply_consensus(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + assert resolve_tag(card, tag) == VotePolarity.APPLY + + def test_not_applicable_consensus(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.ADMIN) + assert resolve_tag(card, tag) == VotePolarity.NOT_APPLICABLE + + def test_votes_on_a_different_tag_are_not_counted(self, db): + card = CardFactory() + tag_a = TagFactory() + tag_b = TagFactory() + CardTagVoteFactory(card=card, tag=tag_a, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + assert resolve_tag(card, tag_b) is None + + +class TestResolveAndPersistTagVotes: + def test_applies_a_resolved_apply_vote(self, db): + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tags == ["Borderless"] + + def test_removes_a_resolved_not_applicable_vote(self, db): + card = CardFactory(tags=["Borderless"]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.ADMIN) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tags == [] + + def test_unresolved_tag_does_not_change_tags(self, db): + card = CardFactory(tags=["Existing"]) + tag = TagFactory(name="Contested") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tags == ["Existing"] + + def test_multiple_tags_resolve_independently_on_the_same_card(self, db): + card = CardFactory(tags=[]) + apply_tag = TagFactory(name="Apply Me") + reject_tag = TagFactory(name="Reject Me") + CardTagVoteFactory(card=card, tag=apply_tag, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + CardTagVoteFactory(card=card, tag=reject_tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.ADMIN) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tags == ["Apply Me"] + + def test_persists_resolved_apply_status(self, db): + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tag_vote_statuses == {"Borderless": TagVoteStatus.RESOLVED_APPLY} + + def test_persists_resolved_reject_status(self, db): + card = CardFactory(tags=["Borderless"]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.ADMIN) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tag_vote_statuses == {"Borderless": TagVoteStatus.RESOLVED_REJECT} + + def test_persists_contested_when_both_polarities_present(self, db): + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.USER) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tag_vote_statuses == {"Borderless": TagVoteStatus.CONTESTED} + + def test_persists_unresolved_for_a_single_vote_below_threshold(self, db): + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tag_vote_statuses == {"Borderless": TagVoteStatus.UNRESOLVED} + + def test_a_resolved_tag_status_survives_unrelated_new_votes_on_a_different_tag(self, db): + # regression guard for the kind=tag queue's "persisted state, not raw vote existence" + # requirement - once a tag resolves, later votes on a *different* tag on the same card + # must not disturb its already-persisted status + card = CardFactory(tags=[]) + resolved_tag = TagFactory(name="Borderless") + other_tag = TagFactory(name="Extended") + CardTagVoteFactory(card=card, tag=resolved_tag, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + resolve_and_persist_tag_votes(card) + + CardTagVoteFactory(card=card, tag=other_tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + resolve_and_persist_tag_votes(card) + + card.refresh_from_db() + assert card.tag_vote_statuses["Borderless"] == TagVoteStatus.RESOLVED_APPLY + + +class TestGetTagVoteTally: + def test_tally_groups_by_polarity(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE) + + tally = get_tag_vote_tally(card, tag) + + assert {(entry["polarity"], entry["count"]) for entry in tally} == {(1, 2), (-1, 1)} + + +class TestGetResolvedTagOverlay: + def test_batches_across_multiple_cards_and_tags(self, db): + card_a = CardFactory() + card_b = CardFactory() + tag_a = TagFactory(name="Tag A") + tag_b = TagFactory(name="Tag B") + CardTagVoteFactory(card=card_a, tag=tag_a, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + CardTagVoteFactory(card=card_b, tag=tag_b, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.ADMIN) + + overlay = get_resolved_tag_overlay([card_a.pk, card_b.pk]) + + assert overlay == {card_a.pk: {"Tag A": 1}, card_b.pk: {"Tag B": -1}} + + def test_unresolved_pairs_are_omitted(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + + overlay = get_resolved_tag_overlay([card.pk]) + + assert overlay == {} + + +class TestGetContestedTagPairs: + def test_both_polarities_present_is_contested(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE) + + assert (card.pk, tag.pk) in get_contested_tag_pairs() + + def test_agreeing_votes_are_not_contested(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY) + + assert (card.pk, tag.pk) not in get_contested_tag_pairs() + + def test_contested_pairs_are_scoped_to_the_specific_tag(self, db): + card = CardFactory() + tag_a = TagFactory() + tag_b = TagFactory() + CardTagVoteFactory(card=card, tag=tag_a, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card, tag=tag_a, polarity=VotePolarity.NOT_APPLICABLE) + CardTagVoteFactory(card=card, tag=tag_b, polarity=VotePolarity.APPLY) + + pairs = get_contested_tag_pairs() + + assert (card.pk, tag_a.pk) in pairs + assert (card.pk, tag_b.pk) not in pairs + + +class TestGetTagReviewQueuePairs: + def test_resolved_pairs_are_excluded(self, db, settings): + settings.PRINTING_TAG_MIN_VOTES = 1 + settings.PRINTING_TAG_MIN_SHARE = 0.5 + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + resolve_and_persist_tag_votes(card) + + assert get_tag_review_queue_pairs() == [] + + def test_contested_before_lopsided(self, db): + contested_card = CardFactory(tags=[]) + contested_tag = TagFactory(name="Contested Tag") + CardTagVoteFactory(card=contested_card, tag=contested_tag, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=contested_card, tag=contested_tag, polarity=VotePolarity.NOT_APPLICABLE) + resolve_and_persist_tag_votes(contested_card) + + lopsided_card = CardFactory(tags=[]) + lopsided_tag = TagFactory(name="Lopsided Tag") + CardTagVoteFactory(card=lopsided_card, tag=lopsided_tag, polarity=VotePolarity.APPLY) + resolve_and_persist_tag_votes(lopsided_card) + + pairs = get_tag_review_queue_pairs() + + assert pairs[0] == (contested_card.pk, "Contested Tag") + + def test_same_card_is_not_served_back_to_back_when_a_different_card_is_available(self, db): + # two cards, each with two tags tied at net weight 0 - symmetric group sizes, so + # round-robin interleaving can (and must) keep every card's items apart for the whole + # sequence, unlike an asymmetric setup where the smaller group exhausting first would + # force the larger group's leftover items to end up adjacent regardless of interleaving + card_a = CardFactory(tags=[]) + tag_1 = TagFactory(name="Tag 1") + tag_2 = TagFactory(name="Tag 2") + CardTagVoteFactory(card=card_a, tag=tag_1, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card_a, tag=tag_1, polarity=VotePolarity.NOT_APPLICABLE) + resolve_and_persist_tag_votes(card_a) + CardTagVoteFactory(card=card_a, tag=tag_2, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card_a, tag=tag_2, polarity=VotePolarity.NOT_APPLICABLE) + resolve_and_persist_tag_votes(card_a) + + card_b = CardFactory(tags=[]) + tag_3 = TagFactory(name="Tag 3") + tag_4 = TagFactory(name="Tag 4") + CardTagVoteFactory(card=card_b, tag=tag_3, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card_b, tag=tag_3, polarity=VotePolarity.NOT_APPLICABLE) + resolve_and_persist_tag_votes(card_b) + CardTagVoteFactory(card=card_b, tag=tag_4, polarity=VotePolarity.APPLY) + CardTagVoteFactory(card=card_b, tag=tag_4, polarity=VotePolarity.NOT_APPLICABLE) + resolve_and_persist_tag_votes(card_b) + + pairs = get_tag_review_queue_pairs() + + card_ids_in_order = [card_id for card_id, _ in pairs] + # neither card's two items are adjacent, since the other card's item is always + # available to interleave with at every step + for card in (card_a, card_b): + positions = [i for i, card_id in enumerate(card_ids_in_order) if card_id == card.pk] + assert positions[1] - positions[0] > 1 + + +class TestPostTagConsensus: + def test_unknown_card_identifier_is_a_bad_request(self, client, django_settings): + response = client.post( + reverse(views.post_tag_consensus), + {"identifier": "does-not-exist"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_returns_an_entry_for_every_seeded_tag(self, client, django_settings): + card = CardFactory() + TagFactory(name="Tag A") + TagFactory(name="Tag B") + + response = client.post( + reverse(views.post_tag_consensus), + {"identifier": card.identifier}, + content_type="application/json", + ) + + body = response.json() + assert {entry["tagName"] for entry in body["tags"]} == {"Tag A", "Tag B"} + assert all(entry["resolvedPolarity"] is None for entry in body["tags"]) + + +class TestPostSubmitTagVote: + def test_unknown_card_identifier_is_a_bad_request(self, client, django_settings): + response = client.post( + reverse(views.post_submit_tag_vote), + {"identifier": "does-not-exist", "tagName": "x", "polarity": 1, "anonymousId": "anon-1"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_unknown_tag_name_is_a_bad_request(self, client, django_settings): + card = CardFactory() + response = client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": "does-not-exist", "polarity": 1, "anonymousId": "anon-1"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_invalid_polarity_is_a_bad_request(self, client, django_settings): + card = CardFactory() + tag = TagFactory() + response = client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": 99, "anonymousId": "anon-1"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_creates_a_vote_and_persists_consensus(self, client, django_settings, settings): + settings.PRINTING_TAG_MIN_VOTES = 1 + settings.PRINTING_TAG_MIN_SHARE = 0.5 + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + + response = client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": 1, "anonymousId": "anon-1"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["resolvedPolarity"] == 1 + card.refresh_from_db() + assert card.tags == ["Borderless"] + assert CardTagVote.objects.filter(card=card, tag=tag, anonymous_id="anon-1").count() == 1 + + def test_changing_your_mind_updates_the_same_row_rather_than_adding_another(self, client, django_settings): + card = CardFactory() + tag = TagFactory() + + for polarity in (1, -1): + client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": polarity, "anonymousId": "anon-1"}, + content_type="application/json", + ) + + votes = CardTagVote.objects.filter(card=card, tag=tag, anonymous_id="anon-1") + assert votes.count() == 1 + assert votes.get().polarity == -1 + + def test_a_vote_on_one_tag_does_not_clear_a_vote_on_another_tag_by_the_same_person(self, client, django_settings): + card = CardFactory() + tag_a = TagFactory() + tag_b = TagFactory() + + client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag_a.name, "polarity": 1, "anonymousId": "anon-1"}, + content_type="application/json", + ) + client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag_b.name, "polarity": 1, "anonymousId": "anon-1"}, + content_type="application/json", + ) + + assert CardTagVote.objects.filter(card=card, anonymous_id="anon-1").count() == 2 + + def test_rate_limited_after_exceeding_the_configured_rate(self, client, django_settings, settings): + settings.PRINTING_TAG_SUBMISSION_RATE = "1/m" + card = CardFactory() + tag = TagFactory() + body = { + "identifier": card.identifier, + "tagName": tag.name, + "polarity": 1, + "anonymousId": "anon-rate-limited", + } + + first = client.post(reverse(views.post_submit_tag_vote), body, content_type="application/json") + second = client.post(reverse(views.post_submit_tag_vote), body, content_type="application/json") + + assert first.status_code == 200 + assert second.status_code == 429 diff --git a/MPCAutofill/cardpicker/tests/test_vote_consensus.py b/MPCAutofill/cardpicker/tests/test_vote_consensus.py new file mode 100644 index 000000000..8134626be --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_vote_consensus.py @@ -0,0 +1,103 @@ +from django.conf import settings + +from cardpicker.models import VoteSource +from cardpicker.tests.factories import CardArtistVoteFactory, CardFactory +from cardpicker.vote_consensus import ( + _SOURCE_WEIGHTS, + VoteTuple, + resolve_weighted_consensus, +) + + +class TestResolveWeightedConsensus: + def test_no_votes_returns_none(self): + assert resolve_weighted_consensus([], min_weight=2, min_share=0.6) is None + + def test_single_group_clears_thresholds(self): + votes = [ + VoteTuple(outcome_key="a", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="a", weight=1.0, is_human_backed=True), + ] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) == "a" + + def test_below_min_weight_returns_none(self): + votes = [VoteTuple(outcome_key="a", weight=1.0, is_human_backed=True)] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) is None + + def test_tie_below_min_share_returns_none(self): + # two outcomes with equal weight: share is exactly 0.5, below a 0.6 threshold + votes = [ + VoteTuple(outcome_key="a", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="a", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="b", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="b", weight=1.0, is_human_backed=True), + ] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) is None + + def test_admin_style_weight_override(self): + # one high-weight vote (e.g. an "admin") outweighs two conflicting low-weight votes + votes = [ + VoteTuple(outcome_key="a", weight=5.0, is_human_backed=True), + VoteTuple(outcome_key="b", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="b", weight=1.0, is_human_backed=True), + ] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) == "a" + + def test_ai_only_votes_never_resolve_even_with_large_weight(self): + votes = [VoteTuple(outcome_key="a", weight=10.0, is_human_backed=False) for _ in range(5)] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) is None + + def test_mixed_ai_and_non_ai_can_resolve(self): + votes = [ + VoteTuple(outcome_key="a", weight=0.5, is_human_backed=False), + VoteTuple(outcome_key="a", weight=2.0, is_human_backed=True), + ] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) == "a" + + def test_three_way_split_leader_below_share_returns_none(self): + votes = [ + VoteTuple(outcome_key="a", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="a", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="b", weight=1.0, is_human_backed=True), + VoteTuple(outcome_key="c", weight=1.0, is_human_backed=True), + ] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) is None + + +class TestFederatedWeighting: + """ + Federation-readiness stub (see docs/federation-v1.md) - no import path creates federated + votes yet, so these tests exercise the plumbing directly via VoteTuple/settings rather than + through a real submit view. + """ + + def test_federated_source_uses_the_configured_weight(self): + assert _SOURCE_WEIGHTS[VoteSource.FEDERATED] == settings.VOTE_FEDERATED_WEIGHT + + def test_federated_vote_with_human_backed_true_satisfies_the_gate(self): + votes = [ + VoteTuple(outcome_key="a", weight=settings.VOTE_FEDERATED_WEIGHT * 5, is_human_backed=True), + ] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) == "a" + + def test_federated_vote_with_human_backed_false_does_not_satisfy_the_gate_alone(self): + # mirrors test_ai_only_votes_never_resolve_even_with_large_weight - a federated vote + # explicitly marked not-human-backed can never single-handedly clear consensus, same + # as an AI vote, regardless of how much weight it carries + votes = [VoteTuple(outcome_key="a", weight=settings.VOTE_FEDERATED_WEIGHT * 100, is_human_backed=False)] + assert resolve_weighted_consensus(votes, min_weight=2, min_share=0.6) is None + + +class TestFederatedModelFields: + def test_federated_source_and_peer_round_trip(self, db): + card = CardFactory() + vote = CardArtistVoteFactory(card=card, source=VoteSource.FEDERATED, peer="peer-instance-1") + vote.refresh_from_db() + assert vote.source == VoteSource.FEDERATED + assert vote.peer == "peer-instance-1" + + def test_peer_defaults_to_none_for_non_federated_votes(self, db): + card = CardFactory() + vote = CardArtistVoteFactory(card=card, source=VoteSource.USER) + vote.refresh_from_db() + assert vote.peer is None diff --git a/MPCAutofill/cardpicker/tests/test_vote_queue_views.py b/MPCAutofill/cardpicker/tests/test_vote_queue_views.py new file mode 100644 index 000000000..91876caf3 --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_vote_queue_views.py @@ -0,0 +1,189 @@ +import pytest + +from django.urls import reverse + +from cardpicker import views +from cardpicker.models import ( + ArtistVoteStatus, + PrintingTagStatus, + VotePolarity, + VoteSource, +) +from cardpicker.tag_consensus import resolve_and_persist_tag_votes +from cardpicker.tests.factories import ( + CanonicalArtistFactory, + CanonicalCardFactory, + CanonicalExpansionFactory, + CardArtistVoteFactory, + CardFactory, + CardPrintingTagFactory, + CardTagVoteFactory, + SourceFactory, + TagFactory, +) + +# see test_printing_consensus.py for why this capture-and-restore fixture exists +_SHARED_FACTORIES = [ + CardFactory, + SourceFactory, + CanonicalArtistFactory, + CanonicalExpansionFactory, + CanonicalCardFactory, +] + + +@pytest.fixture(autouse=True) +def _preserve_shared_factory_sequences(): + before = {f: f._meta.next_sequence() for f in _SHARED_FACTORIES} + for f, n in before.items(): + f.reset_sequence(n, force=True) + yield + for f, n in before.items(): + f.reset_sequence(n, force=True) + + +def _post_vote_queue(client, kind: str, page: int = 1): + return client.post( + reverse(views.post_vote_queue), + {"kind": kind, "page": page}, + content_type="application/json", + ) + + +class TestPostVoteQueuePrinting: + def test_only_unresolved_cards_are_returned(self, client, django_settings): + unresolved = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + CardFactory(printing_tag_status=PrintingTagStatus.RESOLVED) + CardFactory(printing_tag_status=PrintingTagStatus.NO_MATCH) + + response = _post_vote_queue(client, "printing") + + assert response.status_code == 200 + body = response.json() + assert body["hits"] == 1 + assert [item["card"]["identifier"] for item in body["items"]] == [unresolved.identifier] + assert all(item["tagName"] is None for item in body["items"]) + + def test_contested_cards_are_returned_first(self, client, django_settings): + contested = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + printing_a = CanonicalCardFactory() + printing_b = CanonicalCardFactory() + CardPrintingTagFactory(card=contested, printing=printing_a) + CardPrintingTagFactory(card=contested, printing=printing_b) + uncontested = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + + response = _post_vote_queue(client, "printing") + + identifiers = [item["card"]["identifier"] for item in response.json()["items"]] + assert identifiers == [contested.identifier, uncontested.identifier] + + def test_invalid_page_is_a_bad_request(self, client, django_settings): + CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + + response = _post_vote_queue(client, "printing", page=999) + + assert response.status_code == 400 + + def test_non_post_method_is_rejected(self, client, django_settings): + response = client.get(reverse(views.post_vote_queue)) + assert response.status_code == 400 + + def test_invalid_kind_is_a_bad_request(self, client, django_settings): + response = client.post( + reverse(views.post_vote_queue), + {"kind": "not-a-real-kind", "page": 1}, + content_type="application/json", + ) + assert response.status_code == 400 + + +class TestPostVoteQueueArtist: + def test_only_unresolved_and_contested_cards_are_returned(self, client, django_settings): + unresolved = CardFactory(artist_vote_status=ArtistVoteStatus.UNRESOLVED) + CardFactory(artist_vote_status=ArtistVoteStatus.RESOLVED) + CardFactory(artist_vote_status=ArtistVoteStatus.UNKNOWN) + contested = CardFactory(artist_vote_status=ArtistVoteStatus.CONTESTED) + + response = _post_vote_queue(client, "artist") + + identifiers = {item["card"]["identifier"] for item in response.json()["items"]} + assert identifiers == {unresolved.identifier, contested.identifier} + + def test_contested_cards_are_returned_first(self, client, django_settings): + contested = CardFactory(artist_vote_status=ArtistVoteStatus.UNRESOLVED) + artist_a = CanonicalArtistFactory() + artist_b = CanonicalArtistFactory() + CardArtistVoteFactory(card=contested, artist=artist_a) + CardArtistVoteFactory(card=contested, artist=artist_b) + uncontested = CardFactory(artist_vote_status=ArtistVoteStatus.UNRESOLVED) + + response = _post_vote_queue(client, "artist") + + identifiers = [item["card"]["identifier"] for item in response.json()["items"]] + assert identifiers == [contested.identifier, uncontested.identifier] + + +class TestPostVoteQueueTag: + def test_resolved_pairs_are_excluded(self, client, django_settings, settings): + settings.PRINTING_TAG_MIN_VOTES = 1 + settings.PRINTING_TAG_MIN_SHARE = 0.5 + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + resolve_and_persist_tag_votes(card) + + response = _post_vote_queue(client, "tag") + + assert response.json()["items"] == [] + + def test_a_resolved_pair_stays_excluded_even_as_unrelated_votes_trickle_in(self, client, django_settings): + # regression guard matching the plan's "persisted state, not raw vote existence" + # requirement - once resolved, new votes on a *different* tag on the same card must + # not resurface the already-resolved pair + card = CardFactory(tags=[]) + resolved_tag = TagFactory(name="Borderless") + other_tag = TagFactory(name="Extended") + CardTagVoteFactory(card=card, tag=resolved_tag, polarity=VotePolarity.APPLY, source=VoteSource.ADMIN) + resolve_and_persist_tag_votes(card) + + CardTagVoteFactory(card=card, tag=other_tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + resolve_and_persist_tag_votes(card) + + response = _post_vote_queue(client, "tag") + + tag_names = {item["tagName"] for item in response.json()["items"]} + assert "Borderless" not in tag_names + assert "Extended" in tag_names + + def test_contested_pair_outranks_a_less_contested_pair(self, client, django_settings): + # a 1-vs-1 split (net weight 0) is a closer contest than a lone unresolved vote + # (net weight 1) and should sort first + contested_card = CardFactory(tags=[]) + contested_tag = TagFactory(name="Contested Tag") + CardTagVoteFactory(card=contested_card, tag=contested_tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + CardTagVoteFactory( + card=contested_card, tag=contested_tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.USER + ) + resolve_and_persist_tag_votes(contested_card) + + lopsided_card = CardFactory(tags=[]) + lopsided_tag = TagFactory(name="Lopsided Tag") + CardTagVoteFactory(card=lopsided_card, tag=lopsided_tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + resolve_and_persist_tag_votes(lopsided_card) + + response = _post_vote_queue(client, "tag") + + tag_names = [item["tagName"] for item in response.json()["items"]] + assert tag_names[0] == "Contested Tag" + + def test_items_include_the_tag_name(self, client, django_settings): + card = CardFactory(tags=[]) + tag = TagFactory(name="Showcase") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + resolve_and_persist_tag_votes(card) + + response = _post_vote_queue(client, "tag") + + [item] = response.json()["items"] + assert item["tagName"] == "Showcase" + assert item["card"]["identifier"] == card.identifier diff --git a/MPCAutofill/cardpicker/urls.py b/MPCAutofill/cardpicker/urls.py index 4cc54c016..e41d5dea2 100755 --- a/MPCAutofill/cardpicker/urls.py +++ b/MPCAutofill/cardpicker/urls.py @@ -26,4 +26,10 @@ path("2/printingConsensus/", views.post_printing_consensus), path("2/submitPrintingTag/", views.post_submit_printing_tag), path("2/printingTagQueue/", views.get_printing_tag_queue), + path("2/artistCandidates/", views.post_artist_candidates), + path("2/artistConsensus/", views.post_artist_consensus), + path("2/submitArtistVote/", views.post_submit_artist_vote), + path("2/tagConsensus/", views.post_tag_consensus), + path("2/submitTagVote/", views.post_submit_tag_vote), + path("2/voteQueue/", views.post_vote_queue), ] diff --git a/MPCAutofill/cardpicker/views.py b/MPCAutofill/cardpicker/views.py index 5321cc25a..8bfcbf5da 100644 --- a/MPCAutofill/cardpicker/views.py +++ b/MPCAutofill/cardpicker/views.py @@ -7,6 +7,7 @@ from random import sample from typing import Any, Callable, Literal, TypeVar, Union, cast +import Levenshtein import pycountry from django_ratelimit.decorators import ratelimit from elasticsearch_dsl.index import Index @@ -25,6 +26,13 @@ ) from django.views.decorators.csrf import csrf_exempt +from cardpicker.artist_consensus import UNKNOWN as ARTIST_UNKNOWN +from cardpicker.artist_consensus import ( + get_artist_vote_tally, + get_contested_artist_card_ids, + resolve_and_persist_artist, + resolve_artist, +) from cardpicker.constants import ( CARDS_PAGE_SIZE, DEFAULT_LANGUAGE, @@ -37,17 +45,27 @@ from cardpicker.integrations.integrations import get_configured_game_integration from cardpicker.integrations.patreon import get_patreon_campaign_details, get_patrons from cardpicker.models import ( + ArtistVoteStatus, + CanonicalArtist, CanonicalCard, Card, + CardArtistVote, CardPrintingTag, - CardPrintingTagSource, + CardTagVote, CardTypes, DFCPair, PrintingTagStatus, Source, + Tag, + VotePolarity, + VoteSource, summarise_contributions, ) -from cardpicker.printing_candidates import get_ranked_printing_candidates +from cardpicker.printing_candidates import ( + CANDIDATE_QUERY_LIMIT, + CANDIDATE_RESULT_LIMIT, + get_ranked_printing_candidates, +) from cardpicker.printing_consensus import ( NO_MATCH, get_contested_card_ids, @@ -55,7 +73,15 @@ resolve_and_persist_printing, resolve_printing, ) -from cardpicker.schema_types import CardbacksRequest, CardbacksResponse +from cardpicker.schema_types import ( + ArtistCandidatesRequest, + ArtistCandidatesResponse, + ArtistConsensusRequest, + ArtistConsensusResponse, + ArtistVoteTallyEntry, + CardbacksRequest, + CardbacksResponse, +) from cardpicker.schema_types import Cards as SampleCards from cardpicker.schema_types import ( CardsRequest, @@ -73,6 +99,9 @@ ImportSitesResponse, Info, InfoResponse, +) +from cardpicker.schema_types import Kind as VoteQueueKind +from cardpicker.schema_types import ( Language, LanguagesResponse, NewCardsFirstPage, @@ -91,10 +120,20 @@ SearchEngineHealthResponse, SortBy, SourcesResponse, + SubmitArtistVoteRequest, SubmitPrintingTagRequest, + SubmitTagVoteRequest, + TagConsensusEntry, + TagConsensusRequest, + TagConsensusResponse, TagsResponse, + TagVoteTallyEntry, + VoteQueueItem, + VoteQueueRequest, + VoteQueueResponse, VoteTallyEntry, ) +from cardpicker.search.sanitisation import to_searchable from cardpicker.search.search_functions import ( SearchExceptions, get_new_cards_paginator, @@ -105,6 +144,12 @@ ) from cardpicker.sources.api import PathTraversalError, resolve_within_root from cardpicker.sources.source_types import SourceTypeChoices +from cardpicker.tag_consensus import ( + get_tag_review_queue_pairs, + get_tag_vote_tally, + resolve_and_persist_tag_votes, + resolve_tag, +) from cardpicker.tags import Tags logger = logging.getLogger(__name__) @@ -805,8 +850,264 @@ def post_submit_printing_tag(request: HttpRequest) -> HttpResponse: printing=printing, is_no_match=req.isNoMatch, anonymous_id=req.anonymousId, - source=CardPrintingTagSource.USER, + source=VoteSource.USER, ) resolved = resolve_and_persist_printing(card) return JsonResponse(_build_printing_consensus_response(card, resolved).model_dump()) + + +def _build_artist_consensus_response( + card: Card, resolved: CanonicalArtist | Literal["UNKNOWN"] | None +) -> ArtistConsensusResponse: + return ArtistConsensusResponse( + resolvedArtist=resolved.serialise() if isinstance(resolved, CanonicalArtist) else None, + isUnknown=resolved == ARTIST_UNKNOWN, + voteTally=[ + ArtistVoteTallyEntry( + artist=entry["artist"].serialise() if entry["artist"] else None, + isUnknown=entry["is_unknown"], + count=entry["count"], + ) + for entry in get_artist_vote_tally(card) + ], + ) + + +@csrf_exempt +@ErrorWrappers.to_json +def post_artist_candidates(request: HttpRequest) -> HttpResponse: + """ + Return candidate artists for a card to be tagged against. Two modes: by default, ranks by + deduplicating the artists of `get_ranked_printing_candidates`'s own results (free ranking + signal, no separate query needed, since those printings are already ranked by relevance to + this card); if `query` is given, switches to a typeahead search over `CanonicalArtist.name` + for when the right artist isn't among those candidates. + """ + + if request.method != "POST": + raise BadRequestException("Expected POST request.") + + req = ArtistCandidatesRequest.model_validate(json.loads(request.body)) + card = _get_card_or_400(req.identifier) + + if req.query: + words = to_searchable(req.query).split() + artists_qs = CanonicalArtist.objects.all() + for word in words: + artists_qs = artists_qs.filter(name__icontains=word) + normalised_query = to_searchable(req.query) + artists = sorted( + artists_qs[:CANDIDATE_QUERY_LIMIT], + key=lambda artist: Levenshtein.ratio(normalised_query, to_searchable(artist.name)), + reverse=True, + )[:CANDIDATE_RESULT_LIMIT] + else: + seen_artist_ids: set[int] = set() + artists = [] + for printing in get_ranked_printing_candidates(card, None): + if printing.artist_id not in seen_artist_ids: + seen_artist_ids.add(printing.artist_id) + artists.append(printing.artist) + + return JsonResponse(ArtistCandidatesResponse(results=[artist.serialise() for artist in artists]).model_dump()) + + +@csrf_exempt +@ErrorWrappers.to_json +def post_artist_consensus(request: HttpRequest) -> HttpResponse: + """ + Return the currently resolved artist-vote consensus for a card, plus a plain vote-count + breakdown - mirrors `post_printing_consensus`. + """ + + if request.method != "POST": + raise BadRequestException("Expected POST request.") + + req = ArtistConsensusRequest.model_validate(json.loads(request.body)) + card = _get_card_or_400(req.identifier) + return JsonResponse(_build_artist_consensus_response(card, resolve_artist(card)).model_dump()) + + +@csrf_exempt +@ratelimit( # type: ignore # `django-ratelimit` does not implement decorator typing correctly + key=_printing_tag_rate_limit_key, rate=_printing_tag_rate_limit_rate, method="POST", block=False +) +@ErrorWrappers.to_json +def post_submit_artist_vote(request: HttpRequest) -> HttpResponse: + """ + Submit a vote that a card was illustrated by a specific artist (or definitively by an + unlisted/unknown artist). Same delete-then-create-then-recompute pattern as + `post_submit_printing_tag` - one artist opinion per (card, anonymous ID) pair at a time, + reusing the same rate-limit plumbing (it already reads `anonymousId` from the request body + generically, nothing printing-specific about it despite the name). + """ + + if request.method != "POST": + raise BadRequestException("Expected POST request.") + if getattr(request, "limited", False): + return JsonResponse( + ErrorResponse( + name="Rate limited", message="Too many artist vote submissions - please slow down." + ).model_dump(), + status=429, + ) + + req = SubmitArtistVoteRequest.model_validate(json.loads(request.body)) + card = _get_card_or_400(req.identifier) + + artist = None + if not req.isUnknown: + if not req.artistName: + raise BadRequestException("artistName is required unless isUnknown is set.") + try: + artist = CanonicalArtist.objects.get(name=req.artistName) + except CanonicalArtist.DoesNotExist: + raise BadRequestException(f"No artist found with name {req.artistName!r}.") + + with transaction.atomic(): + CardArtistVote.objects.filter(card=card, anonymous_id=req.anonymousId).delete() + CardArtistVote.objects.create( + card=card, + artist=artist, + is_unknown=req.isUnknown, + anonymous_id=req.anonymousId, + source=VoteSource.USER, + ) + resolved = resolve_and_persist_artist(card) + + return JsonResponse(_build_artist_consensus_response(card, resolved).model_dump()) + + +def _build_tag_consensus_entry(card: Card, tag: Tag) -> TagConsensusEntry: + return TagConsensusEntry( + tagName=tag.name, + resolvedPolarity=resolve_tag(card, tag), + tally=[ + TagVoteTallyEntry(polarity=entry["polarity"], count=entry["count"]) + for entry in get_tag_vote_tally(card, tag) + ], + ) + + +@csrf_exempt +@ErrorWrappers.to_json +def post_tag_consensus(request: HttpRequest) -> HttpResponse: + """ + Return the currently resolved tag-vote consensus for every seeded tag against a card, so a + voter can see and toggle every tag's state in one call rather than fetching per-tag. + """ + + if request.method != "POST": + raise BadRequestException("Expected POST request.") + + req = TagConsensusRequest.model_validate(json.loads(request.body)) + card = _get_card_or_400(req.identifier) + tags = Tag.objects.order_by("name") + return JsonResponse(TagConsensusResponse(tags=[_build_tag_consensus_entry(card, tag) for tag in tags]).model_dump()) + + +@csrf_exempt +@ratelimit( # type: ignore # `django-ratelimit` does not implement decorator typing correctly + key=_printing_tag_rate_limit_key, rate=_printing_tag_rate_limit_rate, method="POST", block=False +) +@ErrorWrappers.to_json +def post_submit_tag_vote(request: HttpRequest) -> HttpResponse: + """ + Submit a vote on whether a specific tag applies to a card. Unlike printing/artist votes, + this is `update_or_create` rather than delete-then-create: a card can carry independent, + simultaneous votes across many different tags at once, so submitting a vote on one tag + must not clear votes this same person has already cast on any other tag for this card. + """ + + if request.method != "POST": + raise BadRequestException("Expected POST request.") + if getattr(request, "limited", False): + return JsonResponse( + ErrorResponse( + name="Rate limited", message="Too many tag vote submissions - please slow down." + ).model_dump(), + status=429, + ) + + req = SubmitTagVoteRequest.model_validate(json.loads(request.body)) + card = _get_card_or_400(req.identifier) + try: + tag = Tag.objects.get(name=req.tagName) + except Tag.DoesNotExist: + raise BadRequestException(f"No tag found with name {req.tagName!r}.") + if req.polarity not in (VotePolarity.APPLY, VotePolarity.NOT_APPLICABLE): + raise BadRequestException(f"Invalid polarity {req.polarity!r} - must be 1 (apply) or -1 (not applicable).") + + with transaction.atomic(): + CardTagVote.objects.update_or_create( + card=card, + tag=tag, + anonymous_id=req.anonymousId, + defaults={"polarity": req.polarity, "source": VoteSource.USER}, + ) + resolve_and_persist_tag_votes(card) + + return JsonResponse(_build_tag_consensus_entry(card, tag).model_dump()) + + +def _paginate(items: Any, page: int) -> Any: + """Shared page-index validation for the vote queue, mirroring `get_printing_tag_queue`'s + own inline validation (not reused directly - that view's validation lives inline, not as + a separate helper, and duplicating six lines here is simpler than refactoring it out from + under a view this task doesn't otherwise touch).""" + paginator: Paginator[Any] = Paginator(items, PRINTING_TAG_QUEUE_PAGE_SIZE) + if not (paginator.num_pages >= page > 0): + raise BadRequestException(f"Invalid page {page} specified - must be between 1 and {paginator.num_pages}.") + return paginator + + +@csrf_exempt +@ErrorWrappers.to_json +def post_vote_queue(request: HttpRequest) -> HttpResponse: + """ + Generalizes the review queue across all three vote kinds via a `kind` request field - a + new sibling endpoint (POST, unlike `2/printingTagQueue/`'s GET) rather than a mutation of + that one, which stays completely untouched/reachable for anything still calling it. + + One queue item per card for `kind=printing`/`artist` (`tagName` always null, exactly + `2/printingTagQueue/`'s existing shape plus that field) - printing mode's candidate + set/ordering is byte-for-byte what `get_printing_tag_queue` already does (unresolved, + contested-first). Artist mode is the same shape, generalized to also include `CONTESTED` + (a status `PrintingTagStatus` has no equivalent for - printing's own contested cards are + already tagged `UNRESOLVED`, distinguished only by the ordering annotation). + + For `kind=tag`, one item per (card, tag) pair - see `get_tag_review_queue_pairs` for the + persisted-state candidate filter and the net-polarity-weight/card-interleave ordering. + """ + if request.method != "POST": + raise BadRequestException("Expected POST request.") + + req = VoteQueueRequest.model_validate(json.loads(request.body)) + + if req.kind == VoteQueueKind.tag: + pairs = get_tag_review_queue_pairs() + paginator = _paginate(pairs, req.page) + page_pairs = paginator.page(req.page).object_list + cards_by_id = {card.pk: card for card in Card.objects.filter(pk__in=[card_id for card_id, _ in page_pairs])} + items = [ + VoteQueueItem(card=cards_by_id[card_id].serialise(), tagName=tag_name) for card_id, tag_name in page_pairs + ] + else: + if req.kind == VoteQueueKind.printing: + cards = Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED).annotate( + is_contested=Case(When(pk__in=get_contested_card_ids(), then=1), default=0, output_field=IntegerField()) + ) + else: + cards = Card.objects.filter( + artist_vote_status__in=[ArtistVoteStatus.UNRESOLVED, ArtistVoteStatus.CONTESTED] + ).annotate( + is_contested=Case( + When(pk__in=get_contested_artist_card_ids(), then=1), default=0, output_field=IntegerField() + ) + ) + cards = cards.order_by("-is_contested", "-date_created", "name") + paginator = _paginate(cards, req.page) + items = [VoteQueueItem(card=card.serialise(), tagName=None) for card in paginator.page(req.page).object_list] + + return JsonResponse(VoteQueueResponse(hits=paginator.count, pages=paginator.num_pages, items=items).model_dump()) diff --git a/MPCAutofill/cardpicker/vote_consensus.py b/MPCAutofill/cardpicker/vote_consensus.py new file mode 100644 index 000000000..ccf39cc38 --- /dev/null +++ b/MPCAutofill/cardpicker/vote_consensus.py @@ -0,0 +1,118 @@ +from collections import defaultdict +from typing import Any, Hashable, Iterable, NamedTuple, TypedDict + +from django.conf import settings +from django.db.models import Case, Count, IntegerField, Q, QuerySet, When + +from cardpicker.models import VoteSource + +# Shared across printing/artist/tag consensus - previously duplicated identically in each of +# their own modules; hoisted here so a new source (e.g. `FEDERATED`) can't be forgotten in one +# of them. `printing_consensus.py`/`artist_consensus.py`/`tag_consensus.py` import this rather +# than redefining it. +_SOURCE_WEIGHTS: dict[str, float] = { + VoteSource.USER: 1.0, + VoteSource.ADMIN: settings.PRINTING_TAG_ADMIN_WEIGHT, + VoteSource.AI: settings.PRINTING_TAG_AI_WEIGHT, + VoteSource.FEDERATED: settings.VOTE_FEDERATED_WEIGHT, +} + + +class VoteTuple(NamedTuple): + """ + A single vote reduced to just what `resolve_weighted_consensus` needs to reconcile it: the + outcome it argues for (grouping key - e.g. a printing's pk, an artist's pk, or a tag's + polarity), its weight (already resolved from the vote's `source` by the caller), and + whether it should count towards the human-backed gate below. This is deliberately not + derived automatically from `source == AI` inside this module - the caller decides, since a + future federated vote's human-backed-ness depends on what the exporting peer reported, not + on the local `source` value alone (see docs/federation-v1.md). Every wrapper today + (`USER`/`ADMIN`/`AI`) still computes this as `source != AI`, so behaviour is unchanged. + """ + + outcome_key: Hashable + weight: float + is_human_backed: bool + + +class _VoteGroup(TypedDict): + weight: float + has_human_backed: bool + + +def resolve_weighted_consensus(votes: Iterable[VoteTuple], min_weight: float, min_share: float) -> Hashable | None: + """ + Reconciles a set of weighted votes into a single resolved outcome key, or `None` if there + isn't yet enough signal to conclude anything (no votes, a tie, or a genuinely contested + set of votes). This is the shared core behind `cardpicker.printing_consensus.resolve_printing`, + `cardpicker.artist_consensus.resolve_artist`, and `cardpicker.tag_consensus.resolve_tag` - each + of those is a thin wrapper that builds `VoteTuple`s from its own vote model and calls this. + + Votes are grouped by `outcome_key`, and the highest-weighted group wins if, and only if, + ALL of the following hold: + - its summed weight is >= `min_weight` (compared against summed weight, not a raw row + count - a single admin vote, at a typical admin weight of 5, already clears a default + threshold of 2 on its own, which is what produces "admin override" behaviour from this + one unified formula, with no special-cased branch for admin votes); + - its share of the total weight across all groups is >= `min_share`; + - it contains at least one human-backed vote (a hard gate, independent of the weight math + above, so that no volume of non-human-backed votes - e.g. AI-only - can ever resolve + consensus on their own). + """ + votes = list(votes) + if not votes: + return None + + groups: dict[Hashable, _VoteGroup] = defaultdict(lambda: _VoteGroup(weight=0.0, has_human_backed=False)) + for vote in votes: + group = groups[vote.outcome_key] + group["weight"] += vote.weight + if vote.is_human_backed: + group["has_human_backed"] = True + + total_weight = sum(group["weight"] for group in groups.values()) + winning_key, winner = max(groups.items(), key=lambda item: item[1]["weight"]) + share = winner["weight"] / total_weight + + if winner["weight"] >= min_weight and share >= min_share and winner["has_human_backed"]: + return winning_key + return None + + +def contested_queryset( + queryset: "QuerySet[Any]", + group_by: str | list[str], + outcome_field: str, + sentinel_field: str | None = None, +) -> list[Any]: + """ + Generalizes what was originally `printing_consensus.get_contested_card_ids`'s logic across + any `AbstractWeightedVote` subclass - see that function for the original "what does + contested mean, and why is this a cheap proxy rather than a full consensus + recomputation" reasoning, unchanged here. + + Takes an unfiltered base `queryset` (e.g. `CardPrintingTag.objects.all()` - a queryset + rather than the model class itself, since django-stubs can't statically resolve `.objects` + off a bare `type[Model]`) and groups its rows by `group_by` (a field name, e.g. `"card_id"`, + or a list of field names for a composite grouping, e.g. `["card_id", "tag_id"]`), counts + distinct `outcome_field` values per group, and flags a group as contested if it has more + than one distinct outcome, or - only when `sentinel_field` is given - if it has exactly one + outcome AND at least one sentinel vote alongside it (e.g. a printing vote coexisting with + an `is_no_match` vote). Returns a plain list of group-by values (or tuples, for a composite + `group_by`) - materialized eagerly, same rationale as the original: the contested set is + always a small fraction of the total. + """ + group_fields = [group_by] if isinstance(group_by, str) else group_by + condition = Q(distinct_outcomes__gt=1) + annotations = {"distinct_outcomes": Count(outcome_field, distinct=True)} + if sentinel_field is not None: + annotations["has_sentinel"] = Count(Case(When(**{sentinel_field: True}, then=1), output_field=IntegerField())) + condition = condition | (Q(distinct_outcomes__gte=1) & Q(has_sentinel__gt=0)) + + grouped = queryset.values(*group_fields).annotate(**annotations).filter(condition) + if len(group_fields) == 1: + return list(grouped.values_list(group_fields[0], flat=True)) + return list(grouped.values_list(*group_fields)) + + +__all__ = ["VoteTuple", "resolve_weighted_consensus", "_SOURCE_WEIGHTS", "contested_queryset"] diff --git a/docs/federation-v1.md b/docs/federation-v1.md new file mode 100644 index 000000000..f45745c90 --- /dev/null +++ b/docs/federation-v1.md @@ -0,0 +1,58 @@ +# Federation verdict exchange — format v1 (spec; implementation pending) + +Instances share **resolved consensus verdicts** (never raw votes) as signed +JSON. Each importing instance ingests peer verdicts as `source='federated'` +votes, subject to its own consensus thresholds and admin override. Instances +export only locally-resolved consensus — never re-broadcast federated votes +(no transitive trust / echo amplification). + +## File shape + + { + "schema_version": 1, + "instance": "", + "public_key": "", + "generated_at": "", + "game": "MTG", + "verdicts": [ + { + "image": { + "drive_id": "", + "content_hash": null, // reserved: perceptual hash, v1 nullable + "name": "" + }, + "kind": "printing" | "artist" | "tag", + "outcome": "scryfall:" | "no_match" + | "artist:" | "artist:unknown" + | "tag::apply" | "tag::reject", + "resolved_at": "", + "vote_weight": , + "human_votes": = 1 as the condition for the imported + vote to be human-backed for gate purposes> + } + ], + "signature": "" + } + +## Interchange keys & stability contract + +- Images join across instances by `drive_id` (Google Drive file id). + `content_hash` is the planned upgrade path for surviving re-uploads. +- Artists travel by canonical name; tags by `Tag.name`. **Tag names are + therefore a cross-instance contract: renaming a Tag is a breaking data + migration, not an edit.** + +## Import rules (normative for future implementation) + +- Verify signature against a pinned per-peer public key (out-of-band + exchange; no open enrollment). +- One voice per peer per (image, kind[, tag]): re-import replaces that + peer's prior vote, never stacks. +- Weight: settings.VOTE_FEDERATED_WEIGHT, per-peer override permitted. +- Skip verdicts whose artist/tag can't be matched locally. + +Status: format committed ahead of code. Export/import commands, keygen, and +the peer registry are future work; the schema stub (source='federated', +peer field, weight setting, is_human_backed plumbing) ships now. diff --git a/frontend/src/common/schema_types.ts b/frontend/src/common/schema_types.ts index 52bae57d8..a36624c4e 100644 --- a/frontend/src/common/schema_types.ts +++ b/frontend/src/common/schema_types.ts @@ -2,8 +2,9 @@ // To parse this data: // -// import { Convert, Campaign, CanonicalArtist, CanonicalCard, Card, CardType, FilterSettings, Game, ImportSite, Language, NewCardsFirstPage, PrintingCandidate, SearchQuery, SearchSettings, SearchTypeSettings, SortBy, Source, SourceContribution, SourceSettings, SourceType, Supporter, SupporterTier, Tag, VoteTallyEntry, CardbacksRequest, CardbacksResponse, CardsRequest, CardsResponse, ContributionsResponse, DFCPairsResponse, EditorSearchRequest, EditorSearchResponse, ErrorResponse, ExploreSearchRequest, ExploreSearchResponse, ImportSiteDecklistRequest, ImportSiteDecklistResponse, ImportSitesResponse, InfoResponse, LanguagesResponse, NewCardsFirstPagesResponse, NewCardsPageResponse, OldEditorSearchRequest, OldEditorSearchResponse, PatreonResponse, PrintingCandidatesRequest, PrintingCandidatesResponse, PrintingConsensusRequest, PrintingConsensusResponse, PrintingTagQueueResponse, SampleCardsResponse, SearchEngineHealthResponse, SourcesResponse, SubmitPrintingTagRequest, TagsResponse } from "./file"; +// import { Convert, ArtistVoteTallyEntry, Campaign, CanonicalArtist, CanonicalCard, Card, CardType, FilterSettings, Game, ImportSite, Language, NewCardsFirstPage, PrintingCandidate, SearchQuery, SearchSettings, SearchTypeSettings, SortBy, Source, SourceContribution, SourceSettings, SourceType, Supporter, SupporterTier, Tag, TagConsensusEntry, TagVoteTallyEntry, VoteQueueItem, VoteTallyEntry, ArtistCandidatesRequest, ArtistCandidatesResponse, ArtistConsensusRequest, ArtistConsensusResponse, CardbacksRequest, CardbacksResponse, CardsRequest, CardsResponse, ContributionsResponse, DFCPairsResponse, EditorSearchRequest, EditorSearchResponse, ErrorResponse, ExploreSearchRequest, ExploreSearchResponse, ImportSiteDecklistRequest, ImportSiteDecklistResponse, ImportSitesResponse, InfoResponse, LanguagesResponse, NewCardsFirstPagesResponse, NewCardsPageResponse, OldEditorSearchRequest, OldEditorSearchResponse, PatreonResponse, PrintingCandidatesRequest, PrintingCandidatesResponse, PrintingConsensusRequest, PrintingConsensusResponse, PrintingTagQueueResponse, SampleCardsResponse, SearchEngineHealthResponse, SourcesResponse, SubmitArtistVoteRequest, SubmitPrintingTagRequest, SubmitTagVoteRequest, TagConsensusRequest, TagConsensusResponse, TagsResponse, VoteQueueRequest, VoteQueueResponse } from "./file"; // +// const artistVoteTallyEntry = Convert.toArtistVoteTallyEntry(json); // const campaign = Convert.toCampaign(json); // const canonicalArtist = Convert.toCanonicalArtist(json); // const canonicalCard = Convert.toCanonicalCard(json); @@ -27,7 +28,14 @@ // const supporter = Convert.toSupporter(json); // const supporterTier = Convert.toSupporterTier(json); // const tag = Convert.toTag(json); +// const tagConsensusEntry = Convert.toTagConsensusEntry(json); +// const tagVoteTallyEntry = Convert.toTagVoteTallyEntry(json); +// const voteQueueItem = Convert.toVoteQueueItem(json); // const voteTallyEntry = Convert.toVoteTallyEntry(json); +// const artistCandidatesRequest = Convert.toArtistCandidatesRequest(json); +// const artistCandidatesResponse = Convert.toArtistCandidatesResponse(json); +// const artistConsensusRequest = Convert.toArtistConsensusRequest(json); +// const artistConsensusResponse = Convert.toArtistConsensusResponse(json); // const cardbacksRequest = Convert.toCardbacksRequest(json); // const cardbacksResponse = Convert.toCardbacksResponse(json); // const cardsRequest = Convert.toCardsRequest(json); @@ -57,8 +65,14 @@ // const sampleCardsResponse = Convert.toSampleCardsResponse(json); // const searchEngineHealthResponse = Convert.toSearchEngineHealthResponse(json); // const sourcesResponse = Convert.toSourcesResponse(json); +// const submitArtistVoteRequest = Convert.toSubmitArtistVoteRequest(json); // const submitPrintingTagRequest = Convert.toSubmitPrintingTagRequest(json); +// const submitTagVoteRequest = Convert.toSubmitTagVoteRequest(json); +// const tagConsensusRequest = Convert.toTagConsensusRequest(json); +// const tagConsensusResponse = Convert.toTagConsensusResponse(json); // const tagsResponse = Convert.toTagsResponse(json); +// const voteQueueRequest = Convert.toVoteQueueRequest(json); +// const voteQueueResponse = Convert.toVoteQueueResponse(json); // // These functions will throw an error if the JSON doesn't // match the expected interface, even if the JSON is valid. @@ -67,6 +81,35 @@ export enum Game { Mtg = "MTG", } +export interface ArtistCandidatesRequest { + identifier: string; + query?: null | string; +} + +export interface ArtistCandidatesResponse { + results: Array; +} + +export interface CanonicalArtist { + name: string; +} + +export interface ArtistConsensusRequest { + identifier: string; +} + +export interface ArtistConsensusResponse { + isUnknown: boolean; + resolvedArtist?: CanonicalArtist | null; + voteTally: ArtistVoteTallyEntry[]; +} + +export interface ArtistVoteTallyEntry { + artist?: CanonicalArtist | null; + count: number; + isUnknown: boolean; +} + export interface CardbacksRequest { searchSettings: SearchSettings; } @@ -136,6 +179,18 @@ export interface CardsResponse { export interface Card { canonicalArtist?: CanonicalArtist | null; + /** + * True only when canonicalArtist was supplied by artist-vote consensus alone, with no + * confirmed indexing match or resolved printing backing it - lets the frontend distinguish + * a confidently-known artist from a vote-derived one (e.g. for the ArtistVotePicker + * 'wrong?' affordance) without needing to know serialise()'s fallback chain itself. + */ + canonicalArtistIsFromVoteOnly?: boolean; + /** + * Which rung of the artist fallback chain actually supplied canonicalArtist - + * debug/introspection field, not load-bearing for any current frontend logic. + */ + canonicalArtistSource?: null | string; canonicalCard?: CanonicalCard | null; cardType: CardType; /** @@ -165,10 +220,6 @@ export interface Card { tags: string[]; } -export interface CanonicalArtist { - name: string; -} - export interface CanonicalCard { artist?: string; canonicalId?: string; @@ -427,6 +478,13 @@ export interface SourcesResponse { results: { [key: string]: Source }; } +export interface SubmitArtistVoteRequest { + anonymousId: string; + artistName?: null | string; + identifier: string; + isUnknown: boolean; +} + export interface SubmitPrintingTagRequest { anonymousId: string; identifier: string; @@ -434,6 +492,32 @@ export interface SubmitPrintingTagRequest { printingIdentifier?: null | string; } +export interface SubmitTagVoteRequest { + anonymousId: string; + identifier: string; + polarity: number; + tagName: string; +} + +export interface TagConsensusRequest { + identifier: string; +} + +export interface TagConsensusResponse { + tags: TagConsensusEntry[]; +} + +export interface TagConsensusEntry { + resolvedPolarity?: number | null; + tagName: string; + tally: TagVoteTallyEntry[]; +} + +export interface TagVoteTallyEntry { + count: number; + polarity: number; +} + export interface TagsResponse { tags: Tag[]; } @@ -454,9 +538,41 @@ export interface ChildElement { parent: null | string; } +export interface VoteQueueRequest { + kind: Kind; + page: number; +} + +export enum Kind { + Artist = "artist", + Printing = "printing", + Tag = "tag", +} + +export interface VoteQueueResponse { + hits: number; + items: VoteQueueItem[]; + pages: number; +} + +export interface VoteQueueItem { + card: Card; + tagName?: null | string; +} + // Converts JSON strings to/from your types // and asserts the results of JSON.parse at runtime export class Convert { + public static toArtistVoteTallyEntry(json: string): ArtistVoteTallyEntry { + return cast(JSON.parse(json), r("ArtistVoteTallyEntry")); + } + + public static artistVoteTallyEntryToJson( + value: ArtistVoteTallyEntry + ): string { + return JSON.stringify(uncast(value, r("ArtistVoteTallyEntry")), null, 2); + } + public static toCampaign(json: string): Campaign | null { return cast(JSON.parse(json), u(r("Campaign"), null)); } @@ -645,6 +761,30 @@ export class Convert { return JSON.stringify(uncast(value, r("Tag")), null, 2); } + public static toTagConsensusEntry(json: string): TagConsensusEntry { + return cast(JSON.parse(json), r("TagConsensusEntry")); + } + + public static tagConsensusEntryToJson(value: TagConsensusEntry): string { + return JSON.stringify(uncast(value, r("TagConsensusEntry")), null, 2); + } + + public static toTagVoteTallyEntry(json: string): TagVoteTallyEntry { + return cast(JSON.parse(json), r("TagVoteTallyEntry")); + } + + public static tagVoteTallyEntryToJson(value: TagVoteTallyEntry): string { + return JSON.stringify(uncast(value, r("TagVoteTallyEntry")), null, 2); + } + + public static toVoteQueueItem(json: string): VoteQueueItem { + return cast(JSON.parse(json), r("VoteQueueItem")); + } + + public static voteQueueItemToJson(value: VoteQueueItem): string { + return JSON.stringify(uncast(value, r("VoteQueueItem")), null, 2); + } + public static toVoteTallyEntry(json: string): VoteTallyEntry { return cast(JSON.parse(json), r("VoteTallyEntry")); } @@ -653,6 +793,56 @@ export class Convert { return JSON.stringify(uncast(value, r("VoteTallyEntry")), null, 2); } + public static toArtistCandidatesRequest( + json: string + ): ArtistCandidatesRequest { + return cast(JSON.parse(json), r("ArtistCandidatesRequest")); + } + + public static artistCandidatesRequestToJson( + value: ArtistCandidatesRequest + ): string { + return JSON.stringify(uncast(value, r("ArtistCandidatesRequest")), null, 2); + } + + public static toArtistCandidatesResponse( + json: string + ): ArtistCandidatesResponse { + return cast(JSON.parse(json), r("ArtistCandidatesResponse")); + } + + public static artistCandidatesResponseToJson( + value: ArtistCandidatesResponse + ): string { + return JSON.stringify( + uncast(value, r("ArtistCandidatesResponse")), + null, + 2 + ); + } + + public static toArtistConsensusRequest(json: string): ArtistConsensusRequest { + return cast(JSON.parse(json), r("ArtistConsensusRequest")); + } + + public static artistConsensusRequestToJson( + value: ArtistConsensusRequest + ): string { + return JSON.stringify(uncast(value, r("ArtistConsensusRequest")), null, 2); + } + + public static toArtistConsensusResponse( + json: string + ): ArtistConsensusResponse { + return cast(JSON.parse(json), r("ArtistConsensusResponse")); + } + + public static artistConsensusResponseToJson( + value: ArtistConsensusResponse + ): string { + return JSON.stringify(uncast(value, r("ArtistConsensusResponse")), null, 2); + } + public static toCardbacksRequest(json: string): CardbacksRequest { return cast(JSON.parse(json), r("CardbacksRequest")); } @@ -973,6 +1163,18 @@ export class Convert { return JSON.stringify(uncast(value, r("SourcesResponse")), null, 2); } + public static toSubmitArtistVoteRequest( + json: string + ): SubmitArtistVoteRequest { + return cast(JSON.parse(json), r("SubmitArtistVoteRequest")); + } + + public static submitArtistVoteRequestToJson( + value: SubmitArtistVoteRequest + ): string { + return JSON.stringify(uncast(value, r("SubmitArtistVoteRequest")), null, 2); + } + public static toSubmitPrintingTagRequest( json: string ): SubmitPrintingTagRequest { @@ -989,6 +1191,34 @@ export class Convert { ); } + public static toSubmitTagVoteRequest(json: string): SubmitTagVoteRequest { + return cast(JSON.parse(json), r("SubmitTagVoteRequest")); + } + + public static submitTagVoteRequestToJson( + value: SubmitTagVoteRequest + ): string { + return JSON.stringify(uncast(value, r("SubmitTagVoteRequest")), null, 2); + } + + public static toTagConsensusRequest(json: string): TagConsensusRequest { + return cast(JSON.parse(json), r("TagConsensusRequest")); + } + + public static tagConsensusRequestToJson(value: TagConsensusRequest): string { + return JSON.stringify(uncast(value, r("TagConsensusRequest")), null, 2); + } + + public static toTagConsensusResponse(json: string): TagConsensusResponse { + return cast(JSON.parse(json), r("TagConsensusResponse")); + } + + public static tagConsensusResponseToJson( + value: TagConsensusResponse + ): string { + return JSON.stringify(uncast(value, r("TagConsensusResponse")), null, 2); + } + public static toTagsResponse(json: string): TagsResponse { return cast(JSON.parse(json), r("TagsResponse")); } @@ -996,6 +1226,22 @@ export class Convert { public static tagsResponseToJson(value: TagsResponse): string { return JSON.stringify(uncast(value, r("TagsResponse")), null, 2); } + + public static toVoteQueueRequest(json: string): VoteQueueRequest { + return cast(JSON.parse(json), r("VoteQueueRequest")); + } + + public static voteQueueRequestToJson(value: VoteQueueRequest): string { + return JSON.stringify(uncast(value, r("VoteQueueRequest")), null, 2); + } + + public static toVoteQueueResponse(json: string): VoteQueueResponse { + return cast(JSON.parse(json), r("VoteQueueResponse")); + } + + public static voteQueueResponseToJson(value: VoteQueueResponse): string { + return JSON.stringify(uncast(value, r("VoteQueueResponse")), null, 2); + } } function invalidValue(typ: any, val: any, key: any, parent: any = ""): never { @@ -1181,6 +1427,46 @@ function r(name: string) { } const typeMap: any = { + ArtistCandidatesRequest: o( + [ + { json: "identifier", js: "identifier", typ: "" }, + { json: "query", js: "query", typ: u(undefined, u(null, "")) }, + ], + false + ), + ArtistCandidatesResponse: o( + [{ json: "results", js: "results", typ: a(u(r("CanonicalArtist"), null)) }], + false + ), + CanonicalArtist: o([{ json: "name", js: "name", typ: "" }], false), + ArtistConsensusRequest: o( + [{ json: "identifier", js: "identifier", typ: "" }], + false + ), + ArtistConsensusResponse: o( + [ + { json: "isUnknown", js: "isUnknown", typ: true }, + { + json: "resolvedArtist", + js: "resolvedArtist", + typ: u(undefined, u(r("CanonicalArtist"), null)), + }, + { json: "voteTally", js: "voteTally", typ: a(r("ArtistVoteTallyEntry")) }, + ], + false + ), + ArtistVoteTallyEntry: o( + [ + { + json: "artist", + js: "artist", + typ: u(undefined, u(r("CanonicalArtist"), null)), + }, + { json: "count", js: "count", typ: 0 }, + { json: "isUnknown", js: "isUnknown", typ: true }, + ], + false + ), CardbacksRequest: o( [ { @@ -1252,6 +1538,16 @@ const typeMap: any = { js: "canonicalArtist", typ: u(undefined, u(r("CanonicalArtist"), null)), }, + { + json: "canonicalArtistIsFromVoteOnly", + js: "canonicalArtistIsFromVoteOnly", + typ: u(undefined, true), + }, + { + json: "canonicalArtistSource", + js: "canonicalArtistSource", + typ: u(undefined, u(null, "")), + }, { json: "canonicalCard", js: "canonicalCard", @@ -1288,7 +1584,6 @@ const typeMap: any = { ], false ), - CanonicalArtist: o([{ json: "name", js: "name", typ: "" }], false), CanonicalCard: o( [ { json: "artist", js: "artist", typ: u(undefined, "") }, @@ -1583,6 +1878,15 @@ const typeMap: any = { [{ json: "results", js: "results", typ: m(r("Source")) }], false ), + SubmitArtistVoteRequest: o( + [ + { json: "anonymousId", js: "anonymousId", typ: "" }, + { json: "artistName", js: "artistName", typ: u(undefined, u(null, "")) }, + { json: "identifier", js: "identifier", typ: "" }, + { json: "isUnknown", js: "isUnknown", typ: true }, + ], + false + ), SubmitPrintingTagRequest: o( [ { json: "anonymousId", js: "anonymousId", typ: "" }, @@ -1596,6 +1900,42 @@ const typeMap: any = { ], false ), + SubmitTagVoteRequest: o( + [ + { json: "anonymousId", js: "anonymousId", typ: "" }, + { json: "identifier", js: "identifier", typ: "" }, + { json: "polarity", js: "polarity", typ: 0 }, + { json: "tagName", js: "tagName", typ: "" }, + ], + false + ), + TagConsensusRequest: o( + [{ json: "identifier", js: "identifier", typ: "" }], + false + ), + TagConsensusResponse: o( + [{ json: "tags", js: "tags", typ: a(r("TagConsensusEntry")) }], + false + ), + TagConsensusEntry: o( + [ + { + json: "resolvedPolarity", + js: "resolvedPolarity", + typ: u(undefined, u(0, null)), + }, + { json: "tagName", js: "tagName", typ: "" }, + { json: "tally", js: "tally", typ: a(r("TagVoteTallyEntry")) }, + ], + false + ), + TagVoteTallyEntry: o( + [ + { json: "count", js: "count", typ: 0 }, + { json: "polarity", js: "polarity", typ: 0 }, + ], + false + ), TagsResponse: o([{ json: "tags", js: "tags", typ: a(r("Tag")) }], false), Tag: o( [ @@ -1625,6 +1965,28 @@ const typeMap: any = { ], false ), + VoteQueueRequest: o( + [ + { json: "kind", js: "kind", typ: r("Kind") }, + { json: "page", js: "page", typ: 0 }, + ], + false + ), + VoteQueueResponse: o( + [ + { json: "hits", js: "hits", typ: 0 }, + { json: "items", js: "items", typ: a(r("VoteQueueItem")) }, + { json: "pages", js: "pages", typ: 0 }, + ], + false + ), + VoteQueueItem: o( + [ + { json: "card", js: "card", typ: r("Card") }, + { json: "tagName", js: "tagName", typ: u(undefined, u(null, "")) }, + ], + false + ), Game: ["MTG"], CardType: ["CARD", "CARDBACK", "TOKEN"], SourceType: ["AWS S3", "Google Drive", "Local File"], @@ -1636,4 +1998,5 @@ const typeMap: any = { "nameAscending", "nameDescending", ], + Kind: ["artist", "printing", "tag"], }; diff --git a/frontend/src/common/test-constants.ts b/frontend/src/common/test-constants.ts index cfc21115a..7e270827a 100644 --- a/frontend/src/common/test-constants.ts +++ b/frontend/src/common/test-constants.ts @@ -4,6 +4,7 @@ import { Card, MaximumDPI, MaximumSize, MinimumDPI } from "@/common/constants"; import { + CanonicalArtist, CardType as CardTypeSchema, PrintingCandidate, SourceType, @@ -489,4 +490,7 @@ export const printingCandidate2: PrintingCandidate = { releasedAt: "2010-06-15", }; +export const canonicalArtist1: CanonicalArtist = { name: "Some Artist" }; +export const canonicalArtist2: CanonicalArtist = { name: "Another Artist" }; + //# endregion diff --git a/frontend/src/features/attributeVoting/ArtistVotePicker.tsx b/frontend/src/features/attributeVoting/ArtistVotePicker.tsx new file mode 100644 index 000000000..22a90a91e --- /dev/null +++ b/frontend/src/features/attributeVoting/ArtistVotePicker.tsx @@ -0,0 +1,195 @@ +/** + * Lets a user tag which artist illustrated a card, or mark it as an unlisted/unknown artist. + * Shown as part of AttributeVotingPanel, once a card's printing-tag consensus hasn't resolved + * a printing (see that component for the trigger condition) - mirrors PrintingTagPicker.tsx's + * fetch/submit structure, but candidates here are plain named chips (CanonicalArtist has no + * thumbnail image) rather than thumbnail buttons. + */ + +import React, { useEffect, useState } from "react"; +import Button from "react-bootstrap/Button"; +import Col from "react-bootstrap/Col"; +import Form from "react-bootstrap/Form"; +import Row from "react-bootstrap/Row"; + +import { getOrCreateAnonymousId } from "@/common/cookies"; +import { + ArtistConsensusResponse, + CanonicalArtist, +} from "@/common/schema_types"; +import { useAppDispatch } from "@/common/types"; +import { + APIGetArtistCandidates, + APIGetArtistConsensus, + APISubmitArtistVote, +} from "@/store/api"; +import { setNotification } from "@/store/slices/toastsSlice"; + +interface ArtistVotePickerProps { + backendURL: string; + cardIdentifier: string; + /** + * The card's already-known artist name, if confidently known independent of any artist + * vote - callers derive this from their own Card/CardDocument object as + * `canonicalArtist != null && !canonicalArtistIsFromVoteOnly ? canonicalArtist.name : null` + * (that derivation can't happen inside this component, since it only ever has the card's + * *identifier*, not the full card object). When set, renders as pre-filled text with a + * small "wrong?" link that reveals the full picker on click, instead of soliciting a vote + * outright for an artist that's already confidently known from indexing or a resolved + * printing. Omitted or null: today's unconditional-picker behavior is unchanged. + */ + confidentlyKnownArtistName?: string | null; +} + +export function ArtistVotePicker({ + backendURL, + cardIdentifier, + confidentlyKnownArtistName, +}: ArtistVotePickerProps) { + const dispatch = useAppDispatch(); + + const [query, setQuery] = useState(""); + const [candidates, setCandidates] = useState>([]); + const [consensus, setConsensus] = useState( + null + ); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [revealPickerAnyway, setRevealPickerAnyway] = useState(false); + + useEffect(() => { + APIGetArtistConsensus(backendURL, cardIdentifier) + .then(setConsensus) + .catch(() => undefined); + }, [backendURL, cardIdentifier]); + + useEffect(() => { + setLoading(true); + APIGetArtistCandidates(backendURL, cardIdentifier, query || undefined) + .then((response) => + setCandidates( + response.results.filter( + (candidate): candidate is CanonicalArtist => candidate != null + ) + ) + ) + .catch(() => setCandidates([])) + .finally(() => setLoading(false)); + }, [backendURL, cardIdentifier, query]); + + const submit = (artistName: string | undefined, isUnknown: boolean) => { + setSubmitting(true); + APISubmitArtistVote( + backendURL, + cardIdentifier, + getOrCreateAnonymousId(), + artistName, + isUnknown + ) + .then((response) => { + setConsensus(response); + dispatch( + setNotification([ + Math.random().toString(), + { + name: "Vote submitted", + message: "Thanks for helping tag this card's artist!", + level: "info", + }, + ]) + ); + }) + .catch(() => + dispatch( + setNotification([ + Math.random().toString(), + { + name: "Vote failed", + message: + "Something went wrong submitting your vote - please try again.", + level: "error", + }, + ]) + ) + ) + .finally(() => setSubmitting(false)); + }; + + if (confidentlyKnownArtistName != null && !revealPickerAnyway) { + return ( + + ); + } + + return ( +
+
+ {consensus == null && "Loading current consensus..."} + {consensus != null && consensus.resolvedArtist != null && ( + Current consensus: {consensus.resolvedArtist.name} + )} + {consensus != null && + consensus.resolvedArtist == null && + consensus.isUnknown && Current consensus: unknown artist} + {consensus != null && + consensus.resolvedArtist == null && + !consensus.isUnknown && ( + + Not yet resolved + {consensus.voteTally.length > 0 ? " - contested" : ""} + + )} +
+ setQuery(event.target.value)} + /> + {loading ? ( +
Loading candidates...
+ ) : ( + + + + + {candidates.map((candidate) => ( + + + + ))} + + )} +
+ ); +} diff --git a/frontend/src/features/attributeVoting/AttributeVotingPanel.tsx b/frontend/src/features/attributeVoting/AttributeVotingPanel.tsx new file mode 100644 index 000000000..d2746e996 --- /dev/null +++ b/frontend/src/features/attributeVoting/AttributeVotingPanel.tsx @@ -0,0 +1,38 @@ +/** + * Follow-up voting panel shown once a card's printing-tag consensus hasn't resolved a + * printing (see the two call sites - CardDetailedViewModal.tsx and PrintingTagQueue.tsx - for + * the exact trigger condition, which is the same in both: `resolvedPrinting == null`, covering + * both an explicit "no match" vote and a plain not-yet-resolved card). One panel, two + * independently optional/skippable sections - artist and tags - rather than a modal chain. + */ + +import React from "react"; + +import { ArtistVotePicker } from "@/features/attributeVoting/ArtistVotePicker"; +import { TagVotePicker } from "@/features/attributeVoting/TagVotePicker"; + +interface AttributeVotingPanelProps { + backendURL: string; + cardIdentifier: string; + /** Threaded straight through to ArtistVotePicker - see that component's own prop docstring. */ + confidentlyKnownArtistName?: string | null; +} + +export function AttributeVotingPanel({ + backendURL, + cardIdentifier, + confidentlyKnownArtistName, +}: AttributeVotingPanelProps) { + return ( +
+
Who's the artist?
+ +
Do any of these tags apply?
+ +
+ ); +} diff --git a/frontend/src/features/attributeVoting/GenericVoteQueue.tsx b/frontend/src/features/attributeVoting/GenericVoteQueue.tsx new file mode 100644 index 000000000..8f4b9ac13 --- /dev/null +++ b/frontend/src/features/attributeVoting/GenericVoteQueue.tsx @@ -0,0 +1,178 @@ +/** + * Shared queue shell for the artist and tag modes of the "Who's That Planeswalker?" vote + * queue, driven by the generalized `2/voteQueue/` endpoint. Printing mode is deliberately NOT + * folded into this - it keeps using PrintingTagQueue.tsx and `2/printingTagQueue/` completely + * unchanged (its own data source/shape, and its exact existing behavior is a hard constraint), + * so this component only ever needs to handle the two new kinds, which do share one response + * shape (`VoteQueueItem[]`) and can reasonably share one fetch/pagination/flavor-text + * implementation instead of duplicating it twice. + */ + +import React, { useEffect, useState } from "react"; +import Button from "react-bootstrap/Button"; +import Col from "react-bootstrap/Col"; +import Row from "react-bootstrap/Row"; + +import { Kind, VoteQueueItem } from "@/common/schema_types"; +import { useAppSelector } from "@/common/types"; +import { Spinner } from "@/components/Spinner"; +import { ArtistVotePicker } from "@/features/attributeVoting/ArtistVotePicker"; +import { QueueTagQuestion } from "@/features/attributeVoting/QueueTagQuestion"; +import { APIGetVoteQueue } from "@/store/api"; +import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; + +const FLAVOR_TEXT = [ + "Your spark ignites! On to the next mystery.", + "A planeswalker's eye for detail - nicely done!", + "The multiverse is a little better catalogued because of you.", + "Sharper than a Sphinx's riddle. Next card incoming!", + "That's the stuff legends are made of. Keep going!", + "Another printing pinned down. Onward, planeswalker!", + "You've got a good spark for this. Next!", + "Precisely the kind of insight the Multiverse needs.", + "Well walked, planeswalker. Here comes another.", + "Your knowledge of the planes grows ever stronger.", +]; + +function randomFlavorText(): string { + return FLAVOR_TEXT[Math.floor(Math.random() * FLAVOR_TEXT.length)]; +} + +interface GenericVoteQueueProps { + kind: typeof Kind.Artist | typeof Kind.Tag; + label: string; +} + +export function GenericVoteQueue({ kind, label }: GenericVoteQueueProps) { + const backendURL = useAppSelector(selectRemoteBackendURL); + + const [queueItems, setQueueItems] = useState>([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(1); + const [hits, setHits] = useState(0); + const [loadingQueue, setLoadingQueue] = useState(true); + const [flavorText, setFlavorText] = useState(null); + const fetchedPagesRef = React.useRef>(new Set()); + + const currentItem = queueItems[currentIndex] ?? null; + const queueExhausted = + !loadingQueue && currentIndex >= queueItems.length && page >= pages; + + // reset the locally-held queue whenever the kind changes (switching tabs) + useEffect(() => { + setQueueItems([]); + setCurrentIndex(0); + setPage(1); + setPages(1); + setHits(0); + fetchedPagesRef.current = new Set(); + }, [kind]); + + useEffect(() => { + if (backendURL == null) { + return; + } + if (currentIndex < queueItems.length) { + return; + } + if (queueItems.length > 0 && page >= pages) { + return; + } + const nextPage = queueItems.length === 0 ? 1 : page + 1; + if (fetchedPagesRef.current.has(nextPage)) { + return; + } + fetchedPagesRef.current.add(nextPage); + setLoadingQueue(true); + APIGetVoteQueue(backendURL, kind, nextPage) + .then((response) => { + setQueueItems((previous) => [...previous, ...response.items]); + setHits(response.hits); + setPages(response.pages); + setPage(nextPage); + }) + .catch(() => undefined) + .finally(() => setLoadingQueue(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [backendURL, kind, currentIndex, queueItems.length, page, pages]); + + const advance = () => { + setFlavorText(randomFlavorText()); + setCurrentIndex((previous) => previous + 1); + }; + + if (queueExhausted) { + return ( +
+

+ You're all caught up - no cards left to tag right now! +

+ {flavorText != null && ( +

+ {flavorText} +

+ )} +
+ ); + } + + return ( +
+

+ Still need {label}: {hits} card{hits !== 1 && "s"} +

+ {flavorText != null && ( +

+ {flavorText} +

+ )} + {currentItem == null || backendURL == null ? ( +
+ +
+ ) : ( +
+ + + {currentItem.card.name} +
{currentItem.card.name}
+ + + {kind === Kind.Tag && currentItem.tagName != null ? ( + + ) : ( + <> + +
+ +
+ + )} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/features/attributeVoting/QueueTagQuestion.tsx b/frontend/src/features/attributeVoting/QueueTagQuestion.tsx new file mode 100644 index 000000000..5a1ca0aa9 --- /dev/null +++ b/frontend/src/features/attributeVoting/QueueTagQuestion.tsx @@ -0,0 +1,93 @@ +/** + * Focused single-(card, tag)-question control for the tag-mode vote queue - deliberately not + * a reuse of TagVotePicker.tsx's full chip grid (which shows every seeded tag at once for one + * card - a different unit of interaction). Each queue item here is exactly one contested/ + * unresolved (card, tag) pair, so this only ever asks about that one tag: apply, not + * applicable, or skip. Submits via the same APISubmitTagVote used by TagVotePicker. + */ + +import React, { useState } from "react"; +import Button from "react-bootstrap/Button"; + +import { getOrCreateAnonymousId } from "@/common/cookies"; +import { useAppDispatch } from "@/common/types"; +import { APISubmitTagVote } from "@/store/api"; +import { setNotification } from "@/store/slices/toastsSlice"; + +interface QueueTagQuestionProps { + backendURL: string; + cardIdentifier: string; + tagName: string; + /** Called once the user has answered (apply/not applicable submitted successfully) or skipped. */ + onAnswered: () => void; +} + +const APPLY = 1; +const NOT_APPLICABLE = -1; + +export function QueueTagQuestion({ + backendURL, + cardIdentifier, + tagName, + onAnswered, +}: QueueTagQuestionProps) { + const dispatch = useAppDispatch(); + const [submitting, setSubmitting] = useState(false); + + const submit = (polarity: number) => { + setSubmitting(true); + APISubmitTagVote( + backendURL, + cardIdentifier, + getOrCreateAnonymousId(), + tagName, + polarity + ) + .then(() => onAnswered()) + .catch(() => + dispatch( + setNotification([ + Math.random().toString(), + { + name: "Vote failed", + message: + "Something went wrong submitting your vote - please try again.", + level: "error", + }, + ]) + ) + ) + .finally(() => setSubmitting(false)); + }; + + return ( +
+
+ Does {tagName} apply? +
+
+ + + +
+
+ ); +} diff --git a/frontend/src/features/attributeVoting/TagVotePicker.tsx b/frontend/src/features/attributeVoting/TagVotePicker.tsx new file mode 100644 index 000000000..d855fc90f --- /dev/null +++ b/frontend/src/features/attributeVoting/TagVotePicker.tsx @@ -0,0 +1,112 @@ +/** + * Lets a user vote on whether each seeded descriptor Tag applies to a card - tri-state toggle + * chips: unvoted (no resolved consensus for this tag on this card yet), applied (resolved + * APPLY - clicking again votes NOT_APPLICABLE), or crossed-out (resolved NOT_APPLICABLE - + * clicking again votes APPLY). Unlike printing/artist voting, a card can carry independent, + * simultaneous votes across many different tags at once, so each chip submits its own vote + * immediately on click rather than requiring one shared "submit" action. + */ + +import React, { useEffect, useState } from "react"; +import Badge from "react-bootstrap/Badge"; + +import { getOrCreateAnonymousId } from "@/common/cookies"; +import { TagConsensusResponse } from "@/common/schema_types"; +import { useAppDispatch } from "@/common/types"; +import { APIGetTagConsensus, APISubmitTagVote } from "@/store/api"; +import { setNotification } from "@/store/slices/toastsSlice"; + +interface TagVotePickerProps { + backendURL: string; + cardIdentifier: string; +} + +const APPLY = 1; +const NOT_APPLICABLE = -1; + +export function TagVotePicker({ + backendURL, + cardIdentifier, +}: TagVotePickerProps) { + const dispatch = useAppDispatch(); + + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [submittingTagName, setSubmittingTagName] = useState( + null + ); + + useEffect(() => { + setLoading(true); + APIGetTagConsensus(backendURL, cardIdentifier) + .then((response) => setEntries(response.tags)) + .catch(() => setEntries([])) + .finally(() => setLoading(false)); + }, [backendURL, cardIdentifier]); + + const submit = (tagName: string, currentPolarity?: number | null) => { + const nextPolarity = currentPolarity === APPLY ? NOT_APPLICABLE : APPLY; + setSubmittingTagName(tagName); + APISubmitTagVote( + backendURL, + cardIdentifier, + getOrCreateAnonymousId(), + tagName, + nextPolarity + ) + .then((updatedEntry) => { + setEntries((previous) => + previous.map((entry) => + entry.tagName === tagName ? updatedEntry : entry + ) + ); + }) + .catch(() => + dispatch( + setNotification([ + Math.random().toString(), + { + name: "Vote failed", + message: + "Something went wrong submitting your vote - please try again.", + level: "error", + }, + ]) + ) + ) + .finally(() => setSubmittingTagName(null)); + }; + + if (loading) { + return
Loading tags...
; + } + + return ( +
+ {entries.map((entry) => ( + submit(entry.tagName, entry.resolvedPolarity)} + > + {entry.tagName} + + ))} +
+ ); +} diff --git a/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx b/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx index e34e47c4b..002598629 100644 --- a/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx +++ b/frontend/src/features/cardDetailedView/CardDetailedViewModal.tsx @@ -4,19 +4,21 @@ * some more information (e.g. size, dote uploaded, etc.), and a button to download the full res image. */ -import React, { memo } from "react"; +import React, { memo, useState } from "react"; import Badge from "react-bootstrap/Badge"; import Button from "react-bootstrap/Button"; import Modal from "react-bootstrap/Modal"; import Row from "react-bootstrap/Row"; import { getCardDataAttributes } from "@/common/cardDom"; -import { CardDocument, useAppDispatch } from "@/common/types"; +import { PrintingConsensusResponse } from "@/common/schema_types"; +import { CardDocument, useAppDispatch, useAppSelector } from "@/common/types"; import { imageSizeToMBString, toTitleCase } from "@/common/utils"; import { AutofillTable } from "@/components/AutofillTable"; import { ClickToCopy } from "@/components/ClickToCopy"; import DisableSSR from "@/components/DisableSSR"; import { RightPaddedIcon } from "@/components/icon"; +import { AttributeVotingPanel } from "@/features/attributeVoting/AttributeVotingPanel"; import { AddCardToFavorites } from "@/features/card/AddCardToFavorites"; import { AddCardToProjectForm } from "@/features/card/AddCardToProjectForm"; import { @@ -26,6 +28,7 @@ import { import { useDoImageDownload } from "@/features/download/downloadImages"; import { PrintingTagPicker } from "@/features/printingTags/PrintingTagPicker"; import { useGetLanguagesQuery } from "@/store/api"; +import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; import { setNotification } from "@/store/slices/toastsSlice"; interface CardDetailedViewProps { @@ -47,6 +50,10 @@ export function CardDetailedViewModal({ const dispatch = useAppDispatch(); const queueImageDownload = useDoImageDownload(); const getLanguagesQuery = useGetLanguagesQuery(); + const backendURL = useAppSelector(selectRemoteBackendURL); + + const [printingConsensus, setPrintingConsensus] = + useState(null); //# endregion @@ -180,7 +187,25 @@ export function CardDetailedViewModal({ + {printingConsensus != null && + printingConsensus.resolvedPrinting == null && + backendURL != null && ( + <> +
+ + + )} diff --git a/frontend/src/features/printingTags/PrintingTagPicker.tsx b/frontend/src/features/printingTags/PrintingTagPicker.tsx index e63614dcb..bce4d9658 100644 --- a/frontend/src/features/printingTags/PrintingTagPicker.tsx +++ b/frontend/src/features/printingTags/PrintingTagPicker.tsx @@ -39,11 +39,18 @@ interface PrintingTagPickerProps { cardIdentifier: string; /** The name of the card being tagged. */ cardName: string; + /** + * Notified whenever this component's own consensus state changes (initial fetch and after + * each vote submission) - lets a parent (e.g. CardDetailedViewModal) decide whether to show + * the attribute-voting follow-up panel without this component needing to know about it. + */ + onConsensusChange?: (consensus: PrintingConsensusResponse | null) => void; } export function PrintingTagPicker({ cardIdentifier, cardName, + onConsensusChange, }: PrintingTagPickerProps) { const dispatch = useAppDispatch(); const backendURL = useAppSelector(selectRemoteBackendURL); @@ -61,8 +68,12 @@ export function PrintingTagPicker({ return; } APIGetPrintingConsensus(backendURL, cardIdentifier) - .then(setConsensus) + .then((response) => { + setConsensus(response); + onConsensusChange?.(response); + }) .catch(() => undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [backendURL, cardIdentifier]); useEffect(() => { @@ -93,6 +104,7 @@ export function PrintingTagPicker({ ) .then((response) => { setConsensus(response); + onConsensusChange?.(response); dispatch( setNotification([ Math.random().toString(), diff --git a/frontend/src/features/printingTags/PrintingTagQueue.tsx b/frontend/src/features/printingTags/PrintingTagQueue.tsx index ef5da4e56..d73aa721a 100644 --- a/frontend/src/features/printingTags/PrintingTagQueue.tsx +++ b/frontend/src/features/printingTags/PrintingTagQueue.tsx @@ -26,6 +26,7 @@ import { } from "@/common/schema_types"; import { CardDocument, useAppDispatch, useAppSelector } from "@/common/types"; import { Spinner } from "@/components/Spinner"; +import { AttributeVotingPanel } from "@/features/attributeVoting/AttributeVotingPanel"; import { STARBURST_INNER_COLOR, STARBURST_INNER_FRAMES, @@ -360,6 +361,10 @@ export function PrintingTagQueue() { const [submitting, setSubmitting] = useState(false); const [flavorText, setFlavorText] = useState(null); const [revealed, setRevealed] = useState(false); + // whether the user has taken a printing-tag action (vote or no-match) on the *current* + // card this session - the attribute-voting follow-up panel is step 2 after that action, + // not shown alongside the initial printing picker on a card the user hasn't touched yet. + const [votedThisCard, setVotedThisCard] = useState(false); // guards the fetch effect below against React 18 Strict Mode's dev-time double-invoke, // which would otherwise append the same page's cards twice const fetchedPagesRef = React.useRef>(new Set()); @@ -401,9 +406,10 @@ export function PrintingTagQueue() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [backendURL, currentIndex, queueCards.length, page, pages]); - // reset the reveal animation for each new card + // reset the reveal animation and attribute-voting step for each new card useEffect(() => { setRevealed(false); + setVotedThisCard(false); }, [currentCard?.identifier]); useEffect(() => { @@ -452,7 +458,15 @@ export function PrintingTagQueue() { printingIdentifier, isNoMatch ) - .then(() => advance()) + .then((response) => { + setConsensus(response); + setVotedThisCard(true); + // if this vote itself resolved the printing (e.g. it broke a tie), there's nothing + // left to ask about - advance immediately rather than showing the attribute panel. + if (response.resolvedPrinting != null) { + advance(); + } + }) .catch(() => dispatch( setNotification([ @@ -645,6 +659,17 @@ export function PrintingTagQueue() { ))} + {votedThisCard && + consensus?.resolvedPrinting == null && + backendURL != null && ( +
+
+ +
+ )}
+ {votedThisCard && consensus?.resolvedPrinting == null && ( + + )}
)} diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index fc8a3f66a..5d27aa521 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -9,6 +9,8 @@ import { SupporterTier, } from "@/common/schema_types"; import { + canonicalArtist1, + canonicalArtist2, cardDocument1, cardDocument2, cardDocument3, @@ -714,6 +716,102 @@ export const printingTagQueueNoResults = http.get( () => HttpResponse.json({ hits: 0, pages: 1, cards: [] }, { status: 200 }) ); +// 2/voteQueue/ is shared by all three kinds (kind is in the POST body, not the URL), so this +// one handler branches on it rather than registering three separate handlers for the same route. +export const voteQueueArtistOneTagOneResults = http.post( + buildRoute("2/voteQueue/"), + async ({ request }) => { + const body = (await request.json()) as { kind: string; page: number }; + if (body.kind === "artist") { + return HttpResponse.json( + { hits: 1, pages: 1, items: [{ card: cardDocument8, tagName: null }] }, + { status: 200 } + ); + } + if (body.kind === "tag") { + return HttpResponse.json( + { + hits: 1, + pages: 1, + // deliberately a different card than the printing/artist mock fixtures use - the + // printing tab's mount is never torn down when switching away (matches its + // existing, unchanged behavior), so reusing the same card here would produce two + // simultaneous elements with the same alt text once the tag tab is active + items: [{ card: cardDocument9, tagName: "Borderless" }], + }, + { status: 200 } + ); + } + return HttpResponse.json({ hits: 0, pages: 1, items: [] }, { status: 200 }); + } +); + +export const voteQueueNoResults = http.post(buildRoute("2/voteQueue/"), () => + HttpResponse.json({ hits: 0, pages: 1, items: [] }, { status: 200 }) +); + +//# endregion + +//# region attribute voting + +export const artistCandidatesTwoResults = http.post( + buildRoute("2/artistCandidates/"), + () => + HttpResponse.json( + { results: [canonicalArtist1, canonicalArtist2] }, + { status: 200 } + ) +); + +export const artistConsensusUnresolved = http.post( + buildRoute("2/artistConsensus/"), + () => + HttpResponse.json( + { resolvedArtist: null, isUnknown: false, voteTally: [] }, + { status: 200 } + ) +); + +export const submitArtistVoteResolvesToCanonicalArtist1 = http.post( + buildRoute("2/submitArtistVote/"), + () => + HttpResponse.json( + { + resolvedArtist: canonicalArtist1, + isUnknown: false, + voteTally: [{ artist: canonicalArtist1, isUnknown: false, count: 1 }], + }, + { status: 200 } + ) +); + +export const tagConsensusTwoUnresolvedTags = http.post( + buildRoute("2/tagConsensus/"), + () => + HttpResponse.json( + { + tags: [ + { tagName: "Borderless", resolvedPolarity: null, tally: [] }, + { tagName: "Extended", resolvedPolarity: null, tally: [] }, + ], + }, + { status: 200 } + ) +); + +export const submitTagVoteResolvesToApply = http.post( + buildRoute("2/submitTagVote/"), + () => + HttpResponse.json( + { + tagName: "Borderless", + resolvedPolarity: 1, + tally: [{ polarity: 1, count: 1 }], + }, + { status: 200 } + ) +); + //# endregion //# region presets diff --git a/frontend/src/pages/printingQueue.tsx b/frontend/src/pages/printingQueue.tsx index b4bae1121..8309c8cc5 100644 --- a/frontend/src/pages/printingQueue.tsx +++ b/frontend/src/pages/printingQueue.tsx @@ -1,9 +1,13 @@ import styled from "@emotion/styled"; import Head from "next/head"; -import React from "react"; +import React, { useState } from "react"; +import Nav from "react-bootstrap/Nav"; +import Tab from "react-bootstrap/Tab"; import { ContentMaxWidth, ProjectName } from "@/common/constants"; +import { Kind } from "@/common/schema_types"; import { NoBackendDefault } from "@/components/NoBackendDefault"; +import { GenericVoteQueue } from "@/features/attributeVoting/GenericVoteQueue"; import { PrintingTagQueue } from "@/features/printingTags/PrintingTagQueue"; import { STARBURST_BACKGROUND_COLOR } from "@/features/printingTags/starburstShape"; import Footer from "@/features/ui/Footer"; @@ -13,6 +17,8 @@ import { useRemoteBackendConfigured, } from "@/store/slices/backendSlice"; +type VoteQueueTab = "printing" | "artist" | "tag"; + // "Who's That Pokemon?" style radiating starburst behind the game itself - a jagged // "explosion" burst (see starburstShape.ts, rendered inside PrintingTagQueue.tsx alongside // the subject card so the two stay glued together under position: sticky as the page @@ -63,6 +69,7 @@ const StarburstContent = styled.div` function PrintingQueueOrDefault() { const remoteBackendConfigured = useRemoteBackendConfigured(); + const [activeTab, setActiveTab] = useState("printing"); return remoteBackendConfigured ? ( <> @@ -71,10 +78,48 @@ function PrintingQueueOrDefault() {

Who's That Planeswalker?

Test your Magic: the Gathering knowledge! One card at a time, help - identify which real-world printing each card image depicts - - contested cards come first, since they need your eyes the most. + identify which real-world printing, artist, or descriptor tag each + card image depicts - contested cards come first, since they need + your eyes the most.

- + { + if (key) setActiveTab(key as VoteQueueTab); + }} + > + + + {/* mountOnEnter: each mode's queue does its own fetching/pagination on mount - + no need to pay that cost for tabs the user hasn't visited yet. Printing + mode's own component/behavior is completely unchanged from before this tab + switcher existed. unmountOnExit on the two new tabs (not printing, to avoid + touching its existing behavior at all): without it, react-bootstrap keeps an + already-visited pane's DOM mounted (just hidden) rather than removing it, + which both leaves an inactive tab's queue silently polling for more pages in + the background and produces duplicate data-testid="vote-queue" elements in + the DOM at once. */} + + + + + + + + + + +