Compare commits

..

2 Commits

Author SHA1 Message Date
28bfa8c631 adding proxy code 2026-01-26 04:18:46 -06:00
739d136209 Last bit of major changes
Closes #1
Closes #5
Closes #6
Closes #8
Closes #9
Closes #10
2026-01-26 04:11:38 -06:00
33 changed files with 1210 additions and 410 deletions

View File

@@ -26,7 +26,7 @@ SECRET_KEY = 'django-insecure-y-a6jb6gv)dd!32_c!8rn70cnn%8&_$nc=m)0#4d=%b65%g(0*
DEBUG = True
FEATURE_BOUNTY_BOARD = DEBUG
FEATURE_DEMO_SITE = True
FEATURE_PLAYTEST_PROXY = DEBUG
FEATURE_PLAYTEST_PROXY = False
FEATURE_VIRTUAL_PACKS = DEBUG
ALLOWED_HOSTS = ['testserver', 'localhost', '127.0.0.1']
@@ -45,6 +45,7 @@ INSTALLED_APPS = [
'users',
'store',
'decks',
'proxy',
]
AUTH_USER_MODEL = 'users.User'

View File

@@ -9,6 +9,7 @@ urlpatterns = [
path('', include('store.urls')), # Store is the home app
path('home', RedirectView.as_view(url='/', permanent=True), name='home'), # Redirect /home to /
path('decks/', include('decks.urls')),
path('proxy/', include('proxy.urls')),
# SEO
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),

0
proxy/__init__.py Normal file
View File

3
proxy/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
proxy/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ProxyConfig(AppConfig):
name = "proxy"

View File

3
proxy/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,20 @@
{% extends "base/layout.html" %}
{% block title %}Proxy Service - TCGKof{% endblock %}
{% block content %}
<div class="container" style="padding: 2rem 0;">
<h1>Proxy Card Service</h1>
<p>We offer high-quality proxy cards for playtesting purposes.</p>
<p>This service allows you to test out decks before committing to buying the real cards.</p>
<div style="margin-top: 2rem; padding: 1rem; background-color: var(--card-bg); border-radius: 8px;">
<h3>How it works</h3>
<ol style="margin-left: 1.5rem; margin-top: 1rem;">
<li>Browse our catalog or upload your deck list</li>
<li>Select the cards you want to proxy</li>
<li>Checkout and receive your cards</li>
</ol>
</div>
</div>
{% endblock %}

3
proxy/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
proxy/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = 'proxy'
urlpatterns = [
path('info/', views.proxy_info, name='info'),
]

11
proxy/views.py Normal file
View File

