From c43603bfb5e588d0d649c3a2c982d7d9adca8b88 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Tue, 20 Jan 2026 05:22:38 -0600 Subject: [PATCH] inital checkin --- .python-version | 1 + config/__init__.py | 0 config/asgi.py | 16 + config/settings.py | 128 +++++ config/urls.py | 12 + config/wsgi.py | 16 + decks/__init__.py | 0 decks/admin.py | 3 + decks/apps.py | 5 + decks/migrations/0001_initial.py | 32 ++ decks/migrations/0002_initial.py | 22 + decks/migrations/0003_initial.py | 38 ++ decks/migrations/__init__.py | 0 decks/models.py | 26 + decks/tests.py | 3 + decks/tests_import.py | 127 +++++ decks/urls.py | 12 + decks/utils.py | 61 ++ decks/views.py | 134 +++++ main.py | 6 + manage.py | 22 + pyproject.toml | 17 + pytest.ini | 3 + static/css/style.css | 0 store/__init__.py | 0 store/admin.py | 3 + store/apps.py | 5 + store/management/__init__.py | 0 store/management/commands/__init__.py | 0 .../management/commands/create_test_packs.py | 58 ++ store/management/commands/populate_db.py | 139 +++++ store/migrations/0001_initial.py | 90 +++ store/migrations/0002_initial.py | 58 ++ ...ance_order_insurance_purchased_and_more.py | 40 ++ ...isting_alter_orderitem_listing_and_more.py | 57 ++ store/migrations/0005_vaultitem.py | 29 + store/migrations/__init__.py | 0 store/models.py | 152 +++++ store/tests.py | 3 + store/urls.py | 23 + store/utils.py | 133 +++++ store/views.py | 282 +++++++++ templates/base/layout.html | 347 ++++++++++++ templates/decks/deck_builder.html | 83 +++ templates/decks/deck_create.html | 28 + templates/decks/deck_import.html | 49 ++ templates/decks/deck_list.html | 47 ++ templates/registration/login.html | 17 + templates/store/bounty_board.html | 40 ++ templates/store/card_detail.html | 125 ++++ templates/store/card_list.html | 142 +++++ templates/store/cart.html | 80 +++ templates/store/deck_buyer.html | 60 ++ templates/store/my_packs.html | 30 + templates/store/open_pack.html | 125 ++++ templates/store/order_detail.html | 74 +++ templates/store/pack_list.html | 30 + templates/users/address_form.html | 27 + templates/users/payment_form.html | 30 + templates/users/profile.html | 141 +++++ templates/users/register.html | 16 + templates/users/vault.html | 72 +++ tests/test_core.py | 73 +++ users/__init__.py | 0 users/admin.py | 3 + users/apps.py | 5 + users/forms.py | 47 ++ users/migrations/0001_initial.py | 57 ++ .../migrations/0002_address_paymentmethod.py | 41 ++ users/migrations/__init__.py | 0 users/models.py | 62 ++ users/tests.py | 30 + users/urls.py | 15 + users/views.py | 139 +++++ uv.lock | 536 ++++++++++++++++++ 75 files changed, 4327 insertions(+) create mode 100644 .python-version create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 decks/__init__.py create mode 100644 decks/admin.py create mode 100644 decks/apps.py create mode 100644 decks/migrations/0001_initial.py create mode 100644 decks/migrations/0002_initial.py create mode 100644 decks/migrations/0003_initial.py create mode 100644 decks/migrations/__init__.py create mode 100644 decks/models.py create mode 100644 decks/tests.py create mode 100644 decks/tests_import.py create mode 100644 decks/urls.py create mode 100644 decks/utils.py create mode 100644 decks/views.py create mode 100644 main.py create mode 100755 manage.py create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 static/css/style.css create mode 100644 store/__init__.py create mode 100644 store/admin.py create mode 100644 store/apps.py create mode 100644 store/management/__init__.py create mode 100644 store/management/commands/__init__.py create mode 100644 store/management/commands/create_test_packs.py create mode 100644 store/management/commands/populate_db.py create mode 100644 store/migrations/0001_initial.py create mode 100644 store/migrations/0002_initial.py create mode 100644 store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py create mode 100644 store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py create mode 100644 store/migrations/0005_vaultitem.py create mode 100644 store/migrations/__init__.py create mode 100644 store/models.py create mode 100644 store/tests.py create mode 100644 store/urls.py create mode 100644 store/utils.py create mode 100644 store/views.py create mode 100644 templates/base/layout.html create mode 100644 templates/decks/deck_builder.html create mode 100644 templates/decks/deck_create.html create mode 100644 templates/decks/deck_import.html create mode 100644 templates/decks/deck_list.html create mode 100644 templates/registration/login.html create mode 100644 templates/store/bounty_board.html create mode 100644 templates/store/card_detail.html create mode 100644 templates/store/card_list.html create mode 100644 templates/store/cart.html create mode 100644 templates/store/deck_buyer.html create mode 100644 templates/store/my_packs.html create mode 100644 templates/store/open_pack.html create mode 100644 templates/store/order_detail.html create mode 100644 templates/store/pack_list.html create mode 100644 templates/users/address_form.html create mode 100644 templates/users/payment_form.html create mode 100644 templates/users/profile.html create mode 100644 templates/users/register.html create mode 100644 templates/users/vault.html create mode 100644 tests/test_core.py create mode 100644 users/__init__.py create mode 100644 users/admin.py create mode 100644 users/apps.py create mode 100644 users/forms.py create mode 100644 users/migrations/0001_initial.py create mode 100644 users/migrations/0002_address_paymentmethod.py create mode 100644 users/migrations/__init__.py create mode 100644 users/models.py create mode 100644 users/tests.py create mode 100644 users/urls.py create mode 100644 users/views.py create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..8b2868f --- /dev/null +++ b/config/settings.py @@ -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/' diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..f0566f3 --- /dev/null +++ b/config/urls.py @@ -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')), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/decks/__init__.py b/decks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/decks/admin.py b/decks/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/decks/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/decks/apps.py b/decks/apps.py new file mode 100644 index 0000000..fb92f62 --- /dev/null +++ b/decks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DecksConfig(AppConfig): + name = 'decks' diff --git a/decks/migrations/0001_initial.py b/decks/migrations/0001_initial.py new file mode 100644 index 0000000..d8cb10e --- /dev/null +++ b/decks/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/decks/migrations/0002_initial.py b/decks/migrations/0002_initial.py new file mode 100644 index 0000000..2696427 --- /dev/null +++ b/decks/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/decks/migrations/0003_initial.py b/decks/migrations/0003_initial.py new file mode 100644 index 0000000..4e03695 --- /dev/null +++ b/decks/migrations/0003_initial.py @@ -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')}, + ), + ] diff --git a/decks/migrations/__init__.py b/decks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/decks/models.py b/decks/models.py new file mode 100644 index 0000000..fb075c9 --- /dev/null +++ b/decks/models.py @@ -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}" diff --git a/decks/tests.py b/decks/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/decks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/decks/tests_import.py b/decks/tests_import.py new file mode 100644 index 0000000..67af83e --- /dev/null +++ b/decks/tests_import.py @@ -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 diff --git a/decks/urls.py b/decks/urls.py new file mode 100644 index 0000000..71a9040 --- /dev/null +++ b/decks/urls.py @@ -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('/', views.deck_builder, name='deck_builder'), + path('/remove//', views.remove_card_from_deck, name='remove_card'), +] diff --git a/decks/utils.py b/decks/utils.py new file mode 100644 index 0000000..3cc0b31 --- /dev/null +++ b/decks/utils.py @@ -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 diff --git a/decks/views.py b/decks/views.py new file mode 100644 index 0000000..972ace7 --- /dev/null +++ b/decks/views.py @@ -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}) diff --git a/main.py b/main.py new file mode 100644 index 0000000..92a8026 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from example-tcg-site!") + + +if __name__ == "__main__": + main() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..14d2ac7 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7a4fb9b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings +python_files = tests.py test_*.py *_tests.py diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..e69de29 diff --git a/store/__init__.py b/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/admin.py b/store/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/store/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/store/apps.py b/store/apps.py new file mode 100644 index 0000000..4b21863 --- /dev/null +++ b/store/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class StoreConfig(AppConfig): + name = 'store' diff --git a/store/management/__init__.py b/store/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/management/commands/__init__.py b/store/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/management/commands/create_test_packs.py b/store/management/commands/create_test_packs.py new file mode 100644 index 0000000..3793dd5 --- /dev/null +++ b/store/management/commands/create_test_packs.py @@ -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')) diff --git a/store/management/commands/populate_db.py b/store/management/commands/populate_db.py new file mode 100644 index 0000000..bbd6766 --- /dev/null +++ b/store/management/commands/populate_db.py @@ -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") diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py new file mode 100644 index 0000000..ff2808e --- /dev/null +++ b/store/migrations/0001_initial.py @@ -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')), + ], + ), + ] diff --git a/store/migrations/0002_initial.py b/store/migrations/0002_initial.py new file mode 100644 index 0000000..341dcd4 --- /dev/null +++ b/store/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py b/store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py new file mode 100644 index 0000000..efd7d29 --- /dev/null +++ b/store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py @@ -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')), + ], + ), + ] diff --git a/store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py b/store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py new file mode 100644 index 0000000..5899783 --- /dev/null +++ b/store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py @@ -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)), + ], + ), + ] diff --git a/store/migrations/0005_vaultitem.py b/store/migrations/0005_vaultitem.py new file mode 100644 index 0000000..a45ba3f --- /dev/null +++ b/store/migrations/0005_vaultitem.py @@ -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')}, + }, + ), + ] diff --git a/store/migrations/__init__.py b/store/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/models.py b/store/models.py new file mode 100644 index 0000000..81b6600 --- /dev/null +++ b/store/models.py @@ -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})" diff --git a/store/tests.py b/store/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/store/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/store/urls.py b/store/urls.py new file mode 100644 index 0000000..f292a06 --- /dev/null +++ b/store/urls.py @@ -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//', views.card_detail, name='card_detail'), + path('cart/', views.cart_view, name='cart'), + path('cart/add//', views.add_to_cart, name='add_to_cart'), + path('cart/remove//', views.remove_from_cart, name='remove_from_cart'), + path('api/stock//', 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//', 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//', views.open_pack, name='open_pack'), + path('order//', views.order_detail, name='order_detail'), +] diff --git a/store/utils.py b/store/utils.py new file mode 100644 index 0000000..c514b79 --- /dev/null +++ b/store/utils.py @@ -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 diff --git a/store/views.py b/store/views.py new file mode 100644 index 0000000..c820848 --- /dev/null +++ b/store/views.py @@ -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}) + diff --git a/templates/base/layout.html b/templates/base/layout.html new file mode 100644 index 0000000..d267f31 --- /dev/null +++ b/templates/base/layout.html @@ -0,0 +1,347 @@ +{% load static %} + + + + + + {% block title %}Phantom Card Fam - Premium TCG Store{% endblock %} + + {% if not debug %} + + {% endif %} + + + +
+ DEMO SITE: This is an example application. No real products, payments, or purchases are processed. +
+ + +
+ {% if messages %} +
    + {% for message in messages %} + {{ message }} + {% endfor %} +
