inital checkin
This commit is contained in:
0
decks/__init__.py
Normal file
0
decks/__init__.py
Normal file
3
decks/admin.py
Normal file
3
decks/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
decks/apps.py
Normal file
5
decks/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DecksConfig(AppConfig):
|
||||
name = 'decks'
|
||||
32
decks/migrations/0001_initial.py
Normal file
32
decks/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
22
decks/migrations/0002_initial.py
Normal file
22
decks/migrations/0002_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
38
decks/migrations/0003_initial.py
Normal file
38
decks/migrations/0003_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
0
decks/migrations/__init__.py
Normal file
0
decks/migrations/__init__.py
Normal file
26
decks/models.py
Normal file
26
decks/models.py
Normal 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
3
decks/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
127
decks/tests_import.py
Normal file
127
decks/tests_import.py
Normal 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
12
decks/urls.py
Normal 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
61
decks/utils.py
Normal 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
134
decks/views.py
Normal 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})
|
||||
Reference in New Issue
Block a user