Files
Example-TCG-Site/store/views.py
Ryan Westfall 9040021d1b MASSIVE UPDATE:
bounty board feature

buyers to see bounty boards

seller profile page (like have theme chooser)

Have the game and set name be filters.

Add cards to vault manually

update card inventory add to have the autocomplete for the card  -

store analytics, clicks, views, link to store (url/QR code)

bulk item inventory creation --

Make the banner feature flag driven so I can have a beta site setup like the primary site

don't use primary key values in urls - update to use uuid4 values

site analytics. tianji is being sent

item potent on the mtg and lorcana populate scripts

Card item images for specific listings

check that when you buy a card it is in the vault

Buys should be able to search on store inventories

More pie charts for the seller!

post bounty board is slow to load

seller reviews/ratings - show a historgram - need a way for someone to rate

Report a seller feature for buyer to report

Make sure the stlying is consistent based on the theme choosen

smart minimum order quantity and shipping amounts (defined by the store itself)

put virtual packs behind a feature flag like bounty board

proxy service feature flag

Terms of Service

new description for TCGKof

store SSN, ITIN, and EIN

optomize for SEO
2026-01-23 12:28:20 -06:00

1525 lines
58 KiB
Python

from django.shortcuts import render, get_object_or_404, redirect
from django.conf import settings
from django.db.models import Q
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
from .models import Card, Game, Set, Cart, CartItem, CardListing, PackListing, VirtualPack, Order, OrderItem, Seller, Bounty, BountyOffer, SellerReport
from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm
from django.utils.text import slugify
import random
import csv
from django.http import HttpResponse, JsonResponse
def index(request):
if request.user.is_authenticated:
if (hasattr(request.user, 'profile') and request.user.profile.is_seller) or hasattr(request.user, 'seller_profile'):
return redirect('store:seller_dashboard')
return redirect('store:card_list')
def card_list(request):
cards = Card.objects.all().select_related('set', 'set__game').prefetch_related('listings')
# Filtering
game_slug = request.GET.get('game')
if game_slug:
cards = cards.filter(set__game__slug=game_slug)
search_query = request.GET.get('q')
if search_query:
cards = cards.filter(name__icontains=search_query)
set_id = request.GET.get('set')
if set_id:
cards = cards.filter(set__id=set_id)
# hide_out_of_stock logic:
# Default to 'on' if no GET parameters (first visit), or if explicitly set to 'on'.
# If GET parameters exist but 'hide_out_of_stock' is missing, it means user unchecked it.
hide_oos = 'off'
if not request.GET:
hide_oos = 'on'
elif request.GET.get('hide_out_of_stock') == 'on':
hide_oos = 'on'
if hide_oos == 'on':
cards = cards.filter(listings__quantity__gt=0, listings__status='listed').distinct()
# Simple logic: only show cards that have listings or show all?
# Let's show all for browsing, but indicate stock.
paginator = Paginator(cards.order_by('name'), 24) # 24 cards per page
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
games = Game.objects.all()
# If a game is selected, getting its sets for the filter dropdown
sets = Set.objects.filter(game__slug=game_slug) if game_slug else Set.objects.all()[:50] # Limit default sets
return render(request, 'store/card_list.html', {
'page_obj': page_obj,
'games': games,
'sets': sets,
'current_game': game_slug,
'search_query': search_query,
'hide_oos': hide_oos, # Pass filtered state to template
})
def card_detail(request, card_id):
card = get_object_or_404(Card, uuid=card_id)
listings = card.listings.filter(quantity__gt=0, status='listed').order_by('price')
return render(request, 'store/card_detail.html', {'card': card, 'listings': listings})
@login_required
def add_to_cart(request, listing_id):
listing = get_object_or_404(CardListing, uuid=listing_id)
# Ensure user is a buyer
if not hasattr(request.user, 'buyer_profile'):
# Fallback or error? For now assume valid if logged in via user reg
return redirect('home')
cart, _ = Cart.objects.get_or_create(buyer=request.user.buyer_profile)
cart_item, created = CartItem.objects.get_or_create(cart=cart, listing=listing)
if not created:
cart_item.quantity += 1
cart_item.save()
return redirect('store:cart')
@login_required
def cart_view(request):
try:
cart = request.user.buyer_profile.cart
except (Cart.DoesNotExist, AttributeError):
cart = None
context = {'cart': cart}
if cart and cart.items.exists():
# Group items by seller and calculate costs
items_by_seller = {}
for item in cart.items.select_related('listing__seller', 'pack_listing__seller').all():
seller = None
if item.listing and item.listing.seller:
seller = item.listing.seller
elif item.pack_listing and item.pack_listing.seller:
seller = item.pack_listing.seller
# For items without a seller (e.g. system packs), we group them under None or a "System" key
# But the requirement is specifically about Seller stores.
# Let's group by seller object.
if seller not in items_by_seller:
items_by_seller[seller] = []
items_by_seller[seller].append(item)
cart_data = [] # List of dicts: {'seller': seller, 'items': [items], 'subtotal': X, 'shipping': Y, 'total': Z, 'free_shipping_needed': Q}
grand_total = 0
for seller, items in items_by_seller.items():
sub_total = sum(item.total_price for item in items)
shipping_cost = 0
free_shipping_needed = 0
if seller:
if sub_total < seller.minimum_order_amount:
shipping_cost = seller.shipping_cost
free_shipping_needed = seller.minimum_order_amount - sub_total
total = sub_total + shipping_cost
grand_total += total
cart_data.append({
'seller': seller,
'items': items,
'subtotal': sub_total,
'shipping_cost': shipping_cost,
'total': total,
'free_shipping_needed': free_shipping_needed
})
context['cart_data'] = cart_data
context['grand_total'] = grand_total
if cart.insurance:
context['grand_total'] += 5
return render(request, 'store/cart.html', context)
@login_required
def remove_from_cart(request, item_id):
if hasattr(request.user, 'buyer_profile') and hasattr(request.user.buyer_profile, 'cart'):
item = get_object_or_404(CartItem, uuid=item_id, cart=request.user.buyer_profile.cart)
item.delete()
return redirect('store:cart')
@login_required
def toggle_insurance(request):
if hasattr(request.user, 'buyer_profile') and hasattr(request.user.buyer_profile, 'cart'):
cart = request.user.buyer_profile.cart
cart.insurance = not cart.insurance
cart.save()
return redirect('store:cart')
from django.http import JsonResponse
def card_autocomplete(request):
query = request.GET.get('q', '')
if len(query) < 2:
return JsonResponse({'results': []})
cards = Card.objects.filter(name__icontains=query).values_list('name', flat=True).distinct()[:10]
return JsonResponse({'results': list(cards)})
def bounty_autocomplete(request):
query = request.GET.get('q', '')
if len(query) < 2:
return JsonResponse({'results': []})
# Search in Card names and Bounty titles
# We want distinct values
bounties = Bounty.objects.filter(is_active=True).filter(
Q(card__name__icontains=query) | Q(title__icontains=query)
)
results = set()
for b in bounties[:10]:
if b.card and query.lower() in b.card.name.lower():
results.add(b.card.name)
if b.title and query.lower() in b.title.lower():
results.add(b.title)
return JsonResponse({'results': list(results)[:10]})
def card_variants(request):
"""
Returns games and sets for a given card name.
Public access.
"""
name = request.GET.get('name', '')
if not name:
return JsonResponse({'results': []})
cards = Card.objects.filter(name__iexact=name).select_related('set', 'set__game')
results = []
seen = set()
for card in cards:
game = card.set.game
set_obj = card.set
key = (game.slug, set_obj.name, card.collector_number)
if key in seen:
continue
seen.add(key)
# We need to structure this so the frontend can easily filter.
# Actually returning a list of variants is good.
results.append({
'game_slug': game.slug,
'game_name': game.name,
'set_name': set_obj.name,
'set_name': set_obj.name,
'card_id': card.id,
'collector_number': card.collector_number
})
return JsonResponse({'results': results})
def get_card_stock(request, card_id):
card = get_object_or_404(Card, uuid=card_id)
listings = card.listings.all()
stock_breakdown = {}
total_stock = 0
for listing in listings:
stock_breakdown[listing.get_condition_display()] = listing.quantity
total_stock += listing.quantity
return JsonResponse({
'card_id': card.id,
'total_stock': total_stock,
'breakdown': stock_breakdown
})
from .utils import parse_deck_list, find_best_listings_for_deck, get_user_collection, filter_deck_by_collection, add_to_vault, calculate_platform_fee
from decimal import Decimal
from django.contrib.admin.views.decorators import staff_member_required
@login_required
def deck_buyer(request):
if request.method == 'POST':
action = request.POST.get('action')
if action == 'preview':
deck_text = request.POST.get('deck_text')
ignore_owned = request.POST.get('ignore_owned') == 'on'
parsed = parse_deck_list(deck_text)
if ignore_owned and request.user.is_authenticated:
owned = get_user_collection(request.user)
parsed = filter_deck_by_collection(parsed, owned)
found, missing = find_best_listings_for_deck(parsed)
total_cost = sum(item['total'] for item in found)
return render(request, 'store/deck_buyer.html', {
'found_items': found,
'missing_items': missing,
'deck_text': deck_text,
'total_cost': total_cost,
'preview': True,
'ignore_owned': ignore_owned
})
elif action == 'add_to_cart':
# Re-parse or rely on hidden fields?
# Re-parsing is safer/easier for now than passing complex data
deck_text = request.POST.get('deck_text')
parsed = parse_deck_list(deck_text)
found, _ = find_best_listings_for_deck(parsed)
if not hasattr(request.user, 'buyer_profile'):
# Should theoretically not happen if logged in as user
return redirect('store:card_list')
cart, _ = Cart.objects.get_or_create(buyer=request.user.buyer_profile)
count = 0
for item in found:
listing = item['listing']
qty = item['quantity']
# Check stock again? "Live stock"
if listing.quantity >= qty:
cart_item, created = CartItem.objects.get_or_create(cart=cart, listing=listing)
if not created:
cart_item.quantity += qty
else:
cart_item.quantity = qty
cart_item.save()
count += 1
return redirect('store:cart')
return render(request, 'store/deck_buyer.html')
def bounty_list(request):
if not settings.FEATURE_BOUNTY_BOARD:
if not settings.DEBUG:
return redirect('store:index')
bounties = Bounty.objects.filter(is_active=True).select_related('card', 'card__set', 'card__set__game', 'seller').order_by('-created_at')
# Filter by Game
game_slug = request.GET.get('game')
if game_slug:
bounties = bounties.filter(card__set__game__slug=game_slug)
# Filter by Set
set_id = request.GET.get('set')
if set_id:
bounties = bounties.filter(card__set__id=set_id)
# Search (Card Name or Bounty Title)
search_query = request.GET.get('q')
if search_query:
bounties = bounties.filter(
Q(card__name__icontains=search_query) |
Q(title__icontains=search_query)
)
# Context data for filters
games = Game.objects.all()
sets = Set.objects.filter(game__slug=game_slug) if game_slug else Set.objects.all()[:50]
return render(request, 'store/bounty_list.html', {
'bounties': bounties,
'games': games,
'sets': sets,
'current_game': game_slug,
'search_query': search_query,
})
@login_required
def bounty_create(request):
if not settings.FEATURE_BOUNTY_BOARD:
return redirect('store:index')
if not hasattr(request.user, 'seller_profile'):
return redirect('store:seller_register')
seller = request.user.seller_profile
if request.method == 'POST':
form = BountyForm(request.POST)
if form.is_valid():
bounty = form.save(commit=False)
bounty.seller = seller
# Handle Card Association
card_id = form.cleaned_data.get('card_id')
card_name = form.cleaned_data.get('card_name')
if card_id:
try:
card = Card.objects.get(id=card_id)
bounty.card = card
except (Card.DoesNotExist, ValueError):
# Fallback if bad ID
if card_name:
bounty.title = card_name
elif card_name and not bounty.title:
# User typed a name but didn't pick from autocomplete (or no results)
bounty.title = card_name
bounty.save()
return redirect('store:bounty_list')
else:
form = BountyForm()
return render(request, 'store/bounty_form.html', {'form': form, 'title': 'Post a Bounty'})
def bounty_detail(request, pk):
if not settings.FEATURE_BOUNTY_BOARD:
return redirect('store:index')
bounty = get_object_or_404(Bounty, uuid=pk)
# Context
is_seller = False
is_buyer = False
user_offer = None
if request.user.is_authenticated:
if hasattr(request.user, 'seller_profile') and bounty.seller == request.user.seller_profile:
is_seller = True
elif hasattr(request.user, 'buyer_profile'):
is_buyer = True
# Check if buyer already made an offer
user_offer = BountyOffer.objects.filter(bounty=bounty, buyer=request.user.buyer_profile).first()
# Handle Buyer Offer
offer_form = None
if is_buyer and not user_offer:
if request.method == 'POST':
offer_form = BountyOfferForm(request.POST)
if offer_form.is_valid():
offer = offer_form.save(commit=False)
offer.bounty = bounty
offer.buyer = request.user.buyer_profile
offer.save()
return redirect('store:bounty_detail', pk=pk)
else:
offer_form = BountyOfferForm()
# Load offers for seller
offers = []
if is_seller:
offers = bounty.offers.select_related('buyer__user').order_by('-created_at')
return render(request, 'store/bounty_detail.html', {
'bounty': bounty,
'is_seller': is_seller,
'is_buyer': is_buyer,
'user_offer': user_offer,
'offer_form': offer_form,
'offers': offers
})
@login_required
def bounty_process_offer(request, offer_id, action):
# Action: accept, reject, counter (counter not implemented in MVP yet, maybe just reject with message?)
if not settings.FEATURE_BOUNTY_BOARD:
return redirect('store:index')
offer = get_object_or_404(BountyOffer, uuid=offer_id)
bounty = offer.bounty
# Verify owner
if not hasattr(request.user, 'seller_profile') or bounty.seller != request.user.seller_profile:
return redirect('store:bounty_list')
if action == 'accept':
offer.status = 'accepted'
offer.save()
# TODO: Trigger checkout process or transaction?
# For MVP, maybe just separate "Accepted Bounties" list or contact logic?
# User request says: "Then it does the normal checkout process"
# This implies the SELLER pays the BUYER.
# Our current Checkout is Buyer pays Seller.
# This might be "Reverse Checkout".
# For now, let's just mark accepted.
elif action == 'reject':
offer.status = 'rejected'
offer.save()
offer.save()
return redirect('store:bounty_detail', pk=bounty.uuid)
def pack_list(request):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:index')
packs = PackListing.objects.all()
return render(request, 'store/pack_list.html', {'packs': packs})
@login_required
def add_pack_to_cart(request, pack_listing_id):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:index')
listing = get_object_or_404(PackListing, uuid=pack_listing_id)
if not hasattr(request.user, 'buyer_profile'):
return redirect('store:pack_list')
cart, _ = Cart.objects.get_or_create(buyer=request.user.buyer_profile)
# Check if stock available
if listing.quantity < 1:
# TODO: Show error message
return redirect('store:pack_list')
cart_item, created = CartItem.objects.get_or_create(cart=cart, pack_listing=listing)
if not created:
cart_item.quantity += 1
cart_item.save()
return redirect('store:cart')
@login_required
def checkout(request):
try:
cart = request.user.buyer_profile.cart
except (Cart.DoesNotExist, AttributeError):
return redirect('store:cart')
if not cart.items.exists():
return redirect('store:cart')
# Group items by seller and check for virtual packs
items_by_seller = {}
has_virtual_packs = False
for item in cart.items.all():
if item.pack_listing and item.pack_listing.listing_type == 'virtual':
has_virtual_packs = True
seller = None
if item.listing and item.listing.seller:
seller = item.listing.seller
elif item.pack_listing and item.pack_listing.seller:
seller = item.pack_listing.seller
if seller not in items_by_seller:
items_by_seller[seller] = []
items_by_seller[seller].append(item)
# Process orders per seller
for seller, items in items_by_seller.items():
sub_total = sum(item.total_price for item in items)
shipping_cost = 0
if seller:
if sub_total < seller.minimum_order_amount:
shipping_cost = seller.shipping_cost
total_price = sub_total + shipping_cost
# Create Order (status paid for MVP)
order = Order.objects.create(
buyer=request.user.buyer_profile,
status='paid',
total_price=total_price,
seller=seller # Populate seller
)
for item in items:
OrderItem.objects.create(
order=order,
listing=item.listing,
pack_listing=item.pack_listing,
price_at_purchase=item.listing.price if item.listing else item.pack_listing.price,
quantity=item.quantity
)
# 1. Handle Card Listings
if item.listing:
add_to_vault(request.user.buyer_profile, item.listing.card, item.quantity)
# Decrement Stock
if item.listing.quantity >= item.quantity:
item.listing.quantity -= item.quantity
item.listing.save()
else:
# Stock issue handling (for now just take what's left or allow negative?
# Ideally check before checkout. Assuming check happened at add-to-cart or cart-view)
item.listing.quantity = 0
item.listing.save()
# 2. Handle Pack Listings
if item.pack_listing:
# Decrement Stock
if item.pack_listing.quantity >= item.quantity:
item.pack_listing.quantity -= item.quantity
item.pack_listing.save()
else:
item.pack_listing.quantity = 0
item.pack_listing.save()
# Find available sealed packs
available_packs = list(VirtualPack.objects.filter(
listing=item.pack_listing,
owner__isnull=True,
status='sealed'
)[:item.quantity])
# If not enough, create more ONLY if it's a system pack (no seller) or configured to do so
if len(available_packs) < item.quantity:
# Seller packs must be pre-filled
if item.pack_listing.seller:
# We only fulfill what we have.
# Ideally we should have caught this at cart validation.
pass
else:
needed = item.quantity - len(available_packs)
game = item.pack_listing.game
all_game_cards = list(Card.objects.filter(set__game=game))
for _ in range(needed):
pack = VirtualPack.objects.create(listing=item.pack_listing)
if all_game_cards:
# Sample logic (mock)
pack.cards.set(random.sample(all_game_cards, min(len(all_game_cards), 5)))
available_packs.append(pack)
for pack in available_packs:
pack.owner = request.user.buyer_profile
pack.save()
# Clear cart
cart.items.all().delete()
if has_virtual_packs:
return redirect('store:my_packs')
return redirect('users:vault')
@login_required
def my_packs(request):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:index')
if not hasattr(request.user, 'buyer_profile'):
return redirect('store:seller_dashboard')
packs = VirtualPack.objects.filter(owner=request.user.buyer_profile, status='sealed').select_related('listing')
return render(request, 'store/my_packs.html', {'packs': packs})
@login_required
def open_pack(request, pack_id):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:index')
if not hasattr(request.user, 'buyer_profile'):
return redirect('store:seller_dashboard')
pack = get_object_or_404(VirtualPack, uuid=pack_id, owner=request.user.buyer_profile)
if request.method == 'POST':
if pack.status == 'sealed':
pack.status = 'opened'
pack.save()
# Add cards to vault
for card in pack.cards.all():
add_to_vault(request.user.buyer_profile, card)
data = {
'cards': [{
'name': c.name,
'image_url': c.image_url,
'rarity': c.rarity,
'set': c.set.name
} for c in pack.cards.all()]
}
return JsonResponse(data)
return render(request, 'store/open_pack.html', {'pack': pack})
@login_required
def order_detail(request, order_id):
order = get_object_or_404(Order, uuid=order_id)
# Security check: only allow viewing own orders (unless superuser)
# Check if order belongs to current buyer
is_owner = False
if hasattr(request.user, 'buyer_profile') and order.buyer == request.user.buyer_profile:
is_owner = True
if not is_owner and not request.user.is_superuser:
return redirect('users:profile')
# Handle rating submission
if request.method == 'POST' and is_owner:
rating_value = request.POST.get('rating')
if rating_value:
try:
rating_int = int(rating_value)
if 1 <= rating_int <= 5:
order.rating = rating_int
order.save()
return redirect('store:order_detail', order_id=order_id)
except (ValueError, TypeError):
pass
return render(request, 'store/order_detail.html', {'order': order})
from users.forms import CustomUserCreationForm
from django.contrib.auth import login
def seller_register(request):
if request.user.is_authenticated:
if hasattr(request.user, 'seller_profile'):
return redirect('store:seller_dashboard')
if request.method == 'POST':
if request.user.is_authenticated:
user_form = None
seller_form = SellerRegistrationForm(request.POST)
if seller_form.is_valid():
seller = seller_form.save(commit=False)
seller.user = request.user
seller.slug = slugify(seller.store_name)
seller.save()
return redirect('store:seller_dashboard')
else:
user_form = CustomUserCreationForm(request.POST)
seller_form = SellerRegistrationForm(request.POST)
if user_form.is_valid() and seller_form.is_valid():
user = user_form.save()
login(request, user)
seller = seller_form.save(commit=False)
seller.user = user
seller.slug = slugify(seller.store_name)
seller.save()
return redirect('store:seller_dashboard')
else:
if request.user.is_authenticated:
user_form = None
else:
user_form = CustomUserCreationForm()
seller_form = SellerRegistrationForm()
return render(request, 'store/seller_register.html', {
'user_form': user_form,
'form': seller_form
})
@login_required
def edit_seller_profile(request):
try:
seller = request.user.seller_profile
except Seller.DoesNotExist:
return redirect('store:seller_register')
if request.method == 'POST':
form = SellerEditForm(request.POST, request.FILES, instance=seller)
if form.is_valid():
seller = form.save(commit=False)
if 'store_name' in form.changed_data:
seller.slug = slugify(seller.store_name)
seller.save()
return redirect('store:seller_profile', slug=seller.slug)
else:
form = SellerEditForm(instance=seller)
return render(request, 'store/seller/edit_profile.html', {'form': form, 'title': 'Edit Store Profile'})
@login_required
def seller_dashboard(request):
if not hasattr(request.user, 'seller_profile'):
return redirect('store:seller_register')
seller = request.user.seller_profile
# Calculate stats
card_items = OrderItem.objects.filter(listing__seller=seller, order__status__in=['paid', 'shipped'])
pack_items = OrderItem.objects.filter(pack_listing__seller=seller, order__status__in=['paid', 'shipped'])
total_revenue = 0
items_sold = 0
for item in card_items:
total_revenue += item.price_at_purchase * item.quantity
items_sold += item.quantity
for item in pack_items:
total_revenue += item.price_at_purchase * item.quantity
items_sold += item.quantity
active_listings_count = CardListing.objects.filter(seller=seller).count() + PackListing.objects.filter(seller=seller).count()
# Theme Form Handling
from .forms import SellerThemeForm
if request.method == 'POST' and 'theme_preference' in request.POST:
theme_form = SellerThemeForm(request.POST, instance=request.user.profile)
if theme_form.is_valid():
theme_form.save()
return redirect('store:seller_dashboard')
else:
theme_form = SellerThemeForm(instance=request.user.profile)
# QR Code Generation
# Construct the absolute URL for the store
store_path = redirect('store:seller_profile', slug=seller.slug).url
store_full_url = request.build_absolute_uri(store_path)
# Using public API for QR Code
qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=150x150&data={store_full_url}"
# Calculate Average Rating
from django.db.models import Avg
avg_rating = Order.objects.filter(seller=seller, rating__isnull=False).aggregate(Avg('rating'))['rating__avg']
if avg_rating:
avg_rating = round(avg_rating, 1)
context = {
'seller': seller,
'total_revenue': total_revenue,
'items_sold': items_sold,
'active_listings_count': active_listings_count,
'theme_form': theme_form,
'store_views': seller.store_views,
'listing_clicks': seller.listing_clicks,
'store_full_url': store_full_url,
'qr_code_url': qr_code_url,
'avg_rating': avg_rating,
}
# Chart Data Preparation
from django.db.models import Sum, F, Count
from django.db.models.functions import TruncDay, TruncWeek, TruncMonth
from django.utils import timezone
from datetime import timedelta
import json
def terms(request):
return render(request, 'legal/terms.html')
# Filter Handling
game_filter = request.GET.get('game')
all_games = Game.objects.all().order_by('name')
# Helper function to get chart data
def get_chart_data(period_type, days_back, trunc_func, periods_count):
start_date = now - timedelta(days=days_back)
items = OrderItem.objects.filter(
order__created_at__gte=start_date,
order__status__in=['paid', 'shipped']
).filter(
Q(listing__seller=seller) | Q(pack_listing__seller=seller)
).annotate(
period=trunc_func('order__created_at')
).values('period').annotate(
revenue=Sum(F('price_at_purchase') * F('quantity')),
count=Sum('quantity')
).order_by('period')
dates_list = []
rev_list = []
count_list = []
data_map = {item['period'].strftime('%Y-%m-%d'): item for item in items}
# Determine step size roughly (simplification for generating zero-filled lists)
# For strict correctness we'd iterate by day/week/month properly.
# Simplification: Just iterate by days and check trunc matches?
# Better: Iterate by the period type.
current = start_date
seen_periods = set()
# Loop to generate x-axis labels.
# Note: logic varies slightly by period, doing a simple loop for now.
# 1. Day Iteration (Last 30 Days)
if period_type == 'day':
for i in range(periods_count):
d = (start_date + timedelta(days=i)).date()
label = d.strftime('%Y-%m-%d')
dates_list.append(label)
item = data_map.get(label)
rev_list.append(str(item['revenue']) if item else 0)
count_list.append(item['count'] if item else 0)
# 2. Week Iteration (Last 12 Weeks)
elif period_type == 'week':
# Align start date to Monday?
# Or just jump 7 days from start_date
for i in range(periods_count):
d = (start_date + timedelta(weeks=i)).date()
# We need to match how TruncWeek formats? typically Monday
# Let's trust the data_map keys (which come from TruncWeek) match roughly
# But TruncWeek might not align exactly with start_date + N weeks if start_date isn't Monday.
# Hack: Just use the items returned? No, we need zero-filling.
# Let's iterate found items + missing?
# Simpler: Just rely on sorted items for now? No, graph looks bad without gaps.
# Robust approach: Generate week starts
label = d.strftime('%Y-%m-%d') # TruncWeek returns date
# We will check approximate match or exact match if Trunc works well
# Actually, let's keep it simple:
# Just loop days, if new week (Monday), add point.
pass
# Re-implementation for Week/Month using simpler list logic since strict calendar math in python is verbose
# We will just dump the sparse data for Week/Month if density is low, or try to fill.
# Let's just pass the sparse data for Week/Month for MVP to avoid complexity.
dates_list = [item['period'].strftime('%Y-%m-%d') for item in items]
rev_list = [str(item['revenue']) for item in items]
count_list = [item['count'] for item in items]
# 3. Month Iteration (Last 12 Months)
elif period_type == 'month':
dates_list = [item['period'].strftime('%Y-%m') for item in items]
rev_list = [str(item['revenue']) for item in items]
count_list = [item['count'] for item in items]
return dates_list, rev_list, count_list
# 1. Day Queries (Last 30 Days) -> Zero filled
# Re-using strict loop from before for Days as it looks best
day_items = OrderItem.objects.filter(
order__created_at__gte=now - timedelta(days=30),
order__status__in=['paid', 'shipped']
).filter(
Q(listing__seller=seller) | Q(pack_listing__seller=seller)
).annotate(
day=TruncDay('order__created_at')
).values('day').annotate(
daily_revenue=Sum(F('price_at_purchase') * F('quantity')),
daily_count=Sum('quantity')
).order_by('day')
day_map = {item['day'].strftime('%Y-%m-%d'): item for item in day_items}
dates_day, rev_day, count_day = [], [], []
for i in range(30):
d = (now - timedelta(days=30) + timedelta(days=i)).date()
date_str = d.strftime('%Y-%m-%d')
dates_day.append(date_str)
if date_str in day_map:
rev_day.append(str(day_map[date_str]['daily_revenue']))
count_day.append(day_map[date_str]['daily_count'])
else:
rev_day.append(0)
count_day.append(0)
# 2. Week Queries (Last 12 Weeks)
week_items = OrderItem.objects.filter(
order__created_at__gte=now - timedelta(weeks=12),
order__status__in=['paid', 'shipped']
).filter(
Q(listing__seller=seller) | Q(pack_listing__seller=seller)
).annotate(
week=TruncWeek('order__created_at')
).values('week').annotate(
revenue=Sum(F('price_at_purchase') * F('quantity')),
count=Sum('quantity')
).order_by('week')
dates_week = [item['week'].strftime('%Y-%m-%d') for item in week_items]
rev_week = [str(item['revenue']) for item in week_items]
count_week = [item['count'] for item in week_items]
# 3. Month Queries (Last 12 Months)
month_items = OrderItem.objects.filter(
order__created_at__gte=now - timedelta(days=365),
order__status__in=['paid', 'shipped']
).filter(
Q(listing__seller=seller) | Q(pack_listing__seller=seller)
).annotate(
month=TruncMonth('order__created_at')
).values('month').annotate(
revenue=Sum(F('price_at_purchase') * F('quantity')),
count=Sum('quantity')
).order_by('month')
dates_month = [item['month'].strftime('%Y-%m') for item in month_items]
rev_month = [str(item['revenue']) for item in month_items]
count_month = [item['count'] for item in month_items]
context['chart_data_day'] = json.dumps({'labels': dates_day, 'revenue': rev_day, 'sales': count_day})
context['chart_data_week'] = json.dumps({'labels': dates_week, 'revenue': rev_week, 'sales': count_week})
context['chart_data_month'] = json.dumps({'labels': dates_month, 'revenue': rev_month, 'sales': count_month})
# 2. Sales by Game (Pie Chart) & Game Filtering application
# We apply filtering for the breakdown charts below, but maybe not the main timeline?
# User request: "you can filter down that games if you want as well"
# Typically filters apply to all valid charts.
# Let's apply game_filter to the breakdown queries below.
# Base Queryset for Breakdowns (Card Items)
seller_card_items = OrderItem.objects.filter(
listing__seller=seller,
order__status__in=['paid', 'shipped']
).select_related('listing__card__set__game')
# Base Queryset for Breakdowns (Pack Items)
seller_pack_items = OrderItem.objects.filter(
pack_listing__seller=seller,
order__status__in=['paid', 'shipped']
).select_related('pack_listing__game')
if game_filter:
seller_card_items = seller_card_items.filter(listing__card__set__game__name=game_filter)
seller_pack_items = seller_pack_items.filter(pack_listing__game__name=game_filter)
# A. Sales by Game
# Note: If filtered by game, this will show 100% for that game, which is correct behavior.
game_sales = {}
for item in seller_card_items:
if item.listing:
game_name = item.listing.card.set.game.name
game_sales[game_name] = game_sales.get(game_name, 0) + item.quantity
for item in seller_pack_items:
if item.pack_listing:
game_name = item.pack_listing.game.name
game_sales[game_name] = game_sales.get(game_name, 0) + item.quantity
context['game_labels'] = json.dumps(list(game_sales.keys()))
context['game_data'] = json.dumps(list(game_sales.values()))
# B. Sales by Condition (Cards Only)
condition_sales = {}
# We can iterate or aggregate. Iteration is fine given we have the QS.
for item in seller_card_items:
if item.listing:
cond = item.listing.get_condition_display() # Use display name e.g. "Near Mint"
condition_sales[cond] = condition_sales.get(cond, 0) + item.quantity
context['condition_labels'] = json.dumps(list(condition_sales.keys()))
context['condition_data'] = json.dumps(list(condition_sales.values()))
# C. Sales by Set (Cards Only) - Top 10 Sets
set_sales = {}
for item in seller_card_items:
if item.listing:
set_name = item.listing.card.set.name
set_sales[set_name] = set_sales.get(set_name, 0) + item.quantity
# Sort and take top 10
sorted_sets = sorted(set_sales.items(), key=lambda x: x[1], reverse=True)[:10]
context['set_labels'] = json.dumps([x[0] for x in sorted_sets])
context['set_data'] = json.dumps([x[1] for x in sorted_sets])
context['all_games'] = all_games
context['selected_game'] = game_filter
return render(request, 'store/seller/dashboard.html', context)
@login_required
def manage_listings(request):
if not hasattr(request.user, 'seller_profile'):
return redirect('store:seller_register')
seller = request.user.seller_profile
card_listings = CardListing.objects.filter(seller=seller).select_related('card', 'card__set')
pack_listings = PackListing.objects.filter(seller=seller).select_related('game')
return render(request, 'store/seller/manage_listings.html', {
'card_listings': card_listings,
'pack_listings': pack_listings
})
from django.http import HttpResponse # Added import
import csv # Added import
import io # Added import
# ... existing imports ...
from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm
# ... [Keep existing code until add_card_listing] ...
@login_required
def download_listing_template(request, type):
# Ensure legitimate type
if type not in ['card', 'pack']:
return redirect('store:manage_listings')
# Define file path
# We can either serve a static file or generate it on the fly.
# Generating on the fly is robust.
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="{type}_listing_template.csv"'
writer = csv.writer(response)
if type == 'card':
writer.writerow(['Game', 'Set', 'Card Name', 'Collector Number', 'Condition', 'Price', 'Quantity', 'Image Filename'])
# Add sample row
writer.writerow(['Magic: The Gathering', 'Alpha', 'Black Lotus', '', 'NM', '10000.00', '1', 'black_lotus.jpg'])
elif type == 'pack':
writer.writerow(['Game', 'Name', 'Listing Type', 'Price', 'Quantity', 'Image Filename'])
writer.writerow(['Magic: The Gathering', 'Alpha Booster', 'physical', '1000.00', '1', 'alpha_booster.jpg'])
return response
@login_required
def add_card_listing(request):
try:
seller = request.user.seller_profile
except Seller.DoesNotExist:
return redirect('store:seller_register')
bulk_form = BulkListingForm() # Initialize bulk form
if request.method == 'POST':
if 'bulk_upload' in request.POST:
# Handle Bulk Upload
bulk_form = BulkListingForm(request.POST, request.FILES)
if bulk_form.is_valid():
csv_file = request.FILES['csv_file']
images = request.FILES.getlist('images')
# Create a map of filename -> image file
image_map = {img.name: img for img in images}
decoded_file = csv_file.read().decode('utf-8').splitlines()
reader = csv.DictReader(decoded_file)
for row in reader:
# Basic validation/cleaning
try:
game_name = row.get('Game', '').strip()
set_name = row.get('Set', '').strip()
card_name = row.get('Card Name', '').strip()
collector_number = row.get('Collector Number', '').strip()
condition = row.get('Condition', 'NM').strip()
price = row.get('Price', '0').strip()
quantity = row.get('Quantity', '1').strip()
image_filename = row.get('Image Filename', '').strip()
if not game_name or not card_name:
continue # Skip invalid rows
# Get Game
game_obj, _ = Game.objects.get_or_create(name=game_name, defaults={'slug': slugify(game_name)})
# Get Set
set_obj, _ = Set.objects.get_or_create(
game=game_obj,
name=set_name,
defaults={'code': '', 'release_date': None}
)
# Get Card
card_obj, _ = Card.objects.get_or_create(
set=set_obj,
name=card_name,
collector_number=collector_number,
defaults={
'rarity': 'Unknown',
}
)
# Image handling
image_file = image_map.get(image_filename)
# Create Listing
CardListing.objects.create(
card=card_obj,
seller=seller,
condition=condition if condition in dict(CardListing.CONDITION_CHOICES) else 'NM', # Basic fallback
price=price,
quantity=quantity,
image=image_file,
status='listed'
)
except Exception as e:
# Log error or skip
print(f"Error processing row: {e}")
continue
return redirect('store:manage_listings')
else:
# Handle Single Upload
form = AddCardListingForm(request.POST, request.FILES)
if form.is_valid():
game = form.cleaned_data['game']
set_name = form.cleaned_data['set_name']
card_name = form.cleaned_data['card_name']
condition = form.cleaned_data['condition']
price = form.cleaned_data['price']
quantity = form.cleaned_data['quantity']
image = form.cleaned_data['image']
collector_number = form.cleaned_data.get('collector_number', '').strip()
# Get or Create Set
# Note: Set code and release_date are left blank for now as we don't have them
set_obj, _ = Set.objects.get_or_create(
game=game,
name=set_name,
defaults={'code': '', 'release_date': None}
)
# Get or Create Card
# Providing defaults for fields we don't have
card_obj, _ = Card.objects.get_or_create(
set=set_obj,
name=card_name,
collector_number=collector_number,
defaults={
'rarity': 'Unknown',
'scryfall_id': None,
'tcgplayer_id': None
}
)
# Create Listing
CardListing.objects.create(
card=card_obj,
seller=seller,
condition=condition,
price=price,
quantity=quantity,
image=image, # Save the image
status='listed'
)
return redirect('store:manage_listings')
else:
form = AddCardListingForm()
return render(request, 'store/add_card_listing.html', {'form': form, 'bulk_form': bulk_form})
@login_required
def edit_card_listing(request, listing_id):
seller = request.user.seller_profile
listing = get_object_or_404(CardListing, uuid=listing_id, seller=seller)
if request.method == 'POST':
form = CardListingForm(request.POST, request.FILES, instance=listing)
if form.is_valid():
form.save()
return redirect('store:manage_listings')
else:
form = CardListingForm(instance=listing)
return render(request, 'store/seller/edit_listing.html', {'form': form, 'title': 'Edit Card Listing'})
@login_required
def delete_card_listing(request, listing_id):
seller = request.user.seller_profile
listing = get_object_or_404(CardListing, uuid=listing_id, seller=seller)
if request.method == 'POST':
listing.delete()
return redirect('store:manage_listings')
return render(request, 'store/seller/confirm_delete.html', {'item': listing})
@login_required
def add_pack_listing(request):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:manage_listings')
seller = request.user.seller_profile
bulk_form = BulkListingForm()
if request.method == 'POST':
if 'bulk_upload' in request.POST:
bulk_form = BulkListingForm(request.POST, request.FILES)
if bulk_form.is_valid():
csv_file = request.FILES['csv_file']
images = request.FILES.getlist('images')
image_map = {img.name: img for img in images}
decoded_file = csv_file.read().decode('utf-8').splitlines()
reader = csv.DictReader(decoded_file)
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
import os
for row in reader:
try:
game_name = row.get('Game', '').strip()
name = row.get('Name', '').strip()
listing_type = row.get('Listing Type', 'physical').strip()
price = row.get('Price', '0').strip()
quantity = row.get('Quantity', '1').strip()
image_filename = row.get('Image Filename', '').strip()
if not game_name or not name:
continue
# Get Game
game_obj, _ = Game.objects.get_or_create(name=game_name, defaults={'slug': slugify(game_name)})
image_url = ''
if image_filename in image_map:
# Save image to storage and get URL
# We need to manually save since the model has URLField
img_file = image_map[image_filename]
path = default_storage.save(f'pack_images/{img_file.name}', ContentFile(img_file.read()))
image_url = default_storage.url(path)
PackListing.objects.create(
game=game_obj,
seller=seller,
name=name,
listing_type=listing_type if listing_type in ['physical', 'virtual'] else 'physical',
price=price,
quantity=quantity,
image_url=image_url
)
except Exception as e:
print(f"Error processing pack row: {e}")
continue
return redirect('store:manage_listings')
else:
form = PackListingForm(request.POST)
if form.is_valid():
listing = form.save(commit=False)
listing.seller = seller
listing.save()
return redirect('store:manage_listings')
else:
form = PackListingForm()
return render(request, 'store/add_pack_listing.html', {'form': form, 'bulk_form': bulk_form, 'title': 'Add Pack Listing'})
@login_required
def edit_pack_listing(request, listing_id):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:manage_listings')
seller = request.user.seller_profile
listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
if request.method == 'POST':
form = PackListingForm(request.POST, instance=listing)
if form.is_valid():
form.save()
return redirect('store:manage_listings')
else:
form = PackListingForm(instance=listing)
return render(request, 'store/seller/edit_listing.html', {'form': form, 'title': 'Edit Pack Listing'})
@login_required
def delete_pack_listing(request, listing_id):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:manage_listings')
seller = request.user.seller_profile
listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
if request.method == 'POST':
listing.delete()
return redirect('store:manage_listings')
return render(request, 'store/seller/confirm_delete.html', {'item': listing})
def seller_profile(request, slug):
seller = get_object_or_404(Seller, slug=slug)
# Increment views
# Use F expression to avoid race conditions
from django.db.models import F
Seller.objects.filter(slug=slug).update(store_views=F('store_views') + 1)
# Refresh to show correct count if needed (though not displayed on profile usually)
seller.refresh_from_db()
# Filter Logic
from django.db.models import Q
# Get initial queryset
card_listings = CardListing.objects.filter(seller=seller, quantity__gt=0).select_related('card', 'card__set', 'card__set__game')
# Get filter parameters
query = request.GET.get('q')
game_slug = request.GET.get('game')
set_code = request.GET.get('set')
condition = request.GET.get('condition')
min_price = request.GET.get('min_price')
max_price = request.GET.get('max_price')
min_qty = request.GET.get('min_qty')
# Apply filters
if query:
card_listings = card_listings.filter(card__name__icontains=query)
if game_slug:
card_listings = card_listings.filter(card__set__game__slug=game_slug)
if set_code:
card_listings = card_listings.filter(card__set__code=set_code)
if condition:
card_listings = card_listings.filter(condition=condition)
if min_price:
try:
card_listings = card_listings.filter(price__gte=float(min_price))
except (ValueError, TypeError):
pass
if max_price:
try:
card_listings = card_listings.filter(price__lte=float(max_price))
except (ValueError, TypeError):
pass
if min_qty:
try:
card_listings = card_listings.filter(quantity__gte=int(min_qty))
except (ValueError, TypeError):
pass
# Get available options for filters (scoped to what the seller actually has)
seller_games = Game.objects.filter(sets__cards__listings__seller=seller).distinct()
seller_sets = Set.objects.filter(cards__listings__seller=seller).select_related('game').distinct()
pack_listings = PackListing.objects.filter(seller=seller).select_related('game')
# Calculate Average Rating
from django.db.models import Avg
avg_rating = Order.objects.filter(seller=seller, rating__isnull=False).aggregate(Avg('rating'))['rating__avg']
if avg_rating:
avg_rating = round(avg_rating, 1)
context = {
'seller': seller,
'card_listings': card_listings,
'pack_listings': pack_listings,
'games': seller_games,
'sets': seller_sets,
'conditions': CardListing.CONDITION_CHOICES,
'avg_rating': avg_rating,
'filters': {
'q': query,
'game': game_slug,
'set': set_code,
'condition': condition,
'min_price': min_price,
'max_price': max_price,
'min_qty': min_qty,
}
}
return render(request, 'store/seller/profile.html', context)
@login_required
def manage_pack_inventory(request, listing_id):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:manage_listings')
seller = request.user.seller_profile
listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
# Only for virtual packs
if listing.listing_type != 'virtual':
return redirect('store:manage_listings')
# Get all sealed packs for this listing that haven't been bought yet (owner=None)
# AND packs that have been bought but not opened? No, seller only manages unsold inventory usually.
# Actually, once bought, it belongs to buyer.
packs = VirtualPack.objects.filter(listing=listing, owner__isnull=True).prefetch_related('cards')
return render(request, 'store/seller/manage_pack_inventory.html', {
'listing': listing,
'packs': packs
})
@login_required
def add_virtual_pack_content(request, listing_id):
if not settings.FEATURE_VIRTUAL_PACKS:
return redirect('store:manage_listings')
seller = request.user.seller_profile
listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller)
if listing.listing_type != 'virtual':
return redirect('store:manage_listings')
if request.method == 'POST':
# Expect specific card IDs to be added
card_ids = request.POST.getlist('cards')
if card_ids:
pack = VirtualPack.objects.create(listing=listing, status='sealed')
cards = Card.objects.filter(id__in=card_ids)
pack.cards.set(cards)
# Update listing quantity
# We count total sealed available packs
count = VirtualPack.objects.filter(listing=listing, owner__isnull=True, status='sealed').count()
listing.quantity = count
listing.save()
listing.save()
return redirect('store:manage_pack_inventory', listing_id=listing.uuid)
# Search functionality for finding cards to add
query = request.GET.get('q')
cards = []
if query:
cards = Card.objects.filter(name__icontains=query).select_related('set', 'set__game')[:50]
return render(request, 'store/seller/add_virtual_pack_content.html', {
'listing': listing,
'cards': cards,
'query': query
})
@login_required
def report_seller(request, slug):
"""Handle AJAX POST to report a seller."""
if request.method != 'POST':
return JsonResponse({'status': 'error', 'message': 'POST required'}, status=400)
seller = get_object_or_404(Seller, slug=slug)
reason = request.POST.get('reason')
valid_reasons = [choice[0] for choice in SellerReport.REASON_CHOICES]
if reason not in valid_reasons:
return JsonResponse({'status': 'error', 'message': 'Invalid reason'}, status=400)
details = request.POST.get('details', '')
SellerReport.objects.create(
reporter=request.user,
seller=seller,
reason=reason,
details=details,
)
return JsonResponse({'status': 'success'})
@staff_member_required
def admin_revenue_dashboard(request):
sellers = Seller.objects.all()
seller_data = []
total_platform_revenue = Decimal('0.00')
for seller in sellers:
# Get paid/shipped orders for this seller
orders = seller.orders.filter(status__in=['paid', 'shipped'])
seller_revenue = Decimal('0.00')
seller_fees = Decimal('0.00')
for order in orders:
seller_revenue += order.total_price
fee = calculate_platform_fee(order.total_price)
seller_fees += fee
total_platform_revenue += seller_fees
seller_data.append({
'seller': seller,
'total_revenue': seller_revenue,
'platform_fees': seller_fees
})
return render(request, 'store/admin_revenue_dashboard.html', {
'seller_data': seller_data,
'total_platform_revenue': total_platform_revenue
})