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
1525 lines
58 KiB
Python
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
|
|
})
|
|
|