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. +

+ + +
+ + +
+ + +
+
+ {% csrf_token %} + {% for field in form %} +
+ + + {{ field }} + + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% if field.errors %} +
+ {{ field.errors.as_text }} +
+ {% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+ + + +
+
+ + + + +{% 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. +

+ + +
+ + +
+ + +
+
+ {% csrf_token %} + {% for field in form %} +
+ + + {{ field }} + + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% if field.errors %} +
+ {{ field.errors.as_text }} +
+ {% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+ + + +
+
+ + + + +{% 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 }}

+
+ +
+ + + + + + + + + + {% for row in seller_data %} + + + + + + {% endfor %} + +
SellerTotal SalesPlatform Fees
+ {{ row.seller.store_name }}
+ {{ row.seller.user.email }} +
${{ row.total_revenue }}${{ row.platform_fees }}
+
+
+ + +{% 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' %} +
+ Accept + Reject +
+ {% 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

+
+ {% csrf_token %} +
+ + {{ offer_form.price }} + +
+
+ + {{ offer_form.description }} +
+ +
+ {% 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 %} +
+
+

{{ title }}

+ +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} + {% if field.errors %} +
+ {{ field.errors }} +
+ {% endif %} +
+ {% endfor %} + +
+ + Cancel + + +
+
+
+
+ + + + +{% 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 %} +
+ + Post Bounty +
+ {% endif %} +
+ + {% if bounties %} +
+ {% for bounty in bounties %} +
+
+
+ + {% if bounty.card.image_url %} + {{ bounty.card.name }} + {% else %} +
📝
+ {% endif %} + + + +
+ +
+
+

Buying For

+

${{ bounty.target_price }}

+
+
+

Needed

+

{{ bounty.quantity_wanted }}

+
+
+ +
+

+ {{ bounty.description|default:"No details." }} +

+
+
+ +
+ View 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 }}

+ + +
+ + +
+ +
+ {% csrf_token %} + + {% if cards %} +
+

Select Cards to Include

+
+ {% for card in cards %} +
+ + +
+ {% endfor %} +
+

Select multiple cards to create a single pack containing all selected items.

+
+ +
+ + Cancel +
+ + {% elif query %} +

No cards found matching "{{ query }}"

+ {% endif %} +
+
+{% 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 }}

+ +
+ {% csrf_token %} + + Cancel + + +
+
+
+{% 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

+
+ {% csrf_token %} + {{ theme_form.theme_preference }} + +
+
+
+
+ + +
+ + +
+
+

Store Views

+

{{ store_views }}

+
+
+

Listing Clicks

+

{{ listing_clicks }}

+
+
+

Store Link

+
+ Store QR Code +
+ /store/{{ seller.slug }} +

Share this QR code

+
+
+
+
+ + +
+
+

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 %} +

+
+
+ + +
+ + +
+

Analytics

+
+ + + {% if selected_game %}Clear{% endif %} +
+
+ + +
+
+

Sales & Revenue

+
+ + + +
+
+ +
+ + +
+
+
+ + + +
+
+ +
+ +
+ + +
+
+
+ + + + + + +
+{% 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. +

+ +
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + + {% for field in form %} +
+ + + {{ field }} + + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% if field.errors %} +
+ {{ field.errors.as_text }} +
+ {% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+
+ + +{% 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

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+ +
+ + {{ form.store_name }} + {% if form.store_name.errors %} +
{{ form.store_name.errors }}
+ {% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ + +
+
+ + {{ form.store_image }} + {% if form.store_image.errors %} +
{{ form.store_image.errors }}
+ {% endif %} +
+ +
+ + {{ form.hero_image }} + {% if form.hero_image.errors %} +
{{ form.hero_image.errors }}
+ {% endif %} +
+
+ + +
+

Contact Information

+ +
+
+ + {{ form.contact_email }} +
+
+ + {{ form.contact_phone }} +
+
+ +
+ + {{ form.business_address }} +
+
+ + +
+

Shipping Settings

+ +
+
+ + {{ form.minimum_order_amount }} + {% if form.minimum_order_amount.errors %} +
{{ form.minimum_order_amount.errors }}
+ {% endif %} + Set to 0 if shipping is never free. +
+
+ + {{ form.shipping_cost }} + {% if form.shipping_cost.errors %} +
{{ form.shipping_cost.errors }}
+ {% endif %} + Standard cost for orders below minimum. +
+
+
+
+ +
+ + Cancel +
+
+
+
+ + +{% 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

+
+
+ + Add Card + {% if FEATURE_VIRTUAL_PACKS %} + + Add Pack + {% endif %} + + Post Bounty + Dashboard +
+
+ + +
+ +
+
+

Card Listings ({{ card_listings|length }})

+
+ + {% if card_listings %} +
+ {% for listing in card_listings %} +
+
+
+ {% if listing.image %} + {{ listing.card.name }} + {% elif listing.card.image_url %} + {{ listing.card.name }} + {% else %} +
N/A
+ {% endif %} +
+

{{ listing.card.name }}

+

{{ listing.card.set.name }}

+ + {{ listing.get_condition_display }} + +
+
+ +
+
+

Price

+

${{ listing.price }}

+
+
+

Stock

+

{{ listing.quantity }}

+
+
+
+
+ Edit + Delete +
+
+ {% endfor %} +
+ {% else %} +
+

No card listings found.

+ Add your first card +
+ {% endif %} +
+ + + {% if FEATURE_VIRTUAL_PACKS %} +
+
+

Pack Listings ({{ pack_listings|length }})

+
+ + {% if pack_listings %} +
+ {% for listing in pack_listings %} +
+
+
+ {% if listing.image_url %} + {{ listing.name }} + {% else %} +
📦
+ {% endif %} +
+

{{ listing.name }}

+

{{ listing.game.name }}

+
+
+ +
+
+

Price

+

${{ listing.price }}

+
+
+

Stock

+

{{ listing.quantity }}

+
+
+
+
+ {% if listing.listing_type == 'virtual' %} + Manage Inventory + {% endif %} + + Edit + Delete +
+
+ {% endfor %} +
+ {% else %} +
+

No pack listings found.

+ Add your first pack +
+ {% 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 }}

+
+
+ + Add Pack Instance + Back to Listings +
+
+ +
+

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 %} +
+ {{ seller.store_name }} Banner +
+ +
+
+{% endif %} + +
+ +
+
+ + {% if seller.store_image %} + {{ seller.store_name }} + {% 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 %} +
+ + +
+ +
+
+ + +
+

Active Listings

+ + +
+
+
+ +
+ + + +
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ + Clear +
+
+
+ +
+ {% for listing in card_listings %} +
+ {% if listing.card.image_url %} + {{ listing.card.name }} + {% else %} +
No Image
+ {% endif %} +
+

{{ listing.card.name }}

+

{{ listing.card.set.name }}

+ +
+ ${{ listing.price }} + {{ listing.get_condition_display }} +
+ +
+ {% csrf_token %} + +
+
+
+ {% endfor %} + + {% for listing in pack_listings %} +
+
+ PACK +
+
+

{{ listing.name }}

+

{{ listing.game.name }}

+ +
+ ${{ listing.price }} +
+
+ {% csrf_token %} + +
+
+
+ {% 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.

+
+
+
+ + + + + + + + +{% 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 }} + {% 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 }} + {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} +
+ {% endfor %} + + +
+
+
+ + +{% 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 %} @@ -278,9 +133,27 @@ {% endblock %} -