Last bit of major changes

Closes #1
Closes #5
Closes #6
Closes #8
Closes #9
Closes #10
This commit is contained in:
2026-01-26 04:11:38 -06:00
parent 1cd87156bd
commit 739d136209
24 changed files with 1157 additions and 410 deletions

View File

@@ -6,6 +6,7 @@ from .models import Seller, Game, Set, Card, CardListing, PackListing, VirtualPa
class SellerAdmin(admin.ModelAdmin):
list_display = ['store_name', 'user', 'slug', 'created_at']
search_fields = ['store_name', 'user__username']
readonly_fields = ['tax_id', 'payout_details', 'tax_id_encrypted', 'payout_details_encrypted']
@admin.register(Game)
class GameAdmin(admin.ModelAdmin):

View File

@@ -11,24 +11,52 @@ class SellerThemeForm(forms.ModelForm):
}
class SellerRegistrationForm(forms.ModelForm):
street = forms.CharField(max_length=200, widget=forms.TextInput(attrs={'placeholder': 'Street Address'}))
city = forms.CharField(max_length=100, widget=forms.TextInput(attrs={'placeholder': 'City'}))
state = forms.CharField(max_length=100, widget=forms.TextInput(attrs={'placeholder': 'State'}))
zip_code = forms.CharField(max_length=20, widget=forms.TextInput(attrs={'placeholder': 'Zip Code'}))
class Meta:
model = Seller
fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address']
fields = ['store_name', 'description', 'contact_email', 'contact_phone']
widgets = {
'description': forms.Textarea(attrs={'rows': 4}),
'business_address': forms.Textarea(attrs={'rows': 3}),
}
def save(self, commit=True):
seller = super().save(commit=False)
# Create address
# We need the user to link the address to? The Seller model has user.
# But here seller.user might not be set yet if it's done in the view.
# However, save(commit=True) usually saves.
# We'll create the Address instance but we need the user.
# We can't easily get the user here unless we pass it or it's already on seller.
# In the view `seller_register`, we do `form.instance.user = request.user` before saving?
# Let's check `seller_register` view.
# If we can't create Address yet, we might need to handle it in the view.
# BUT, standard ModelForm save() returns the instance.
# Let's attach the address data to the instance temporarily?
# Or better: Override save to creating the Address.
# We will assume seller.user is set by the view before save is called,
# or we just create the address without user first (but user is required in Address model).
# Actually, Address.user is required.
# So we MUST have the user.
return seller
class SellerEditForm(forms.ModelForm):
street = forms.CharField(max_length=200, required=False)
city = forms.CharField(max_length=100, required=False)
state = forms.CharField(max_length=100, required=False)
zip_code = forms.CharField(max_length=20, required=False)
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']
fields = ['store_name', 'description', 'contact_email', 'contact_phone', '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):
@@ -36,11 +64,50 @@ class SellerEditForm(forms.ModelForm):
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
# Populate address fields
if self.instance.store_address:
self.fields['street'].initial = self.instance.store_address.street
self.fields['city'].initial = self.instance.store_address.city
self.fields['state'].initial = self.instance.store_address.state
self.fields['zip_code'].initial = self.instance.store_address.zip_code
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')
# Handle Address
# We need to update existing or create new.
street = self.cleaned_data.get('street')
city = self.cleaned_data.get('city')
state = self.cleaned_data.get('state')
zip_code = self.cleaned_data.get('zip_code')
if street and city and state and zip_code:
from users.models import Address
if seller.store_address:
seller.store_address.street = street
seller.store_address.city = city
seller.store_address.state = state
seller.store_address.zip_code = zip_code
if commit:
seller.store_address.save()
else:
# Create new address. Requires user.
# seller.user should exist.
address = Address(
user=seller.user,
name=seller.store_name, # Use store name for address name
street=street,
city=city,
state=state,
zip_code=zip_code,
address_type='shipping' # Default
)
if commit:
address.save()
seller.store_address = address
if commit:
seller.save()
return seller
@@ -108,3 +175,15 @@ 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.")
class CheckoutForm(forms.Form):
shipping_address = forms.ModelChoiceField(queryset=None, empty_label="Select Shipping Address", widget=forms.Select(attrs={'class': 'form-select'}))
payment_method = forms.ModelChoiceField(queryset=None, empty_label="Select Payment Method", widget=forms.Select(attrs={'class': 'form-select'}))
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
if user:
from users.models import Address, PaymentMethod
self.fields['shipping_address'].queryset = Address.objects.filter(user=user, address_type='shipping')
self.fields['payment_method'].queryset = PaymentMethod.objects.filter(user=user)

View File

@@ -1,139 +1,201 @@
import random
import requests
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from django.contrib.auth import get_user_model
from store.models import Game, Set, Card, CardListing
from faker import Faker
from store.models import Game, Set, Card, CardListing, Seller
User = get_user_model()
fake = Faker()
class Command(BaseCommand):
help = 'Populate database with MTG data from Scryfall and fake data for other games'
help = 'Populate database with specific test stores and listings, leveraging external scripts for card data.'
def handle(self, *args, **options):
self.stdout.write('Starting database population...')
# Create Games
mtg, _ = Game.objects.get_or_create(name='Magic: The Gathering', slug='mtg')
pokemon, _ = Game.objects.get_or_create(name='Pokemon TCG', slug='pokemon')
lorcana, _ = Game.objects.get_or_create(name='Disney Lorcana', slug='lorcana')
# Populate MTG (Real Data)
self.populate_mtg(mtg)
# Populate Pokemon (Fake Data)
self.populate_fake_game(pokemon, 'Pokemon')
# Populate Lorcana (Fake Data)
self.populate_fake_game(lorcana, 'Lorcana')
# Create Superuser
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
self.stdout.write(self.style.SUCCESS('Created superuser: admin/admin'))
# 1. Ensure Games and Cards exist
self.ensure_games_populated()
# Create Demo Users
self.create_demo_users()
self.stdout.write(self.style.SUCCESS('Database populated successfully!'))
# 2. Get Game Objects
try:
mtg = Game.objects.get(slug='magic-the-gathering')
pokemon = Game.objects.get(slug='pokemon-tcg')
lorcana = Game.objects.get(slug='disney-lorcana')
except Game.DoesNotExist as e:
self.stdout.write(self.style.ERROR(f"Missing a required game after population attempts! {e}"))
return
def populate_mtg(self, game):
self.stdout.write('Fetching MTG data from Scryfall...')
# Get a few sets
# 3. Create Stores
self.create_stores_and_listings(mtg, pokemon, lorcana)
self.stdout.write(self.style.SUCCESS('Database population complete!'))
def ensure_games_populated(self):
# MTG - Native (kept simple from original) or call a script if we had one.
# Since the original had MTG logic, I'll keep a simplified version here or call it if I haven't deleted it.
# But wait, I'm replacing the whole file. I should probably keep the MTG fetcher or rely on what's there?
# The prompt implies I should just "overhaul" it. I'll re-implement a robust MTG fetcher or reuse the old one's logic if I want
# but to be safe and clean, I'll just check if cards exist, if not, fetch some.
# Check MTG
if not Game.objects.filter(slug='magic-the-gathering').exists() or not Card.objects.filter(set__game__slug='magic-the-gathering').exists():
self.stdout.write("Populating MTG Cards...")
self.populate_mtg()
else:
self.stdout.write("MTG cards found, skipping population.")
# Check Pokemon
if not Game.objects.filter(slug='pokemon-tcg').exists() or not Card.objects.filter(set__game__slug='pokemon-tcg').exists():
self.stdout.write("Populating Pokemon Cards via script...")
call_command('populate_pokemon_cards')
else:
self.stdout.write("Pokemon cards found, skipping population.")
# Check Lorcana
if not Game.objects.filter(slug='disney-lorcana').exists() or not Card.objects.filter(set__game__slug='disney-lorcana').exists():
self.stdout.write("Populating Lorcana Cards via script...")
call_command('populate_lorcana_cards')
else:
self.stdout.write("Lorcana cards found, skipping population.")
def populate_mtg(self):
# Simplified reused logic from original
game, _ = Game.objects.get_or_create(
slug='magic-the-gathering',
defaults={'name': 'Magic: The Gathering'}
)
sets_api = "https://api.scryfall.com/sets"
try:
resp = requests.get(sets_api).json()
# Pick top 3 recent expansion sets
target_sets = [s for s in resp['data'] if s['set_type'] == 'expansion'][:3]
for s_data in target_sets:
set_obj, created = Set.objects.get_or_create(
set_obj, _ = Set.objects.get_or_create(
game=game,
name=s_data['name'],
code=s_data['code'],
defaults={'release_date': s_data.get('released_at')}
)
if created:
self.stdout.write(f"Created set: {set_obj.name}")
# Fetch cards for this set
cards_url = s_data['search_uri']
cards_resp = requests.get(cards_url).json()
cards_url = s_data['search_uri']
cards_resp = requests.get(cards_url).json()
for card_data in cards_resp.get('data', [])[:50]:
image = None
if 'image_uris' in card_data:
image = card_data['image_uris'].get('normal')
elif 'card_faces' in card_data and 'image_uris' in card_data['card_faces'][0]:
image = card_data['card_faces'][0]['image_uris'].get('normal')
for card_data in cards_resp.get('data', [])[:20]: # Limit to 20 cards per set to be fast
if 'image_uris' in card_data:
image = card_data['image_uris'].get('normal')
elif 'card_faces' in card_data and 'image_uris' in card_data['card_faces'][0]:
image = card_data['card_faces'][0]['image_uris'].get('normal')
else:
continue
if not image: continue
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'),
}
)
# Create Listings
self.create_listings_for_card(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'),
}
)
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch MTG data: {e}"))
self.stdout.write(self.style.ERROR(f"Error populating MTG: {e}"))
def populate_fake_game(self, game, prefix):
self.stdout.write(f'Generating data for {game.name}...')
for i in range(3): # 3 Sets
set_name = f"{prefix} Set {i+1}"
set_obj, _ = Set.objects.get_or_create(
game=game,
name=set_name,
code=f"{prefix[:3].upper()}{i+1}",
defaults={'release_date': fake.date_between(start_date='-2y', end_date='today')}
def create_stores_and_listings(self, mtg, pokemon, lorcana):
# Define Store Configs
stores_config = [
# Store 1: 100 Magic the gathering single card listings
{
'name': 'Mystic Magic',
'slug': 'mystic-magic',
'listings': [(mtg, 100)]
},
# Store 2: 80 lorcana single card listings
{
'name': 'Inkborn Illumineers',
'slug': 'inkborn-illumineers',
'listings': [(lorcana, 80)]
},
# Store 3: 200 pokemon single card listings
{
'name': 'Poke Mart',
'slug': 'poke-mart',
'listings': [(pokemon, 200)]
},
# Store 4: 50 Magic the gathering and lorcana listings (Split 25/25)
{
'name': 'Wizards and Wanderers',
'slug': 'wizards-and-wanderers',
'listings': [(mtg, 25), (lorcana, 25)]
},
# Store 5: 40 Magic the gathering and 20 pokemon listings
{
'name': 'Mana & Mons',
'slug': 'mana-and-mons',
'listings': [(mtg, 40), (pokemon, 20)]
},
# Store 6: 100 lorcana and 10 pokemon listings
{
'name': 'Disney Duelists',
'slug': 'disney-duelists',
'listings': [(lorcana, 100), (pokemon, 10)]
},
# Store 7: 100 cards for all three games (Split ~33 each)
{
'name': 'The Collector Trove',
'slug': 'collector-trove',
'listings': [(mtg, 33), (lorcana, 33), (pokemon, 34)]
}
]
for config in stores_config:
self.stdout.write(f"Setting up store: {config['name']}...")
# Create User and Seller
username = config['slug'].replace('-', '')
user, created = User.objects.get_or_create(username=username, defaults={'email': f"{username}@example.com"})
if created:
user.set_password('password')
user.save()
seller, _ = Seller.objects.get_or_create(
user=user,
defaults={
'store_name': config['name'],
'slug': config['slug'],
'description': f"Welcome to {config['name']}!",
'contact_email': f"{username}@example.com"
}
)
for j in range(15): # 15 Cards per set
card, _ = Card.objects.get_or_create(
set=set_obj,
name=f"{prefix} Monster {fake.word().capitalize()}",
defaults={
'rarity': random.choice(['Common', 'Uncommon', 'Rare', 'Ultra Rare']),
'image_url': f"https://placehold.co/400x600?text={prefix}+{j}",
'collector_number': str(j+1)
}
)
self.create_listings_for_card(card)
# Create Listings
for game, count in config['listings']:
self.create_listings_for_store(seller, game, count)
def create_listings_for_card(self, card):
# Create 1-5 listings per card with different conditions
for _ in range(random.randint(1, 4)):
def create_listings_for_store(self, seller, game, count):
cards = list(Card.objects.filter(set__game=game))
if not cards:
self.stdout.write(self.style.WARNING(f"No cards found for {game.name}, cannot create listings."))
return
listing_count = 0
while listing_count < count:
card = random.choice(cards)
# Randomize attributes
price = round(random.uniform(1.00, 150.00), 2)
quantity = random.randint(1, 10)
condition = random.choice(['NM', 'LP', 'MP', 'HP'])
price = round(random.uniform(0.50, 100.00), 2)
CardListing.objects.create(
card=card,
condition=condition,
seller=seller,
price=price,
quantity=random.randint(1, 20),
market_price=price, # Simplified
is_foil=random.choice([True, False])
quantity=quantity,
condition=condition,
status='listed'
)
def create_demo_users(self):
# Create a Pro user
if not User.objects.filter(username='prouser').exists():
u = User.objects.create_user('prouser', 'pro@example.com', 'password')
u.profile.is_pro = True
u.profile.save()
self.stdout.write("Created prouser/password")
listing_count += 1
# Create a Basic user
if not User.objects.filter(username='basicuser').exists():
User.objects.create_user('basicuser', 'basic@example.com', 'password')
self.stdout.write("Created basicuser/password")
self.stdout.write(f" - Created {count} listings for {game.name}")

View File

@@ -1,12 +1,11 @@
import requests
import time
import os
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 Pokémon TCG sets and cards using the Pokémon TCG API.'
help = 'Populates the database with Pokémon TCG sets and cards using the TCGDex REST API (English).'
def add_arguments(self, parser):
parser.add_argument(
@@ -15,21 +14,17 @@ class Command(BaseCommand):
help='Clear existing Pokémon TCG cards and sets before populating.'
)
parser.add_argument(
'--duration',
default='7',
help='Duration in days to look back for new sets/cards. Use "all" to fetch everything. Default is 7 days.'
)
parser.add_argument(
'--api-key',
default=os.getenv('POKEMONTCG_API_KEY', None),
help='Optional API Key for higher rate limits.'
'--duration',
default='7',
help='(Not full supported by TCGDex) Duration in days to look back. For now, this will just fetch all sets as TCGDex sets endpoint is not sorted by date.'
)
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Starting Pokémon TCG population...'))
# Setup Headers for API (Rate limits are better with a key)
self.headers = {'X-Api-Key': options['api_key']} if options['api_key'] else {}
self.stdout.write(self.style.SUCCESS('Starting Pokémon TCG population (via TCGDex)...'))
# User Agent is good practice
self.headers = {'User-Agent': 'ExampleTCGSite/1.0'}
base_url = "https://api.tcgdex.net/v2/en"
# 1. Ensure Game exists
game, created = Game.objects.get_or_create(
@@ -48,177 +43,99 @@ class Command(BaseCommand):
Set.objects.filter(game=game).delete()
self.stdout.write(self.style.SUCCESS('Cleared Pokémon data.'))
# Handle --duration
duration = options['duration']
start_date_str = 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)
start_date_str = start_date.strftime('%Y/%m/%d') # API uses YYYY/MM/DD
self.stdout.write(f'Fetching data released since {start_date_str}...')
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 Pokémon TCG API...')
self.stdout.write('Fetching sets from TCGDex...')
# Build query for sets
# If duration is set, we use the Lucene search syntax provided by the API
params = {'orderBy': '-releaseDate', 'pageSize': 250}
if start_date_str:
params['q'] = f'releaseDate:>=("{start_date_str}")'
try:
# Note: /v2/sets does not usually require pagination for < 250 sets if filtering by recent date
# But "all" will require pagination.
sets_data = self.fetch_all_pages('https://api.pokemontcg.io/v2/sets', params)
# TCGDex /sets returns a list of minimal set objects
response = requests.get(f"{base_url}/sets", headers=self.headers)
response.raise_for_status()
sets_data = response.json()
except Exception as e:
self.stdout.write(self.style.ERROR(f'Failed to fetch sets: {e}'))
return
self.stdout.write(self.style.ERROR(f'Failed to fetch sets: {e}'))
return
self.stdout.write(f'Found {len(sets_data)} sets. Processing...')
processed_sets = []
for set_data in sets_data:
release_date = parse_date(set_data.get('releaseDate', '').replace('/', '-'))
# Pokémon sets have an 'id' (e.g., 'swsh1') and 'ptcgoCode' (e.g., 'SSH').
# We use 'id' as the unique code.
set_code = set_data.get('id')
for s_data in sets_data:
# s_data example: {"id": "base1", "name": "Base Set", ...}
# TCGDex sets don't consistently provide releaseDate in the list view,
# so we'll leave it null or updated if we fetched details (which we might do for cards).
# For efficiency we might not fetch set details just for date if unnecessary.
set_obj, created = Set.objects.update_or_create(
code=set_code,
code=s_data.get('id'),
game=game,
defaults={
'name': set_data.get('name'),
'release_date': release_date,
'name': s_data.get('name'),
# 'release_date': None # Not available in simple list
}
)
processed_sets.append(set_obj)
self.stdout.write(self.style.SUCCESS(f'Processed {len(processed_sets)} sets.'))
# 3. Fetch Cards
# Strategy: To be efficient, if we have a specific duration, we query cards by date.
# If we are doing "all", we iterate through the sets we just found (or all sets) to ensure we get everything structured.
self.stdout.write('Fetching cards...')
card_params = {'pageSize': 250}
# We must iterate sets to get cards, as there isn't a robust "all cards new" stream without pagination headaches
# on some APIs, and TCGDex structure favors set traversal.
if start_date_str:
# Fetch all cards released after date (cross-set)
card_params['q'] = f'set.releaseDate:>=("{start_date_str}")'
self.fetch_and_process_cards(card_params, game)
else:
# Fetch by set to allow for better progress tracking/chunking if doing a full import
total_sets = len(processed_sets)
for idx, set_obj in enumerate(processed_sets):
self.stdout.write(f' [{idx+1}/{total_sets}] Fetching cards for set: {set_obj.name} ({set_obj.code})...')
total_sets = len(processed_sets)
for idx, set_obj in enumerate(processed_sets):
self.stdout.write(f' [{idx+1}/{total_sets}] Fetching cards for set: {set_obj.name} ({set_obj.code})...')
try:
# Fetch Set Detail to get cards
# Endpoint: /sets/{id}
set_resp = requests.get(f"{base_url}/sets/{set_obj.code}", headers=self.headers)
if set_resp.status_code == 404:
self.stdout.write(self.style.WARNING(f' Set {set_obj.code} detail not found. Skipping.'))
continue
set_resp.raise_for_status()
# Filter by specific set ID
set_card_params = {'pageSize': 250, 'q': f'set.id:{set_obj.code}'}
self.fetch_and_process_cards(set_card_params, game, specific_set=set_obj)
set_detail = set_resp.json()
cards = set_detail.get('cards', [])
# Sleep briefly to respect rate limits (60/min without key, 1000/min with key)
time.sleep(0.5 if options['api_key'] else 1.5)
except Exception as e:
self.stdout.write(self.style.ERROR(f' Failed to fetch cards for {set_obj.name}: {e}'))
continue
self.stdout.write(f' Found {len(cards)} cards.')
for c_data in cards:
# c_data example: {"id": "base1-1", "localId": "1", "name": "Alakazam", "image": "..."}
# Rarity is NOT in this list usually, requires fetching card detail. Skipping for speed.
# Image URL: TCGDex gives a base URL usually, e.g. ".../base1/1"
# Sometimes it has /high.png or /low.png supported. The provided 'image' field often works as is.
# It might have extension like .png or just be the base.
# The user-provided example curl showed "image": "https://assets.tcgdex.net/en/base/base1/1"
# Those usually redirect to an image or handle extension. Let's append /high.png if we want best quality or try as is.
# Actually, TCGDex assets usually need an extension. Let's assume the API provides a valid URL or we append.
# Inspecting typical TCGDex response: "image": ".../1" (no extension).
# Browsers handle it, but for our backend saving it might be tricky if it's not a direct file.
# Let's save the URL as provided + "/high.png" as a guess for better quality if it doesn't have extension,
# Or just use the provided one.
# Update: TCGDex documentation often says: {image}/high.webp or {image}/low.webp
base_image = c_data.get('image')
image_url = f"{base_image}/high.webp" if base_image else ''
Card.objects.update_or_create(
scryfall_id=c_data.get('id'),
defaults={
'set': set_obj,
'name': c_data.get('name'),
'rarity': '', # specific call needed, simplifying
'image_url': image_url,
'collector_number': c_data.get('localId', ''),
'external_url': f"https://tcgdex.dev/cards/{c_data.get('id')}", # simplified assumption
}
)
# Rate limiting check - TCGDex is generous but good validation to not slam
# time.sleep(0.1)
self.stdout.write(self.style.SUCCESS('Finished Pokémon TCG population!'))
def fetch_all_pages(self, url, params):
"""Helper to handle API pagination"""
results = []
page = 1
has_more = True
while has_more:
params['page'] = page
response = requests.get(url, params=params, headers=self.headers)
if response.status_code == 429:
self.stdout.write(self.style.WARNING('Rate limit hit. Sleeping for 10 seconds...'))
time.sleep(10)
continue
if response.status_code != 200:
raise Exception(f"API Error {response.status_code}: {response.text}")
data = response.json()
batch = data.get('data', [])
results.extend(batch)
# Check if we need more pages
total_count = data.get('totalCount', 0)
count = data.get('count', 0)
if len(results) >= total_count or count == 0:
has_more = False
else:
page += 1
return results
def fetch_and_process_cards(self, params, game, specific_set=None):
try:
cards_data = self.fetch_all_pages('https://api.pokemontcg.io/v2/cards', params)
except Exception as e:
self.stdout.write(self.style.ERROR(f' Failed to fetch cards: {e}'))
return
self.stdout.write(f' Processing {len(cards_data)} cards...')
# Cache sets if we are doing a bulk mixed query
sets_map = {}
if not specific_set:
sets_map = {s.code: s for s in Set.objects.filter(game=game)}
for card_data in cards_data:
# Determine Set
if specific_set:
set_obj = specific_set
else:
set_code = card_data.get('set', {}).get('id')
if set_code in sets_map:
set_obj = sets_map[set_code]
else:
# If set missing (rare if we synced sets first), try to fetch/create or skip
# For speed, we skip if not found in our pre-fetched map
continue
# Extract Image URL (Prefer Hi-Res)
image_url = ''
if 'images' in card_data:
image_url = card_data['images'].get('large', card_data['images'].get('small', ''))
# TCGPlayer ID (Sometimes provided in tcgplayer field)
tcgplayer_url = card_data.get('tcgplayer', {}).get('url', '')
# Extract ID from URL if possible, or store URL.
# Model expects 'tcgplayer_id' (integer usually).
# The API doesn't always give a clean ID field, often just the URL.
# We will try to parse or leave null if your model requires int.
# Assuming model handles null or we just store nothing.
tcgplayer_id = None
# External URL
external_url = tcgplayer_url if tcgplayer_url else f"https://pkmncards.com/card/{card_data.get('id')}"
# Collector Number
collector_number = card_data.get('number', '')
Card.objects.update_or_create(
scryfall_id=card_data.get('id'), # Using API ID as unique identifier
defaults={
'set': set_obj,
'name': card_data.get('name'),
'rarity': card_data.get('rarity', 'Common'),
'image_url': image_url,
'tcgplayer_id': tcgplayer_id, # Can be updated if you add parsing logic
'collector_number': collector_number,
'external_url': external_url,
}
)

View File

@@ -0,0 +1,24 @@
# Generated by Django 6.0.1 on 2026-01-25 14:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0016_seller_payout_details_encrypted_and_more'),
('users', '0002_paymentmethod_billing_address_and_more'),
]
operations = [
migrations.RemoveField(
model_name='seller',
name='business_address',
),
migrations.AddField(
model_name='seller',
name='store_address',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='seller_store_address', to='users.address'),
),
]

