diff --git a/src/pkmntrade_club/cards/admin.py b/src/pkmntrade_club/cards/admin.py index b778a69..b3ce633 100644 --- a/src/pkmntrade_club/cards/admin.py +++ b/src/pkmntrade_club/cards/admin.py @@ -1,7 +1,481 @@ -from django.contrib import admin -from .models import Deck, Card, DeckNameTranslation, CardNameTranslation +from django.contrib import admin, messages +from django.urls import path +from django.shortcuts import render +from django.http import HttpResponseRedirect +from parler.admin import TranslatableAdmin +from .models import ( + CardSet_New, Pack_New, Energy_New, Attack_New, Ability_New, + Rarity_New, CardType_New, Card_New, AttackCost_New, RarityMapping +) -admin.site.register(Deck) -admin.site.register(Card) -admin.site.register(DeckNameTranslation) -admin.site.register(CardNameTranslation) \ No newline at end of file +import json +import os +import re # For parsing set name and ID +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.db import transaction +import hashlib + +def parse_set_details(set_string): + match = re.match(r'^(.*?)\s*\(([A-Za-z0-9]+)\)$', set_string) + if match: + name = match.group(1).strip() + set_id = match.group(2) + return name, set_id + match = re.match(r'^Promo-(.*?)$', set_string) + if match: + name = set_string + set_id = 'P-' + match.group(1) + return name, set_id + return set_string, None + +def calculate_card_checksum(card_data): + """ + Calculates a SHA256 checksum for a card's data. + The data is first normalized by sorting lists and dictionary keys + to ensure consistent checksums for semantically identical cards. + """ + # Select and normalize fields that define the card's state + # Order of keys in `data_to_hash` and sorting of lists are important for consistency + data_to_hash = { + 'id': card_data.get('id'), + 'name': card_data.get('name'), + 'type': card_data.get('type'), + 'subtype': card_data.get('subtype'), + 'rarity': card_data.get('rarity'), # Rarity name from JSON + 'health': card_data.get('health'), + 'evolvesFrom': card_data.get('evolvesFrom'), + 'retreatCost': card_data.get('retreatCost'), + 'element': card_data.get('element'), # Element name from JSON + 'weakness': card_data.get('weakness'), # Weakness name from JSON + 'pack': card_data.get('pack'), # Pack name from JSON + # For abilities and attacks, ensure stable order and content + 'abilities': sorted([ + {'name': a.get('name'), 'effect': a.get('effect')} + for a in card_data.get('abilities', []) if a and a.get('name') # ensure ability itself and name exist + ], key=lambda x: x['name'] if x and x.get('name') else ''), + 'attacks': sorted([ + { + 'name': atk.get('name'), + 'effect': atk.get('effect', ''), + 'damage': atk.get('damage', ''), + 'cost': sorted(atk.get('cost', []) if atk.get('cost') else []) # Sort energy costs + } + for atk in card_data.get('attacks', []) if atk and atk.get('name') # ensure attack itself and name exist + ], key=lambda x: x['name'] if x and x.get('name') else ''), + } + + # Serialize to a canonical JSON string (sort keys, no indent, compact) + canonical_json = json.dumps(data_to_hash, sort_keys=True, separators=(',', ':')) + + sha256_hash = hashlib.sha256(canonical_json.encode('utf-8')).hexdigest() + return sha256_hash + +def _get_or_create_card_type(card_data): + card_type_obj, created = CardType_New.objects.language('en').get_or_create( + translations__name=card_data['type'], + translations__subtype=card_data.get('subtype', ''), + defaults={'name': card_data['type'], 'subtype': card_data.get('subtype', '')} + ) + if not created: + current_subtype = card_data.get('subtype') + if current_subtype is not None and card_type_obj.subtype != current_subtype: + card_type_obj.set_current_language('en') + card_type_obj.subtype = current_subtype + card_type_obj.save() + return card_type_obj + +def _get_or_create_rarity(card_data, rarity_mappings_dict): + original_rarity_name_from_json = card_data.get('rarity') + + # Attempt to find a mapping for the original rarity name + mapping = rarity_mappings_dict.get(original_rarity_name_from_json) + + if mapping: + # Use mapped values + target_rarity_name = mapping.mapped_name + target_icon = mapping.icon + target_level = mapping.level + elif original_rarity_name_from_json: + # No mapping found, use the original name from JSON, default icon/level + target_rarity_name = original_rarity_name_from_json + target_icon = 'x' # Default icon if no mapping + target_level = 0 # Default level if no mapping + else: + # Rarity is None or empty in JSON, treat as 'Promo' + target_rarity_name = 'Promo' + # Check if 'Promo' itself has a mapping + promo_mapping = rarity_mappings_dict.get('Promo') + if promo_mapping: + target_icon = promo_mapping.icon + target_level = promo_mapping.level + else: + target_icon = 'x' # Default icon for 'Promo' if no mapping for 'Promo' + target_level = 0 # Default level for 'Promo' if no mapping for 'Promo' + + # Get or create the Rarity_New object using the (potentially mapped) values + rarity_obj, created = Rarity_New.objects.language('en').get_or_create( + translations__name=target_rarity_name, + defaults={'name': target_rarity_name, 'icon': target_icon, 'level': target_level} + ) + + # If the rarity already existed, check if its icon or level needs updating based on the mapping + if not created: + updated_fields = False + if rarity_obj.icon != target_icon: + rarity_obj.icon = target_icon + updated_fields = True + if rarity_obj.level != target_level: + rarity_obj.level = target_level + updated_fields = True + + if updated_fields: + rarity_obj.save() + + return rarity_obj + +def _get_or_create_energy(energy_name): + if not energy_name: + return None + energy_obj, _ = Energy_New.objects.language('en').get_or_create( + translations__name=energy_name, + defaults={'name': energy_name} + ) + return energy_obj + +def _update_card_packs(card_obj, card_data, card_set): + card_obj.packs.clear() + pack_name_from_json = card_data.get('pack') + if pack_name_from_json: + card_set.set_current_language('en') + pack_full_name = f"{card_set.name}: {pack_name_from_json}" + + pack_obj, _ = Pack_New.objects.language('en').get_or_create( + translations__name=pack_name_from_json, + cardset=card_set, + defaults={ + 'name': pack_name_from_json, + 'full_name': pack_full_name, + 'hex_color': '#FFFFFF' + } + ) + card_obj.packs.add(pack_obj) + else: + all_packs_in_set = Pack_New.objects.filter(cardset=card_set) + if all_packs_in_set.exists(): + card_obj.packs.add(*all_packs_in_set) + +def _update_card_abilities(card_obj, card_data): + card_obj.abilities.clear() + for ability_data in card_data.get('abilities', []): + ability_obj, created = Ability_New.objects.language('en').get_or_create( + translations__name=ability_data['name'], + defaults={'name': ability_data['name'], 'effect': ability_data['effect']} + ) + if not created and ability_obj.effect != ability_data['effect']: + ability_obj.set_current_language('en') + ability_obj.effect = ability_data['effect'] + ability_obj.save() + card_obj.abilities.add(ability_obj) + +def _update_card_attacks_and_costs(card_obj, card_data): + card_obj.attacks.clear() + for attack_data in card_data.get('attacks', []): + attack_obj, created = Attack_New.objects.language('en').get_or_create( + translations__name=attack_data['name'], + defaults={ + 'name': attack_data['name'], + 'effect': attack_data.get('effect', ''), + 'damage': attack_data.get('damage', '') + } + ) + + needs_save = False + if not created: + json_effect = attack_data.get('effect', '') + if attack_obj.effect != json_effect: + attack_obj.set_current_language('en') + attack_obj.effect = json_effect + needs_save = True + + json_damage = attack_data.get('damage', '') + if attack_obj.damage != json_damage: + attack_obj.damage = json_damage + needs_save = True + + if created or needs_save: + attack_obj.save() + + card_obj.attacks.add(attack_obj) + + attack_obj.energy_cost.clear() + energy_counts = {} + for cost_energy_name in attack_data.get('cost', []): + energy_counts[cost_energy_name] = energy_counts.get(cost_energy_name, 0) + 1 + + for energy_name, quantity in energy_counts.items(): + energy_obj = _get_or_create_energy(energy_name) + if energy_obj: + AttackCost_New.objects.update_or_create( + attack=attack_obj, + energy=energy_obj, + defaults={'quantity': quantity} + ) + +def _process_single_card_data(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. + """ + card_id = card_data['id'] + incoming_checksum = calculate_card_checksum(card_data) + error_tracking['card_id'] = card_id + + try: + existing_card = Card_New.objects.language('en').get(id=card_id) + if existing_card.checksum == incoming_checksum: + stats_accumulator['skipped_count'] += 1 + return + except Card_New.DoesNotExist: + existing_card = None + + card_type_obj = _get_or_create_card_type(card_data) + rarity_obj = _get_or_create_rarity(card_data, rarity_mappings_dict) + pkmn_type_obj = _get_or_create_energy(card_data.get('element')) + weakness_type_obj = _get_or_create_energy(card_data.get('weakness')) + + card_defaults = { + 'name': card_data['name'], + 'cardset': card_set, + 'card_type': card_type_obj, + 'rarity': rarity_obj, + 'health': card_data.get('health'), + 'evolves_from_name': card_data.get('evolvesFrom'), + 'retreat_cost': card_data.get('retreatCost'), + 'pkmn_type': pkmn_type_obj, + 'weakness_type': weakness_type_obj, + 'checksum': incoming_checksum + } + + card_obj, card_created = Card_New.objects.language('en').update_or_create( + id=card_id, + defaults=card_defaults + ) + + if card_created: + stats_accumulator['newly_imported_count'] += 1 + elif existing_card: + stats_accumulator['updated_count'] +=1 + # If not created and checksum differs, it's an update, which is handled by updated_count. + # update_or_create takes care of setting the new checksum via defaults. + + _update_card_packs(card_obj, card_data, card_set) + _update_card_abilities(card_obj, card_data) + _update_card_attacks_and_costs(card_obj, card_data) + + # The checksum is based on the incoming card_data. If the update_* functions + # modify the card_obj in a way that would change its representation + # based on the original card_data fields, the checksum logic is fine. + # If those functions derive new data that *should* be part of the checksum, + # the checksum calculation would need to happen *after* them, using the card_obj state. + # However, for skipping based on *incoming JSON data*, this approach is correct. + # The `update_or_create` will ensure the `checksum` field (which is part of `card_defaults`) is saved. + +def perform_card_import_logic(): + """ + Main importer logic. Iterates through JSON files and processes them. + Halts and rolls back on any error. + """ + print("Card import process started.") + base_path = os.path.join(settings.BASE_DIR, 'REMOTE_GIT_REPOS', 'pokemon-tcg-pocket-card-database', 'cards', 'en') + + stats = {'newly_imported_count': 0, 'updated_count': 0, 'skipped_count': 0, 'files_processed_count': 0} + 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.") + + if not os.path.isdir(base_path): + message = f"Source directory not found: {base_path}. Import halted." + print(message) + return 0, 0, True, message, 0, 0 + + json_files = [f for f in os.listdir(base_path) if f.endswith('.json')] + json_files.sort() + if not json_files: + message = "No JSON files found in the source directory to import." + print(message) + return 0, 0, False, message, 0, 0 + + print(f"Found {len(json_files)} JSON files to process.") + + try: + with transaction.atomic(): + for idx, file_name in enumerate(json_files): + error_tracking['file_name'] = file_name + error_tracking['card_id'] = "N/A" + file_path = os.path.join(base_path, file_name) + + print(f"Processing file: {file_name} ({idx + 1}/{len(json_files)})") + + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if not data: + raise ValueError(f"JSON file {file_name} is empty or contains no data.") + + 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}.") + + 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}.") + + card_set_defaults = { + 'name': parsed_set_name, + 'file_name': file_name + } + card_set, _ = CardSet_New.objects.language('en').update_or_create( + id=parsed_set_id, + defaults=card_set_defaults + ) + + for card_data_item in data: + print("Processing card: ", card_data_item['id']) + _process_single_card_data(card_data_item, card_set, stats, error_tracking, rarity_mappings_dict) + + print(f"Finished processing file: {file_name}") + + success_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(success_message)) + return stats['newly_imported_count'], stats['updated_count'], False, success_message, stats['files_processed_count'], stats['skipped_count'] + + except Exception as e: + # Any 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)}" + halt_message = f"Import HALTED. All changes rolled back. Reason: {error_detail}" + print(halt_message) + # Return 0 for counts as the transaction is rolled back + return 0, 0, True, halt_message, stats['files_processed_count'], stats.get('skipped_count', 0) + + +if admin.site.is_registered(CardSet_New): admin.site.unregister(CardSet_New) +if admin.site.is_registered(Pack_New): admin.site.unregister(Pack_New) +if admin.site.is_registered(Energy_New): admin.site.unregister(Energy_New) +if admin.site.is_registered(Attack_New): admin.site.unregister(Attack_New) +if admin.site.is_registered(Ability_New): admin.site.unregister(Ability_New) +if admin.site.is_registered(Rarity_New): admin.site.unregister(Rarity_New) +if admin.site.is_registered(CardType_New): admin.site.unregister(CardType_New) +if admin.site.is_registered(Card_New): admin.site.unregister(Card_New) +if admin.site.is_registered(AttackCost_New): admin.site.unregister(AttackCost_New) +if admin.site.is_registered(RarityMapping): admin.site.unregister(RarityMapping) + + +@admin.register(CardSet_New) +class CardSetAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'file_name') + readonly_fields = ('id', 'file_name', 'created_at', 'updated_at') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Pack_New) +class PackAdmin(TranslatableAdmin): + list_display = ('id', 'full_name', 'name', 'cardset', 'hex_color') + list_filter = ('cardset',) + search_fields = ('translations__name', 'translations__full_name') + readonly_fields = ('id', 'created_at', 'updated_at') + +@admin.register(Energy_New) +class EnergyAdmin(TranslatableAdmin): + list_display = ('id', 'name') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Attack_New) +class AttackAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'damage', 'effect') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Ability_New) +class AbilityAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'effect') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at') + +@admin.register(Rarity_New) +class RarityAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'icon', 'level') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(CardType_New) +class CardTypeAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'subtype') + search_fields = ('translations__name', 'translations__subtype') + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Card_New) +class CardAdmin(TranslatableAdmin): + list_display = ('id', 'cardnum', 'name', 'cardset', 'card_type', 'rarity', 'health', 'pkmn_type') + list_filter = ('cardset', 'card_type', 'rarity', 'pkmn_type', 'packs') + search_fields = ('id', 'translations__name', 'cardset__translations__name', 'packs__translations__name') + filter_horizontal = ('packs', 'abilities', 'attacks') + readonly_fields = ('id', 'cardnum', 'created_at', 'updated_at', 'deleted_at') + +admin.site.register(AttackCost_New) + +@admin.register(RarityMapping) +class RarityMappingAdmin(admin.ModelAdmin): + list_display = ('original_name', 'mapped_name', 'icon', 'level', 'created_at', 'updated_at', 'deleted_at') + search_fields = ('original_name', 'mapped_name') + list_filter = ('level',) + readonly_fields = ('created_at', 'updated_at', 'deleted_at') + +def get_admin_urls(urls): + def importer_view(request): + context = { + 'title': 'Card Importer', + 'site_header': admin.site.site_header, + 'site_title': admin.site.site_title, + 'index_title': admin.site.index_title, + 'has_permission': admin.site.has_permission(request), + 'app_label': 'cards', + } + if request.method == 'POST': + new, updated, has_error, message_text, files_processed, skipped = perform_card_import_logic() + + if has_error: + messages.error(request, message_text + f" Files attempted before halt: {files_processed}.") + else: + messages.success(request, message_text) + + return HttpResponseRedirect(request.path_info) + + return render(request, 'admin/cards/importer_status.html', context) + + custom_urls = [ + path('cards/import/', admin.site.admin_view(importer_view), name='cards_full_importer'), + ] + return custom_urls + urls + +original_get_urls = admin.site.get_urls + +def new_get_urls(): + urls = original_get_urls() + return get_admin_urls(urls) + +admin.site.get_urls = new_get_urls \ No newline at end of file diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index 9f014ea..b3376ef 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -1,53 +1,319 @@ from django.db import models -from django.db.models import Prefetch -from django.apps import apps +from parler.models import TranslatableModel, TranslatedFields +from django.utils.translation import gettext_lazy as _ -class DeckNameTranslation(models.Model): - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=64) - deck = models.ForeignKey("Deck", on_delete=models.PROTECT, related_name='name_translations') - language = models.CharField(max_length=64) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - def __str__(self): - return self.name +class CardSet(TranslatableModel): + """ + Represents a single JSON file from the repository, considered a 'cardset', e.g., "Genetic Apex (A1)", + or collection of cards. Each cardset file belongs to a specific CardSet and language. + """ -class CardNameTranslation(models.Model): - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=64) - card = models.ForeignKey("Card", on_delete=models.PROTECT, related_name='name_translations') - language = models.CharField(max_length=64) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + translations = TranslatedFields( + name=models.CharField( + max_length=32, + help_text=_("The full name of the set, e.g., 'Genetic Apex'."), + ) + ) + id = models.CharField( + max_length=3, + primary_key=True, + help_text=_("The ID for the set, e.g., 'A1', 'A1a'."), + ) + file_name = models.CharField( + max_length=32, + help_text=_("Original name of the JSON file, e.g., 'a1-genetic-apex.json'."), + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(null=True, blank=True) - def __str__(self): - return self.name -class Deck(models.Model): - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=64) - hex_color = models.CharField(max_length=9) - cardset = models.CharField(max_length=8) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + class Meta: + verbose_name = _("Card Set (New)") + verbose_name_plural = _("Card Sets (New)") - def __str__(self): - return self.name + def __str__(self): + return f"{self.id} - {self.name}" -class Card(models.Model): - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=64) - decks = models.ManyToManyField("Deck") - cardset = models.CharField(max_length=32) - cardnum = models.IntegerField() - style = models.CharField(max_length=128) - rarity_icon = models.CharField(max_length=12) - rarity_level = models.IntegerField() - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - class Meta: - unique_together = ('cardset', 'cardnum') +class Pack(TranslatableModel): + """ + Represents a single pack that is part of a cardset. E.g., "Genetic Apex: Mewtwo" + """ - def __str__(self): - return f"{self.name} ({self.cardset} #{self.cardnum})" \ No newline at end of file + translations = TranslatedFields( + full_name=models.CharField( + max_length=32, + help_text=_("The full name of the pack, e.g., 'Genetic Apex: Mewtwo'."), + ), + name=models.CharField( + max_length=32, help_text=_("The pack name itself, e.g., 'Mewtwo'.") + ), + ) + 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) + deleted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = _("Pack (New)") + verbose_name_plural = _("Packs (New)") + + def __str__(self): + return f"{self.full_name}" + + +class Energy(TranslatableModel): + """ + A type a Pokémon card can have. + """ + + translations = TranslatedFields( + name=models.CharField(max_length=32, help_text=_("The name of the energy.")) + ) + id = models.AutoField(primary_key=True) + 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 = _("Energy (New)") + verbose_name_plural = _("Energies (New)") + + def __str__(self): + return f"{self.name}" + + +class AttackCost(models.Model): + """ + Intermediary model to store the quantity of each energy type for an attack's cost. + """ + + attack = models.ForeignKey("Attack", on_delete=models.CASCADE) + energy = models.ForeignKey(Energy, on_delete=models.CASCADE) + quantity = models.PositiveIntegerField( + default=1, help_text=_("Quantity of this energy type required for the attack.") + ) + 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 = _("Attack Cost (New)") + verbose_name_plural = _("Attack Costs (New)") + unique_together = ("attack", "energy") + + def __str__(self): + return f"{self.attack.name} {_("requires")} {self.quantity} {self.energy.name}" + + +class Attack(TranslatableModel): + """ + An attack a Pokémon card can have. + """ + + translations = TranslatedFields( + name=models.CharField(max_length=32, help_text=_("The name of the attack.")), + effect=models.TextField(help_text=_("Description of the attack's effect.")), + ) + id = models.AutoField(primary_key=True) + damage = models.CharField( + max_length=10, + null=True, + blank=True, + help_text=_("Damage string, e.g., '40', '20x', '80+'."), + ) + energy_cost = models.ManyToManyField( + Energy, through=AttackCost, related_name="attacks" + ) + 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 = _("Attack (New)") + verbose_name_plural = _("Attacks (New)") + + def __str__(self): + return f"{self.name}" + + +class Ability(TranslatableModel): + """ + An ability a Pokémon card can have. + """ + + translations = TranslatedFields( + name=models.CharField(max_length=32, help_text=_("The name of the ability.")), + effect=models.TextField(help_text=_("Description of the ability's effect.")), + ) + id = models.AutoField(primary_key=True) + 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 = _("Ability (New)") + verbose_name_plural = _("Abilities (New)") + + def __str__(self): + return f"{self.name}" + + +class Rarity(TranslatableModel): + """ + A rarity a Pokémon card can have. + """ + + translations = TranslatedFields( + name=models.CharField(max_length=32, help_text=_("The name of the rarity.")) + ) + id = models.AutoField(primary_key=True) + icon = models.CharField(max_length=12) + level = models.PositiveIntegerField() + 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 = _("Rarity (New)") + verbose_name_plural = _("Rarities (New)") + + def __str__(self): + return f"{self.name}" + + +class CardType(TranslatableModel): + """ + A type a Pokémon card can have. + """ + + translations = TranslatedFields( + name=models.CharField(max_length=32, help_text=_("The name of the card type.")), + subtype=models.CharField( + max_length=32, + null=True, + blank=True, + help_text=_("The subtype of the card type."), + ), + ) + id = models.AutoField(primary_key=True) + 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 = _("Card Type (New)") + verbose_name_plural = _("Card Types (New)") + + def __str__(self): + return f"{self.name}" + + +class Card(TranslatableModel): + """ + Represents a single, unique digital printing of a Pokémon card. + """ + + translations = TranslatedFields( + name=models.CharField(max_length=32, help_text=_("The name of the card.")), + evolves_from_name=models.CharField( + max_length=32, + null=True, + blank=True, + help_text=_("Name of the Pokémon this card evolves from."), + ), + ) + cardnum = models.AutoField(primary_key=True) + id = models.CharField( + max_length=10, + db_index=True, + help_text=_( + "The unique ID from the JSON source, cardset-cardnum (e.g., 'a1-001')." + ), + ) + checksum = models.CharField( + max_length=64, + null=True, + blank=True, + help_text=_("SHA256 checksum of the card data."), + db_index=True, + ) + health = models.PositiveIntegerField( + null=True, blank=True, help_text=_("HP of the Pokémon.") + ) + + retreat_cost = models.PositiveIntegerField( + null=True, blank=True, help_text=_("The number of retreat cost for the card.") + ) + weakness_type = models.ForeignKey( + Energy, + on_delete=models.CASCADE, + related_name="cards_weakness_type", + null=True, + blank=True, + ) + pkmn_type = models.ForeignKey( + Energy, + on_delete=models.CASCADE, + related_name="cards_pkmn_type", + null=True, + blank=True, + ) + card_type = models.ForeignKey( + CardType, on_delete=models.CASCADE, related_name="cards" + ) + packs = models.ManyToManyField(Pack, related_name="cards") + cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="cards") + abilities = models.ManyToManyField(Ability, blank=True, related_name="cards") + attacks = models.ManyToManyField(Attack, related_name="cards") + rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, related_name="cards") + + 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 = _("Card (New)") + verbose_name_plural = _("Cards (New)") + + def __str__(self): + return f"{self.id} {self.name}" + + +class RarityMapping(models.Model): + """ + Maps an original rarity name from the import source to a standardized + rarity name, icon, and level to be used in the system. + """ + + id = models.AutoField(primary_key=True) + original_name = models.CharField( + max_length=255, + unique=True, + help_text=_( + "The rarity name as it appears in the import source (e.g., JSON file)." + ), + ) + mapped_name = models.CharField( + max_length=32, help_text=_("The standardized rarity name to use in the system.") + ) + icon = models.CharField( + max_length=12, help_text=_("The icon associated with this rarity.") + ) + level = models.PositiveIntegerField( + help_text=_("The level or order of this rarity.") + ) + 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 = _("Rarity Mapping") + verbose_name_plural = _("Rarity Mappings") + ordering = ["original_name"] + + def __str__(self): + return f"'{self.original_name}' -> '{self.mapped_name}' (L{self.level}, {self.icon})" diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index 550b184..d048108 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -19,6 +19,7 @@ env = environ.Env( DJANGO_EMAIL_PASSWORD=(str, ''), DJANGO_EMAIL_USE_TLS=(bool, True), DJANGO_DEFAULT_FROM_EMAIL=(str, ''), + DJANGO_EMAIL_SUBJECT_PREFIX=(str, ''), SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'), ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'), PUBLIC_HOST=(str, 'localhost'), @@ -112,6 +113,9 @@ except Exception: CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"] +SHORTHAND_DATETIME_FORMAT = 'Y-m-d P' +SHORTHAND_DATE_FORMAT = 'Y-m-d' + FIRST_PARTY_APPS = [ 'pkmntrade_club.accounts', 'pkmntrade_club.cards', @@ -151,6 +155,7 @@ INSTALLED_APPS = [ 'health_check.contrib.psutil', 'health_check.contrib.redis', "meta", + "parler", ] + FIRST_PARTY_APPS if DEBUG: @@ -251,6 +256,10 @@ AUTH_PASSWORD_VALIDATORS = [ # https://docs.djangoproject.com/en/dev/ref/settings/#language-code LANGUAGE_CODE = "en-us" +LANGUAGES = ( + ('en', _("English")), +) + # https://docs.djangoproject.com/en/dev/ref/settings/#time-zone TIME_ZONE = env('TIME_ZONE') @@ -310,6 +319,7 @@ EMAIL_PORT = env('DJANGO_EMAIL_PORT') EMAIL_HOST_USER = env('DJANGO_EMAIL_USER') EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD') EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS') +EMAIL_SUBJECT_PREFIX = env('DJANGO_EMAIL_SUBJECT_PREFIX') # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL') @@ -334,6 +344,17 @@ AUTH_USER_MODEL = "accounts.CustomUser" # https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 +PARLER_LANGUAGES = { + SITE_ID: ( + {'code': 'en'}, + ), + 'default': { + 'fallbacks': ['en'], + 'hide_untranslated': False, + }, +} + + # https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url LOGIN_REDIRECT_URL = "home" @@ -374,6 +395,7 @@ SOCIALACCOUNT_ONLY = False SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SECURE = PUBLIC_HOST != 'localhost' or PUBLIC_HOST != '127.0.0.1' # auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} diff --git a/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html b/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html new file mode 100644 index 0000000..7bd2b47 --- /dev/null +++ b/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html @@ -0,0 +1,38 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %}{{ block.super }} + +{% endblock %} + +{% block coltype %}colM{% endblock %} + +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} + +{% block breadcrumbs %} +
+{% endblock %} + +{% block content %} +{% translate 'Click the button below to import all card data from the configured JSON file directory.' %}
+{% translate 'This process will scan all .json files, create or update card sets, and then import or update all individual cards and their related data (packs, abilities, attacks, etc.).' %}
+ + + + {% if messages %} +