@@ -0,0 +1,11 @@
from django.shortcuts import render
from django.conf import settings
from django.http import Http404
def proxy_info(request):
if not getattr(settings, 'FEATURE_PLAYTEST_PROXY', False):
raise Http404("Proxy service is not available")
return render(request, 'proxy/info.html', {
'title': 'Proxy Service'
})

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')
# 1. Ensure Games and Cards exist
self.ensure_games_populated()
# Populate MTG (Real Data)
self.populate_mtg(mtg)
# 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
# Populate Pokemon (Fake Data)
self.populate_fake_game(pokemon, 'Pokemon')
# 3. Create Stores
self.create_stores_and_listings(mtg, pokemon, lorcana)
# Populate Lorcana (Fake Data)
self.populate_fake_game(lorcana, 'Lorcana')
self.stdout.write(self.style.SUCCESS('Database population complete!'))
# 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'))
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.
# Create Demo Users
self.create_demo_users()
# 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.")
self.stdout.write(self.style.SUCCESS('Database populated successfully!'))
# 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.")
def populate_mtg(self, game):
self.stdout.write('Fetching MTG data from Scryfall...')
# Get a few sets
# 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')}
)
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')
if created:
self.stdout.write(f"Created set: {set_obj.name}")
# Fetch cards for this set
cards_url = s_data['search_uri']
cards_resp = requests.get(cards_url).json()
for card_data in cards_resp.get('data', [])[:20]: # Limit to 20 cards per set to be fast
if 'image_uris' in card_data:
image = card_data['image_uris'].get('normal')
elif 'card_faces' in card_data and 'image_uris' in card_data['card_faces'][0]:
image = card_data['card_faces'][0]['image_uris'].get('normal')
else:
continue
card, _ = Card.objects.get_or_create(
set=set_obj,
name=card_data['name'],
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)
if not image: continue
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'
)
listing_count += 1
def create_demo_users(self):
# Create a Pro user
if not User.objects.filter(username='prouser').exists():
u = User.objects.create_user('prouser', 'pro@example.com', 'password')
u.profile.is_pro = True
u.profile.save()
self.stdout.write("Created prouser/password")
# Create a Basic user
if not User.objects.filter(username='basicuser').exists():
User.objects.create_user('basicuser', 'basic@example.com', 'password')
self.stdout.write("Created basicuser/password")
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...'))
self.stdout.write(self.style.SUCCESS('Starting Pokémon TCG population (via TCGDex)...'))
# Setup Headers for API (Rate limits are better with a key)
self.headers = {'X-Api-Key': options['api_key']} if options['api_key'] else {}
# 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,55 +43,33 @@ 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...')
# 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}")'
self.stdout.write('Fetching sets from TCGDex...')
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)
@@ -104,121 +77,65 @@ class Command(BaseCommand):
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})...')
# 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)
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()
# 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)
set_detail = set_resp.json()
cards = set_detail.get('cards', [])
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)
except Exception as e:
self.stdout.write(self.style.ERROR(f' Failed to fetch cards for {set_obj.name}: {e}'))
continue
if response.status_code != 200:
raise Exception(f"API Error {response.status_code}: {response.text}")
self.stdout.write(f' Found {len(cards)} cards.')
data = response.json()
batch = data.get('data', [])
results.extend(batch)
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.
# Check if we need more pages
total_count = data.get('totalCount', 0)
count = data.get('count', 0)
# 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
if len(results) >= total_count or count == 0:
has_more = False
else:
page += 1
base_image = c_data.get('image')
image_url = f"{base_image}/high.webp" if base_image else ''
return results
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
}
)
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
# Rate limiting check - TCGDex is generous but good validation to not slam
# time.sleep(0.1)
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,
}
)
self.stdout.write(self.style.SUCCESS('Finished Pokémon TCG population!'))

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
@@ -368,6 +369,11 @@ def bounty_create(request):
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)
if form.is_valid():
@@ -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
# 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 not in items_by_seller:
items_by_seller[seller] = []
items_by_seller[seller].append(item)
if seller:
if sub_total < seller.minimum_order_amount:
shipping_cost = seller.shipping_cost
# 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
total_price = sub_total + shipping_cost
if seller:
if sub_total < seller.minimum_order_amount:
shipping_cost = seller.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
)
total_price = sub_total + shipping_cost
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
)
# Format Address Snapshot
addr_str = f"{shipping_address.name}\n{shipping_address.street}\n{shipping_address.city}, {shipping_address.state} {shipping_address.zip_code}"
# 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()
# 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
)
# 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()
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
)
# Find available sealed packs
available_packs = list(VirtualPack.objects.filter(
listing=item.pack_listing,
owner__isnull=True,
status='sealed'
)[: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()
# 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))
# 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()
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)
# Find available sealed packs
available_packs = list(VirtualPack.objects.filter(
listing=item.pack_listing,
owner__isnull=True,
status='sealed'
)[:item.quantity])
for pack in available_packs:
pack.owner = request.user.buyer_profile
pack.save()
# 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))
# Clear cart
cart.items.all().delete()
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)
if has_virtual_packs:
return redirect('store:my_packs')
return redirect('users:vault')
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)
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':
@@ -1245,6 +1304,11 @@ def add_pack_listing(request):
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()
if request.method == 'POST':

View File

