inital checkin
This commit is contained in:
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
16
config/asgi.py
Normal file
16
config/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for config project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
128
config/settings.py
Normal file
128
config/settings.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Django settings for config project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 6.0.1.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = 'django-insecure-y-a6jb6gv)dd!32_c!8rn70cnn%8&_$nc=m)0#4d=%b65%g(0*'
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
# Local apps
|
||||||
|
'users',
|
||||||
|
'store',
|
||||||
|
'decks',
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
# Stripe Configuration
|
||||||
|
STRIPE_PUBLISHABLE_KEY = 'pk_test_placeholder'
|
||||||
|
STRIPE_SECRET_KEY = 'sk_test_placeholder'
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
12
config/urls.py
Normal file
12
config/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('users/', include('users.urls')),
|
||||||
|
path('accounts/', include('django.contrib.auth.urls')), # For login/logout
|
||||||
|
path('', include('store.urls')), # Store is the home app
|
||||||
|
path('home', RedirectView.as_view(url='/', permanent=True), name='home'), # Redirect /home to /
|
||||||
|
path('decks/', include('decks.urls')),
|
||||||
|
]
|
||||||
16
config/wsgi.py
Normal file
16
config/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for config project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
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})
|
||||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from example-tcg-site!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[project]
|
||||||
|
name = "example-tcg-site"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"black>=26.1.0",
|
||||||
|
"coverage>=7.13.1",
|
||||||
|
"django>=6.0.1",
|
||||||
|
"faker>=40.1.2",
|
||||||
|
"pillow>=12.1.0",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
|
"pytest-django>=4.11.1",
|
||||||
|
"requests>=2.32.5",
|
||||||
|
"stripe>=14.2.0",
|
||||||
|
]
|
||||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
DJANGO_SETTINGS_MODULE = config.settings
|
||||||
|
python_files = tests.py test_*.py *_tests.py
|
||||||
0
static/css/style.css
Normal file
0
static/css/style.css
Normal file
0
store/__init__.py
Normal file
0
store/__init__.py
Normal file
3
store/admin.py
Normal file
3
store/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
store/apps.py
Normal file
5
store/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StoreConfig(AppConfig):
|
||||||
|
name = 'store'
|
||||||
0
store/management/__init__.py
Normal file
0
store/management/__init__.py
Normal file
0
store/management/commands/__init__.py
Normal file
0
store/management/commands/__init__.py
Normal file
58
store/management/commands/create_test_packs.py
Normal file
58
store/management/commands/create_test_packs.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from store.models import Game, Set, Card, PackListing, VirtualPack
|
||||||
|
import random
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Creates test packs for MTG, Lorcana, and Pokemon'
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
games_data = [
|
||||||
|
{'name': 'Magic: The Gathering', 'slug': 'mtg', 'pack_price': 4.99},
|
||||||
|
{'name': 'Disney Lorcana', 'slug': 'lorcana', 'pack_price': 5.99},
|
||||||
|
{'name': 'Pokémon', 'slug': 'pokemon', 'pack_price': 3.99},
|
||||||
|
]
|
||||||
|
|
||||||
|
for game_info in games_data:
|
||||||
|
# Lookup by slug since it's unique
|
||||||
|
game, _ = Game.objects.get_or_create(
|
||||||
|
slug=game_info['slug'],
|
||||||
|
defaults={'name': game_info['name']}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure at least one set exists
|
||||||
|
set_obj, _ = Set.objects.get_or_create(
|
||||||
|
game=game,
|
||||||
|
name=f"{game.name} Base Set",
|
||||||
|
defaults={'code': 'BASE'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure cards exist
|
||||||
|
if not Card.objects.filter(set__game=game).exists():
|
||||||
|
self.stdout.write(f"Creating dummy cards for {game.name}...")
|
||||||
|
for i in range(20):
|
||||||
|
Card.objects.create(
|
||||||
|
set=set_obj,
|
||||||
|
name=f"{game.name} Card {i+1}",
|
||||||
|
rarity='Common',
|
||||||
|
collector_number=str(i+1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create Pack Listing
|
||||||
|
listing, _ = PackListing.objects.get_or_create(
|
||||||
|
game=game,
|
||||||
|
name=f"{game.name} Booster Pack",
|
||||||
|
defaults={'price': game_info['pack_price']}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(f"Generating packs for {listing.name}...")
|
||||||
|
|
||||||
|
all_game_cards = list(Card.objects.filter(set__game=game))
|
||||||
|
|
||||||
|
# Create 10 packs
|
||||||
|
for _ in range(10):
|
||||||
|
pack = VirtualPack.objects.create(listing=listing)
|
||||||
|
# Add 5 random cards
|
||||||
|
pack_cards = random.sample(all_game_cards, min(len(all_game_cards), 5))
|
||||||
|
pack.cards.set(pack_cards)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('Successfully created test packs'))
|
||||||
139
store/management/commands/populate_db.py
Normal file
139
store/management/commands/populate_db.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import random
|
||||||
|
import requests
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from store.models import Game, Set, Card, CardListing
|
||||||
|
from faker import Faker
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
fake = Faker()
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Populate database with MTG data from Scryfall and fake data for other games'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write('Starting database population...')
|
||||||
|
|
||||||
|
# Create Games
|
||||||
|
mtg, _ = Game.objects.get_or_create(name='Magic: The Gathering', slug='mtg')
|
||||||
|
pokemon, _ = Game.objects.get_or_create(name='Pokemon TCG', slug='pokemon')
|
||||||
|
lorcana, _ = Game.objects.get_or_create(name='Disney Lorcana', slug='lorcana')
|
||||||
|
|
||||||
|
# Populate MTG (Real Data)
|
||||||
|
self.populate_mtg(mtg)
|
||||||
|
|
||||||
|
# Populate Pokemon (Fake Data)
|
||||||
|
self.populate_fake_game(pokemon, 'Pokemon')
|
||||||
|
|
||||||
|
# Populate Lorcana (Fake Data)
|
||||||
|
self.populate_fake_game(lorcana, 'Lorcana')
|
||||||
|
|
||||||
|
# Create Superuser
|
||||||
|
if not User.objects.filter(username='admin').exists():
|
||||||
|
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
||||||
|
self.stdout.write(self.style.SUCCESS('Created superuser: admin/admin'))
|
||||||
|
|
||||||
|
# Create Demo Users
|
||||||
|
self.create_demo_users()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('Database populated successfully!'))
|
||||||
|
|
||||||
|
def populate_mtg(self, game):
|
||||||
|
self.stdout.write('Fetching MTG data from Scryfall...')
|
||||||
|
# Get a few sets
|
||||||
|
sets_api = "https://api.scryfall.com/sets"
|
||||||
|
try:
|
||||||
|
resp = requests.get(sets_api).json()
|
||||||
|
# Pick top 3 recent expansion sets
|
||||||
|
target_sets = [s for s in resp['data'] if s['set_type'] == 'expansion'][:3]
|
||||||
|
|
||||||
|
for s_data in target_sets:
|
||||||
|
set_obj, created = Set.objects.get_or_create(
|
||||||
|
game=game,
|
||||||
|
name=s_data['name'],
|
||||||
|
code=s_data['code'],
|
||||||
|
defaults={'release_date': s_data.get('released_at')}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(f"Created set: {set_obj.name}")
|
||||||
|
# Fetch cards for this set
|
||||||
|
cards_url = s_data['search_uri']
|
||||||
|
cards_resp = requests.get(cards_url).json()
|
||||||
|
|
||||||
|
for card_data in cards_resp.get('data', [])[:20]: # Limit to 20 cards per set to be fast
|
||||||
|
if 'image_uris' in card_data:
|
||||||
|
image = card_data['image_uris'].get('normal')
|
||||||
|
elif 'card_faces' in card_data and 'image_uris' in card_data['card_faces'][0]:
|
||||||
|
image = card_data['card_faces'][0]['image_uris'].get('normal')
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
card, _ = Card.objects.get_or_create(
|
||||||
|
set=set_obj,
|
||||||
|
name=card_data['name'],
|
||||||
|
defaults={
|
||||||
|
'rarity': card_data['rarity'].capitalize(),
|
||||||
|
'image_url': image,
|
||||||
|
'scryfall_id': card_data['id'],
|
||||||
|
'tcgplayer_id': card_data.get('tcgplayer_id'),
|
||||||
|
'collector_number': card_data['collector_number']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create Listings
|
||||||
|
self.create_listings_for_card(card)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.ERROR(f"Failed to fetch MTG data: {e}"))
|
||||||
|
|
||||||
|
def populate_fake_game(self, game, prefix):
|
||||||
|
self.stdout.write(f'Generating data for {game.name}...')
|
||||||
|
for i in range(3): # 3 Sets
|
||||||
|
set_name = f"{prefix} Set {i+1}"
|
||||||
|
set_obj, _ = Set.objects.get_or_create(
|
||||||
|
game=game,
|
||||||
|
name=set_name,
|
||||||
|
code=f"{prefix[:3].upper()}{i+1}",
|
||||||
|
defaults={'release_date': fake.date_between(start_date='-2y', end_date='today')}
|
||||||
|
)
|
||||||
|
|
||||||
|
for j in range(15): # 15 Cards per set
|
||||||
|
card, _ = Card.objects.get_or_create(
|
||||||
|
set=set_obj,
|
||||||
|
name=f"{prefix} Monster {fake.word().capitalize()}",
|
||||||
|
defaults={
|
||||||
|
'rarity': random.choice(['Common', 'Uncommon', 'Rare', 'Ultra Rare']),
|
||||||
|
'image_url': f"https://placehold.co/400x600?text={prefix}+{j}",
|
||||||
|
'collector_number': str(j+1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.create_listings_for_card(card)
|
||||||
|
|
||||||
|
def create_listings_for_card(self, card):
|
||||||
|
# Create 1-5 listings per card with different conditions
|
||||||
|
for _ in range(random.randint(1, 4)):
|
||||||
|
condition = random.choice(['NM', 'LP', 'MP', 'HP'])
|
||||||
|
price = round(random.uniform(0.50, 100.00), 2)
|
||||||
|
CardListing.objects.create(
|
||||||
|
card=card,
|
||||||
|
condition=condition,
|
||||||
|
price=price,
|
||||||
|
quantity=random.randint(1, 20),
|
||||||
|
market_price=price, # Simplified
|
||||||
|
is_foil=random.choice([True, False])
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_demo_users(self):
|
||||||
|
# Create a Pro user
|
||||||
|
if not User.objects.filter(username='prouser').exists():
|
||||||
|
u = User.objects.create_user('prouser', 'pro@example.com', 'password')
|
||||||
|
u.profile.is_pro = True
|
||||||
|
u.profile.save()
|
||||||
|
self.stdout.write("Created prouser/password")
|
||||||
|
|
||||||
|
# Create a Basic user
|
||||||
|
if not User.objects.filter(username='basicuser').exists():
|
||||||
|
User.objects.create_user('basicuser', 'basic@example.com', 'password')
|
||||||
|
self.stdout.write("Created basicuser/password")
|
||||||
90
store/migrations/0001_initial.py
Normal file
90
store/migrations/0001_initial.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 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 = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Card',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('rarity', models.CharField(blank=True, max_length=50)),
|
||||||
|
('image_url', models.URLField(blank=True, max_length=500)),
|
||||||
|
('scryfall_id', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('tcgplayer_id', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('collector_number', models.CharField(blank=True, max_length=50)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Cart',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CartItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Game',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Order',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('shipped', 'Shipped'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||||
|
('stripe_payment_intent', models.CharField(blank=True, max_length=100)),
|
||||||
|
('shipping_address', models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('price_at_purchase', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Set',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('code', models.CharField(blank=True, max_length=20)),
|
||||||
|
('release_date', models.DateField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CardListing',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('condition', models.CharField(choices=[('NM', 'Near Mint'), ('LP', 'Lightly Played'), ('MP', 'Moderately Played'), ('HP', 'Heavily Played')], default='NM', max_length=2)),
|
||||||
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('quantity', models.PositiveIntegerField(default=0)),
|
||||||
|
('market_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('is_foil', models.BooleanField(default=False)),
|
||||||
|
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listings', to='store.card')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
58
store/migrations/0002_initial.py
Normal file
58
store/migrations/0002_initial.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 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 = [
|
||||||
|
('store', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cart',
|
||||||
|
name='user',
|
||||||
|
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cartitem',
|
||||||
|
name='cart',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='store.cart'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cartitem',
|
||||||
|
name='listing',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='listing',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='store.order'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='set',
|
||||||
|
name='game',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sets', to='store.game'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='card',
|
||||||
|
name='set',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='store.set'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-01-19 16:22
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('store', '0002_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cart',
|
||||||
|
name='insurance',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='insurance_purchased',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='proxy_service',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Bounty',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('target_price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('quantity_wanted', models.PositiveIntegerField(default=1)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.card')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-01-19 18:44
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('store', '0003_cart_insurance_order_insurance_purchased_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cartitem',
|
||||||
|
name='listing',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='listing',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PackListing',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('image_url', models.URLField(blank=True, max_length=500)),
|
||||||
|
('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.game')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cartitem',
|
||||||
|
name='pack_listing',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.packlisting'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='pack_listing',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.packlisting'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VirtualPack',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('sealed', 'Sealed'), ('opened', 'Opened')], default='sealed', max_length=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('cards', models.ManyToManyField(related_name='packs', to='store.card')),
|
||||||
|
('listing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packs', to='store.packlisting')),
|
||||||
|
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='packs', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
29
store/migrations/0005_vaultitem.py
Normal file
29
store/migrations/0005_vaultitem.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-01-19 19:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('store', '0004_alter_cartitem_listing_alter_orderitem_listing_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VaultItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='store.card')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'card')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
store/migrations/__init__.py
Normal file
0
store/migrations/__init__.py
Normal file
152
store/models.py
Normal file
152
store/models.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class Game(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Set(models.Model):
|
||||||
|
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='sets')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
code = models.CharField(max_length=20, blank=True)
|
||||||
|
release_date = models.DateField(null=True, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.game.name} - {self.name}"
|
||||||
|
|
||||||
|
class Card(models.Model):
|
||||||
|
set = models.ForeignKey(Set, on_delete=models.CASCADE, related_name='cards')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
rarity = models.CharField(max_length=50, blank=True)
|
||||||
|
image_url = models.URLField(max_length=500, blank=True)
|
||||||
|
scryfall_id = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
tcgplayer_id = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
collector_number = models.CharField(max_length=50, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class CardListing(models.Model):
|
||||||
|
CONDITION_CHOICES = (
|
||||||
|
('NM', 'Near Mint'),
|
||||||
|
('LP', 'Lightly Played'),
|
||||||
|
('MP', 'Moderately Played'),
|
||||||
|
('HP', 'Heavily Played'),
|
||||||
|
)
|
||||||
|
|
||||||
|
card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='listings')
|
||||||
|
condition = models.CharField(max_length=2, choices=CONDITION_CHOICES, default='NM')
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
quantity = models.PositiveIntegerField(default=0)
|
||||||
|
market_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
is_foil = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.card.name} ({self.condition}) - ${self.price}"
|
||||||
|
|
||||||
|
class PackListing(models.Model):
|
||||||
|
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='pack_listings')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
image_url = models.URLField(max_length=500, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} - ${self.price}"
|
||||||
|
|
||||||
|
class VirtualPack(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('sealed', 'Sealed'),
|
||||||
|
('opened', 'Opened'),
|
||||||
|
)
|
||||||
|
listing = models.ForeignKey(PackListing, on_delete=models.CASCADE, related_name='packs')
|
||||||
|
cards = models.ManyToManyField(Card, related_name='packs')
|
||||||
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='sealed')
|
||||||
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='packs')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.listing.name} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
class Order(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('paid', 'Paid'),
|
||||||
|
('shipped', 'Shipped'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders')
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
total_price = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||||
|
stripe_payment_intent = models.CharField(max_length=100, blank=True)
|
||||||
|
shipping_address = models.TextField(blank=True)
|
||||||
|
insurance_purchased = models.BooleanField(default=False)
|
||||||
|
proxy_service = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Order #{self.id} - {self.user.username}"
|
||||||
|
|
||||||
|
class OrderItem(models.Model):
|
||||||
|
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
|
||||||
|
listing = models.ForeignKey(CardListing, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
pack_listing = models.ForeignKey(PackListing, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
price_at_purchase = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.pack_listing:
|
||||||
|
return f"{self.quantity}x {self.pack_listing.name}"
|
||||||
|
return f"{self.quantity}x {self.listing.card.name if self.listing else 'Deleted Listing'}"
|
||||||
|
|
||||||
|
class Cart(models.Model):
|
||||||
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='cart', null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
insurance = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_price(self):
|
||||||
|
base_total = sum(item.total_price for item in self.items.all())
|
||||||
|
if self.insurance:
|
||||||
|
return base_total + 5 # Flat fee
|
||||||
|
return base_total
|
||||||
|
|
||||||
|
class CartItem(models.Model):
|
||||||
|
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
|
||||||
|
listing = models.ForeignKey(CardListing, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
pack_listing = models.ForeignKey(PackListing, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_price(self):
|
||||||
|
if self.listing:
|
||||||
|
return self.listing.price * self.quantity
|
||||||
|
if self.pack_listing:
|
||||||
|
return self.pack_listing.price * self.quantity
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class Bounty(models.Model):
|
||||||
|
card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='bounties')
|
||||||
|
target_price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
quantity_wanted = models.PositiveIntegerField(default=1)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"WANTED: {self.card.name} @ ${self.target_price}"
|
||||||
|
|
||||||
|
class VaultItem(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='vault_items')
|
||||||
|
card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='vault_items')
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'card')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username}'s {self.card.name} ({self.quantity})"
|
||||||
3
store/tests.py
Normal file
3
store/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
23
store/urls.py
Normal file
23
store/urls.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'store'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.card_list, name='card_list'), # Home page associated with 'card_list' view
|
||||||
|
path('home/', views.card_list, name='home'), # Explicit home alias for readability and templates using 'home' naming convention
|
||||||
|
path('card/<int:card_id>/', views.card_detail, name='card_detail'),
|
||||||
|
path('cart/', views.cart_view, name='cart'),
|
||||||
|
path('cart/add/<int:listing_id>/', views.add_to_cart, name='add_to_cart'),
|
||||||
|
path('cart/remove/<int:item_id>/', views.remove_from_cart, name='remove_from_cart'),
|
||||||
|
path('api/stock/<int:card_id>/', views.get_card_stock, name='get_card_stock'),
|
||||||
|
path('deck-buyer/', views.deck_buyer, name='deck_buyer'),
|
||||||
|
path('cart/insurance/', views.toggle_insurance, name='toggle_insurance'),
|
||||||
|
path('bounty-board/', views.bounty_board, name='bounty_board'),
|
||||||
|
path('packs/', views.pack_list, name='pack_list'),
|
||||||
|
path('cart/add-pack/<int:pack_listing_id>/', views.add_pack_to_cart, name='add_pack_to_cart'),
|
||||||
|
path('checkout/', views.checkout, name='checkout'),
|
||||||
|
path('my-packs/', views.my_packs, name='my_packs'),
|
||||||
|
path('packs/open/<int:pack_id>/', views.open_pack, name='open_pack'),
|
||||||
|
path('order/<int:order_id>/', views.order_detail, name='order_detail'),
|
||||||
|
]
|
||||||
133
store/utils.py
Normal file
133
store/utils.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import re
|
||||||
|
from .models import Card, CardListing, Order, OrderItem, VaultItem
|
||||||
|
from django.db.models import Min
|
||||||
|
|
||||||
|
def add_to_vault(user, card, quantity=1):
|
||||||
|
"""
|
||||||
|
Adds a card to the user's vault.
|
||||||
|
"""
|
||||||
|
vault_item, created = VaultItem.objects.get_or_create(user=user, card=card)
|
||||||
|
if not created:
|
||||||
|
vault_item.quantity += quantity
|
||||||
|
else:
|
||||||
|
vault_item.quantity = quantity
|
||||||
|
vault_item.save()
|
||||||
|
|
||||||
|
def parse_deck_list(deck_text):
|
||||||
|
"""
|
||||||
|
Parses a deck list string and returns a list of dictionaries with 'quantity' and 'name'.
|
||||||
|
Format expected: "4 Lightning Bolt" or "1x Lightning Bolt"
|
||||||
|
"""
|
||||||
|
lines = deck_text.strip().split('\n')
|
||||||
|
parsed_cards = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = re.match(r'^(\d+)[xX]?\s+(.+)$', line)
|
||||||
|
if match:
|
||||||
|
quantity = int(match.group(1))
|
||||||
|
name = match.group(2).strip()
|
||||||
|
parsed_cards.append({'quantity': quantity, 'name': name})
|
||||||
|
else:
|
||||||
|
# Maybe just name? assume 1
|
||||||
|
parsed_cards.append({'quantity': 1, 'name': line})
|
||||||
|
|
||||||
|
return parsed_cards
|
||||||
|
|
||||||
|
def find_best_listings_for_deck(parsed_cards):
|
||||||
|
"""
|
||||||
|
Finds cheapest listings for the parsed cards.
|
||||||
|
Returns:
|
||||||
|
- found_items: list of {listing, quantity_needed, total_cost, card_name}
|
||||||
|
- missing_items: list of {name, quantity}
|
||||||
|
"""
|
||||||
|
found_items = []
|
||||||
|
missing_items = []
|
||||||
|
|
||||||
|
for item in parsed_cards:
|
||||||
|
name = item['name']
|
||||||
|
qty_needed = item['quantity']
|
||||||
|
|
||||||
|
# Find card (simple name match)
|
||||||
|
cards = Card.objects.filter(name__iexact=name)
|
||||||
|
if not cards.exists():
|
||||||
|
# Try contains
|
||||||
|
cards = Card.objects.filter(name__icontains=name)
|
||||||
|
|
||||||
|
if not cards.exists():
|
||||||
|
missing_items.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find cheapest listing with stock
|
||||||
|
# We try to fill the quantity from multiple listings if needed
|
||||||
|
listings = CardListing.objects.filter(
|
||||||
|
card__in=cards,
|
||||||
|
quantity__gt=0
|
||||||
|
).order_by('price')
|
||||||
|
|
||||||
|
qty_remaining = qty_needed
|
||||||
|
|
||||||
|
for listing in listings:
|
||||||
|
if qty_remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
qty_to_take = min(listing.quantity, qty_remaining)
|
||||||
|
|
||||||
|
found_items.append({
|
||||||
|
'listing': listing,
|
||||||
|
'quantity': qty_to_take,
|
||||||
|
'card_name': listing.card.name,
|
||||||
|
'price': listing.price,
|
||||||
|
'total': listing.price * qty_to_take
|
||||||
|
})
|
||||||
|
|
||||||
|
qty_remaining -= qty_to_take
|
||||||
|
|
||||||
|
if qty_remaining > 0:
|
||||||
|
missing_items.append({'name': name, 'quantity': qty_remaining})
|
||||||
|
|
||||||
|
return found_items, missing_items
|
||||||
|
|
||||||
|
def get_user_collection(user):
|
||||||
|
"""
|
||||||
|
Returns a dict {card_name: quantity} of cards in user's vault.
|
||||||
|
"""
|
||||||
|
owned = {}
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return owned
|
||||||
|
|
||||||
|
vault_items = VaultItem.objects.filter(user=user).select_related('card')
|
||||||
|
for item in vault_items:
|
||||||
|
owned[item.card.name] = item.quantity
|
||||||
|
|
||||||
|
return owned
|
||||||
|
|
||||||
|
def filter_deck_by_collection(parsed_cards, owned_cards):
|
||||||
|
"""
|
||||||
|
Subtracts owned quantities from parsed_cards.
|
||||||
|
Returns new list of parsed_cards.
|
||||||
|
"""
|
||||||
|
filtered = []
|
||||||
|
for item in parsed_cards:
|
||||||
|
name = item['name']
|
||||||
|
needed = item['quantity']
|
||||||
|
# Simple name match
|
||||||
|
owned_qty = 0
|
||||||
|
# Try exact match first
|
||||||
|
if name in owned_cards:
|
||||||
|
owned_qty = owned_cards[name]
|
||||||
|
else:
|
||||||
|
# Try case insensitive fallback
|
||||||
|
for key in owned_cards:
|
||||||
|
if key.lower() == name.lower():
|
||||||
|
owned_qty = owned_cards[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
remaining = needed - owned_qty
|
||||||
|
if remaining > 0:
|
||||||
|
filtered.append({'name': name, 'quantity': remaining})
|
||||||
|
|
||||||
|
return filtered
|
||||||
282
store/views.py
Normal file
282
store/views.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from .models import Card, Game, Set, Cart, CartItem, CardListing, PackListing, VirtualPack, Order, OrderItem
|
||||||
|
import random
|
||||||
|
|
||||||
|
def card_list(request):
|
||||||
|
cards = Card.objects.all().select_related('set', 'set__game').prefetch_related('listings')
|
||||||
|
|
||||||
|
# Filtering
|
||||||
|
game_slug = request.GET.get('game')
|
||||||
|
if game_slug:
|
||||||
|
cards = cards.filter(set__game__slug=game_slug)
|
||||||
|
|
||||||
|
search_query = request.GET.get('q')
|
||||||
|
if search_query:
|
||||||
|
cards = cards.filter(name__icontains=search_query)
|
||||||
|
|
||||||
|
set_id = request.GET.get('set')
|
||||||
|
if set_id:
|
||||||
|
cards = cards.filter(set__id=set_id)
|
||||||
|
|
||||||
|
# Simple logic: only show cards that have listings or show all?
|
||||||
|
# Let's show all for browsing, but indicate stock.
|
||||||
|
|
||||||
|
paginator = Paginator(cards.order_by('name'), 24) # 24 cards per page
|
||||||
|
page_number = request.GET.get('page')
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
games = Game.objects.all()
|
||||||
|
# If a game is selected, getting its sets for the filter dropdown
|
||||||
|
sets = Set.objects.filter(game__slug=game_slug) if game_slug else Set.objects.all()[:50] # Limit default sets
|
||||||
|
|
||||||
|
return render(request, 'store/card_list.html', {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'games': games,
|
||||||
|
'sets': sets,
|
||||||
|
'current_game': game_slug,
|
||||||
|
'search_query': search_query,
|
||||||
|
})
|
||||||
|
|
||||||
|
def card_detail(request, card_id):
|
||||||
|
card = get_object_or_404(Card, id=card_id)
|
||||||
|
listings = card.listings.filter(quantity__gt=0).order_by('price')
|
||||||
|
return render(request, 'store/card_detail.html', {'card': card, 'listings': listings})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_to_cart(request, listing_id):
|
||||||
|
listing = get_object_or_404(CardListing, id=listing_id)
|
||||||
|
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
cart_item, created = CartItem.objects.get_or_create(cart=cart, listing=listing)
|
||||||
|
if not created:
|
||||||
|
cart_item.quantity += 1
|
||||||
|
cart_item.save()
|
||||||
|
|
||||||
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def cart_view(request):
|
||||||
|
try:
|
||||||
|
cart = request.user.cart
|
||||||
|
except Cart.DoesNotExist:
|
||||||
|
cart = None
|
||||||
|
return render(request, 'store/cart.html', {'cart': cart})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def remove_from_cart(request, item_id):
|
||||||
|
if hasattr(request.user, 'cart'):
|
||||||
|
item = get_object_or_404(CartItem, id=item_id, cart=request.user.cart)
|
||||||
|
item.delete()
|
||||||
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def toggle_insurance(request):
|
||||||
|
if hasattr(request.user, 'cart'):
|
||||||
|
cart = request.user.cart
|
||||||
|
cart.insurance = not cart.insurance
|
||||||
|
cart.save()
|
||||||
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
def get_card_stock(request, card_id):
|
||||||
|
card = get_object_or_404(Card, id=card_id)
|
||||||
|
listings = card.listings.all()
|
||||||
|
stock_breakdown = {}
|
||||||
|
total_stock = 0
|
||||||
|
for listing in listings:
|
||||||
|
stock_breakdown[listing.get_condition_display()] = listing.quantity
|
||||||
|
total_stock += listing.quantity
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'card_id': card.id,
|
||||||
|
'total_stock': total_stock,
|
||||||
|
'breakdown': stock_breakdown
|
||||||
|
})
|
||||||
|
|
||||||
|
from .utils import parse_deck_list, find_best_listings_for_deck, get_user_collection, filter_deck_by_collection, add_to_vault
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def deck_buyer(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.POST.get('action')
|
||||||
|
|
||||||
|
if action == 'preview':
|
||||||
|
deck_text = request.POST.get('deck_text')
|
||||||
|
ignore_owned = request.POST.get('ignore_owned') == 'on'
|
||||||
|
|
||||||
|
parsed = parse_deck_list(deck_text)
|
||||||
|
|
||||||
|
if ignore_owned and request.user.is_authenticated:
|
||||||
|
owned = get_user_collection(request.user)
|
||||||
|
parsed = filter_deck_by_collection(parsed, owned)
|
||||||
|
|
||||||
|
found, missing = find_best_listings_for_deck(parsed)
|
||||||
|
|
||||||
|
total_cost = sum(item['total'] for item in found)
|
||||||
|
|
||||||
|
return render(request, 'store/deck_buyer.html', {
|
||||||
|
'found_items': found,
|
||||||
|
'missing_items': missing,
|
||||||
|
'deck_text': deck_text,
|
||||||
|
'total_cost': total_cost,
|
||||||
|
'preview': True,
|
||||||
|
'ignore_owned': ignore_owned
|
||||||
|
})
|
||||||
|
|
||||||
|
elif action == 'add_to_cart':
|
||||||
|
# Re-parse or rely on hidden fields?
|
||||||
|
# Re-parsing is safer/easier for now than passing complex data
|
||||||
|
deck_text = request.POST.get('deck_text')
|
||||||
|
parsed = parse_deck_list(deck_text)
|
||||||
|
found, _ = find_best_listings_for_deck(parsed)
|
||||||
|
|
||||||
|
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for item in found:
|
||||||
|
listing = item['listing']
|
||||||
|
qty = item['quantity']
|
||||||
|
# Check stock again? "Live stock"
|
||||||
|
if listing.quantity >= qty:
|
||||||
|
cart_item, created = CartItem.objects.get_or_create(cart=cart, listing=listing)
|
||||||
|
if not created:
|
||||||
|
cart_item.quantity += qty
|
||||||
|
else:
|
||||||
|
cart_item.quantity = qty
|
||||||
|
cart_item.save()
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
return render(request, 'store/deck_buyer.html')
|
||||||
|
|
||||||
|
from .models import Bounty
|
||||||
|
|
||||||
|
def bounty_board(request):
|
||||||
|
bounties = Bounty.objects.filter(is_active=True).select_related('card', 'card__set').order_by('-created_at')
|
||||||
|
return render(request, 'store/bounty_board.html', {'bounties': bounties})
|
||||||
|
|
||||||
|
def pack_list(request):
|
||||||
|
packs = PackListing.objects.all()
|
||||||
|
return render(request, 'store/pack_list.html', {'packs': packs})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_pack_to_cart(request, pack_listing_id):
|
||||||
|
listing = get_object_or_404(PackListing, id=pack_listing_id)
|
||||||
|
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
cart_item, created = CartItem.objects.get_or_create(cart=cart, pack_listing=listing)
|
||||||
|
if not created:
|
||||||
|
cart_item.quantity += 1
|
||||||
|
cart_item.save()
|
||||||
|
|
||||||
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def checkout(request):
|
||||||
|
try:
|
||||||
|
cart = request.user.cart
|
||||||
|
except Cart.DoesNotExist:
|
||||||
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
if not cart.items.exists():
|
||||||
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
# Create Order
|
||||||
|
order = Order.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
status='paid',
|
||||||
|
total_price=cart.total_price
|
||||||
|
)
|
||||||
|
|
||||||
|
# Move items
|
||||||
|
for item in cart.items.all():
|
||||||
|
OrderItem.objects.create(
|
||||||
|
order=order,
|
||||||
|
listing=item.listing,
|
||||||
|
pack_listing=item.pack_listing,
|
||||||
|
price_at_purchase=item.listing.price if item.listing else item.pack_listing.price,
|
||||||
|
quantity=item.quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add single cards to vault
|
||||||
|
if item.listing:
|
||||||
|
add_to_vault(request.user, item.listing.card, item.quantity)
|
||||||
|
|
||||||
|
# If it's a pack, assign VirtualPacks to user
|
||||||
|
if item.pack_listing:
|
||||||
|
# Find available sealed packs
|
||||||
|
available_packs = list(VirtualPack.objects.filter(
|
||||||
|
listing=item.pack_listing,
|
||||||
|
owner__isnull=True,
|
||||||
|
status='sealed'
|
||||||
|
)[:item.quantity])
|
||||||
|
|
||||||
|
# If not enough, create more
|
||||||
|
if len(available_packs) < item.quantity:
|
||||||
|
needed = item.quantity - len(available_packs)
|
||||||
|
game = item.pack_listing.game
|
||||||
|
all_game_cards = list(Card.objects.filter(set__game=game))
|
||||||
|
if not all_game_cards:
|
||||||
|
# Fallback if no cards? Should not happen due to management command or basic setup
|
||||||
|
pass
|
||||||
|
|
||||||
|
for _ in range(needed):
|
||||||
|
pack = VirtualPack.objects.create(listing=item.pack_listing)
|
||||||
|
if all_game_cards:
|
||||||
|
pack.cards.set(random.sample(all_game_cards, min(len(all_game_cards), 5)))
|
||||||
|
available_packs.append(pack)
|
||||||
|
|
||||||
|
for pack in available_packs:
|
||||||
|
pack.owner = request.user
|
||||||
|
pack.save()
|
||||||
|
|
||||||
|
# Clear cart
|
||||||
|
cart.items.all().delete()
|
||||||
|
|
||||||
|
return redirect('store:my_packs')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def my_packs(request):
|
||||||
|
packs = VirtualPack.objects.filter(owner=request.user, status='sealed').select_related('listing')
|
||||||
|
return render(request, 'store/my_packs.html', {'packs': packs})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def open_pack(request, pack_id):
|
||||||
|
pack = get_object_or_404(VirtualPack, id=pack_id, owner=request.user)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if pack.status == 'sealed':
|
||||||
|
pack.status = 'opened'
|
||||||
|
pack.save()
|
||||||
|
|
||||||
|
# Add cards to vault
|
||||||
|
for card in pack.cards.all():
|
||||||
|
add_to_vault(request.user, card)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'cards': [{
|
||||||
|
'name': c.name,
|
||||||
|
'image_url': c.image_url,
|
||||||
|
'rarity': c.rarity,
|
||||||
|
'set': c.set.name
|
||||||
|
} for c in pack.cards.all()]
|
||||||
|
}
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
return render(request, 'store/open_pack.html', {'pack': pack})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def order_detail(request, order_id):
|
||||||
|
order = get_object_or_404(Order, id=order_id)
|
||||||
|
# Security check: only allow viewing own orders (unless superuser)
|
||||||
|
if order.user != request.user and not request.user.is_superuser:
|
||||||
|
return redirect('users:profile')
|
||||||
|
|
||||||
|
return render(request, 'store/order_detail.html', {'order': order})
|
||||||
|
|
||||||
347
templates/base/layout.html
Normal file
347
templates/base/layout.html
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="{% if user.is_authenticated %}{{ user.profile.theme_preference }}{% else %}dark{% endif %}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Phantom Card Fam - Premium TCG Store{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||||
|
{% if not debug %}
|
||||||
|
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cmklg5jenh4wx14nrurt5yqyl"></script>
|
||||||
|
{% endif %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #6366f1;
|
||||||
|
--secondary-color: #a855f7;
|
||||||
|
--bg-color: #0f172a;
|
||||||
|
--text-color: #f8fafc;
|
||||||
|
--card-bg: #1e293b;
|
||||||
|
--border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--primary-color: #4f46e5;
|
||||||
|
--secondary-color: #9333ea;
|
||||||
|
--bg-color: #f8fafc;
|
||||||
|
--text-color: #0f172a;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcg-card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcg-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcg-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcg-card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages li {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages .success { background-color: #064e3b; color: #a7f3d0; }
|
||||||
|
.messages .error { background-color: #7f1d1d; color: #fecaca; }
|
||||||
|
[data-theme="light"] .messages .success { background-color: #d1fae5; color: #065f46; }
|
||||||
|
[data-theme="light"] .messages .error { background-color: #fce7f3; color: #9d174d; }
|
||||||
|
|
||||||
|
/* Auth Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s, visibility 0.3s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-modal {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active .auth-modal {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-modal h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-modal p {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background-color: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="background: #f59e0b; color: #000; text-align: center; padding: 0.5rem; font-weight: 600; font-size: 0.875rem;">
|
||||||
|
DEMO SITE: This is an example application. No real products, payments, or purchases are processed.
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<a href="{% url 'home' %}" class="nav-brand">Phantom Card Fam</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="{% url 'store:card_list' %}">Browse</a>
|
||||||
|
<a href="{% url 'store:pack_list' %}">Packs</a>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<a href="{% url 'decks:deck_list' %}">Decks</a>
|
||||||
|
<a href="{% url 'users:vault' %}">Vault</a>
|
||||||
|
<a href="{% url 'store:my_packs' %}">My Packs</a>
|
||||||
|
<a href="{% url 'store:cart' %}">Cart ({{ user.cart.items.count|default:0 }})</a>
|
||||||
|
<a href="{% url 'users:profile' %}">Profile</a>
|
||||||
|
<form action="{% url 'logout' %}" method="post" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn" style="background:none; color:var(--text-color); margin-left:1rem;">Logout</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'decks:deck_list' %}"
|
||||||
|
class="auth-required"
|
||||||
|
data-feature-title="Deck Builder"
|
||||||
|
data-feature-desc="Build and save custom decks, analyze mana curves, and prepare your strategies for battle. Create an account to start building!">Decks</a>
|
||||||
|
|
||||||
|
<a href="{% url 'users:vault' %}"
|
||||||
|
class="auth-required"
|
||||||
|
data-feature-title="Collection Vault"
|
||||||
|
data-feature-desc="Track your entire card collection, monitor value trends, and manage your inventory in one place. Log in to access your Vault.">Vault</a>
|
||||||
|
|
||||||
|
<a href="{% url 'store:my_packs' %}"
|
||||||
|
class="auth-required"
|
||||||
|
data-feature-title="My Packs"
|
||||||
|
data-feature-desc="Open virtual packs, collect rare cards, and grow your digital library. Sign up now to start cracking packs!">My Packs</a>
|
||||||
|
|
||||||
|
<a href="{% url 'login' %}">Login</a>
|
||||||
|
<a href="{% url 'users:register' %}">Register</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer style="text-align: center; padding: 2rem; color: #64748b; font-size: 0.875rem;">
|
||||||
|
<p>© 2026 Phantom Card Fam TCG Store.</p>
|
||||||
|
<p>Made by <a href="https://aimloperations.com" target="_blank" style="color: inherit; text-decoration: underline;">AI ML Operations, LLC</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{% if not user.is_authenticated %}
|
||||||
|
<div id="authModal" class="modal-overlay">
|
||||||
|
<div class="auth-modal">
|
||||||
|
<button class="close-modal" aria-label="Close modal">×</button>
|
||||||
|
<h2 id="modalTitle">Login Required</h2>
|
||||||
|
<p id="modalDesc">Please log in to access this feature.</p>
|
||||||
|
<div class="auth-modal-actions">
|
||||||
|
<a href="{% url 'login' %}" class="btn">Log In</a>
|
||||||
|
<a href="{% url 'users:register' %}" class="btn btn-outline">Create Account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const modal = document.getElementById('authModal');
|
||||||
|
const closeBtn = document.querySelector('.close-modal');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalDesc = document.getElementById('modalDesc');
|
||||||
|
const authLinks = document.querySelectorAll('.auth-required');
|
||||||
|
|
||||||
|
function openModal(title, desc) {
|
||||||
|
modalTitle.textContent = title;
|
||||||
|
modalDesc.textContent = desc;
|
||||||
|
modal.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden'; // Prevent scrolling
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
authLinks.forEach(link => {
|
||||||
|
link.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = this.dataset.featureTitle || 'Login Required';
|
||||||
|
const desc = this.dataset.featureDesc || 'Please log in to access this feature.';
|
||||||
|
openModal(title, desc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
83
templates/decks/deck_builder.html
Normal file
83
templates/decks/deck_builder.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="display: grid; grid-template-columns: 350px 1fr; gap: 2rem; height: calc(100vh - 100px);">
|
||||||
|
<!-- Card Search Sidebar -->
|
||||||
|
<div
|
||||||
|
style="background: var(--card-bg); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; overflow: hidden; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<div style="padding: 1rem; border-bottom: 1px solid var(--border-color);">
|
||||||
|
<h3 style="margin: 0 0 1rem;">Add Cards</h3>
|
||||||
|
<form method="get">
|
||||||
|
<input type="text" name="q" value="{{ query|default:'' }}"
|
||||||
|
placeholder="Search {{ deck.game.name }} cards..."
|
||||||
|
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow-y: auto; flex: 1; padding: 1rem;">
|
||||||
|
{% if search_results %}
|
||||||
|
{% for card in search_results %}
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center;">
|
||||||
|
{% if card.image_url %}
|
||||||
|
<img src="{{ card.image_url }}"
|
||||||
|
style="width: 40px; height: 56px; object-fit: cover; border-radius: 2px;">
|
||||||
|
{% endif %}
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-size: 0.875rem; font-weight: 500;">{{ card.name }}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: #94a3b8;">{{ card.set.code }}</div>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="card_id" value="{{ card.id }}">
|
||||||
|
<button type="submit" class="btn" style="padding: 0.25rem 0.5rem; font-size: 1.25rem;">+</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif query %}
|
||||||
|
<p style="text-align: center; color: #94a3b8;">No cards found.</p>
|
||||||
|
{% else %}
|
||||||
|
<p style="text-align: center; color: #94a3b8; margin-top: 2rem;">Search to add cards.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deck View -->
|
||||||
|
<div style="display: flex; flex-direction: column; overflow: hidden;">
|
||||||
|
<div style="margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h1 style="margin: 0;">{{ deck.name }}</h1>
|
||||||
|
<p style="margin: 0.25rem 0 0; color: #94a3b8;">{{ deck.game.name }} • {{ deck.cards.count }} cards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn">Save & Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; align-content: start;">
|
||||||
|
{% for item in deck.cards.all %}
|
||||||
|
<div style="position: relative; group">
|
||||||
|
<div
|
||||||
|
style="aspect-ratio: 2.5/3.5; background: #000; border-radius: 4px; overflow: hidden; position: relative;">
|
||||||
|
{% if item.card.image_url %}
|
||||||
|
<img src="{{ item.card.image_url }}" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
{% endif %}
|
||||||
|
<div
|
||||||
|
style="position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.8); color: white; padding: 0.25rem 0.5rem; border-top-left-radius: 4px; font-weight: 700;">
|
||||||
|
x{{ item.quantity }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 0.25rem;">
|
||||||
|
<a href="{% url 'decks:remove_card' deck.id item.card.id %}"
|
||||||
|
style="color: #ef4444; font-size: 0.75rem; text-decoration: none;">Remove</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div
|
||||||
|
style="grid-column: 1/-1; text-align: center; padding: 4rem; border: 2px dashed var(--border-color); border-radius: 0.5rem; color: #94a3b8;">
|
||||||
|
<p>Your deck is empty. Add cards from the left sidebar.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
28
templates/decks/deck_create.html
Normal file
28
templates/decks/deck_create.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div
|
||||||
|
style="max-width: 400px; margin: 4rem auto; background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<h2 style="margin-top: 0;">Create New Deck</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; margin-bottom: 0.5rem;">Deck Name</label>
|
||||||
|
<input type="text" name="name" required
|
||||||
|
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label style="display: block; margin-bottom: 0.5rem;">Game Format</label>
|
||||||
|
<select name="game" required
|
||||||
|
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
{% for game in games %}
|
||||||
|
<option value="{{ game.id }}">{{ game.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">Create Deck</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
49
templates/decks/deck_import.html
Normal file
49
templates/decks/deck_import.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h4 class="mb-0">Import Deck</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Deck Name</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="game" class="form-label">Game</label>
|
||||||
|
<select class="form-select" id="game" name="game" required>
|
||||||
|
{% for game in games %}
|
||||||
|
<option value="{{ game.id }}">{{ game.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="deckList" class="form-label">Deck List</label>
|
||||||
|
<div class="form-text mb-2">
|
||||||
|
Supported formats: Arena, MTGO, Moxfield, Plain Text.
|
||||||
|
<br>Example: <code>4 Lightning Bolt</code> or <code>1 Sheoldred, the Apocalypse (DMU) 107</code>
|
||||||
|
</div>
|
||||||
|
<textarea class="form-control" id="deckList" name="deck_list" rows="15" required
|
||||||
|
placeholder="1 Card Name 1 Another Card ... SIDEBOARD: 1 Sideboard Card"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Import Deck</button>
|
||||||
|
<a href="{% url 'decks:deck_list' %}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
47
templates/decks/deck_list.html
Normal file
47
templates/decks/deck_list.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 800px; margin: 0 auto;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||||
|
<h1>My Decks</h1>
|
||||||
|
|
||||||
|
{% if user.profile.is_pro %}
|
||||||
|
<div class="col-md-6 text-end">
|
||||||
|
<a href="{% url 'decks:deck_import' %}" class="btn btn-outline-primary me-2">
|
||||||
|
<i class="bi bi-upload"></i> Import Deck
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'decks:deck_create' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-lg"></i> Create New Deck
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'users:profile' %}" class="btn" style="background: #475569;">Upgrade to Pro to Create Decks</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if decks %}
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for deck in decks %}
|
||||||
|
<div class="tcg-card">
|
||||||
|
<a href="{% url 'decks:deck_builder' deck.id %}"
|
||||||
|
style="text-decoration: none; color: inherit; display: block; height: 100%;">
|
||||||
|
<div
|
||||||
|
style="background: #334155; height: 120px; display: flex; align-items: center; justify-content: center; font-size: 3rem;">
|
||||||
|
🎴
|
||||||
|
</div>
|
||||||
|
<div class="tcg-card-body">
|
||||||
|
<h3 style="margin: 0;">{{ deck.name }}</h3>
|
||||||
|
<p style="margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.875rem;">{{ deck.game.name }}</p>
|
||||||
|
<p style="margin: 0.5rem 0 0; font-size: 0.875rem;">{{ deck.cards.count }} cards</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 4rem; background: var(--card-bg); border-radius: 0.5rem; color: #94a3b8;">
|
||||||
|
<p style="font-size: 1.25rem;">You haven't created any decks yet.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
17
templates/registration/login.html
Normal file
17
templates/registration/login.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div
|
||||||
|
style="max-width: 400px; margin: 4rem auto; background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<h2 style="margin-top: 0;">Login</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">Login</button>
|
||||||
|
</form>
|
||||||
|
<p style="margin-top: 1rem; text-align: center;">
|
||||||
|
Don't have an account? <a href="{% url 'users:register' %}" style="color: var(--primary-color);">Register
|
||||||
|
here</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
40
templates/store/bounty_board.html
Normal file
40
templates/store/bounty_board.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 1000px; margin: 0 auto;">
|
||||||
|
<div style="text-align: center; margin-bottom: 3rem;">
|
||||||
|
<h1 style="font-size: 2.5rem; margin-bottom: 0.5rem; background: linear-gradient(to right, #fbbf24, #d97706); -webkit-background-clip: text; color: transparent;">Community Bounty Board</h1>
|
||||||
|
<p style="color: #94a3b8; font-size: 1.125rem;">We're looking for these cards! Sell them to us for a bonus.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem;">
|
||||||
|
{% for bounty in bounties %}
|
||||||
|
<div style="background: var(--card-bg); border: 1px solid #d97706; border-radius: 0.75rem; overflow: hidden; position: relative;">
|
||||||
|
<div style="background: #d97706; color: white; padding: 0.25rem 0.5rem; position: absolute; top: 0; right: 0; font-weight: 700; font-size: 0.75rem; border-bottom-left-radius: 0.5rem;">
|
||||||
|
WANTED
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 1.5rem;">
|
||||||
|
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">{{ bounty.card.name }}</h3>
|
||||||
|
<p style="margin: 0; color: #94a3b8; font-size: 0.875rem;">{{ bounty.card.set.name }}</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem; display: flex; justify-content: space-between; align-items: flex-end;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.75rem; color: #94a3b8;">Buying At</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: 800; color: #fbbf24;">${{ bounty.target_price }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.25rem;">Qty Wanted: {{ bounty.quantity_wanted }}</div>
|
||||||
|
<button onclick="alert('Sell Offer Submitted! (Mockup)')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.875rem;">I Have This</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div style="grid-column: 1 / -1; text-align: center; padding: 4rem; background: var(--card-bg); border-radius: 0.5rem; border: 1px dashed var(--border-color);">
|
||||||
|
<p style="color: #94a3b8; font-size: 1.25rem;">No active bounties at the moment.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
125
templates/store/card_detail.html
Normal file
125
templates/store/card_detail.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 3rem;">
|
||||||
|
<!-- Image Column -->
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style="background: #000; border-radius: 0.75rem; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);">
|
||||||
|
{% if card.image_url %}
|
||||||
|
<img src="{{ card.image_url }}" alt="{{ card.name }}" style="width: 100%; display: block;">
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
style="aspect-ratio: 2.5/3.5; display: flex; align-items: center; justify-content: center; color: #64748b; background: #334155;">
|
||||||
|
No Image</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External Links -->
|
||||||
|
<div style="margin-top: 1.5rem; display: grid; gap: 0.5rem;">
|
||||||
|
{% if card.tcgplayer_id %}
|
||||||
|
<a href="https://www.tcgplayer.com/product/{{ card.tcgplayer_id }}" target="_blank"
|
||||||
|
style="display: block; text-align: center; padding: 0.75rem; background: #27272a; color: white; border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem;">
|
||||||
|
View on TCGPlayer
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="https://www.ebay.com/sch/i.html?_nkw={{ card.name|urlencode }}+{{ card.set.name|urlencode }}"
|
||||||
|
target="_blank"
|
||||||
|
style="display: block; text-align: center; padding: 0.75rem; background: #27272a; color: white; border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem;">
|
||||||
|
Search on eBay
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Column -->
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<h4 style="margin: 0; color: var(--primary-color);">{{ card.set.game.name }} • {{ card.set.name }}</h4>
|
||||||
|
<h1 style="margin: 0.5rem 0 0; font-size: 2.5rem;">{{ card.name }}</h1>
|
||||||
|
<div style="margin-top: 1rem; display: flex; gap: 1rem; color: #94a3b8;">
|
||||||
|
<span>Rarity: <strong style="color: var(--text-color);">{{ card.rarity }}</strong></span>
|
||||||
|
<span>Collector #: <strong style="color: var(--text-color);">{{ card.collector_number }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; margin-bottom: 1rem;">Available
|
||||||
|
Listings</h3>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="font-size: 0.875rem; color: #94a3b8;">Filter Condition:</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.25rem;">
|
||||||
|
<button onclick="filterCondition('ALL')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--card-bg); border: 1px solid var(--border-color);">All</button>
|
||||||
|
<button onclick="filterCondition('NM')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--card-bg); border: 1px solid var(--border-color);">NM</button>
|
||||||
|
<button onclick="filterCondition('LP')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--card-bg); border: 1px solid var(--border-color);">LP</button>
|
||||||
|
<button onclick="filterCondition('MP')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--card-bg); border: 1px solid var(--border-color);">MP</button>
|
||||||
|
<button onclick="filterCondition('HP')" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--card-bg); border: 1px solid var(--border-color);">HP</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="listings-container" style="display: flex; flex-direction: column; gap: 1rem;">
|
||||||
|
{% for listing in listings %}
|
||||||
|
<div class="listing-item" data-condition="{{ listing.condition }}"
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center; background: var(--card-bg); padding: 1rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||||
|
<span style="font-weight: 700; font-size: 1.25rem; width: 3rem; text-align: center;">{{
|
||||||
|
listing.condition }}</span>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.875rem; color: #94a3b8;">Condition</div>
|
||||||
|
{% if listing.is_foil %}
|
||||||
|
<span
|
||||||
|
style="background: linear-gradient(45deg, #f59e0b, #d97706); -webkit-background-clip: text; color: transparent; font-weight: 700; font-size: 0.75rem; text-transform: uppercase;">Foil</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 2rem;">
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div style="font-weight: 700; font-size: 1.5rem;">${{ listing.price }}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: #94a3b8;">{{ listing.quantity }} available</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<form action="{% url 'store:add_to_cart' listing.id %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn">Add to Cart</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'login' %}?next={{ request.path }}" class="btn" style="background: #334155;">Login
|
||||||
|
to Buy</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div
|
||||||
|
style="text-align: center; padding: 2rem; background: var(--card-bg); border-radius: 0.5rem; border: 1px dashed var(--border-color);">
|
||||||
|
<p style="margin: 0; color: #94a3b8;">No listings currently available for this card.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proxy Service -->
|
||||||
|
<div style="margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 0.5rem;">Playtest Proxy Service</h3>
|
||||||
|
<p style="margin: 0; color: #94a3b8; font-size: 0.875rem;">Download a high-res proxy for playtesting. Credit offered if you buy later.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="alert('Proxy PDF generated! (Mockup)')" class="btn" style="background: transparent; border: 1px solid var(--border-color);">Download Proxy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function filterCondition(cond) {
|
||||||
|
const items = document.querySelectorAll('.listing-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
if (cond === 'ALL' || item.dataset.condition === cond) {
|
||||||
|
item.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
142
templates/store/card_list.html
Normal file
142
templates/store/card_list.html
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="display: grid; grid-template-columns: 250px 1fr; gap: 2rem;">
|
||||||
|
<!-- Sidebar Filters -->
|
||||||
|
<aside
|
||||||
|
style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); height: fit-content;">
|
||||||
|
<h3 style="margin-top: 0;">Filters</h3>
|
||||||
|
<form method="get">
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Game</label>
|
||||||
|
<select name="game"
|
||||||
|
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);"
|
||||||
|
onchange="this.form.submit()">
|
||||||
|
<option value="">All Games</option>
|
||||||
|
{% for game in games %}
|
||||||
|
<option value="{{ game.slug }}" {% if current_game|slugify == game.slug %}selected{% endif %}>
|
||||||
|
{{game.name}}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_game %}
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Set</label>
|
||||||
|
<select name="set"
|
||||||
|
style="width: 100%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
<option value="">All Sets</option>
|
||||||
|
{% for set in sets %}
|
||||||
|
<option value="{{ set.id }}" {% if request.GET.set|add:"0" == set.id %}selected{% endif %}>{{ set.name
|
||||||
|
}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="display: block; font-size: 0.875rem; margin-bottom: 0.5rem;">Search</label>
|
||||||
|
<input type="text" name="q" value="{{ search_query|default:'' }}" placeholder="Card name..."
|
||||||
|
style="width: 90%; padding: 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">Apply Filters</button>
|
||||||
|
<a href="{% url 'store:card_list' %}"
|
||||||
|
style="display: block; text-align: center; margin-top: 1rem; color: #94a3b8; font-size: 0.875rem; text-decoration: none;">Clear
|
||||||
|
Filters</a>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Card Grid -->
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top: 0;">Browse Cards</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for card in page_obj %}
|
||||||
|
<div class="tcg-card">
|
||||||
|
<a href="{% url 'store:card_detail' card.id %}" style="text-decoration: none; color: inherit;">
|
||||||
|
<div style="aspect-ratio: 2.5/3.5; background: #000; position: relative;">
|
||||||
|
<!-- Placeholder or Real Image -->
|
||||||
|
{% if card.image_url %}
|
||||||
|
<img src="{{ card.image_url }}" alt="{{ card.name }}"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: center; height: 100%; color: #64748b; background: #334155;">
|
||||||
|
No Image</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tcg-card-body">
|
||||||
|
<h4
|
||||||
|
style="margin: 0 0 0.5rem; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
{{ card.name }}</h4>
|
||||||
|
<div
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: #94a3b8;">
|
||||||
|
<span>{{ card.set.code|default:card.set.game.name }}</span>
|
||||||
|
<span>{{ card.rarity }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
{% with card.listings.first as cheapest %}
|
||||||
|
{% if cheapest %}
|
||||||
|
<span style="font-weight: 700; color: var(--text-color);">From ${{ cheapest.price }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #64748b;">Out of Stock</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<span id="stock-{{ card.id }}" class="stock-counter" data-card-id="{{ card.id }}" style="font-size: 0.75rem; color: #94a3b8; margin-left: auto;">...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p>No cards found matching your criteria.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<div style="margin-top: 2rem; display: flex; justify-content: center; gap: 0.5rem;">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}&game={{ current_game }}&q={{ search_query }}" class="btn"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.875rem;">Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span
|
||||||
|
style="padding: 0.25rem 0.75rem; background: var(--card-bg); border-radius: 0.25rem; border: 1px solid var(--border-color);">
|
||||||
|
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}&game={{ current_game }}&q={{ search_query }}" class="btn"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.875rem;">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const stockCounters = document.querySelectorAll('.stock-counter');
|
||||||
|
stockCounters.forEach(counter => {
|
||||||
|
const cardId = counter.getAttribute('data-card-id');
|
||||||
|
fetch(`/store/api/stock/${cardId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.total_stock > 0) {
|
||||||
|
counter.textContent = `${data.total_stock} in stock`;
|
||||||
|
counter.style.color = '#10b981'; // green
|
||||||
|
} else {
|
||||||
|
counter.textContent = 'Out of Stock';
|
||||||
|
counter.style.color = '#ef4444'; // red
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
counter.textContent = 'Stock unknown';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
80
templates/store/cart.html
Normal file
80
templates/store/cart.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 800px; margin: 0 auto;">
|
||||||
|
<h1 style="margin-bottom: 2rem;">Shopping Cart</h1>
|
||||||
|
|
||||||
|
{% if cart and cart.items.count > 0 %}
|
||||||
|
<div
|
||||||
|
style="background: var(--card-bg); border-radius: 0.5rem; border: 1px solid var(--border-color); overflow: hidden;">
|
||||||
|
{% for item in cart.items.all %}
|
||||||
|
<div
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center; padding: 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||||
|
<div style="display: flex; gap: 1.5rem; align-items: center;">
|
||||||
|
{% if item.listing %}
|
||||||
|
{% if item.listing.card.image_url %}
|
||||||
|
<img src="{{ item.listing.card.image_url }}"
|
||||||
|
style="width: 50px; height: 70px; object-fit: cover; border-radius: 4px;">
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0; font-size: 1.125rem;">{{ item.listing.card.name }}</h3>
|
||||||
|
<p style="margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.875rem;">
|
||||||
|
{{ item.listing.get_condition_display }}
|
||||||
|
{% if item.listing.is_foil %}• Foil{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if item.pack_listing.image_url %}
|
||||||
|
<img src="{{ item.pack_listing.image_url }}" style="width: 50px; height: 70px; object-fit: cover; border-radius: 4px;">
|
||||||
|
{% else %}
|
||||||
|
<div style="width: 50px; height: 70px; background: #6366f1; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem;">📦</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0; font-size: 1.125rem;">{{ item.pack_listing.name }}</h3>
|
||||||
|
<p style="margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.875rem;">Booster Pack</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 2rem;">
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div style="font-weight: 600;">{{ item.quantity }} x ${% if item.listing %}{{ item.listing.price }}{% else %}{{ item.pack_listing.price }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight: 700; font-size: 1.25rem;">
|
||||||
|
${{ item.total_price }}
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'store:remove_from_cart' item.id %}"
|
||||||
|
style="color: #ef4444; text-decoration: none; font-size: 1.25rem;">×</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div style="padding: 1rem 1.5rem; background: rgba(0,0,0,0.1); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<a href="{% url 'store:toggle_insurance' %}" style="text-decoration: none; display: flex; align-items: center; gap: 0.5rem; color: var(--text-color);">
|
||||||
|
<div style="width: 20px; height: 20px; border: 2px solid var(--border-color); border-radius: 4px; display: flex; align-items: center; justify-content: center; background: {% if cart.insurance %}var(--primary-color, #3b82f6){% else %}transparent{% endif %};">
|
||||||
|
{% if cart.insurance %}<span style="color: white; font-size: 14px;">✓</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<span><strong>Add Shipping Insurance</strong> (Protects against damage/loss)</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span>+$5.00</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="padding: 1.5rem; background: rgba(0,0,0,0.2); display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span style="font-size: 1.25rem; font-weight: 600;">Total</span>
|
||||||
|
<span style="font-size: 1.5rem; font-weight: 800;">${{ cart.total_price }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; text-align: right;">
|
||||||
|
<a href="{% url 'store:checkout' %}" class="btn" style="padding: 1rem 2rem; font-size: 1.125rem;">Proceed to Checkout</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 4rem; background: var(--card-bg); border-radius: 0.5rem; color: #94a3b8;">
|
||||||
|
<p style="font-size: 1.25rem; margin-bottom: 1.5rem;">Your cart is empty.</p>
|
||||||
|
<a href="{% url 'store:card_list' %}" class="btn">Browse Cards</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
60
templates/store/deck_buyer.html
Normal file
60
templates/store/deck_buyer.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 800px; margin: 0 auto;">
|
||||||
|
<h2 style="margin-bottom: 1.5rem;">Mass Deck Buyer</h2>
|
||||||
|
<p style="margin-bottom: 2rem; color: #94a3b8;">Paste your deck list below to instantly find the cheapest printings for every card.</p>
|
||||||
|
|
||||||
|
{% if preview %}
|
||||||
|
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); margin-bottom: 2rem;">
|
||||||
|
<h3 style="margin-top: 0;">Review Purchase</h3>
|
||||||
|
<p style="font-size: 1.25rem; font-weight: bold; margin-bottom: 1rem;">Total Estimated Cost: <span style="color: #10b981;">${{ total_cost }}</span></p>
|
||||||
|
|
||||||
|
<h4 style="border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">Found Items</h4>
|
||||||
|
<ul style="list-style: none; padding: 0; margin-bottom: 1.5rem;">
|
||||||
|
{% for item in found_items %}
|
||||||
|
<li style="display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid #334155;">
|
||||||
|
<span>{{ item.quantity }}x <strong>{{ item.card_name }}</strong> <small style="color: #94a3b8;">({{ item.listing.get_condition_display }})</small></span>
|
||||||
|
<span>${{ item.total }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if missing_items %}
|
||||||
|
<h4 style="border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; color: #ef4444;">Missing / Out of Stock</h4>
|
||||||
|
<ul style="list-style: none; padding: 0; margin-bottom: 1.5rem;">
|
||||||
|
{% for item in missing_items %}
|
||||||
|
<li style="padding: 0.5rem 0; color: #f87171;">
|
||||||
|
{{ item.quantity }}x {{ item.name }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" style="display: flex; gap: 1rem;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_to_cart">
|
||||||
|
<input type="hidden" name="deck_text" value="{{ deck_text }}">
|
||||||
|
<button type="submit" class="btn" style="flex: 1;">Confirm & Add to Cart</button>
|
||||||
|
<a href="{% url 'store:deck_buyer' %}" class="btn" style="background: var(--bg-color); border: 1px solid var(--border-color); color: var(--text-color); flex: 1; text-align: center; text-decoration: none;">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" style="display: flex; flex-direction: column; gap: 1rem;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="preview">
|
||||||
|
|
||||||
|
<div style="background: var(--bg-color); padding: 1rem; border: 1px solid var(--border-color); border-radius: 0.5rem;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="ignore_owned" id="ignore_owned">
|
||||||
|
<span><strong>Complete My Deck:</strong> Exclude cards I already own from my purchase.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="deck_text" style="font-weight: bold;">Deck List</label>
|
||||||
|
<textarea name="deck_text" id="deck_text" rows="15" placeholder="4 Lightning Bolt 2 Counterspell 1 Sol Ring" style="padding: 1rem; border-radius: 0.5rem; background: var(--card-bg); color: var(--text-color); border: 1px solid var(--border-color); font-family: monospace;"></textarea>
|
||||||
|
<button type="submit" class="btn">Find Cards</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
30
templates/store/my_packs.html
Normal file
30
templates/store/my_packs.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 1200px; margin: 0 auto;">
|
||||||
|
<h1 style="margin-bottom: 2rem;">My Packs</h1>
|
||||||
|
{% if packs %}
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for pack in packs %}
|
||||||
|
<div class="tcg-card">
|
||||||
|
<div style="aspect-ratio: 2.5/3.5; background: linear-gradient(135deg, #4f46e5, #0ea5e9); position: relative; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<div style="text-align: center; color: white;">
|
||||||
|
<div style="font-size: 3rem;">🎁</div>
|
||||||
|
<div>{{ pack.listing.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tcg-card-body" style="text-align: center;">
|
||||||
|
<h4 style="margin: 0 0 0.5rem; font-size: 1rem;">{{ pack.listing.name }}</h4>
|
||||||
|
<a href="{% url 'store:open_pack' pack.id %}" class="btn" style="width: 100%; display: block; margin-top: 1rem;">Open Pack</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 4rem; background: var(--card-bg); border-radius: 0.5rem;">
|
||||||
|
<p>You don't have any sealed packs.</p>
|
||||||
|
<a href="{% url 'store:pack_list' %}" class="btn">Buy Packs</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
125
templates/store/open_pack.html
Normal file
125
templates/store/open_pack.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 1000px; margin: 0 auto; text-align: center; min-height: 600px;">
|
||||||
|
<h1 id="pack-title">{{ pack.listing.name }}</h1>
|
||||||
|
|
||||||
|
<div id="pack-container" style="margin: 4rem auto; perspective: 1000px; width: 300px; height: 420px; cursor: pointer;">
|
||||||
|
<div id="pack-wrapper" style="width: 100%; height: 100%; position: relative; transform-style: preserve-3d; transition: transform 0.6s;">
|
||||||
|
<div class="pack-face" style="position: absolute; width: 100%; height: 100%; backface-visibility: hidden; background: linear-gradient(135deg, #4f46e5, #0ea5e9); border-radius: 1rem; display: flex; align-items: center; justify-content: center; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);">
|
||||||
|
<div style="font-size: 5rem;">🎁</div>
|
||||||
|
<div style="position: absolute; bottom: 2rem; color: white; font-weight: bold;">Click to Open</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cards-container" style="display: none; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 2rem;">
|
||||||
|
<!-- Cards injected here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="actions" style="margin-top: 2rem; display: none;">
|
||||||
|
<a href="{% url 'store:my_packs' %}" class="btn">Back to My Packs</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes shake {
|
||||||
|
0% { transform: translate(1px, 1px) rotate(0deg); }
|
||||||
|
10% { transform: translate(-1px, -2px) rotate(-1deg); }
|
||||||
|
20% { transform: translate(-3px, 0px) rotate(1deg); }
|
||||||
|
30% { transform: translate(3px, 2px) rotate(0deg); }
|
||||||
|
40% { transform: translate(1px, -1px) rotate(1deg); }
|
||||||
|
50% { transform: translate(-1px, 2px) rotate(-1deg); }
|
||||||
|
60% { transform: translate(-3px, 1px) rotate(0deg); }
|
||||||
|
70% { transform: translate(3px, 1px) rotate(-1deg); }
|
||||||
|
80% { transform: translate(-1px, -1px) rotate(1deg); }
|
||||||
|
90% { transform: translate(1px, 2px) rotate(0deg); }
|
||||||
|
100% { transform: translate(1px, -2px) rotate(-1deg); }
|
||||||
|
}
|
||||||
|
.shaking {
|
||||||
|
animation: shake 0.5s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
.card-reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
animation: fadeInUp 0.5s forwards;
|
||||||
|
}
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const packWrapper = document.getElementById('pack-wrapper');
|
||||||
|
const packContainer = document.getElementById('pack-container');
|
||||||
|
const cardsContainer = document.getElementById('cards-container');
|
||||||
|
const actions = document.getElementById('actions');
|
||||||
|
const packTitle = document.getElementById('pack-title');
|
||||||
|
|
||||||
|
packContainer.addEventListener('click', function() {
|
||||||
|
if (packContainer.classList.contains('opened')) return;
|
||||||
|
|
||||||
|
packWrapper.classList.add('shaking');
|
||||||
|
|
||||||
|
// Use fetch with CSRF token
|
||||||
|
const csrftoken = document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
|
||||||
|
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': '{{ csrf_token }}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
setTimeout(() => {
|
||||||
|
packWrapper.classList.remove('shaking');
|
||||||
|
packWrapper.style.transform = 'scale(0) rotate(720deg)';
|
||||||
|
packWrapper.style.opacity = '0';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
packContainer.style.display = 'none';
|
||||||
|
cardsContainer.style.display = 'grid';
|
||||||
|
actions.style.display = 'block';
|
||||||
|
packTitle.textContent = "New Cards!";
|
||||||
|
|
||||||
|
data.cards.forEach((card, index) => {
|
||||||
|
const cardEl = document.createElement('div');
|
||||||
|
cardEl.className = 'tcg-card card-reveal';
|
||||||
|
cardEl.style.animationDelay = `${index * 0.2}s`;
|
||||||
|
|
||||||
|
let imgHtml = '';
|
||||||
|
if (card.image_url) {
|
||||||
|
imgHtml = `<img src="${card.image_url}" style="width: 100%; height: auto;">`;
|
||||||
|
} else {
|
||||||
|
imgHtml = `<div style="aspect-ratio: 2.5/3.5; background: #000; display: flex; align-items: center; justify-content: center; color: white;">${card.name}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div style="aspect-ratio: 2.5/3.5; background: #000; position: relative;">
|
||||||
|
${imgHtml}
|
||||||
|
</div>
|
||||||
|
<div class="tcg-card-body">
|
||||||
|
<h4>${card.name}</h4>
|
||||||
|
<p>${card.set} - ${card.rarity}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
cardsContainer.appendChild(cardEl);
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}, 1000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
packWrapper.classList.remove('shaking');
|
||||||
|
alert('Error opening pack');
|
||||||
|
});
|
||||||
|
|
||||||
|
packContainer.classList.add('opened');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
74
templates/store/order_detail.html
Normal file
74
templates/store/order_detail.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
|
||||||
|
<div style="margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h1 style="margin: 0;">Order #{{ order.id }}</h1>
|
||||||
|
<a href="{% url 'users:profile' %}" class="btn" style="background-color: var(--card-bg); border: 1px solid var(--border-color); color: var(--text-color);">Back to Profile</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color); margin-bottom: 2rem;">
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem;">
|
||||||
|
<div>
|
||||||
|
<p style="color: #94a3b8; font-size: 0.875rem; margin-bottom: 0.5rem;">Date Placed</p>
|
||||||
|
<p style="font-weight: 600;">{{ order.created_at|date:"F j, Y, P" }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="color: #94a3b8; font-size: 0.875rem; margin-bottom: 0.5rem;">Status</p>
|
||||||
|
<span style="display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; background: #334155; font-size: 0.875rem; font-weight: 600;">
|
||||||
|
{{ order.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="color: #94a3b8; font-size: 0.875rem; margin-bottom: 0.5rem;">Total Amount</p>
|
||||||
|
<p style="font-weight: 600; font-size: 1.25rem;">${{ order.total_price }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if order.insurance_purchased %}
|
||||||
|
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
|
||||||
|
<p style="display: flex; align-items: center; gap: 0.5rem; color: #10b981;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||||||
|
Shipping Insurance Included
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-bottom: 1.5rem;">Order Items</h3>
|
||||||
|
<div style="display: grid; gap: 1rem;">
|
||||||
|
{% for item in order.items.all %}
|
||||||
|
<div style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
{% if item.listing and item.listing.card.image_url %}
|
||||||
|
<img src="{{ item.listing.card.image_url }}" alt="{{ item.listing.card.name }}" style="width: 50px; border-radius: 4px;">
|
||||||
|
{% elif item.pack_listing and item.pack_listing.image_url %}
|
||||||
|
<img src="{{ item.pack_listing.image_url }}" alt="{{ item.pack_listing.name }}" style="width: 50px; border-radius: 4px;">
|
||||||
|
{% else %}
|
||||||
|
<div style="width: 50px; height: 70px; background: #334155; border-radius: 4px;"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% if item.listing %}
|
||||||
|
<h4 style="margin: 0; font-size: 1rem;">{{ item.listing.card.name }}</h4>
|
||||||
|
<p style="margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.875rem;">
|
||||||
|
{{ item.listing.card.set.name }} • {{ item.listing.get_condition_display }} • {% if item.listing.is_foil %}Foil{% else %}Non-Foil{% endif %}
|
||||||
|
</p>
|
||||||
|
{% elif item.pack_listing %}
|
||||||
|
<h4 style="margin: 0; font-size: 1rem;">{{ item.pack_listing.name }}</h4>
|
||||||
|
<p style="margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.875rem;">Booster Pack</p>
|
||||||
|
{% else %}
|
||||||
|
<h4 style="margin: 0; font-size: 1rem;">Unknown Item</h4>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<p style="margin: 0; font-weight: 600;">${{ item.price_at_purchase }}</p>
|
||||||
|
<p style="margin: 0.25rem 0 0; color: #94a3b8; font-size: 0.875rem;">Qty: {{ item.quantity }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
30
templates/store/pack_list.html
Normal file
30
templates/store/pack_list.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 1200px; margin: 0 auto;">
|
||||||
|
<h1 style="margin-bottom: 2rem;">Booster Packs</h1>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for pack in packs %}
|
||||||
|
<div class="tcg-card">
|
||||||
|
<div style="aspect-ratio: 2.5/3.5; background: #334155; position: relative; display: flex; align-items: center; justify-content: center;">
|
||||||
|
{% if pack.image_url %}
|
||||||
|
<img src="{{ pack.image_url }}" alt="{{ pack.name }}" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 1rem;">
|
||||||
|
<div style="font-size: 3rem;">📦</div>
|
||||||
|
<div>{{ pack.game.name }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tcg-card-body">
|
||||||
|
<h4 style="margin: 0 0 0.5rem; font-size: 1rem;">{{ pack.name }}</h4>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem;">
|
||||||
|
<span style="font-weight: 700;">${{ pack.price }}</span>
|
||||||
|
<a href="{% url 'store:add_pack_to_cart' pack.id %}" class="btn" style="padding: 0.25rem 0.75rem; font-size: 0.875rem;">Add to Cart</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
27
templates/users/address_form.html
Normal file
27
templates/users/address_form.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container" style="max-width: 600px; margin: 0 auto; padding-top: 2rem;">
|
||||||
|
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<h2 style="margin-top: 0;">Add Address</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div style="display: grid; gap: 1rem;">
|
||||||
|
{% for field in form %}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<label for="{{ field.id_for_label }}" style="font-weight: 500;">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<span style="color: #ef4444; font-size: 0.875rem;">{{ field.errors.0 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
|
||||||
|
<button type="submit" class="btn">Save Address</button>
|
||||||
|
<a href="{% url 'users:profile' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color);">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
30
templates/users/payment_form.html
Normal file
30
templates/users/payment_form.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container" style="max-width: 600px; margin: 0 auto; padding-top: 2rem;">
|
||||||
|
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<h2 style="margin-top: 0;">Add Payment Method</h2>
|
||||||
|
<div style="background: #3b82f620; border: 1px solid #3b82f6; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||||
|
<p style="margin: 0; color: #60a5fa; font-size: 0.875rem;"><strong>Note:</strong> This is a demo. Do not enter real credit card information.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div style="display: grid; gap: 1rem;">
|
||||||
|
{% for field in form %}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<label for="{{ field.id_for_label }}" style="font-weight: 500;">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<span style="color: #ef4444; font-size: 0.875rem;">{{ field.errors.0 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
|
||||||
|
<button type="submit" class="btn">Save Payment Method</button>
|
||||||
|
<a href="{% url 'users:profile' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color);">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
141
templates/users/profile.html
Normal file
141
templates/users/profile.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card-grid" style="grid-template-columns: 1fr 2fr; gap: 2rem; align-items: start;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 2rem;">
|
||||||
|
<!-- User Info Card -->
|
||||||
|
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<h2>{{ user.username }}</h2>
|
||||||
|
<span
|
||||||
|
style="background: {% if user.profile.is_pro %}{{ 'linear-gradient(to right, #f59e0b, #d97706)' }}{% else %}#475569{% endif %};
|
||||||
|
color: white; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.875rem; font-weight: 600;">
|
||||||
|
{% if user.profile.is_pro %}PRO MEMBER{% else %}BASIC MEMBER{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem;">
|
||||||
|
<p><strong>Email:</strong> {{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p><strong>Theme Preference:</strong></p>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
{{ form.theme_preference }}
|
||||||
|
<button type="submit" class="btn" style="padding: 0.25rem 0.75rem;">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not user.profile.is_pro %}
|
||||||
|
<div style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--border-color);">
|
||||||
|
<h3>Upgrade to Pro</h3>
|
||||||
|
<p>Get access to Deck Builder and exclusive analytics.</p>
|
||||||
|
<form action="{% url 'users:upgrade_account' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">Upgrade Now ($5/mo)</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Addresses Card -->
|
||||||
|
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3 style="margin: 0;">Addresses</h3>
|
||||||
|
<a href="{% url 'users:add_address' %}" class="btn" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">+ Add</a>
|
||||||
|
</div>
|
||||||
|
{% if addresses %}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||||
|
{% for address in addresses %}
|
||||||
|
<div style="border: 1px solid var(--border-color); padding: 0.75rem; border-radius: 0.375rem; position: relative;">
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
<span style="font-weight: 600; font-size: 0.875rem;">{{ address.get_address_type_display }} {% if address.is_default %}(Default){% endif %}</span>
|
||||||
|
<form action="{% url 'users:delete_address' address.pk %}" method="post" onsubmit="return confirm('Are you sure?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" style="background: none; border: none; color: #ef4444; cursor: pointer;">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.875rem;">{{ address.street }}<br>{{ address.city }}, {{ address.state }} {{ address.zip_code }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: #94a3b8; font-size: 0.875rem;">No addresses saved.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Methods Card -->
|
||||||
|
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3 style="margin: 0;">Payment Methods</h3>
|
||||||
|
<a href="{% url 'users:add_payment_method' %}" class="btn" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">+ Add</a>
|
||||||
|
</div>
|
||||||
|
{% if payment_methods %}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||||
|
{% for pm in payment_methods %}
|
||||||
|
<div style="border: 1px solid var(--border-color); padding: 0.75rem; border-radius: 0.375rem; position: relative;">
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
<span style="font-weight: 600; font-size: 0.875rem;">{{ pm.brand }} •••• {{ pm.last4 }}</span>
|
||||||
|
<form action="{% url 'users:delete_payment_method' pm.pk %}" method="post" onsubmit="return confirm('Are you sure?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" style="background: none; border: none; color: #ef4444; cursor: pointer;">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.875rem; color: #94a3b8;">Expires {{ pm.exp_month }}/{{ pm.exp_year }}</p>
|
||||||
|
{% if pm.is_default %}<span style="font-size: 0.75rem; color: var(--primary-color);">Default</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: #94a3b8; font-size: 0.875rem;">No payment methods saved.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h2 style="margin: 0;">Recent Orders</h2>
|
||||||
|
<form method="get" style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="date" name="date" value="{{ date_query }}" style="background: var(--input-bg); border: 1px solid var(--border-color); color: var(--text-color); padding: 0.25rem 0.5rem; border-radius: 0.25rem;">
|
||||||
|
<button type="submit" class="btn" style="padding: 0.25rem 0.75rem;">Filter</button>
|
||||||
|
{% if date_query %}
|
||||||
|
<a href="{% url 'users:profile' %}" class="btn" style="background: var(--card-bg); border: 1px solid var(--border-color); padding: 0.25rem 0.75rem;">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if orders %}
|
||||||
|
<div style="display: grid; gap: 1rem;">
|
||||||
|
{% for order in orders %}
|
||||||
|
<a href="{% url 'store:order_detail' order.id %}" style="text-decoration: none; color: inherit;">
|
||||||
|
<div
|
||||||
|
style="background: var(--card-bg); padding: 1.5rem; border-radius: 0.5rem; border: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; transition: transform 0.2s;"
|
||||||
|
onmouseover="this.style.transform='translateY(-2px)'; this.style.borderColor='var(--accent-color)'"
|
||||||
|
onmouseout="this.style.transform='translateY(0)'; this.style.borderColor='var(--border-color)'">
|
||||||
|
<div>
|
||||||
|
<h4 style="margin: 0;">Order #{{ order.id }}</h4>
|
||||||
|
<p style="margin: 0.5rem 0 0; color: #94a3b8; font-size: 0.875rem;">{{ order.created_at|date }}</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<span style="display: block; font-weight: 600; font-size: 1.125rem;">${{ order.total_price }}</span>
|
||||||
|
<span
|
||||||
|
style="display: inline-block; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; background: #334155; margin-top: 0.5rem;">
|
||||||
|
{{ order.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
style="text-align: center; padding: 4rem; background: var(--card-bg); border-radius: 0.5rem; color: #94a3b8;">
|
||||||
|
<p>No orders found.</p>
|
||||||
|
<a href="{% url 'store:card_list' %}" class="btn" style="margin-top: 1rem;">Start Shopping</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
16
templates/users/register.html
Normal file
16
templates/users/register.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div
|
||||||
|
style="max-width: 400px; margin: 4rem auto; background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<h2 style="margin-top: 0;">Create Account</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">Register</button>
|
||||||
|
</form>
|
||||||
|
<p style="margin-top: 1rem; text-align: center;">
|
||||||
|
Already have an account? <a href="{% url 'login' %}" style="color: var(--primary-color);">Login here</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
72
templates/users/vault.html
Normal file
72
templates/users/vault.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container" style="max-width: 1000px; margin: 0 auto; padding-top: 2rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||||
|
<h1>My Card Vault</h1>
|
||||||
|
<div style="background: var(--card-bg); padding: 0.5rem 1rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
Total Cards: <strong>{{ total_cards }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div style="margin-bottom: 2rem; background: var(--card-bg); padding: 1rem; border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<form method="get" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<label for="set" style="margin-right: 0.5rem; font-size: 0.875rem;">Set:</label>
|
||||||
|
<select name="set" id="set" style="padding: 0.25rem 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
<option value="">All Sets</option>
|
||||||
|
{% for set in available_sets %}
|
||||||
|
<option value="{{ set.id }}" {% if current_set == set.id %}selected{% endif %}>{{ set.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="rarity" style="margin-right: 0.5rem; font-size: 0.875rem;">Rarity:</label>
|
||||||
|
<select name="rarity" id="rarity" style="padding: 0.25rem 0.5rem; border-radius: 0.25rem; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color);">
|
||||||
|
<option value="">All Rarities</option>
|
||||||
|
{% for rarity in available_rarities %}
|
||||||
|
<option value="{{ rarity }}" {% if current_rarity == rarity %}selected{% endif %}>{{ rarity|title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" style="padding: 0.25rem 0.75rem;">Filter</button>
|
||||||
|
<a href="{% url 'users:vault' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color); padding: 0.25rem 0.75rem;">Clear</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if vault_items %}
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1.5rem;">
|
||||||
|
{% for item in vault_items %}
|
||||||
|
<div style="background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 0.5rem; overflow: hidden; display: flex; flex-direction: column;">
|
||||||
|
<div style="position: relative; aspect-ratio: 2.5/3.5;">
|
||||||
|
{% if item.card.image_url %}
|
||||||
|
<img src="{{ item.card.image_url }}" alt="{{ item.card.name }}" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
{% else %}
|
||||||
|
<div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #1e293b; color: #94a3b8;">No Image</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.8); color: white; padding: 0.25rem 0.5rem; border-top-left-radius: 0.5rem;">
|
||||||
|
x{{ item.quantity }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 1rem; flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0; font-size: 1rem;">{{ item.card.name }}</h3>
|
||||||
|
<p style="color: #94a3b8; font-size: 0.875rem; margin: 0.25rem 0;">{{ item.card.set.name }}</p>
|
||||||
|
<p style="color: #94a3b8; font-size: 0.75rem; margin: 0;">{{ item.card.rarity|title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 4rem; background: var(--card-bg); border-radius: 0.5rem; border: 1px solid var(--border-color);">
|
||||||
|
<p style="font-size: 1.25rem; color: #94a3b8; margin-bottom: 1rem;">No cards found.</p>
|
||||||
|
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 2rem;">
|
||||||
|
<a href="{% url 'store:card_list' %}" class="btn">Browse Cards</a>
|
||||||
|
<a href="{% url 'users:vault' %}" class="btn" style="background: transparent; border: 1px solid var(--border-color);">Clear Filters</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
73
tests/test_core.py
Normal file
73
tests/test_core.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from users.models import User
|
||||||
|
from store.models import Game, Set, Card, CardListing, Cart
|
||||||
|
from decks.models import Deck, DeckCard
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestModels:
|
||||||
|
def test_user_profile_created_signal(self):
|
||||||
|
user = User.objects.create_user('testuser', 'test@example.com', 'password')
|
||||||
|
assert hasattr(user, 'profile')
|
||||||
|
assert user.profile.is_pro is False
|
||||||
|
|
||||||
|
def test_cart_total_price(self, db):
|
||||||
|
user = User.objects.create_user('cartuser', 'c@e.com', 'pass')
|
||||||
|
cart = Cart.objects.create(user=user)
|
||||||
|
|
||||||
|
game = Game.objects.create(name="G", slug="g")
|
||||||
|
set_ = Set.objects.create(game=game, name="S")
|
||||||
|
card = Card.objects.create(set=set_, name="C")
|
||||||
|
listing = CardListing.objects.create(card=card, price=10.00, quantity=5)
|
||||||
|
|
||||||
|
cart.items.create(listing=listing, quantity=2)
|
||||||
|
assert cart.total_price == 20.00
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestStoreViews:
|
||||||
|
def test_card_list_view(self, client):
|
||||||
|
url = reverse('store:card_list')
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_add_to_cart_requires_login(self, client):
|
||||||
|
listing = CardListing.objects.create(
|
||||||
|
card=Card.objects.create(
|
||||||
|
set=Set.objects.create(
|
||||||
|
game=Game.objects.create(name="G", slug="g"),
|
||||||
|
name="S"
|
||||||
|
),
|
||||||
|
name="C"
|
||||||
|
),
|
||||||
|
price=10.00
|
||||||
|
)
|
||||||
|
url = reverse('store:add_to_cart', args=[listing.id])
|
||||||
|
response = client.post(url)
|
||||||
|
assert response.status_code == 302 # Redirect to login
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestDeckViews:
|
||||||
|
@pytest.fixture
|
||||||
|
def pro_user(self):
|
||||||
|
u = User.objects.create_user('pro', 'pro@e.com', 'pass')
|
||||||
|
u.profile.is_pro = True
|
||||||
|
u.profile.save()
|
||||||
|
return u
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def basic_user(self):
|
||||||
|
return User.objects.create_user('basic', 'basic@e.com', 'pass')
|
||||||
|
|
||||||
|
def test_deck_builder_access_pro(self, client, pro_user):
|
||||||
|
client.force_login(pro_user)
|
||||||
|
deck = Deck.objects.create(user=pro_user, name="Pro Deck", game=Game.objects.create(name="G", slug="g"))
|
||||||
|
url = reverse('decks:deck_builder', args=[deck.id])
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_deck_builder_access_basic(self, client, basic_user):
|
||||||
|
client.force_login(basic_user)
|
||||||
|
# Basic user tries to access create page
|
||||||
|
url = reverse('decks:deck_create')
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 302 # Redirect expected
|
||||||
0
users/__init__.py
Normal file
0
users/__init__.py
Normal file
3
users/admin.py
Normal file
3
users/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
users/apps.py
Normal file
5
users/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UsersConfig(AppConfig):
|
||||||
|
name = 'users'
|
||||||
47
users/forms.py
Normal file
47
users/forms.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
|
||||||
|
from .models import User, Profile, Address, PaymentMethod
|
||||||
|
|
||||||
|
class CustomUserCreationForm(UserCreationForm):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('username', 'email')
|
||||||
|
|
||||||
|
class CustomUserChangeForm(UserChangeForm):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('username', 'email')
|
||||||
|
|
||||||
|
class ProfileForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Profile
|
||||||
|
fields = ('theme_preference',)
|
||||||
|
widgets = {
|
||||||
|
'theme_preference': forms.Select(attrs={'class': 'form-control'})
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddressForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Address
|
||||||
|
fields = ('name', 'street', 'city', 'state', 'zip_code', 'address_type', 'is_default')
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Full Name'}),
|
||||||
|
'street': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Street Address'}),
|
||||||
|
'city': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'City'}),
|
||||||
|
'state': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'State'}),
|
||||||
|
'zip_code': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'ZIP Code'}),
|
||||||
|
'address_type': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'is_default': forms.CheckboxInput(attrs={'class': 'form-checkbox'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentMethodForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = PaymentMethod
|
||||||
|
fields = ('brand', 'last4', 'exp_month', 'exp_year', 'is_default')
|
||||||
|
widgets = {
|
||||||
|
'brand': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Card Brand (e.g. Visa)'}),
|
||||||
|
'last4': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Last 4 Digits', 'maxlength': '4'}),
|
||||||
|
'exp_month': forms.NumberInput(attrs={'class': 'form-input', 'placeholder': 'Exp Month', 'min': '1', 'max': '12'}),
|
||||||
|
'exp_year': forms.NumberInput(attrs={'class': 'form-input', 'placeholder': 'Exp Year', 'min': '2024'}),
|
||||||
|
'is_default': forms.CheckboxInput(attrs={'class': 'form-checkbox'}),
|
||||||
|
}
|
||||||
57
users/migrations/0001_initial.py
Normal file
57
users/migrations/0001_initial.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-01-19 13:38
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='User',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'user',
|
||||||
|
'verbose_name_plural': 'users',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Profile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('is_pro', models.BooleanField(default=False)),
|
||||||
|
('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark')], default='dark', max_length=10)),
|
||||||
|
('shipping_address', models.TextField(blank=True)),
|
||||||
|
('stripe_customer_id', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
41
users/migrations/0002_address_paymentmethod.py
Normal file
41
users/migrations/0002_address_paymentmethod.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-01-20 11:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Address',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('street', models.CharField(max_length=200)),
|
||||||
|
('city', models.CharField(max_length=100)),
|
||||||
|
('state', models.CharField(max_length=100)),
|
||||||
|
('zip_code', models.CharField(max_length=20)),
|
||||||
|
('address_type', models.CharField(choices=[('shipping', 'Shipping'), ('billing', 'Billing')], default='shipping', max_length=20)),
|
||||||
|
('is_default', models.BooleanField(default=False)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PaymentMethod',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('brand', models.CharField(max_length=50)),
|
||||||
|
('last4', models.CharField(max_length=4)),
|
||||||
|
('exp_month', models.PositiveIntegerField()),
|
||||||
|
('exp_year', models.PositiveIntegerField()),
|
||||||
|
('is_default', models.BooleanField(default=False)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
users/migrations/__init__.py
Normal file
0
users/migrations/__init__.py
Normal file
62
users/models.py
Normal file
62
users/models.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
THEME_CHOICES = (
|
||||||
|
('light', 'Light'),
|
||||||
|
('dark', 'Dark'),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||||
|
is_pro = models.BooleanField(default=False)
|
||||||
|
theme_preference = models.CharField(max_length=10, choices=THEME_CHOICES, default='dark')
|
||||||
|
# Keeping this simple text field for legacy/simple addressing, whilst adding robust Address model below
|
||||||
|
shipping_address = models.TextField(blank=True)
|
||||||
|
stripe_customer_id = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.user.username
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
Profile.objects.create(user=instance)
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
|
instance.profile.save()
|
||||||
|
|
||||||
|
class Address(models.Model):
|
||||||
|
ADDRESS_TYPE_CHOICES = (
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
('billing', 'Billing'),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='addresses')
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
street = models.CharField(max_length=200)
|
||||||
|
city = models.CharField(max_length=100)
|
||||||
|
state = models.CharField(max_length=100)
|
||||||
|
zip_code = models.CharField(max_length=20)
|
||||||
|
address_type = models.CharField(max_length=20, choices=ADDRESS_TYPE_CHOICES, default='shipping')
|
||||||
|
is_default = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} - {self.street}"
|
||||||
|
|
||||||
|
class PaymentMethod(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='payment_methods')
|
||||||
|
# For now, we'll store mock data. In a real app complexity is higher (PCI compliance etc)
|
||||||
|
brand = models.CharField(max_length=50) # Visa, Mastercard
|
||||||
|
last4 = models.CharField(max_length=4)
|
||||||
|
exp_month = models.PositiveIntegerField()
|
||||||
|
exp_year = models.PositiveIntegerField()
|
||||||
|
is_default = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.brand} ending in {self.last4}"
|
||||||
30
users/tests.py
Normal file
30
users/tests.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
class ProfileThemeTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='testuser', password='password')
|
||||||
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
|
def test_theme_preference_update(self):
|
||||||
|
# Default should be dark
|
||||||
|
self.assertEqual(self.user.profile.theme_preference, 'dark')
|
||||||
|
|
||||||
|
# Check if form is in context
|
||||||
|
response = self.client.get(reverse('users:profile'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('form', response.context)
|
||||||
|
|
||||||
|
# Update theme to light
|
||||||
|
response = self.client.post(reverse('users:profile'), {
|
||||||
|
'theme_preference': 'light'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should redirect back to profile
|
||||||
|
self.assertRedirects(response, reverse('users:profile'))
|
||||||
|
|
||||||
|
# Check if updated in DB
|
||||||
|
self.user.profile.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.profile.theme_preference, 'light')
|
||||||
15
users/urls.py
Normal file
15
users/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'users'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('register/', views.RegisterView.as_view(), name='register'),
|
||||||
|
path('profile/', views.profile_view, name='profile'),
|
||||||
|
path('vault/', views.vault_view, name='vault'),
|
||||||
|
path('upgrade/', views.upgrade_account_view, name='upgrade_account'),
|
||||||
|
path('profile/address/add/', views.add_address_view, name='add_address'),
|
||||||
|
path('profile/payment/add/', views.add_payment_method_view, name='add_payment_method'),
|
||||||
|
path('profile/address/delete/<int:pk>/', views.delete_address_view, name='delete_address'),
|
||||||
|
path('profile/payment/delete/<int:pk>/', views.delete_payment_method_view, name='delete_payment_method'),
|
||||||
|
]
|
||||||
139
users/views.py
Normal file
139
users/views.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.contrib.auth import login
|
||||||
|
from .forms import CustomUserCreationForm, ProfileForm, AddressForm, PaymentMethodForm
|
||||||
|
from django.views.generic import CreateView
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib import messages
|
||||||
|
from .models import User, Address, PaymentMethod
|
||||||
|
from store.models import VaultItem, Set, Card
|
||||||
|
|
||||||
|
class RegisterView(CreateView):
|
||||||
|
model = User
|
||||||
|
form_class = CustomUserCreationForm
|
||||||
|
template_name = 'users/register.html'
|
||||||
|
success_url = reverse_lazy('home')
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
user = form.save()
|
||||||
|
login(self.request, user)
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def profile_view(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ProfileForm(request.POST, instance=request.user.profile)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Profile updated successfully.')
|
||||||
|
return redirect('users:profile')
|
||||||
|
else:
|
||||||
|
form = ProfileForm(instance=request.user.profile)
|
||||||
|
|
||||||
|
# Order filtering
|
||||||
|
orders = request.user.orders.all().order_by('-created_at')
|
||||||
|
date_query = request.GET.get('date')
|
||||||
|
if date_query:
|
||||||
|
orders = orders.filter(created_at__date=date_query)
|
||||||
|
|
||||||
|
addresses = request.user.addresses.all()
|
||||||
|
payment_methods = request.user.payment_methods.all()
|
||||||
|
|
||||||
|
return render(request, 'users/profile.html', {
|
||||||
|
'user': request.user,
|
||||||
|
'form': form,
|
||||||
|
'orders': orders,
|
||||||
|
'date_query': date_query,
|
||||||
|
'addresses': addresses,
|
||||||
|
'payment_methods': payment_methods,
|
||||||
|
})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def upgrade_account_view(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Mock payment processing would go here
|
||||||
|
profile = request.user.profile
|
||||||
|
profile.is_pro = True
|
||||||
|
profile.save()
|
||||||
|
messages.success(request, 'Account upgraded to Pro!')
|
||||||
|
return redirect('users:profile')
|
||||||
|
return redirect('users:profile')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_address_view(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AddressForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
address = form.save(commit=False)
|
||||||
|
address.user = request.user
|
||||||
|
if address.is_default:
|
||||||
|
# Unset previous default
|
||||||
|
Address.objects.filter(user=request.user, address_type=address.address_type, is_default=True).update(is_default=False)
|
||||||
|
address.save()
|
||||||
|
messages.success(request, 'Address added successfully.')
|
||||||
|
return redirect('users:profile')
|
||||||
|
else:
|
||||||
|
form = AddressForm()
|
||||||
|
return render(request, 'users/address_form.html', {'form': form})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_address_view(request, pk):
|
||||||
|
address = get_object_or_404(Address, pk=pk, user=request.user)
|
||||||
|
if request.method == 'POST':
|
||||||
|
address.delete()
|
||||||
|
messages.success(request, 'Address deleted.')
|
||||||
|
return redirect('users:profile')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_payment_method_view(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = PaymentMethodForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
pm = form.save(commit=False)
|
||||||
|
pm.user = request.user
|
||||||
|
if pm.is_default:
|
||||||
|
PaymentMethod.objects.filter(user=request.user, is_default=True).update(is_default=False)
|
||||||
|
pm.save()
|
||||||
|
messages.success(request, 'Payment method added successfully.')
|
||||||
|
return redirect('users:profile')
|
||||||
|
else:
|
||||||
|
form = PaymentMethodForm()
|
||||||
|
return render(request, 'users/payment_form.html', {'form': form})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_payment_method_view(request, pk):
|
||||||
|
pm = get_object_or_404(PaymentMethod, pk=pk, user=request.user)
|
||||||
|
if request.method == 'POST':
|
||||||
|
pm.delete()
|
||||||
|
messages.success(request, 'Payment method deleted.')
|
||||||
|
return redirect('users:profile')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def vault_view(request):
|
||||||
|
vault_items = VaultItem.objects.filter(user=request.user).select_related('card', 'card__set').order_by('-added_at')
|
||||||
|
|
||||||
|
# Filtering
|
||||||
|
set_id = request.GET.get('set')
|
||||||
|
rarity = request.GET.get('rarity')
|
||||||
|
|
||||||
|
if set_id:
|
||||||
|
vault_items = vault_items.filter(card__set_id=set_id)
|
||||||
|
if rarity:
|
||||||
|
vault_items = vault_items.filter(card__rarity=rarity)
|
||||||
|
|
||||||
|
total_cards = sum(item.quantity for item in vault_items)
|
||||||
|
|
||||||
|
# Get options for filters
|
||||||
|
# We only want sets and rarities that are actually in the user's vault
|
||||||
|
user_card_ids = VaultItem.objects.filter(user=request.user).values_list('card_id', flat=True)
|
||||||
|
available_sets = Set.objects.filter(cards__id__in=user_card_ids).distinct()
|
||||||
|
available_rarities = Card.objects.filter(id__in=user_card_ids).values_list('rarity', flat=True).distinct()
|
||||||
|
|
||||||
|
return render(request, 'users/vault.html', {
|
||||||
|
'vault_items': vault_items,
|
||||||
|
'total_cards': total_cards,
|
||||||
|
'available_sets': available_sets,
|
||||||
|
'available_rarities': available_rarities,
|
||||||
|
'current_set': int(set_id) if set_id else None,
|
||||||
|
'current_rarity': rarity
|
||||||
|
})
|
||||||
536
uv.lock
generated
Normal file
536
uv.lock
generated
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asgiref"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "black"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "pytokens" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/13/710298938a61f0f54cdb4d1c0baeb672c01ff0358712eddaf29f76d32a0b/black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f", size = 1878189, upload-time = "2026-01-18T04:59:30.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/a6/5179beaa57e5dbd2ec9f1c64016214057b4265647c62125aa6aeffb05392/black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6", size = 1700178, upload-time = "2026-01-18T04:59:32.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/04/c96f79d7b93e8f09d9298b333ca0d31cd9b2ee6c46c274fd0f531de9dc61/black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a", size = 1777029, upload-time = "2026-01-18T04:59:33.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/f9/71c161c4c7aa18bdda3776b66ac2dc07aed62053c7c0ff8bbda8c2624fe2/black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791", size = 1406466, upload-time = "2026-01-18T04:59:35.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/8b/a7b0f974e473b159d0ac1b6bcefffeb6bec465898a516ee5cc989503cbc7/black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954", size = 1216393, upload-time = "2026-01-18T04:59:37.18Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.13.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django"
|
||||||
|
version = "6.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/016f7e55e855ee738a352b05139d4f8b278d0b451bd01ebef07456ef3b0e/django-6.0.1.tar.gz", hash = "sha256:ed76a7af4da21551573b3d9dfc1f53e20dd2e6c7d70a3adc93eedb6338130a5f", size = 11069565, upload-time = "2026-01-06T18:55:53.069Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/b5/814ed98bd21235c116fd3436a7ed44d47560329a6d694ec8aac2982dbb93/django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d", size = 8338791, upload-time = "2026-01-06T18:55:46.175Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "example-tcg-site"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "black" },
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "faker" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-django" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "stripe" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "black", specifier = ">=26.1.0" },
|
||||||
|
{ name = "coverage", specifier = ">=7.13.1" },
|
||||||
|
{ name = "django", specifier = ">=6.0.1" },
|
||||||
|
{ name = "faker", specifier = ">=40.1.2" },
|
||||||
|
{ name = "pillow", specifier = ">=12.1.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||||
|
{ name = "pytest-django", specifier = ">=4.11.1" },
|
||||||
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
|
{ name = "stripe", specifier = ">=14.2.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "faker"
|
||||||
|
version = "40.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/77/1c3ff07b6739b9a1d23ca01ec0a90a309a33b78e345a3eb52f9ce9240e36/faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30", size = 1949802, upload-time = "2026-01-13T20:51:49.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-django"
|
||||||
|
version = "4.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytokens"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e5/16/4b9cfd90d55e66ffdb277d7ebe3bc25250c2311336ec3fc73b2673c794d5/pytokens-0.4.0.tar.gz", hash = "sha256:6b0b03e6ea7c9f9d47c5c61164b69ad30f4f0d70a5d9fe7eac4d19f24f77af2d", size = 15039, upload-time = "2026-01-19T07:59:50.623Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/65/65460ebbfefd0bc1b160457904370d44f269e6e4582e0a9b6cba7c267b04/pytokens-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd8da894e5a29ba6b6da8be06a4f7589d7220c099b5e363cb0643234b9b38c2a", size = 159864, upload-time = "2026-01-19T07:59:08.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/70/a46669ec55876c392036b4da9808b5c3b1c5870bbca3d4cc923bf68bdbc1/pytokens-0.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:237ba7cfb677dbd3b01b09860810aceb448871150566b93cd24501d5734a04b1", size = 254448, upload-time = "2026-01-19T07:59:10.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/0b/c486fc61299c2fc3b7f88ee4e115d4c8b6ffd1a7f88dc94b398b5b1bc4b8/pytokens-0.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d1a61e36812e4e971cfe2c0e4c1f2d66d8311031dac8bf168af8a249fa04dd", size = 268863, upload-time = "2026-01-19T07:59:12.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/92/b036af846707d25feaff7cafbd5280f1bd6a1034c16bb06a7c910209c1ab/pytokens-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47e2ef3ec6ee86909e520d79f965f9b23389fda47460303cf715d510a6fe544", size = 267181, upload-time = "2026-01-19T07:59:13.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/c0/6d011fc00fefa74ce34816c84a923d2dd7c46b8dbc6ee52d13419786834c/pytokens-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d36954aba4557fd5a418a03cf595ecbb1cdcce119f91a49b19ef09d691a22ae", size = 102814, upload-time = "2026-01-19T07:59:15.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/63/627b7e71d557383da5a97f473ad50f8d9c2c1f55c7d3c2531a120c796f6e/pytokens-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73eff3bdd8ad08da679867992782568db0529b887bed4c85694f84cdf35eafc6", size = 159744, upload-time = "2026-01-19T07:59:16.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/d7/16f434c37ec3824eba6bcb6e798e5381a8dc83af7a1eda0f95c16fe3ade5/pytokens-0.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d97cc1f91b1a8e8ebccf31c367f28225699bea26592df27141deade771ed0afb", size = 253207, upload-time = "2026-01-19T07:59:18.069Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/96/04102856b9527701ae57d74a6393d1aca5bad18a1b1ca48ccffb3c93b392/pytokens-0.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c8952c537cb73a1a74369501a83b7f9d208c3cf92c41dd88a17814e68d48ce", size = 267452, upload-time = "2026-01-19T07:59:19.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/ef/0936eb472b89ab2d2c2c24bb81c50417e803fa89c731930d9fb01176fe9f/pytokens-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dbf56f3c748aed9310b310d5b8b14e2c96d3ad682ad5a943f381bdbbdddf753", size = 265965, upload-time = "2026-01-19T07:59:20.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/f5/64f3d6f7df4a9e92ebda35ee85061f6260e16eac82df9396020eebbca775/pytokens-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:e131804513597f2dff2b18f9911d9b6276e21ef3699abeffc1c087c65a3d975e", size = 102813, upload-time = "2026-01-19T07:59:22.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/f1/d07e6209f18ef378fc2ae9dee8d1dfe91fd2447c2e2dbfa32867b6dd30cf/pytokens-0.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0d7374c917197106d3c4761374718bc55ea2e9ac0fb94171588ef5840ee1f016", size = 159968, upload-time = "2026-01-19T07:59:23.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/73/0eb111400abd382a04f253b269819db9fcc748aa40748441cebdcb6d068f/pytokens-0.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cd3fa1caf9e47a72ee134a29ca6b5bea84712724bba165d6628baa190c6ea5b", size = 253373, upload-time = "2026-01-19T07:59:24.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/8d/9e4e2fdb5bcaba679e54afcc304e9f13f488eb4d626e6b613f9553e03dbd/pytokens-0.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c6986576b7b07fe9791854caa5347923005a80b079d45b63b0be70d50cce5f1", size = 267024, upload-time = "2026-01-19T07:59:25.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b7/e0a370321af2deb772cff14ff337e1140d1eac2c29a8876bfee995f486f0/pytokens-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9940f7c2e2f54fb1cb5fe17d0803c54da7a2bf62222704eb4217433664a186a7", size = 270912, upload-time = "2026-01-19T07:59:27.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/54/4348f916c440d4c3e68b53b4ed0e66b292d119e799fa07afa159566dcc86/pytokens-0.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:54691cf8f299e7efabcc25adb4ce715d3cef1491e1c930eaf555182f898ef66a", size = 103836, upload-time = "2026-01-19T07:59:28.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/f8/a693c0cfa9c783a2a8c4500b7b2a8bab420f8ca4f2d496153226bf1c12e3/pytokens-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94ff5db97a0d3cd7248a5b07ba2167bd3edc1db92f76c6db00137bbaf068ddf8", size = 167643, upload-time = "2026-01-19T07:59:29.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/dd/a64eb1e9f3ec277b69b33ef1b40ffbcc8f0a3bafcde120997efc7bdefebf/pytokens-0.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dd6261cd9cc95fae1227b1b6ebee023a5fd4a4b6330b071c73a516f5f59b63", size = 289553, upload-time = "2026-01-19T07:59:30.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/22/06c1079d93dbc3bca5d013e1795f3d8b9ed6c87290acd6913c1c526a6bb2/pytokens-0.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdca8159df407dbd669145af4171a0d967006e0be25f3b520896bc7068f02c4", size = 302490, upload-time = "2026-01-19T07:59:32.352Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/de/a6f5e43115b4fbf4b93aa87d6c83c79932cdb084f9711daae04549e1e4ad/pytokens-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4b5770abeb2a24347380a1164a558f0ebe06e98aedbd54c45f7929527a5fb26e", size = 305652, upload-time = "2026-01-19T07:59:33.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/3d/c136e057cb622e36e0c3ff7a8aaa19ff9720050c4078235691da885fe6ee/pytokens-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:74500d72c561dad14c037a9e86a657afd63e277dd5a3bb7570932ab7a3b12551", size = 115472, upload-time = "2026-01-19T07:59:34.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/3c/6941a82f4f130af6e1c68c076b6789069ef10c04559bd4733650f902fd3b/pytokens-0.4.0-py3-none-any.whl", hash = "sha256:0508d11b4de157ee12063901603be87fb0253e8f4cb9305eb168b1202ab92068", size = 13224, upload-time = "2026-01-19T07:59:49.822Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stripe"
|
||||||
|
version = "14.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/17866d0ba0233d4f4a6de8424e52a06939178196b4aefad0ad128a9b4eef/stripe-14.2.0.tar.gz", hash = "sha256:9c707cb0503e179c2d9f18731e94f1333705b5cd9dcaae692db09995ad28d411", size = 1455417, upload-time = "2026-01-16T21:32:55.458Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/2f/9f996230072baa188de4c7eae9d017efac3553c01afa67db347ca4a54ba8/stripe-14.2.0-py3-none-any.whl", hash = "sha256:0a1413ca5eff55df40de49f72f4e17ad51e684600b7876a330ba012e9caae689", size = 2097021, upload-time = "2026-01-16T21:32:53.608Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2025.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user