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 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': []}) 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 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 })