View File

@@ -28,7 +28,8 @@ class Seller(models.Model):
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)
# business_address = models.TextField(blank=True) # Deprecated
store_address = models.ForeignKey('users.Address', on_delete=models.SET_NULL, null=True, blank=True, related_name='seller_store_address')
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)

View File

@@ -0,0 +1,74 @@
{% extends 'base/layout.html' %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6 text-white">Checkout</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Order Summary -->
<div class="bg-gray-800 p-6 rounded-lg shadow-lg">
<h2 class="text-xl font-bold mb-4 text-white">Order Summary</h2>
<p class="text-gray-300 text-lg mb-2">Total Items: <span class="font-semibold text-white">{{ cart.items.count }}</span></p>
<p class="text-gray-300 text-2xl font-bold mb-4">Total: ${{ cart.total_price }}</p>
<div class="mt-4 pt-4 border-t border-gray-700">
<a href="{% url 'store:cart' %}" class="text-blue-400 hover:text-blue-300 transition-colors">← Back to Cart</a>
</div>
</div>
<!-- Checkout Form -->
<div class="bg-gray-800 p-6 rounded-lg shadow-lg">
<h2 class="text-xl font-bold mb-4 text-white">Shipping & Payment</h2>
{% if not form.fields.shipping_address.queryset.exists or not form.fields.payment_method.queryset.exists %}
<div class="bg-yellow-900/50 border border-yellow-600 text-yellow-200 p-4 rounded mb-4">
<p class="font-bold">Missing Information</p>
<p class="text-sm mt-1">Please add a shipping address and payment method to your profile before checking out.</p>
<div class="mt-3">
<a href="{% url 'users:profile' %}" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded text-sm inline-block">Update Profile</a>
</div>
</div>
{% endif %}
<form method="post" class="space-y-6">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="bg-red-500/80 text-white p-3 rounded">{{ form.non_field_errors }}</div>
{% endif %}
<div class="space-y-4">
{% for field in form %}
<div class="flex flex-col">
<label for="{{ field.id_for_label }}" class="text-gray-300 mb-1 font-medium">{{ field.label }}</label>
{{ field }}
{% if field.errors %}<p class="text-red-400 text-sm mt-1">{{ field.errors.0 }}</p>{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="w-full mt-2 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded transition duration-200 shadow-lg transform active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
{% if not form.fields.shipping_address.queryset.exists or not form.fields.payment_method.queryset.exists %}disabled{% endif %}>
Place Order (${{ cart.total_price }})
</button>
</form>
</div>
</div>
</div>
<style>
/* basic styling for form inputs */
select {
background-color: var(--input-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.75rem;
border-radius: 0.375rem;
width: 100%;
}
select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
</style>
{% endblock %}

View File

@@ -2,6 +2,20 @@
{% block content %}
<div class="card-grid" style="grid-template-columns: 1fr 3fr; gap: 2rem; align-items: start;">
{% if not seller.tax_id or not seller.payout_details %}
<div style="grid-column: 1 / -1; background-color: #fee2e2; border: 1px solid #ef4444; color: #b91c1c; padding: 1rem; border-radius: 0.5rem; display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span><strong>Profile Not Complete:</strong> You must provide your Tax ID and Payout Details to start selling.</span>
</div>
<a href="{% url 'store:edit_seller_profile' %}" class="btn" style="background-color: #ef4444; color: white; border: none; padding: 0.5rem 1rem;">Complete Profile</a>
</div>
{% endif %}
<!-- Sidebar: Store Info -->
<div style="background: var(--card-bg); padding: 2rem; border-radius: 0.5rem; border: 1px solid var(--border-color); position: sticky; top: calc(var(--nav-height) + 2rem);">
<h2 style="margin-top: 0;">{{ seller.store_name }}</h2>

View File

@@ -66,9 +66,26 @@
</div>
</div>
<div class="form-group" style="margin-top: 1.5rem;">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Business Address</label>
{{ form.business_address }}
<div style="margin-top: 1.5rem;">
<h4 style="margin-top: 0; margin-bottom: 0.5rem; color: #94a3b8; font-size: 1rem;">Store Address</h4>
<div class="form-group" style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Street</label>
{{ form.street }}
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">City</label>
{{ form.city }}
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">State</label>
{{ form.state }}
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Zip Code</label>
{{ form.zip_code }}
</div>
</div>
</div>
</div>
@@ -95,6 +112,34 @@
</div>
</div>
</div>
<!-- Financial Information -->
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem; margin-top: 0.5rem;">
<h3 style="margin-top: 0; margin-bottom: 1rem; font-size: 1.125rem;">Financial Information <span style="font-size: 0.75rem; color: #ef4444; font-weight: normal;">(Required to sell)</span></h3>
<div style="background: rgba(59, 130, 246, 0.1); border: 1px solid #3b82f6; color: #93c5fd; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem; font-size: 0.875rem;">
<p style="margin: 0;"><strong>Secure Storage:</strong> Your Tax ID and Payout Details are encrypted before storage. We use industry-standard encryption to protect your sensitive data.</p>
</div>
<div style="display: grid; gap: 1.5rem;">
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Tax ID (SSN/EIN/ITIN)</label>
{{ form.tax_id }}
{% if form.tax_id.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.tax_id.errors }}</div>
{% endif %}
<small style="display: block; margin-top: 0.25rem; color: #64748b; font-size: 0.75rem;">{{ form.tax_id.help_text }}</small>
</div>
<div class="form-group">
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Payout Details</label>
{{ form.payout_details }}
{% if form.payout_details.errors %}
<div style="color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;">{{ form.payout_details.errors }}</div>
{% endif %}
<small style="display: block; margin-top: 0.25rem; color: #64748b; font-size: 0.75rem;">{{ form.payout_details.help_text }}</small>
</div>
</div>
</div>
</div>
<div style="margin-top: 2rem; display: flex; gap: 1rem;">

View File

@@ -30,15 +30,60 @@
</div>
{% endif %}
{% for field in form %}
<!-- Store Details -->
<div class="space-y-4">
<div class="flex flex-col">
<label for="{{ field.id_for_label }}" class="text-gray-300 mb-1">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<p class="text-red-400 text-sm mt-1">{{ field.errors.0 }}</p>
{% endif %}
<label for="{{ form.store_name.id_for_label }}" class="text-gray-300 mb-1">{{ form.store_name.label }}</label>
{{ form.store_name }}
{% if form.store_name.errors %}<p class="text-red-400 text-sm mt-1">{{ form.store_name.errors.0 }}</p>{% endif %}
</div>
{% endfor %}
<div class="flex flex-col">
<label for="{{ form.description.id_for_label }}" class="text-gray-300 mb-1">{{ form.description.label }}</label>
{{ form.description }}
{% if form.description.errors %}<p class="text-red-400 text-sm mt-1">{{ form.description.errors.0 }}</p>{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col">
<label for="{{ form.contact_email.id_for_label }}" class="text-gray-300 mb-1">{{ form.contact_email.label }}</label>
{{ form.contact_email }}
{% if form.contact_email.errors %}<p class="text-red-400 text-sm mt-1">{{ form.contact_email.errors.0 }}</p>{% endif %}
</div>
<div class="flex flex-col">
<label for="{{ form.contact_phone.id_for_label }}" class="text-gray-300 mb-1">{{ form.contact_phone.label }}</label>
{{ form.contact_phone }}
{% if form.contact_phone.errors %}<p class="text-red-400 text-sm mt-1">{{ form.contact_phone.errors.0 }}</p>{% endif %}
</div>
</div>
</div>
<!-- Store Address -->
<div class="mt-6">
<h3 class="text-xl font-semibold text-white mb-4">Store Address</h3>
<div class="space-y-4">
<div class="flex flex-col">
<label for="{{ form.street.id_for_label }}" class="text-gray-300 mb-1">Street Address</label>
{{ form.street }}
{% if form.street.errors %}<p class="text-red-400 text-sm mt-1">{{ form.street.errors.0 }}</p>{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex flex-col">
<label for="{{ form.city.id_for_label }}" class="text-gray-300 mb-1">City</label>
{{ form.city }}
{% if form.city.errors %}<p class="text-red-400 text-sm mt-1">{{ form.city.errors.0 }}</p>{% endif %}
</div>
<div class="flex flex-col">
<label for="{{ form.state.id_for_label }}" class="text-gray-300 mb-1">State</label>
{{ form.state }}
{% if form.state.errors %}<p class="text-red-400 text-sm mt-1">{{ form.state.errors.0 }}</p>{% endif %}
</div>
<div class="flex flex-col">
<label for="{{ form.zip_code.id_for_label }}" class="text-gray-300 mb-1">Zip Code</label>
{{ form.zip_code }}
{% if form.zip_code.errors %}<p class="text-red-400 text-sm mt-1">{{ form.zip_code.errors.0 }}</p>{% endif %}
</div>
</div>
</div>
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition duration-200">
Register Store

View File

@@ -0,0 +1,90 @@
from django.test import TestCase, Client
from django.urls import reverse
from users.models import User, Address, PaymentMethod
from store.models import Seller, CardListing, Card, Set, Game, Cart, Order, OrderItem
from django.utils.text import slugify
class CheckoutFlowTest(TestCase):
def setUp(self):
self.client = Client()
# Create Buyer
self.user = User.objects.create_user(username='buyer', password='password')
self.client.login(username='buyer', password='password')
# Create Address
self.address = Address.objects.create(
user=self.user,
name='Buyer Name',
street='123 Initial St',
city='New York',
state='NY',
zip_code='10001',
address_type='shipping'
)
# Create Payment Method
self.pm = PaymentMethod.objects.create(
user=self.user,
brand='Visa',
last4='4242', # Mock
exp_month=12,
exp_year=2030,
billing_address=self.address
)
self.pm.card_number = '4242424242424242' # Encrypts
self.pm.save()
# Create Seller and items
self.seller_user = User.objects.create_user(username='seller', password='password')
self.seller = Seller.objects.create(
user=self.seller_user,
store_name='Test Store',
slug='test-store',
minimum_order_amount=200,
shipping_cost=5
)
self.game = Game.objects.create(name='Magic', slug='magic')
self.set = Set.objects.create(game=self.game, name='Alpha')
self.card = Card.objects.create(set=self.set, name='Black Lotus')
self.listing = CardListing.objects.create(
card=self.card,
seller=self.seller,
price=100.00,
quantity=1,
status='listed'
)
def test_checkout_process(self):
# Add to cart (requires manual cart creation or view call, let's create cart manually for speed)
from users.models import Buyer
buyer, _ = Buyer.objects.get_or_create(user=self.user)
cart = Cart.objects.create(buyer=buyer)
from store.models import CartItem
CartItem.objects.create(cart=cart, listing=self.listing, quantity=1)
# Get checkout page
response = self.client.get(reverse('store:checkout'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Checkout')
self.assertContains(response, 'Total: $100.00')
# Post checkout
data = {
'shipping_address': self.address.id,
'payment_method': self.pm.id
}
response = self.client.post(reverse('store:checkout'), data)
self.assertEqual(response.status_code, 302) # Redirect to vault
# Verify Order
order = Order.objects.filter(buyer=buyer).first()
self.assertIsNotNone(order)
self.assertEqual(order.status, 'paid')
self.assertIn('123 Initial St', order.shipping_address)
self.assertEqual(order.total_price, 105.00) # 100 + 5 shipping
# Verify Stock
self.listing.refresh_from_db()
self.assertEqual(self.listing.quantity, 0)

View File

@@ -269,3 +269,48 @@ class CardListStockTests(TestCase):
response = self.client.get(url, {'hide_out_of_stock': 'off'})
# Should be 5 + 3 = 8
self.assertEqual(response.context['page_obj'][0].total_quantity, 8)
class SellerProfileRestrictionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='restricted_seller', password='password')
self.seller = Seller.objects.create(
user=self.user,
store_name='Restricted Store',
slug='restricted-store'
# tax_id and payout_details are initially empty
)
self.client = Client()
self.client.force_login(self.user)
def test_add_listing_incomplete_profile(self):
url = reverse('store:add_card_listing')
response = self.client.get(url)
# Should redirect to edit profile
self.assertRedirects(response, reverse('store:edit_seller_profile'))
# Verify message (this requires session support in test client which is default)
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertIn("must complete your seller profile", str(messages[0]))
def test_add_bounty_incomplete_profile(self):
# Ensure feature flag is on if needed, or assume True based on settings
# The view checks FEATURE_BOUNTY_BOARD, so we might need override_settings if it defaults to False
# But settings.DEBUG is True in current env, so it should be on.
url = reverse('store:bounty_create')
response = self.client.get(url)
self.assertRedirects(response, reverse('store:edit_seller_profile'))
def test_complete_profile_allows_access(self):
# Update profile
self.seller.tax_id = '123'
self.seller.payout_details = 'Bank'
self.seller.save()
url = reverse('store:add_card_listing')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
url = reverse('store:bounty_create')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View File

@@ -1,4 +1,5 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.conf import settings
from django.db.models import Q
from django.core.paginator import Paginator
@@ -6,7 +7,7 @@ from django.contrib.auth.decorators import login_required
from .models import Card, Game, Set, Cart, CartItem, CardListing, PackListing, VirtualPack, Order, OrderItem, Seller, Bounty, BountyOffer, SellerReport
from django.db.models import Sum, Value
from django.db.models.functions import Coalesce, Length
from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm
from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm, CheckoutForm
from django.utils.text import slugify
import random
import csv
@@ -367,6 +368,11 @@ def bounty_create(request):
return redirect('store:seller_register')
seller = request.user.seller_profile
# Check for profile completion
if not seller.tax_id or not seller.payout_details:
messages.error(request, "You must complete your seller profile (Tax ID and Payout Details) before posting a bounty.")
return redirect('store:edit_seller_profile')
if request.method == 'POST':
form = BountyForm(request.POST)
@@ -512,109 +518,116 @@ def checkout(request):
if not cart.items.exists():
return redirect('store:cart')
# Group items by seller and check for virtual packs
items_by_seller = {}
has_virtual_packs = False
for item in cart.items.all():
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 request.method == 'POST':
form = CheckoutForm(request.POST, user=request.user)
if form.is_valid():
shipping_address = form.cleaned_data['shipping_address']
if seller not in items_by_seller:
items_by_seller[seller] = []
items_by_seller[seller].append(item)
# Group items by seller and check for virtual packs
items_by_seller = {}
has_virtual_packs = False
for item in cart.items.all():
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
# Format Address Snapshot
addr_str = f"{shipping_address.name}\n{shipping_address.street}\n{shipping_address.city}, {shipping_address.state} {shipping_address.zip_code}"
# Create Order (status paid for MVP)
order = Order.objects.create(
buyer=request.user.buyer_profile,
status='paid',
total_price=total_price,
seller=seller,
shipping_address=addr_str
)
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
)
# 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:
item.listing.quantity = 0
item.listing.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)
if len(available_packs) < item.quantity:
if item.pack_listing.seller:
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:
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()
if has_virtual_packs:
return redirect('store:my_packs')
return redirect('users:vault')
else:
form = CheckoutForm(user=request.user)
# 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
)
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
)
# 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()
# 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()
if has_virtual_packs:
return redirect('store:my_packs')
return redirect('users:vault')
return render(request, 'store/checkout.html', {'form': form, 'cart': cart})
@login_required
def my_packs(request):
@@ -699,6 +712,27 @@ def seller_register(request):
seller.user = request.user
seller.slug = slugify(seller.store_name)
seller.save()
# Create Address and link to seller
from users.models import Address
street = seller_form.cleaned_data.get('street')
city = seller_form.cleaned_data.get('city')
state = seller_form.cleaned_data.get('state')
zip_code = seller_form.cleaned_data.get('zip_code')
if street and city:
address = Address.objects.create(
user=request.user,
name=seller.store_name,
street=street,
city=city,
state=state,
zip_code=zip_code,
address_type='shipping'
)
seller.store_address = address
seller.save()
return redirect('store:seller_dashboard')
else:
user_form = CustomUserCreationForm(request.POST)
@@ -712,6 +746,27 @@ def seller_register(request):
seller.user = user
seller.slug = slugify(seller.store_name)
seller.save()
# Create Address and link to seller
from users.models import Address
street = seller_form.cleaned_data.get('street')
city = seller_form.cleaned_data.get('city')
state = seller_form.cleaned_data.get('state')
zip_code = seller_form.cleaned_data.get('zip_code')
if street and city:
address = Address.objects.create(
user=user,
name=seller.store_name,
street=street,
city=city,
state=state,
zip_code=zip_code,
address_type='shipping'
)
seller.store_address = address
seller.save()
return redirect('store:seller_dashboard')
else:
if request.user.is_authenticated:
@@ -735,10 +790,9 @@ def edit_seller_profile(request):
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()
form.instance.slug = slugify(form.cleaned_data['store_name'])
form.save()
return redirect('store:seller_profile', slug=seller.slug)
else:
form = SellerEditForm(instance=seller)
@@ -1057,7 +1111,7 @@ import csv # Added import
import io # Added import
# ... existing imports ...
from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm
from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm, CheckoutForm
# ... [Keep existing code until add_card_listing] ...
@@ -1093,6 +1147,11 @@ def add_card_listing(request):
except Seller.DoesNotExist:
return redirect('store:seller_register')
# Check for profile completion
if not seller.tax_id or not seller.payout_details:
messages.error(request, "You must complete your seller profile (Tax ID and Payout Details) before listing items.")
return redirect('store:edit_seller_profile')
bulk_form = BulkListingForm() # Initialize bulk form
if request.method == 'POST':
@@ -1244,6 +1303,11 @@ def add_pack_listing(request):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:manage_listings')
seller = request.user.seller_profile
# Check for profile completion
if not seller.tax_id or not seller.payout_details:
messages.error(request, "You must complete your seller profile (Tax ID and Payout Details) before listing items.")
return redirect('store:edit_seller_profile')
bulk_form = BulkListingForm()