diff --git a/config/settings.py b/config/settings.py
index 8b2868f..890d0ad 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -24,8 +24,12 @@ 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
+FEATURE_BOUNTY_BOARD = DEBUG
+FEATURE_DEMO_SITE = True
+FEATURE_PLAYTEST_PROXY = DEBUG
+FEATURE_VIRTUAL_PACKS = DEBUG
-ALLOWED_HOSTS = []
+ALLOWED_HOSTS = ['testserver', 'localhost', '127.0.0.1']
# Application definition
@@ -45,6 +49,9 @@ INSTALLED_APPS = [
AUTH_USER_MODEL = 'users.User'
+LOGOUT_REDIRECT_URL = '/'
+LOGIN_REDIRECT_URL = 'users:login_success'
+
# Stripe Configuration
STRIPE_PUBLISHABLE_KEY = 'pk_test_placeholder'
STRIPE_SECRET_KEY = 'sk_test_placeholder'
@@ -71,7 +78,9 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
+ 'django.contrib.messages.context_processors.messages',
'django.template.context_processors.debug',
+ 'store.context_processors.feature_flags',
],
},
},
@@ -126,3 +135,11 @@ USE_TZ = True
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
+
+STATICFILES_DIRS = [
+ BASE_DIR / "static",
+]
+
+# Media files (Uploaded by user)
+MEDIA_URL = '/media/'
+MEDIA_ROOT = BASE_DIR / 'media'
diff --git a/config/urls.py b/config/urls.py
index f0566f3..5c59e65 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -1,6 +1,6 @@
from django.contrib import admin
from django.urls import path, include
-from django.views.generic import RedirectView
+from django.views.generic import RedirectView, TemplateView
urlpatterns = [
path('admin/', admin.site.urls),
@@ -9,4 +9,13 @@ urlpatterns = [
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')),
+
+ # SEO
+ path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
]
+
+from django.conf import settings
+from django.conf.urls.static import static
+
+if settings.DEBUG:
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/decks/migrations/0001_initial.py b/decks/migrations/0001_initial.py
index d8cb10e..9917f25 100644
--- a/decks/migrations/0001_initial.py
+++ b/decks/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.1 on 2026-01-19 13:38
+# Generated by Django 6.0.1 on 2026-01-21 17:40
from django.db import migrations, models
diff --git a/decks/migrations/0002_initial.py b/decks/migrations/0002_initial.py
index 2696427..8539092 100644
--- a/decks/migrations/0002_initial.py
+++ b/decks/migrations/0002_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.1 on 2026-01-19 13:38
+# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.db import migrations, models
diff --git a/decks/migrations/0003_initial.py b/decks/migrations/0003_initial.py
index 4e03695..bc2a3eb 100644
--- a/decks/migrations/0003_initial.py
+++ b/decks/migrations/0003_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.1 on 2026-01-19 13:38
+# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.conf import settings
diff --git a/pyproject.toml b/pyproject.toml
index 14d2ac7..2a3d297 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,12 +6,15 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"black>=26.1.0",
+ "bs4>=0.0.2",
"coverage>=7.13.1",
+ "cryptography>=46.0.3",
"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",
+ "sitemap>=20191121",
"stripe>=14.2.0",
]
diff --git a/static/css/style.css b/static/css/style.css
index e69de29..9d59f97 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -0,0 +1,383 @@
+:root {
+ --primary-color: #6366f1;
+ --secondary-color: #a855f7;
+ --bg-color: #0f172a;
+ --text-color: #f8fafc;
+ --card-bg: #1e293b;
+ --border-color: #334155;
+ --nav-height: 70px;
+ /* Semantic colors for theming */
+ --muted-text-color: #94a3b8;
+ --input-bg: #0f172a;
+ --success-color: #34d399;
+ --info-color: #60a5fa;
+ --danger-color: #ef4444;
+ --warning-color: #f59e0b;
+}
+
+[data-theme="light"] {
+ --primary-color: #4f46e5;
+ --secondary-color: #9333ea;
+ --bg-color: #f8fafc;
+ --text-color: #0f172a;
+ --card-bg: #ffffff;
+ --border-color: #e2e8f0;
+ --muted-text-color: #64748b;
+ --input-bg: #ffffff;
+ --success-color: #059669;
+ --info-color: #2563eb;
+ --danger-color: #dc2626;
+ --warning-color: #d97706;
+}
+
+[data-theme="compact"] {
+ --primary-color: #2563eb;
+ --secondary-color: #475569;
+ --bg-color: #f1f5f9;
+ --text-color: #1e293b;
+ --card-bg: #ffffff;
+ --border-color: #cbd5e1;
+ --gap-size: 0.5rem;
+ --muted-text-color: #64748b;
+ --input-bg: #ffffff;
+ --success-color: #059669;
+ --info-color: #2563eb;
+ --danger-color: #dc2626;
+ --warning-color: #d97706;
+}
+
+[data-theme="expressive"] {
+ --primary-color: #ec4899;
+ --secondary-color: #f59e0b;
+ --bg-color: #2a0a2e;
+ /* Dark purple */
+ --text-color: #fdf2f8;
+ --card-bg: #4a1d4b;
+ --border-color: #831843;
+ --muted-text-color: #f9a8d4;
+ --input-bg: #3b0d40;
+ --success-color: #34d399;
+ --info-color: #a78bfa;
+ --danger-color: #fb7185;
+ --warning-color: #fbbf24;
+ font-family: 'Outfit', sans-serif;
+}
+
+[data-theme="technical"] {
+ --primary-color: #10b981;
+ /* Green */
+ --secondary-color: #0ea5e9;
+ /* Blue */
+ --bg-color: #000000;
+ --text-color: #d1d5db;
+ --card-bg: #111827;
+ --border-color: #374151;
+ --muted-text-color: #9ca3af;
+ --input-bg: #0a0a0a;
+ --success-color: #10b981;
+ --info-color: #0ea5e9;
+ --danger-color: #ef4444;
+ --warning-color: #f59e0b;
+ font-family: 'JetBrains Mono', monospace;
+}
+
+/* Compact specific overrides */
+[data-theme="compact"] .card-grid {
+ gap: 1rem;
+}
+
+[data-theme="compact"] .tcg-card-body {
+ padding: 0.5rem;
+}
+
+body {
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ margin: 0;
+ line-height: 1.5;
+ transition: background-color 0.3s, color 0.3s;
+}
+
+nav {
+ background-color: var(--card-bg);
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 2rem;
+ height: var(--nav-height);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.nav-brand {
+ font-size: 1.5rem;
+ font-weight: 800;
+ background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-decoration: none;
+}
+
+.nav-links {
+ display: flex;
+ align-items: center;
+}
+
+.nav-links a {
+ color: var(--text-color);
+ text-decoration: none;
+ margin-left: 1.5rem;
+ font-weight: 500;
+ transition: color 0.2s;
+}
+
+.nav-links a:hover {
+ color: var(--primary-color);
+}
+
+.container {
+ max-width: 1200px;
+ margin: 2rem auto;
+ padding: 0 1rem;
+ min-height: 80vh;
+}
+
+.btn {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ background-color: var(--primary-color);
+ color: white;
+ text-decoration: none;
+ border-radius: 0.375rem;
+ font-weight: 600;
+ transition: opacity 0.2s;
+ border: none;
+ cursor: pointer;
+}
+
+.btn:hover {
+ opacity: 0.9;
+}
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 2rem;
+}
+
+.tcg-card {
+ background-color: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 0.5rem;
+ overflow: hidden;
+ transition: transform 0.2s;
+}
+
+.tcg-card:hover {
+ transform: translateY(-4px);
+}
+
+.tcg-card img {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.tcg-card-body {
+ padding: 1rem;
+}
+
+.messages {
+ list-style: none;
+ padding: 0;
+ margin-bottom: 2rem;
+}
+
+.messages li {
+ padding: 1rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+}
+
+.messages .success {
+ background-color: #064e3b;
+ color: #a7f3d0;
+}
+
+.messages .error {
+ background-color: #7f1d1d;
+ color: #fecaca;
+}
+
+[data-theme="light"] .messages .success {
+ background-color: #d1fae5;
+ color: #065f46;
+}
+
+[data-theme="light"] .messages .error {
+ background-color: #fce7f3;
+ color: #9d174d;
+}
+
+/* Auth Modal Styles */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s, visibility 0.3s;
+ backdrop-filter: blur(4px);
+}
+
+.modal-overlay.active {
+ opacity: 1;
+ visibility: visible;
+}
+
+.auth-modal {
+ background-color: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 0.75rem;
+ padding: 2rem;
+ max-width: 400px;
+ width: 90%;
+ text-align: center;
+ transform: translateY(20px);
+ transition: transform 0.3s;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+}
+
+.modal-overlay.active .auth-modal {
+ transform: translateY(0);
+}
+
+.auth-modal h2 {
+ margin-top: 0;
+ color: var(--primary-color);
+ font-size: 1.5rem;
+ margin-bottom: 1rem;
+}
+
+.auth-modal p {
+ margin-bottom: 2rem;
+ color: var(--text-color);
+ line-height: 1.6;
+}
+
+.auth-modal-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+}
+
+.btn-outline {
+ background-color: transparent;
+ border: 1px solid var(--primary-color);
+ color: var(--primary-color);
+}
+
+.btn-outline:hover {
+ background-color: rgba(99, 102, 241, 0.1);
+}
+
+.close-modal {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ background: none;
+ border: none;
+ color: var(--text-color);
+ font-size: 1.5rem;
+ cursor: pointer;
+ opacity: 0.5;
+ padding: 0;
+ line-height: 1;
+}
+
+.close-modal:hover {
+ opacity: 1;
+}
+
+/* Mobile Menu Button */
+.mobile-menu-btn {
+ display: none;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.5rem;
+}
+
+.mobile-menu-btn .bar {
+ display: block;
+ width: 25px;
+ height: 3px;
+ margin: 5px auto;
+ -webkit-transition: all 0.3s ease-in-out;
+ transition: all 0.3s ease-in-out;
+ background-color: var(--text-color);
+}
+
+/* Media Queries */
+@media (max-width: 768px) {
+
+ /* Navigation */
+ .mobile-menu-btn {
+ display: block;
+ }
+
+ .nav-links {
+ position: fixed;
+ left: -100%;
+ top: var(--nav-height);
+ gap: 0;
+ flex-direction: column;
+ background-color: var(--card-bg);
+ width: 100%;
+ text-align: center;
+ transition: 0.3s;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 1rem;
+ }
+
+ .nav-links.active {
+ left: 0;
+ }
+
+ .nav-links a {
+ margin: 1rem 0;
+ display: block;
+ }
+
+ /* Card Layout - Move filters to top */
+ .browse-container {
+ grid-template-columns: 1fr !important;
+ /* Force single column */
+ }
+
+ .browse-sidebar {
+ width: 100% !important;
+ margin-bottom: 1rem;
+ }
+
+ .browse-sidebar form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ }
+
+ .browse-sidebar button {
+ grid-column: span 2;
+ }
+}
\ No newline at end of file
diff --git a/store/admin.py b/store/admin.py
index 8c38f3f..7a20ddc 100644
--- a/store/admin.py
+++ b/store/admin.py
@@ -1,3 +1,91 @@
from django.contrib import admin
+from .models import Seller, Game, Set, Card, CardListing, PackListing, VirtualPack, Order, OrderItem, Cart, Bounty, VaultItem
# Register your models here.
+@admin.register(Seller)
+class SellerAdmin(admin.ModelAdmin):
+ list_display = ['store_name', 'user', 'slug', 'created_at']
+ search_fields = ['store_name', 'user__username']
+
+@admin.register(Game)
+class GameAdmin(admin.ModelAdmin):
+ list_display = ['name', 'slug']
+
+@admin.register(Set)
+class SetAdmin(admin.ModelAdmin):
+ list_display = ['name', 'game', 'code', 'release_date']
+ list_select_related = ['game']
+ search_fields = ['name', 'code']
+ list_filter = ['game']
+
+class CardListingInline(admin.StackedInline):
+ model = CardListing
+ extra = 0
+ autocomplete_fields = ['seller']
+
+@admin.register(Card)
+class CardAdmin(admin.ModelAdmin):
+ list_display = ['name', 'set', 'rarity', 'collector_number', 'scryfall_id', 'uuid']
+ list_select_related = ['set', 'set__game']
+ search_fields = ['name', 'set__name', 'collector_number', 'uuid']
+ list_filter = ['set__game', 'rarity']
+ inlines = [CardListingInline]
+
+@admin.register(CardListing)
+class CardListingAdmin(admin.ModelAdmin):
+ list_display = ['card', 'seller', 'condition', 'price', 'status', 'quantity', 'uuid']
+ list_select_related = ['card', 'card__set', 'seller']
+ list_filter = ['status', 'condition', 'is_foil']
+ autocomplete_fields = ['card', 'seller']
+
+@admin.register(PackListing)
+class PackListingAdmin(admin.ModelAdmin):
+ list_display = ['name', 'game', 'seller', 'listing_type', 'price', 'uuid']
+ list_select_related = ['game', 'seller']
+ list_filter = ['listing_type', 'game']
+ autocomplete_fields = ['seller']
+
+@admin.register(VirtualPack)
+class VirtualPackAdmin(admin.ModelAdmin):
+ list_display = ['listing', 'owner', 'status', 'created_at', 'uuid']
+ list_select_related = ['listing', 'owner', 'owner__user']
+ list_filter = ['status']
+ raw_id_fields = ['owner'] # Buyer might not have search_fields set up yet, safer to use raw_id or just autocomplete if Buyer has search
+
+@admin.register(Order)
+class OrderAdmin(admin.ModelAdmin):
+ list_display = ['id', 'uuid', 'buyer_info', 'status', 'total_price', 'created_at']
+ list_select_related = ['buyer', 'buyer__user']
+ list_filter = ['status', 'created_at']
+
+ def buyer_info(self, obj):
+ return obj.buyer.user.username
+ buyer_info.short_description = 'Buyer'
+
+@admin.register(OrderItem)
+class OrderItemAdmin(admin.ModelAdmin):
+ list_display = ['order', 'item_description', 'price_at_purchase', 'quantity']
+ list_select_related = ['order', 'listing', 'listing__card', 'pack_listing']
+
+ def item_description(self, obj):
+ if obj.pack_listing:
+ return obj.pack_listing.name
+ return obj.listing.card.name if obj.listing else "Deleted Listing"
+
+@admin.register(Cart)
+class CartAdmin(admin.ModelAdmin):
+ list_display = ['buyer', 'created_at', 'insurance']
+ list_select_related = ['buyer', 'buyer__user']
+
+@admin.register(Bounty)
+class BountyAdmin(admin.ModelAdmin):
+ list_display = ['card', 'target_price', 'quantity_wanted', 'is_active', 'uuid']
+ list_select_related = ['card', 'card__set']
+ autocomplete_fields = ['card']
+
+@admin.register(VaultItem)
+class VaultItemAdmin(admin.ModelAdmin):
+ list_display = ['buyer', 'card', 'quantity', 'added_at']
+ list_select_related = ['buyer', 'buyer__user', 'card', 'card__set']
+ autocomplete_fields = ['card']
+ raw_id_fields = ['buyer']
diff --git a/store/context_processors.py b/store/context_processors.py
new file mode 100644
index 0000000..11e774d
--- /dev/null
+++ b/store/context_processors.py
@@ -0,0 +1,10 @@
+from django.conf import settings
+
+def feature_flags(request):
+ return {
+ 'FEATURE_DEMO_SITE': getattr(settings, 'FEATURE_DEMO_SITE', False),
+ 'FEATURE_BOUNTY_BOARD': getattr(settings, 'FEATURE_BOUNTY_BOARD', False),
+ 'FEATURE_PLAYTEST_PROXY': getattr(settings, 'FEATURE_PLAYTEST_PROXY', False),
+ 'FEATURE_VIRTUAL_PACKS': getattr(settings, 'FEATURE_VIRTUAL_PACKS', False),
+ 'debug': settings.DEBUG,
+ }
diff --git a/store/forms.py b/store/forms.py
new file mode 100644
index 0000000..a8de903
--- /dev/null
+++ b/store/forms.py
@@ -0,0 +1,110 @@
+from django import forms
+from .models import Seller, CardListing, PackListing, Game, Bounty, BountyOffer
+from users.models import Profile
+
+class SellerThemeForm(forms.ModelForm):
+ class Meta:
+ model = Profile
+ fields = ['theme_preference']
+ widgets = {
+ 'theme_preference': forms.Select(attrs={'class': 'form-select'})
+ }
+
+class SellerRegistrationForm(forms.ModelForm):
+ class Meta:
+ model = Seller
+ fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address']
+ widgets = {
+ 'description': forms.Textarea(attrs={'rows': 4}),
+ 'business_address': forms.Textarea(attrs={'rows': 3}),
+ }
+
+class SellerEditForm(forms.ModelForm):
+ tax_id = forms.CharField(required=False, widget=forms.TextInput(attrs={'type': 'password'}), help_text="SSN, ITIN, or EIN (Stored securely)")
+ payout_details = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 3}), help_text="Bank account or other payout details (Stored securely)")
+
+ class Meta:
+ model = Seller
+ fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address', 'store_image', 'hero_image', 'minimum_order_amount', 'shipping_cost', 'tax_id', 'payout_details']
+ widgets = {
+ 'description': forms.Textarea(attrs={'rows': 4}),
+ 'business_address': forms.Textarea(attrs={'rows': 3}),
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if self.instance and self.instance.pk:
+ self.fields['tax_id'].initial = self.instance.tax_id
+ self.fields['payout_details'].initial = self.instance.payout_details
+
+ def save(self, commit=True):
+ seller = super().save(commit=False)
+ seller.tax_id = self.cleaned_data.get('tax_id')
+ seller.payout_details = self.cleaned_data.get('payout_details')
+ if commit:
+ seller.save()
+ return seller
+
+class CardListingForm(forms.ModelForm):
+ class Meta:
+ model = CardListing
+ fields = ['condition', 'price', 'quantity', 'status', 'image']
+ # TODO: Add search widget for card selection or filter by game/set
+
+class PackListingForm(forms.ModelForm):
+ class Meta:
+ model = PackListing
+ fields = ['game', 'name', 'listing_type', 'price', 'quantity', 'image_url']
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Helper to indicate quantity logic
+ self.fields['quantity'].help_text = "For Virtual packs, this is automatically updated based on inventory."
+
+class AddCardListingForm(forms.Form):
+ card_name = forms.CharField(max_length=200, label="Card Name", help_text="Enter the card name.")
+ game = forms.ModelChoiceField(queryset=Game.objects.all(), empty_label="Select Game")
+ set_name = forms.CharField(max_length=200, label="Set Name", help_text="Enter the set name (e.g., 'Alpha', 'Base Set').")
+ collector_number = forms.CharField(max_length=20, required=False, label="Card Number", help_text="The number on the bottom of the card (e.g. '197'). Useful for variants.")
+ condition = forms.ChoiceField(choices=CardListing.CONDITION_CHOICES)
+ price = forms.DecimalField(max_digits=10, decimal_places=2)
+ quantity = forms.IntegerField(min_value=1, initial=1)
+ image = forms.ImageField(required=False, label="Card Image")
+
+class BountyForm(forms.ModelForm):
+ card_name = forms.CharField(max_length=200, label="Card Name", help_text="Search for a card...", required=False)
+ card_id = forms.CharField(widget=forms.HiddenInput(), required=False)
+
+ class Meta:
+ model = Bounty
+ fields = ['title', 'description', 'target_price', 'quantity_wanted']
+ widgets = {
+ 'description': forms.Textarea(attrs={'rows': 3}),
+ }
+
+ def clean(self):
+ cleaned_data = super().clean()
+ card_id = cleaned_data.get('card_id')
+ title = cleaned_data.get('title')
+ card_name = cleaned_data.get('card_name')
+
+ if not card_id and not title and not card_name:
+ raise forms.ValidationError("You must either select a Card or provide a Title.")
+
+ return cleaned_data
+
+class BountyOfferForm(forms.ModelForm):
+ class Meta:
+ model = BountyOffer
+ fields = ['price', 'description']
+ widgets = {
+ 'description': forms.Textarea(attrs={'rows': 3}),
+ }
+
+class MultipleFileInput(forms.ClearableFileInput):
+ allow_multiple_selected = True
+
+class BulkListingForm(forms.Form):
+ csv_file = forms.FileField(label="Upload CSV", help_text="Upload the filled-out template CSV.")
+ images = forms.FileField(widget=MultipleFileInput(attrs={'multiple': True}), required=False, label="Upload Images", help_text="Select all images referenced in your CSV.")
+
diff --git a/store/management/commands/populate_db.py b/store/management/commands/populate_db.py
index bbd6766..3b5d069 100644
--- a/store/management/commands/populate_db.py
+++ b/store/management/commands/populate_db.py
@@ -73,12 +73,12 @@ class Command(BaseCommand):
card, _ = Card.objects.get_or_create(
set=set_obj,
name=card_data['name'],
+ collector_number=card_data['collector_number'],
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']
}
)
diff --git a/store/management/commands/populate_lorcana_cards.py b/store/management/commands/populate_lorcana_cards.py
new file mode 100644
index 0000000..78a6eb7
--- /dev/null
+++ b/store/management/commands/populate_lorcana_cards.py
@@ -0,0 +1,140 @@
+import requests
+import sys
+from django.core.management.base import BaseCommand
+from django.utils.dateparse import parse_date
+from store.models import Game, Set, Card
+
+class Command(BaseCommand):
+ help = 'Populates the database with Disney Lorcana sets and cards from Lorcast.'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--clear',
+ action='store_true',
+ help='Clear existing Disney Lorcana cards and sets before populating.'
+ )
+ parser.add_argument(
+ '--duration',
+ default='7',
+ help='Duration in days to look back for new sets. Use "all" to fetch everything. Default is 7 days.'
+ )
+
+ def handle(self, *args, **options):
+ self.stdout.write(self.style.SUCCESS('Starting Lorcana population...'))
+
+ # 1. Ensure Game exists
+ game, created = Game.objects.get_or_create(
+ name="Disney Lorcana",
+ defaults={'slug': 'disney-lorcana'}
+ )
+ if created:
+ self.stdout.write(self.style.SUCCESS(f'Created Game: {game.name}'))
+ else:
+ self.stdout.write(f'Found Game: {game.name}')
+
+ # Handle --clear
+ if options['clear']:
+ self.stdout.write(self.style.WARNING('Clearing existing Lorcana data...'))
+ Card.objects.filter(set__game=game).delete()
+ Set.objects.filter(game=game).delete()
+ self.stdout.write(self.style.SUCCESS('Cleared Lorcana data.'))
+
+ # Handle --duration
+ duration = options['duration']
+ start_date = None
+
+ if duration != 'all':
+ try:
+ days = int(duration)
+ from django.utils import timezone
+ from datetime import timedelta
+ start_date = timezone.now().date() - timedelta(days=days)
+ self.stdout.write(f'Fetching data from the last {days} days (since {start_date})...')
+ except ValueError:
+ self.stdout.write(self.style.ERROR('Invalid duration. Must be an integer or "all".'))
+ return
+
+ # 2. Fetch Sets
+ self.stdout.write('Fetching sets from Lorcast...')
+ response = requests.get('https://api.lorcast.com/v0/sets')
+ if response.status_code != 200:
+ self.stdout.write(self.style.ERROR(f'Failed to fetch sets: {response.status_code}'))
+ return
+
+ sets_data = response.json().get('results', []) # Lorcast returns { results: [...] }
+ self.stdout.write(f'Found {len(sets_data)} sets. Processing...')
+
+ # Iterate through sets
+ for set_data in sets_data:
+ release_date = parse_date(set_data.get('released_at')) if set_data.get('released_at') else None
+
+ # Update API might return sets that are not in DB, we should add them?
+ # Start date filter:
+ # If we are doing a partial update, we only want to PROCESS cards for sets that are new?
+ # But we update the Set object itself anyway because it is cheap.
+
+ set_obj, created = Set.objects.update_or_create(
+ code=set_data.get('code'),
+ game=game,
+ defaults={
+ 'name': set_data.get('name'),
+ 'release_date': release_date,
+ }
+ )
+
+ # Decide whether to fetch cards for this set
+ should_fetch_cards = True
+ if start_date:
+ # If set is older than start_date, skip fetching cards
+ if not release_date or release_date < start_date:
+ should_fetch_cards = False
+
+ if not should_fetch_cards:
+ # self.stdout.write(f' Skipping cards for older set: {set_obj.name}')
+ continue
+
+ # Fetch cards for this set
+ # GET https://api.lorcast.com/v0/sets/:id/cards
+ set_id = set_data.get('id')
+ self.stdout.write(f' Fetching cards for set: {set_obj.name} (ID: {set_id})...')
+
+ cards_response = requests.get(f'https://api.lorcast.com/v0/sets/{set_id}/cards')
+ if cards_response.status_code != 200:
+ self.stdout.write(self.style.ERROR(f' Failed to fetch cards for set {set_obj.name}'))
+ continue
+
+ cards_data = cards_response.json() # Returns list directly according to docs example
+
+ self.stdout.write(f' Found {len(cards_data)} cards. Updating...')
+
+ for card_data in cards_data:
+ # Extract Image URL
+ image_url = ''
+ if 'image_uris' in card_data and 'digital' in card_data['image_uris']:
+ if 'normal' in card_data['image_uris']['digital']:
+ image_url = card_data['image_uris']['digital']['normal']
+
+ # TCGPlayer ID
+ tcgplayer_id = card_data.get('tcgplayer_id')
+
+ lorcast_id = card_data.get('id')
+
+ # External URL
+ external_url = f"https://lorcast.com/cards/{lorcast_id}"
+
+ collector_number = card_data.get('collector_number', '')
+
+ Card.objects.update_or_create(
+ scryfall_id=lorcast_id, # Re-using this field for unique ID
+ defaults={
+ 'set': set_obj,
+ 'name': card_data.get('name'),
+ 'rarity': card_data.get('rarity'),
+ 'image_url': image_url,
+ 'tcgplayer_id': tcgplayer_id,
+ 'collector_number': collector_number,
+ 'external_url': external_url,
+ }
+ )
+
+ self.stdout.write(self.style.SUCCESS(f'Finished Lorcana population!'))
diff --git a/store/management/commands/populate_mtg_cards.py b/store/management/commands/populate_mtg_cards.py
new file mode 100644
index 0000000..f9d9187
--- /dev/null
+++ b/store/management/commands/populate_mtg_cards.py
@@ -0,0 +1,231 @@
+import requests
+import sys
+from django.core.management.base import BaseCommand
+from django.utils.dateparse import parse_date
+from store.models import Game, Set, Card
+
+class Command(BaseCommand):
+ help = 'Populates the database with Magic: The Gathering sets and cards from Scryfall.'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--clear',
+ action='store_true',
+ help='Clear existing Magic: The Gathering cards and sets before populating.'
+ )
+ parser.add_argument(
+ '--duration',
+ default='7',
+ help='Duration in days to look back for new cards/sets. Use "all" to fetch everything. Default is 7 days.'
+ )
+
+ def handle(self, *args, **options):
+ self.stdout.write(self.style.SUCCESS('Starting MTG population...'))
+
+ # 1. Ensure Game exists
+ game, created = Game.objects.get_or_create(
+ name="Magic: The Gathering",
+ defaults={'slug': 'magic-the-gathering'}
+ )
+ if created:
+ self.stdout.write(self.style.SUCCESS(f'Created Game: {game.name}'))
+ else:
+ self.stdout.write(f'Found Game: {game.name}')
+
+ # Handle --clear
+ if options['clear']:
+ self.stdout.write(self.style.WARNING('Clearing existing MTG data...'))
+ Card.objects.filter(set__game=game).delete()
+ Set.objects.filter(game=game).delete()
+ self.stdout.write(self.style.SUCCESS('Cleared MTG data.'))
+
+ # Handle --duration
+ duration = options['duration']
+ start_date = None
+
+ if duration != 'all':
+ try:
+ days = int(duration)
+ from django.utils import timezone
+ from datetime import timedelta
+ start_date = timezone.now().date() - timedelta(days=days)
+ self.stdout.write(f'Fetching data from the last {days} days (since {start_date})...')
+ except ValueError:
+ self.stdout.write(self.style.ERROR('Invalid duration. Must be an integer or "all".'))
+ return
+
+ # 2. Fetch Sets
+ self.stdout.write('Fetching sets from Scryfall...')
+ response = requests.get('https://api.scryfall.com/sets')
+ if response.status_code != 200:
+ self.stdout.write(self.style.ERROR('Failed to fetch sets'))
+ return
+
+ sets_data = response.json().get('data', [])
+ self.stdout.write(f'Found {len(sets_data)} sets total. Filtering and processing...')
+
+ sets_processed = 0
+ for set_data in sets_data:
+ release_date = parse_date(set_data.get('released_at')) if set_data.get('released_at') else None
+
+ # If start_date is set, skip sets older than start_date
+ # Note: Scryfall API doesn't allow filtering sets by date in the list endpoint efficienty, so we filter here.
+ # However, cards might be added to old sets? Scryfall cards usually are released with sets.
+ # But if we are doing a partial update, we only care about sets released recently?
+ # Or should we check all sets? The user requirement implies "grabs the data from the past X days".
+ # Safest is to update all sets (lightweight) or just new ones.
+ # Let's update all sets if "all", or filter if "duration".
+ # Actually, updating Sets is fast. Let's just update all sets regardless of duration to be safe,
+ # unless clearing.
+ # WAIT, if we only want "latest set", we should filter.
+ # User said: "If we can go based off of duration then we can do sets too. and only grab that latests set."
+ # So let's filter sets by date if duration is set.
+
+ if start_date and release_date and release_date < start_date:
+ continue
+
+ set_obj, created = Set.objects.update_or_create(
+ code=set_data.get('code'),
+ game=game,
+ defaults={
+ 'name': set_data.get('name'),
+ 'release_date': release_date,
+ }
+ )
+ sets_processed += 1
+
+ self.stdout.write(self.style.SUCCESS(f'Processed {sets_processed} sets.'))
+
+ # 3. Fetch Cards
+ if duration == 'all':
+ self.fetch_bulk_data(game)
+ else:
+ self.fetch_recent_cards(game, start_date)
+
+ self.stdout.write(self.style.SUCCESS('Finished MTG population!'))
+
+ def fetch_bulk_data(self, game):
+ self.stdout.write('Fetching Bulk Data info (Full Update)...')
+ response = requests.get('https://api.scryfall.com/bulk-data')
+ if response.status_code != 200:
+ self.stdout.write(self.style.ERROR('Failed to fetch bulk data info'))
+ return
+
+ bulk_data_list = response.json().get('data', [])
+ download_uri = None
+ for item in bulk_data_list:
+ if item.get('type') == 'default_cards':
+ download_uri = item.get('download_uri')
+ break
+
+ if not download_uri:
+ self.stdout.write(self.style.ERROR('Could not find "default_cards" bulk data.'))
+ return
+
+ self.stdout.write(f'Downloading card data from {download_uri} ...')
+
+ with requests.get(download_uri, stream=True) as r:
+ r.raise_for_status()
+ try:
+ cards_data = r.json()
+ except Exception as e:
+ self.stdout.write(self.style.ERROR(f'Failed to load JSON: {e}'))
+ return
+
+ self.process_cards(cards_data, game)
+
+ def fetch_recent_cards(self, game, start_date):
+ self.stdout.write(f'Fetching cards released since {start_date}...')
+ # Use Search API
+ # date>=YYYY-MM-DD
+ query = f"date>={start_date.isoformat()}"
+ url = "https://api.scryfall.com/cards/search"
+ params = {'q': query, 'order': 'released'}
+
+ has_more = True
+ next_url = url
+ total_processed = 0
+
+ while has_more:
+ self.stdout.write(f' Requesting: {next_url} (params: {params})')
+ response = requests.get(next_url, params=params if next_url == url else None)
+
+ if response.status_code != 200:
+ self.stdout.write(self.style.ERROR(f'Failed to search cards: {response.text}'))
+ return
+
+ data = response.json()
+ cards_data = data.get('data', [])
+ self.process_cards(cards_data, game, verbose=False)
+
+ total_processed += len(cards_data)
+ has_more = data.get('has_more', False)
+ next_url = data.get('next_page')
+
+ # Scryfall requests being nice
+ import time
+ time.sleep(0.1)
+
+ self.stdout.write(f'fetched {total_processed} cards via search.')
+
+ def process_cards(self, cards_data, game, verbose=True):
+ if verbose:
+ self.stdout.write(f'Processing {len(cards_data)} cards...')
+
+ # Cache sets
+ sets_map = {s.code: s for s in Set.objects.filter(game=game)}
+ count = 0
+
+ for card_data in cards_data:
+ set_code = card_data.get('set')
+ if set_code not in sets_map:
+ # If we filtered sets by date, we might miss the set for this card if the card is newer than the set release?
+ # (e.g. late additions).
+ # Or if we have a partial set update but the card belongs to an old set (reprints in new product?)
+ # If set is not in DB, we skip or fetch it?
+ # Given we updated sets based on duration, if the set isn't there, we probably shouldn't add the card
+ # OR we should lazily create the set.
+ # For safety, let's try to get the set from DB again or skip.
+ # If we skipped the set because of date, we probably shouldn't double guess ourselves.
+ continue
+
+ set_obj = sets_map[set_code]
+
+ # Extract Image URL
+ image_url = ''
+ if 'image_uris' in card_data and 'normal' in card_data['image_uris']:
+ image_url = card_data['image_uris']['normal']
+ elif 'card_faces' in card_data and card_data['card_faces'] and 'image_uris' in card_data['card_faces'][0]:
+ if 'normal' in card_data['card_faces'][0]['image_uris']:
+ image_url = card_data['card_faces'][0]['image_uris']['normal']
+
+ # TCGPlayer ID
+ tcgplayer_id = card_data.get('tcgplayer_id')
+
+ # Scryfall ID
+ scryfall_id = card_data.get('id')
+
+ # External URL (Scryfall URI)
+ external_url = card_data.get('scryfall_uri', '')
+
+ # Collector Number
+ collector_number = card_data.get('collector_number', '')
+
+ Card.objects.update_or_create(
+ scryfall_id=scryfall_id,
+ defaults={
+ 'set': set_obj,
+ 'name': card_data.get('name'),
+ 'rarity': card_data.get('rarity'),
+ 'image_url': image_url,
+ 'tcgplayer_id': tcgplayer_id,
+ 'collector_number': collector_number,
+ 'external_url': external_url,
+ }
+ )
+ count += 1
+ if verbose and count % 1000 == 0:
+ self.stdout.write(f' Processed {count}...')
+
+ if verbose:
+ self.stdout.write(f'Batch processed {count} cards.')
diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py
index ff2808e..68eebef 100644
--- a/store/migrations/0001_initial.py
+++ b/store/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.1 on 2026-01-19 13:38
+# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.db import migrations, models
@@ -29,6 +29,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
+ ('insurance', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
@@ -56,6 +57,8 @@ class Migration(migrations.Migration):
('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)),
+ ('insurance_purchased', models.BooleanField(default=False)),
+ ('proxy_service', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
@@ -66,6 +69,28 @@ class Migration(migrations.Migration):
('quantity', models.PositiveIntegerField(default=1)),
],
),
+ 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)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Seller',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('store_name', models.CharField(max_length=100, unique=True)),
+ ('slug', models.SlugField(unique=True)),
+ ('description', models.TextField(blank=True)),
+ ('contact_email', models.EmailField(blank=True, max_length=254)),
+ ('contact_phone', models.CharField(blank=True, max_length=20)),
+ ('business_address', models.TextField(blank=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
migrations.CreateModel(
name='Set',
fields=[
@@ -75,6 +100,33 @@ class Migration(migrations.Migration):
('release_date', models.DateField(blank=True, null=True)),
],
),
+ 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)),
+ ],
+ ),
+ 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)),
+ ],
+ ),
+ 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')),
+ ],
+ ),
migrations.CreateModel(
name='CardListing',
fields=[
diff --git a/store/migrations/0002_initial.py b/store/migrations/0002_initial.py
index 341dcd4..4d6f2f2 100644
--- a/store/migrations/0002_initial.py
+++ b/store/migrations/0002_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.1 on 2026-01-19 13:38
+# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.db.models.deletion
from django.conf import settings
@@ -11,14 +11,15 @@ class Migration(migrations.Migration):
dependencies = [
('store', '0001_initial'),
+ ('users', '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),
+ name='buyer',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to='users.buyer'),
),
migrations.AddField(
model_name='cartitem',
@@ -28,23 +29,53 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='cartitem',
name='listing',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'),
+ field=models.ForeignKey(blank=True, null=True, 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),
+ name='buyer',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='users.buyer'),
),
migrations.AddField(
model_name='orderitem',
name='listing',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'),
+ field=models.ForeignKey(blank=True, 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='packlisting',
+ name='game',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.game'),
+ ),
+ 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.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='seller',
+ name='user',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='seller_profile', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='packlisting',
+ name='seller',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.seller'),
+ ),
+ migrations.AddField(
+ model_name='cardlisting',
+ name='seller',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='card_listings', to='store.seller'),
+ ),
migrations.AddField(
model_name='set',
name='game',
@@ -55,4 +86,33 @@ class Migration(migrations.Migration):
name='set',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='store.set'),
),
+ migrations.AddField(
+ model_name='vaultitem',
+ name='buyer',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='users.buyer'),
+ ),
+ migrations.AddField(
+ model_name='vaultitem',
+ name='card',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='store.card'),
+ ),
+ migrations.AddField(
+ model_name='virtualpack',
+ name='cards',
+ field=models.ManyToManyField(related_name='packs', to='store.card'),
+ ),
+ migrations.AddField(
+ model_name='virtualpack',
+ name='listing',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packs', to='store.packlisting'),
+ ),
+ migrations.AddField(
+ model_name='virtualpack',
+ name='owner',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='packs', to='users.buyer'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='vaultitem',
+ unique_together={('buyer', 'card')},
+ ),
]
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
deleted file mode 100644
index efd7d29..0000000
--- a/store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# 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/0003_packlisting_quantity.py b/store/migrations/0003_packlisting_quantity.py
new file mode 100644
index 0000000..5e177f3
--- /dev/null
+++ b/store/migrations/0003_packlisting_quantity.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.1 on 2026-01-21 18:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('store', '0002_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='packlisting',
+ name='quantity',
+ field=models.PositiveIntegerField(default=0),
+ ),
+ ]
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
deleted file mode 100644
index 5899783..0000000
--- a/store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# 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/0004_packlisting_listing_type.py b/store/migrations/0004_packlisting_listing_type.py
new file mode 100644
index 0000000..a1c9a5b
--- /dev/null
+++ b/store/migrations/0004_packlisting_listing_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.1 on 2026-01-21 19:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('store', '0003_packlisting_quantity'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='packlisting',
+ name='listing_type',
+ field=models.CharField(choices=[('physical', 'Physical (Shipped)'), ('virtual', 'Virtual (Open on Store)')], default='physical', max_length=10),
+ ),
+ ]
diff --git a/store/migrations/0005_cardlisting_image_cardlisting_status.py b/store/migrations/0005_cardlisting_image_cardlisting_status.py
new file mode 100644
index 0000000..3890424
--- /dev/null
+++ b/store/migrations/0005_cardlisting_image_cardlisting_status.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.1 on 2026-01-21 20:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('store', '0004_packlisting_listing_type'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cardlisting',
+ name='image',
+ field=models.ImageField(blank=True, null=True, upload_to='listing_images/'),
+ ),
+ migrations.AddField(
+ model_name='cardlisting',
+ name='status',
+ field=models.CharField(choices=[('listed', 'Listed'), ('sold', 'Sold'), ('off_market', 'Off Market')], default='listed', max_length=20),
+ ),
+ ]
diff --git a/store/migrations/0005_vaultitem.py b/store/migrations/0005_vaultitem.py
deleted file mode 100644
index a45ba3f..0000000
--- a/store/migrations/0005_vaultitem.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# 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/0006_card_external_url.py b/store/migrations/0006_card_external_url.py
new file mode 100644
index 0000000..91df678
--- /dev/null
+++ b/store/migrations/0006_card_external_url.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.1 on 2026-01-21 20:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('store', '0005_cardlisting_image_cardlisting_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='card',
+ name='external_url',
+ field=models.URLField(blank=True, help_text='Link to official card page (e.g. Scryfall, Lorcast)', max_length=500),
+ ),
+ ]
diff --git a/store/migrations/0007_seller_hero_image_seller_store_image.py b/store/migrations/0007_seller_hero_image_seller_store_image.py
new file mode 100644
index 0000000..262fab6
--- /dev/null
+++ b/store/migrations/0007_seller_hero_image_seller_store_image.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.1 on 2026-01-22 13:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('store', '0006_card_external_url'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='seller',
+ name='hero_image',
+ field=models.ImageField(blank=True, null=True, upload_to='seller_hero_images/'),
+ ),
+ migrations.AddField(
+ model_name='seller',
+ name='store_image',
+ field=models.ImageField(blank=True, null=True, upload_to='seller_images/'),
+ ),
+ ]
diff --git a/store/migrations/0008_bounty_description_bounty_seller_bounty_title_and_more.py b/store/migrations/0008_bounty_description_bounty_seller_bounty_title_and_more.py
new file mode 100644
index 0000000..72e6a5f
--- /dev/null
+++ b/store/migrations/0008_bounty_description_bounty_seller_bounty_title_and_more.py
@@ -0,0 +1,47 @@
+# Generated by Django 6.0.1 on 2026-01-22 15:02
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('store', '0007_seller_hero_image_seller_store_image'),
+ ('users', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='bounty',
+ name='description',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='bounty',
+ name='seller',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.seller'),
+ ),
+ migrations.AddField(
+ model_name='bounty',
+ name='title',
+ field=models.CharField(blank=True, help_text='Required if no card selected', max_length=200),
+ ),
+ migrations.AlterField(
+ model_name='bounty',
+ name='card',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bounties', to='store.card'),
+ ),
+ migrations.CreateModel(
+ name='BountyOffer',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('price', models.DecimalField(decimal_places=2, max_digits=10)),
+ ('description', models.TextField(blank=True, help_text='Details about what you are offering (e.g. card condition)')),
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('countered', 'Countered')], default='pending', max_length=20)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offers', to='store.bounty')),
+ ('buyer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_offers', to='users.buyer')),
+ ],
+ ),
+ ]
diff --git a/store/migrations/0009_seller_listing_clicks_seller_store_views.py b/store/migrations/0009_seller_listing_clicks_seller_store_views.py
new file mode 100644
index 0000000..2e8630c
--- /dev/null
+++ b/store/migrations/0009_seller_listing_clicks_seller_store_views.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.1 on 2026-01-23 12:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('store', '0008_bounty_description_bounty_seller_bounty_title_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='seller',
+ name='listing_clicks',
+ field=models.PositiveIntegerField(default=0),
+ ),
+ migrations.AddField(
+ model_name='seller',
+ name='store_views',
+ field=models.PositiveIntegerField(default=0),
+ ),
+ ]
diff --git a/store/migrations/0010_remove_cartitem_pack_listing_bounty_uuid_and_more.py b/store/migrations/0010_remove_cartitem_pack_listing_bounty_uuid_and_more.py
new file mode 100644
index 0000000..70e8e39
--- /dev/null
+++ b/store/migrations/0010_remove_cartitem_pack_listing_bounty_uuid_and_more.py
@@ -0,0 +1,58 @@
+# Generated by Django 6.0.1 on 2026-01-23 12:48
+
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("store", "0009_seller_listing_clicks_seller_store_views"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="cartitem",
+ name="pack_listing",
+ ),
+ migrations.AddField(
+ model_name="bounty",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="bountyoffer",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="card",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="cardlisting",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="cartitem",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="order",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="packlisting",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="virtualpack",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
+ ),
+ ]
diff --git a/store/migrations/0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more.py b/store/migrations/0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more.py
new file mode 100644
index 0000000..183435b
--- /dev/null
+++ b/store/migrations/0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more.py
@@ -0,0 +1,74 @@
+# Generated by Django 6.0.1 on 2026-01-23 12:48
+
+import uuid
+from django.db import migrations, models
+
+def populate_uuids(apps, schema_editor):
+ models_to_update = [
+ "Bounty",
+ "BountyOffer",
+ "Card",
+ "CardListing",
+ "CartItem",
+ "Order",
+ "PackListing",
+ "VirtualPack",
+ ]
+ for model_name in models_to_update:
+ try:
+ Model = apps.get_model("store", model_name)
+ for obj in Model.objects.all():
+ obj.uuid = uuid.uuid4()
+ obj.save()
+ except LookupError:
+ pass
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("store", "0010_remove_cartitem_pack_listing_bounty_uuid_and_more"),
+ ]
+
+ operations = [
+ migrations.RunPython(populate_uuids, reverse_code=migrations.RunPython.noop),
+ migrations.AlterField(
+ model_name="bounty",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="bountyoffer",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="card",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="cardlisting",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="cartitem",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="order",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="packlisting",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="virtualpack",
+ name="uuid",
+ field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+ ),
+ ]
diff --git a/store/migrations/0012_cartitem_pack_listing.py b/store/migrations/0012_cartitem_pack_listing.py
new file mode 100644
index 0000000..33848cb
--- /dev/null
+++ b/store/migrations/0012_cartitem_pack_listing.py
@@ -0,0 +1,24 @@
+# Generated by Django 6.0.1 on 2026-01-23 15:49
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("store", "0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more"),
+ ]
+
+ operations = [
+ 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",
+ ),
+ ),
+ ]
diff --git a/store/migrations/0013_order_rating_order_seller.py b/store/migrations/0013_order_rating_order_seller.py
new file mode 100644
index 0000000..693e327
--- /dev/null
+++ b/store/migrations/0013_order_rating_order_seller.py
@@ -0,0 +1,38 @@
+# Generated by Django 6.0.1 on 2026-01-23 16:16
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("store", "0012_cartitem_pack_listing"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="order",
+ name="rating",
+ field=models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(5),
+ ],
+ ),
+ ),
+ migrations.AddField(
+ model_name="order",
+ name="seller",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="orders",
+ to="store.seller",
+ ),
+ ),
+ ]
diff --git a/store/migrations/0014_sellerreport.py b/store/migrations/0014_sellerreport.py
new file mode 100644
index 0000000..7df07b6
--- /dev/null
+++ b/store/migrations/0014_sellerreport.py
@@ -0,0 +1,59 @@
+# Generated by Django 6.0.1 on 2026-01-23 16:34
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("store", "0013_order_rating_order_seller"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="SellerReport",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "reason",
+ models.CharField(
+ choices=[
+ ("explicit", "Explicit/NSFW Content"),
+ ("scam", "Scam"),
+ ("other", "Other"),
+ ],
+ max_length=20,
+ ),
+ ),
+ ("details", models.TextField(blank=True)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "reporter",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="seller_reports",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "seller",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="reports",
+ to="store.seller",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/store/migrations/0015_seller_minimum_order_amount_seller_shipping_cost.py b/store/migrations/0015_seller_minimum_order_amount_seller_shipping_cost.py
new file mode 100644
index 0000000..d13ab70
--- /dev/null
+++ b/store/migrations/0015_seller_minimum_order_amount_seller_shipping_cost.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.1 on 2026-01-23 17:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("store", "0014_sellerreport"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="seller",
+ name="minimum_order_amount",
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
+ ),
+ migrations.AddField(
+ model_name="seller",
+ name="shipping_cost",
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
+ ),
+ ]
diff --git a/store/migrations/0016_seller_payout_details_encrypted_and_more.py b/store/migrations/0016_seller_payout_details_encrypted_and_more.py
new file mode 100644
index 0000000..3e9d9ac
--- /dev/null
+++ b/store/migrations/0016_seller_payout_details_encrypted_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.1 on 2026-01-23 17:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("store", "0015_seller_minimum_order_amount_seller_shipping_cost"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="seller",
+ name="payout_details_encrypted",
+ field=models.BinaryField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="seller",
+ name="tax_id_encrypted",
+ field=models.BinaryField(blank=True, null=True),
+ ),
+ ]
diff --git a/store/models.py b/store/models.py
index 81b6600..1d12c63 100644
--- a/store/models.py
+++ b/store/models.py
@@ -1,5 +1,7 @@
from django.db import models
+from django.core.validators import MinValueValidator, MaxValueValidator
from django.conf import settings
+import uuid
class Game(models.Model):
name = models.CharField(max_length=100, unique=True)
@@ -17,6 +19,48 @@ class Set(models.Model):
def __str__(self):
return f"{self.game.name} - {self.name}"
+class Seller(models.Model):
+ user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='seller_profile')
+ store_name = models.CharField(max_length=100, unique=True)
+ slug = models.SlugField(unique=True)
+ description = models.TextField(blank=True)
+ store_image = models.ImageField(upload_to='seller_images/', blank=True, null=True)
+ hero_image = models.ImageField(upload_to='seller_hero_images/', blank=True, null=True)
+ contact_email = models.EmailField(blank=True)
+ contact_phone = models.CharField(max_length=20, blank=True)
+ business_address = models.TextField(blank=True)
+ store_views = models.PositiveIntegerField(default=0)
+ listing_clicks = models.PositiveIntegerField(default=0)
+ minimum_order_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
+ shipping_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ tax_id_encrypted = models.BinaryField(blank=True, null=True)
+ payout_details_encrypted = models.BinaryField(blank=True, null=True)
+
+ @property
+ def tax_id(self):
+ from .utils import Encryptor
+ return Encryptor.decrypt(self.tax_id_encrypted)
+
+ @tax_id.setter
+ def tax_id(self, value):
+ from .utils import Encryptor
+ self.tax_id_encrypted = Encryptor.encrypt(value)
+
+ @property
+ def payout_details(self):
+ from .utils import Encryptor
+ return Encryptor.decrypt(self.payout_details_encrypted)
+
+ @payout_details.setter
+ def payout_details(self, value):
+ from .utils import Encryptor
+ self.payout_details_encrypted = Encryptor.encrypt(value)
+
+ def __str__(self):
+ return self.store_name
+
class Card(models.Model):
set = models.ForeignKey(Set, on_delete=models.CASCADE, related_name='cards')
name = models.CharField(max_length=200)
@@ -25,10 +69,13 @@ class Card(models.Model):
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)
+ external_url = models.URLField(max_length=500, blank=True, help_text="Link to official card page (e.g. Scryfall, Lorcast)")
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return self.name
+
class CardListing(models.Model):
CONDITION_CHOICES = (
('NM', 'Near Mint'),
@@ -37,21 +84,42 @@ class CardListing(models.Model):
('HP', 'Heavily Played'),
)
+ STATUS_CHOICES = (
+ ('listed', 'Listed'),
+ ('sold', 'Sold'),
+ ('off_market', 'Off Market'),
+ )
+
card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='listings')
+ seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='card_listings', null=True, blank=True)
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)
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='listed')
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='listed')
+ image = models.ImageField(upload_to='listing_images/', blank=True, null=True)
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
- return f"{self.card.name} ({self.condition}) - ${self.price}"
+ return f"{self.card.name} ({self.condition}) - ${self.price} [{self.status}]"
class PackListing(models.Model):
+ LISTING_TYPE_CHOICES = (
+ ('physical', 'Physical (Shipped)'),
+ ('virtual', 'Virtual (Open on Store)'),
+ )
+
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='pack_listings')
+ seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='pack_listings', null=True, blank=True)
name = models.CharField(max_length=200)
+ listing_type = models.CharField(max_length=10, choices=LISTING_TYPE_CHOICES, default='physical')
price = models.DecimalField(max_digits=10, decimal_places=2)
+ quantity = models.PositiveIntegerField(default=0)
+ quantity = models.PositiveIntegerField(default=0)
image_url = models.URLField(max_length=500, blank=True)
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"{self.name} - ${self.price}"
@@ -64,8 +132,9 @@ class VirtualPack(models.Model):
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')
+ owner = models.ForeignKey('users.Buyer', on_delete=models.SET_NULL, null=True, blank=True, related_name='packs')
created_at = models.DateTimeField(auto_now_add=True)
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
return f"{self.listing.name} ({self.get_status_display()})"
@@ -78,7 +147,7 @@ class Order(models.Model):
('cancelled', 'Cancelled'),
)
- user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders')
+ buyer = models.ForeignKey('users.Buyer', 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)
@@ -87,9 +156,12 @@ class Order(models.Model):
shipping_address = models.TextField(blank=True)
insurance_purchased = models.BooleanField(default=False)
proxy_service = models.BooleanField(default=False)
+ seller = models.ForeignKey(Seller, on_delete=models.SET_NULL, related_name='orders', null=True, blank=True)
+ rating = models.PositiveSmallIntegerField(null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(5)])
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
- return f"Order #{self.id} - {self.user.username}"
+ return f"Order #{self.id} - {self.buyer.user.username}"
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
@@ -104,7 +176,7 @@ class OrderItem(models.Model):
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)
+ buyer = models.OneToOneField('users.Buyer', on_delete=models.CASCADE, related_name='cart', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
insurance = models.BooleanField(default=False)
@@ -120,6 +192,7 @@ class CartItem(models.Model):
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)
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
@property
def total_price(self):
@@ -130,23 +203,69 @@ class CartItem(models.Model):
return 0
class Bounty(models.Model):
- card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='bounties')
+ seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='bounties', null=True)
+ card = models.ForeignKey(Card, on_delete=models.SET_NULL, related_name='bounties', null=True, blank=True)
+ title = models.CharField(max_length=200, blank=True, help_text="Required if no card selected")
+ description = models.TextField(blank=True)
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)
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
def __str__(self):
- return f"WANTED: {self.card.name} @ ${self.target_price}"
+ if self.card:
+ return f"WANTED: {self.card.name} @ ${self.target_price}"
+ return f"WANTED: {self.title} @ ${self.target_price}"
+
+ def save(self, *args, **kwargs):
+ if not self.card and not self.title:
+ raise ValueError("Bounty must have either a Card or a Title")
+ if self.card and not self.title:
+ self.title = f"Buying {self.card.name}"
+ super().save(*args, **kwargs)
+
+class BountyOffer(models.Model):
+ STATUS_CHOICES = (
+ ('pending', 'Pending'),
+ ('accepted', 'Accepted'),
+ ('rejected', 'Rejected'),
+ ('countered', 'Countered'),
+ )
+ bounty = models.ForeignKey(Bounty, on_delete=models.CASCADE, related_name='offers')
+ buyer = models.ForeignKey('users.Buyer', on_delete=models.CASCADE, related_name='bounty_offers')
+ price = models.DecimalField(max_digits=10, decimal_places=2)
+ description = models.TextField(blank=True, help_text="Details about what you are offering (e.g. card condition)")
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
+ created_at = models.DateTimeField(auto_now_add=True)
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
+
+ def __str__(self):
+ return f"Offer of ${self.price} by {self.buyer.user.username} on {self.bounty}"
class VaultItem(models.Model):
- user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='vault_items')
+ buyer = models.ForeignKey('users.Buyer', 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')
+ unique_together = ('buyer', 'card')
def __str__(self):
- return f"{self.user.username}'s {self.card.name} ({self.quantity})"
+ return f"{self.buyer.user.username}'s {self.card.name} ({self.quantity})"
+
+class SellerReport(models.Model):
+ reporter = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='seller_reports')
+ seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='reports')
+ REASON_CHOICES = [
+ ('explicit', 'Explicit/NSFW Content'),
+ ('scam', 'Scam'),
+ ('other', 'Other'),
+ ]
+ reason = models.CharField(max_length=20, choices=REASON_CHOICES)
+ details = models.TextField(blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ def __str__(self):
+ return f"Report by {self.reporter} on {self.seller.store_name} - {self.get_reason_display()}"
diff --git a/store/templates/store/add_card_listing.html b/store/templates/store/add_card_listing.html
new file mode 100644
index 0000000..5a56643
--- /dev/null
+++ b/store/templates/store/add_card_listing.html
@@ -0,0 +1,348 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
Add New Card Listing
+
+ Specify the card details. If the card or set doesn't exist, it will be created automatically.
+
+
+
+
+ Single Listing
+ Bulk Upload
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/add_pack_listing.html b/store/templates/store/add_pack_listing.html
new file mode 100644
index 0000000..5e135a9
--- /dev/null
+++ b/store/templates/store/add_pack_listing.html
@@ -0,0 +1,153 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
Add New Pack Listing
+
+ Create a listing for physical or virtual packs.
+
+
+
+
+ Single Listing
+ Bulk Upload
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/admin_revenue_dashboard.html b/store/templates/store/admin_revenue_dashboard.html
new file mode 100644
index 0000000..9199a5b
--- /dev/null
+++ b/store/templates/store/admin_revenue_dashboard.html
@@ -0,0 +1,86 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
Platform Revenue Dashboard
+
+
+
Total Platform Revenue
+
${{ total_platform_revenue }}
+
+
+
+
+
+
+ Seller
+ Total Sales
+ Platform Fees
+
+
+
+ {% for row in seller_data %}
+
+
+ {{ row.seller.store_name }}
+ {{ row.seller.user.email }}
+
+ ${{ row.total_revenue }}
+ ${{ row.platform_fees }}
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/bounty_detail.html b/store/templates/store/bounty_detail.html
new file mode 100644
index 0000000..3e107e9
--- /dev/null
+++ b/store/templates/store/bounty_detail.html
@@ -0,0 +1,126 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% if bounty.card %}
+ Wanted: {{ bounty.card.name }}
+ {% else %}
+ Wanted: {{ bounty.title }}
+ {% endif %}
+
+
+ Posted by {{ bounty.seller.store_name }} on {{ bounty.created_at|date:"M d, Y" }}
+
+
+
+ ${{ bounty.target_price }}
+ Target Price / item
+
+
+
+
+
Details
+
{{ bounty.description|default:"No additional details provided."|linebreaks }}
+
+
+
+
+ Quantity Wanted
+ {{ bounty.quantity_wanted }}
+
+
+
+
+
+ {% if is_seller %}
+
+
+
Offers Received ({{ offers|length }})
+
+ {% if offers %}
+
+ {% for offer in offers %}
+
+
+
+
+ ${{ offer.price }}
+ from {{ offer.buyer.user.username }}
+
+
+ {{ offer.description|default:"No note included." }}
+
+
{{ offer.created_at|date:"M d, H:i" }}
+
+
+
+
+ {{ offer.get_status_display }}
+
+
+ {% if offer.status == 'pending' %}
+
+ {% endif %}
+
+
+
+ {% endfor %}
+
+ {% else %}
+
No offers yet.
+ {% endif %}
+
+
+ {% elif is_buyer %}
+
+
+ {% if user_offer %}
+
+
Your Offer
+
+ ${{ user_offer.price }}
+
+ {{ user_offer.get_status_display }}
+
+
+
You have already submitted an offer for this bounty.
+
+ {% else %}
+
Make an Offer
+
+ {% endif %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/store/templates/store/bounty_form.html b/store/templates/store/bounty_form.html
new file mode 100644
index 0000000..6881169
--- /dev/null
+++ b/store/templates/store/bounty_form.html
@@ -0,0 +1,211 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/bounty_list.html b/store/templates/store/bounty_list.html
new file mode 100644
index 0000000..5e79ee1
--- /dev/null
+++ b/store/templates/store/bounty_list.html
@@ -0,0 +1,78 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
+
Bounty Board
+
Seller/Buyer Marketplace for Buying Cards
+
+ {% if user.is_authenticated and user.seller_profile %}
+
+ {% endif %}
+
+
+ {% if bounties %}
+
+ {% for bounty in bounties %}
+
+
+
+
+ {% if bounty.card.image_url %}
+
+ {% else %}
+
📝
+ {% endif %}
+
+
+
+
+
Wanted by {{ bounty.seller.store_name }}
+
+
+
+
+
+
Buying For
+
${{ bounty.target_price }}
+
+
+
Needed
+
{{ bounty.quantity_wanted }}
+
+
+
+
+
+ {{ bounty.description|default:"No details." }}
+
+
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+
No active bounties at the moment.
+ {% if user.is_authenticated and user.seller_profile %}
+
Be the first to post one!
+ {% endif %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/store/templates/store/csv/card_listing_template.csv b/store/templates/store/csv/card_listing_template.csv
new file mode 100644
index 0000000..26c4ebf
--- /dev/null
+++ b/store/templates/store/csv/card_listing_template.csv
@@ -0,0 +1,3 @@
+Game,Set,Card Name,Collector Number,Condition (NM/LP/MP/HP),Price,Quantity,Image Filename
+Magic: The Gathering,Alpha,Black Lotus,,NM,10000.00,1,black_lotus.jpg
+Magic: The Gathering,Beta,Mox Sapphire,,LP,5000.00,1,
diff --git a/store/templates/store/csv/pack_listing_template.csv b/store/templates/store/csv/pack_listing_template.csv
new file mode 100644
index 0000000..c557b58
--- /dev/null
+++ b/store/templates/store/csv/pack_listing_template.csv
@@ -0,0 +1,3 @@
+Game,Name,Listing Type (physical/virtual),Price,Quantity,Image Filename
+Magic: The Gathering,Alpha Booster Box,physical,15000.00,1,alpha_box.jpg
+Magic: The Gathering,Beta Booster Pack,virtual,500.00,10,
diff --git a/store/templates/store/seller/add_virtual_pack_content.html b/store/templates/store/seller/add_virtual_pack_content.html
new file mode 100644
index 0000000..2d8ba8b
--- /dev/null
+++ b/store/templates/store/seller/add_virtual_pack_content.html
@@ -0,0 +1,49 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
Add Content to Pack
+
Listing: {{ listing.name }}
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/seller/confirm_delete.html b/store/templates/store/seller/confirm_delete.html
new file mode 100644
index 0000000..52cae82
--- /dev/null
+++ b/store/templates/store/seller/confirm_delete.html
@@ -0,0 +1,21 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
Confirm Delete
+
Are you sure you want to delete this listing?
+
{{ item }}
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/seller/dashboard.html b/store/templates/store/seller/dashboard.html
new file mode 100644
index 0000000..a710545
--- /dev/null
+++ b/store/templates/store/seller/dashboard.html
@@ -0,0 +1,330 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
+
{{ seller.store_name }}
+
Seller Dashboard
+
+
+
View Storefront
+
Edit Profile
+
Manage Inventory
+
Bounty Board
+
+
+
Active Listings: {{ active_listings_count }}
+
Items Sold: {{ items_sold }}
+
Revenue: ${{ total_revenue|floatformat:2 }}
+ {% if avg_rating %}
+
+ Avg Rating:
+
+
+
+
+ {{ avg_rating }}/5
+
+
+ {% endif %}
+
+
+
+
+
+
+
Theme
+
+
+
+
+
+
+
+
+
+
+
+
Store Views
+
{{ store_views }}
+
+
+
Listing Clicks
+
{{ listing_clicks }}
+
+
+
Store Link
+
+
+
+
+
+
+
+
+
+
+
Revenue (All Time)
+
${{ total_revenue|floatformat:2 }}
+
+
+
Units Sold
+
{{ items_sold }}
+
+
+
Avg. Order Value
+
+
+ {% if items_sold > 0 %}
+ ${{ total_revenue|divisibleby:items_sold|default:"0.00" }}
+ {# Django template math is limited, leaving simpler for now or use filters if available #}
+ {# Just leaving placeholder logic or better computed in view #}
+ --
+ {% else %}
+ $0.00
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sales & Revenue
+
+ Day
+ Week
+ Month
+
+
+
+
+
+
+
+
+
+ By Game
+ By Condition
+ By Set
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/seller/edit_listing.html b/store/templates/store/seller/edit_listing.html
new file mode 100644
index 0000000..ab48d71
--- /dev/null
+++ b/store/templates/store/seller/edit_listing.html
@@ -0,0 +1,91 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
{{ title }}
+
+ Please fill out the details below as accurately as possible.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/seller/edit_profile.html b/store/templates/store/seller/edit_profile.html
new file mode 100644
index 0000000..d0143a3
--- /dev/null
+++ b/store/templates/store/seller/edit_profile.html
@@ -0,0 +1,127 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
Edit Store Profile
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/seller/manage_listings.html b/store/templates/store/seller/manage_listings.html
new file mode 100644
index 0000000..d958cd8
--- /dev/null
+++ b/store/templates/store/seller/manage_listings.html
@@ -0,0 +1,135 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
+
Manage Listings
+
Manage your inventory of Single Cards and Packs
+
+
+
+
+
+
+
+
+
+
Card Listings ({{ card_listings|length }})
+
+
+ {% if card_listings %}
+
+ {% for listing in card_listings %}
+
+
+
+ {% if listing.image %}
+
+ {% elif listing.card.image_url %}
+
+ {% else %}
+
N/A
+ {% endif %}
+
+
{{ listing.card.name }}
+
{{ listing.card.set.name }}
+
+ {{ listing.get_condition_display }}
+
+
+
+
+
+
+
Price
+
${{ listing.price }}
+
+
+
Stock
+
{{ listing.quantity }}
+
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+ {% if FEATURE_VIRTUAL_PACKS %}
+
+
+
Pack Listings ({{ pack_listings|length }})
+
+
+ {% if pack_listings %}
+
+ {% for listing in pack_listings %}
+
+
+
+ {% if listing.image_url %}
+
+ {% else %}
+
📦
+ {% endif %}
+
+
{{ listing.name }}
+
{{ listing.game.name }}
+
+
+
+
+
+
Price
+
${{ listing.price }}
+
+
+
Stock
+
{{ listing.quantity }}
+
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/store/templates/store/seller/manage_pack_inventory.html b/store/templates/store/seller/manage_pack_inventory.html
new file mode 100644
index 0000000..691af53
--- /dev/null
+++ b/store/templates/store/seller/manage_pack_inventory.html
@@ -0,0 +1,43 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
+
Manage Pack Inventory
+
Listing: {{ listing.name }}
+
+
+
+
+
+ Existing Packs ({{ packs|length }})
+ {% if packs %}
+
+ {% for pack in packs %}
+
+
Pack #{{ pack.id }}
+
{{ pack.get_status_display }}
+
+
Cards Inside:
+
+ {% for card in pack.cards.all %}
+ • {{ card.name }} ({{ card.set.code }})
+ {% empty %}
+ No cards assigned!
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {% else %}
+
+
No pack instances found for this listing.
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/store/templates/store/seller/profile.html b/store/templates/store/seller/profile.html
new file mode 100644
index 0000000..43ace03
--- /dev/null
+++ b/store/templates/store/seller/profile.html
@@ -0,0 +1,374 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+{% if seller.hero_image %}
+
+
+
+
+
+
+{% endif %}
+
+
+
+
+
+
+ {% if seller.store_image %}
+
+ {% else %}
+
+ 🏪
+
+ {% endif %}
+
+
{{ seller.store_name }}
+
Verified Seller
+ {% if avg_rating %}
+
+
+
+
+
{{ avg_rating }}/5
+
+ {% endif %}
+
+
+
+ {{ seller.description|default:"No description available." }}
+
+
+ {% if seller.contact_email or seller.contact_phone or seller.business_address %}
+
+
Contact Info
+
+ {% if seller.contact_email %}
+
📧 {{ seller.contact_email }}
+ {% endif %}
+ {% if seller.contact_phone %}
+
📞 {{ seller.contact_phone }}
+ {% endif %}
+ {% if seller.business_address %}
+
📍 {{ seller.business_address|linebreaksbr }}
+ {% endif %}
+
+
+ {% endif %}
+
+
+
+
Shipping Policy
+ {% if seller.minimum_order_amount > 0 %}
+
+ Standard Shipping: ${{ seller.shipping_cost }}
+
+
+ Free shipping on orders over ${{ seller.minimum_order_amount }} !
+
+ {% else %}
+
+ Free Shipping on all orders!
+
+ {% endif %}
+
+
+
+
+
+ ⚠️ Report Seller
+
+
+
+
+
+
+
Active Listings
+
+
+
+
+
+ {% for listing in card_listings %}
+
+ {% if listing.card.image_url %}
+
+ {% else %}
+
No Image
+ {% endif %}
+
+
{{ listing.card.name }}
+
{{ listing.card.set.name }}
+
+
+ ${{ listing.price }}
+ {{ listing.get_condition_display }}
+
+
+
+
+
+ {% endfor %}
+
+ {% for listing in pack_listings %}
+
+
+ PACK
+
+
+
{{ listing.name }}
+
{{ listing.game.name }}
+
+
+ ${{ listing.price }}
+
+
+
+
+ {% endfor %}
+
+
+ {% if not card_listings and not pack_listings %}
+
+
This seller has no active listings at the moment.
+
+ {% endif %}
+
+
+
Bounty Board
+
+
TBD
+
This seller is not currently accepting bounties.
+
+
+
+
+
+
+
+
+
+
+
Report Seller
+
Please select a reason for reporting this seller.
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/seller_register.html b/store/templates/store/seller_register.html
new file mode 100644
index 0000000..aca17a6
--- /dev/null
+++ b/store/templates/store/seller_register.html
@@ -0,0 +1,66 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
Become a Seller
+
+
+ {% csrf_token %}
+
+ {% if user_form %}
+ Account Details
+ {% if user_form.non_field_errors %}
+ {{ user_form.non_field_errors }}
+ {% endif %}
+ {% for field in user_form %}
+
+
{{ field.label }}
+ {{ field }}
+ {% if field.errors %}
{{ field.errors.0 }}
{% endif %}
+
+ {% endfor %}
+
+ Store Details
+ {% endif %}
+
+ {% if form.non_field_errors %}
+
+ {{ form.non_field_errors }}
+
+ {% endif %}
+
+ {% for field in form %}
+
+
{{ field.label }}
+ {{ field }}
+ {% if field.errors %}
+
{{ field.errors.0 }}
+ {% endif %}
+
+ {% endfor %}
+
+
+ Register Store
+
+
+
+
+
+
+{% endblock %}
diff --git a/store/templates/store/seller_register_success.html b/store/templates/store/seller_register_success.html
new file mode 100644
index 0000000..750436e
--- /dev/null
+++ b/store/templates/store/seller_register_success.html
@@ -0,0 +1,19 @@
+{% extends 'base/layout.html' %}
+
+{% block content %}
+
+
+
Store Created!
+
Congratulations, your store "{{ store_name }}" is now active.
+
+
+
+
+{% endblock %}
diff --git a/store/tests.py b/store/tests.py
index 7ce503c..0907815 100644
--- a/store/tests.py
+++ b/store/tests.py
@@ -1,3 +1,239 @@
-from django.test import TestCase
+from django.test import TestCase, Client
+from django.urls import reverse
+from django.contrib.auth import get_user_model
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.files.uploadedfile import SimpleUploadedFile
+from .models import Seller, Game, Set, Card, CardListing, Order
+from decimal import Decimal
-# Create your tests here.
+User = get_user_model()
+
+class CardListingTests(TestCase):
+ def setUp(self):
+ # Create User and Seller
+ self.user = User.objects.create_user(username='seller', password='password')
+ self.seller = Seller.objects.create(
+ user=self.user,
+ store_name='Test Store',
+ slug='test-store'
+ )
+
+ # Create Game, Set, Card
+ self.game = Game.objects.create(name='Test Game', slug='test-game')
+ self.set = Set.objects.create(game=self.game, name='Test Set')
+ self.card = Card.objects.create(set=self.set, name='Test Card')
+
+ # Create Listing
+ self.listing = CardListing.objects.create(
+ card=self.card,
+ seller=self.seller,
+ price=10.00,
+ quantity=1,
+ condition='NM'
+ )
+
+ self.client = Client()
+ self.client.force_login(self.user)
+
+ def test_edit_card_listing_image_upload(self):
+ url = reverse('store:edit_card_listing', args=[self.listing.uuid])
+
+ # Create a small image file
+ image_content = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x01\x44\x00\x3b'
+ image = SimpleUploadedFile("test_image.gif", image_content, content_type="image/gif")
+
+ data = {
+ 'condition': 'LP',
+ 'price': '15.00',
+ 'quantity': 2,
+ 'status': 'listed',
+ 'image': image
+ }
+
+ response = self.client.post(url, data, format='multipart')
+
+ # Check redirect
+ self.assertRedirects(response, reverse('store:manage_listings'))
+
+ # Reload listing
+ self.listing.refresh_from_db()
+
+ # Check updates
+ self.assertEqual(self.listing.condition, 'LP')
+ self.assertEqual(self.listing.price, 15.00)
+ self.assertEqual(self.listing.quantity, 2)
+
+ # Check image
+ self.assertTrue(self.listing.image)
+ self.assertTrue(self.listing.image.name.endswith('test_image.gif'))
+
+class SellerDashboardTests(TestCase):
+ def setUp(self):
+ # Create User and Seller
+ self.user = User.objects.create_user(username='dashboard_seller', password='password')
+ self.seller = Seller.objects.create(
+ user=self.user,
+ store_name='Dashboard Store',
+ slug='dashboard-store'
+ )
+ self.buyer_user = User.objects.create_user(username='buyer', password='password')
+ from users.models import Buyer
+ self.buyer = Buyer.objects.create(user=self.buyer_user)
+
+ # Create Game, Set, Card
+ self.game = Game.objects.create(name='Dashboard Game', slug='dashboard-game')
+ self.set = Set.objects.create(game=self.game, name='Dashboard Set')
+ self.card = Card.objects.create(set=self.set, name='Dashboard Card')
+
+ # Create Listing
+ self.listing = CardListing.objects.create(
+ card=self.card,
+ seller=self.seller,
+ price=10.00,
+ quantity=10,
+ condition='NM'
+ )
+
+ # Create Order & OrderItem
+ from .models import Order, OrderItem
+ self.order = Order.objects.create(
+ buyer=self.buyer,
+ status='paid',
+ total_price=20.00
+ )
+ OrderItem.objects.create(
+ order=self.order,
+ listing=self.listing,
+ price_at_purchase=10.00,
+ quantity=2
+ )
+
+ self.client = Client()
+ self.client.force_login(self.user)
+
+ def test_dashboard_context_data(self):
+ url = reverse('store:seller_dashboard')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ # Check for new context keys
+ self.assertIn('condition_labels', response.context)
+ self.assertIn('condition_data', response.context)
+ self.assertIn('set_labels', response.context)
+ self.assertIn('set_data', response.context)
+ self.assertIn('game_labels', response.context)
+ self.assertIn('game_data', response.context)
+ self.assertIn('all_games', response.context)
+
+ # Check data correctness (we sold 2 NM items)
+ import json
+ cond_data = json.loads(response.context['condition_data'])
+ cond_labels = json.loads(response.context['condition_labels'])
+
+ self.assertIn('Near Mint', cond_labels)
+ idx = cond_labels.index('Near Mint')
+ self.assertEqual(cond_data[idx], 2)
+
+ def test_dashboard_game_filter(self):
+ from .models import OrderItem
+ url = reverse('store:seller_dashboard')
+
+ # Create another game/sale
+ game2 = Game.objects.create(name='Other Game', slug='other-game')
+ set2 = Set.objects.create(game=game2, name='Other Set')
+ card2 = Card.objects.create(set=set2, name='Other Card')
+ listing2 = CardListing.objects.create(card=card2, seller=self.seller, price=5, quantity=5, condition='LP')
+
+ OrderItem.objects.create(
+ order=self.order,
+ listing=listing2,
+ price_at_purchase=5.00,
+ quantity=1
+ )
+
+ # 1. No Filter - Should see both
+ response = self.client.get(url)
+ import json
+ game_labels = json.loads(response.context['game_labels'])
+ self.assertIn('Dashboard Game', game_labels)
+ self.assertIn('Other Game', game_labels)
+
+ # 2. Filter by Dashboard Game
+ response = self.client.get(url, {'game': 'Dashboard Game'})
+ game_labels = json.loads(response.context['game_labels'])
+ self.assertIn('Dashboard Game', game_labels)
+ self.assertNotIn('Other Game', game_labels)
+
+ # Check condition data also filtered
+ cond_labels = json.loads(response.context['condition_labels'])
+ # Dashboard Game items were NM. Other Game items were LP.
+ self.assertIn('Near Mint', cond_labels)
+ self.assertNotIn('Lightly Played', cond_labels)
+
+class AdminRevenueTests(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.staff_user = User.objects.create_user(username='staff', password='password', is_staff=True)
+ self.seller_user = User.objects.create_user(username='seller2', password='password')
+
+ self.seller = Seller.objects.create(
+ user=self.seller_user,
+ store_name='Revenue Store',
+ slug='revenue-store',
+ tax_id='123-45-6789',
+ payout_details='Bank Acct 123'
+ )
+
+ # Create Orders
+ from users.models import Buyer
+ buyer_user = User.objects.create_user(username='buyer2', password='password')
+ buyer = Buyer.objects.create(user=buyer_user)
+
+ # Order 1: $100 -> Fee: 5 + 0.70 = 5.70
+ order1 = Order.objects.create(buyer=buyer, status='paid', total_price=Decimal('100.00'), seller=self.seller)
+
+ # Order 2: $1000 -> Fee: 50 + 0.70 = 50.70 -> Capped at 25.00
+ order2 = Order.objects.create(buyer=buyer, status='shipped', total_price=Decimal('1000.00'), seller=self.seller)
+
+ # Order 3: Pending (should be ignored)
+ order3 = Order.objects.create(buyer=buyer, status='pending', total_price=Decimal('50.00'), seller=self.seller)
+
+ def test_encryption(self):
+ # Refresh from db
+ seller = Seller.objects.get(pk=self.seller.pk)
+
+ # Verify decrypted values match
+ self.assertEqual(seller.tax_id, '123-45-6789')
+ self.assertEqual(seller.payout_details, 'Bank Acct 123')
+
+ # Verify db values are encrypted (not plaintext and are bytes)
+ self.assertIsInstance(seller.tax_id_encrypted, bytes)
+ self.assertNotEqual(seller.tax_id_encrypted, b'123-45-6789')
+ # Ensure it's not just the string encoded
+ self.assertNotEqual(seller.tax_id_encrypted, '123-45-6789'.encode('utf-8'))
+
+ def test_fee_calculation(self):
+ from .utils import calculate_platform_fee
+ # 5% + 0.70
+ self.assertEqual(calculate_platform_fee(Decimal('10.00')), Decimal('1.20')) # 0.50 + 0.70
+ self.assertEqual(calculate_platform_fee(Decimal('100.00')), Decimal('5.70')) # 5.00 + 0.70
+
+ # Cap at 25
+ # Threshold: (25 - 0.70) / 0.05 = 486.00
+ self.assertEqual(calculate_platform_fee(Decimal('486.00')), Decimal('25.00'))
+ self.assertEqual(calculate_platform_fee(Decimal('1000.00')), Decimal('25.00'))
+
+ def test_dashboard_view(self):
+ self.client.force_login(self.staff_user)
+ response = self.client.get(reverse('store:admin_revenue_dashboard'))
+ self.assertEqual(response.status_code, 200)
+
+ # Check context
+ total_rev = response.context['total_platform_revenue']
+ # Order 1 (5.70) + Order 2 (25.00) = 30.70
+ self.assertEqual(total_rev, Decimal('30.70'))
+
+ seller_data = response.context['seller_data']
+ self.assertEqual(len(seller_data), 1)
+ self.assertEqual(seller_data[0]['total_revenue'], Decimal('1100.00'))
+ self.assertEqual(seller_data[0]['platform_fees'], Decimal('30.70'))
diff --git a/store/tests_bounty.py b/store/tests_bounty.py
new file mode 100644
index 0000000..a7714c3
--- /dev/null
+++ b/store/tests_bounty.py
@@ -0,0 +1,87 @@
+from django.test import TestCase, Client
+from django.urls import reverse
+from django.contrib.auth import get_user_model
+from .models import Seller, Game, Set, Card, Bounty
+from django.conf import settings
+
+User = get_user_model()
+
+class BountyCreateTest(TestCase):
+ def setUp(self):
+ # Ensure feature is on (might need to mock settings if not settable directly,
+ # but usually tests run with default settings. We need ensuring it's True)
+ # Note: In some setups, modifying settings in test needs override_settings
+
+ self.user = User.objects.create_user(username='bounty_seller', password='password')
+ self.seller = Seller.objects.create(
+ user=self.user,
+ store_name='Bounty Store',
+ slug='bounty-store'
+ )
+
+ self.game = Game.objects.create(name='Bounty Game', slug='bounty-game')
+ self.set = Set.objects.create(game=self.game, name='Bounty Set')
+ self.card = Card.objects.create(set=self.set, name='Bounty Card')
+
+ self.client = Client()
+ self.client.force_login(self.user)
+ self.url = reverse('store:bounty_create')
+
+ def test_create_bounty_with_card_id(self):
+ # Simulate selecting a card via autocomplete
+ data = {
+ 'card_name': 'Bounty Card',
+ 'card_id': self.card.id,
+ 'target_price': '100.00',
+ 'quantity_wanted': 1,
+ # Title is optional if card picked? Form logic says "if no card and no title raise error"
+ # Our view logic: if card_id -> get card -> set bounty.card.
+ # Form clean: if not card_id and not title and not card_name -> Error.
+ }
+
+ with self.settings(FEATURE_BOUNTY_BOARD=True):
+ response = self.client.post(self.url, data)
+
+ self.assertRedirects(response, reverse('store:bounty_list'))
+
+ # Verify
+ bounty = Bounty.objects.first()
+ self.assertIsNotNone(bounty)
+ self.assertEqual(bounty.card, self.card)
+ self.assertEqual(bounty.seller, self.seller)
+ # Title should be auto-set in save() if empty
+ self.assertEqual(bounty.title, "Buying Bounty Card")
+
+ def test_create_bounty_with_name_fallback(self):
+ # Simulate typing a name but not selecting a card (no ID)
+ data = {
+ 'card_name': 'Generic Card',
+ 'card_id': '',
+ 'target_price': '50.00',
+ 'quantity_wanted': 1
+ }
+
+ with self.settings(FEATURE_BOUNTY_BOARD=True):
+ response = self.client.post(self.url, data)
+
+ self.assertRedirects(response, reverse('store:bounty_list'))
+
+ bounty = Bounty.objects.last()
+ self.assertIsNone(bounty.card)
+ self.assertEqual(bounty.title, 'Generic Card')
+
+ def test_create_bounty_validation_error(self):
+ # Empty everything
+ data = {
+ 'card_name': '',
+ 'card_id': '',
+ 'target_price': '50.00',
+ 'quantity_wanted': 1
+ }
+
+ with self.settings(FEATURE_BOUNTY_BOARD=True):
+ response = self.client.post(self.url, data)
+
+ self.assertEqual(response.status_code, 200) # Form errors, no redirect
+ form = response.context['form']
+ self.assertIn("You must either select a Card or provide a Title.", form.non_field_errors())
diff --git a/store/tests_smart_pricing.py b/store/tests_smart_pricing.py
new file mode 100644
index 0000000..8c67148
--- /dev/null
+++ b/store/tests_smart_pricing.py
@@ -0,0 +1,95 @@
+from django.test import TestCase, Client
+from django.contrib.auth import get_user_model
+from .models import Seller, Game, Set, Card, CardListing, Cart, CartItem, Order
+
+User = get_user_model()
+
+class SmartPricingTests(TestCase):
+ def setUp(self):
+ # Create Seller User
+ self.seller_user = User.objects.create_user(username='seller', password='password')
+ self.seller = Seller.objects.create(
+ user=self.seller_user,
+ store_name='Smart Store',
+ slug='smart-store',
+ minimum_order_amount=10.00,
+ shipping_cost=2.50
+ )
+
+ # Create Buyer User
+ self.buyer_user = User.objects.create_user(username='buyer', password='password')
+ from users.models import Buyer
+ self.buyer = Buyer.objects.create(user=self.buyer_user)
+
+ # Create Products
+ self.game = Game.objects.create(name='Test Game', slug='test-game')
+ self.set = Set.objects.create(game=self.game, name='Test Set')
+ self.card = Card.objects.create(set=self.set, name='Test Card')
+
+ self.listing_cheap = CardListing.objects.create(
+ card=self.card, seller=self.seller, price=5.00, quantity=10, condition='NM'
+ )
+ self.listing_expensive = CardListing.objects.create(
+ card=self.card, seller=self.seller, price=15.00, quantity=10, condition='NM'
+ )
+
+ self.client = Client()
+ self.client.force_login(self.buyer_user)
+
+ def test_cart_shipping_below_minimum(self):
+ from django.urls import reverse
+ # Add cheap item (5.00) < 10.00 minimum
+ url = reverse('store:add_to_cart', args=[self.listing_cheap.uuid])
+ self.client.post(url)
+
+ # Determine cart directly
+ cart, _ = Cart.objects.get_or_create(buyer=self.buyer)
+
+ response = self.client.get(reverse('store:cart'))
+ self.assertEqual(response.status_code, 200)
+
+ # Check context data
+ cart_data = response.context['cart_data']
+ self.assertEqual(len(cart_data), 1)
+ data = cart_data[0]
+
+ self.assertEqual(data['subtotal'], 5.00)
+ self.assertEqual(data['shipping_cost'], 2.50)
+ self.assertEqual(data['total'], 7.50)
+ self.assertEqual(data['free_shipping_needed'], 5.00)
+
+ # Check Final Calculation
+ self.assertEqual(response.context['grand_total'], 7.50)
+
+ def test_cart_shipping_above_minimum(self):
+ from django.urls import reverse
+ # Add expensive item (15.00) > 10.00 minimum
+ cart, _ = Cart.objects.get_or_create(buyer=self.buyer)
+ CartItem.objects.create(cart=cart, listing=self.listing_expensive, quantity=1)
+
+ response = self.client.get(reverse('store:cart'))
+
+ cart_data = response.context['cart_data']
+ data = cart_data[0]
+
+ self.assertEqual(data['subtotal'], 15.00)
+ self.assertEqual(data['shipping_cost'], 0)
+ self.assertEqual(data['total'], 15.00)
+ self.assertEqual(data['free_shipping_needed'], 0)
+
+ def test_checkout_shipping_application(self):
+ from django.urls import reverse
+ # Setup cart below minimum
+ cart, _ = Cart.objects.get_or_create(buyer=self.buyer)
+ CartItem.objects.create(cart=cart, listing=self.listing_cheap, quantity=1)
+
+ response = self.client.get(reverse('store:checkout'))
+ # Checkout redirects to my_packs (if virtual) or vault (users:vault)
+ # Since we have no virtual, it goes to vault.
+ # Check for 302 Found
+ self.assertEqual(response.status_code, 302)
+
+ # Verify Order
+ order = Order.objects.filter(buyer=self.buyer).first()
+ self.assertIsNotNone(order)
+ self.assertEqual(order.total_price, 7.50) # 5.00 + 2.50 shipping
diff --git a/store/urls.py b/store/urls.py
index f292a06..ef79e7a 100644
--- a/store/urls.py
+++ b/store/urls.py
@@ -4,20 +4,44 @@ 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('', views.index, name='index'), # Root redirect logic
+ path('browse/', views.card_list, name='card_list'), # Detailed browse page
+ path('home/', views.card_list, name='home'), # Explicit home alias for compatibility
+ 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('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('api/card-autocomplete/', views.card_autocomplete, name='card_autocomplete'),
+ path('api/bounty-autocomplete/', views.bounty_autocomplete, name='bounty_autocomplete'),
+ path('api/card-variants/', views.card_variants, name='card_variants'),
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('bounties/', views.bounty_list, name='bounty_list'),
+ path('bounties/create/', views.bounty_create, name='bounty_create'),
+ path('bounties//', views.bounty_detail, name='bounty_detail'),
+ path('bounties/offer///', views.bounty_process_offer, name='bounty_process_offer'),
path('packs/', views.pack_list, name='pack_list'),
- path('cart/add-pack//', views.add_pack_to_cart, name='add_pack_to_cart'),
+ 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'),
+ path('packs/open//', views.open_pack, name='open_pack'),
+ path('order//', views.order_detail, name='order_detail'),
+ path('sell/register/', views.seller_register, name='seller_register'),
+ path('sell/dashboard/', views.seller_dashboard, name='seller_dashboard'),
+ path('sell/profile/edit/', views.edit_seller_profile, name='edit_seller_profile'),
+ path('sell/listings/', views.manage_listings, name='manage_listings'),
+ path('sell/listings/card/add/', views.add_card_listing, name='add_card_listing'),
+ path('sell/listings/download-template//', views.download_listing_template, name='download_listing_template'),
+ path('sell/listings/card//edit/', views.edit_card_listing, name='edit_card_listing'),
+ path('sell/listings/card//delete/', views.delete_card_listing, name='delete_card_listing'),
+ path('sell/listings/pack/add/', views.add_pack_listing, name='add_pack_listing'),
+ path('sell/listings/pack//edit/', views.edit_pack_listing, name='edit_pack_listing'),
+ path('sell/listings/pack//delete/', views.delete_pack_listing, name='delete_pack_listing'),
+ path('sell/listings/pack//inventory/', views.manage_pack_inventory, name='manage_pack_inventory'),
+ path('sell/listings/pack//inventory/add/', views.add_virtual_pack_content, name='add_virtual_pack_content'),
+ path('store//', views.seller_profile, name='seller_profile'),
+ path('store//report/', views.report_seller, name='report_seller'),
+ path('platform-admin/revenue/', views.admin_revenue_dashboard, name='admin_revenue_dashboard'),
+ path('terms/', views.terms, name='terms'),
]
diff --git a/store/utils.py b/store/utils.py
index c514b79..416362f 100644
--- a/store/utils.py
+++ b/store/utils.py
@@ -1,12 +1,16 @@
import re
from .models import Card, CardListing, Order, OrderItem, VaultItem
from django.db.models import Min
+import base64
+from cryptography.fernet import Fernet
+from django.conf import settings
+from decimal import Decimal
-def add_to_vault(user, card, quantity=1):
+def add_to_vault(buyer, card, quantity=1):
"""
- Adds a card to the user's vault.
+ Adds a card to the buyer's vault.
"""
- vault_item, created = VaultItem.objects.get_or_create(user=user, card=card)
+ vault_item, created = VaultItem.objects.get_or_create(buyer=buyer, card=card)
if not created:
vault_item.quantity += quantity
else:
@@ -96,10 +100,10 @@ def get_user_collection(user):
Returns a dict {card_name: quantity} of cards in user's vault.
"""
owned = {}
- if not user.is_authenticated:
+ if not user.is_authenticated or not hasattr(user, 'buyer_profile'):
return owned
- vault_items = VaultItem.objects.filter(user=user).select_related('card')
+ vault_items = VaultItem.objects.filter(buyer=user.buyer_profile).select_related('card')
for item in vault_items:
owned[item.card.name] = item.quantity
@@ -130,4 +134,54 @@ def filter_deck_by_collection(parsed_cards, owned_cards):
if remaining > 0:
filtered.append({'name': name, 'quantity': remaining})
+
return filtered
+
+class Encryptor:
+ """
+ Utility for encrypting and decrypting sensitive data using Fernet.
+ Derives a key from settings.SECRET_KEY.
+ """
+ _cipher = None
+
+ @classmethod
+ def get_cipher(cls):
+ if cls._cipher is None:
+ # Derive a 32-byte key from SECRET_KEY
+ # Ensure key is url-safe base64-encoded 32-byte key
+ # We use hashlib to ensure we get a valid 32-byte key for Fernet,
+ # regardless of SECRET_KEY length.
+ import hashlib
+ key_hash = hashlib.sha256(settings.SECRET_KEY.encode('utf-8')).digest()
+ key_b64 = base64.urlsafe_b64encode(key_hash)
+ cls._cipher = Fernet(key_b64)
+ return cls._cipher
+
+ @classmethod
+ def encrypt(cls, plaintext):
+ if not plaintext:
+ return None
+ if isinstance(plaintext, str):
+ plaintext = plaintext.encode('utf-8')
+ return cls.get_cipher().encrypt(plaintext)
+
+ @classmethod
+ def decrypt(cls, ciphertext):
+ if not ciphertext:
+ return None
+ if isinstance(ciphertext, memoryview):
+ ciphertext = bytes(ciphertext)
+ try:
+ return cls.get_cipher().decrypt(ciphertext).decode('utf-8')
+ except Exception:
+ return None
+
+def calculate_platform_fee(total_amount):
+ """
+ Calculates platform fee: 5% + $0.70, capped at $25.
+ """
+ if not total_amount:
+ return Decimal('0.00')
+
+ fee = (total_amount * Decimal('0.05')) + Decimal('0.70')
+ return min(fee, Decimal('25.00'))
diff --git a/store/views.py b/store/views.py
index c820848..b9627dc 100644
--- a/store/views.py
+++ b/store/views.py
@@ -1,9 +1,20 @@
from django.shortcuts import render, get_object_or_404, redirect
+from django.conf import settings
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
+from .models import Card, Game, Set, Cart, CartItem, CardListing, PackListing, VirtualPack, Order, OrderItem, Seller, Bounty, BountyOffer, SellerReport
+from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm
+from django.utils.text import slugify
import random
+import csv
+from django.http import HttpResponse, JsonResponse
+
+def index(request):
+ if request.user.is_authenticated:
+ if (hasattr(request.user, 'profile') and request.user.profile.is_seller) or hasattr(request.user, 'seller_profile'):
+ return redirect('store:seller_dashboard')
+ return redirect('store:card_list')
def card_list(request):
cards = Card.objects.all().select_related('set', 'set__game').prefetch_related('listings')
@@ -21,6 +32,19 @@ def card_list(request):
if set_id:
cards = cards.filter(set__id=set_id)
+ # hide_out_of_stock logic:
+ # Default to 'on' if no GET parameters (first visit), or if explicitly set to 'on'.
+ # If GET parameters exist but 'hide_out_of_stock' is missing, it means user unchecked it.
+
+ hide_oos = 'off'
+ if not request.GET:
+ hide_oos = 'on'
+ elif request.GET.get('hide_out_of_stock') == 'on':
+ hide_oos = 'on'
+
+ if hide_oos == 'on':
+ cards = cards.filter(listings__quantity__gt=0, listings__status='listed').distinct()
+
# Simple logic: only show cards that have listings or show all?
# Let's show all for browsing, but indicate stock.
@@ -38,17 +62,23 @@ def card_list(request):
'sets': sets,
'current_game': game_slug,
'search_query': search_query,
+ 'hide_oos': hide_oos, # Pass filtered state to template
})
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')
+ card = get_object_or_404(Card, uuid=card_id)
+ listings = card.listings.filter(quantity__gt=0, status='listed').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)
+ listing = get_object_or_404(CardListing, uuid=listing_id)
+ # Ensure user is a buyer
+ if not hasattr(request.user, 'buyer_profile'):
+ # Fallback or error? For now assume valid if logged in via user reg
+ return redirect('home')
+
+ cart, _ = Cart.objects.get_or_create(buyer=request.user.buyer_profile)
cart_item, created = CartItem.objects.get_or_create(cart=cart, listing=listing)
if not created:
@@ -60,30 +90,147 @@ def add_to_cart(request, listing_id):
@login_required
def cart_view(request):
try:
- cart = request.user.cart
- except Cart.DoesNotExist:
+ cart = request.user.buyer_profile.cart
+ except (Cart.DoesNotExist, AttributeError):
cart = None
- return render(request, 'store/cart.html', {'cart': cart})
+
+ context = {'cart': cart}
+
+ if cart and cart.items.exists():
+ # Group items by seller and calculate costs
+ items_by_seller = {}
+
+ for item in cart.items.select_related('listing__seller', 'pack_listing__seller').all():
+ seller = None
+ if item.listing and item.listing.seller:
+ seller = item.listing.seller
+ elif item.pack_listing and item.pack_listing.seller:
+ seller = item.pack_listing.seller
+
+ # For items without a seller (e.g. system packs), we group them under None or a "System" key
+ # But the requirement is specifically about Seller stores.
+ # Let's group by seller object.
+
+ if seller not in items_by_seller:
+ items_by_seller[seller] = []
+ items_by_seller[seller].append(item)
+
+ cart_data = [] # List of dicts: {'seller': seller, 'items': [items], 'subtotal': X, 'shipping': Y, 'total': Z, 'free_shipping_needed': Q}
+ grand_total = 0
+
+ for seller, items in items_by_seller.items():
+ sub_total = sum(item.total_price for item in items)
+ shipping_cost = 0
+ free_shipping_needed = 0
+
+ if seller:
+ if sub_total < seller.minimum_order_amount:
+ shipping_cost = seller.shipping_cost
+ free_shipping_needed = seller.minimum_order_amount - sub_total
+
+ total = sub_total + shipping_cost
+ grand_total += total
+
+ cart_data.append({
+ 'seller': seller,
+ 'items': items,
+ 'subtotal': sub_total,
+ 'shipping_cost': shipping_cost,
+ 'total': total,
+ 'free_shipping_needed': free_shipping_needed
+ })
+
+ context['cart_data'] = cart_data
+ context['grand_total'] = grand_total
+ if cart.insurance:
+ context['grand_total'] += 5
+
+ return render(request, 'store/cart.html', context)
@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)
+ if hasattr(request.user, 'buyer_profile') and hasattr(request.user.buyer_profile, 'cart'):
+ item = get_object_or_404(CartItem, uuid=item_id, cart=request.user.buyer_profile.cart)
item.delete()
return redirect('store:cart')
@login_required
def toggle_insurance(request):
- if hasattr(request.user, 'cart'):
- cart = request.user.cart
+ if hasattr(request.user, 'buyer_profile') and hasattr(request.user.buyer_profile, 'cart'):
+ cart = request.user.buyer_profile.cart
cart.insurance = not cart.insurance
cart.save()
return redirect('store:cart')
from django.http import JsonResponse
+def card_autocomplete(request):
+ query = request.GET.get('q', '')
+ if len(query) < 2:
+ return JsonResponse({'results': []})
+
+ cards = Card.objects.filter(name__icontains=query).values_list('name', flat=True).distinct()[:10]
+ return JsonResponse({'results': list(cards)})
+
+def bounty_autocomplete(request):
+ query = request.GET.get('q', '')
+ if len(query) < 2:
+ return JsonResponse({'results': []})
+
+ # Search in Card names and Bounty titles
+ # We want distinct values
+
+ bounties = Bounty.objects.filter(is_active=True).filter(
+ Q(card__name__icontains=query) | Q(title__icontains=query)
+ )
+
+ results = set()
+ for b in bounties[:10]:
+ if b.card and query.lower() in b.card.name.lower():
+ results.add(b.card.name)
+ if b.title and query.lower() in b.title.lower():
+ results.add(b.title)
+
+ return JsonResponse({'results': list(results)[:10]})
+
+def card_variants(request):
+ """
+ Returns games and sets for a given card name.
+ Public access.
+ """
+ name = request.GET.get('name', '')
+ if not name:
+ return JsonResponse({'results': []})
+
+ cards = Card.objects.filter(name__iexact=name).select_related('set', 'set__game')
+
+ results = []
+ seen = set()
+
+ for card in cards:
+ game = card.set.game
+ set_obj = card.set
+
+ key = (game.slug, set_obj.name, card.collector_number)
+ if key in seen:
+ continue
+ seen.add(key)
+
+ # We need to structure this so the frontend can easily filter.
+ # Actually returning a list of variants is good.
+ results.append({
+ 'game_slug': game.slug,
+ 'game_name': game.name,
+ 'set_name': set_obj.name,
+ 'set_name': set_obj.name,
+ 'card_id': card.id,
+ 'collector_number': card.collector_number
+ })
+
+ return JsonResponse({'results': results})
+
def get_card_stock(request, card_id):
- card = get_object_or_404(Card, id=card_id)
+ card = get_object_or_404(Card, uuid=card_id)
listings = card.listings.all()
stock_breakdown = {}
total_stock = 0
@@ -97,7 +244,9 @@ def get_card_stock(request, card_id):
'breakdown': stock_breakdown
})
-from .utils import parse_deck_list, find_best_listings_for_deck, get_user_collection, filter_deck_by_collection, add_to_vault
+from .utils import parse_deck_list, find_best_listings_for_deck, get_user_collection, filter_deck_by_collection, add_to_vault, calculate_platform_fee
+from decimal import Decimal
+from django.contrib.admin.views.decorators import staff_member_required
@login_required
def deck_buyer(request):
@@ -134,7 +283,10 @@ def deck_buyer(request):
parsed = parse_deck_list(deck_text)
found, _ = find_best_listings_for_deck(parsed)
- cart, _ = Cart.objects.get_or_create(user=request.user)
+ if not hasattr(request.user, 'buyer_profile'):
+ # Should theoretically not happen if logged in as user
+ return redirect('store:card_list')
+ cart, _ = Cart.objects.get_or_create(buyer=request.user.buyer_profile)
count = 0
for item in found:
@@ -154,21 +306,180 @@ def deck_buyer(request):
return render(request, 'store/deck_buyer.html')
-from .models import Bounty
+def bounty_list(request):
+ if not settings.FEATURE_BOUNTY_BOARD:
+ if not settings.DEBUG:
+ return redirect('store:index')
-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})
+ bounties = Bounty.objects.filter(is_active=True).select_related('card', 'card__set', 'card__set__game', 'seller').order_by('-created_at')
+
+ # Filter by Game
+ game_slug = request.GET.get('game')
+ if game_slug:
+ bounties = bounties.filter(card__set__game__slug=game_slug)
+
+ # Filter by Set
+ set_id = request.GET.get('set')
+ if set_id:
+ bounties = bounties.filter(card__set__id=set_id)
+
+ # Search (Card Name or Bounty Title)
+ search_query = request.GET.get('q')
+ if search_query:
+ bounties = bounties.filter(
+ Q(card__name__icontains=search_query) |
+ Q(title__icontains=search_query)
+ )
+
+ # Context data for filters
+ games = Game.objects.all()
+ sets = Set.objects.filter(game__slug=game_slug) if game_slug else Set.objects.all()[:50]
+
+ return render(request, 'store/bounty_list.html', {
+ 'bounties': bounties,
+ 'games': games,
+ 'sets': sets,
+ 'current_game': game_slug,
+ 'search_query': search_query,
+ })
+
+@login_required
+def bounty_create(request):
+ if not settings.FEATURE_BOUNTY_BOARD:
+ return redirect('store:index')
+
+ if not hasattr(request.user, 'seller_profile'):
+ return redirect('store:seller_register')
+
+ seller = request.user.seller_profile
+
+ if request.method == 'POST':
+ form = BountyForm(request.POST)
+ if form.is_valid():
+ bounty = form.save(commit=False)
+ bounty.seller = seller
+
+ # Handle Card Association
+ card_id = form.cleaned_data.get('card_id')
+ card_name = form.cleaned_data.get('card_name')
+
+ if card_id:
+ try:
+ card = Card.objects.get(id=card_id)
+ bounty.card = card
+ except (Card.DoesNotExist, ValueError):
+ # Fallback if bad ID
+ if card_name:
+ bounty.title = card_name
+ elif card_name and not bounty.title:
+ # User typed a name but didn't pick from autocomplete (or no results)
+ bounty.title = card_name
+
+ bounty.save()
+ return redirect('store:bounty_list')
+ else:
+ form = BountyForm()
+
+ return render(request, 'store/bounty_form.html', {'form': form, 'title': 'Post a Bounty'})
+
+def bounty_detail(request, pk):
+ if not settings.FEATURE_BOUNTY_BOARD:
+ return redirect('store:index')
+
+ bounty = get_object_or_404(Bounty, uuid=pk)
+
+ # Context
+ is_seller = False
+ is_buyer = False
+ user_offer = None
+
+ if request.user.is_authenticated:
+ if hasattr(request.user, 'seller_profile') and bounty.seller == request.user.seller_profile:
+ is_seller = True
+ elif hasattr(request.user, 'buyer_profile'):
+ is_buyer = True
+ # Check if buyer already made an offer
+ user_offer = BountyOffer.objects.filter(bounty=bounty, buyer=request.user.buyer_profile).first()
+
+ # Handle Buyer Offer
+ offer_form = None
+ if is_buyer and not user_offer:
+ if request.method == 'POST':
+ offer_form = BountyOfferForm(request.POST)
+ if offer_form.is_valid():
+ offer = offer_form.save(commit=False)
+ offer.bounty = bounty
+ offer.buyer = request.user.buyer_profile
+ offer.save()
+ return redirect('store:bounty_detail', pk=pk)
+ else:
+ offer_form = BountyOfferForm()
+
+ # Load offers for seller
+ offers = []
+ if is_seller:
+ offers = bounty.offers.select_related('buyer__user').order_by('-created_at')
+
+ return render(request, 'store/bounty_detail.html', {
+ 'bounty': bounty,
+ 'is_seller': is_seller,
+ 'is_buyer': is_buyer,
+ 'user_offer': user_offer,
+ 'offer_form': offer_form,
+ 'offers': offers
+ })
+
+@login_required
+def bounty_process_offer(request, offer_id, action):
+ # Action: accept, reject, counter (counter not implemented in MVP yet, maybe just reject with message?)
+ if not settings.FEATURE_BOUNTY_BOARD:
+ return redirect('store:index')
+
+ offer = get_object_or_404(BountyOffer, uuid=offer_id)
+ bounty = offer.bounty
+
+ # Verify owner
+ if not hasattr(request.user, 'seller_profile') or bounty.seller != request.user.seller_profile:
+ return redirect('store:bounty_list')
+
+ if action == 'accept':
+ offer.status = 'accepted'
+ offer.save()
+ # TODO: Trigger checkout process or transaction?
+ # For MVP, maybe just separate "Accepted Bounties" list or contact logic?
+ # User request says: "Then it does the normal checkout process"
+ # This implies the SELLER pays the BUYER.
+ # Our current Checkout is Buyer pays Seller.
+ # This might be "Reverse Checkout".
+ # For now, let's just mark accepted.
+ elif action == 'reject':
+ offer.status = 'rejected'
+ offer.save()
+
+ offer.save()
+
+ return redirect('store:bounty_detail', pk=bounty.uuid)
def pack_list(request):
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:index')
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)
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:index')
+ listing = get_object_or_404(PackListing, uuid=pack_listing_id)
+ if not hasattr(request.user, 'buyer_profile'):
+ return redirect('store:pack_list')
+ cart, _ = Cart.objects.get_or_create(buyer=request.user.buyer_profile)
+ # Check if stock available
+ if listing.quantity < 1:
+ # TODO: Show error message
+ return redirect('store:pack_list')
+
cart_item, created = CartItem.objects.get_or_create(cart=cart, pack_listing=listing)
if not created:
cart_item.quantity += 1
@@ -179,75 +490,133 @@ def add_pack_to_cart(request, pack_listing_id):
@login_required
def checkout(request):
try:
- cart = request.user.cart
- except Cart.DoesNotExist:
+ cart = request.user.buyer_profile.cart
+ except (Cart.DoesNotExist, AttributeError):
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
+ # Group items by seller and check for virtual packs
+ items_by_seller = {}
+ has_virtual_packs = False
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
+ if item.pack_listing and item.pack_listing.listing_type == 'virtual':
+ has_virtual_packs = True
+ seller = None
+ if item.listing and item.listing.seller:
+ seller = item.listing.seller
+ elif item.pack_listing and item.pack_listing.seller:
+ seller = item.pack_listing.seller
+
+ if seller not in items_by_seller:
+ items_by_seller[seller] = []
+ items_by_seller[seller].append(item)
+
+ # Process orders per seller
+ for seller, items in items_by_seller.items():
+ sub_total = sum(item.total_price for item in items)
+ shipping_cost = 0
+
+ if seller:
+ if sub_total < seller.minimum_order_amount:
+ shipping_cost = seller.shipping_cost
+
+ total_price = sub_total + shipping_cost
+
+ # Create Order (status paid for MVP)
+ order = Order.objects.create(
+ buyer=request.user.buyer_profile,
+ status='paid',
+ total_price=total_price,
+ seller=seller # Populate seller
)
- # 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])
+ for item in items:
+ 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
+ )
- # 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)
+ # 1. Handle Card Listings
+ if item.listing:
+ add_to_vault(request.user.buyer_profile, item.listing.card, item.quantity)
+ # Decrement Stock
+ if item.listing.quantity >= item.quantity:
+ item.listing.quantity -= item.quantity
+ item.listing.save()
+ else:
+ # Stock issue handling (for now just take what's left or allow negative?
+ # Ideally check before checkout. Assuming check happened at add-to-cart or cart-view)
+ item.listing.quantity = 0
+ item.listing.save()
- for pack in available_packs:
- pack.owner = request.user
- pack.save()
+ # 2. Handle Pack Listings
+ if item.pack_listing:
+ # Decrement Stock
+ if item.pack_listing.quantity >= item.quantity:
+ item.pack_listing.quantity -= item.quantity
+ item.pack_listing.save()
+ else:
+ item.pack_listing.quantity = 0
+ item.pack_listing.save()
+
+ # 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 ONLY if it's a system pack (no seller) or configured to do so
+ if len(available_packs) < item.quantity:
+ # Seller packs must be pre-filled
+ if item.pack_listing.seller:
+ # We only fulfill what we have.
+ # Ideally we should have caught this at cart validation.
+ pass
+ else:
+ needed = item.quantity - len(available_packs)
+ game = item.pack_listing.game
+ all_game_cards = list(Card.objects.filter(set__game=game))
+
+ for _ in range(needed):
+ pack = VirtualPack.objects.create(listing=item.pack_listing)
+ if all_game_cards:
+ # Sample logic (mock)
+ 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.buyer_profile
+ pack.save()
# Clear cart
cart.items.all().delete()
- return redirect('store:my_packs')
+ if has_virtual_packs:
+ return redirect('store:my_packs')
+ return redirect('users:vault')
@login_required
def my_packs(request):
- packs = VirtualPack.objects.filter(owner=request.user, status='sealed').select_related('listing')
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:index')
+ if not hasattr(request.user, 'buyer_profile'):
+ return redirect('store:seller_dashboard')
+ packs = VirtualPack.objects.filter(owner=request.user.buyer_profile, 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 not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:index')
+ if not hasattr(request.user, 'buyer_profile'):
+ return redirect('store:seller_dashboard')
+ pack = get_object_or_404(VirtualPack, uuid=pack_id, owner=request.user.buyer_profile)
if request.method == 'POST':
if pack.status == 'sealed':
@@ -256,7 +625,7 @@ def open_pack(request, pack_id):
# Add cards to vault
for card in pack.cards.all():
- add_to_vault(request.user, card)
+ add_to_vault(request.user.buyer_profile, card)
data = {
'cards': [{
@@ -273,10 +642,883 @@ def open_pack(request, pack_id):
@login_required
def order_detail(request, order_id):
- order = get_object_or_404(Order, id=order_id)
+ order = get_object_or_404(Order, uuid=order_id)
# Security check: only allow viewing own orders (unless superuser)
- if order.user != request.user and not request.user.is_superuser:
+ # Check if order belongs to current buyer
+ is_owner = False
+ if hasattr(request.user, 'buyer_profile') and order.buyer == request.user.buyer_profile:
+ is_owner = True
+
+ if not is_owner and not request.user.is_superuser:
return redirect('users:profile')
+
+ # Handle rating submission
+ if request.method == 'POST' and is_owner:
+ rating_value = request.POST.get('rating')
+ if rating_value:
+ try:
+ rating_int = int(rating_value)
+ if 1 <= rating_int <= 5:
+ order.rating = rating_int
+ order.save()
+ return redirect('store:order_detail', order_id=order_id)
+ except (ValueError, TypeError):
+ pass
return render(request, 'store/order_detail.html', {'order': order})
+from users.forms import CustomUserCreationForm
+from django.contrib.auth import login
+
+def seller_register(request):
+ if request.user.is_authenticated:
+ if hasattr(request.user, 'seller_profile'):
+ return redirect('store:seller_dashboard')
+
+ if request.method == 'POST':
+ if request.user.is_authenticated:
+ user_form = None
+ seller_form = SellerRegistrationForm(request.POST)
+ if seller_form.is_valid():
+ seller = seller_form.save(commit=False)
+ seller.user = request.user
+ seller.slug = slugify(seller.store_name)
+ seller.save()
+ return redirect('store:seller_dashboard')
+ else:
+ user_form = CustomUserCreationForm(request.POST)
+ seller_form = SellerRegistrationForm(request.POST)
+
+ if user_form.is_valid() and seller_form.is_valid():
+ user = user_form.save()
+ login(request, user)
+
+ seller = seller_form.save(commit=False)
+ seller.user = user
+ seller.slug = slugify(seller.store_name)
+ seller.save()
+ return redirect('store:seller_dashboard')
+ else:
+ if request.user.is_authenticated:
+ user_form = None
+ else:
+ user_form = CustomUserCreationForm()
+ seller_form = SellerRegistrationForm()
+
+ return render(request, 'store/seller_register.html', {
+ 'user_form': user_form,
+ 'form': seller_form
+ })
+
+@login_required
+def edit_seller_profile(request):
+ try:
+ seller = request.user.seller_profile
+ except Seller.DoesNotExist:
+ return redirect('store:seller_register')
+
+ if request.method == 'POST':
+ form = SellerEditForm(request.POST, request.FILES, instance=seller)
+ if form.is_valid():
+ seller = form.save(commit=False)
+ if 'store_name' in form.changed_data:
+ seller.slug = slugify(seller.store_name)
+ seller.save()
+ return redirect('store:seller_profile', slug=seller.slug)
+ else:
+ form = SellerEditForm(instance=seller)
+
+ return render(request, 'store/seller/edit_profile.html', {'form': form, 'title': 'Edit Store Profile'})
+
+@login_required
+def seller_dashboard(request):
+ if not hasattr(request.user, 'seller_profile'):
+ return redirect('store:seller_register')
+
+ seller = request.user.seller_profile
+
+ # Calculate stats
+ card_items = OrderItem.objects.filter(listing__seller=seller, order__status__in=['paid', 'shipped'])
+ pack_items = OrderItem.objects.filter(pack_listing__seller=seller, order__status__in=['paid', 'shipped'])
+
+ total_revenue = 0
+ items_sold = 0
+
+ for item in card_items:
+ total_revenue += item.price_at_purchase * item.quantity
+ items_sold += item.quantity
+
+ for item in pack_items:
+ total_revenue += item.price_at_purchase * item.quantity
+ items_sold += item.quantity
+
+ active_listings_count = CardListing.objects.filter(seller=seller).count() + PackListing.objects.filter(seller=seller).count()
+
+ # Theme Form Handling
+ from .forms import SellerThemeForm
+ if request.method == 'POST' and 'theme_preference' in request.POST:
+ theme_form = SellerThemeForm(request.POST, instance=request.user.profile)
+ if theme_form.is_valid():
+ theme_form.save()
+ return redirect('store:seller_dashboard')
+ else:
+ theme_form = SellerThemeForm(instance=request.user.profile)
+
+ # QR Code Generation
+ # Construct the absolute URL for the store
+ store_path = redirect('store:seller_profile', slug=seller.slug).url
+ store_full_url = request.build_absolute_uri(store_path)
+
+ # Using public API for QR Code
+ qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=150x150&data={store_full_url}"
+
+ # Calculate Average Rating
+ from django.db.models import Avg
+ avg_rating = Order.objects.filter(seller=seller, rating__isnull=False).aggregate(Avg('rating'))['rating__avg']
+ if avg_rating:
+ avg_rating = round(avg_rating, 1)
+
+ context = {
+ 'seller': seller,
+ 'total_revenue': total_revenue,
+ 'items_sold': items_sold,
+ 'active_listings_count': active_listings_count,
+ 'theme_form': theme_form,
+ 'store_views': seller.store_views,
+ 'listing_clicks': seller.listing_clicks,
+ 'store_full_url': store_full_url,
+ 'qr_code_url': qr_code_url,
+ 'avg_rating': avg_rating,
+ }
+
+ # Chart Data Preparation
+ from django.db.models import Sum, F, Count
+ from django.db.models.functions import TruncDay, TruncWeek, TruncMonth
+ from django.utils import timezone
+ from datetime import timedelta
+ import json
+
+
+def terms(request):
+ return render(request, 'legal/terms.html')
+
+ # Filter Handling
+ game_filter = request.GET.get('game')
+
+ all_games = Game.objects.all().order_by('name')
+
+ # Helper function to get chart data
+ def get_chart_data(period_type, days_back, trunc_func, periods_count):
+ start_date = now - timedelta(days=days_back)
+
+ items = OrderItem.objects.filter(
+ order__created_at__gte=start_date,
+ order__status__in=['paid', 'shipped']
+ ).filter(
+ Q(listing__seller=seller) | Q(pack_listing__seller=seller)
+ ).annotate(
+ period=trunc_func('order__created_at')
+ ).values('period').annotate(
+ revenue=Sum(F('price_at_purchase') * F('quantity')),
+ count=Sum('quantity')
+ ).order_by('period')
+
+ dates_list = []
+ rev_list = []
+ count_list = []
+
+ data_map = {item['period'].strftime('%Y-%m-%d'): item for item in items}
+
+ # Determine step size roughly (simplification for generating zero-filled lists)
+ # For strict correctness we'd iterate by day/week/month properly.
+ # Simplification: Just iterate by days and check trunc matches?
+ # Better: Iterate by the period type.
+
+ current = start_date
+ seen_periods = set()
+
+ # Loop to generate x-axis labels.
+ # Note: logic varies slightly by period, doing a simple loop for now.
+
+ # 1. Day Iteration (Last 30 Days)
+ if period_type == 'day':
+ for i in range(periods_count):
+ d = (start_date + timedelta(days=i)).date()
+ label = d.strftime('%Y-%m-%d')
+ dates_list.append(label)
+ item = data_map.get(label)
+ rev_list.append(str(item['revenue']) if item else 0)
+ count_list.append(item['count'] if item else 0)
+
+ # 2. Week Iteration (Last 12 Weeks)
+ elif period_type == 'week':
+ # Align start date to Monday?
+ # Or just jump 7 days from start_date
+ for i in range(periods_count):
+ d = (start_date + timedelta(weeks=i)).date()
+ # We need to match how TruncWeek formats? typically Monday
+ # Let's trust the data_map keys (which come from TruncWeek) match roughly
+ # But TruncWeek might not align exactly with start_date + N weeks if start_date isn't Monday.
+ # Hack: Just use the items returned? No, we need zero-filling.
+ # Let's iterate found items + missing?
+ # Simpler: Just rely on sorted items for now? No, graph looks bad without gaps.
+
+ # Robust approach: Generate week starts
+ label = d.strftime('%Y-%m-%d') # TruncWeek returns date
+ # We will check approximate match or exact match if Trunc works well
+ # Actually, let's keep it simple:
+ # Just loop days, if new week (Monday), add point.
+ pass
+
+ # Re-implementation for Week/Month using simpler list logic since strict calendar math in python is verbose
+ # We will just dump the sparse data for Week/Month if density is low, or try to fill.
+ # Let's just pass the sparse data for Week/Month for MVP to avoid complexity.
+
+ dates_list = [item['period'].strftime('%Y-%m-%d') for item in items]
+ rev_list = [str(item['revenue']) for item in items]
+ count_list = [item['count'] for item in items]
+
+ # 3. Month Iteration (Last 12 Months)
+ elif period_type == 'month':
+ dates_list = [item['period'].strftime('%Y-%m') for item in items]
+ rev_list = [str(item['revenue']) for item in items]
+ count_list = [item['count'] for item in items]
+
+ return dates_list, rev_list, count_list
+
+ # 1. Day Queries (Last 30 Days) -> Zero filled
+ # Re-using strict loop from before for Days as it looks best
+ day_items = OrderItem.objects.filter(
+ order__created_at__gte=now - timedelta(days=30),
+ order__status__in=['paid', 'shipped']
+ ).filter(
+ Q(listing__seller=seller) | Q(pack_listing__seller=seller)
+ ).annotate(
+ day=TruncDay('order__created_at')
+ ).values('day').annotate(
+ daily_revenue=Sum(F('price_at_purchase') * F('quantity')),
+ daily_count=Sum('quantity')
+ ).order_by('day')
+
+ day_map = {item['day'].strftime('%Y-%m-%d'): item for item in day_items}
+ dates_day, rev_day, count_day = [], [], []
+ for i in range(30):
+ d = (now - timedelta(days=30) + timedelta(days=i)).date()
+ date_str = d.strftime('%Y-%m-%d')
+ dates_day.append(date_str)
+ if date_str in day_map:
+ rev_day.append(str(day_map[date_str]['daily_revenue']))
+ count_day.append(day_map[date_str]['daily_count'])
+ else:
+ rev_day.append(0)
+ count_day.append(0)
+
+ # 2. Week Queries (Last 12 Weeks)
+ week_items = OrderItem.objects.filter(
+ order__created_at__gte=now - timedelta(weeks=12),
+ order__status__in=['paid', 'shipped']
+ ).filter(
+ Q(listing__seller=seller) | Q(pack_listing__seller=seller)
+ ).annotate(
+ week=TruncWeek('order__created_at')
+ ).values('week').annotate(
+ revenue=Sum(F('price_at_purchase') * F('quantity')),
+ count=Sum('quantity')
+ ).order_by('week')
+
+ dates_week = [item['week'].strftime('%Y-%m-%d') for item in week_items]
+ rev_week = [str(item['revenue']) for item in week_items]
+ count_week = [item['count'] for item in week_items]
+
+ # 3. Month Queries (Last 12 Months)
+ month_items = OrderItem.objects.filter(
+ order__created_at__gte=now - timedelta(days=365),
+ order__status__in=['paid', 'shipped']
+ ).filter(
+ Q(listing__seller=seller) | Q(pack_listing__seller=seller)
+ ).annotate(
+ month=TruncMonth('order__created_at')
+ ).values('month').annotate(
+ revenue=Sum(F('price_at_purchase') * F('quantity')),
+ count=Sum('quantity')
+ ).order_by('month')
+
+ dates_month = [item['month'].strftime('%Y-%m') for item in month_items]
+ rev_month = [str(item['revenue']) for item in month_items]
+ count_month = [item['count'] for item in month_items]
+
+ context['chart_data_day'] = json.dumps({'labels': dates_day, 'revenue': rev_day, 'sales': count_day})
+ context['chart_data_week'] = json.dumps({'labels': dates_week, 'revenue': rev_week, 'sales': count_week})
+ context['chart_data_month'] = json.dumps({'labels': dates_month, 'revenue': rev_month, 'sales': count_month})
+
+ # 2. Sales by Game (Pie Chart) & Game Filtering application
+ # We apply filtering for the breakdown charts below, but maybe not the main timeline?
+ # User request: "you can filter down that games if you want as well"
+ # Typically filters apply to all valid charts.
+ # Let's apply game_filter to the breakdown queries below.
+
+ # Base Queryset for Breakdowns (Card Items)
+ seller_card_items = OrderItem.objects.filter(
+ listing__seller=seller,
+ order__status__in=['paid', 'shipped']
+ ).select_related('listing__card__set__game')
+
+ # Base Queryset for Breakdowns (Pack Items)
+ seller_pack_items = OrderItem.objects.filter(
+ pack_listing__seller=seller,
+ order__status__in=['paid', 'shipped']
+ ).select_related('pack_listing__game')
+
+ if game_filter:
+ seller_card_items = seller_card_items.filter(listing__card__set__game__name=game_filter)
+ seller_pack_items = seller_pack_items.filter(pack_listing__game__name=game_filter)
+
+ # A. Sales by Game
+ # Note: If filtered by game, this will show 100% for that game, which is correct behavior.
+ game_sales = {}
+
+ for item in seller_card_items:
+ if item.listing:
+ game_name = item.listing.card.set.game.name
+ game_sales[game_name] = game_sales.get(game_name, 0) + item.quantity
+
+ for item in seller_pack_items:
+ if item.pack_listing:
+ game_name = item.pack_listing.game.name
+ game_sales[game_name] = game_sales.get(game_name, 0) + item.quantity
+
+ context['game_labels'] = json.dumps(list(game_sales.keys()))
+ context['game_data'] = json.dumps(list(game_sales.values()))
+
+ # B. Sales by Condition (Cards Only)
+ condition_sales = {}
+ # We can iterate or aggregate. Iteration is fine given we have the QS.
+ for item in seller_card_items:
+ if item.listing:
+ cond = item.listing.get_condition_display() # Use display name e.g. "Near Mint"
+ condition_sales[cond] = condition_sales.get(cond, 0) + item.quantity
+
+ context['condition_labels'] = json.dumps(list(condition_sales.keys()))
+ context['condition_data'] = json.dumps(list(condition_sales.values()))
+
+ # C. Sales by Set (Cards Only) - Top 10 Sets
+ set_sales = {}
+ for item in seller_card_items:
+ if item.listing:
+ set_name = item.listing.card.set.name
+ set_sales[set_name] = set_sales.get(set_name, 0) + item.quantity
+
+ # Sort and take top 10
+ sorted_sets = sorted(set_sales.items(), key=lambda x: x[1], reverse=True)[:10]
+ context['set_labels'] = json.dumps([x[0] for x in sorted_sets])
+ context['set_data'] = json.dumps([x[1] for x in sorted_sets])
+
+ context['all_games'] = all_games
+ context['selected_game'] = game_filter
+
+ return render(request, 'store/seller/dashboard.html', context)
+
+@login_required
+def manage_listings(request):
+ if not hasattr(request.user, 'seller_profile'):
+ return redirect('store:seller_register')
+ seller = request.user.seller_profile
+
+ card_listings = CardListing.objects.filter(seller=seller).select_related('card', 'card__set')
+ pack_listings = PackListing.objects.filter(seller=seller).select_related('game')
+
+ return render(request, 'store/seller/manage_listings.html', {
+ 'card_listings': card_listings,
+ 'pack_listings': pack_listings
+ })
+
+from django.http import HttpResponse # Added import
+import csv # Added import
+import io # Added import
+
+# ... existing imports ...
+from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm
+
+# ... [Keep existing code until add_card_listing] ...
+
+@login_required
+def download_listing_template(request, type):
+ # Ensure legitimate type
+ if type not in ['card', 'pack']:
+ return redirect('store:manage_listings')
+
+ # Define file path
+ # We can either serve a static file or generate it on the fly.
+ # Generating on the fly is robust.
+
+ response = HttpResponse(content_type='text/csv')
+ response['Content-Disposition'] = f'attachment; filename="{type}_listing_template.csv"'
+
+ writer = csv.writer(response)
+
+ if type == 'card':
+ writer.writerow(['Game', 'Set', 'Card Name', 'Collector Number', 'Condition', 'Price', 'Quantity', 'Image Filename'])
+ # Add sample row
+ writer.writerow(['Magic: The Gathering', 'Alpha', 'Black Lotus', '', 'NM', '10000.00', '1', 'black_lotus.jpg'])
+ elif type == 'pack':
+ writer.writerow(['Game', 'Name', 'Listing Type', 'Price', 'Quantity', 'Image Filename'])
+ writer.writerow(['Magic: The Gathering', 'Alpha Booster', 'physical', '1000.00', '1', 'alpha_booster.jpg'])
+
+ return response
+
+@login_required
+def add_card_listing(request):
+ try:
+ seller = request.user.seller_profile
+ except Seller.DoesNotExist:
+ return redirect('store:seller_register')
+
+ bulk_form = BulkListingForm() # Initialize bulk form
+
+ if request.method == 'POST':
+ if 'bulk_upload' in request.POST:
+ # Handle Bulk Upload
+ bulk_form = BulkListingForm(request.POST, request.FILES)
+ if bulk_form.is_valid():
+ csv_file = request.FILES['csv_file']
+ images = request.FILES.getlist('images')
+
+ # Create a map of filename -> image file
+ image_map = {img.name: img for img in images}
+
+ decoded_file = csv_file.read().decode('utf-8').splitlines()
+ reader = csv.DictReader(decoded_file)
+
+ for row in reader:
+ # Basic validation/cleaning
+ try:
+ game_name = row.get('Game', '').strip()
+ set_name = row.get('Set', '').strip()
+ card_name = row.get('Card Name', '').strip()
+ collector_number = row.get('Collector Number', '').strip()
+ condition = row.get('Condition', 'NM').strip()
+ price = row.get('Price', '0').strip()
+ quantity = row.get('Quantity', '1').strip()
+ image_filename = row.get('Image Filename', '').strip()
+
+ if not game_name or not card_name:
+ continue # Skip invalid rows
+
+ # Get Game
+ game_obj, _ = Game.objects.get_or_create(name=game_name, defaults={'slug': slugify(game_name)})
+
+ # Get Set
+ set_obj, _ = Set.objects.get_or_create(
+ game=game_obj,
+ name=set_name,
+ defaults={'code': '', 'release_date': None}
+ )
+
+ # Get Card
+ card_obj, _ = Card.objects.get_or_create(
+ set=set_obj,
+ name=card_name,
+ collector_number=collector_number,
+ defaults={
+ 'rarity': 'Unknown',
+ }
+ )
+
+ # Image handling
+ image_file = image_map.get(image_filename)
+
+ # Create Listing
+ CardListing.objects.create(
+ card=card_obj,
+ seller=seller,
+ condition=condition if condition in dict(CardListing.CONDITION_CHOICES) else 'NM', # Basic fallback
+ price=price,
+ quantity=quantity,
+ image=image_file,
+ status='listed'
+ )
+
+ except Exception as e:
+ # Log error or skip
+ print(f"Error processing row: {e}")
+ continue
+
+ return redirect('store:manage_listings')
+
+ else:
+ # Handle Single Upload
+ form = AddCardListingForm(request.POST, request.FILES)
+ if form.is_valid():
+ game = form.cleaned_data['game']
+ set_name = form.cleaned_data['set_name']
+ card_name = form.cleaned_data['card_name']
+ condition = form.cleaned_data['condition']
+ price = form.cleaned_data['price']
+ quantity = form.cleaned_data['quantity']
+ image = form.cleaned_data['image']
+
+ collector_number = form.cleaned_data.get('collector_number', '').strip()
+
+ # Get or Create Set
+ # Note: Set code and release_date are left blank for now as we don't have them
+ set_obj, _ = Set.objects.get_or_create(
+ game=game,
+ name=set_name,
+ defaults={'code': '', 'release_date': None}
+ )
+
+ # Get or Create Card
+ # Providing defaults for fields we don't have
+ card_obj, _ = Card.objects.get_or_create(
+ set=set_obj,
+ name=card_name,
+ collector_number=collector_number,
+ defaults={
+ 'rarity': 'Unknown',
+ 'scryfall_id': None,
+ 'tcgplayer_id': None
+ }
+ )
+
+ # Create Listing
+ CardListing.objects.create(
+ card=card_obj,
+ seller=seller,
+ condition=condition,
+ price=price,
+ quantity=quantity,
+ image=image, # Save the image
+ status='listed'
+ )
+
+ return redirect('store:manage_listings')
+ else:
+ form = AddCardListingForm()
+
+ return render(request, 'store/add_card_listing.html', {'form': form, 'bulk_form': bulk_form})
+
+@login_required
+def edit_card_listing(request, listing_id):
+ seller = request.user.seller_profile
+ listing = get_object_or_404(CardListing, uuid=listing_id, seller=seller)
+ if request.method == 'POST':
+ form = CardListingForm(request.POST, request.FILES, instance=listing)
+ if form.is_valid():
+ form.save()
+ return redirect('store:manage_listings')
+ else:
+ form = CardListingForm(instance=listing)
+ return render(request, 'store/seller/edit_listing.html', {'form': form, 'title': 'Edit Card Listing'})
+
+@login_required
+def delete_card_listing(request, listing_id):
+ seller = request.user.seller_profile
+ listing = get_object_or_404(CardListing, uuid=listing_id, seller=seller)
+ if request.method == 'POST':
+ listing.delete()
+ return redirect('store:manage_listings')
+ return render(request, 'store/seller/confirm_delete.html', {'item': listing})
+
+@login_required
+def add_pack_listing(request):
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:manage_listings')
+ seller = request.user.seller_profile
+
+ bulk_form = BulkListingForm()
+
+ if request.method == 'POST':
+ if 'bulk_upload' in request.POST:
+ bulk_form = BulkListingForm(request.POST, request.FILES)
+ if bulk_form.is_valid():
+ csv_file = request.FILES['csv_file']
+ images = request.FILES.getlist('images')
+
+ image_map = {img.name: img for img in images}
+
+ decoded_file = csv_file.read().decode('utf-8').splitlines()
+ reader = csv.DictReader(decoded_file)
+
+ from django.core.files.storage import default_storage
+ from django.core.files.base import ContentFile
+ import os
+
+ for row in reader:
+ try:
+ game_name = row.get('Game', '').strip()
+ name = row.get('Name', '').strip()
+ listing_type = row.get('Listing Type', 'physical').strip()
+ price = row.get('Price', '0').strip()
+ quantity = row.get('Quantity', '1').strip()
+ image_filename = row.get('Image Filename', '').strip()
+
+ if not game_name or not name:
+ continue
+
+ # Get Game
+ game_obj, _ = Game.objects.get_or_create(name=game_name, defaults={'slug': slugify(game_name)})
+
+ image_url = ''
+ if image_filename in image_map:
+ # Save image to storage and get URL
+ # We need to manually save since the model has URLField
+ img_file = image_map[image_filename]
+ path = default_storage.save(f'pack_images/{img_file.name}', ContentFile(img_file.read()))
+ image_url = default_storage.url(path)
+
+ PackListing.objects.create(
+ game=game_obj,
+ seller=seller,
+ name=name,
+ listing_type=listing_type if listing_type in ['physical', 'virtual'] else 'physical',
+ price=price,
+ quantity=quantity,
+ image_url=image_url
+ )
+ except Exception as e:
+ print(f"Error processing pack row: {e}")
+ continue
+
+ return redirect('store:manage_listings')
+ else:
+ form = PackListingForm(request.POST)
+ if form.is_valid():
+ listing = form.save(commit=False)
+ listing.seller = seller
+ listing.save()
+ return redirect('store:manage_listings')
+ else:
+ form = PackListingForm()
+ return render(request, 'store/add_pack_listing.html', {'form': form, 'bulk_form': bulk_form, 'title': 'Add Pack Listing'})
+
+@login_required
+def edit_pack_listing(request, listing_id):
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:manage_listings')
+ seller = request.user.seller_profile
+ listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
+ if request.method == 'POST':
+ form = PackListingForm(request.POST, instance=listing)
+ if form.is_valid():
+ form.save()
+ return redirect('store:manage_listings')
+ else:
+ form = PackListingForm(instance=listing)
+ return render(request, 'store/seller/edit_listing.html', {'form': form, 'title': 'Edit Pack Listing'})
+
+@login_required
+def delete_pack_listing(request, listing_id):
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:manage_listings')
+ seller = request.user.seller_profile
+ listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
+ if request.method == 'POST':
+ listing.delete()
+ return redirect('store:manage_listings')
+ return render(request, 'store/seller/confirm_delete.html', {'item': listing})
+
+def seller_profile(request, slug):
+ seller = get_object_or_404(Seller, slug=slug)
+
+ # Increment views
+ # Use F expression to avoid race conditions
+ from django.db.models import F
+ Seller.objects.filter(slug=slug).update(store_views=F('store_views') + 1)
+
+ # Refresh to show correct count if needed (though not displayed on profile usually)
+ seller.refresh_from_db()
+
+ # Filter Logic
+ from django.db.models import Q
+
+ # Get initial queryset
+ card_listings = CardListing.objects.filter(seller=seller, quantity__gt=0).select_related('card', 'card__set', 'card__set__game')
+
+ # Get filter parameters
+ query = request.GET.get('q')
+ game_slug = request.GET.get('game')
+ set_code = request.GET.get('set')
+ condition = request.GET.get('condition')
+ min_price = request.GET.get('min_price')
+ max_price = request.GET.get('max_price')
+ min_qty = request.GET.get('min_qty')
+
+ # Apply filters
+ if query:
+ card_listings = card_listings.filter(card__name__icontains=query)
+
+ if game_slug:
+ card_listings = card_listings.filter(card__set__game__slug=game_slug)
+
+ if set_code:
+ card_listings = card_listings.filter(card__set__code=set_code)
+
+ if condition:
+ card_listings = card_listings.filter(condition=condition)
+
+ if min_price:
+ try:
+ card_listings = card_listings.filter(price__gte=float(min_price))
+ except (ValueError, TypeError):
+ pass
+
+ if max_price:
+ try:
+ card_listings = card_listings.filter(price__lte=float(max_price))
+ except (ValueError, TypeError):
+ pass
+
+ if min_qty:
+ try:
+ card_listings = card_listings.filter(quantity__gte=int(min_qty))
+ except (ValueError, TypeError):
+ pass
+
+ # Get available options for filters (scoped to what the seller actually has)
+ seller_games = Game.objects.filter(sets__cards__listings__seller=seller).distinct()
+ seller_sets = Set.objects.filter(cards__listings__seller=seller).select_related('game').distinct()
+
+ pack_listings = PackListing.objects.filter(seller=seller).select_related('game')
+
+ # Calculate Average Rating
+ from django.db.models import Avg
+ avg_rating = Order.objects.filter(seller=seller, rating__isnull=False).aggregate(Avg('rating'))['rating__avg']
+ if avg_rating:
+ avg_rating = round(avg_rating, 1)
+
+ context = {
+ 'seller': seller,
+ 'card_listings': card_listings,
+ 'pack_listings': pack_listings,
+ 'games': seller_games,
+ 'sets': seller_sets,
+ 'conditions': CardListing.CONDITION_CHOICES,
+ 'avg_rating': avg_rating,
+ 'filters': {
+ 'q': query,
+ 'game': game_slug,
+ 'set': set_code,
+ 'condition': condition,
+ 'min_price': min_price,
+ 'max_price': max_price,
+ 'min_qty': min_qty,
+ }
+ }
+
+ return render(request, 'store/seller/profile.html', context)
+
+@login_required
+def manage_pack_inventory(request, listing_id):
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:manage_listings')
+ seller = request.user.seller_profile
+ listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
+
+ # Only for virtual packs
+ if listing.listing_type != 'virtual':
+ return redirect('store:manage_listings')
+
+ # Get all sealed packs for this listing that haven't been bought yet (owner=None)
+ # AND packs that have been bought but not opened? No, seller only manages unsold inventory usually.
+ # Actually, once bought, it belongs to buyer.
+ packs = VirtualPack.objects.filter(listing=listing, owner__isnull=True).prefetch_related('cards')
+
+ return render(request, 'store/seller/manage_pack_inventory.html', {
+ 'listing': listing,
+ 'packs': packs
+ })
+
+@login_required
+def add_virtual_pack_content(request, listing_id):
+ if not settings.FEATURE_VIRTUAL_PACKS:
+ return redirect('store:manage_listings')
+ seller = request.user.seller_profile
+ listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
+
+ if listing.listing_type != 'virtual':
+ return redirect('store:manage_listings')
+
+ if request.method == 'POST':
+ # Expect specific card IDs to be added
+ card_ids = request.POST.getlist('cards')
+ if card_ids:
+ pack = VirtualPack.objects.create(listing=listing, status='sealed')
+ cards = Card.objects.filter(id__in=card_ids)
+ pack.cards.set(cards)
+
+ # Update listing quantity
+ # We count total sealed available packs
+ count = VirtualPack.objects.filter(listing=listing, owner__isnull=True, status='sealed').count()
+ listing.quantity = count
+ listing.save()
+
+ listing.save()
+
+ return redirect('store:manage_pack_inventory', listing_id=listing.uuid)
+
+ # Search functionality for finding cards to add
+ query = request.GET.get('q')
+ cards = []
+ if query:
+ cards = Card.objects.filter(name__icontains=query).select_related('set', 'set__game')[:50]
+
+ return render(request, 'store/seller/add_virtual_pack_content.html', {
+ 'listing': listing,
+ 'cards': cards,
+ 'query': query
+ })
+
+@login_required
+def report_seller(request, slug):
+ """Handle AJAX POST to report a seller."""
+ if request.method != 'POST':
+ return JsonResponse({'status': 'error', 'message': 'POST required'}, status=400)
+
+ seller = get_object_or_404(Seller, slug=slug)
+ reason = request.POST.get('reason')
+
+ valid_reasons = [choice[0] for choice in SellerReport.REASON_CHOICES]
+ if reason not in valid_reasons:
+ return JsonResponse({'status': 'error', 'message': 'Invalid reason'}, status=400)
+
+ details = request.POST.get('details', '')
+
+ SellerReport.objects.create(
+ reporter=request.user,
+ seller=seller,
+ reason=reason,
+ details=details,
+ )
+ return JsonResponse({'status': 'success'})
+
+@staff_member_required
+def admin_revenue_dashboard(request):
+ sellers = Seller.objects.all()
+ seller_data = []
+ total_platform_revenue = Decimal('0.00')
+
+ for seller in sellers:
+ # Get paid/shipped orders for this seller
+ orders = seller.orders.filter(status__in=['paid', 'shipped'])
+
+ seller_revenue = Decimal('0.00')
+ seller_fees = Decimal('0.00')
+
+ for order in orders:
+ seller_revenue += order.total_price
+ fee = calculate_platform_fee(order.total_price)
+ seller_fees += fee
+
+ total_platform_revenue += seller_fees
+
+ seller_data.append({
+ 'seller': seller,
+ 'total_revenue': seller_revenue,
+ 'platform_fees': seller_fees
+ })
+
+ return render(request, 'store/admin_revenue_dashboard.html', {
+ 'seller_data': seller_data,
+ 'total_platform_revenue': total_platform_revenue
+ })
+
diff --git a/templates/base/layout.html b/templates/base/layout.html
index d267f31..405f2ec 100644
--- a/templates/base/layout.html
+++ b/templates/base/layout.html
@@ -4,246 +4,98 @@
- {% block title %}Phantom Card Fam - Premium TCG Store{% endblock %}
+ {% block title %}TCGKof - Premium TCG Store{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% if not debug %}
{% endif %}
-
+ {% if FEATURE_DEMO_SITE %}
DEMO SITE: This is an example application. No real products, payments, or purchases are processed.
+ {% endif %}
- Phantom Card Fam
+
@@ -278,9 +133,27 @@
{% endblock %}
-
- © 2026 Phantom Card Fam TCG Store.
- Made by AI ML Operations, LLC
+
+
+
+
TCGKof
+
The premier destination for trading card game buyers and sellers.
+
© 2026 TCGKof Store.
+
Operated by AI ML Operations, LLC
+
+
+
{% if not user.is_authenticated %}
@@ -298,48 +171,59 @@
{% endif %}
diff --git a/templates/decks/deck_import.html b/templates/decks/deck_import.html
index 9967773..3ca5419 100644
--- a/templates/decks/deck_import.html
+++ b/templates/decks/deck_import.html
@@ -1,4 +1,4 @@
-{% extends 'base.html' %}
+{% extends 'base/layout.html' %}
{% block content %}
diff --git a/templates/legal/terms.html b/templates/legal/terms.html
new file mode 100644
index 0000000..2ad8bd3
--- /dev/null
+++ b/templates/legal/terms.html
@@ -0,0 +1,33 @@
+{% extends 'base/layout.html' %}
+
+{% block title %}Terms and Service - TCGKof{% endblock %}
+
+{% block content %}
+
+
Terms of Service
+
+
+
Welcome to TCGKof. By accessing our website, you agree to these terms and conditions.
+
+
1. General Terms
+
TCGKof is a platform for buying and selling trading cards. Users must be at least 18 years old or have parental consent to use this service.
+
+
2. User Accounts
+
You are responsible for maintaining the confidentiality of your account and password. You agree to accept responsibility for all activities that occur under your account.
+
+
3. Buying and Selling
+
Sellers must accurately describe items. Buyers must pay for items they commit to purchase. TCGKof facilitates transactions but is not a party to the contract between buyer and seller.
+
+
4. Prohibited Content
+
Users may not post content that is illegal, obscene, threatening, defamatory, or otherwise objectionable.
+
+
5. Limitation of Liability
+
TCGKof shall not be liable for any indirect, incidental, special, consequential, or punitive damages happening out of or in connection with your use of the site.
+
+
6. Changes to Terms
+
We reserve the right to modify these terms at any time. Your continued use of the site constitutes acceptance of the modified terms.
+
+
Last Updated: January 2026
+
+
+{% endblock %}
diff --git a/templates/registration/login.html b/templates/registration/login.html
index 3bd41bf..13f603a 100644
--- a/templates/registration/login.html
+++ b/templates/registration/login.html
@@ -13,5 +13,8 @@
Don't have an account?
Register
here .
+
+ Want to sell on TCGKof? Register here .
+
{% endblock %}
\ No newline at end of file
diff --git a/templates/robots.txt b/templates/robots.txt
new file mode 100644
index 0000000..c1142cf
--- /dev/null
+++ b/templates/robots.txt
@@ -0,0 +1,6 @@
+User-agent: *
+Disallow: /admin/
+Disallow: /users/
+Disallow: /cart/
+Disallow: /checkout/
+Disallow: /api/
diff --git a/templates/store/bounty_list.html b/templates/store/bounty_list.html
new file mode 100644
index 0000000..e0afc66
--- /dev/null
+++ b/templates/store/bounty_list.html
@@ -0,0 +1,164 @@
+{% extends 'base/layout.html' %}
+{% load static %}
+
+{% block content %}
+
+
+
+
+
+
+
+
Bounty Board
+ {% if user.seller_profile %}
+
Post a Bounty
+ {% endif %}
+
+
+
+ {% for bounty in bounties %}
+
+ {% empty %}
+
+
No active bounties found matching your criteria.
+
Clear Filters
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/store/card_detail.html b/templates/store/card_detail.html
index f3bca3d..69bf6d3 100644
--- a/templates/store/card_detail.html
+++ b/templates/store/card_detail.html
@@ -1,16 +1,52 @@
{% extends 'base/layout.html' %}
+{% block title %}{{ card.name }} - TCGKof{% endblock %}
+
+{% block meta_description %}Buy {{ card.name }} ({{ card.set.name }}) on TCGKof. {{ listings|length }} listings available starting from ${{ listings.first.price|default:'0.00' }}.{% endblock %}
+
+{% block meta_keywords %}{{ card.name }}, {{ card.set.name }}, {{ card.rarity }}, {{ card.set.game.name }}, buy {{ card.name }}, sell {{ card.name }}{% endblock %}
+
+{% block og_title %}{{ card.name }} - {{ card.set.name }} | TCGKof{% endblock %}
+{% block og_description %}Buy {{ card.name }} from {{ card.set.name }} set. best prices on TCGKof.{% endblock %}
+{% block og_image %}{{ card.image_url|default:'' }}{% endblock %}
+{% block og_type %}product{% endblock %}
+
+{% block twitter_title %}{{ card.name }} - {{ card.set.name }}{% endblock %}
+{% block twitter_description %}Find the best deals for {{ card.name }} on TCGKof.{% endblock %}
+{% block twitter_image %}{{ card.image_url|default:'' }}{% endblock %}
+
{% block content %}
+
+ style="background: var(--card-bg); border-radius: 0.75rem; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);">
{% if card.image_url %}
{% else %}
-
+ style="aspect-ratio: 2.5/3.5; display: flex; align-items: center; justify-content: center; color: var(--muted-text-color); background: var(--border-color);">
No Image
{% endif %}
@@ -19,13 +55,13 @@
@@ -36,7 +72,7 @@
{{ card.set.game.name }} • {{ card.set.name }}
{{ card.name }}
-
+
Rarity: {{ card.rarity }}
Collector #: {{ card.collector_number }}
@@ -46,7 +82,7 @@
Listings
-
Filter Condition:
+
Filter Condition:
All
NM
@@ -61,30 +97,43 @@
-
{{
- listing.condition }}
+ {% if listing.image %}
+
+ {% endif %}
+
+ {{ listing.condition }}
-
Condition
+
Condition
{% if listing.is_foil %}
Foil
+ style="background: linear-gradient(45deg, #f59e0b, #d97706); -webkit-background-clip: text; background-clip: text; color: transparent; font-weight: 700; font-size: 0.75rem; text-transform: uppercase;">Foil
{% endif %}
+
${{ listing.price }}
-
{{ listing.quantity }} available
+
{{ listing.quantity }} available
{% if user.is_authenticated %}
-
+
{% csrf_token %}
Add to Cart
{% else %}
-
Login
+ Login
to Buy
{% endif %}
@@ -92,21 +141,23 @@
{% empty %}
-
No listings currently available for this card.
+
No listings currently available for this card.
{% endfor %}
+ {% if FEATURE_PLAYTEST_PROXY %}
Playtest Proxy Service
-
Download a high-res proxy for playtesting. Credit offered if you buy later.
+
Download a high-res proxy for playtesting. Credit offered if you buy later.
Download Proxy
+ {% endif %}
+
Apply Filters
Clear
@@ -55,7 +116,7 @@
{% for card in page_obj %}
@@ -99,7 +160,7 @@
{% if page_obj.has_other_pages %}
{% if page_obj.has_previous %}
-
Prev
{% endif %}
@@ -109,7 +170,7 @@
{% if page_obj.has_next %}
-
Next
{% endif %}
diff --git a/templates/store/cart.html b/templates/store/cart.html
index 03bad9f..50ea1e5 100644
--- a/templates/store/cart.html
+++ b/templates/store/cart.html
@@ -7,7 +7,19 @@
{% if cart and cart.items.count > 0 %}
- {% for item in cart.items.all %}
+
+
+ {% for section in cart_data %}
+
+
+ {% for item in section.items %}
@@ -43,12 +55,27 @@
${{ item.total_price }}
-
×
{% endfor %}
+
+
+ {% if section.free_shipping_needed > 0 %}
+ Add ${{ section.free_shipping_needed }} more for free shipping!
+ {% else %}
+ Free Shipping Qualifies!
+ {% endif %}
+
+
+
Subtotal: ${{ section.subtotal }}
+
Shipping: ${{ section.shipping_cost }}
+
+
+ {% endfor %}
+
diff --git a/templates/store/my_packs.html b/templates/store/my_packs.html
index 2c95698..b94d5e5 100644
--- a/templates/store/my_packs.html
+++ b/templates/store/my_packs.html
@@ -15,7 +15,7 @@
{{ pack.listing.name }}
-
Open Pack
+
Open Pack
{% endfor %}
diff --git a/templates/store/order_detail.html b/templates/store/order_detail.html
index e9f7370..600fc6c 100644
--- a/templates/store/order_detail.html
+++ b/templates/store/order_detail.html
@@ -70,5 +70,85 @@
{% endfor %}
+
+
+ {% if order.seller %}
+
+
Rate Your Order
+
+ {% if order.rating %}
+
+
You rated this order:
+
+ {% for i in "12345" %}
+ {% if forloop.counter <= order.rating %}
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+ {% endfor %}
+
+
{{ order.rating }}/5
+
+ {% else %}
+
How would you rate your experience with {{ order.seller.store_name }}?
+
+ {% csrf_token %}
+
+ Submit Rating
+
+
+
+ {% endif %}
+
+ {% endif %}
{% endblock %}
diff --git a/templates/store/pack_list.html b/templates/store/pack_list.html
index 2fe08c8..aaed8bf 100644
--- a/templates/store/pack_list.html
+++ b/templates/store/pack_list.html
@@ -18,9 +18,17 @@
diff --git a/templates/users/profile.html b/templates/users/profile.html
index 4232e38..1ea609e 100644
--- a/templates/users/profile.html
+++ b/templates/users/profile.html
@@ -109,7 +109,7 @@
{% if orders %}
diff --git a/templates/users/sell_on_tcgkof.html b/templates/users/sell_on_tcgkof.html
new file mode 100644
index 0000000..83a6ae0
--- /dev/null
+++ b/templates/users/sell_on_tcgkof.html
@@ -0,0 +1,83 @@
+{% extends 'base/layout.html' %}
+{% load static %}
+
+{% block title %}Sell on TCGKof - Become a Seller{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Unlock the Value of Your Collection
+
+
+ Join the fastest-growing TCG marketplace. Turn your extra cards into cash with powerful tools designed for sellers of all sizes.
+
+
+
+
+
+
+
+
$2M+
+
Paid to Sellers
+
+
+
+
24h
+
Average Payout Time
+
+
+
+
+
+
Why Sell on TCGKof?
+
+
+
+
+
+
Low Selling Fees
+
Keep more of your profit with our straightforward 5% commission rate, one of the lowest in the industry.
+
+
+
+
+
+
Massive Audience
+
Instantly connect with thousands of collectors actively looking for the cards you have in your binder.
+
+
+
+
+
+
Secure Protection
+
Our Seller Protection Guarantee ensures you're covered against fraud, chargebacks, and shipping issues.
+
+
+
+
+
+
+
Ready to Get Started?
+
+ Create your account in less than 2 minutes and list your first card today. It's completely free to join.
+
+
Create Seller Account
+
+
+
+{% endblock %}
diff --git a/templates/users/seller_dashboard.html b/templates/users/seller_dashboard.html
new file mode 100644
index 0000000..d220bf4
--- /dev/null
+++ b/templates/users/seller_dashboard.html
@@ -0,0 +1,37 @@
+{% extends 'base/layout.html' %}
+{% load static %}
+
+{% block title %}Seller Dashboard - TCGKof{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Start Listing
+
You don't have any active listings yet. Add your cards to the marketplace to start selling.
+
Add New Listing (Coming Soon)
+
+
+{% endblock %}
diff --git a/templates/users/vault.html b/templates/users/vault.html
index ff9383f..5ec7a45 100644
--- a/templates/users/vault.html
+++ b/templates/users/vault.html
@@ -12,6 +12,14 @@
+
+
Set:
@@ -35,6 +43,66 @@
+
+
{% if vault_items %}
{% for item in vault_items %}
diff --git a/users/admin.py b/users/admin.py
index 8c38f3f..874fd72 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -1,3 +1,32 @@
from django.contrib import admin
+from .models import User, Profile, Address, PaymentMethod, Buyer
-# Register your models here.
+from django.contrib.auth.admin import UserAdmin
+from .models import User, Profile, Address, PaymentMethod, Buyer
+
+admin.site.register(User, UserAdmin)
+
+@admin.register(Profile)
+class ProfileAdmin(admin.ModelAdmin):
+ list_display = ['user', 'theme_preference', 'is_pro', 'is_seller']
+ list_select_related = ['user']
+ search_fields = ['user__username', 'user__email']
+
+@admin.register(Address)
+class AddressAdmin(admin.ModelAdmin):
+ list_display = ['user', 'name', 'address_type', 'city', 'state']
+ list_select_related = ['user']
+ list_filter = ['address_type']
+ search_fields = ['user__username', 'name', 'street']
+
+@admin.register(PaymentMethod)
+class PaymentMethodAdmin(admin.ModelAdmin):
+ list_display = ['user', 'brand', 'last4', 'is_default']
+ list_select_related = ['user']
+ search_fields = ['user__username']
+
+@admin.register(Buyer)
+class BuyerAdmin(admin.ModelAdmin):
+ list_display = ['user', 'created_at']
+ list_select_related = ['user']
+ search_fields = ['user__username', 'user__email']
diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py
index f59d66d..aa8d676 100644
--- a/users/migrations/0001_initial.py
+++ b/users/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 6.0.1 on 2026-01-19 13:38
+# Generated by Django 6.0.1 on 2026-01-21 17:40
import django.contrib.auth.models
import django.contrib.auth.validators
@@ -43,12 +43,47 @@ class Migration(migrations.Migration):
('objects', django.contrib.auth.models.UserManager()),
],
),
+ 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='Buyer',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='buyer_profile', 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)),
+ ],
+ ),
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)),
+ ('is_seller', models.BooleanField(default=False)),
+ ('theme_preference', models.CharField(choices=[('light', 'Light'), ('dark', 'Dark'), ('compact', 'Compact'), ('expressive', 'Expressive'), ('technical', 'Technical')], 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
deleted file mode 100644
index cab5229..0000000
--- a/users/migrations/0002_address_paymentmethod.py
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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/models.py b/users/models.py
index e77215a..0dee813 100644
--- a/users/models.py
+++ b/users/models.py
@@ -10,10 +10,14 @@ class Profile(models.Model):
THEME_CHOICES = (
('light', 'Light'),
('dark', 'Dark'),
+ ('compact', 'Compact'),
+ ('expressive', 'Expressive'),
+ ('technical', 'Technical'),
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
is_pro = models.BooleanField(default=False)
+ is_seller = 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)
@@ -57,6 +61,23 @@ class PaymentMethod(models.Model):
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}"
+
+class Buyer(models.Model):
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='buyer_profile')
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ def __str__(self):
+ return self.user.username
+
+@receiver(post_save, sender=User)
+def create_buyer_profile(sender, instance, created, **kwargs):
+ # We only auto-create Buyer if NOT a seller?
+ # Or do we create it manually in views?
+ # The requirement says "/user/register creates a buy account and /sell/register creates a seller account"
+ # So we probably shouldn't auto-create blindly for EVERY user if we want them distinct.
+ # However, easy way is to create it manually in the view.
+ # I will NOT add a signal here to avoid auto-creation when we might want a pure Seller.
+ pass
diff --git a/users/urls.py b/users/urls.py
index d28dd09..743424d 100644
--- a/users/urls.py
+++ b/users/urls.py
@@ -7,9 +7,13 @@ urlpatterns = [
path('register/', views.RegisterView.as_view(), name='register'),
path('profile/', views.profile_view, name='profile'),
path('vault/', views.vault_view, name='vault'),
+ path('api/vault-autocomplete/', views.vault_autocomplete, name='vault_autocomplete'),
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'),
+ path('sell-on-tcgkof/', views.sell_on_tcgkof_view, name='sell_on_tcgkof'),
+ path('dashboard/', views.seller_dashboard_view, name='seller_dashboard'),
+ path('login-success/', views.login_success_view, name='login_success'),
]
diff --git a/users/views.py b/users/views.py
index 64fee41..70028a3 100644
--- a/users/views.py
+++ b/users/views.py
@@ -1,4 +1,5 @@
from django.shortcuts import render, redirect, get_object_or_404
+from django.http import JsonResponse
from django.contrib.auth import login
from .forms import CustomUserCreationForm, ProfileForm, AddressForm, PaymentMethodForm
from django.views.generic import CreateView
@@ -12,12 +13,26 @@ class RegisterView(CreateView):
model = User
form_class = CustomUserCreationForm
template_name = 'users/register.html'
- success_url = reverse_lazy('home')
+ success_url = reverse_lazy('users:login_success')
def form_valid(self, form):
user = form.save()
+ # Create Buyer profile for every new user registered via this form
+ from .models import Buyer
+ Buyer.objects.create(user=user)
+
login(self.request, user)
- return redirect('home')
+ return redirect('users:login_success')
+
+@login_required
+def seller_dashboard_view(request):
+ return render(request, 'users/seller_dashboard.html')
+
+@login_required
+def login_success_view(request):
+ if hasattr(request.user, 'profile') and request.user.profile.is_seller:
+ return redirect('store:seller_dashboard')
+ return redirect('home')
@login_required
def profile_view(request):
@@ -31,9 +46,12 @@ def profile_view(request):
form = ProfileForm(instance=request.user.profile)
# Order filtering
- orders = request.user.orders.all().order_by('-created_at')
+ orders = []
+ if hasattr(request.user, 'buyer_profile'):
+ orders = request.user.buyer_profile.orders.all().order_by('-created_at')
+
date_query = request.GET.get('date')
- if date_query:
+ if date_query and orders:
orders = orders.filter(created_at__date=date_query)
addresses = request.user.addresses.all()
@@ -110,11 +128,20 @@ def delete_payment_method_view(request, pk):
@login_required
def vault_view(request):
- vault_items = VaultItem.objects.filter(user=request.user).select_related('card', 'card__set').order_by('-added_at')
+ if not hasattr(request.user, 'buyer_profile'):
+ # If not a buyer (e.g. seller), maybe show empty or redirect?
+ # For now, let's just show empty list
+ vault_items = VaultItem.objects.none()
+ else:
+ vault_items = VaultItem.objects.filter(buyer=request.user.buyer_profile).select_related('card', 'card__set').order_by('-added_at')
# Filtering
set_id = request.GET.get('set')
rarity = request.GET.get('rarity')
+ search_query = request.GET.get('q')
+
+ if search_query:
+ vault_items = vault_items.filter(card__name__icontains=search_query)
if set_id:
vault_items = vault_items.filter(card__set_id=set_id)
@@ -125,7 +152,10 @@ def vault_view(request):
# 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)
+ if hasattr(request.user, 'buyer_profile'):
+ user_card_ids = VaultItem.objects.filter(buyer=request.user.buyer_profile).values_list('card_id', flat=True)
+ else:
+ user_card_ids = []
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()
@@ -135,5 +165,26 @@ def vault_view(request):
'available_sets': available_sets,
'available_rarities': available_rarities,
'current_set': int(set_id) if set_id else None,
- 'current_rarity': rarity
+ 'current_rarity': rarity,
+ 'search_query': search_query
})
+
+@login_required
+def vault_autocomplete(request):
+ if not hasattr(request.user, 'buyer_profile'):
+ return JsonResponse({'results': []})
+
+ query = request.GET.get('q', '')
+ if len(query) < 2:
+ return JsonResponse({'results': []})
+
+ # Filter items in user's vault matching the query
+ items = VaultItem.objects.filter(
+ buyer=request.user.buyer_profile,
+ card__name__icontains=query
+ ).values_list('card__name', flat=True).distinct()[:10]
+
+ return JsonResponse({'results': list(items)})
+
+def sell_on_tcgkof_view(request):
+ return render(request, 'users/sell_on_tcgkof.html')
diff --git a/uv.lock b/uv.lock
index 182c067..a953fe8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -11,6 +11,19 @@ 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 = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
[[package]]
name = "black"
version = "26.1.0"
@@ -43,6 +56,18 @@ wheels = [
{ 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 = "bs4"
+version = "0.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -52,6 +77,63 @@ 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 = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -204,6 +286,62 @@ wheels = [
{ 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 = "cryptography"
+version = "46.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
+ { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
+ { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
+ { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
+ { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
+ { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
+ { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
+ { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
+ { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
+ { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
+]
+
[[package]]
name = "django"
version = "6.0.1"
@@ -224,26 +362,32 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "black" },
+ { name = "bs4" },
{ name = "coverage" },
+ { name = "cryptography" },
{ name = "django" },
{ name = "faker" },
{ name = "pillow" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "requests" },
+ { name = "sitemap" },
{ name = "stripe" },
]
[package.metadata]
requires-dist = [
{ name = "black", specifier = ">=26.1.0" },
+ { name = "bs4", specifier = ">=0.0.2" },
{ name = "coverage", specifier = ">=7.13.1" },
+ { name = "cryptography", specifier = ">=46.0.3" },
{ 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 = "sitemap", specifier = ">=20191121" },
{ name = "stripe", specifier = ">=14.2.0" },
]
@@ -391,6 +535,15 @@ 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 = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
[[package]]
name = "pygments"
version = "2.19.2"
@@ -486,6 +639,21 @@ 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 = "sitemap"
+version = "20191121"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/df/4b9c43a9ae042c634d7d19e3a172fc1a88bd139cade8050b3e87d5f3a839/sitemap-20191121.tar.gz", hash = "sha256:00570c0306f697c8786262e105822418bb7bce4826d862991932fad5117002e6", size = 2254, upload-time = "2019-11-20T06:36:21.534Z" }
+
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
[[package]]
name = "sqlparse"
version = "0.5.5"