+ {% endif %} + + {% block content %} + {% endblock %} +
+ + + + {% if not user.is_authenticated %} + + + + {% endif %} + + diff --git a/templates/decks/deck_builder.html b/templates/decks/deck_builder.html new file mode 100644 index 0000000..09dc391 --- /dev/null +++ b/templates/decks/deck_builder.html @@ -0,0 +1,83 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+ +
+
+

Add Cards

+
+ +
+
+ +
+ {% if search_results %} + {% for card in search_results %} +
+ {% if card.image_url %} + + {% endif %} +
+
{{ card.name }}
+
{{ card.set.code }}
+
+
+ {% csrf_token %} + + +
+
+ {% endfor %} + {% elif query %} +

No cards found.

+ {% else %} +

Search to add cards.

+ {% endif %} +
+
+ + +
+
+
+

{{ deck.name }}

+

{{ deck.game.name }} • {{ deck.cards.count }} cards +

+
+ +
+ +
+ {% for item in deck.cards.all %} +
+
+ {% if item.card.image_url %} + + {% endif %} +
+ x{{ item.quantity }} +
+
+
+ Remove +
+
+ {% empty %} +
+

Your deck is empty. Add cards from the left sidebar.

+
+ {% endfor %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/decks/deck_create.html b/templates/decks/deck_create.html new file mode 100644 index 0000000..ca0e924 --- /dev/null +++ b/templates/decks/deck_create.html @@ -0,0 +1,28 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Create New Deck

+
+ {% csrf_token %} +
+ + +
+ +
+ + +
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/decks/deck_import.html b/templates/decks/deck_import.html new file mode 100644 index 0000000..9967773 --- /dev/null +++ b/templates/decks/deck_import.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

Import Deck

+
+
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ +
+ Supported formats: Arena, MTGO, Moxfield, Plain Text. +
Example: 4 Lightning Bolt or 1 Sheoldred, the Apocalypse (DMU) 107 +
+ +
+ +
+ + Cancel +
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/decks/deck_list.html b/templates/decks/deck_list.html new file mode 100644 index 0000000..86c06f2 --- /dev/null +++ b/templates/decks/deck_list.html @@ -0,0 +1,47 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

My Decks

+ + {% if user.profile.is_pro %} + + {% else %} + Upgrade to Pro to Create Decks + {% endif %} +
+ + {% if decks %} + + {% else %} +
+

You haven't created any decks yet.

+
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..3bd41bf --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,17 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Login

+
+ {% csrf_token %} + {{ form.as_p }} + +
+

+ Don't have an account? Register + here. +

+
+{% endblock %} \ No newline at end of file diff --git a/templates/store/bounty_board.html b/templates/store/bounty_board.html new file mode 100644 index 0000000..cbe447e --- /dev/null +++ b/templates/store/bounty_board.html @@ -0,0 +1,40 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Community Bounty Board

+

We're looking for these cards! Sell them to us for a bonus.

+
+ +
+ {% for bounty in bounties %} +
+
+ WANTED +
+ +
+

{{ bounty.card.name }}

+

{{ bounty.card.set.name }}

+ +
+
+
Buying At
+
${{ bounty.target_price }}
+
+
+
Qty Wanted: {{ bounty.quantity_wanted }}
+ +
+
+
+
+ {% empty %} +
+

No active bounties at the moment.

+
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/store/card_detail.html b/templates/store/card_detail.html new file mode 100644 index 0000000..f3bca3d --- /dev/null +++ b/templates/store/card_detail.html @@ -0,0 +1,125 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+ +
+
+ {% if card.image_url %} + {{ card.name }} + {% else %} +
+ No Image
+ {% endif %} +
+ + +
+ {% if card.tcgplayer_id %} + + View on TCGPlayer + + {% endif %} + + Search on eBay + +
+
+ + +
+
+

{{ card.set.game.name }} • {{ card.set.name }}

+

{{ card.name }}

+
+ Rarity: {{ card.rarity }} + Collector #: {{ card.collector_number }} +
+
+ +

Available + Listings

+ +
+ +
+ + + + + +
+
+ +
+ {% for listing in listings %} +
+
+ {{ + listing.condition }} +
+
Condition
+ {% if listing.is_foil %} + Foil + {% endif %} +
+
+ +
+
+
${{ listing.price }}
+
{{ listing.quantity }} available
+
+ + {% if user.is_authenticated %} +
+ {% csrf_token %} + +
+ {% else %} + Login + to Buy + {% endif %} +
+
+ {% empty %} +
+

No listings currently available for this card.

+
+ {% endfor %} +
+ + +
+
+
+

Playtest Proxy Service

+

Download a high-res proxy for playtesting. Credit offered if you buy later.

+
+ +
+
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/store/card_list.html b/templates/store/card_list.html new file mode 100644 index 0000000..7bb0b58 --- /dev/null +++ b/templates/store/card_list.html @@ -0,0 +1,142 @@ +{% extends 'base/layout.html' %} +{% load static %} + +{% block content %} +
+ + + + +
+

Browse Cards

+ + + + {% if page_obj.has_other_pages %} +
+ {% if page_obj.has_previous %} + Prev + {% endif %} + + + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + + + {% if page_obj.has_next %} + Next + {% endif %} +
+ {% endif %} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/store/cart.html b/templates/store/cart.html new file mode 100644 index 0000000..03bad9f --- /dev/null +++ b/templates/store/cart.html @@ -0,0 +1,80 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Shopping Cart

+ + {% if cart and cart.items.count > 0 %} +
+ {% for item in cart.items.all %} +
+
+ {% if item.listing %} + {% if item.listing.card.image_url %} + + {% endif %} +
+

{{ item.listing.card.name }}

+

+ {{ item.listing.get_condition_display }} + {% if item.listing.is_foil %}• Foil{% endif %} +

+
+ {% else %} + {% if item.pack_listing.image_url %} + + {% else %} +
📦
+ {% endif %} +
+

{{ item.pack_listing.name }}

+

Booster Pack

+
+ {% endif %} +
+ +
+
+
{{ item.quantity }} x ${% if item.listing %}{{ item.listing.price }}{% else %}{{ item.pack_listing.price }}{% endif %}
+
+
+ ${{ item.total_price }} +
+ × +
+
+ {% endfor %} + + +
+ Total + ${{ cart.total_price }} +
+
+ + + {% else %} +
+

Your cart is empty.

+ Browse Cards +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/store/deck_buyer.html b/templates/store/deck_buyer.html new file mode 100644 index 0000000..21475c3 --- /dev/null +++ b/templates/store/deck_buyer.html @@ -0,0 +1,60 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Mass Deck Buyer

+

Paste your deck list below to instantly find the cheapest printings for every card.

+ + {% if preview %} +
+

Review Purchase

+

Total Estimated Cost: ${{ total_cost }}

+ +

Found Items

+
    + {% for item in found_items %} +
  • + {{ item.quantity }}x {{ item.card_name }} ({{ item.listing.get_condition_display }}) + ${{ item.total }} +
  • + {% endfor %} +
+ + {% if missing_items %} +

Missing / Out of Stock

+
    + {% for item in missing_items %} +
  • + {{ item.quantity }}x {{ item.name }} +
  • + {% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} + + + + Cancel +
+
+ {% else %} +
+ {% csrf_token %} + + +
+ +
+ + + + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/store/my_packs.html b/templates/store/my_packs.html new file mode 100644 index 0000000..2c95698 --- /dev/null +++ b/templates/store/my_packs.html @@ -0,0 +1,30 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

My Packs

+ {% if packs %} +
+ {% for pack in packs %} +
+
+
+
🎁
+
{{ pack.listing.name }}
+
+
+
+

{{ pack.listing.name }}

+ Open Pack +
+
+ {% endfor %} +
+ {% else %} +
+

You don't have any sealed packs.

+ Buy Packs +
+ {% endif %} +
+{% endblock %} diff --git a/templates/store/open_pack.html b/templates/store/open_pack.html new file mode 100644 index 0000000..f7fb1b6 --- /dev/null +++ b/templates/store/open_pack.html @@ -0,0 +1,125 @@ +{% extends 'base/layout.html' %} +{% load static %} + +{% block content %} +
+

{{ pack.listing.name }}

+ +
+
+
+
🎁
+
Click to Open
+
+
+
+ + + + +
+ + + + +{% endblock %} diff --git a/templates/store/order_detail.html b/templates/store/order_detail.html new file mode 100644 index 0000000..e9f7370 --- /dev/null +++ b/templates/store/order_detail.html @@ -0,0 +1,74 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Order #{{ order.id }}

+ Back to Profile +
+ +
+
+
+

Date Placed

+

{{ order.created_at|date:"F j, Y, P" }}

+
+
+

Status

+ + {{ order.get_status_display }} + +
+
+

Total Amount

+

${{ order.total_price }}

+
+
+ + {% if order.insurance_purchased %} +
+

+ + Shipping Insurance Included +

+
+ {% endif %} +
+ +

Order Items

+
+ {% for item in order.items.all %} +
+
+ {% if item.listing and item.listing.card.image_url %} + {{ item.listing.card.name }} + {% elif item.pack_listing and item.pack_listing.image_url %} + {{ item.pack_listing.name }} + {% else %} +
+ {% endif %} + +
+ {% if item.listing %} +

{{ item.listing.card.name }}

+

+ {{ item.listing.card.set.name }} • {{ item.listing.get_condition_display }} • {% if item.listing.is_foil %}Foil{% else %}Non-Foil{% endif %} +

+ {% elif item.pack_listing %} +

{{ item.pack_listing.name }}

+

Booster Pack

+ {% else %} +

Unknown Item

+ {% endif %} +
+
+ +
+

${{ item.price_at_purchase }}

+

Qty: {{ item.quantity }}

+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/store/pack_list.html b/templates/store/pack_list.html new file mode 100644 index 0000000..2fe08c8 --- /dev/null +++ b/templates/store/pack_list.html @@ -0,0 +1,30 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Booster Packs

+
+ {% for pack in packs %} +
+
+ {% if pack.image_url %} + {{ pack.name }} + {% else %} +
+
📦
+
{{ pack.game.name }}
+
+ {% endif %} +
+
+

{{ pack.name }}

+
+ ${{ pack.price }} + Add to Cart +
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/users/address_form.html b/templates/users/address_form.html new file mode 100644 index 0000000..6b62f1c --- /dev/null +++ b/templates/users/address_form.html @@ -0,0 +1,27 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Add Address

+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% if field.errors %} + {{ field.errors.0 }} + {% endif %} +
+ {% endfor %} +
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/templates/users/payment_form.html b/templates/users/payment_form.html new file mode 100644 index 0000000..a0e0041 --- /dev/null +++ b/templates/users/payment_form.html @@ -0,0 +1,30 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Add Payment Method

