diff --git a/backend/spellbook/admin.py b/backend/spellbook/admin.py index 3793b131..438d6752 100644 --- a/backend/spellbook/admin.py +++ b/backend/spellbook/admin.py @@ -17,12 +17,12 @@ class CardAdmin(admin.ModelAdmin): fieldsets = [ ('Spellbook', {'fields': ['name', 'features']}), - ('Scryfall', {'fields': ['oracle_id']}), + ('Scryfall', {'fields': ['oracle_id', 'identity']}), ] # inlines = [FeatureInline] search_fields = ['name', 'features__name'] autocomplete_fields = ['features'] - list_display = ['name', 'id'] + list_display = ['name', 'identity', 'id'] class CardInline(admin.StackedInline): @@ -72,9 +72,9 @@ def clean_mana_needed(self): @admin.register(Variant) class VariantAdmin(admin.ModelAdmin): form = VariantForm - readonly_fields = ['uses', 'produces', 'of', 'includes', 'unique_id'] + readonly_fields = ['uses', 'produces', 'of', 'includes', 'unique_id', 'identity'] fieldsets = [ - ('Generated', {'fields': ['unique_id', 'uses', 'produces', 'of', 'includes']}), + ('Generated', {'fields': ['unique_id', 'uses', 'produces', 'of', 'includes', 'identity']}), ('Editable', {'fields': [ 'status', 'zone_locations', @@ -84,9 +84,9 @@ class VariantAdmin(admin.ModelAdmin): 'description', 'frozen']}) ] - list_filter = ['status'] - list_display = ['__str__', 'status', 'id'] - search_fields = ['id', 'uses__name', 'produces__name', 'unique_id'] + list_filter = ['status', 'identity'] + list_display = ['__str__', 'status', 'id', 'identity'] + search_fields = ['id', 'uses__name', 'produces__name', 'unique_id', 'identity'] actions = [set_restore, set_draft, set_new, set_not_working] def generate(self, request): diff --git a/backend/spellbook/management/commands/import_combos.py b/backend/spellbook/management/commands/import_combos.py index 833b9bd9..1e5dfeed 100644 --- a/backend/spellbook/management/commands/import_combos.py +++ b/backend/spellbook/management/commands/import_combos.py @@ -2,7 +2,7 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen from django.core.management.base import BaseCommand -from spellbook.variants import unique_id_from_cards_ids +from spellbook.variants import unique_id_from_cards_ids, merge_identities from spellbook.models import Feature, Card, Job, Variant from django.utils import timezone from django.db.models import Count, Q @@ -89,17 +89,17 @@ def handle(self, *args, **options): except Card.DoesNotExist: q = Card.objects.filter(name=data['name']) if q.exists(): - q.update(oracle_id=data['oracle_id']) + q.update(oracle_id=data['oracle_id'], identity=merge_identities(data['color_identity'])) else: - Card.objects.create(name=data['name'], oracle_id=data['oracle_id']) + Card.objects.create(name=data['name'], oracle_id=data['oracle_id'], identity=merge_identities(data['color_identity'])) self.stdout.write('Done fetching cards') self.stdout.write('Importing combos...') for i, (id, _cards, produced, prerequisite, description) in enumerate(x): self.stdout.write(f'{i+1}/{len(x)}') cards = [Card.objects.get(oracle_id=scryfall_db[card.lower()]['oracle_id']) for card in _cards] already_present = Variant.objects.annotate( - total_cards=Count('includes'), - matching_cards=Count('includes', filter=Q(includes__in=cards)), + total_cards=Count('uses'), + matching_cards=Count('uses', filter=Q(uses__in=cards)), ).filter( total_cards=len(cards), matching_cards=len(cards), @@ -107,7 +107,12 @@ def handle(self, *args, **options): if already_present.exists(): self.stdout.write(f'Skipping combo [{id}] {cards}: already present in variants') continue - combo = Variant(other_prerequisites=prerequisite, description=description, frozen=True, status=Variant.Status.OK, unique_id=unique_id_from_cards_ids([c.id for c in cards])) + combo = Variant(other_prerequisites=prerequisite, + description=description, + frozen=True, + status=Variant.Status.OK, + unique_id=unique_id_from_cards_ids([c.id for c in cards]), + identity=merge_identities([c.identity for c in cards])) combo.save() combo.uses.set(cards) for p in produced: @@ -126,4 +131,5 @@ def handle(self, *args, **options): job.status = Job.Status.FAILURE job.message = f'Failed to import combos: {e}' job.save() + print(e) raise e diff --git a/backend/spellbook/migrations/0003_card_identity_variant_identity_and_more.py b/backend/spellbook/migrations/0003_card_identity_variant_identity_and_more.py new file mode 100644 index 00000000..1889e955 --- /dev/null +++ b/backend/spellbook/migrations/0003_card_identity_variant_identity_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1 on 2022-10-01 22:24 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('spellbook', '0002_alter_combo_cards_state_alter_combo_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='card', + name='identity', + field=models.CharField(blank=True, help_text='Card mana identity', max_length=5, validators=[django.core.validators.RegexValidator(message='Can be any combination of zero or more letters in [W,U,B,R,G], in order.', regex='^W?U?B?R?G?$')], verbose_name='mana identity of card'), + ), + migrations.AddField( + model_name='variant', + name='identity', + field=models.CharField(blank=True, editable=False, help_text='Mana identity', max_length=5, validators=[django.core.validators.RegexValidator(message='Can be any combination of zero or more letters in [W,U,B,R,G], in order.', regex='^W?U?B?R?G?$')], verbose_name='mana identity'), + ), + migrations.AlterField( + model_name='variant', + name='description', + field=models.TextField(blank=True, help_text='Long description, in steps', validators=[django.core.validators.RegexValidator(message='Unpaired double square brackets are not allowed.', regex='^(?:[^\\[]*(?:\\[(?!\\[)|\\[{2}[^\\[]+\\]{2}|\\[{3,}))*[^\\[]*$'), django.core.validators.RegexValidator(message='Symbols must be in the {1}{W}{U}{B}{R}{G}{B/P}{A}{E}{T}{Q}... format.', regex='^(?:[^\\{]*\\{(?:[0-9WUBRGCPXYZSTQEA½∞]|PW|CHAOS|TK|[1-9][0-9]{1,2}|H[WUBRG]|(?:2\\/[WUBRG]|W\\/U|W\\/B|B\\/R|B\\/G|U\\/B|U\\/R|R\\/G|R\\/W|G\\/W|G\\/U)(?:\\/P)?)\\})*[^\\{]*$')]), + ), + ] diff --git a/backend/spellbook/models.py b/backend/spellbook/models.py index 59f010d3..59eb4d93 100644 --- a/backend/spellbook/models.py +++ b/backend/spellbook/models.py @@ -2,7 +2,7 @@ from django.db import OperationalError, models, transaction from sortedm2m.fields import SortedManyToManyField from django.contrib.auth.models import User -from .symbols import MANA_VALIDATOR, TEXT_VALIDATORS +from .symbols import MANA_VALIDATOR, TEXT_VALIDATORS, IDENTITY_VALIDATOR class Feature(models.Model): @@ -29,6 +29,7 @@ class Card(models.Model): related_name='cards', help_text='Features provided by this single card effects or characteristics', blank=True) + identity = models.CharField(max_length=5, blank=True, help_text='Card mana identity', verbose_name='mana identity of card', validators=[IDENTITY_VALIDATOR]) added = models.DateTimeField(auto_now_add=True, editable=False) updated = models.DateTimeField(auto_now=True, editable=False) @@ -123,11 +124,12 @@ class Status(models.TextChoices): cards_state = models.TextField(blank=True, default='', help_text='State of cards in their starting locations.', validators=TEXT_VALIDATORS, verbose_name='starting cards state') mana_needed = models.CharField(blank=True, max_length=200, default='', help_text='Mana needed for this combo. Use the {1}{W}{U}{B}{R}{G}{B/P}... format.', validators=[MANA_VALIDATOR]) other_prerequisites = models.TextField(blank=True, default='', help_text='Other prerequisites for this variant.', validators=TEXT_VALIDATORS) - description = models.TextField(blank=True, help_text='Long description of the variant, in steps', validators=TEXT_VALIDATORS) + description = models.TextField(blank=True, help_text='Long description, in steps', validators=TEXT_VALIDATORS) created = models.DateTimeField(auto_now_add=True, editable=False) updated = models.DateTimeField(auto_now=True, editable=False) unique_id = models.CharField(max_length=128, unique=True, blank=False, help_text='Unique ID for this variant', editable=False) frozen = models.BooleanField(default=False, blank=False, help_text='Is this variant undeletable?', verbose_name='is frozen') + identity = models.CharField(max_length=5, blank=True, help_text='Mana identity', verbose_name='mana identity', editable=False, validators=[IDENTITY_VALIDATOR]) class Meta: ordering = ['-status', '-created'] diff --git a/backend/spellbook/serializers.py b/backend/spellbook/serializers.py index 3e54108a..cf65b2fa 100644 --- a/backend/spellbook/serializers.py +++ b/backend/spellbook/serializers.py @@ -5,7 +5,7 @@ class CardSerializer(serializers.ModelSerializer): class Meta: model = Card - fields = ['id', 'name', 'oracle_id'] + fields = ['id', 'name', 'oracle_id', 'identity'] class FeatureSerializer(serializers.ModelSerializer): @@ -19,7 +19,7 @@ class CardDetailSerializer(serializers.ModelSerializer): class Meta: model = Card - fields = ['id', 'name', 'oracle_id', 'features'] + fields = ['id', 'name', 'oracle_id', 'identity', 'features'] class ComboSerializer(serializers.ModelSerializer): @@ -56,6 +56,7 @@ class Meta: 'produces', 'of', 'includes', + 'identity', 'zone_locations', 'cards_state', 'mana_needed', diff --git a/backend/spellbook/symbols.py b/backend/spellbook/symbols.py index c8651506..61a474bc 100644 --- a/backend/spellbook/symbols.py +++ b/backend/spellbook/symbols.py @@ -8,3 +8,5 @@ SYMBOLS_TEXT_REGEX = r'^(?:[^\{]*\{(?:[0-9WUBRGCPXYZSTQEA½∞]|PW|CHAOS|TK|[1-9][0-9]{1,2}|H[WUBRG]|(?:2\/[WUBRG]|W\/U|W\/B|B\/R|B\/G|U\/B|U\/R|R\/G|R\/W|G\/W|G\/U)(?:\/P)?)\})*[^\{]*$' SYMBOLS_TEXT_VALIDATOR = RegexValidator(regex=SYMBOLS_TEXT_REGEX, message='Symbols must be in the {1}{W}{U}{B}{R}{G}{B/P}{A}{E}{T}{Q}... format.') TEXT_VALIDATORS = [DOUBLE_SQUARE_BRACKET_TEXT_VALIDATOR, SYMBOLS_TEXT_VALIDATOR] +IDENTITY_REGEX = r'^W?U?B?R?G?$' +IDENTITY_VALIDATOR = RegexValidator(regex=IDENTITY_REGEX, message='Can be any combination of zero or more letters in [W,U,B,R,G], in order.') diff --git a/backend/spellbook/templates/admin/spellbook/card/change_form.html b/backend/spellbook/templates/admin/spellbook/card/change_form.html index 495a35c6..88d835a9 100644 --- a/backend/spellbook/templates/admin/spellbook/card/change_form.html +++ b/backend/spellbook/templates/admin/spellbook/card/change_form.html @@ -41,6 +41,8 @@ success: function(data) { let oracle_id = data.oracle_id; $('#id_oracle_id').val(oracle_id); + let color_identity = Array.from(new Set(Array.from(data.color_identity).map(i => i.toUpperCase()))).sort().join(''); + $('#id_identity').val(color_identity); } }) }, diff --git a/backend/spellbook/variants.py b/backend/spellbook/variants.py index c5f81dcf..44e69253 100644 --- a/backend/spellbook/variants.py +++ b/backend/spellbook/variants.py @@ -1,6 +1,7 @@ import json import hashlib import logging +from typing import Iterable import pyomo.environ as pyo from dataclasses import dataclass from itertools import starmap @@ -61,6 +62,11 @@ def removed_features(variant: Variant, features: set[int]) -> set[int]: return features - set(variant.includes.values_list('removes__id', flat=True)) +def merge_identities(identities: Iterable[str]): + i = set(''.join(identities).upper()) + return ''.join([color for color in 'WUBRG' if color in i]) + + def update_variant( data: Data, unique_id: str, @@ -73,18 +79,14 @@ def update_variant( variant.of.set(combos_that_generated) variant.includes.set(combos_included) variant.produces.set(removed_features(variant, features) - data.utility_features_ids) + variant.identity = merge_identities(variant.uses.values_list('identity', flat=True)) if restore: combos = data.combos.filter(id__in=combos_included) - zone_locations = '\n'.join(c.zone_locations for c in combos if len(c.zone_locations) > 0) - cards_state = '\n'.join(c.cards_state for c in combos if len(c.cards_state) > 0) - other_prerequisites = '\n'.join(c.other_prerequisites for c in combos if len(c.other_prerequisites) > 0) - mana_needed = ' '.join(c.mana_needed for c in combos if len(c.mana_needed) > 0) - description = '\n'.join(c.description for c in combos if len(c.description) > 0) - variant.zone_locations = zone_locations - variant.cards_state = cards_state - variant.other_prerequisites = other_prerequisites - variant.mana_needed = mana_needed - variant.description = description + variant.zone_locations = '\n'.join(c.zone_locations for c in combos if len(c.zone_locations) > 0) + variant.cards_state = '\n'.join(c.cards_state for c in combos if len(c.cards_state) > 0) + variant.other_prerequisites = '\n'.join(c.other_prerequisites for c in combos if len(c.other_prerequisites) > 0) + variant.mana_needed = ' '.join(c.mana_needed for c in combos if len(c.mana_needed) > 0) + variant.description = '\n'.join(c.description for c in combos if len(c.description) > 0) variant.status = Variant.Status.NEW if ok else Variant.Status.NOT_WORKING if not ok: variant.status = Variant.Status.NOT_WORKING @@ -113,7 +115,8 @@ def create_variant( cards_state=cards_state, other_prerequisites=other_prerequisites, mana_needed=mana_needed, - description=description) + description=description, + identity=merge_identities(data.cards.filter(id__in=cards).values_list('identity', flat=True))) if not ok: variant.status = Variant.Status.NOT_WORKING variant.save() diff --git a/backend/spellbook/views.py b/backend/spellbook/views.py index 7b880a80..768b9c2a 100644 --- a/backend/spellbook/views.py +++ b/backend/spellbook/views.py @@ -10,7 +10,7 @@ class VariantViewSet(viewsets.ReadOnlyModelViewSet): queryset = Variant.objects.filter(status=Variant.Status.OK) serializer_class = VariantSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - filterset_fields = ['unique_id', 'uses__id', 'includes__id', 'produces__id', 'of__id'] + filterset_fields = ['unique_id', 'uses__id', 'includes__id', 'produces__id', 'of__id', 'identity'] search_fields = ['uses__name', 'produces__name'] ordering_fields = ['created', 'updated', 'unique_id'] @@ -40,6 +40,7 @@ class ComboViewSet(viewsets.ReadOnlyModelViewSet): class CardViewSet(viewsets.ReadOnlyModelViewSet): queryset = Card.objects.all() serializer_class = CardDetailSerializer + filterset_fields = ['oracle_id', 'identity'] card_list = CardViewSet.as_view({'get': 'list'})