Last bit of major changes
Closes #1 Closes #5 Closes #6 Closes #8 Closes #9 Closes #10
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
74
store/templates/store/checkout.html
Normal file
74
store/templates/store/checkout.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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
|
||||
|
||||
90
store/tests/test_checkout_flow.py
Normal file
90
store/tests/test_checkout_flow.py
Normal 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)
|
||||
@@ -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)
|
||||
276
store/views.py
276
store/views.py
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user