+
+

Note: This is a demo. Do not enter real credit card information.

+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% if field.errors %} + {{ field.errors.0 }} + {% endif %} +
+ {% endfor %} +
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/templates/users/profile.html b/templates/users/profile.html new file mode 100644 index 0000000..4232e38 --- /dev/null +++ b/templates/users/profile.html @@ -0,0 +1,141 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+ +
+

{{ user.username }}

+ + {% if user.profile.is_pro %}PRO MEMBER{% else %}BASIC MEMBER{% endif %} + + +
+

Email: {{ user.email }}

+
+ +
+
+ {% csrf_token %} +

Theme Preference:

+
+ {{ form.theme_preference }} + +
+
+
+ + {% if not user.profile.is_pro %} +
+

Upgrade to Pro

+

Get access to Deck Builder and exclusive analytics.

+
+ {% csrf_token %} + +
+
+ {% endif %} +
+ + +
+
+

Addresses

+ + Add +
+ {% if addresses %} +
+ {% for address in addresses %} +
+
+ {{ address.get_address_type_display }} {% if address.is_default %}(Default){% endif %} +
+ {% csrf_token %} + +
+
+

{{ address.street }}
{{ address.city }}, {{ address.state }} {{ address.zip_code }}

+
+ {% endfor %} +
+ {% else %} +

