diff --git a/seed/0003_CardSetColorMappings.json b/seed/0003_CardSetColorMappings.json new file mode 100644 index 0000000..4f70324 --- /dev/null +++ b/seed/0003_CardSetColorMappings.json @@ -0,0 +1,79 @@ +[ + { + "model": "cards.cardsetcolormapping", + "pk": 1, + "fields": { + "cardset_id": "A3a", + "hex_color": "#FA1A1A", + "created_at": "2025-06-20T05:48:33.579Z", + "updated_at": "2025-06-20T06:07:05.636Z", + "deleted_at": null + } + }, + { + "model": "cards.cardsetcolormapping", + "pk": 2, + "fields": { + "cardset_id": "A3", + "hex_color": "#0B47C6", + "created_at": "2025-06-20T05:49:48.100Z", + "updated_at": "2025-06-20T06:06:37.342Z", + "deleted_at": null + } + }, + { + "model": "cards.cardsetcolormapping", + "pk": 3, + "fields": { + "cardset_id": "A2b", + "hex_color": "#B3D6EE", + "created_at": "2025-06-20T05:57:08.639Z", + "updated_at": "2025-06-20T06:06:19.207Z", + "deleted_at": null + } + }, + { + "model": "cards.cardsetcolormapping", + "pk": 4, + "fields": { + "cardset_id": "A2a", + "hex_color": "#EA9706", + "created_at": "2025-06-20T05:58:45.284Z", + "updated_at": "2025-06-20T06:05:40.057Z", + "deleted_at": null + } + }, + { + "model": "cards.cardsetcolormapping", + "pk": 5, + "fields": { + "cardset_id": "A2", + "hex_color": "#7A8696", + "created_at": "2025-06-20T05:59:26.177Z", + "updated_at": "2025-06-20T06:05:23.890Z", + "deleted_at": null + } + }, + { + "model": "cards.cardsetcolormapping", + "pk": 6, + "fields": { + "cardset_id": "A1a", + "hex_color": "#31DDAA", + "created_at": "2025-06-20T06:01:35.316Z", + "updated_at": "2025-06-20T06:05:06.221Z", + "deleted_at": null + } + }, + { + "model": "cards.cardsetcolormapping", + "pk": 7, + "fields": { + "cardset_id": "A1", + "hex_color": "#7911F0", + "created_at": "2025-06-20T06:03:51.759Z", + "updated_at": "2025-06-20T06:04:48.969Z", + "deleted_at": null + } + } +] \ No newline at end of file diff --git a/src/pkmntrade_club/cards/admin.py b/src/pkmntrade_club/cards/admin.py index 759cd4e..963a29c 100644 --- a/src/pkmntrade_club/cards/admin.py +++ b/src/pkmntrade_club/cards/admin.py @@ -22,6 +22,7 @@ from .models import ( AttackCost, Card, CardSet, + CardSetColorMapping, CardType, Energy, Pack, @@ -50,15 +51,28 @@ class PrefetchedSortedRelatedFieldListFilter(RelatedFieldListFilter): def parse_set_details(set_string): + # Handle filenames like 'a1-genetic-apex' + match = re.match(r"^([a-zA-Z0-9]+)-(.+)$", set_string) + if match: + set_id = match.group(1) + set_id = set_id[0].upper() + set_id[1:] + name = match.group(2).replace("-", " ").title() + return name, set_id + + # Handle 'promo' filename, assuming 'PRO' as a 3-char ID + if set_string == "promo": + return "Promo-A", "PA" + match = re.match(r"^(.*?)\s*\(([A-Za-z0-9]+)\)$", set_string) if match: name = match.group(1).strip() set_id = match.group(2) + set_id = set_id[0].upper() + set_id[1:] return name, set_id match = re.match(r"^Promo-(.*?)$", set_string) if match: name = set_string - set_id = "P-" + match.group(1) + set_id = "P" + match.group(1) return name, set_id return set_string, None @@ -207,7 +221,6 @@ def _update_card_packs(card_obj, card_data, card_set): defaults={ "name": pack_name_from_json, "full_name": pack_full_name, - "hex_color": "#FFFFFF", }, ) card_obj.packs.add(pack_obj) @@ -277,12 +290,15 @@ def _update_card_attacks_and_costs(card_obj, card_data): def _process_single_card_data( - card_data, card_set, stats_accumulator, error_tracking, rarity_mappings_dict + card_data, + card_set, + stats_accumulator, + error_tracking, + rarity_mappings_dict, ): """ - Processes a single card's data from the JSON. - Updates stats_accumulator with newly_imported_count, updated_count, or skipped_count. - error_tracking is a dict {'file_name': ..., 'card_id': ...} for precise error reporting. + Processes a single card's data from a JSON file, creating or updating + the card and its related objects in the database. """ card_id = card_data["id"] incoming_checksum = calculate_card_checksum(card_data) @@ -412,37 +428,30 @@ def _fetch_card_data_from_local_files(): def perform_card_import_logic() -> ImportResult: """ - Main importer logic. Iterates through JSON files and processes them. - In DEBUG mode, it reads from local files. Otherwise, fetches from a remote GitHub repo. - Halts and rolls back on any error. + Main logic to perform the card import process. + This can be triggered from an admin view or a management command. """ - print("Card import process started.") - result = ImportResult() - error_tracking = {"file_name": "N/A", "card_id": "N/A"} - - # Fetch all rarity mappings once - rarity_mappings = RarityMapping.objects.all() - rarity_mappings_dict = { - mapping.original_name: mapping for mapping in rarity_mappings - } - print(f"Loaded {len(rarity_mappings_dict)} rarity mappings.") - + stats = ImportResult() try: + # Step 1: Pre-fetch all RarityMapping and ColorMapping objects into dictionaries + rarity_mappings = {m.original_name: m for m in RarityMapping.objects.all()} + color_mappings = {m.cardset_id: m for m in CardSetColorMapping.objects.all()} + + # Step 2: Decide whether to fetch from GitHub or local files if settings.DEBUG: - card_data_iterator = _fetch_card_data_from_local_files() + card_files_source = _fetch_card_data_from_local_files() source_message = "local files" else: - # Fetch card data from the GitHub zip archive - card_data_iterator = _fetch_card_data_from_github_zip() + card_files_source = _fetch_card_data_from_github_zip() source_message = "the GitHub archive" - all_files_data = list(card_data_iterator) + all_files_data = list(card_files_source) total_files = len(all_files_data) if not all_files_data: - result.message = f"No JSON files found in {source_message} to import." - print(result.message) - return result + stats.message = f"No JSON files found in {source_message} to import." + print(stats.message) + return stats print(f"Found {total_files} JSON files to process from {source_message}.") @@ -454,83 +463,82 @@ def perform_card_import_logic() -> ImportResult: } for idx, (file_name, data) in enumerate(all_files_data): - error_tracking["file_name"] = file_name - error_tracking["card_id"] = "N/A" + error_tracking = {"file_name": file_name, "card_id": "N/A"} print(f"Processing file: {file_name} ({idx + 1}/{total_files})") if not data: - raise ValueError( - f"JSON file {file_name} is empty or contains no data." - ) + print(f"Skipping empty file: {file_name}") + continue - result.files_processed_count += 1 + stats.files_processed_count += 1 - first_card_data = data[0] - set_info_str = first_card_data.get("set") - if not set_info_str: - raise ValueError( - f"Could not determine set information from first card in {file_name}." - ) + set_name_from_file = os.path.splitext(file_name)[0] + parsed_set_name, parsed_set_id = parse_set_details(set_name_from_file) - parsed_set_name, parsed_set_id = parse_set_details(set_info_str) if not parsed_set_id: raise ValueError( - f"Could not parse set ID from '{set_info_str}' in {file_name}." + f"Could not parse set ID from file name '{file_name}'." ) card_set_defaults = {"name": parsed_set_name, "file_name": file_name} - card_set, _ = CardSet.objects.language("en").update_or_create( - id=parsed_set_id, defaults=card_set_defaults - ) + card_set, card_set_created = CardSet.objects.language( + "en" + ).update_or_create(id=parsed_set_id, defaults=card_set_defaults) + + # If the card set color mapping exists and is different, update it. + color_mapping = color_mappings.get(card_set.id) + if color_mapping and card_set.hex_color != color_mapping.hex_color: + card_set.hex_color = color_mapping.hex_color + card_set.save() for card_data_item in data: - print("Processing card: ", card_data_item["id"]) + # print("Processing card: ", card_data_item["id"]) # This is very verbose _process_single_card_data( card_data_item, card_set, stats_accumulator, error_tracking, - rarity_mappings_dict, + rarity_mappings, ) print(f"Finished processing file: {file_name}") - result.newly_imported_count = stats_accumulator["newly_imported_count"] - result.updated_count = stats_accumulator["updated_count"] - result.skipped_count = stats_accumulator["skipped_count"] + stats.newly_imported_count = stats_accumulator["newly_imported_count"] + stats.updated_count = stats_accumulator["updated_count"] + stats.skipped_count = stats_accumulator["skipped_count"] - result.message = ( - f"Import completed successfully. Processed {result.files_processed_count} files. " - f"Imported {result.newly_imported_count} new cards. " - f"Updated {result.updated_count} existing cards. " - f"Skipped {result.skipped_count} unchanged cards." + stats.message = ( + f"Import completed successfully. Processed {stats.files_processed_count} files. " + f"Imported {stats.newly_imported_count} new cards. " + f"Updated {stats.updated_count} existing cards. " + f"Skipped {stats.skipped_count} unchanged cards." ) print("Committing transaction.") - transaction.on_commit(lambda: print(result.message)) - return result + transaction.on_commit(lambda: print(stats.message)) + return stats except requests.exceptions.RequestException as e: # Handle network-related errors for the download - result.has_error = True - result.message = f"Failed to download card data from GitHub: {e}" - print(result.message) - return result + stats.has_error = True + stats.message = f"Failed to download card data from GitHub: {e}" + print(stats.message) + return stats except Exception as e: # Any other exception during the process will cause the transaction to roll back. error_detail = f"Error during import (file: {error_tracking['file_name']}, card: {error_tracking['card_id']}): {str(e)}" - result.has_error = True - result.message = ( + stats.has_error = True + stats.message = ( f"Import HALTED. All changes rolled back. Reason: {error_detail}" ) - print(result.message) - return result + print(stats.message) + return stats @admin.register(CardSet) class CardSetAdmin(TranslatableAdmin): - list_display = ("id", "name", "file_name") + list_display = ("id", "name", "file_name", "hex_color") search_fields = ("translations__name",) readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at") @@ -540,7 +548,7 @@ class CardSetAdmin(TranslatableAdmin): @admin.register(Pack) class PackAdmin(TranslatableAdmin): - list_display = ("id", "full_name", "name", "cardset", "hex_color") + list_display = ("id", "full_name", "name", "cardset") list_filter = ("cardset",) search_fields = ("translations__name", "translations__full_name") readonly_fields = ("id", "created_at", "updated_at") @@ -664,6 +672,19 @@ class RarityMappingAdmin(admin.ModelAdmin): readonly_fields = ("created_at", "updated_at", "deleted_at") +@admin.register(CardSetColorMapping) +class CardSetColorMappingAdmin(admin.ModelAdmin): + list_display = ( + "cardset_id", + "hex_color", + "created_at", + "updated_at", + "deleted_at", + ) + search_fields = ("cardset_id", "hex_color") + readonly_fields = ("created_at", "updated_at", "deleted_at") + + def get_admin_urls(urls): def importer_view(request): context = { diff --git a/src/pkmntrade_club/cards/migrations/0001_initial.py b/src/pkmntrade_club/cards/migrations/0001_initial.py index 1536cf4..d25a66e 100644 --- a/src/pkmntrade_club/cards/migrations/0001_initial.py +++ b/src/pkmntrade_club/cards/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2025-06-15 03:44 +# Generated by Django 5.1 on 2025-06-20 07:14 import django.db.models.deletion import parler.fields @@ -79,6 +79,35 @@ class Migration(migrations.Migration): }, bases=(parler.models.TranslatableModelMixin, models.Model), ), + migrations.CreateModel( + name="CardSetColorMapping", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ( + "cardset_id", + models.CharField( + help_text="The cardset ID to match (e.g., 'A1').", + max_length=10, + unique=True, + ), + ), + ( + "hex_color", + models.CharField( + help_text="The hex color code to use for this cardset.", + max_length=9, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "verbose_name": "Cardset Color Mapping", + "verbose_name_plural": "Cardset Color Mappings", + "ordering": ["cardset_id"], + }, + ), migrations.CreateModel( name="CardType", fields=[ @@ -216,7 +245,6 @@ class Migration(migrations.Migration): name="Pack", fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), - ("hex_color", models.CharField(max_length=9)), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ("deleted_at", models.DateTimeField(blank=True, null=True)), @@ -271,14 +299,6 @@ class Migration(migrations.Migration): null=True, ), ), - ( - "style", - models.CharField( - blank=True, - help_text="Inline CSS style for the card, used for dynamic styling.", - max_length=255, - ), - ), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ("deleted_at", models.DateTimeField(blank=True, null=True)), @@ -470,6 +490,15 @@ class Migration(migrations.Migration): max_length=32, ), ), + ( + "hex_color", + models.CharField( + blank=True, + help_text="The hex color code associated with this card set.", + max_length=9, + null=True, + ), + ), ( "master", parler.fields.TranslationsForeignKey( diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index 7e05f57..92c0601 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -42,7 +42,13 @@ class CardSet(TranslatableModel): name=models.CharField( max_length=32, help_text=_("The full name of the set, e.g., 'Genetic Apex'."), - ) + ), + hex_color=models.CharField( + max_length=9, + null=True, + blank=True, + help_text=_("The hex color code associated with this card set."), + ), ) id = models.CharField( max_length=3, @@ -80,7 +86,6 @@ class Pack(TranslatableModel): ), ) id = models.AutoField(primary_key=True) - hex_color = models.CharField(max_length=9) cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="packs") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -302,12 +307,6 @@ class Card(TranslatableModel): attacks = models.ManyToManyField(Attack, related_name="cards") rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, related_name="cards") - style = models.CharField( - max_length=255, - blank=True, - help_text=_("Inline CSS style for the card, used for dynamic styling."), - ) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) deleted_at = models.DateTimeField(null=True, blank=True) @@ -354,3 +353,31 @@ class RarityMapping(models.Model): def __str__(self): return f"'{self.original_name}' -> '{self.mapped_name}' (L{self.level}, {self.icon})" + + +class CardSetColorMapping(models.Model): + """ + Maps a cardset ID to a hex color code. This is used to pre-map cardset + colors when an original hex color is not available from the import source. + """ + + id = models.AutoField(primary_key=True) + cardset_id = models.CharField( + max_length=10, + unique=True, + help_text=_("The cardset ID to match (e.g., 'A1')."), + ) + hex_color = models.CharField( + max_length=9, help_text=_("The hex color code to use for this cardset.") + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = _("Cardset Color Mapping") + verbose_name_plural = _("Cardset Color Mappings") + ordering = ["cardset_id"] + + def __str__(self): + return f"'{self.cardset_id}' -> '{self.hex_color}'" diff --git a/src/pkmntrade_club/cards/signals.py b/src/pkmntrade_club/cards/signals.py index 64afbee..ac03177 100644 --- a/src/pkmntrade_club/cards/signals.py +++ b/src/pkmntrade_club/cards/signals.py @@ -1,12 +1,3 @@ -from django.core.cache import cache -from django.db.models.signals import m2m_changed, post_delete, post_save -from django.dispatch import receiver - -from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard - -from .models import Card - - def color_is_dark(bg_color): """ Determine if a given hexadecimal color is dark. @@ -36,70 +27,3 @@ def color_is_dark(bg_color): brightness = (r * 0.299) + (g * 0.587) + (b * 0.114) return brightness <= 200 - - -@receiver(m2m_changed, sender=Card.packs.through) -def update_card_style(sender, instance, action, **kwargs): - if action == "post_add": - packs = instance.packs.all() - num_packs = packs.count() - - style_parts = [] - - if num_packs == 0: - style_parts.append( - "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);" - ) - style_parts.append("text-shadow: 0 0 0 #fff;") - else: - if num_packs == 1: - style_parts.append(f"background-color: {packs.first().hex_color};") - else: # num_packs >= 2 - hex_colors = [pack.hex_color for pack in packs] - gradient = f"linear-gradient(to right, {', '.join(hex_colors)})" - style_parts.append(f"background: {gradient};") - - if not color_is_dark(packs.first().hex_color): - style_parts.append("color: var(--color-gray-700);") - style_parts.append("text-shadow: 0 0 0 var(--color-gray-700);") - else: - style_parts.append("text-shadow: 0 0 0 #fff;") - - instance.style = "".join(style_parts) - instance.save(update_fields=["style"]) - - -def invalidate_card_cache(instance): - """Invalidate cache for a card.""" - # Invalidate the card_badge cache - # The key is constructed as "card_badge_" - cache.delete(f"card_badge_{instance.pk}") - - # Invalidate card_multiselect cache by clearing all of them using a pattern. - # This is necessary as we can't easily reconstruct all possible keys in the signal. - cache.delete_pattern("card_multiselect_*") - - # Invalidate trade offers that contain this card in either have or want lists. - have_offers_pks = TradeOfferHaveCard.objects.filter(card=instance).values_list( - "trade_offer_id", flat=True - ) - want_offers_pks = TradeOfferWantCard.objects.filter(card=instance).values_list( - "trade_offer_id", flat=True - ) - - all_offer_pks = set(have_offers_pks) | set(want_offers_pks) - - for offer_pk in all_offer_pks: - cache.delete(f"trade_offer_{offer_pk}") - - -@receiver(post_save, sender=Card) -def on_card_save(sender, instance, **kwargs): - """Invalidate cache for a card when it's updated.""" - invalidate_card_cache(instance) - - -@receiver(post_delete, sender=Card) -def on_card_delete(sender, instance, **kwargs): - """Invalidate cache for a card when it's deleted.""" - invalidate_card_cache(instance) diff --git a/src/pkmntrade_club/cards/templatetags/card_badge.py b/src/pkmntrade_club/cards/templatetags/card_badge.py index 23c5d6c..c626cd9 100644 --- a/src/pkmntrade_club/cards/templatetags/card_badge.py +++ b/src/pkmntrade_club/cards/templatetags/card_badge.py @@ -7,21 +7,46 @@ from django.utils.safestring import mark_safe register = template.Library() +def _get_gradient_style(hex_color): + """ + Generates a gradient style from a single hex color. + """ + if not hex_color: + return "" + + try: + hex_color = hex_color.lstrip("#") + r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + # Create a slightly darker shade for the gradient + darker_r = max(0, r - 30) + darker_g = max(0, g - 30) + darker_b = max(0, b - 30) + darker_color = f"#{darker_r:02x}{darker_g:02x}{darker_b:02x}" + + return f"background-image: linear-gradient(to bottom right, #{hex_color}, {darker_color});" + except (ValueError, IndexError): + return "" + + @register.inclusion_tag("templatetags/card_badge.html", takes_context=True) -def card_badge(context, card, quantity=None, expanded=False): +def card_badge(context, card, quantity=None, expanded=False, clickable=True): """ Renders a card badge. """ - url = reverse_lazy("cards:card_detail", args=[card.pk]) + url = reverse_lazy("cards:detail", args=[card.pk]) + style = _get_gradient_style(card.cardset.hex_color) tag_context = { "quantity": quantity, - "style": card.style, + "style": style, "name": card.name, "rarity": card.rarity.icon, "cardset": card.cardset.id, "expanded": expanded, - "cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}", + "cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}_{card.cardset.hex_color}", "url": url, + "clickable": clickable, + "closeable": True, "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, } context.update(tag_context) @@ -33,15 +58,16 @@ def card_badge_inline(card, quantity=None): """ Renders an inline card badge by directly rendering the template. """ - url = reverse_lazy("cards:card_detail", args=[card.pk]) + url = reverse_lazy("cards:detail", args=[card.pk]) + style = _get_gradient_style(card.cardset.hex_color) tag_context = { "quantity": quantity, - "style": card.style, + "style": style, "name": card.name, "rarity": card.rarity, "cardset": card.cardset, "expanded": True, - "cache_key": f"card_badge_{card.pk}_{quantity}_{True}", + "cache_key": f"card_badge_{card.pk}_{quantity}_{True}_{card.cardset.hex_color}", "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, "url": url, } diff --git a/src/pkmntrade_club/cards/templatetags/card_multiselect.py b/src/pkmntrade_club/cards/templatetags/card_multiselect.py index 7f616ef..e0d0fc7 100644 --- a/src/pkmntrade_club/cards/templatetags/card_multiselect.py +++ b/src/pkmntrade_club/cards/templatetags/card_multiselect.py @@ -1,10 +1,10 @@ -import hashlib import json -import logging -import uuid from django import template -from django.db.models.query import QuerySet +from django.conf import settings +from django.db.models import QuerySet +from django.template.loader import render_to_string +from django.urls import reverse from pkmntrade_club.cards.models import Card @@ -28,57 +28,60 @@ def card_multiselect( context, field_name, label, placeholder, cards=None, selected_values=None ): """ - Prepares context for rendering a card multiselect input. - Database querying and rendering are handled within the template's cache block. + Renders a card multiselect widget with client-side searching. + + For the JS-driven component, it prepares initial selected cards. + For the non-JS fallback, it accepts a `cards` queryset from a + server-side search. """ if selected_values is None: selected_values = [] - selected_cards = {} - for val in selected_values: - parts = str(val).split(":") - if len(parts) >= 1 and parts[0]: - card_id = parts[0] - quantity = parts[1] if len(parts) > 1 else 1 - selected_cards[str(card_id)] = quantity + # Fetch full objects for any pre-selected cards for initial display. + initial_selected_cards = [] + if selected_values: + card_ids = [str(val) for val in selected_values] + initial_selected_cards = list( + Card.objects.with_details().filter(id__in=card_ids) + ) - effective_field_name = field_name if field_name is not None else "card_multiselect" - effective_label = label if label is not None else "Card" - effective_placeholder = placeholder if placeholder is not None else "Select Cards" + # `cards` is for the non-JS fallback search result. + non_js_search_results = cards if isinstance(cards, QuerySet) else [] - selected_cards_key_part = json.dumps(selected_cards, sort_keys=True) + initial_cards_data = [] + for card in initial_selected_cards: + badge_context = { + "card": card, + "quantity": None, + "expanded": False, + "url": reverse("cards:detail", args=[card.pk]), + "style": card.style, + "name": card.name, + "rarity": card.rarity.icon if card.rarity else "", + "cardset": card.cardset.id, + "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, + } + initial_cards_data.append( + { + "id": str(card.id), + "name": card.name, + "set_name": card.cardset.name, + "html": render_to_string("templatetags/card_badge.html", badge_context), + } + ) - has_passed_cards = isinstance(cards, QuerySet) + initial_selected_cards_json = json.dumps(initial_cards_data) - if has_passed_cards: - try: - query_string = str(cards.query) - passed_cards_identifier = hashlib.sha256( - query_string.encode("utf-8") - ).hexdigest() - except Exception as e: - logging.warning( - f"Could not generate query hash for card_multiselect. Error: {e}" - ) - passed_cards_identifier = "specific_qs_fallback_" + str(uuid.uuid4()) - else: - passed_cards_identifier = "all_cards" - - # Define the variables specific to this tag - tag_specific_context = { - "field_name": effective_field_name, - "field_id": effective_field_name, - "label": effective_label, - "placeholder": effective_placeholder, - "passed_cards": cards if has_passed_cards else None, - "has_passed_cards": has_passed_cards, - "selected_cards": selected_cards, - "selected_cards_key_part": selected_cards_key_part, - "passed_cards_identifier": passed_cards_identifier, - } - - # Update the original context with the tag-specific variables - # This preserves CACHE_TIMEOUT and other parent context variables - context.update(tag_specific_context) - - return context # Return the MODIFIED original context + context.update( + { + "field_name": field_name, + "field_id": f"id_{field_name}", + "label": label, + "placeholder": placeholder, + "initial_selected_cards": initial_selected_cards, + "initial_selected_cards_json": initial_selected_cards_json, + "non_js_search_results": non_js_search_results, + "has_non_js_results": bool(non_js_search_results), + } + ) + return context diff --git a/src/pkmntrade_club/cards/urls.py b/src/pkmntrade_club/cards/urls.py index 3338d08..8c0e3bf 100644 --- a/src/pkmntrade_club/cards/urls.py +++ b/src/pkmntrade_club/cards/urls.py @@ -1,16 +1,19 @@ from django.urls import path + from .views import ( CardDetailView, + CardListView, TradeOfferHaveCardListView, TradeOfferWantCardListView, - CardListView, + card_search, ) app_name = "cards" urlpatterns = [ - path("", CardListView.as_view(), name="card_list"), - path("/", CardDetailView.as_view(), name="card_detail"), + path("", view=CardListView.as_view(), name="list"), + path("/", view=CardDetailView.as_view(), name="detail"), + path("api/search/", card_search, name="api_search"), path( "/trade-offers-have/", TradeOfferHaveCardListView.as_view(), diff --git a/src/pkmntrade_club/cards/views.py b/src/pkmntrade_club/cards/views.py index e5f7457..515f070 100644 --- a/src/pkmntrade_club/cards/views.py +++ b/src/pkmntrade_club/cards/views.py @@ -1,4 +1,9 @@ +from django.conf import settings +from django.db.models import Q +from django.http import JsonResponse from django.shortcuts import get_object_or_404, render +from django.template.loader import render_to_string +from django.urls import reverse from django.views import View from django.views.generic import ( DetailView, @@ -169,3 +174,49 @@ class CardListView(ReusablePaginationMixin, ListView): context["page_obj"] = pagination_context context["object_list"] = queryset return context + + +def card_search(request): + """ + Searches for cards and returns a JSON list of objects, where each object + contains card data and its pre-rendered HTML badge. + """ + query = request.GET.get("q", "").strip() + results = [] + if query and len(query) >= 2: + selected_ids = request.GET.getlist("selected_ids[]") + + cards = ( + Card.objects.with_details() + .filter( + Q(translations__name__icontains=query) + | Q(cardset__translations__name__icontains=query) + ) + .exclude(id__in=selected_ids) + .order_by("translations__name", "cardset__translations__name")[:20] + ) + + for card in cards: + # Prepare the full context required by the card_badge.html template + badge_context = { + "card": card, + "quantity": None, + "expanded": True, + "clickable": False, + "url": reverse("cards:detail", args=[card.pk]), + "name": card.name, + "rarity": card.rarity.icon if card.rarity else "", + "cardset": card.cardset.id, + "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, + } + results.append( + { + "id": str(card.id), + "name": card.name, + "set_name": card.cardset.name, + "html": render_to_string( + "templatetags/card_badge.html", badge_context + ), + } + ) + return JsonResponse(results, safe=False) diff --git a/src/pkmntrade_club/theme/templates/base.html b/src/pkmntrade_club/theme/templates/base.html index c97662f..f07fed0 100644 --- a/src/pkmntrade_club/theme/templates/base.html +++ b/src/pkmntrade_club/theme/templates/base.html @@ -1,7 +1,7 @@ {% load static tailwind_tags gravatar %} {% url 'home' as home_url %} {% url 'trade_offer_list' as trade_offer_list_url %} -{% url 'cards:card_list' as cards_list_url %} +{% url 'cards:list' as cards_list_url %} {% url 'dashboard' as dashboard_url %} @@ -65,7 +65,7 @@ diff --git a/src/pkmntrade_club/theme/templates/home/_card_list.html b/src/pkmntrade_club/theme/templates/home/_card_list.html index 0681317..2669cca 100644 --- a/src/pkmntrade_club/theme/templates/home/_card_list.html +++ b/src/pkmntrade_club/theme/templates/home/_card_list.html @@ -3,7 +3,6 @@
{% for card in cards %} {% card_badge card quantity=card.offer_count expanded=True %} -
{{ card.rarity.icon }}
{% endfor %}
{% else %} diff --git a/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html b/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html deleted file mode 100644 index 1361ea7..0000000 --- a/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html +++ /dev/null @@ -1,32 +0,0 @@ -{% load card_badge card_multiselect %} - -{% for card in cards_to_render %} - {% with card_id_str=card.pk|stringformat:"s" %} {# Ensure card PK is string for lookup #} - {% if card_id_str in selected_cards %} - - {% else %} - - {% endif %} - {% endwith %} -{% endfor %} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templatetags/card_badge.html b/src/pkmntrade_club/theme/templatetags/card_badge.html index f1f9774..63086be 100644 --- a/src/pkmntrade_club/theme/templatetags/card_badge.html +++ b/src/pkmntrade_club/theme/templatetags/card_badge.html @@ -1,6 +1,10 @@ {% load cache %} -{% cache CACHE_LONG_TIMEOUT "card_badge" card.pk %} - +{% cache CACHE_LONG_TIMEOUT cache_key %} + + + {% if clickable %} + + {% endif %}
{% if not expanded %}
@@ -10,6 +14,10 @@
{{ quantity }}
+ {% elif closeable == True %} +
+ × +
{% endif %}
{% else %} @@ -19,11 +27,17 @@
{{ quantity }}
+ {% elif closeable == True %} +
+ × +
{% endif %}
{{ rarity }}
{{ cardset }}
{% endif %} -
+ {% if clickable %} + + {% endif %} {% endcache %} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templatetags/card_multiselect.html b/src/pkmntrade_club/theme/templatetags/card_multiselect.html index 26b2920..3bf46a1 100644 --- a/src/pkmntrade_club/theme/templatetags/card_multiselect.html +++ b/src/pkmntrade_club/theme/templatetags/card_multiselect.html @@ -1,16 +1,267 @@ -{% load cache card_badge %} -{% load cache card_multiselect %} +{% load i18n card_badge %} - - \ No newline at end of file +
+ + + {# Hidden select that holds the actual form values #} + + + {# JS-powered search input and selected card display #} +
+
+ + +
+
    + +
+
{% trans "Searching..." %}
+
+ {% trans "No results found." %} +
+
+
+ +
+ +
+
+ + {# Non-JS fallback search form #} + +
+ + + \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templatetags/trade_offer_old.html b/src/pkmntrade_club/theme/templatetags/trade_offer_old.html deleted file mode 100644 index 528ffcc..0000000 --- a/src/pkmntrade_club/theme/templatetags/trade_offer_old.html +++ /dev/null @@ -1,260 +0,0 @@ -{% load gravatar card_badge cache %} - -{% cache 60 trade_offer offer_pk %} -
-
- - -
- -
- - - - - - - -
- -
-
-
- {% if acceptances.0 %} - - {% endif %} -
- -
-
- {% if acceptances|length > 1 %} - - - - {% endif %} -
-
-
- -
- - - -
-
- - Acceptances ({{ acceptances|length }}) - -
-
- - - -
-
-
-
-
-
-
- -{% endcache %} \ No newline at end of file