@@ -48,7 +48,7 @@
{% if user.seller_profile %}
{# Seller Navigation #}
<a href="{% url 'store:seller_dashboard' %}">Dashboard</a>
<a href="{% url 'store:manage_listings' %}">Store</a>
<a href="{% url 'store:seller_profile' user.seller_profile.slug %}">Store</a>
<a href="{% url 'store:manage_listings' %}">Inventory</a>
{% if debug %}
<a href="{% url 'store:bounty_list' %}">Bounties</a>
@@ -148,6 +148,14 @@
<li style="margin-bottom: 0.5rem;"><a href="{% url 'store:seller_register' %}" style="color: inherit; text-decoration: none; transition: color 0.2s;">Seller Registration</a></li>
</ul>
</div>
<div style="text-align: left;">
<h4 style="color: #e2e8f0; font-weight: 600; margin-bottom: 1rem;">Services</h4>
<ul style="list-style: none; padding: 0;">
{% if FEATURE_PLAYTEST_PROXY %}
<li style="margin-bottom: 0.5rem;"><a href="{% url 'proxy:info' %}" style="color: inherit; text-decoration: none; transition: color 0.2s;">Proxy Service</a></li>
{% endif %}
</ul>
</div>
<div style="text-align: left;">
<h4 style="color: #e2e8f0; font-weight: 600; margin-bottom: 1rem;">Legal</h4>
<ul style="list-style: none; padding: 0;">

View File

@@ -1,33 +1,95 @@
{% extends 'base/layout.html' %}
{% block title %}Terms and Service - TCGKof{% endblock %}
{% block title %}Terms of Service - TCGKof{% endblock %}
{% block content %}
<div class="terms-container" style="max-width: 800px; margin: 2rem auto; padding: 2rem; background: var(--bg-card); border-radius: var(--border-radius); border: 1px solid var(--border-color);">
<h1 style="color: var(--primary-color); margin-bottom: 2rem; text-align: center;">Terms of Service</h1>
<div class="terms-container" style="max-width: 900px; margin: 3rem auto; padding: 2.5rem; background: var(--bg-card); border-radius: var(--border-radius); border: 1px solid var(--border-color); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);">
<div class="terms-header" style="text-align: center; margin-bottom: 3rem; border-bottom: 1px solid var(--border-color); padding-bottom: 2rem;">
<h1 style="color: var(--primary-color); font-size: 2.5rem; margin-bottom: 0.5rem; letter-spacing: -0.5px;">Terms of Service</h1>
<p style="color: var(--text-muted); font-size: 0.95rem;">Last Updated: January 25, 2026</p>
</div>
<div class="terms-content" style="color: var(--text-color); line-height: 1.6;">
<p style="margin-bottom: 1.5rem;">Welcome to TCGKof. By accessing our website, you agree to these terms and conditions.</p>
<div class="terms-body" style="color: var(--text-color); line-height: 1.7;">
<p style="margin-bottom: 2rem; font-size: 1.1rem;">Welcome to TCGKof. These Terms of Service ("Terms") enable you to understand your rights and responsibilities when using our marketplace. By accessing or using TCGKof, you agree to be bound by these Terms.</p>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">1. General Terms</h2>
<p style="margin-bottom: 1rem;">TCGKof is a platform for buying and selling trading cards. Users must be at least 18 years old or have parental consent to use this service.</p>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">1. Acceptance of Terms</h2>
<p>By registering for, accessing, or using the TCGKof marketplace, website, or services (collectively, the "Service"), you agree to these Terms. If you do not agree, you may not use the Service.</p>
</section>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">2. User Accounts</h2>
<p style="margin-bottom: 1rem;">You are responsible for maintaining the confidentiality of your account and password. You agree to accept responsibility for all activities that occur under your account.</p>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">2. Definitions</h2>
<ul style="list-style-type: disc; padding-left: 1.5rem; margin-top: 0.5rem;">
<li style="margin-bottom: 0.5rem;"><strong>"Buyer"</strong> means a user who purchases items on the Service.</li>
<li style="margin-bottom: 0.5rem;"><strong>"Seller"</strong> means a user who lists and sells items on the Service.</li>
<li style="margin-bottom: 0.5rem;"><strong>"Service"</strong> refers to the TCGKof marketplace platform and related tools.</li>
<li style="margin-bottom: 0.5rem;"><strong>"User"</strong> means any individual or entity accessing the Service.</li>
</ul>
</section>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">3. Buying and Selling</h2>
<p style="margin-bottom: 1rem;">Sellers must accurately describe items. Buyers must pay for items they commit to purchase. TCGKof facilitates transactions but is not a party to the contract between buyer and seller.</p>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">3. Eligibility</h2>
<p>You must be at least 18 years old to use the Service. Individuals between 13 and 18 may use the Service only with the supervision and consent of a parent or legal guardian. The Service is not available to any users previously suspended or removed from the system.</p>
</section>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">4. Prohibited Content</h2>
<p style="margin-bottom: 1rem;">Users may not post content that is illegal, obscene, threatening, defamatory, or otherwise objectionable.</p>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">4. User Accounts</h2>
<p style="margin-bottom: 1rem;">To access certain features, you must register for an account. You agree to provide accurate, current, and complete information during registration. You are responsible for safeguarding your password and for all activities that occur under your account.</p>
</section>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">5. Limitation of Liability</h2>
<p style="margin-bottom: 1rem;">TCGKof shall not be liable for any indirect, incidental, special, consequential, or punitive damages happening out of or in connection with your use of the site.</p>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">5. Selling on TCGKof</h2>
<p style="margin-bottom: 1rem;"><strong>Listings:</strong> Sellers are responsible for the accuracy of their listings. All items must be in the condition described. Misleading listings may result in account suspension.</p>
<p style="margin-bottom: 1rem;"><strong>Fees:</strong> When you make a sale, TCGKof charges a transaction fee of 5% of the total sale price plus a fixed fee of $0.70 per transaction. These fees are automatically deducted from the payout.</p>
<p style="margin-bottom: 1rem;"><strong>Shipping:</strong> Sellers must ship items within the timeframe specified in their store policies (typically 48 hours). Tracking is recommended for all orders and required for orders over $50.</p>
</section>
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-top: 2rem; margin-bottom: 1rem;">6. Changes to Terms</h2>
<p style="margin-bottom: 1rem;">We reserve the right to modify these terms at any time. Your continued use of the site constitutes acceptance of the modified terms.</p>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">6. Buying on TCGKof</h2>
<p style="margin-bottom: 1rem;"><strong>Payments:</strong> Buyers engage in a transaction with the Seller, not TCGKof. TCGKof processes payments as an agent for the Seller.</p>
<p style="margin-bottom: 1rem;"><strong>Refunds & Safeguard:</strong> If an item is not received or differs significantly from the description, TCGKof's Buyer Safeguard program may permit a refund. Buyers must report issues within 48 hours of delivery.</p>
</section>
<p style="margin-top: 3rem; font-size: 0.875rem; color: var(--text-muted); text-align: center;">Last Updated: January 2026</p>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">7. Fees and Taxes</h2>
<p>Users are responsible for paying all fees associated with using the Service. Sellers are responsible for determining, determining, and remitting any applicable taxes on their sales. TCGKof may collect and remit sales tax where required by law.</p>
</section>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">8. Content and Conduct</h2>
<p style="margin-bottom: 1rem;">You agree not to:</p>
<ul style="list-style-type: disc; padding-left: 1.5rem; margin-top: 0.5rem;">
<li style="margin-bottom: 0.5rem;">Post content that is illegal, abusive, defamatory, or obscene.</li>
<li style="margin-bottom: 0.5rem;">Interfere with the proper working of the Service.</li>
<li style="margin-bottom: 0.5rem;">Bypass or circumvent any measures we may use to prevent or restrict access to the Service.</li>
<li style="margin-bottom: 0.5rem;">Harvest or scrape any data from the Service without express written permission.</li>
</ul>
</section>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">9. Intellectual Property</h2>
<p>The Service and its original content, features, and functionality are owned by TCGKof and are protected by international copyright, trademark, patent, trade secret, and other intellectual property or proprietary rights laws.</p>
</section>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">10. Termination</h2>
<p>We may terminate or suspend your account and bar access to the Service immediately, without prior notice or liability, under our sole discretion, for any reason whatsoever and without limitation, including but not limited to a breach of the Terms.</p>
</section>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">11. Limitation of Liability</h2>
<p style="text-transform: uppercase;">In no event shall TCGKof, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your access to or use of or inability to access or use the Service.</p>
</section>
<section style="margin-bottom: 2.5rem;">
<h2 style="color: var(--text-heading); font-size: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--primary-color); padding-left: 1rem;">12. Governing Law</h2>
<p>These Terms shall be governed and construed in accordance with the laws of the United States, without regard to its conflict of law provisions.</p>
</section>
<div class="terms-footer" style="margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border-color); text-align: center;">
<p>Have questions about these terms?</p>
<a href="#" style="color: var(--primary-color); font-weight: 500; text-decoration: none;">Contact Support</a>
</div>
</div>
</div>
{% endblock %}

108
tests/test_marketplace.py Normal file
View File

@@ -0,0 +1,108 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from store.models import Seller, CardListing, PackListing, Order, OrderItem, Card, Game, Set, Cart, CartItem
User = get_user_model()
class MarketplaceTests(TestCase):
def setUp(self):
# Create Users
self.seller_user = User.objects.create_user(username='seller', password='password')
self.buyer_user = User.objects.create_user(username='buyer', password='password')
# Create Seller
self.seller = Seller.objects.create(
user=self.seller_user,
store_name='Test Store',
slug='test-store',
contact_email='seller@test.com'
)
# Create Game/Set/Card
self.game = Game.objects.create(name='Test Game', slug='test-game')
self.set = Set.objects.create(game=self.game, name='Test Set')
self.card = Card.objects.create(set=self.set, name='Test Card')
# Create Listing
self.listing = CardListing.objects.create(
card=self.card,
seller=self.seller,
price=10.00,
quantity=5
)
def test_seller_creation(self):
self.assertEqual(self.seller.store_name, 'Test Store')
self.assertEqual(self.seller.user.username, 'seller')
def test_order_creation_and_revenue(self):
# Create Cart and Add Item
cart = Cart.objects.create(user=self.buyer_user)
CartItem.objects.create(cart=cart, listing=self.listing, quantity=2)
# Simulate Checkout Logic (Manual or call view? Logic is in view. We can simulate logic here to test models/signals, but view logic needs integration test.)
# For simplicity, let's replicate the core logic or create order manually as checkout would.
# Logic from checkout view:
order = Order.objects.create(
user=self.buyer_user,
status='paid',
total_price=20.00
)
item = OrderItem.objects.create(
order=order,
listing=self.listing,
price_at_purchase=self.listing.price,
quantity=2
)
# Decrement stock
self.listing.quantity -= 2
self.listing.save()
# Verify Listing Stock
self.listing.refresh_from_db()
self.assertEqual(self.listing.quantity, 3)
# Verify Revenue Logic (as in Dashboard)
# card_items = OrderItem.objects.filter(listing__seller=seller, order__status__in=['paid', 'shipped'])
card_items = OrderItem.objects.filter(listing__seller=self.seller, order__status='paid')
revenue = sum(i.price_at_purchase * i.quantity for i in card_items)
self.assertEqual(revenue, 20.00)
def test_multi_seller_order_split(self):
# Create another seller
seller2_user = User.objects.create_user(username='seller2', password='password')
seller2 = Seller.objects.create(user=seller2_user, store_name='Store 2', slug='store-2')
card2 = Card.objects.create(set=self.set, name='Card 2')
listing2 = CardListing.objects.create(card=card2, seller=seller2, price=5.00, quantity=10)
# Setup Cart with items from both sellers
cart = Cart.objects.create(user=self.buyer_user)
CartItem.objects.create(cart=cart, listing=self.listing, quantity=1) # Seller 1 ($10)
CartItem.objects.create(cart=cart, listing=listing2, quantity=1) # Seller 2 ($5)
# Verify Split Logic (Simulating checkout view logic)
items_by_seller = {}
for item in cart.items.all():
seller = item.listing.seller
if seller not in items_by_seller: items_by_seller[seller] = []
items_by_seller[seller].append(item)
self.assertEqual(len(items_by_seller), 2)
self.assertIn(self.seller, items_by_seller)
self.assertIn(seller2, items_by_seller)
# Create Orders
orders_created = []
for seller, items in items_by_seller.items():
sub_total = sum(i.listing.price * i.quantity for i in items)
order = Order.objects.create(user=self.buyer_user, status='paid', total_price=sub_total)
for i in items:
OrderItem.objects.create(order=order, listing=i.listing, price_at_purchase=i.listing.price, quantity=i.quantity)
orders_created.append(order)
self.assertEqual(len(orders_created), 2)
self.assertEqual(orders_created[0].total_price + orders_created[1].total_price, 15.00)

View File

@@ -35,13 +35,31 @@ class AddressForm(forms.ModelForm):
}
class PaymentMethodForm(forms.ModelForm):
card_number = forms.CharField(max_length=19, widget=forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Card Number'}))
cvv = forms.CharField(max_length=4, widget=forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'CVV'}))
billing_address = forms.ModelChoiceField(queryset=Address.objects.none(), required=False, empty_label="Select Billing Address")
class Meta:
model = PaymentMethod
fields = ('brand', 'last4', 'exp_month', 'exp_year', 'is_default')
fields = ('brand', 'exp_month', 'exp_year', 'billing_address', 'is_default')
widgets = {
'brand': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Card Brand (e.g. Visa)'}),
'last4': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Last 4 Digits', 'maxlength': '4'}),
'exp_month': forms.NumberInput(attrs={'class': 'form-input', 'placeholder': 'Exp Month', 'min': '1', 'max': '12'}),
'exp_year': forms.NumberInput(attrs={'class': 'form-input', 'placeholder': 'Exp Year', 'min': '2024'}),
'billing_address': forms.Select(attrs={'class': 'form-select'}),
'is_default': forms.CheckboxInput(attrs={'class': 'form-checkbox'}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
if user:
self.fields['billing_address'].queryset = Address.objects.filter(user=user)
def save(self, commit=True):
pm = super().save(commit=False)
pm.card_number = self.cleaned_data['card_number']
pm.cvv = self.cleaned_data['cvv']
if commit:
pm.save()
return pm

View File

@@ -0,0 +1,29 @@
# 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 = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='paymentmethod',
name='billing_address',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payment_methods', to='users.address'),
),
migrations.AddField(
model_name='paymentmethod',
name='card_number_encrypted',
field=models.BinaryField(blank=True, null=True),
),
migrations.AddField(
model_name='paymentmethod',
name='cvv_encrypted',
field=models.BinaryField(blank=True, null=True),
),
]