No addresses saved.

+ {% endif %} +
+ + +
+
+

Payment Methods

+ + Add +
+ {% if payment_methods %} +
+ {% for pm in payment_methods %} +
+
+ {{ pm.brand }} •••• {{ pm.last4 }} +
+ {% csrf_token %} + +
+
+

Expires {{ pm.exp_month }}/{{ pm.exp_year }}

+ {% if pm.is_default %}Default{% endif %} +
+ {% endfor %} +
+ {% else %} +

No payment methods saved.

+ {% endif %} +
+ +
+ +
+
+

Recent Orders

+
+ + + {% if date_query %} + Clear + {% endif %} +
+
+ + {% if orders %} + + {% else %} +
+

No orders found.

+ Start Shopping +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/users/register.html b/templates/users/register.html new file mode 100644 index 0000000..bef89a5 --- /dev/null +++ b/templates/users/register.html @@ -0,0 +1,16 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Create Account

+
+ {% csrf_token %} + {{ form.as_p }} + +
+

+ Already have an account? Login here. +

+
+{% endblock %} \ No newline at end of file diff --git a/templates/users/vault.html b/templates/users/vault.html new file mode 100644 index 0000000..ff9383f --- /dev/null +++ b/templates/users/vault.html @@ -0,0 +1,72 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

