Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions MPCAutofill/cardpicker/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
# Register your models here.
@admin.register(Tag)
class AdminTag(admin.ModelAdmin[Tag]):
list_display = ("name",)
search_fields = ("name",)
list_display = ("name", "display_name")
search_fields = ("name", "display_name")


@admin.register(Card)
Expand Down
58 changes: 38 additions & 20 deletions MPCAutofill/cardpicker/default_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,61 @@
`cardpicker.printing_candidates`'s `expansion_hint` handling for those instead.
"""

from typing import Optional

from cardpicker.models import Tag

DEFAULT_TAGS: list[tuple[str, list[str]]] = [
("Borderless", ["Borderless Art"]),
("Popout", []),
("Extended", []),
("Showcase", []),
("Retro", []),
("Classic", []),
("Anime", []),
("Full Art", ["Fullart"]),
("Custom", []),
("Upscaled", ["Upscaled Scan"]),
("Placeholder", []),
("Token", []),
("AI-Generated", ["Midjourney"]),
# (name, aliases, display_name). display_name is None for almost every entry here - these
# names are already nice human-readable Title Case, so they fall back to rendering as `name`
# with no further seeding needed (see frontend useTagDisplayName). The two entries that do
# specify one ("Full Art", "Borderless") aren't renamed or given a *different* display_name -
# they're just given an explicit row so the taxonomy has no silent gaps, matching every other
# actively-displayed tag having a real display_name value rather than relying on fallback.
DEFAULT_TAGS: list[tuple[str, list[str], Optional[str]]] = [
("Borderless", ["Borderless Art"], "Borderless"),
("Popout", [], None),
("Extended", [], None),
("Showcase", [], None),
("Retro", [], None),
("Classic", [], None),
("Anime", [], None),
("Full Art", ["Fullart"], "Full Art"),
("Custom", [], None),
("Upscaled", ["Upscaled Scan"], None),
("Placeholder", [], None),
("Token", [], None),
("AI-Generated", ["Midjourney"], None),
]


def seed_default_tags() -> dict[str, int]:
"""
Idempotent - safe to re-run. Creates any tag that doesn't exist yet, and adds any
alias from `DEFAULT_TAGS` that's missing from an already-existing tag (e.g. if it
was previously created by hand, or by an earlier version of this list).
Idempotent - safe to re-run. Creates any tag that doesn't exist yet, adds any alias from
`DEFAULT_TAGS` that's missing from an already-existing tag, and backfills display_name for
the (few) entries above that specify one - only when it's still null, never overwriting a
manually-edited display_name (see Tag.display_name's help_text: "freely editable").
"""

created = 0
updated = 0
for name, aliases in DEFAULT_TAGS:
tag, was_created = Tag.objects.get_or_create(name=name, defaults={"aliases": aliases})
for name, aliases, display_name in DEFAULT_TAGS:
defaults: dict[str, object] = {"aliases": aliases}
if display_name is not None:
defaults["display_name"] = display_name
tag, was_created = Tag.objects.get_or_create(name=name, defaults=defaults)
if was_created:
created += 1
continue
changed_fields = []
missing_aliases = [alias for alias in aliases if alias not in tag.aliases]
if missing_aliases:
tag.aliases = [*tag.aliases, *missing_aliases]
tag.save(update_fields=["aliases"])
changed_fields.append("aliases")
if display_name is not None and tag.display_name is None:
tag.display_name = display_name
changed_fields.append("display_name")
if changed_fields:
tag.save(update_fields=changed_fields)
updated += 1
return {"created": created, "updated": updated}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ class Command(BaseCommand):

def handle(self, *args: Any, **kwargs: Any) -> None:
stats = seed_no_match_reason_tags()
print(f"No-match reason tags: {stats['created']} created.")
print(f"No-match reason tags: {stats['created']} created, {stats['updated']} display_name backfilled.")
23 changes: 23 additions & 0 deletions MPCAutofill/cardpicker/migrations/0056_tag_display_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-07-14 10:08

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("cardpicker", "0055_card_tag_vote_statuses_alter_card_artist_vote_status"),
]

operations = [
migrations.AddField(
model_name="tag",
name="display_name",
field=models.CharField(
blank=True,
help_text="Presentation only — freely editable. `name` is the immutable machine key used by votes, tag_vote_statuses, Card.tags, and federation; NEVER rename a Tag after creation.",
max_length=200,
null=True,
),
),
]
9 changes: 9 additions & 0 deletions MPCAutofill/cardpicker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,14 @@ def __str__(self) -> str:

class Tag(models.Model):
name = models.CharField(unique=True)
display_name = models.CharField(
max_length=200,
null=True,
blank=True,
help_text="Presentation only — freely editable. `name` is the immutable machine key "
"used by votes, tag_vote_statuses, Card.tags, and federation; NEVER rename a Tag "
"after creation.",
)
# null=True is just for admin panel
aliases = ArrayField(models.CharField(max_length=200), default=list, blank=True)
is_enabled_by_default = models.BooleanField(default=True)
Expand All @@ -638,6 +646,7 @@ def __str__(self) -> str:
def serialise(self) -> SerialisedTag:
return SerialisedTag(
name=self.name,
displayName=self.display_name,
aliases=self.aliases,
isEnabledByDefault=self.is_enabled_by_default,
parent=(self.parent.name if self.parent else None),
Expand Down
37 changes: 24 additions & 13 deletions MPCAutofill/cardpicker/reason_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,40 @@

from cardpicker.models import Tag

NO_MATCH_REASON_TAGS: list[tuple[str, str]] = [
("custom-art", "Original or alternate artwork - does not depict a real printing"),
("altered-frame", "Real printing's art in a modified frame"),
("upscaled", "AI-upscaled version of an official image"),
("ai-art", "AI-generated artwork"),
("no-collector-line", "No legible collector line on the card face"),
("non-english", "Non-English printing"),
# (name, description, display_name). `description` is documentation only (no DB column for
# it - see Tag.display_name's help_text); `display_name` is real, seeded presentation text
# the frontend looks up dynamically (useTagDisplayName) rather than hardcoding, so this list
# is the single source of truth for both the machine key and its human label.
NO_MATCH_REASON_TAGS: list[tuple[str, str, str]] = [
("custom-art", "Original or alternate artwork - does not depict a real printing", "Custom art"),
("altered-frame", "Real printing's art in a modified frame", "Altered frame"),
("upscaled", "AI-upscaled version of an official image", "Upscaled"),
("ai-art", "AI-generated artwork", "AI art"),
("no-collector-line", "No legible collector line on the card face", "No collector line"),
("non-english", "Non-English printing", "Non-English"),
]


def seed_no_match_reason_tags() -> dict[str, int]:
"""
Idempotent - safe to re-run. Creates any tag that doesn't exist yet. `Tag` has no
description field (see cardpicker.models.Tag) - the descriptions above are documentation
only, mirrored as display copy in the frontend's NoMatchReasonStrip.tsx.
Idempotent - safe to re-run. Creates any tag that doesn't exist yet (display_name set at
creation), and backfills display_name on an already-existing tag only if it's still null
- never overwrites a manually-edited display_name (see Tag.display_name's help_text:
"freely editable").
"""

created = 0
for name, _description in NO_MATCH_REASON_TAGS:
_tag, was_created = Tag.objects.get_or_create(name=name, defaults={"aliases": []})
updated = 0
for name, _description, display_name in NO_MATCH_REASON_TAGS:
tag, was_created = Tag.objects.get_or_create(name=name, defaults={"aliases": [], "display_name": display_name})
if was_created:
created += 1
return {"created": created}
continue
if tag.display_name is None:
tag.display_name = display_name
tag.save(update_fields=["display_name"])
updated += 1
return {"created": created, "updated": updated}


__all__ = ["seed_no_match_reason_tags", "NO_MATCH_REASON_TAGS"]
12 changes: 10 additions & 2 deletions MPCAutofill/cardpicker/schema_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,7 @@ class ChildElement(BaseModel):
children: List["ChildElement"]
name: str
aliases: Optional[List[str]] = None
displayName: Optional[str] = None
isEnabledByDefault: Optional[bool] = None
parent: Optional[str] = None

Expand All @@ -1560,16 +1561,19 @@ def from_dict(obj: Any) -> "ChildElement":
children = from_list(ChildElement.from_dict, obj.get("children"))
name = from_str(obj.get("name"))
aliases = from_union([lambda x: from_list(from_str, x), from_none], obj.get("aliases"))
displayName = from_union([from_none, from_str], obj.get("displayName"))
isEnabledByDefault = from_union([from_bool, from_none], obj.get("isEnabledByDefault"))
parent = from_union([from_none, from_str], obj.get("parent"))
return ChildElement(children, name, aliases, isEnabledByDefault, parent)
return ChildElement(children, name, aliases, displayName, isEnabledByDefault, parent)

def to_dict(self) -> dict:
result: dict = {}
result["children"] = from_list(lambda x: to_class(ChildElement, x), self.children)
result["name"] = from_str(self.name)
if self.aliases is not None:
result["aliases"] = from_union([lambda x: from_list(from_str, x), from_none], self.aliases)
if self.displayName is not None:
result["displayName"] = from_union([from_none, from_str], self.displayName)
if self.isEnabledByDefault is not None:
result["isEnabledByDefault"] = from_union([from_bool, from_none], self.isEnabledByDefault)
result["parent"] = from_union([from_none, from_str], self.parent)
Expand All @@ -1580,6 +1584,7 @@ class Tag(BaseModel):
children: List[ChildElement]
name: str
aliases: Optional[List[str]] = None
displayName: Optional[str] = None
isEnabledByDefault: Optional[bool] = None
parent: Optional[str] = None

Expand All @@ -1589,16 +1594,19 @@ def from_dict(obj: Any) -> "Tag":
children = from_list(ChildElement.from_dict, obj.get("children"))
name = from_str(obj.get("name"))
aliases = from_union([lambda x: from_list(from_str, x), from_none], obj.get("aliases"))
displayName = from_union([from_none, from_str], obj.get("displayName"))
isEnabledByDefault = from_union([from_bool, from_none], obj.get("isEnabledByDefault"))
parent = from_union([from_none, from_str], obj.get("parent"))
return Tag(children, name, aliases, isEnabledByDefault, parent)
return Tag(children, name, aliases, displayName, isEnabledByDefault, parent)

def to_dict(self) -> dict:
result: dict = {}
result["children"] = from_list(lambda x: to_class(ChildElement, x), self.children)
result["name"] = from_str(self.name)
if self.aliases is not None:
result["aliases"] = from_union([lambda x: from_list(from_str, x), from_none], self.aliases)
if self.displayName is not None:
result["displayName"] = from_union([from_none, from_str], self.displayName)
if self.isEnabledByDefault is not None:
result["isEnabledByDefault"] = from_union([from_bool, from_none], self.isEnabledByDefault)
result["parent"] = from_union([from_none, from_str], self.parent)
Expand Down
38 changes: 38 additions & 0 deletions MPCAutofill/cardpicker/tests/test_default_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from cardpicker.default_tags import DEFAULT_TAGS, seed_default_tags
from cardpicker.models import Tag


class TestSeedDefaultTags:
def test_creates_every_tag(self, db):
stats = seed_default_tags()
assert stats["created"] == len(DEFAULT_TAGS)
assert Tag.objects.count() == len(DEFAULT_TAGS)

def test_display_name_only_set_for_full_art_and_borderless(self, db):
seed_default_tags()
assert Tag.objects.get(name="Full Art").display_name == "Full Art"
assert Tag.objects.get(name="Borderless").display_name == "Borderless"
# every other seeded tag has no display_name - it's already a nice Title Case
# `name`, so frontend fallback (displayName ?? name) covers it without a seeded row
others = Tag.objects.exclude(name__in=["Full Art", "Borderless"])
assert all(tag.display_name is None for tag in others)

def test_rerunning_does_not_duplicate_or_reclobber(self, db):
seed_default_tags()
stats = seed_default_tags()
assert stats["created"] == 0
assert stats["updated"] == 0
assert Tag.objects.count() == len(DEFAULT_TAGS)

def test_backfills_display_name_only_when_null_never_overwrites_manual_edit(self, db):
Tag.objects.create(name="Full Art", aliases=[], display_name=None)

stats = seed_default_tags()

assert stats["created"] == len(DEFAULT_TAGS) - 1
assert stats["updated"] == 1
assert Tag.objects.get(name="Full Art").display_name == "Full Art"

Tag.objects.filter(name="Full Art").update(display_name="Admin's custom label")
seed_default_tags()
assert Tag.objects.get(name="Full Art").display_name == "Admin's custom label"
37 changes: 29 additions & 8 deletions MPCAutofill/cardpicker/tests/test_reason_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,52 @@ def test_creates_all_six_reason_tags(self, db):
assert stats["created"] == len(NO_MATCH_REASON_TAGS)

names = set(
Tag.objects.filter(name__in=[name for name, _description in NO_MATCH_REASON_TAGS]).values_list(
"name", flat=True
)
Tag.objects.filter(
name__in=[name for name, _description, _display_name in NO_MATCH_REASON_TAGS]
).values_list("name", flat=True)
)
assert names == {name for name, _description in NO_MATCH_REASON_TAGS}
assert names == {name for name, _description, _display_name in NO_MATCH_REASON_TAGS}

def test_display_name_set_at_creation(self, db):
seed_no_match_reason_tags()
for name, _description, display_name in NO_MATCH_REASON_TAGS:
assert Tag.objects.get(name=name).display_name == display_name

def test_rerunning_does_not_duplicate(self, db):
seed_no_match_reason_tags()
count_after_first_run = Tag.objects.filter(
name__in=[name for name, _description in NO_MATCH_REASON_TAGS]
name__in=[name for name, _description, _display_name in NO_MATCH_REASON_TAGS]
).count()

stats = seed_no_match_reason_tags()

count_after_second_run = Tag.objects.filter(
name__in=[name for name, _description in NO_MATCH_REASON_TAGS]
name__in=[name for name, _description, _display_name in NO_MATCH_REASON_TAGS]
).count()
assert stats["created"] == 0
assert stats["updated"] == 0 # display_name already set on the first run, nothing left to backfill
assert count_after_first_run == count_after_second_run == len(NO_MATCH_REASON_TAGS)

def test_backfills_display_name_only_when_null_never_overwrites_manual_edit(self, db):
name, _description, seeded_display_name = NO_MATCH_REASON_TAGS[0]
Tag.objects.create(name=name, aliases=[], display_name=None)

stats = seed_no_match_reason_tags()

assert stats["created"] == len(NO_MATCH_REASON_TAGS) - 1
assert stats["updated"] == 1
assert Tag.objects.get(name=name).display_name == seeded_display_name

# a second run must never clobber a manual edit make after seeding
Tag.objects.filter(name=name).update(display_name="Admin's custom label")
seed_no_match_reason_tags()
assert Tag.objects.get(name=name).display_name == "Admin's custom label"

def test_reason_tags_are_case_distinct_from_default_tags(self, db):
# "upscaled" (reason tag) and "Upscaled" (DEFAULT_TAGS) are deliberately two separate
# rows covering related but distinct vote populations - see reason_tags.py's header
# comment. Exact-string collision (not just case-insensitive overlap) would mean
# seeding silently reused an existing row instead of creating a new one.
default_tag_names = {name for name, _aliases in DEFAULT_TAGS}
reason_tag_names = {name for name, _description in NO_MATCH_REASON_TAGS}
default_tag_names = {name for name, _aliases, _display_name in DEFAULT_TAGS}
reason_tag_names = {name for name, _description, _display_name in NO_MATCH_REASON_TAGS}
assert default_tag_names.isdisjoint(reason_tag_names)
Loading
Loading