View File

@@ -55,13 +55,44 @@ class Address(models.Model):
class PaymentMethod(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='payment_methods')
# For now, we'll store mock data. In a real app complexity is higher (PCI compliance etc)
billing_address = models.ForeignKey(Address, on_delete=models.SET_NULL, null=True, blank=True, related_name='payment_methods')
brand = models.CharField(max_length=50) # Visa, Mastercard
last4 = models.CharField(max_length=4)
exp_month = models.PositiveIntegerField()
exp_year = models.PositiveIntegerField()
is_default = models.BooleanField(default=False)
# Encrypted fields
card_number_encrypted = models.BinaryField(blank=True, null=True)
cvv_encrypted = models.BinaryField(blank=True, null=True)
@property
def card_number(self):
try:
from store.utils import Encryptor
return Encryptor.decrypt(self.card_number_encrypted)
except ImportError:
# Fallback if store isn't ready or circular import
return None
@card_number.setter
def card_number(self, value):
from store.utils import Encryptor
self.card_number_encrypted = Encryptor.encrypt(value)
if value and len(str(value)) >= 4:
self.last4 = str(value)[-4:]
@property
def cvv(self):
from store.utils import Encryptor
return Encryptor.decrypt(self.cvv_encrypted)
@cvv.setter
def cvv(self, value):
from store.utils import Encryptor
self.cvv_encrypted = Encryptor.encrypt(value)
def __str__(self):
return f"{self.brand} ending in {self.last4}"

