inital checkin

This commit is contained in:
2026-01-20 05:22:38 -06:00
parent 9784e14c77
commit c43603bfb5
75 changed files with 4327 additions and 0 deletions

0
decks/__init__.py Normal file
View File

3
decks/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
decks/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class DecksConfig(AppConfig):
name = 'decks'

View File

@@ -0,0 +1,32 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Deck',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_public', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='DeckCard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('is_sideboard', models.BooleanField(default=False)),
],
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('decks', '0001_initial'),
('store', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='deck',
name='game',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='store.game'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.1 on 2026-01-19 13:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('decks', '0002_initial'),
('store', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='deck',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='decks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='deckcard',
name='card',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='store.card'),
),
migrations.AddField(
model_name='deckcard',
name='deck',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='decks.deck'),
),
migrations.AlterUniqueTogether(
name='deckcard',
unique_together={('deck', 'card', 'is_sideboard')},
),
]

View File

26
decks/models.py Normal file
View File

@@ -0,0 +1,26 @@
from django.db import models
from django.conf import settings
from store.models import Card, Game
class Deck(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='decks')
name = models.CharField(max_length=200)
game = models.ForeignKey(Game, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_public = models.BooleanField(default=False)
def __str__(self):
return f"{self.name} ({self.game.name})"
class DeckCard(models.Model):
deck = models.ForeignKey(Deck, on_delete=models.CASCADE, related_name='cards')
card = models.ForeignKey(Card, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
is_sideboard = models.BooleanField(default=False)
class Meta:
unique_together = ('deck', 'card', 'is_sideboard')
def __str__(self):
return f"{self.quantity}x {self.card.name}"

3
decks/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

127
decks/tests_import.py Normal file
View File

@@ -0,0 +1,127 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from store.models import Game, Set, Card
from decks.models import Deck
from .utils import parse_deck_list
User = get_user_model()
class DeckImportParsingTests(TestCase):
def test_parse_arena_format(self):
text = """
1 Laughing Jasper Flint (OTJ) 215 *F*
1 Arcane Signet (LTC) 273
1 At Knifepoint (OTJ) 193
SIDEBOARD:
1 Agate Instigator (BLC) 21
"""
parsed = parse_deck_list(text)
self.assertEqual(len(parsed), 4)
self.assertEqual(parsed[0]['name'], "Laughing Jasper Flint")
self.assertEqual(parsed[0]['quantity'], 1)
self.assertFalse(parsed[0]['is_sideboard'])
self.assertEqual(parsed[2]['name'], "At Knifepoint")
self.assertFalse(parsed[2]['is_sideboard'])
self.assertEqual(parsed[3]['name'], "Agate Instigator")
self.assertTrue(parsed[3]['is_sideboard'])
def test_parse_mtgo_format(self):
text = """
1 Arcane Signet
1 At Knifepoint
SIDEBOARD:
1 Agate Instigator
"""
parsed = parse_deck_list(text)
self.assertEqual(len(parsed), 3)
self.assertEqual(parsed[0]['name'], "Arcane Signet")
self.assertEqual(parsed[2]['name'], "Agate Instigator")
self.assertTrue(parsed[2]['is_sideboard'])
def test_parse_moxfield_complex(self):
# Moxfield/Arena sometimes has confusing lines like split cards
text = """
1 Blightstep Pathway / Searstep Pathway (KHM) 291
1 Command Tower (PLST) C21-284
"""
parsed = parse_deck_list(text)
self.assertEqual(len(parsed), 2)
# Verify it stops capturing before the (SET) part
self.assertEqual(parsed[0]['name'], "Blightstep Pathway / Searstep Pathway")
self.assertEqual(parsed[1]['name'], "Command Tower")
def test_parse_plain_text(self):
text = """
10 Mountain
1 Sol Ring
"""
parsed = parse_deck_list(text)
self.assertEqual(len(parsed), 2)
self.assertEqual(parsed[0]['quantity'], 10)
self.assertEqual(parsed[0]['name'], "Mountain")
class DeckImportViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='testuser', password='password')
# Create profile and set is_pro = True
# Using get_or_create to handle signal-created profiles if any, though standard is signals usually handle create
if not hasattr(self.user, 'profile'):
from users.models import Profile
Profile.objects.create(user=self.user)
self.user.profile.is_pro = True
self.user.profile.save()
self.client = Client()
self.client.login(username='testuser', password='password')
self.game = Game.objects.create(name='Magic: The Gathering', slug='mtg')
self.set = Set.objects.create(game=self.game, name='Test Set', code='TST')
self.card1 = Card.objects.create(set=self.set, name='Lightning Bolt')
self.card2 = Card.objects.create(set=self.set, name='Mountain')
def test_import_simple_deck(self):
url = reverse('decks:deck_import')
deck_text = """
4 Lightning Bolt
10 Mountain
"""
data = {
'name': 'Red Deck',
'game': self.game.id,
'deck_list': deck_text
}
response = self.client.post(url, data, follow=True)
self.assertContains(response, 'Successfully imported deck')
deck = Deck.objects.get(name='Red Deck')
self.assertEqual(deck.cards.count(), 2)
bolt = deck.cards.get(card=self.card1)
self.assertEqual(bolt.quantity, 4)
mountain = deck.cards.get(card=self.card2)
self.assertEqual(mountain.quantity, 10)
def test_import_with_missing_cards(self):
url = reverse('decks:deck_import')
deck_text = """
4 Lightning Bolt
1 Black Lotus
"""
data = {
'name': 'Power Deck',
'game': self.game.id,
'deck_list': deck_text
}
response = self.client.post(url, data, follow=True)
self.assertContains(response, 'Could not find')
deck = Deck.objects.get(name='Power Deck')
self.assertEqual(deck.cards.count(), 1) # Only bolt

12
decks/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = 'decks'
urlpatterns = [
path('', views.deck_list, name='deck_list'),
path('new/', views.deck_create, name='deck_create'),
path('import/', views.deck_import, name='deck_import'),
path('<int:deck_id>/', views.deck_builder, name='deck_builder'),
path('<int:deck_id>/remove/<int:card_id>/', views.remove_card_from_deck, name='remove_card'),
]

61
decks/utils.py Normal file
View File

@@ -0,0 +1,61 @@
import re
def parse_deck_list(text):
"""
Parses a deck list string and returns a list of dictionaries with quantity, card name, and is_sideboard.
Supported formats:
- Arena/Moxfield: "1 Laughing Jasper Flint (OTJ) 215 *F*"
- MTGO: "1 Arcane Signet"
- Plain Text: "1 Arcane Signet"
Returns:
list of dict: [{'quantity': int, 'name': str, 'is_sideboard': bool}]
"""
lines = text.strip().split('\n')
parsed_cards = []
is_sideboard = False
# Regex for Arena/Moxfield lines: e.g., "1 Card Name (SET) 123"
# Captures: Qty, Name
# We ignore Set code and collector number for lookup simplicity for now, relying on name.
# Pattern: Digit+ space Name space (SetCode) space ...
arena_pattern = re.compile(r'^(\d+)\s+(.+?)\s+\([A-Z0-9]+\)\s+\S+')
# Regex for simple Quantity + Name: "1 Card Name"
simple_pattern = re.compile(r'^(\d+)\s+(.+)$')
for line in lines:
line = line.strip()
if not line:
continue
if line.lower().startswith('sideboard'):
is_sideboard = True
continue
# Try Arena/Moxfield/detailed format first
match = arena_pattern.match(line)
if match:
qty = int(match.group(1))
name = match.group(2).strip()
parsed_cards.append({
'quantity': qty,
'name': name,
'is_sideboard': is_sideboard
})
continue
# Try simple format
match = simple_pattern.match(line)
if match:
qty = int(match.group(1))
name = match.group(2).strip()
parsed_cards.append({
'quantity': qty,
'name': name,
'is_sideboard': is_sideboard
})
continue
return parsed_cards

134
decks/views.py Normal file
View File

@@ -0,0 +1,134 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib import messages
from django.utils.decorators import method_decorator
from django.views.generic import ListView, DetailView, CreateView
from django.urls import reverse_lazy
from django.db.models import Prefetch
from .models import Deck, DeckCard
from store.models import Card, Game
def is_pro_user(user):
return user.is_authenticated and user.profile.is_pro
@login_required
def deck_list(request):
decks = Deck.objects.filter(user=request.user)
return render(request, 'decks/deck_list.html', {'decks': decks})
@user_passes_test(is_pro_user, login_url='users:profile') # Redirect to profile to upgrade
def deck_create(request):
if request.method == 'POST':
name = request.POST.get('name')
game_id = request.POST.get('game')
game = get_object_or_404(Game, id=game_id)
deck = Deck.objects.create(user=request.user, name=name, game=game)
messages.success(request, 'Deck created! Start adding cards.')
return redirect('decks:deck_builder', deck_id=deck.id)
games = Game.objects.all()
return render(request, 'decks/deck_create.html', {'games': games})
@user_passes_test(is_pro_user, login_url='users:profile')
def deck_builder(request, deck_id):
deck = get_object_or_404(Deck, id=deck_id, user=request.user)
if request.method == 'POST':
# Simple add card logic from search
card_id = request.POST.get('card_id')
quantity = int(request.POST.get('quantity', 1))
card = get_object_or_404(Card, id=card_id)
if card.set.game != deck.game:
messages.error(request, 'Cannot add card from different game.')
else:
deck_card, created = DeckCard.objects.get_or_create(deck=deck, card=card)
if not created:
deck_card.quantity += quantity
else:
deck_card.quantity = quantity
deck_card.save()
messages.success(request, f'Added {card.name}')
# Search for cards to add (simplified)
search_results = []
query = request.GET.get('q')
if query:
search_results = Card.objects.filter(
set__game=deck.game,
name__icontains=query
).select_related('set')[:20]
return render(request, 'decks/deck_builder.html', {
'deck': deck,
'search_results': search_results,
'query': query
})
@user_passes_test(is_pro_user, login_url='users:profile')
def remove_card_from_deck(request, deck_id, card_id):
deck = get_object_or_404(Deck, id=deck_id, user=request.user)
DeckCard.objects.filter(deck=deck, card_id=card_id).delete()
return redirect('decks:deck_builder', deck_id=deck.id)
@user_passes_test(is_pro_user, login_url='users:profile')
def deck_import(request):
if request.method == 'POST':
name = request.POST.get('name')
game_id = request.POST.get('game')
deck_list_text = request.POST.get('deck_list')
game = get_object_or_404(Game, id=game_id)
# Create Deck
deck = Deck.objects.create(user=request.user, name=name, game=game)
# Parse Deck List
from .utils import parse_deck_list
parsed_cards = parse_deck_list(deck_list_text)
cards_added = 0
cards_not_found = []
for item in parsed_cards:
# Try to find card in the selected game
# We strip characters that might be weird or just look for exact match first
# Ideally we'd have a better search, but exact match for now
# Using iexact for case insensitivity
# Simple fuzzy matching could go here, but let's stick to name__iexact for MVP
# And also checking if it's in the correct game
card_qs = Card.objects.filter(
set__game=game,
name__iexact=item['name']
)
if not card_qs.exists():
# Try simple cleaning? e.g. remove " // " split cards logic if name search fails?
# For now, just report error
cards_not_found.append(item['name'])
continue
card = card_qs.first() # Pick first valid print
DeckCard.objects.create(
deck=deck,
card=card,
quantity=item['quantity'],
is_sideboard=item['is_sideboard']
)
cards_added += 1
if cards_not_found:
messages.warning(request, f"Imported {cards_added} cards. Could not find: {', '.join(cards_not_found[:10])}...")
else:
messages.success(request, f"Successfully imported deck with {cards_added} cards!")
return redirect('decks:deck_builder', deck_id=deck.id)
games = Game.objects.all()
return render(request, 'decks/deck_import.html', {'games': games})