refactor(db): initial, incomplete work to update model and re-normalize fields

This commit is contained in:
badblocks 2025-06-12 17:06:21 -07:00
parent 30ce126a07
commit 4b9e4f651e
No known key found for this signature in database
4 changed files with 850 additions and 50 deletions

View file

@ -1,7 +1,481 @@
from django.contrib import admin from django.contrib import admin, messages
from .models import Deck, Card, DeckNameTranslation, CardNameTranslation 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) import json
admin.site.register(Card) import os
admin.site.register(DeckNameTranslation) import re # For parsing set name and ID
admin.site.register(CardNameTranslation) 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

View file

@ -1,53 +1,319 @@
from django.db import models from django.db import models
from django.db.models import Prefetch from parler.models import TranslatableModel, TranslatedFields
from django.apps import apps 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): class CardSet(TranslatableModel):
return self.name """
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): translations = TranslatedFields(
id = models.AutoField(primary_key=True) name=models.CharField(
name = models.CharField(max_length=64) max_length=32,
card = models.ForeignKey("Card", on_delete=models.PROTECT, related_name='name_translations') help_text=_("The full name of the set, e.g., 'Genetic Apex'."),
language = models.CharField(max_length=64) )
created_at = models.DateTimeField(auto_now_add=True) )
updated_at = models.DateTimeField(auto_now=True) 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): class Meta:
return self.name verbose_name = _("Card Set (New)")
class Deck(models.Model): verbose_name_plural = _("Card Sets (New)")
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)
def __str__(self): def __str__(self):
return self.name 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: class Pack(TranslatableModel):
unique_together = ('cardset', 'cardnum') """
Represents a single pack that is part of a cardset. E.g., "Genetic Apex: Mewtwo"
"""
def __str__(self): translations = TranslatedFields(
return f"{self.name} ({self.cardset} #{self.cardnum})" 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})"

View file

@ -19,6 +19,7 @@ env = environ.Env(
DJANGO_EMAIL_PASSWORD=(str, ''), DJANGO_EMAIL_PASSWORD=(str, ''),
DJANGO_EMAIL_USE_TLS=(bool, True), DJANGO_EMAIL_USE_TLS=(bool, True),
DJANGO_DEFAULT_FROM_EMAIL=(str, ''), DJANGO_DEFAULT_FROM_EMAIL=(str, ''),
DJANGO_EMAIL_SUBJECT_PREFIX=(str, ''),
SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'), SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'),
ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'), ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'),
PUBLIC_HOST=(str, 'localhost'), PUBLIC_HOST=(str, 'localhost'),
@ -112,6 +113,9 @@ except Exception:
CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"] CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"]
SHORTHAND_DATETIME_FORMAT = 'Y-m-d P'
SHORTHAND_DATE_FORMAT = 'Y-m-d'
FIRST_PARTY_APPS = [ FIRST_PARTY_APPS = [
'pkmntrade_club.accounts', 'pkmntrade_club.accounts',
'pkmntrade_club.cards', 'pkmntrade_club.cards',
@ -151,6 +155,7 @@ INSTALLED_APPS = [
'health_check.contrib.psutil', 'health_check.contrib.psutil',
'health_check.contrib.redis', 'health_check.contrib.redis',
"meta", "meta",
"parler",
] + FIRST_PARTY_APPS ] + FIRST_PARTY_APPS
if DEBUG: if DEBUG:
@ -251,6 +256,10 @@ AUTH_PASSWORD_VALIDATORS = [
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code # https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
LANGUAGES = (
('en', _("English")),
)
# https://docs.djangoproject.com/en/dev/ref/settings/#time-zone # https://docs.djangoproject.com/en/dev/ref/settings/#time-zone
TIME_ZONE = env('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_USER = env('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD') EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD')
EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS') 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 # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
DEFAULT_FROM_EMAIL = env('DJANGO_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 # https://docs.djangoproject.com/en/dev/ref/settings/#site-id
SITE_ID = 1 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 # https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
LOGIN_REDIRECT_URL = "home" LOGIN_REDIRECT_URL = "home"
@ -374,6 +395,7 @@ SOCIALACCOUNT_ONLY = False
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
SESSION_COOKIE_HTTPONLY = True 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 # auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG}

View file

@ -0,0 +1,38 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/dashboard.css" %}">
{% endblock %}
{% block coltype %}colM{% endblock %}
{% block bodyclass %}{{ block.super }} dashboard{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label='cards' %}">Cards</a>
&rsaquo; {% translate 'Full Card Importer' %}
</div>
{% endblock %}
{% block content %}
<h1>{% translate 'Full Card Set Importer' %}</h1>
<p>{% translate 'Click the button below to import all card data from the configured JSON file directory.' %}</p>
<p>{% 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.).' %}</p>
<form method="POST" action="">
{% csrf_token %}
<button type="submit" class="button btn-success">{% translate 'Start Full Import' %}</button>
</form>
{% if messages %}
<h2>{% translate 'Import Status:' %}</h2>
<ul class="messagelist">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}