View File

@@ -0,0 +1,28 @@
from django.test import TestCase
from users.models import PaymentMethod, User
from store.utils import Encryptor
class PaymentEncryptionTest(TestCase):
def test_encryption_decryption(self):
user = User.objects.create_user(username='testuser', password='password')
pm = PaymentMethod(user=user, brand='Visa', exp_month=12, exp_year=2030)
# Test setter
pm.card_number = '1234567890123456'
pm.cvv = '123'
# Verify it's encrypted
self.assertIsNotNone(pm.card_number_encrypted)
self.assertNotEqual(pm.card_number_encrypted, b'1234567890123456')
# Verify getter
self.assertEqual(pm.card_number, '1234567890123456')
self.assertEqual(pm.cvv, '123')
# Save and retrieval
pm.save()
pm_fetched = PaymentMethod.objects.get(id=pm.id)
self.assertEqual(pm_fetched.card_number, '1234567890123456')
self.assertEqual(pm_fetched.cvv, '123')
self.assertEqual(pm_fetched.last4, '3456')

View File

@@ -105,7 +105,7 @@ def delete_address_view(request, pk):
@login_required
def add_payment_method_view(request):
if request.method == 'POST':
form = PaymentMethodForm(request.POST)
form = PaymentMethodForm(request.POST, user=request.user)
if form.is_valid():
pm = form.save(commit=False)
pm.user = request.user
@@ -115,7 +115,7 @@ def add_payment_method_view(request):
messages.success(request, 'Payment method added successfully.')
return redirect('users:profile')
else:
form = PaymentMethodForm()
form = PaymentMethodForm(user=request.user)
return render(request, 'users/payment_form.html', {'form': form})
@login_required