My Card Vault

+
+ Total Cards: {{ total_cards }} +
+
+ + +
+
+
+ + +
+
+ + +
+ + Clear +
+
+ + {% if vault_items %} +
+ {% for item in vault_items %} +
+
+ {% if item.card.image_url %} + {{ item.card.name }} + {% else %} +
No Image
+ {% endif %} +
+ x{{ item.quantity }} +
+
+
+
+

{{ item.card.name }}

+

{{ item.card.set.name }}

+

{{ item.card.rarity|title }}

+
+
+
+ {% endfor %} +
+ {% else %} +
+

No cards found.

+ +
+ {% endif %} +
+{% endblock %} diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..8d28d93 --- /dev/null +++ b/tests/test_core.py @@ -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 diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/users/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..4ce1fab --- /dev/null +++ b/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' diff --git a/users/forms.py b/users/forms.py new file mode 100644 index 0000000..fd44d67 --- /dev/null +++ b/users/forms.py @@ -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'}), + } diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..f59d66d --- /dev/null +++ b/users/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/users/migrations/0002_address_paymentmethod.py b/users/migrations/0002_address_paymentmethod.py new file mode 100644 index 0000000..cab5229 --- /dev/null +++ b/users/migrations/0002_address_paymentmethod.py @@ -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)), + ], + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..e77215a --- /dev/null +++ b/users/models.py @@ -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}" diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..ab6cc25 --- /dev/null +++ b/users/tests.py @@ -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') diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..d28dd09 --- /dev/null +++ b/users/urls.py @@ -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//', views.delete_address_view, name='delete_address'), + path('profile/payment/delete//', views.delete_payment_method_view, name='delete_payment_method'), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..64fee41 --- /dev/null +++ b/users/views.py @@ -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 + }) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..182c067 --- /dev/null +++ b/uv.lock @@ -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" }, +]