Compare commits
2 Commits
1cd87156bd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 28bfa8c631 | |||
| 739d136209 |
@@ -26,7 +26,7 @@ SECRET_KEY = 'django-insecure-y-a6jb6gv)dd!32_c!8rn70cnn%8&_$nc=m)0#4d=%b65%g(0*
|
|||||||
DEBUG = True
|
DEBUG = True
|
||||||
FEATURE_BOUNTY_BOARD = DEBUG
|
FEATURE_BOUNTY_BOARD = DEBUG
|
||||||
FEATURE_DEMO_SITE = True
|
FEATURE_DEMO_SITE = True
|
||||||
FEATURE_PLAYTEST_PROXY = DEBUG
|
FEATURE_PLAYTEST_PROXY = False
|
||||||
FEATURE_VIRTUAL_PACKS = DEBUG
|
FEATURE_VIRTUAL_PACKS = DEBUG
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['testserver', 'localhost', '127.0.0.1']
|
ALLOWED_HOSTS = ['testserver', 'localhost', '127.0.0.1']
|
||||||
@@ -45,6 +45,7 @@ INSTALLED_APPS = [
|
|||||||
'users',
|
'users',
|
||||||
'store',
|
'store',
|
||||||
'decks',
|
'decks',
|
||||||
|
'proxy',
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'users.User'
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
|||||||
path('', include('store.urls')), # Store is the home app
|
path('', include('store.urls')), # Store is the home app
|
||||||
path('home', RedirectView.as_view(url='/', permanent=True), name='home'), # Redirect /home to /
|
path('home', RedirectView.as_view(url='/', permanent=True), name='home'), # Redirect /home to /
|
||||||
path('decks/', include('decks.urls')),
|
path('decks/', include('decks.urls')),
|
||||||
|
path('proxy/', include('proxy.urls')),
|
||||||
|
|
||||||
# SEO
|
# SEO
|
||||||
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
|
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
|
||||||
|
|||||||
0
proxy/__init__.py
Normal file
0
proxy/__init__.py
Normal file
3
proxy/admin.py
Normal file
3
proxy/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
proxy/apps.py
Normal file
5
proxy/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyConfig(AppConfig):
|
||||||
|
name = "proxy"
|
||||||
0
proxy/migrations/__init__.py
Normal file
0
proxy/migrations/__init__.py
Normal file
3
proxy/models.py
Normal file
3
proxy/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
20
proxy/templates/proxy/info.html
Normal file
20
proxy/templates/proxy/info.html
Normal 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
3
proxy/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
8
proxy/urls.py
Normal file
8
proxy/urls.py
Normal 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
11
proxy/views.py
Normal 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'
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@ from .models import Seller, Game, Set, Card, CardListing, PackListing, VirtualPa
|
|||||||
class SellerAdmin(admin.ModelAdmin):
|
class SellerAdmin(admin.ModelAdmin):
|
||||||
list_display = ['store_name', 'user', 'slug', 'created_at']
|
list_display = ['store_name', 'user', 'slug', 'created_at']
|
||||||
search_fields = ['store_name', 'user__username']
|
search_fields = ['store_name', 'user__username']
|
||||||
|
readonly_fields = ['tax_id', 'payout_details', 'tax_id_encrypted', 'payout_details_encrypted']
|
||||||
|
|
||||||
@admin.register(Game)
|
@admin.register(Game)
|
||||||
class GameAdmin(admin.ModelAdmin):
|
class GameAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -11,24 +11,52 @@ class SellerThemeForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SellerRegistrationForm(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:
|
class Meta:
|
||||||
model = Seller
|
model = Seller
|
||||||
fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address']
|
fields = ['store_name', 'description', 'contact_email', 'contact_phone']
|
||||||
widgets = {
|
widgets = {
|
||||||
'description': forms.Textarea(attrs={'rows': 4}),
|
'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):
|
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)")
|
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)")
|
payout_details = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 3}), help_text="Bank account or other payout details (Stored securely)")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Seller
|
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 = {
|
widgets = {
|
||||||
'description': forms.Textarea(attrs={'rows': 4}),
|
'description': forms.Textarea(attrs={'rows': 4}),
|
||||||
'business_address': forms.Textarea(attrs={'rows': 3}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -36,11 +64,50 @@ class SellerEditForm(forms.ModelForm):
|
|||||||
if self.instance and self.instance.pk:
|
if self.instance and self.instance.pk:
|
||||||
self.fields['tax_id'].initial = self.instance.tax_id
|
self.fields['tax_id'].initial = self.instance.tax_id
|
||||||
self.fields['payout_details'].initial = self.instance.payout_details
|
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):
|
def save(self, commit=True):
|
||||||
seller = super().save(commit=False)
|
seller = super().save(commit=False)
|
||||||
seller.tax_id = self.cleaned_data.get('tax_id')
|
seller.tax_id = self.cleaned_data.get('tax_id')
|
||||||
seller.payout_details = self.cleaned_data.get('payout_details')
|
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:
|
if commit:
|
||||||
seller.save()
|
seller.save()
|
||||||
return seller
|
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.")
|
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.")
|
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,76 +1,93 @@
|
|||||||
import random
|
import random
|
||||||
import requests
|
import requests
|
||||||
|
from django.core.management import call_command
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from store.models import Game, Set, Card, CardListing
|
from store.models import Game, Set, Card, CardListing, Seller
|
||||||
from faker import Faker
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
fake = Faker()
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def handle(self, *args, **options):
|
||||||
self.stdout.write('Starting database population...')
|
self.stdout.write('Starting database population...')
|
||||||
|
|
||||||
# Create Games
|
# 1. Ensure Games and Cards exist
|
||||||
mtg, _ = Game.objects.get_or_create(name='Magic: The Gathering', slug='mtg')
|
self.ensure_games_populated()
|
||||||
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)
|
# 2. Get Game Objects
|
||||||
self.populate_mtg(mtg)
|
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)
|
# 3. Create Stores
|
||||||
self.populate_fake_game(pokemon, 'Pokemon')
|
self.create_stores_and_listings(mtg, pokemon, lorcana)
|
||||||
|
|
||||||
# Populate Lorcana (Fake Data)
|
self.stdout.write(self.style.SUCCESS('Database population complete!'))
|
||||||
self.populate_fake_game(lorcana, 'Lorcana')
|
|
||||||
|
|
||||||
# Create Superuser
|
def ensure_games_populated(self):
|
||||||
if not User.objects.filter(username='admin').exists():
|
# MTG - Native (kept simple from original) or call a script if we had one.
|
||||||
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
# Since the original had MTG logic, I'll keep a simplified version here or call it if I haven't deleted it.
|
||||||
self.stdout.write(self.style.SUCCESS('Created superuser: admin/admin'))
|
# 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
|
# Check MTG
|
||||||
self.create_demo_users()
|
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):
|
# Check Lorcana
|
||||||
self.stdout.write('Fetching MTG data from Scryfall...')
|
if not Game.objects.filter(slug='disney-lorcana').exists() or not Card.objects.filter(set__game__slug='disney-lorcana').exists():
|
||||||
# Get a few sets
|
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"
|
sets_api = "https://api.scryfall.com/sets"
|
||||||
try:
|
try:
|
||||||
resp = requests.get(sets_api).json()
|
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]
|
target_sets = [s for s in resp['data'] if s['set_type'] == 'expansion'][:3]
|
||||||
|
|
||||||
for s_data in target_sets:
|
for s_data in target_sets:
|
||||||
set_obj, created = Set.objects.get_or_create(
|
set_obj, _ = Set.objects.get_or_create(
|
||||||
game=game,
|
game=game,
|
||||||
name=s_data['name'],
|
name=s_data['name'],
|
||||||
code=s_data['code'],
|
code=s_data['code'],
|
||||||
defaults={'release_date': s_data.get('released_at')}
|
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_url = s_data['search_uri']
|
||||||
cards_resp = requests.get(cards_url).json()
|
cards_resp = requests.get(cards_url).json()
|
||||||
|
for card_data in cards_resp.get('data', [])[:50]:
|
||||||
for card_data in cards_resp.get('data', [])[:20]: # Limit to 20 cards per set to be fast
|
image = None
|
||||||
if 'image_uris' in card_data:
|
if 'image_uris' in card_data:
|
||||||
image = card_data['image_uris'].get('normal')
|
image = card_data['image_uris'].get('normal')
|
||||||
elif 'card_faces' in card_data and 'image_uris' in card_data['card_faces'][0]:
|
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')
|
image = card_data['card_faces'][0]['image_uris'].get('normal')
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
card, _ = Card.objects.get_or_create(
|
if not image: continue
|
||||||
|
|
||||||
|
Card.objects.get_or_create(
|
||||||
set=set_obj,
|
set=set_obj,
|
||||||
name=card_data['name'],
|
name=card_data['name'],
|
||||||
collector_number=card_data['collector_number'],
|
collector_number=card_data['collector_number'],
|
||||||
@@ -81,59 +98,104 @@ class Command(BaseCommand):
|
|||||||
'tcgplayer_id': card_data.get('tcgplayer_id'),
|
'tcgplayer_id': card_data.get('tcgplayer_id'),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Listings
|
|
||||||
self.create_listings_for_card(card)
|
|
||||||
|
|
||||||
except Exception as e:
|
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):
|
def create_stores_and_listings(self, mtg, pokemon, lorcana):
|
||||||
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')}
|
|
||||||
)
|
|
||||||
|
|
||||||
for j in range(15): # 15 Cards per set
|
# Define Store Configs
|
||||||
card, _ = Card.objects.get_or_create(
|
stores_config = [
|
||||||
set=set_obj,
|
# Store 1: 100 Magic the gathering single card listings
|
||||||
name=f"{prefix} Monster {fake.word().capitalize()}",
|
{
|
||||||
|
'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={
|
defaults={
|
||||||
'rarity': random.choice(['Common', 'Uncommon', 'Rare', 'Ultra Rare']),
|
'store_name': config['name'],
|
||||||
'image_url': f"https://placehold.co/400x600?text={prefix}+{j}",
|
'slug': config['slug'],
|
||||||
'collector_number': str(j+1)
|
'description': f"Welcome to {config['name']}!",
|
||||||
|
'contact_email': f"{username}@example.com"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.create_listings_for_card(card)
|
|
||||||
|
|
||||||
def create_listings_for_card(self, card):
|
# Create Listings
|
||||||
# Create 1-5 listings per card with different conditions
|
for game, count in config['listings']:
|
||||||
for _ in range(random.randint(1, 4)):
|
self.create_listings_for_store(seller, game, count)
|
||||||
|
|
||||||
|
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'])
|
condition = random.choice(['NM', 'LP', 'MP', 'HP'])
|
||||||
price = round(random.uniform(0.50, 100.00), 2)
|
|
||||||
CardListing.objects.create(
|
CardListing.objects.create(
|
||||||
card=card,
|
card=card,
|
||||||
condition=condition,
|
seller=seller,
|
||||||
price=price,
|
price=price,
|
||||||
quantity=random.randint(1, 20),
|
quantity=quantity,
|
||||||
market_price=price, # Simplified
|
condition=condition,
|
||||||
is_foil=random.choice([True, False])
|
status='listed'
|
||||||
)
|
)
|
||||||
|
listing_count += 1
|
||||||
|
|
||||||
def create_demo_users(self):
|
self.stdout.write(f" - Created {count} listings for {game.name}")
|
||||||
# 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")
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import requests
|
import requests
|
||||||
import time
|
import time
|
||||||
import os
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils.dateparse import parse_date
|
from django.utils.dateparse import parse_date
|
||||||
from store.models import Game, Set, Card
|
from store.models import Game, Set, Card
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -17,19 +16,15 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--duration',
|
'--duration',
|
||||||
default='7',
|
default='7',
|
||||||
help='Duration in days to look back for new sets/cards. Use "all" to fetch everything. Default is 7 days.'
|
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.'
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--api-key',
|
|
||||||
default=os.getenv('POKEMONTCG_API_KEY', None),
|
|
||||||
help='Optional API Key for higher rate limits.'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
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)
|
# User Agent is good practice
|
||||||
self.headers = {'X-Api-Key': options['api_key']} if options['api_key'] else {}
|
self.headers = {'User-Agent': 'ExampleTCGSite/1.0'}
|
||||||
|
base_url = "https://api.tcgdex.net/v2/en"
|
||||||
|
|
||||||
# 1. Ensure Game exists
|
# 1. Ensure Game exists
|
||||||
game, created = Game.objects.get_or_create(
|
game, created = Game.objects.get_or_create(
|
||||||
@@ -48,35 +43,14 @@ class Command(BaseCommand):
|
|||||||
Set.objects.filter(game=game).delete()
|
Set.objects.filter(game=game).delete()
|
||||||
self.stdout.write(self.style.SUCCESS('Cleared Pokémon data.'))
|
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
|
# 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:
|
try:
|
||||||
# Note: /v2/sets does not usually require pagination for < 250 sets if filtering by recent date
|
# TCGDex /sets returns a list of minimal set objects
|
||||||
# But "all" will require pagination.
|
response = requests.get(f"{base_url}/sets", headers=self.headers)
|
||||||
sets_data = self.fetch_all_pages('https://api.pokemontcg.io/v2/sets', params)
|
response.raise_for_status()
|
||||||
|
sets_data = response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Failed to fetch sets: {e}'))
|
self.stdout.write(self.style.ERROR(f'Failed to fetch sets: {e}'))
|
||||||
return
|
return
|
||||||
@@ -84,19 +58,18 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f'Found {len(sets_data)} sets. Processing...')
|
self.stdout.write(f'Found {len(sets_data)} sets. Processing...')
|
||||||
|
|
||||||
processed_sets = []
|
processed_sets = []
|
||||||
for set_data in sets_data:
|
for s_data in sets_data:
|
||||||
release_date = parse_date(set_data.get('releaseDate', '').replace('/', '-'))
|
# s_data example: {"id": "base1", "name": "Base Set", ...}
|
||||||
|
# TCGDex sets don't consistently provide releaseDate in the list view,
|
||||||
# Pokémon sets have an 'id' (e.g., 'swsh1') and 'ptcgoCode' (e.g., 'SSH').
|
# so we'll leave it null or updated if we fetched details (which we might do for cards).
|
||||||
# We use 'id' as the unique code.
|
# For efficiency we might not fetch set details just for date if unnecessary.
|
||||||
set_code = set_data.get('id')
|
|
||||||
|
|
||||||
set_obj, created = Set.objects.update_or_create(
|
set_obj, created = Set.objects.update_or_create(
|
||||||
code=set_code,
|
code=s_data.get('id'),
|
||||||
game=game,
|
game=game,
|
||||||
defaults={
|
defaults={
|
||||||
'name': set_data.get('name'),
|
'name': s_data.get('name'),
|
||||||
'release_date': release_date,
|
# 'release_date': None # Not available in simple list
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
processed_sets.append(set_obj)
|
processed_sets.append(set_obj)
|
||||||
@@ -104,121 +77,65 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(self.style.SUCCESS(f'Processed {len(processed_sets)} sets.'))
|
self.stdout.write(self.style.SUCCESS(f'Processed {len(processed_sets)} sets.'))
|
||||||
|
|
||||||
# 3. Fetch Cards
|
# 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...')
|
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)
|
total_sets = len(processed_sets)
|
||||||
for idx, set_obj in enumerate(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})...')
|
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)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
cards_data = self.fetch_all_pages('https://api.pokemontcg.io/v2/cards', params)
|
# 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()
|
||||||
|
|
||||||
|
set_detail = set_resp.json()
|
||||||
|
cards = set_detail.get('cards', [])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f' Failed to fetch cards: {e}'))
|
self.stdout.write(self.style.ERROR(f' Failed to fetch cards for {set_obj.name}: {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
|
continue
|
||||||
|
|
||||||
# Extract Image URL (Prefer Hi-Res)
|
self.stdout.write(f' Found {len(cards)} cards.')
|
||||||
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)
|
for c_data in cards:
|
||||||
tcgplayer_url = card_data.get('tcgplayer', {}).get('url', '')
|
# c_data example: {"id": "base1-1", "localId": "1", "name": "Alakazam", "image": "..."}
|
||||||
# Extract ID from URL if possible, or store URL.
|
# Rarity is NOT in this list usually, requires fetching card detail. Skipping for speed.
|
||||||
# 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
|
# Image URL: TCGDex gives a base URL usually, e.g. ".../base1/1"
|
||||||
external_url = tcgplayer_url if tcgplayer_url else f"https://pkmncards.com/card/{card_data.get('id')}"
|
# 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
|
||||||
|
|
||||||
# Collector Number
|
base_image = c_data.get('image')
|
||||||
collector_number = card_data.get('number', '')
|
image_url = f"{base_image}/high.webp" if base_image else ''
|
||||||
|
|
||||||
Card.objects.update_or_create(
|
Card.objects.update_or_create(
|
||||||
scryfall_id=card_data.get('id'), # Using API ID as unique identifier
|
scryfall_id=c_data.get('id'),
|
||||||
defaults={
|
defaults={
|
||||||
'set': set_obj,
|
'set': set_obj,
|
||||||
'name': card_data.get('name'),
|
'name': c_data.get('name'),
|
||||||
'rarity': card_data.get('rarity', 'Common'),
|
'rarity': '', # specific call needed, simplifying
|
||||||
'image_url': image_url,
|
'image_url': image_url,
|
||||||
'tcgplayer_id': tcgplayer_id, # Can be updated if you add parsing logic
|
'collector_number': c_data.get('localId', ''),
|
||||||
'collector_number': collector_number,
|
'external_url': f"https://tcgdex.dev/cards/{c_data.get('id')}", # simplified assumption
|
||||||
'external_url': external_url,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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!'))
|
||||||
|
|||||||
@@ -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)
|
hero_image = models.ImageField(upload_to='seller_hero_images/', blank=True, null=True)
|
||||||
contact_email = models.EmailField(blank=True)
|
contact_email = models.EmailField(blank=True)
|
||||||
contact_phone = models.CharField(max_length=20, 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)
|
store_views = models.PositiveIntegerField(default=0)
|
||||||
listing_clicks = models.PositiveIntegerField(default=0)
|
listing_clicks = models.PositiveIntegerField(default=0)
|
||||||
minimum_order_amount = models.DecimalField(max_digits=10, decimal_places=2, 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 %}
|
{% block content %}
|
||||||
<div class="card-grid" style="grid-template-columns: 1fr 3fr; gap: 2rem; align-items: start;">
|
<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 -->
|
<!-- 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);">
|
<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>
|
<h2 style="margin-top: 0;">{{ seller.store_name }}</h2>
|
||||||
|
|||||||
@@ -66,9 +66,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: 1.5rem;">
|
<div style="margin-top: 1.5rem;">
|
||||||
<label style="display: block; margin-bottom: 0.5rem; color: #94a3b8;">Business Address</label>
|
<h4 style="margin-top: 0; margin-bottom: 0.5rem; color: #94a3b8; font-size: 1rem;">Store Address</h4>
|
||||||
{{ form.business_address }}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,6 +112,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
|
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
|
||||||
|
|||||||
@@ -30,15 +30,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for field in form %}
|
<!-- Store Details -->
|
||||||
|
<div class="space-y-4">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="{{ field.id_for_label }}" class="text-gray-300 mb-1">{{ field.label }}</label>
|
<label for="{{ form.store_name.id_for_label }}" class="text-gray-300 mb-1">{{ form.store_name.label }}</label>
|
||||||
{{ field }}
|
{{ form.store_name }}
|
||||||
{% if field.errors %}
|
{% if form.store_name.errors %}<p class="text-red-400 text-sm mt-1">{{ form.store_name.errors.0 }}</p>{% endif %}
|
||||||
<p class="text-red-400 text-sm mt-1">{{ field.errors.0 }}</p>
|
</div>
|
||||||
{% endif %}
|
<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>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<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">
|
<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
|
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'})
|
response = self.client.get(url, {'hide_out_of_stock': 'off'})
|
||||||
# Should be 5 + 3 = 8
|
# Should be 5 + 3 = 8
|
||||||
self.assertEqual(response.context['page_obj'][0].total_quantity, 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)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.contrib import messages
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.core.paginator import Paginator
|
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 .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 import Sum, Value
|
||||||
from django.db.models.functions import Coalesce, Length
|
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
|
from django.utils.text import slugify
|
||||||
import random
|
import random
|
||||||
import csv
|
import csv
|
||||||
@@ -368,6 +369,11 @@ def bounty_create(request):
|
|||||||
|
|
||||||
seller = request.user.seller_profile
|
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':
|
if request.method == 'POST':
|
||||||
form = BountyForm(request.POST)
|
form = BountyForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@@ -512,6 +518,11 @@ def checkout(request):
|
|||||||
if not cart.items.exists():
|
if not cart.items.exists():
|
||||||
return redirect('store:cart')
|
return redirect('store:cart')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CheckoutForm(request.POST, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
shipping_address = form.cleaned_data['shipping_address']
|
||||||
|
|
||||||
# Group items by seller and check for virtual packs
|
# Group items by seller and check for virtual packs
|
||||||
items_by_seller = {}
|
items_by_seller = {}
|
||||||
has_virtual_packs = False
|
has_virtual_packs = False
|
||||||
@@ -539,12 +550,16 @@ def checkout(request):
|
|||||||
|
|
||||||
total_price = sub_total + 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)
|
# Create Order (status paid for MVP)
|
||||||
order = Order.objects.create(
|
order = Order.objects.create(
|
||||||
buyer=request.user.buyer_profile,
|
buyer=request.user.buyer_profile,
|
||||||
status='paid',
|
status='paid',
|
||||||
total_price=total_price,
|
total_price=total_price,
|
||||||
seller=seller # Populate seller
|
seller=seller,
|
||||||
|
shipping_address=addr_str
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -564,8 +579,6 @@ def checkout(request):
|
|||||||
item.listing.quantity -= item.quantity
|
item.listing.quantity -= item.quantity
|
||||||
item.listing.save()
|
item.listing.save()
|
||||||
else:
|
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.quantity = 0
|
||||||
item.listing.save()
|
item.listing.save()
|
||||||
|
|
||||||
@@ -586,12 +599,9 @@ def checkout(request):
|
|||||||
status='sealed'
|
status='sealed'
|
||||||
)[:item.quantity])
|
)[:item.quantity])
|
||||||
|
|
||||||
# If not enough, create more ONLY if it's a system pack (no seller) or configured to do so
|
# If not enough, create more ONLY if it's a system pack (no seller)
|
||||||
if len(available_packs) < item.quantity:
|
if len(available_packs) < item.quantity:
|
||||||
# Seller packs must be pre-filled
|
|
||||||
if item.pack_listing.seller:
|
if item.pack_listing.seller:
|
||||||
# We only fulfill what we have.
|
|
||||||
# Ideally we should have caught this at cart validation.
|
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
needed = item.quantity - len(available_packs)
|
needed = item.quantity - len(available_packs)
|
||||||
@@ -601,7 +611,6 @@ def checkout(request):
|
|||||||
for _ in range(needed):
|
for _ in range(needed):
|
||||||
pack = VirtualPack.objects.create(listing=item.pack_listing)
|
pack = VirtualPack.objects.create(listing=item.pack_listing)
|
||||||
if all_game_cards:
|
if all_game_cards:
|
||||||
# Sample logic (mock)
|
|
||||||
pack.cards.set(random.sample(all_game_cards, min(len(all_game_cards), 5)))
|
pack.cards.set(random.sample(all_game_cards, min(len(all_game_cards), 5)))
|
||||||
available_packs.append(pack)
|
available_packs.append(pack)
|
||||||
|
|
||||||
@@ -615,6 +624,10 @@ def checkout(request):
|
|||||||
if has_virtual_packs:
|
if has_virtual_packs:
|
||||||
return redirect('store:my_packs')
|
return redirect('store:my_packs')
|
||||||
return redirect('users:vault')
|
return redirect('users:vault')
|
||||||
|
else:
|
||||||
|
form = CheckoutForm(user=request.user)
|
||||||
|
|
||||||
|
return render(request, 'store/checkout.html', {'form': form, 'cart': cart})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def my_packs(request):
|
def my_packs(request):
|
||||||
@@ -699,6 +712,27 @@ def seller_register(request):
|
|||||||
seller.user = request.user
|
seller.user = request.user
|
||||||
seller.slug = slugify(seller.store_name)
|
seller.slug = slugify(seller.store_name)
|
||||||
seller.save()
|
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')
|
return redirect('store:seller_dashboard')
|
||||||
else:
|
else:
|
||||||
user_form = CustomUserCreationForm(request.POST)
|
user_form = CustomUserCreationForm(request.POST)
|
||||||
@@ -712,6 +746,27 @@ def seller_register(request):
|
|||||||
seller.user = user
|
seller.user = user
|
||||||
seller.slug = slugify(seller.store_name)
|
seller.slug = slugify(seller.store_name)
|
||||||
seller.save()
|
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')
|
return redirect('store:seller_dashboard')
|
||||||
else:
|
else:
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
@@ -735,10 +790,9 @@ def edit_seller_profile(request):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = SellerEditForm(request.POST, request.FILES, instance=seller)
|
form = SellerEditForm(request.POST, request.FILES, instance=seller)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
seller = form.save(commit=False)
|
|
||||||
if 'store_name' in form.changed_data:
|
if 'store_name' in form.changed_data:
|
||||||
seller.slug = slugify(seller.store_name)
|
form.instance.slug = slugify(form.cleaned_data['store_name'])
|
||||||
seller.save()
|
form.save()
|
||||||
return redirect('store:seller_profile', slug=seller.slug)
|
return redirect('store:seller_profile', slug=seller.slug)
|
||||||
else:
|
else:
|
||||||
form = SellerEditForm(instance=seller)
|
form = SellerEditForm(instance=seller)
|
||||||
@@ -1057,7 +1111,7 @@ import csv # Added import
|
|||||||
import io # Added import
|
import io # Added import
|
||||||
|
|
||||||
# ... existing imports ...
|
# ... 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] ...
|
# ... [Keep existing code until add_card_listing] ...
|
||||||
|
|
||||||
@@ -1093,6 +1147,11 @@ def add_card_listing(request):
|
|||||||
except Seller.DoesNotExist:
|
except Seller.DoesNotExist:
|
||||||
return redirect('store:seller_register')
|
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
|
bulk_form = BulkListingForm() # Initialize bulk form
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -1245,6 +1304,11 @@ def add_pack_listing(request):
|
|||||||
return redirect('store:manage_listings')
|
return redirect('store:manage_listings')
|
||||||
seller = request.user.seller_profile
|
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()
|
bulk_form = BulkListingForm()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
{% if user.seller_profile %}
|
{% if user.seller_profile %}
|
||||||
{# Seller Navigation #}
|
{# Seller Navigation #}
|
||||||
<a href="{% url 'store:seller_dashboard' %}">Dashboard</a>
|
<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>
|
<a href="{% url 'store:manage_listings' %}">Inventory</a>
|
||||||
{% if debug %}
|
{% if debug %}
|
||||||
<a href="{% url 'store:bounty_list' %}">Bounties</a>
|
<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>
|
<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>
|
</ul>
|
||||||
</div>
|
</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;">
|
<div style="text-align: left;">
|
||||||
<h4 style="color: #e2e8f0; font-weight: 600; margin-bottom: 1rem;">Legal</h4>
|
<h4 style="color: #e2e8f0; font-weight: 600; margin-bottom: 1rem;">Legal</h4>
|
||||||
<ul style="list-style: none; padding: 0;">
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
|||||||
@@ -1,33 +1,95 @@
|
|||||||
{% extends 'base/layout.html' %}
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
{% block title %}Terms and Service - TCGKof{% endblock %}
|
{% block title %}Terms of Service - TCGKof{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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);">
|
<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);">
|
||||||
<h1 style="color: var(--primary-color); margin-bottom: 2rem; text-align: center;">Terms of Service</h1>
|
<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;">
|
<div class="terms-body" style="color: var(--text-color); line-height: 1.7;">
|
||||||
<p style="margin-bottom: 1.5rem;">Welcome to TCGKof. By accessing our website, you agree to these terms and conditions.</p>
|
<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>
|
<section style="margin-bottom: 2.5rem;">
|
||||||
<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>
|
<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>
|
<section style="margin-bottom: 2.5rem;">
|
||||||
<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>
|
<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>
|
<section style="margin-bottom: 2.5rem;">
|
||||||
<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>
|
<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>
|
<section style="margin-bottom: 2.5rem;">
|
||||||
<p style="margin-bottom: 1rem;">Users may not post content that is illegal, obscene, threatening, defamatory, or otherwise objectionable.</p>
|
<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>
|
<section style="margin-bottom: 2.5rem;">
|
||||||
<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>
|
<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>
|
<section style="margin-bottom: 2.5rem;">
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
108
tests/test_marketplace.py
Normal file
108
tests/test_marketplace.py
Normal 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)
|
||||||
|
|
||||||
@@ -35,13 +35,31 @@ class AddressForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PaymentMethodForm(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:
|
class Meta:
|
||||||
model = PaymentMethod
|
model = PaymentMethod
|
||||||
fields = ('brand', 'last4', 'exp_month', 'exp_year', 'is_default')
|
fields = ('brand', 'exp_month', 'exp_year', 'billing_address', 'is_default')
|
||||||
widgets = {
|
widgets = {
|
||||||
'brand': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Card Brand (e.g. Visa)'}),
|
'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_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'}),
|
'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'}),
|
'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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -55,13 +55,44 @@ class Address(models.Model):
|
|||||||
|
|
||||||
class PaymentMethod(models.Model):
|
class PaymentMethod(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='payment_methods')
|
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
|
brand = models.CharField(max_length=50) # Visa, Mastercard
|
||||||
last4 = models.CharField(max_length=4)
|
last4 = models.CharField(max_length=4)
|
||||||
exp_month = models.PositiveIntegerField()
|
exp_month = models.PositiveIntegerField()
|
||||||
exp_year = models.PositiveIntegerField()
|
exp_year = models.PositiveIntegerField()
|
||||||
is_default = models.BooleanField(default=False)
|
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):
|
def __str__(self):
|
||||||
return f"{self.brand} ending in {self.last4}"
|
return f"{self.brand} ending in {self.last4}"
|
||||||
|
|
||||||
|
|||||||
28
users/tests/test_payment.py
Normal file
28
users/tests/test_payment.py
Normal 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')
|
||||||
@@ -105,7 +105,7 @@ def delete_address_view(request, pk):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_payment_method_view(request):
|
def add_payment_method_view(request):
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = PaymentMethodForm(request.POST)
|
form = PaymentMethodForm(request.POST, user=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
pm = form.save(commit=False)
|
pm = form.save(commit=False)
|
||||||
pm.user = request.user
|
pm.user = request.user
|
||||||
@@ -115,7 +115,7 @@ def add_payment_method_view(request):
|
|||||||
messages.success(request, 'Payment method added successfully.')
|
messages.success(request, 'Payment method added successfully.')
|
||||||
return redirect('users:profile')
|
return redirect('users:profile')
|
||||||
else:
|
else:
|
||||||
form = PaymentMethodForm()
|
form = PaymentMethodForm(user=request.user)
|
||||||
return render(request, 'users/payment_form.html', {'form': form})
|
return render(request, 'users/payment_form.html', {'form': form})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
Reference in New Issue
Block a user