1545 lines
59 KiB
Python
1545 lines
59 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 django.db.models import Sum, Value
|
|
from django.db.models.functions import Coalesce, Length
|
|
from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm
|
|
from 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')
|
|
|
|
# Annotate with total quantity of listed items
|
|
cards = cards.annotate(
|
|
total_quantity=Coalesce(
|
|
Sum('listings__quantity', filter=Q(listings__status='listed')),
|
|
Value(0)
|
|
)
|
|
)
|
|
|
|
# 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(total_quantity__gt=0)
|
|
|
|
# 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': []})
|
|
|
|
# Order by length to show shorter (likely more relevant) matches first
|
|
# Increase limit to 25 to avoid crowding out other games
|
|
cards = Card.objects.filter(name__icontains=query)\
|
|
.annotate(name_len=Length('name'))\
|
|
.order_by('name_len')\
|
|
.values_list('name', flat=True).distinct()[:25]
|
|
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
|
|
|
|
now = timezone.now()
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
def terms(request):
|
|
return render(request, 'legal/terms.html')
|
|
|
|
|
|
@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
|
|
})
|
|
|