From 9040021d1b708b15381dde7e2c0c9829d6d8724b Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 23 Jan 2026 12:28:20 -0600 Subject: [PATCH] MASSIVE UPDATE: bounty board feature buyers to see bounty boards seller profile page (like have theme chooser) Have the game and set name be filters. Add cards to vault manually update card inventory add to have the autocomplete for the card - store analytics, clicks, views, link to store (url/QR code) bulk item inventory creation -- Make the banner feature flag driven so I can have a beta site setup like the primary site don't use primary key values in urls - update to use uuid4 values site analytics. tianji is being sent item potent on the mtg and lorcana populate scripts Card item images for specific listings check that when you buy a card it is in the vault Buys should be able to search on store inventories More pie charts for the seller! post bounty board is slow to load seller reviews/ratings - show a historgram - need a way for someone to rate Report a seller feature for buyer to report Make sure the stlying is consistent based on the theme choosen smart minimum order quantity and shipping amounts (defined by the store itself) put virtual packs behind a feature flag like bounty board proxy service feature flag Terms of Service new description for TCGKof store SSN, ITIN, and EIN optomize for SEO --- config/settings.py | 19 +- config/urls.py | 11 +- decks/migrations/0001_initial.py | 2 +- decks/migrations/0002_initial.py | 2 +- decks/migrations/0003_initial.py | 2 +- pyproject.toml | 3 + static/css/style.css | 383 +++++ store/admin.py | 88 ++ store/context_processors.py | 10 + store/forms.py | 110 ++ store/management/commands/populate_db.py | 2 +- .../commands/populate_lorcana_cards.py | 140 ++ .../management/commands/populate_mtg_cards.py | 231 +++ store/migrations/0001_initial.py | 54 +- store/migrations/0002_initial.py | 74 +- ...ance_order_insurance_purchased_and_more.py | 40 - store/migrations/0003_packlisting_quantity.py | 18 + ...isting_alter_orderitem_listing_and_more.py | 57 - .../0004_packlisting_listing_type.py | 18 + ...05_cardlisting_image_cardlisting_status.py | 23 + store/migrations/0005_vaultitem.py | 29 - store/migrations/0006_card_external_url.py | 18 + ...07_seller_hero_image_seller_store_image.py | 23 + ...ion_bounty_seller_bounty_title_and_more.py | 47 + ...eller_listing_clicks_seller_store_views.py | 23 + ...titem_pack_listing_bounty_uuid_and_more.py | 58 + ...ty_uuid_alter_bountyoffer_uuid_and_more.py | 74 + .../migrations/0012_cartitem_pack_listing.py | 24 + .../0013_order_rating_order_seller.py | 38 + store/migrations/0014_sellerreport.py | 59 + ...nimum_order_amount_seller_shipping_cost.py | 23 + ...eller_payout_details_encrypted_and_more.py | 23 + store/models.py | 139 +- store/templates/store/add_card_listing.html | 348 +++++ store/templates/store/add_pack_listing.html | 153 ++ .../store/admin_revenue_dashboard.html | 86 + store/templates/store/bounty_detail.html | 126 ++ store/templates/store/bounty_form.html | 211 +++ store/templates/store/bounty_list.html | 78 + .../store/csv/card_listing_template.csv | 3 + .../store/csv/pack_listing_template.csv | 3 + .../seller/add_virtual_pack_content.html | 49 + .../store/seller/confirm_delete.html | 21 + store/templates/store/seller/dashboard.html | 330 ++++ .../templates/store/seller/edit_listing.html | 91 ++ .../templates/store/seller/edit_profile.html | 127 ++ .../store/seller/manage_listings.html | 135 ++ .../store/seller/manage_pack_inventory.html | 43 + store/templates/store/seller/profile.html | 374 +++++ store/templates/store/seller_register.html | 66 + .../store/seller_register_success.html | 19 + store/tests.py | 240 ++- store/tests_bounty.py | 87 ++ store/tests_smart_pricing.py | 95 ++ store/urls.py | 44 +- store/utils.py | 64 +- store/views.py | 1386 ++++++++++++++++- templates/base/layout.html | 416 ++--- templates/decks/deck_import.html | 2 +- templates/legal/terms.html | 33 + templates/registration/login.html | 3 + templates/robots.txt | 6 + templates/store/bounty_list.html | 164 ++ templates/store/card_detail.html | 83 +- templates/store/card_list.html | 85 +- templates/store/cart.html | 33 +- templates/store/my_packs.html | 2 +- templates/store/order_detail.html | 80 + templates/store/pack_list.html | 10 +- templates/users/profile.html | 10 +- templates/users/sell_on_tcgkof.html | 83 + templates/users/seller_dashboard.html | 37 + templates/users/vault.html | 68 + users/admin.py | 31 +- users/migrations/0001_initial.py | 39 +- .../migrations/0002_address_paymentmethod.py | 41 - users/models.py | 23 +- users/urls.py | 4 + users/views.py | 65 +- uv.lock | 168 ++ 80 files changed, 6938 insertions(+), 592 deletions(-) create mode 100644 store/context_processors.py create mode 100644 store/forms.py create mode 100644 store/management/commands/populate_lorcana_cards.py create mode 100644 store/management/commands/populate_mtg_cards.py delete mode 100644 store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py create mode 100644 store/migrations/0003_packlisting_quantity.py delete mode 100644 store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py create mode 100644 store/migrations/0004_packlisting_listing_type.py create mode 100644 store/migrations/0005_cardlisting_image_cardlisting_status.py delete mode 100644 store/migrations/0005_vaultitem.py create mode 100644 store/migrations/0006_card_external_url.py create mode 100644 store/migrations/0007_seller_hero_image_seller_store_image.py create mode 100644 store/migrations/0008_bounty_description_bounty_seller_bounty_title_and_more.py create mode 100644 store/migrations/0009_seller_listing_clicks_seller_store_views.py create mode 100644 store/migrations/0010_remove_cartitem_pack_listing_bounty_uuid_and_more.py create mode 100644 store/migrations/0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more.py create mode 100644 store/migrations/0012_cartitem_pack_listing.py create mode 100644 store/migrations/0013_order_rating_order_seller.py create mode 100644 store/migrations/0014_sellerreport.py create mode 100644 store/migrations/0015_seller_minimum_order_amount_seller_shipping_cost.py create mode 100644 store/migrations/0016_seller_payout_details_encrypted_and_more.py create mode 100644 store/templates/store/add_card_listing.html create mode 100644 store/templates/store/add_pack_listing.html create mode 100644 store/templates/store/admin_revenue_dashboard.html create mode 100644 store/templates/store/bounty_detail.html create mode 100644 store/templates/store/bounty_form.html create mode 100644 store/templates/store/bounty_list.html create mode 100644 store/templates/store/csv/card_listing_template.csv create mode 100644 store/templates/store/csv/pack_listing_template.csv create mode 100644 store/templates/store/seller/add_virtual_pack_content.html create mode 100644 store/templates/store/seller/confirm_delete.html create mode 100644 store/templates/store/seller/dashboard.html create mode 100644 store/templates/store/seller/edit_listing.html create mode 100644 store/templates/store/seller/edit_profile.html create mode 100644 store/templates/store/seller/manage_listings.html create mode 100644 store/templates/store/seller/manage_pack_inventory.html create mode 100644 store/templates/store/seller/profile.html create mode 100644 store/templates/store/seller_register.html create mode 100644 store/templates/store/seller_register_success.html create mode 100644 store/tests_bounty.py create mode 100644 store/tests_smart_pricing.py create mode 100644 templates/legal/terms.html create mode 100644 templates/robots.txt create mode 100644 templates/store/bounty_list.html create mode 100644 templates/users/sell_on_tcgkof.html create mode 100644 templates/users/seller_dashboard.html delete mode 100644 users/migrations/0002_address_paymentmethod.py diff --git a/config/settings.py b/config/settings.py index 8b2868f..890d0ad 100644 --- a/config/settings.py +++ b/config/settings.py @@ -24,8 +24,12 @@ SECRET_KEY = 'django-insecure-y-a6jb6gv)dd!32_c!8rn70cnn%8&_$nc=m)0#4d=%b65%g(0* # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True +FEATURE_BOUNTY_BOARD = DEBUG +FEATURE_DEMO_SITE = True +FEATURE_PLAYTEST_PROXY = DEBUG +FEATURE_VIRTUAL_PACKS = DEBUG -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['testserver', 'localhost', '127.0.0.1'] # Application definition @@ -45,6 +49,9 @@ INSTALLED_APPS = [ AUTH_USER_MODEL = 'users.User' +LOGOUT_REDIRECT_URL = '/' +LOGIN_REDIRECT_URL = 'users:login_success' + # Stripe Configuration STRIPE_PUBLISHABLE_KEY = 'pk_test_placeholder' STRIPE_SECRET_KEY = 'sk_test_placeholder' @@ -71,7 +78,9 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'django.contrib.messages.context_processors.messages', 'django.template.context_processors.debug', + 'store.context_processors.feature_flags', ], }, }, @@ -126,3 +135,11 @@ USE_TZ = True # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = 'static/' + +STATICFILES_DIRS = [ + BASE_DIR / "static", +] + +# Media files (Uploaded by user) +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' diff --git a/config/urls.py b/config/urls.py index f0566f3..5c59e65 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.urls import path, include -from django.views.generic import RedirectView +from django.views.generic import RedirectView, TemplateView urlpatterns = [ path('admin/', admin.site.urls), @@ -9,4 +9,13 @@ urlpatterns = [ path('', include('store.urls')), # Store is the home app path('home', RedirectView.as_view(url='/', permanent=True), name='home'), # Redirect /home to / path('decks/', include('decks.urls')), + + # SEO + path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")), ] + +from django.conf import settings +from django.conf.urls.static import static + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/decks/migrations/0001_initial.py b/decks/migrations/0001_initial.py index d8cb10e..9917f25 100644 --- a/decks/migrations/0001_initial.py +++ b/decks/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.1 on 2026-01-19 13:38 +# Generated by Django 6.0.1 on 2026-01-21 17:40 from django.db import migrations, models diff --git a/decks/migrations/0002_initial.py b/decks/migrations/0002_initial.py index 2696427..8539092 100644 --- a/decks/migrations/0002_initial.py +++ b/decks/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.1 on 2026-01-19 13:38 +# Generated by Django 6.0.1 on 2026-01-21 17:40 import django.db.models.deletion from django.db import migrations, models diff --git a/decks/migrations/0003_initial.py b/decks/migrations/0003_initial.py index 4e03695..bc2a3eb 100644 --- a/decks/migrations/0003_initial.py +++ b/decks/migrations/0003_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.1 on 2026-01-19 13:38 +# Generated by Django 6.0.1 on 2026-01-21 17:40 import django.db.models.deletion from django.conf import settings diff --git a/pyproject.toml b/pyproject.toml index 14d2ac7..2a3d297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,15 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "black>=26.1.0", + "bs4>=0.0.2", "coverage>=7.13.1", + "cryptography>=46.0.3", "django>=6.0.1", "faker>=40.1.2", "pillow>=12.1.0", "pytest-cov>=7.0.0", "pytest-django>=4.11.1", "requests>=2.32.5", + "sitemap>=20191121", "stripe>=14.2.0", ] diff --git a/static/css/style.css b/static/css/style.css index e69de29..9d59f97 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -0,0 +1,383 @@ +:root { + --primary-color: #6366f1; + --secondary-color: #a855f7; + --bg-color: #0f172a; + --text-color: #f8fafc; + --card-bg: #1e293b; + --border-color: #334155; + --nav-height: 70px; + /* Semantic colors for theming */ + --muted-text-color: #94a3b8; + --input-bg: #0f172a; + --success-color: #34d399; + --info-color: #60a5fa; + --danger-color: #ef4444; + --warning-color: #f59e0b; +} + +[data-theme="light"] { + --primary-color: #4f46e5; + --secondary-color: #9333ea; + --bg-color: #f8fafc; + --text-color: #0f172a; + --card-bg: #ffffff; + --border-color: #e2e8f0; + --muted-text-color: #64748b; + --input-bg: #ffffff; + --success-color: #059669; + --info-color: #2563eb; + --danger-color: #dc2626; + --warning-color: #d97706; +} + +[data-theme="compact"] { + --primary-color: #2563eb; + --secondary-color: #475569; + --bg-color: #f1f5f9; + --text-color: #1e293b; + --card-bg: #ffffff; + --border-color: #cbd5e1; + --gap-size: 0.5rem; + --muted-text-color: #64748b; + --input-bg: #ffffff; + --success-color: #059669; + --info-color: #2563eb; + --danger-color: #dc2626; + --warning-color: #d97706; +} + +[data-theme="expressive"] { + --primary-color: #ec4899; + --secondary-color: #f59e0b; + --bg-color: #2a0a2e; + /* Dark purple */ + --text-color: #fdf2f8; + --card-bg: #4a1d4b; + --border-color: #831843; + --muted-text-color: #f9a8d4; + --input-bg: #3b0d40; + --success-color: #34d399; + --info-color: #a78bfa; + --danger-color: #fb7185; + --warning-color: #fbbf24; + font-family: 'Outfit', sans-serif; +} + +[data-theme="technical"] { + --primary-color: #10b981; + /* Green */ + --secondary-color: #0ea5e9; + /* Blue */ + --bg-color: #000000; + --text-color: #d1d5db; + --card-bg: #111827; + --border-color: #374151; + --muted-text-color: #9ca3af; + --input-bg: #0a0a0a; + --success-color: #10b981; + --info-color: #0ea5e9; + --danger-color: #ef4444; + --warning-color: #f59e0b; + font-family: 'JetBrains Mono', monospace; +} + +/* Compact specific overrides */ +[data-theme="compact"] .card-grid { + gap: 1rem; +} + +[data-theme="compact"] .tcg-card-body { + padding: 0.5rem; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background-color: var(--bg-color); + color: var(--text-color); + margin: 0; + line-height: 1.5; + transition: background-color 0.3s, color 0.3s; +} + +nav { + background-color: var(--card-bg); + border-bottom: 1px solid var(--border-color); + padding: 0 2rem; + height: var(--nav-height); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-brand { + font-size: 1.5rem; + font-weight: 800; + background: linear-gradient(to right, var(--primary-color), var(--secondary-color)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-decoration: none; +} + +.nav-links { + display: flex; + align-items: center; +} + +.nav-links a { + color: var(--text-color); + text-decoration: none; + margin-left: 1.5rem; + font-weight: 500; + transition: color 0.2s; +} + +.nav-links a:hover { + color: var(--primary-color); +} + +.container { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1rem; + min-height: 80vh; +} + +.btn { + display: inline-block; + padding: 0.5rem 1rem; + background-color: var(--primary-color); + color: white; + text-decoration: none; + border-radius: 0.375rem; + font-weight: 600; + transition: opacity 0.2s; + border: none; + cursor: pointer; +} + +.btn:hover { + opacity: 0.9; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 2rem; +} + +.tcg-card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + transition: transform 0.2s; +} + +.tcg-card:hover { + transform: translateY(-4px); +} + +.tcg-card img { + width: 100%; + height: auto; + display: block; +} + +.tcg-card-body { + padding: 1rem; +} + +.messages { + list-style: none; + padding: 0; + margin-bottom: 2rem; +} + +.messages li { + padding: 1rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; +} + +.messages .success { + background-color: #064e3b; + color: #a7f3d0; +} + +.messages .error { + background-color: #7f1d1d; + color: #fecaca; +} + +[data-theme="light"] .messages .success { + background-color: #d1fae5; + color: #065f46; +} + +[data-theme="light"] .messages .error { + background-color: #fce7f3; + color: #9d174d; +} + +/* Auth Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; + backdrop-filter: blur(4px); +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.auth-modal { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + padding: 2rem; + max-width: 400px; + width: 90%; + text-align: center; + transform: translateY(20px); + transition: transform 0.3s; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.modal-overlay.active .auth-modal { + transform: translateY(0); +} + +.auth-modal h2 { + margin-top: 0; + color: var(--primary-color); + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.auth-modal p { + margin-bottom: 2rem; + color: var(--text-color); + line-height: 1.6; +} + +.auth-modal-actions { + display: flex; + gap: 1rem; + justify-content: center; +} + +.btn-outline { + background-color: transparent; + border: 1px solid var(--primary-color); + color: var(--primary-color); +} + +.btn-outline:hover { + background-color: rgba(99, 102, 241, 0.1); +} + +.close-modal { + position: absolute; + top: 1rem; + right: 1rem; + background: none; + border: none; + color: var(--text-color); + font-size: 1.5rem; + cursor: pointer; + opacity: 0.5; + padding: 0; + line-height: 1; +} + +.close-modal:hover { + opacity: 1; +} + +/* Mobile Menu Button */ +.mobile-menu-btn { + display: none; + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; +} + +.mobile-menu-btn .bar { + display: block; + width: 25px; + height: 3px; + margin: 5px auto; + -webkit-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; + background-color: var(--text-color); +} + +/* Media Queries */ +@media (max-width: 768px) { + + /* Navigation */ + .mobile-menu-btn { + display: block; + } + + .nav-links { + position: fixed; + left: -100%; + top: var(--nav-height); + gap: 0; + flex-direction: column; + background-color: var(--card-bg); + width: 100%; + text-align: center; + transition: 0.3s; + border-bottom: 1px solid var(--border-color); + padding-bottom: 1rem; + } + + .nav-links.active { + left: 0; + } + + .nav-links a { + margin: 1rem 0; + display: block; + } + + /* Card Layout - Move filters to top */ + .browse-container { + grid-template-columns: 1fr !important; + /* Force single column */ + } + + .browse-sidebar { + width: 100% !important; + margin-bottom: 1rem; + } + + .browse-sidebar form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + } + + .browse-sidebar button { + grid-column: span 2; + } +} \ No newline at end of file diff --git a/store/admin.py b/store/admin.py index 8c38f3f..7a20ddc 100644 --- a/store/admin.py +++ b/store/admin.py @@ -1,3 +1,91 @@ from django.contrib import admin +from .models import Seller, Game, Set, Card, CardListing, PackListing, VirtualPack, Order, OrderItem, Cart, Bounty, VaultItem # Register your models here. +@admin.register(Seller) +class SellerAdmin(admin.ModelAdmin): + list_display = ['store_name', 'user', 'slug', 'created_at'] + search_fields = ['store_name', 'user__username'] + +@admin.register(Game) +class GameAdmin(admin.ModelAdmin): + list_display = ['name', 'slug'] + +@admin.register(Set) +class SetAdmin(admin.ModelAdmin): + list_display = ['name', 'game', 'code', 'release_date'] + list_select_related = ['game'] + search_fields = ['name', 'code'] + list_filter = ['game'] + +class CardListingInline(admin.StackedInline): + model = CardListing + extra = 0 + autocomplete_fields = ['seller'] + +@admin.register(Card) +class CardAdmin(admin.ModelAdmin): + list_display = ['name', 'set', 'rarity', 'collector_number', 'scryfall_id', 'uuid'] + list_select_related = ['set', 'set__game'] + search_fields = ['name', 'set__name', 'collector_number', 'uuid'] + list_filter = ['set__game', 'rarity'] + inlines = [CardListingInline] + +@admin.register(CardListing) +class CardListingAdmin(admin.ModelAdmin): + list_display = ['card', 'seller', 'condition', 'price', 'status', 'quantity', 'uuid'] + list_select_related = ['card', 'card__set', 'seller'] + list_filter = ['status', 'condition', 'is_foil'] + autocomplete_fields = ['card', 'seller'] + +@admin.register(PackListing) +class PackListingAdmin(admin.ModelAdmin): + list_display = ['name', 'game', 'seller', 'listing_type', 'price', 'uuid'] + list_select_related = ['game', 'seller'] + list_filter = ['listing_type', 'game'] + autocomplete_fields = ['seller'] + +@admin.register(VirtualPack) +class VirtualPackAdmin(admin.ModelAdmin): + list_display = ['listing', 'owner', 'status', 'created_at', 'uuid'] + list_select_related = ['listing', 'owner', 'owner__user'] + list_filter = ['status'] + raw_id_fields = ['owner'] # Buyer might not have search_fields set up yet, safer to use raw_id or just autocomplete if Buyer has search + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ['id', 'uuid', 'buyer_info', 'status', 'total_price', 'created_at'] + list_select_related = ['buyer', 'buyer__user'] + list_filter = ['status', 'created_at'] + + def buyer_info(self, obj): + return obj.buyer.user.username + buyer_info.short_description = 'Buyer' + +@admin.register(OrderItem) +class OrderItemAdmin(admin.ModelAdmin): + list_display = ['order', 'item_description', 'price_at_purchase', 'quantity'] + list_select_related = ['order', 'listing', 'listing__card', 'pack_listing'] + + def item_description(self, obj): + if obj.pack_listing: + return obj.pack_listing.name + return obj.listing.card.name if obj.listing else "Deleted Listing" + +@admin.register(Cart) +class CartAdmin(admin.ModelAdmin): + list_display = ['buyer', 'created_at', 'insurance'] + list_select_related = ['buyer', 'buyer__user'] + +@admin.register(Bounty) +class BountyAdmin(admin.ModelAdmin): + list_display = ['card', 'target_price', 'quantity_wanted', 'is_active', 'uuid'] + list_select_related = ['card', 'card__set'] + autocomplete_fields = ['card'] + +@admin.register(VaultItem) +class VaultItemAdmin(admin.ModelAdmin): + list_display = ['buyer', 'card', 'quantity', 'added_at'] + list_select_related = ['buyer', 'buyer__user', 'card', 'card__set'] + autocomplete_fields = ['card'] + raw_id_fields = ['buyer'] diff --git a/store/context_processors.py b/store/context_processors.py new file mode 100644 index 0000000..11e774d --- /dev/null +++ b/store/context_processors.py @@ -0,0 +1,10 @@ +from django.conf import settings + +def feature_flags(request): + return { + 'FEATURE_DEMO_SITE': getattr(settings, 'FEATURE_DEMO_SITE', False), + 'FEATURE_BOUNTY_BOARD': getattr(settings, 'FEATURE_BOUNTY_BOARD', False), + 'FEATURE_PLAYTEST_PROXY': getattr(settings, 'FEATURE_PLAYTEST_PROXY', False), + 'FEATURE_VIRTUAL_PACKS': getattr(settings, 'FEATURE_VIRTUAL_PACKS', False), + 'debug': settings.DEBUG, + } diff --git a/store/forms.py b/store/forms.py new file mode 100644 index 0000000..a8de903 --- /dev/null +++ b/store/forms.py @@ -0,0 +1,110 @@ +from django import forms +from .models import Seller, CardListing, PackListing, Game, Bounty, BountyOffer +from users.models import Profile + +class SellerThemeForm(forms.ModelForm): + class Meta: + model = Profile + fields = ['theme_preference'] + widgets = { + 'theme_preference': forms.Select(attrs={'class': 'form-select'}) + } + +class SellerRegistrationForm(forms.ModelForm): + class Meta: + model = Seller + fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 4}), + 'business_address': forms.Textarea(attrs={'rows': 3}), + } + +class SellerEditForm(forms.ModelForm): + tax_id = forms.CharField(required=False, widget=forms.TextInput(attrs={'type': 'password'}), help_text="SSN, ITIN, or EIN (Stored securely)") + payout_details = forms.CharField(required=False, widget=forms.Textarea(attrs={'rows': 3}), help_text="Bank account or other payout details (Stored securely)") + + class Meta: + model = Seller + fields = ['store_name', 'description', 'contact_email', 'contact_phone', 'business_address', 'store_image', 'hero_image', 'minimum_order_amount', 'shipping_cost', 'tax_id', 'payout_details'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 4}), + 'business_address': forms.Textarea(attrs={'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields['tax_id'].initial = self.instance.tax_id + self.fields['payout_details'].initial = self.instance.payout_details + + def save(self, commit=True): + seller = super().save(commit=False) + seller.tax_id = self.cleaned_data.get('tax_id') + seller.payout_details = self.cleaned_data.get('payout_details') + if commit: + seller.save() + return seller + +class CardListingForm(forms.ModelForm): + class Meta: + model = CardListing + fields = ['condition', 'price', 'quantity', 'status', 'image'] + # TODO: Add search widget for card selection or filter by game/set + +class PackListingForm(forms.ModelForm): + class Meta: + model = PackListing + fields = ['game', 'name', 'listing_type', 'price', 'quantity', 'image_url'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Helper to indicate quantity logic + self.fields['quantity'].help_text = "For Virtual packs, this is automatically updated based on inventory." + +class AddCardListingForm(forms.Form): + card_name = forms.CharField(max_length=200, label="Card Name", help_text="Enter the card name.") + game = forms.ModelChoiceField(queryset=Game.objects.all(), empty_label="Select Game") + set_name = forms.CharField(max_length=200, label="Set Name", help_text="Enter the set name (e.g., 'Alpha', 'Base Set').") + collector_number = forms.CharField(max_length=20, required=False, label="Card Number", help_text="The number on the bottom of the card (e.g. '197'). Useful for variants.") + condition = forms.ChoiceField(choices=CardListing.CONDITION_CHOICES) + price = forms.DecimalField(max_digits=10, decimal_places=2) + quantity = forms.IntegerField(min_value=1, initial=1) + image = forms.ImageField(required=False, label="Card Image") + +class BountyForm(forms.ModelForm): + card_name = forms.CharField(max_length=200, label="Card Name", help_text="Search for a card...", required=False) + card_id = forms.CharField(widget=forms.HiddenInput(), required=False) + + class Meta: + model = Bounty + fields = ['title', 'description', 'target_price', 'quantity_wanted'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 3}), + } + + def clean(self): + cleaned_data = super().clean() + card_id = cleaned_data.get('card_id') + title = cleaned_data.get('title') + card_name = cleaned_data.get('card_name') + + if not card_id and not title and not card_name: + raise forms.ValidationError("You must either select a Card or provide a Title.") + + return cleaned_data + +class BountyOfferForm(forms.ModelForm): + class Meta: + model = BountyOffer + fields = ['price', 'description'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 3}), + } + +class MultipleFileInput(forms.ClearableFileInput): + allow_multiple_selected = True + +class BulkListingForm(forms.Form): + csv_file = forms.FileField(label="Upload CSV", help_text="Upload the filled-out template CSV.") + images = forms.FileField(widget=MultipleFileInput(attrs={'multiple': True}), required=False, label="Upload Images", help_text="Select all images referenced in your CSV.") + diff --git a/store/management/commands/populate_db.py b/store/management/commands/populate_db.py index bbd6766..3b5d069 100644 --- a/store/management/commands/populate_db.py +++ b/store/management/commands/populate_db.py @@ -73,12 +73,12 @@ class Command(BaseCommand): card, _ = Card.objects.get_or_create( set=set_obj, name=card_data['name'], + collector_number=card_data['collector_number'], defaults={ 'rarity': card_data['rarity'].capitalize(), 'image_url': image, 'scryfall_id': card_data['id'], 'tcgplayer_id': card_data.get('tcgplayer_id'), - 'collector_number': card_data['collector_number'] } ) diff --git a/store/management/commands/populate_lorcana_cards.py b/store/management/commands/populate_lorcana_cards.py new file mode 100644 index 0000000..78a6eb7 --- /dev/null +++ b/store/management/commands/populate_lorcana_cards.py @@ -0,0 +1,140 @@ +import requests +import sys +from django.core.management.base import BaseCommand +from django.utils.dateparse import parse_date +from store.models import Game, Set, Card + +class Command(BaseCommand): + help = 'Populates the database with Disney Lorcana sets and cards from Lorcast.' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing Disney Lorcana cards and sets before populating.' + ) + parser.add_argument( + '--duration', + default='7', + help='Duration in days to look back for new sets. Use "all" to fetch everything. Default is 7 days.' + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('Starting Lorcana population...')) + + # 1. Ensure Game exists + game, created = Game.objects.get_or_create( + name="Disney Lorcana", + defaults={'slug': 'disney-lorcana'} + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created Game: {game.name}')) + else: + self.stdout.write(f'Found Game: {game.name}') + + # Handle --clear + if options['clear']: + self.stdout.write(self.style.WARNING('Clearing existing Lorcana data...')) + Card.objects.filter(set__game=game).delete() + Set.objects.filter(game=game).delete() + self.stdout.write(self.style.SUCCESS('Cleared Lorcana data.')) + + # Handle --duration + duration = options['duration'] + start_date = None + + if duration != 'all': + try: + days = int(duration) + from django.utils import timezone + from datetime import timedelta + start_date = timezone.now().date() - timedelta(days=days) + self.stdout.write(f'Fetching data from the last {days} days (since {start_date})...') + except ValueError: + self.stdout.write(self.style.ERROR('Invalid duration. Must be an integer or "all".')) + return + + # 2. Fetch Sets + self.stdout.write('Fetching sets from Lorcast...') + response = requests.get('https://api.lorcast.com/v0/sets') + if response.status_code != 200: + self.stdout.write(self.style.ERROR(f'Failed to fetch sets: {response.status_code}')) + return + + sets_data = response.json().get('results', []) # Lorcast returns { results: [...] } + self.stdout.write(f'Found {len(sets_data)} sets. Processing...') + + # Iterate through sets + for set_data in sets_data: + release_date = parse_date(set_data.get('released_at')) if set_data.get('released_at') else None + + # Update API might return sets that are not in DB, we should add them? + # Start date filter: + # If we are doing a partial update, we only want to PROCESS cards for sets that are new? + # But we update the Set object itself anyway because it is cheap. + + set_obj, created = Set.objects.update_or_create( + code=set_data.get('code'), + game=game, + defaults={ + 'name': set_data.get('name'), + 'release_date': release_date, + } + ) + + # Decide whether to fetch cards for this set + should_fetch_cards = True + if start_date: + # If set is older than start_date, skip fetching cards + if not release_date or release_date < start_date: + should_fetch_cards = False + + if not should_fetch_cards: + # self.stdout.write(f' Skipping cards for older set: {set_obj.name}') + continue + + # Fetch cards for this set + # GET https://api.lorcast.com/v0/sets/:id/cards + set_id = set_data.get('id') + self.stdout.write(f' Fetching cards for set: {set_obj.name} (ID: {set_id})...') + + cards_response = requests.get(f'https://api.lorcast.com/v0/sets/{set_id}/cards') + if cards_response.status_code != 200: + self.stdout.write(self.style.ERROR(f' Failed to fetch cards for set {set_obj.name}')) + continue + + cards_data = cards_response.json() # Returns list directly according to docs example + + self.stdout.write(f' Found {len(cards_data)} cards. Updating...') + + for card_data in cards_data: + # Extract Image URL + image_url = '' + if 'image_uris' in card_data and 'digital' in card_data['image_uris']: + if 'normal' in card_data['image_uris']['digital']: + image_url = card_data['image_uris']['digital']['normal'] + + # TCGPlayer ID + tcgplayer_id = card_data.get('tcgplayer_id') + + lorcast_id = card_data.get('id') + + # External URL + external_url = f"https://lorcast.com/cards/{lorcast_id}" + + collector_number = card_data.get('collector_number', '') + + Card.objects.update_or_create( + scryfall_id=lorcast_id, # Re-using this field for unique ID + defaults={ + 'set': set_obj, + 'name': card_data.get('name'), + 'rarity': card_data.get('rarity'), + 'image_url': image_url, + 'tcgplayer_id': tcgplayer_id, + 'collector_number': collector_number, + 'external_url': external_url, + } + ) + + self.stdout.write(self.style.SUCCESS(f'Finished Lorcana population!')) diff --git a/store/management/commands/populate_mtg_cards.py b/store/management/commands/populate_mtg_cards.py new file mode 100644 index 0000000..f9d9187 --- /dev/null +++ b/store/management/commands/populate_mtg_cards.py @@ -0,0 +1,231 @@ +import requests +import sys +from django.core.management.base import BaseCommand +from django.utils.dateparse import parse_date +from store.models import Game, Set, Card + +class Command(BaseCommand): + help = 'Populates the database with Magic: The Gathering sets and cards from Scryfall.' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing Magic: The Gathering cards and sets before populating.' + ) + parser.add_argument( + '--duration', + default='7', + help='Duration in days to look back for new cards/sets. Use "all" to fetch everything. Default is 7 days.' + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('Starting MTG population...')) + + # 1. Ensure Game exists + game, created = Game.objects.get_or_create( + name="Magic: The Gathering", + defaults={'slug': 'magic-the-gathering'} + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created Game: {game.name}')) + else: + self.stdout.write(f'Found Game: {game.name}') + + # Handle --clear + if options['clear']: + self.stdout.write(self.style.WARNING('Clearing existing MTG data...')) + Card.objects.filter(set__game=game).delete() + Set.objects.filter(game=game).delete() + self.stdout.write(self.style.SUCCESS('Cleared MTG data.')) + + # Handle --duration + duration = options['duration'] + start_date = None + + if duration != 'all': + try: + days = int(duration) + from django.utils import timezone + from datetime import timedelta + start_date = timezone.now().date() - timedelta(days=days) + self.stdout.write(f'Fetching data from the last {days} days (since {start_date})...') + except ValueError: + self.stdout.write(self.style.ERROR('Invalid duration. Must be an integer or "all".')) + return + + # 2. Fetch Sets + self.stdout.write('Fetching sets from Scryfall...') + response = requests.get('https://api.scryfall.com/sets') + if response.status_code != 200: + self.stdout.write(self.style.ERROR('Failed to fetch sets')) + return + + sets_data = response.json().get('data', []) + self.stdout.write(f'Found {len(sets_data)} sets total. Filtering and processing...') + + sets_processed = 0 + for set_data in sets_data: + release_date = parse_date(set_data.get('released_at')) if set_data.get('released_at') else None + + # If start_date is set, skip sets older than start_date + # Note: Scryfall API doesn't allow filtering sets by date in the list endpoint efficienty, so we filter here. + # However, cards might be added to old sets? Scryfall cards usually are released with sets. + # But if we are doing a partial update, we only care about sets released recently? + # Or should we check all sets? The user requirement implies "grabs the data from the past X days". + # Safest is to update all sets (lightweight) or just new ones. + # Let's update all sets if "all", or filter if "duration". + # Actually, updating Sets is fast. Let's just update all sets regardless of duration to be safe, + # unless clearing. + # WAIT, if we only want "latest set", we should filter. + # User said: "If we can go based off of duration then we can do sets too. and only grab that latests set." + # So let's filter sets by date if duration is set. + + if start_date and release_date and release_date < start_date: + continue + + set_obj, created = Set.objects.update_or_create( + code=set_data.get('code'), + game=game, + defaults={ + 'name': set_data.get('name'), + 'release_date': release_date, + } + ) + sets_processed += 1 + + self.stdout.write(self.style.SUCCESS(f'Processed {sets_processed} sets.')) + + # 3. Fetch Cards + if duration == 'all': + self.fetch_bulk_data(game) + else: + self.fetch_recent_cards(game, start_date) + + self.stdout.write(self.style.SUCCESS('Finished MTG population!')) + + def fetch_bulk_data(self, game): + self.stdout.write('Fetching Bulk Data info (Full Update)...') + response = requests.get('https://api.scryfall.com/bulk-data') + if response.status_code != 200: + self.stdout.write(self.style.ERROR('Failed to fetch bulk data info')) + return + + bulk_data_list = response.json().get('data', []) + download_uri = None + for item in bulk_data_list: + if item.get('type') == 'default_cards': + download_uri = item.get('download_uri') + break + + if not download_uri: + self.stdout.write(self.style.ERROR('Could not find "default_cards" bulk data.')) + return + + self.stdout.write(f'Downloading card data from {download_uri} ...') + + with requests.get(download_uri, stream=True) as r: + r.raise_for_status() + try: + cards_data = r.json() + except Exception as e: + self.stdout.write(self.style.ERROR(f'Failed to load JSON: {e}')) + return + + self.process_cards(cards_data, game) + + def fetch_recent_cards(self, game, start_date): + self.stdout.write(f'Fetching cards released since {start_date}...') + # Use Search API + # date>=YYYY-MM-DD + query = f"date>={start_date.isoformat()}" + url = "https://api.scryfall.com/cards/search" + params = {'q': query, 'order': 'released'} + + has_more = True + next_url = url + total_processed = 0 + + while has_more: + self.stdout.write(f' Requesting: {next_url} (params: {params})') + response = requests.get(next_url, params=params if next_url == url else None) + + if response.status_code != 200: + self.stdout.write(self.style.ERROR(f'Failed to search cards: {response.text}')) + return + + data = response.json() + cards_data = data.get('data', []) + self.process_cards(cards_data, game, verbose=False) + + total_processed += len(cards_data) + has_more = data.get('has_more', False) + next_url = data.get('next_page') + + # Scryfall requests being nice + import time + time.sleep(0.1) + + self.stdout.write(f'fetched {total_processed} cards via search.') + + def process_cards(self, cards_data, game, verbose=True): + if verbose: + self.stdout.write(f'Processing {len(cards_data)} cards...') + + # Cache sets + sets_map = {s.code: s for s in Set.objects.filter(game=game)} + count = 0 + + for card_data in cards_data: + set_code = card_data.get('set') + if set_code not in sets_map: + # If we filtered sets by date, we might miss the set for this card if the card is newer than the set release? + # (e.g. late additions). + # Or if we have a partial set update but the card belongs to an old set (reprints in new product?) + # If set is not in DB, we skip or fetch it? + # Given we updated sets based on duration, if the set isn't there, we probably shouldn't add the card + # OR we should lazily create the set. + # For safety, let's try to get the set from DB again or skip. + # If we skipped the set because of date, we probably shouldn't double guess ourselves. + continue + + set_obj = sets_map[set_code] + + # Extract Image URL + image_url = '' + if 'image_uris' in card_data and 'normal' in card_data['image_uris']: + image_url = card_data['image_uris']['normal'] + elif 'card_faces' in card_data and card_data['card_faces'] and 'image_uris' in card_data['card_faces'][0]: + if 'normal' in card_data['card_faces'][0]['image_uris']: + image_url = card_data['card_faces'][0]['image_uris']['normal'] + + # TCGPlayer ID + tcgplayer_id = card_data.get('tcgplayer_id') + + # Scryfall ID + scryfall_id = card_data.get('id') + + # External URL (Scryfall URI) + external_url = card_data.get('scryfall_uri', '') + + # Collector Number + collector_number = card_data.get('collector_number', '') + + Card.objects.update_or_create( + scryfall_id=scryfall_id, + defaults={ + 'set': set_obj, + 'name': card_data.get('name'), + 'rarity': card_data.get('rarity'), + 'image_url': image_url, + 'tcgplayer_id': tcgplayer_id, + 'collector_number': collector_number, + 'external_url': external_url, + } + ) + count += 1 + if verbose and count % 1000 == 0: + self.stdout.write(f' Processed {count}...') + + if verbose: + self.stdout.write(f'Batch processed {count} cards.') diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py index ff2808e..68eebef 100644 --- a/store/migrations/0001_initial.py +++ b/store/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.1 on 2026-01-19 13:38 +# Generated by Django 6.0.1 on 2026-01-21 17:40 import django.db.models.deletion from django.db import migrations, models @@ -29,6 +29,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), + ('insurance', models.BooleanField(default=False)), ], ), migrations.CreateModel( @@ -56,6 +57,8 @@ class Migration(migrations.Migration): ('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)), ('stripe_payment_intent', models.CharField(blank=True, max_length=100)), ('shipping_address', models.TextField(blank=True)), + ('insurance_purchased', models.BooleanField(default=False)), + ('proxy_service', models.BooleanField(default=False)), ], ), migrations.CreateModel( @@ -66,6 +69,28 @@ class Migration(migrations.Migration): ('quantity', models.PositiveIntegerField(default=1)), ], ), + migrations.CreateModel( + name='PackListing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('image_url', models.URLField(blank=True, max_length=500)), + ], + ), + migrations.CreateModel( + name='Seller', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('store_name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(unique=True)), + ('description', models.TextField(blank=True)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('contact_phone', models.CharField(blank=True, max_length=20)), + ('business_address', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), migrations.CreateModel( name='Set', fields=[ @@ -75,6 +100,33 @@ class Migration(migrations.Migration): ('release_date', models.DateField(blank=True, null=True)), ], ), + migrations.CreateModel( + name='VaultItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1)), + ('added_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='VirtualPack', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('sealed', 'Sealed'), ('opened', 'Opened')], default='sealed', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Bounty', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('target_price', models.DecimalField(decimal_places=2, max_digits=10)), + ('quantity_wanted', models.PositiveIntegerField(default=1)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.card')), + ], + ), migrations.CreateModel( name='CardListing', fields=[ diff --git a/store/migrations/0002_initial.py b/store/migrations/0002_initial.py index 341dcd4..4d6f2f2 100644 --- a/store/migrations/0002_initial.py +++ b/store/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.1 on 2026-01-19 13:38 +# Generated by Django 6.0.1 on 2026-01-21 17:40 import django.db.models.deletion from django.conf import settings @@ -11,14 +11,15 @@ class Migration(migrations.Migration): dependencies = [ ('store', '0001_initial'), + ('users', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( model_name='cart', - name='user', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL), + name='buyer', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to='users.buyer'), ), migrations.AddField( model_name='cartitem', @@ -28,23 +29,53 @@ class Migration(migrations.Migration): migrations.AddField( model_name='cartitem', name='listing', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'), ), migrations.AddField( model_name='order', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL), + name='buyer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='users.buyer'), ), migrations.AddField( model_name='orderitem', name='listing', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'), ), migrations.AddField( model_name='orderitem', name='order', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='store.order'), ), + migrations.AddField( + model_name='packlisting', + name='game', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.game'), + ), + migrations.AddField( + model_name='orderitem', + name='pack_listing', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.packlisting'), + ), + migrations.AddField( + model_name='cartitem', + name='pack_listing', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.packlisting'), + ), + migrations.AddField( + model_name='seller', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='seller_profile', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='packlisting', + name='seller', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.seller'), + ), + migrations.AddField( + model_name='cardlisting', + name='seller', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='card_listings', to='store.seller'), + ), migrations.AddField( model_name='set', name='game', @@ -55,4 +86,33 @@ class Migration(migrations.Migration): name='set', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='store.set'), ), + migrations.AddField( + model_name='vaultitem', + name='buyer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='users.buyer'), + ), + migrations.AddField( + model_name='vaultitem', + name='card', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='store.card'), + ), + migrations.AddField( + model_name='virtualpack', + name='cards', + field=models.ManyToManyField(related_name='packs', to='store.card'), + ), + migrations.AddField( + model_name='virtualpack', + name='listing', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packs', to='store.packlisting'), + ), + migrations.AddField( + model_name='virtualpack', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='packs', to='users.buyer'), + ), + migrations.AlterUniqueTogether( + name='vaultitem', + unique_together={('buyer', 'card')}, + ), ] diff --git a/store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py b/store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py deleted file mode 100644 index efd7d29..0000000 --- a/store/migrations/0003_cart_insurance_order_insurance_purchased_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 6.0.1 on 2026-01-19 16:22 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0002_initial'), - ] - - operations = [ - migrations.AddField( - model_name='cart', - name='insurance', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='order', - name='insurance_purchased', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='order', - name='proxy_service', - field=models.BooleanField(default=False), - ), - migrations.CreateModel( - name='Bounty', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('target_price', models.DecimalField(decimal_places=2, max_digits=10)), - ('quantity_wanted', models.PositiveIntegerField(default=1)), - ('is_active', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.card')), - ], - ), - ] diff --git a/store/migrations/0003_packlisting_quantity.py b/store/migrations/0003_packlisting_quantity.py new file mode 100644 index 0000000..5e177f3 --- /dev/null +++ b/store/migrations/0003_packlisting_quantity.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-01-21 18:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='packlisting', + name='quantity', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py b/store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py deleted file mode 100644 index 5899783..0000000 --- a/store/migrations/0004_alter_cartitem_listing_alter_orderitem_listing_and_more.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 6.0.1 on 2026-01-19 18:44 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0003_cart_insurance_order_insurance_purchased_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='cartitem', - name='listing', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.cardlisting'), - ), - migrations.AlterField( - model_name='orderitem', - name='listing', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.cardlisting'), - ), - migrations.CreateModel( - name='PackListing', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=200)), - ('price', models.DecimalField(decimal_places=2, max_digits=10)), - ('image_url', models.URLField(blank=True, max_length=500)), - ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pack_listings', to='store.game')), - ], - ), - migrations.AddField( - model_name='cartitem', - name='pack_listing', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='store.packlisting'), - ), - migrations.AddField( - model_name='orderitem', - name='pack_listing', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.packlisting'), - ), - migrations.CreateModel( - name='VirtualPack', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.CharField(choices=[('sealed', 'Sealed'), ('opened', 'Opened')], default='sealed', max_length=10)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('cards', models.ManyToManyField(related_name='packs', to='store.card')), - ('listing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packs', to='store.packlisting')), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='packs', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/store/migrations/0004_packlisting_listing_type.py b/store/migrations/0004_packlisting_listing_type.py new file mode 100644 index 0000000..a1c9a5b --- /dev/null +++ b/store/migrations/0004_packlisting_listing_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-01-21 19:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0003_packlisting_quantity'), + ] + + operations = [ + migrations.AddField( + model_name='packlisting', + name='listing_type', + field=models.CharField(choices=[('physical', 'Physical (Shipped)'), ('virtual', 'Virtual (Open on Store)')], default='physical', max_length=10), + ), + ] diff --git a/store/migrations/0005_cardlisting_image_cardlisting_status.py b/store/migrations/0005_cardlisting_image_cardlisting_status.py new file mode 100644 index 0000000..3890424 --- /dev/null +++ b/store/migrations/0005_cardlisting_image_cardlisting_status.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-01-21 20:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0004_packlisting_listing_type'), + ] + + operations = [ + migrations.AddField( + model_name='cardlisting', + name='image', + field=models.ImageField(blank=True, null=True, upload_to='listing_images/'), + ), + migrations.AddField( + model_name='cardlisting', + name='status', + field=models.CharField(choices=[('listed', 'Listed'), ('sold', 'Sold'), ('off_market', 'Off Market')], default='listed', max_length=20), + ), + ] diff --git a/store/migrations/0005_vaultitem.py b/store/migrations/0005_vaultitem.py deleted file mode 100644 index a45ba3f..0000000 --- a/store/migrations/0005_vaultitem.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0.1 on 2026-01-19 19:14 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0004_alter_cartitem_listing_alter_orderitem_listing_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='VaultItem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.PositiveIntegerField(default=1)), - ('added_at', models.DateTimeField(auto_now_add=True)), - ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to='store.card')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault_items', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'unique_together': {('user', 'card')}, - }, - ), - ] diff --git a/store/migrations/0006_card_external_url.py b/store/migrations/0006_card_external_url.py new file mode 100644 index 0000000..91df678 --- /dev/null +++ b/store/migrations/0006_card_external_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-01-21 20:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0005_cardlisting_image_cardlisting_status'), + ] + + operations = [ + migrations.AddField( + model_name='card', + name='external_url', + field=models.URLField(blank=True, help_text='Link to official card page (e.g. Scryfall, Lorcast)', max_length=500), + ), + ] diff --git a/store/migrations/0007_seller_hero_image_seller_store_image.py b/store/migrations/0007_seller_hero_image_seller_store_image.py new file mode 100644 index 0000000..262fab6 --- /dev/null +++ b/store/migrations/0007_seller_hero_image_seller_store_image.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-01-22 13:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0006_card_external_url'), + ] + + operations = [ + migrations.AddField( + model_name='seller', + name='hero_image', + field=models.ImageField(blank=True, null=True, upload_to='seller_hero_images/'), + ), + migrations.AddField( + model_name='seller', + name='store_image', + field=models.ImageField(blank=True, null=True, upload_to='seller_images/'), + ), + ] diff --git a/store/migrations/0008_bounty_description_bounty_seller_bounty_title_and_more.py b/store/migrations/0008_bounty_description_bounty_seller_bounty_title_and_more.py new file mode 100644 index 0000000..72e6a5f --- /dev/null +++ b/store/migrations/0008_bounty_description_bounty_seller_bounty_title_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.1 on 2026-01-22 15:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0007_seller_hero_image_seller_store_image'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bounty', + name='description', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='bounty', + name='seller', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bounties', to='store.seller'), + ), + migrations.AddField( + model_name='bounty', + name='title', + field=models.CharField(blank=True, help_text='Required if no card selected', max_length=200), + ), + migrations.AlterField( + model_name='bounty', + name='card', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bounties', to='store.card'), + ), + migrations.CreateModel( + name='BountyOffer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('description', models.TextField(blank=True, help_text='Details about what you are offering (e.g. card condition)')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('countered', 'Countered')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offers', to='store.bounty')), + ('buyer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_offers', to='users.buyer')), + ], + ), + ] diff --git a/store/migrations/0009_seller_listing_clicks_seller_store_views.py b/store/migrations/0009_seller_listing_clicks_seller_store_views.py new file mode 100644 index 0000000..2e8630c --- /dev/null +++ b/store/migrations/0009_seller_listing_clicks_seller_store_views.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-01-23 12:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0008_bounty_description_bounty_seller_bounty_title_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='seller', + name='listing_clicks', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='seller', + name='store_views', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/store/migrations/0010_remove_cartitem_pack_listing_bounty_uuid_and_more.py b/store/migrations/0010_remove_cartitem_pack_listing_bounty_uuid_and_more.py new file mode 100644 index 0000000..70e8e39 --- /dev/null +++ b/store/migrations/0010_remove_cartitem_pack_listing_bounty_uuid_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 6.0.1 on 2026-01-23 12:48 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("store", "0009_seller_listing_clicks_seller_store_views"), + ] + + operations = [ + migrations.RemoveField( + model_name="cartitem", + name="pack_listing", + ), + migrations.AddField( + model_name="bounty", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.AddField( + model_name="bountyoffer", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.AddField( + model_name="card", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.AddField( + model_name="cardlisting", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.AddField( + model_name="cartitem", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.AddField( + model_name="order", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.AddField( + model_name="packlisting", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + migrations.AddField( + model_name="virtualpack", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + ] diff --git a/store/migrations/0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more.py b/store/migrations/0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more.py new file mode 100644 index 0000000..183435b --- /dev/null +++ b/store/migrations/0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 6.0.1 on 2026-01-23 12:48 + +import uuid +from django.db import migrations, models + +def populate_uuids(apps, schema_editor): + models_to_update = [ + "Bounty", + "BountyOffer", + "Card", + "CardListing", + "CartItem", + "Order", + "PackListing", + "VirtualPack", + ] + for model_name in models_to_update: + try: + Model = apps.get_model("store", model_name) + for obj in Model.objects.all(): + obj.uuid = uuid.uuid4() + obj.save() + except LookupError: + pass + +class Migration(migrations.Migration): + + dependencies = [ + ("store", "0010_remove_cartitem_pack_listing_bounty_uuid_and_more"), + ] + + operations = [ + migrations.RunPython(populate_uuids, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name="bounty", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="bountyoffer", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="card", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="cardlisting", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="cartitem", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="order", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="packlisting", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name="virtualpack", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/store/migrations/0012_cartitem_pack_listing.py b/store/migrations/0012_cartitem_pack_listing.py new file mode 100644 index 0000000..33848cb --- /dev/null +++ b/store/migrations/0012_cartitem_pack_listing.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.1 on 2026-01-23 15:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("store", "0011_alter_bounty_uuid_alter_bountyoffer_uuid_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="cartitem", + name="pack_listing", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="store.packlisting", + ), + ), + ] diff --git a/store/migrations/0013_order_rating_order_seller.py b/store/migrations/0013_order_rating_order_seller.py new file mode 100644 index 0000000..693e327 --- /dev/null +++ b/store/migrations/0013_order_rating_order_seller.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.1 on 2026-01-23 16:16 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("store", "0012_cartitem_pack_listing"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="rating", + field=models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(5), + ], + ), + ), + migrations.AddField( + model_name="order", + name="seller", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="orders", + to="store.seller", + ), + ), + ] diff --git a/store/migrations/0014_sellerreport.py b/store/migrations/0014_sellerreport.py new file mode 100644 index 0000000..7df07b6 --- /dev/null +++ b/store/migrations/0014_sellerreport.py @@ -0,0 +1,59 @@ +# Generated by Django 6.0.1 on 2026-01-23 16:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("store", "0013_order_rating_order_seller"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SellerReport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "reason", + models.CharField( + choices=[ + ("explicit", "Explicit/NSFW Content"), + ("scam", "Scam"), + ("other", "Other"), + ], + max_length=20, + ), + ), + ("details", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "reporter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="seller_reports", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "seller", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reports", + to="store.seller", + ), + ), + ], + ), + ] diff --git a/store/migrations/0015_seller_minimum_order_amount_seller_shipping_cost.py b/store/migrations/0015_seller_minimum_order_amount_seller_shipping_cost.py new file mode 100644 index 0000000..d13ab70 --- /dev/null +++ b/store/migrations/0015_seller_minimum_order_amount_seller_shipping_cost.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-01-23 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("store", "0014_sellerreport"), + ] + + operations = [ + migrations.AddField( + model_name="seller", + name="minimum_order_amount", + field=models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + migrations.AddField( + model_name="seller", + name="shipping_cost", + field=models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + ] diff --git a/store/migrations/0016_seller_payout_details_encrypted_and_more.py b/store/migrations/0016_seller_payout_details_encrypted_and_more.py new file mode 100644 index 0000000..3e9d9ac --- /dev/null +++ b/store/migrations/0016_seller_payout_details_encrypted_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-01-23 17:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("store", "0015_seller_minimum_order_amount_seller_shipping_cost"), + ] + + operations = [ + migrations.AddField( + model_name="seller", + name="payout_details_encrypted", + field=models.BinaryField(blank=True, null=True), + ), + migrations.AddField( + model_name="seller", + name="tax_id_encrypted", + field=models.BinaryField(blank=True, null=True), + ), + ] diff --git a/store/models.py b/store/models.py index 81b6600..1d12c63 100644 --- a/store/models.py +++ b/store/models.py @@ -1,5 +1,7 @@ from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator from django.conf import settings +import uuid class Game(models.Model): name = models.CharField(max_length=100, unique=True) @@ -17,6 +19,48 @@ class Set(models.Model): def __str__(self): return f"{self.game.name} - {self.name}" +class Seller(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='seller_profile') + store_name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(unique=True) + description = models.TextField(blank=True) + store_image = models.ImageField(upload_to='seller_images/', blank=True, null=True) + hero_image = models.ImageField(upload_to='seller_hero_images/', blank=True, null=True) + contact_email = models.EmailField(blank=True) + contact_phone = models.CharField(max_length=20, blank=True) + business_address = models.TextField(blank=True) + store_views = models.PositiveIntegerField(default=0) + listing_clicks = models.PositiveIntegerField(default=0) + minimum_order_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + shipping_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0) + created_at = models.DateTimeField(auto_now_add=True) + + tax_id_encrypted = models.BinaryField(blank=True, null=True) + payout_details_encrypted = models.BinaryField(blank=True, null=True) + + @property + def tax_id(self): + from .utils import Encryptor + return Encryptor.decrypt(self.tax_id_encrypted) + + @tax_id.setter + def tax_id(self, value): + from .utils import Encryptor + self.tax_id_encrypted = Encryptor.encrypt(value) + + @property + def payout_details(self): + from .utils import Encryptor + return Encryptor.decrypt(self.payout_details_encrypted) + + @payout_details.setter + def payout_details(self, value): + from .utils import Encryptor + self.payout_details_encrypted = Encryptor.encrypt(value) + + def __str__(self): + return self.store_name + class Card(models.Model): set = models.ForeignKey(Set, on_delete=models.CASCADE, related_name='cards') name = models.CharField(max_length=200) @@ -25,10 +69,13 @@ class Card(models.Model): scryfall_id = models.CharField(max_length=100, blank=True, null=True) tcgplayer_id = models.CharField(max_length=100, blank=True, null=True) collector_number = models.CharField(max_length=50, blank=True) + external_url = models.URLField(max_length=500, blank=True, help_text="Link to official card page (e.g. Scryfall, Lorcast)") + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) def __str__(self): return self.name + class CardListing(models.Model): CONDITION_CHOICES = ( ('NM', 'Near Mint'), @@ -37,21 +84,42 @@ class CardListing(models.Model): ('HP', 'Heavily Played'), ) + STATUS_CHOICES = ( + ('listed', 'Listed'), + ('sold', 'Sold'), + ('off_market', 'Off Market'), + ) + card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='listings') + seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='card_listings', null=True, blank=True) condition = models.CharField(max_length=2, choices=CONDITION_CHOICES, default='NM') price = models.DecimalField(max_digits=10, decimal_places=2) quantity = models.PositiveIntegerField(default=0) market_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) is_foil = models.BooleanField(default=False) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='listed') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='listed') + image = models.ImageField(upload_to='listing_images/', blank=True, null=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) def __str__(self): - return f"{self.card.name} ({self.condition}) - ${self.price}" + return f"{self.card.name} ({self.condition}) - ${self.price} [{self.status}]" class PackListing(models.Model): + LISTING_TYPE_CHOICES = ( + ('physical', 'Physical (Shipped)'), + ('virtual', 'Virtual (Open on Store)'), + ) + game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='pack_listings') + seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='pack_listings', null=True, blank=True) name = models.CharField(max_length=200) + listing_type = models.CharField(max_length=10, choices=LISTING_TYPE_CHOICES, default='physical') price = models.DecimalField(max_digits=10, decimal_places=2) + quantity = models.PositiveIntegerField(default=0) + quantity = models.PositiveIntegerField(default=0) image_url = models.URLField(max_length=500, blank=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) def __str__(self): return f"{self.name} - ${self.price}" @@ -64,8 +132,9 @@ class VirtualPack(models.Model): listing = models.ForeignKey(PackListing, on_delete=models.CASCADE, related_name='packs') cards = models.ManyToManyField(Card, related_name='packs') status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='sealed') - owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='packs') + owner = models.ForeignKey('users.Buyer', on_delete=models.SET_NULL, null=True, blank=True, related_name='packs') created_at = models.DateTimeField(auto_now_add=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) def __str__(self): return f"{self.listing.name} ({self.get_status_display()})" @@ -78,7 +147,7 @@ class Order(models.Model): ('cancelled', 'Cancelled'), ) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders') + buyer = models.ForeignKey('users.Buyer', on_delete=models.CASCADE, related_name='orders') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -87,9 +156,12 @@ class Order(models.Model): shipping_address = models.TextField(blank=True) insurance_purchased = models.BooleanField(default=False) proxy_service = models.BooleanField(default=False) + seller = models.ForeignKey(Seller, on_delete=models.SET_NULL, related_name='orders', null=True, blank=True) + rating = models.PositiveSmallIntegerField(null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(5)]) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) def __str__(self): - return f"Order #{self.id} - {self.user.username}" + return f"Order #{self.id} - {self.buyer.user.username}" class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') @@ -104,7 +176,7 @@ class OrderItem(models.Model): return f"{self.quantity}x {self.listing.card.name if self.listing else 'Deleted Listing'}" class Cart(models.Model): - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='cart', null=True, blank=True) + buyer = models.OneToOneField('users.Buyer', on_delete=models.CASCADE, related_name='cart', null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) insurance = models.BooleanField(default=False) @@ -120,6 +192,7 @@ class CartItem(models.Model): listing = models.ForeignKey(CardListing, on_delete=models.CASCADE, null=True, blank=True) pack_listing = models.ForeignKey(PackListing, on_delete=models.CASCADE, null=True, blank=True) quantity = models.PositiveIntegerField(default=1) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) @property def total_price(self): @@ -130,23 +203,69 @@ class CartItem(models.Model): return 0 class Bounty(models.Model): - card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='bounties') + seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='bounties', null=True) + card = models.ForeignKey(Card, on_delete=models.SET_NULL, related_name='bounties', null=True, blank=True) + title = models.CharField(max_length=200, blank=True, help_text="Required if no card selected") + description = models.TextField(blank=True) target_price = models.DecimalField(max_digits=10, decimal_places=2) quantity_wanted = models.PositiveIntegerField(default=1) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) def __str__(self): - return f"WANTED: {self.card.name} @ ${self.target_price}" + if self.card: + return f"WANTED: {self.card.name} @ ${self.target_price}" + return f"WANTED: {self.title} @ ${self.target_price}" + + def save(self, *args, **kwargs): + if not self.card and not self.title: + raise ValueError("Bounty must have either a Card or a Title") + if self.card and not self.title: + self.title = f"Buying {self.card.name}" + super().save(*args, **kwargs) + +class BountyOffer(models.Model): + STATUS_CHOICES = ( + ('pending', 'Pending'), + ('accepted', 'Accepted'), + ('rejected', 'Rejected'), + ('countered', 'Countered'), + ) + bounty = models.ForeignKey(Bounty, on_delete=models.CASCADE, related_name='offers') + buyer = models.ForeignKey('users.Buyer', on_delete=models.CASCADE, related_name='bounty_offers') + price = models.DecimalField(max_digits=10, decimal_places=2) + description = models.TextField(blank=True, help_text="Details about what you are offering (e.g. card condition)") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + created_at = models.DateTimeField(auto_now_add=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + + def __str__(self): + return f"Offer of ${self.price} by {self.buyer.user.username} on {self.bounty}" class VaultItem(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='vault_items') + buyer = models.ForeignKey('users.Buyer', on_delete=models.CASCADE, related_name='vault_items') card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='vault_items') quantity = models.PositiveIntegerField(default=1) added_at = models.DateTimeField(auto_now_add=True) class Meta: - unique_together = ('user', 'card') + unique_together = ('buyer', 'card') def __str__(self): - return f"{self.user.username}'s {self.card.name} ({self.quantity})" + return f"{self.buyer.user.username}'s {self.card.name} ({self.quantity})" + +class SellerReport(models.Model): + reporter = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='seller_reports') + seller = models.ForeignKey(Seller, on_delete=models.CASCADE, related_name='reports') + REASON_CHOICES = [ + ('explicit', 'Explicit/NSFW Content'), + ('scam', 'Scam'), + ('other', 'Other'), + ] + reason = models.CharField(max_length=20, choices=REASON_CHOICES) + details = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Report by {self.reporter} on {self.seller.store_name} - {self.get_reason_display()}" diff --git a/store/templates/store/add_card_listing.html b/store/templates/store/add_card_listing.html new file mode 100644 index 0000000..5a56643 --- /dev/null +++ b/store/templates/store/add_card_listing.html @@ -0,0 +1,348 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Add New Card Listing

+

+ Specify the card details. If the card or set doesn't exist, it will be created automatically. +

+ + +
+ + +
+ + +
+
+ {% csrf_token %} + {% for field in form %} +
+ + + {{ field }} + + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% if field.errors %} +
+ {{ field.errors.as_text }} +
+ {% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+ + + +
+
+ + + + +{% endblock %} diff --git a/store/templates/store/add_pack_listing.html b/store/templates/store/add_pack_listing.html new file mode 100644 index 0000000..5e135a9 --- /dev/null +++ b/store/templates/store/add_pack_listing.html @@ -0,0 +1,153 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Add New Pack Listing

+

+ Create a listing for physical or virtual packs. +

+ + +
+ + +
+ + +
+
+ {% csrf_token %} + {% for field in form %} +
+ + + {{ field }} + + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% if field.errors %} +
+ {{ field.errors.as_text }} +
+ {% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+ + + +
+
+ + + + +{% endblock %} diff --git a/store/templates/store/admin_revenue_dashboard.html b/store/templates/store/admin_revenue_dashboard.html new file mode 100644 index 0000000..9199a5b --- /dev/null +++ b/store/templates/store/admin_revenue_dashboard.html @@ -0,0 +1,86 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Platform Revenue Dashboard

+ +
+

Total Platform Revenue

+

${{ total_platform_revenue }}

+
+ +
+ + + + + + + + + + {% for row in seller_data %} + + + + + + {% endfor %} + +
SellerTotal SalesPlatform Fees
+ {{ row.seller.store_name }}
+ {{ row.seller.user.email }} +
${{ row.total_revenue }}${{ row.platform_fees }}
+
+
+ + +{% endblock %} diff --git a/store/templates/store/bounty_detail.html b/store/templates/store/bounty_detail.html new file mode 100644 index 0000000..3e107e9 --- /dev/null +++ b/store/templates/store/bounty_detail.html @@ -0,0 +1,126 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+ +
+
+
+

+ {% if bounty.card %} + Wanted: {{ bounty.card.name }} + {% else %} + Wanted: {{ bounty.title }} + {% endif %} +

+

+ Posted by {{ bounty.seller.store_name }} on {{ bounty.created_at|date:"M d, Y" }} +

+
+
+ ${{ bounty.target_price }} + Target Price / item +
+
+ +
+

Details

+

{{ bounty.description|default:"No additional details provided."|linebreaks }}

+
+ +
+
+ Quantity Wanted + {{ bounty.quantity_wanted }} +
+ +
+
+ + {% if is_seller %} + +
+

Offers Received ({{ offers|length }})

+ + {% if offers %} +
+ {% for offer in offers %} +
+
+
+

+ ${{ offer.price }} + from {{ offer.buyer.user.username }} +

+

+ {{ offer.description|default:"No note included." }} +

+

{{ offer.created_at|date:"M d, H:i" }}

+
+ +
+ + {{ offer.get_status_display }} + + + {% if offer.status == 'pending' %} +
+ Accept + Reject +
+ {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +

No offers yet.

+ {% endif %} +
+ + {% elif is_buyer %} + +
+ {% if user_offer %} +
+

Your Offer

+
+ ${{ user_offer.price }} + + {{ user_offer.get_status_display }} + +
+

You have already submitted an offer for this bounty.

+
+ {% else %} +

Make an Offer

+
+ {% csrf_token %} +
+ + {{ offer_form.price }} + +
+
+ + {{ offer_form.description }} +
+ +
+ {% endif %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/store/templates/store/bounty_form.html b/store/templates/store/bounty_form.html new file mode 100644 index 0000000..6881169 --- /dev/null +++ b/store/templates/store/bounty_form.html @@ -0,0 +1,211 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

{{ title }}

+ +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} + {% if field.errors %} +
+ {{ field.errors }} +
+ {% endif %} +
+ {% endfor %} + +
+ + Cancel + + +
+
+
+
+ + + + +{% endblock %} diff --git a/store/templates/store/bounty_list.html b/store/templates/store/bounty_list.html new file mode 100644 index 0000000..5e79ee1 --- /dev/null +++ b/store/templates/store/bounty_list.html @@ -0,0 +1,78 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+
+

Bounty Board

+

Seller/Buyer Marketplace for Buying Cards

+
+ {% if user.is_authenticated and user.seller_profile %} +
+ + Post Bounty +
+ {% endif %} +
+ + {% if bounties %} +
+ {% for bounty in bounties %} +
+
+
+ + {% if bounty.card.image_url %} + {{ bounty.card.name }} + {% else %} +
📝
+ {% endif %} + + + +
+ +
+
+

Buying For

+

${{ bounty.target_price }}

+
+
+

Needed

+

{{ bounty.quantity_wanted }}

+
+
+ +
+

+ {{ bounty.description|default:"No details." }} +

+
+
+ +
+ View Details +
+
+ {% endfor %} +
+ {% else %} +
+

No active bounties at the moment.

+ {% if user.is_authenticated and user.seller_profile %} + Be the first to post one! + {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/store/templates/store/csv/card_listing_template.csv b/store/templates/store/csv/card_listing_template.csv new file mode 100644 index 0000000..26c4ebf --- /dev/null +++ b/store/templates/store/csv/card_listing_template.csv @@ -0,0 +1,3 @@ +Game,Set,Card Name,Collector Number,Condition (NM/LP/MP/HP),Price,Quantity,Image Filename +Magic: The Gathering,Alpha,Black Lotus,,NM,10000.00,1,black_lotus.jpg +Magic: The Gathering,Beta,Mox Sapphire,,LP,5000.00,1, diff --git a/store/templates/store/csv/pack_listing_template.csv b/store/templates/store/csv/pack_listing_template.csv new file mode 100644 index 0000000..c557b58 --- /dev/null +++ b/store/templates/store/csv/pack_listing_template.csv @@ -0,0 +1,3 @@ +Game,Name,Listing Type (physical/virtual),Price,Quantity,Image Filename +Magic: The Gathering,Alpha Booster Box,physical,15000.00,1,alpha_box.jpg +Magic: The Gathering,Beta Booster Pack,virtual,500.00,10, diff --git a/store/templates/store/seller/add_virtual_pack_content.html b/store/templates/store/seller/add_virtual_pack_content.html new file mode 100644 index 0000000..2d8ba8b --- /dev/null +++ b/store/templates/store/seller/add_virtual_pack_content.html @@ -0,0 +1,49 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+

Add Content to Pack

+

Listing: {{ listing.name }}

+ + +
+ + +
+ +
+ {% csrf_token %} + + {% if cards %} +
+

Select Cards to Include

+
+ {% for card in cards %} +
+ + +
+ {% endfor %} +
+

Select multiple cards to create a single pack containing all selected items.

+
+ +
+ + Cancel +
+ + {% elif query %} +

No cards found matching "{{ query }}"

+ {% endif %} +
+
+{% endblock %} diff --git a/store/templates/store/seller/confirm_delete.html b/store/templates/store/seller/confirm_delete.html new file mode 100644 index 0000000..52cae82 --- /dev/null +++ b/store/templates/store/seller/confirm_delete.html @@ -0,0 +1,21 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Confirm Delete

+

Are you sure you want to delete this listing?

+

{{ item }}

+ +
+ {% csrf_token %} + + Cancel + + +
+
+
+{% endblock %} diff --git a/store/templates/store/seller/dashboard.html b/store/templates/store/seller/dashboard.html new file mode 100644 index 0000000..a710545 --- /dev/null +++ b/store/templates/store/seller/dashboard.html @@ -0,0 +1,330 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+ +
+

{{ seller.store_name }}

+

Seller Dashboard

+ +
+ View Storefront + Edit Profile + Manage Inventory + Bounty Board +
+
+

Active Listings: {{ active_listings_count }}

+

Items Sold: {{ items_sold }}

+

Revenue: ${{ total_revenue|floatformat:2 }}

+ {% if avg_rating %} +

+ Avg Rating: + + + + + {{ avg_rating }}/5 + +

+ {% endif %} +
+ +
+ + +
+

Theme

+
+ {% csrf_token %} + {{ theme_form.theme_preference }} + +
+
+
+
+ + +
+ + +
+
+

Store Views

+

{{ store_views }}

+
+
+

Listing Clicks

+

{{ listing_clicks }}

+
+
+

Store Link

+
+ Store QR Code +
+ /store/{{ seller.slug }} +

Share this QR code

+
+
+
+
+ + +
+
+

Revenue (All Time)

+

${{ total_revenue|floatformat:2 }}

+
+
+

Units Sold

+

{{ items_sold }}

+
+
+

Avg. Order Value

+ +

+ {% if items_sold > 0 %} + ${{ total_revenue|divisibleby:items_sold|default:"0.00" }} + {# Django template math is limited, leaving simpler for now or use filters if available #} + {# Just leaving placeholder logic or better computed in view #} + -- + {% else %} + $0.00 + {% endif %} +

+
+
+ + +
+ + +
+

Analytics

+
+ + + {% if selected_game %}Clear{% endif %} +
+
+ + +
+
+

Sales & Revenue

+
+ + + +
+
+ +
+ + +
+
+
+ + + +
+
+ +
+ +
+ + +
+
+
+ + + + + + +
+{% endblock %} diff --git a/store/templates/store/seller/edit_listing.html b/store/templates/store/seller/edit_listing.html new file mode 100644 index 0000000..ab48d71 --- /dev/null +++ b/store/templates/store/seller/edit_listing.html @@ -0,0 +1,91 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

{{ title }}

+

+ Please fill out the details below as accurately as possible. +

+ +
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + + {% for field in form %} +
+ + + {{ field }} + + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% if field.errors %} +
+ {{ field.errors.as_text }} +
+ {% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+
+ + +{% endblock %} diff --git a/store/templates/store/seller/edit_profile.html b/store/templates/store/seller/edit_profile.html new file mode 100644 index 0000000..d0143a3 --- /dev/null +++ b/store/templates/store/seller/edit_profile.html @@ -0,0 +1,127 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Edit Store Profile

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+ +
+ + {{ form.store_name }} + {% if form.store_name.errors %} +
{{ form.store_name.errors }}
+ {% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ + +
+
+ + {{ form.store_image }} + {% if form.store_image.errors %} +
{{ form.store_image.errors }}
+ {% endif %} +
+ +
+ + {{ form.hero_image }} + {% if form.hero_image.errors %} +
{{ form.hero_image.errors }}
+ {% endif %} +
+
+ + +
+

Contact Information

+ +
+
+ + {{ form.contact_email }} +
+
+ + {{ form.contact_phone }} +
+
+ +
+ + {{ form.business_address }} +
+
+ + +
+

Shipping Settings

+ +
+
+ + {{ form.minimum_order_amount }} + {% if form.minimum_order_amount.errors %} +
{{ form.minimum_order_amount.errors }}
+ {% endif %} + Set to 0 if shipping is never free. +
+
+ + {{ form.shipping_cost }} + {% if form.shipping_cost.errors %} +
{{ form.shipping_cost.errors }}
+ {% endif %} + Standard cost for orders below minimum. +
+
+
+
+ +
+ + Cancel +
+
+
+
+ + +{% endblock %} diff --git a/store/templates/store/seller/manage_listings.html b/store/templates/store/seller/manage_listings.html new file mode 100644 index 0000000..d958cd8 --- /dev/null +++ b/store/templates/store/seller/manage_listings.html @@ -0,0 +1,135 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+
+

Manage Listings

+

Manage your inventory of Single Cards and Packs

+
+
+ + Add Card + {% if FEATURE_VIRTUAL_PACKS %} + + Add Pack + {% endif %} + + Post Bounty + Dashboard +
+
+ + +
+ +
+
+

Card Listings ({{ card_listings|length }})

+
+ + {% if card_listings %} +
+ {% for listing in card_listings %} +
+
+
+ {% if listing.image %} + {{ listing.card.name }} + {% elif listing.card.image_url %} + {{ listing.card.name }} + {% else %} +
N/A
+ {% endif %} +
+

{{ listing.card.name }}

+

{{ listing.card.set.name }}

+ + {{ listing.get_condition_display }} + +
+
+ +
+
+

Price

+

${{ listing.price }}

+
+
+

Stock

+

{{ listing.quantity }}

+
+
+
+
+ Edit + Delete +
+
+ {% endfor %} +
+ {% else %} +
+

No card listings found.

+ Add your first card +
+ {% endif %} +
+ + + {% if FEATURE_VIRTUAL_PACKS %} +
+
+

Pack Listings ({{ pack_listings|length }})

+
+ + {% if pack_listings %} +
+ {% for listing in pack_listings %} +
+
+
+ {% if listing.image_url %} + {{ listing.name }} + {% else %} +
📦
+ {% endif %} +
+

{{ listing.name }}

+

{{ listing.game.name }}

+
+
+ +
+
+

Price

+

${{ listing.price }}

+
+
+

Stock

+

{{ listing.quantity }}

+
+
+
+
+ {% if listing.listing_type == 'virtual' %} + Manage Inventory + {% endif %} + + Edit + Delete +
+
+ {% endfor %} +
+ {% else %} +
+

No pack listings found.

+ Add your first pack +
+ {% endif %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/store/templates/store/seller/manage_pack_inventory.html b/store/templates/store/seller/manage_pack_inventory.html new file mode 100644 index 0000000..691af53 --- /dev/null +++ b/store/templates/store/seller/manage_pack_inventory.html @@ -0,0 +1,43 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+
+

Manage Pack Inventory

+

Listing: {{ listing.name }}

+
+
+ + Add Pack Instance + Back to Listings +
+
+ +
+

Existing Packs ({{ packs|length }})

+ {% if packs %} +
+ {% for pack in packs %} +
+

Pack #{{ pack.id }}

+

{{ pack.get_status_display }}

+ +

Cards Inside:

+
    + {% for card in pack.cards.all %} +
  • • {{ card.name }} ({{ card.set.code }})
  • + {% empty %} +
  • No cards assigned!
  • + {% endfor %} +
+
+ {% endfor %} +
+ {% else %} +
+

No pack instances found for this listing.

+
+ {% endif %} +
+
+{% endblock %} diff --git a/store/templates/store/seller/profile.html b/store/templates/store/seller/profile.html new file mode 100644 index 0000000..43ace03 --- /dev/null +++ b/store/templates/store/seller/profile.html @@ -0,0 +1,374 @@ +{% extends 'base/layout.html' %} + +{% block content %} + +{% if seller.hero_image %} +
+ {{ seller.store_name }} Banner +
+ +
+
+{% endif %} + +
+ +
+
+ + {% if seller.store_image %} + {{ seller.store_name }} + {% else %} +
+ 🏪 +
+ {% endif %} + +

{{ seller.store_name }}

+

Verified Seller

+ {% if avg_rating %} +
+ + + + {{ avg_rating }}/5 +
+ {% endif %} +
+ +

+ {{ seller.description|default:"No description available." }} +

+ + {% if seller.contact_email or seller.contact_phone or seller.business_address %} +
+

Contact Info

+
+ {% if seller.contact_email %} +

📧 {{ seller.contact_email }}

+ {% endif %} + {% if seller.contact_phone %} +

📞 {{ seller.contact_phone }}

+ {% endif %} + {% if seller.business_address %} +

📍 {{ seller.business_address|linebreaksbr }}

+ {% endif %} +
+
+ {% endif %} + + +
+

Shipping Policy

+ {% if seller.minimum_order_amount > 0 %} +

+ Standard Shipping: ${{ seller.shipping_cost }} +

+

+ Free shipping on orders over ${{ seller.minimum_order_amount }}! +

+ {% else %} +

+ Free Shipping on all orders! +

+ {% endif %} +
+ + +
+ +
+
+ + +
+

Active Listings

+ + +
+
+
+ +
+ + + +
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ + Clear +
+
+
+ +
+ {% for listing in card_listings %} +
+ {% if listing.card.image_url %} + {{ listing.card.name }} + {% else %} +
No Image
+ {% endif %} +
+

{{ listing.card.name }}

+

{{ listing.card.set.name }}

+ +
+ ${{ listing.price }} + {{ listing.get_condition_display }} +
+ +
+ {% csrf_token %} + +
+
+
+ {% endfor %} + + {% for listing in pack_listings %} +
+
+ PACK +
+
+

{{ listing.name }}

+

{{ listing.game.name }}

+ +
+ ${{ listing.price }} +
+
+ {% csrf_token %} + +
+
+
+ {% endfor %} +
+ + {% if not card_listings and not pack_listings %} +
+

This seller has no active listings at the moment.

+
+ {% endif %} + + +

Bounty Board

+
+

TBD

+

This seller is not currently accepting bounties.

+
+
+
+ + + + + + + + +{% endblock %} diff --git a/store/templates/store/seller_register.html b/store/templates/store/seller_register.html new file mode 100644 index 0000000..aca17a6 --- /dev/null +++ b/store/templates/store/seller_register.html @@ -0,0 +1,66 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Become a Seller

+ +
+ {% csrf_token %} + + {% if user_form %} +

Account Details

+ {% if user_form.non_field_errors %} +
{{ user_form.non_field_errors }}
+ {% endif %} + {% for field in user_form %} +
+ + {{ field }} + {% if field.errors %}

{{ field.errors.0 }}

{% endif %} +
+ {% endfor %} +
+

Store Details

+ {% endif %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} +
+ {% endfor %} + + +
+
+
+ + +{% endblock %} diff --git a/store/templates/store/seller_register_success.html b/store/templates/store/seller_register_success.html new file mode 100644 index 0000000..750436e --- /dev/null +++ b/store/templates/store/seller_register_success.html @@ -0,0 +1,19 @@ +{% extends 'base/layout.html' %} + +{% block content %} +
+
+

Store Created!

+

Congratulations, your store "{{ store_name }}" is now active.

+ + +
+
+{% endblock %} diff --git a/store/tests.py b/store/tests.py index 7ce503c..0907815 100644 --- a/store/tests.py +++ b/store/tests.py @@ -1,3 +1,239 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile +from .models import Seller, Game, Set, Card, CardListing, Order +from decimal import Decimal -# Create your tests here. +User = get_user_model() + +class CardListingTests(TestCase): + def setUp(self): + # Create User and Seller + self.user = User.objects.create_user(username='seller', password='password') + self.seller = Seller.objects.create( + user=self.user, + store_name='Test Store', + slug='test-store' + ) + + # Create Game, Set, Card + self.game = Game.objects.create(name='Test Game', slug='test-game') + self.set = Set.objects.create(game=self.game, name='Test Set') + self.card = Card.objects.create(set=self.set, name='Test Card') + + # Create Listing + self.listing = CardListing.objects.create( + card=self.card, + seller=self.seller, + price=10.00, + quantity=1, + condition='NM' + ) + + self.client = Client() + self.client.force_login(self.user) + + def test_edit_card_listing_image_upload(self): + url = reverse('store:edit_card_listing', args=[self.listing.uuid]) + + # Create a small image file + image_content = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x01\x44\x00\x3b' + image = SimpleUploadedFile("test_image.gif", image_content, content_type="image/gif") + + data = { + 'condition': 'LP', + 'price': '15.00', + 'quantity': 2, + 'status': 'listed', + 'image': image + } + + response = self.client.post(url, data, format='multipart') + + # Check redirect + self.assertRedirects(response, reverse('store:manage_listings')) + + # Reload listing + self.listing.refresh_from_db() + + # Check updates + self.assertEqual(self.listing.condition, 'LP') + self.assertEqual(self.listing.price, 15.00) + self.assertEqual(self.listing.quantity, 2) + + # Check image + self.assertTrue(self.listing.image) + self.assertTrue(self.listing.image.name.endswith('test_image.gif')) + +class SellerDashboardTests(TestCase): + def setUp(self): + # Create User and Seller + self.user = User.objects.create_user(username='dashboard_seller', password='password') + self.seller = Seller.objects.create( + user=self.user, + store_name='Dashboard Store', + slug='dashboard-store' + ) + self.buyer_user = User.objects.create_user(username='buyer', password='password') + from users.models import Buyer + self.buyer = Buyer.objects.create(user=self.buyer_user) + + # Create Game, Set, Card + self.game = Game.objects.create(name='Dashboard Game', slug='dashboard-game') + self.set = Set.objects.create(game=self.game, name='Dashboard Set') + self.card = Card.objects.create(set=self.set, name='Dashboard Card') + + # Create Listing + self.listing = CardListing.objects.create( + card=self.card, + seller=self.seller, + price=10.00, + quantity=10, + condition='NM' + ) + + # Create Order & OrderItem + from .models import Order, OrderItem + self.order = Order.objects.create( + buyer=self.buyer, + status='paid', + total_price=20.00 + ) + OrderItem.objects.create( + order=self.order, + listing=self.listing, + price_at_purchase=10.00, + quantity=2 + ) + + self.client = Client() + self.client.force_login(self.user) + + def test_dashboard_context_data(self): + url = reverse('store:seller_dashboard') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # Check for new context keys + self.assertIn('condition_labels', response.context) + self.assertIn('condition_data', response.context) + self.assertIn('set_labels', response.context) + self.assertIn('set_data', response.context) + self.assertIn('game_labels', response.context) + self.assertIn('game_data', response.context) + self.assertIn('all_games', response.context) + + # Check data correctness (we sold 2 NM items) + import json + cond_data = json.loads(response.context['condition_data']) + cond_labels = json.loads(response.context['condition_labels']) + + self.assertIn('Near Mint', cond_labels) + idx = cond_labels.index('Near Mint') + self.assertEqual(cond_data[idx], 2) + + def test_dashboard_game_filter(self): + from .models import OrderItem + url = reverse('store:seller_dashboard') + + # Create another game/sale + game2 = Game.objects.create(name='Other Game', slug='other-game') + set2 = Set.objects.create(game=game2, name='Other Set') + card2 = Card.objects.create(set=set2, name='Other Card') + listing2 = CardListing.objects.create(card=card2, seller=self.seller, price=5, quantity=5, condition='LP') + + OrderItem.objects.create( + order=self.order, + listing=listing2, + price_at_purchase=5.00, + quantity=1 + ) + + # 1. No Filter - Should see both + response = self.client.get(url) + import json + game_labels = json.loads(response.context['game_labels']) + self.assertIn('Dashboard Game', game_labels) + self.assertIn('Other Game', game_labels) + + # 2. Filter by Dashboard Game + response = self.client.get(url, {'game': 'Dashboard Game'}) + game_labels = json.loads(response.context['game_labels']) + self.assertIn('Dashboard Game', game_labels) + self.assertNotIn('Other Game', game_labels) + + # Check condition data also filtered + cond_labels = json.loads(response.context['condition_labels']) + # Dashboard Game items were NM. Other Game items were LP. + self.assertIn('Near Mint', cond_labels) + self.assertNotIn('Lightly Played', cond_labels) + +class AdminRevenueTests(TestCase): + def setUp(self): + self.client = Client() + self.staff_user = User.objects.create_user(username='staff', password='password', is_staff=True) + self.seller_user = User.objects.create_user(username='seller2', password='password') + + self.seller = Seller.objects.create( + user=self.seller_user, + store_name='Revenue Store', + slug='revenue-store', + tax_id='123-45-6789', + payout_details='Bank Acct 123' + ) + + # Create Orders + from users.models import Buyer + buyer_user = User.objects.create_user(username='buyer2', password='password') + buyer = Buyer.objects.create(user=buyer_user) + + # Order 1: $100 -> Fee: 5 + 0.70 = 5.70 + order1 = Order.objects.create(buyer=buyer, status='paid', total_price=Decimal('100.00'), seller=self.seller) + + # Order 2: $1000 -> Fee: 50 + 0.70 = 50.70 -> Capped at 25.00 + order2 = Order.objects.create(buyer=buyer, status='shipped', total_price=Decimal('1000.00'), seller=self.seller) + + # Order 3: Pending (should be ignored) + order3 = Order.objects.create(buyer=buyer, status='pending', total_price=Decimal('50.00'), seller=self.seller) + + def test_encryption(self): + # Refresh from db + seller = Seller.objects.get(pk=self.seller.pk) + + # Verify decrypted values match + self.assertEqual(seller.tax_id, '123-45-6789') + self.assertEqual(seller.payout_details, 'Bank Acct 123') + + # Verify db values are encrypted (not plaintext and are bytes) + self.assertIsInstance(seller.tax_id_encrypted, bytes) + self.assertNotEqual(seller.tax_id_encrypted, b'123-45-6789') + # Ensure it's not just the string encoded + self.assertNotEqual(seller.tax_id_encrypted, '123-45-6789'.encode('utf-8')) + + def test_fee_calculation(self): + from .utils import calculate_platform_fee + # 5% + 0.70 + self.assertEqual(calculate_platform_fee(Decimal('10.00')), Decimal('1.20')) # 0.50 + 0.70 + self.assertEqual(calculate_platform_fee(Decimal('100.00')), Decimal('5.70')) # 5.00 + 0.70 + + # Cap at 25 + # Threshold: (25 - 0.70) / 0.05 = 486.00 + self.assertEqual(calculate_platform_fee(Decimal('486.00')), Decimal('25.00')) + self.assertEqual(calculate_platform_fee(Decimal('1000.00')), Decimal('25.00')) + + def test_dashboard_view(self): + self.client.force_login(self.staff_user) + response = self.client.get(reverse('store:admin_revenue_dashboard')) + self.assertEqual(response.status_code, 200) + + # Check context + total_rev = response.context['total_platform_revenue'] + # Order 1 (5.70) + Order 2 (25.00) = 30.70 + self.assertEqual(total_rev, Decimal('30.70')) + + seller_data = response.context['seller_data'] + self.assertEqual(len(seller_data), 1) + self.assertEqual(seller_data[0]['total_revenue'], Decimal('1100.00')) + self.assertEqual(seller_data[0]['platform_fees'], Decimal('30.70')) diff --git a/store/tests_bounty.py b/store/tests_bounty.py new file mode 100644 index 0000000..a7714c3 --- /dev/null +++ b/store/tests_bounty.py @@ -0,0 +1,87 @@ +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth import get_user_model +from .models import Seller, Game, Set, Card, Bounty +from django.conf import settings + +User = get_user_model() + +class BountyCreateTest(TestCase): + def setUp(self): + # Ensure feature is on (might need to mock settings if not settable directly, + # but usually tests run with default settings. We need ensuring it's True) + # Note: In some setups, modifying settings in test needs override_settings + + self.user = User.objects.create_user(username='bounty_seller', password='password') + self.seller = Seller.objects.create( + user=self.user, + store_name='Bounty Store', + slug='bounty-store' + ) + + self.game = Game.objects.create(name='Bounty Game', slug='bounty-game') + self.set = Set.objects.create(game=self.game, name='Bounty Set') + self.card = Card.objects.create(set=self.set, name='Bounty Card') + + self.client = Client() + self.client.force_login(self.user) + self.url = reverse('store:bounty_create') + + def test_create_bounty_with_card_id(self): + # Simulate selecting a card via autocomplete + data = { + 'card_name': 'Bounty Card', + 'card_id': self.card.id, + 'target_price': '100.00', + 'quantity_wanted': 1, + # Title is optional if card picked? Form logic says "if no card and no title raise error" + # Our view logic: if card_id -> get card -> set bounty.card. + # Form clean: if not card_id and not title and not card_name -> Error. + } + + with self.settings(FEATURE_BOUNTY_BOARD=True): + response = self.client.post(self.url, data) + + self.assertRedirects(response, reverse('store:bounty_list')) + + # Verify + bounty = Bounty.objects.first() + self.assertIsNotNone(bounty) + self.assertEqual(bounty.card, self.card) + self.assertEqual(bounty.seller, self.seller) + # Title should be auto-set in save() if empty + self.assertEqual(bounty.title, "Buying Bounty Card") + + def test_create_bounty_with_name_fallback(self): + # Simulate typing a name but not selecting a card (no ID) + data = { + 'card_name': 'Generic Card', + 'card_id': '', + 'target_price': '50.00', + 'quantity_wanted': 1 + } + + with self.settings(FEATURE_BOUNTY_BOARD=True): + response = self.client.post(self.url, data) + + self.assertRedirects(response, reverse('store:bounty_list')) + + bounty = Bounty.objects.last() + self.assertIsNone(bounty.card) + self.assertEqual(bounty.title, 'Generic Card') + + def test_create_bounty_validation_error(self): + # Empty everything + data = { + 'card_name': '', + 'card_id': '', + 'target_price': '50.00', + 'quantity_wanted': 1 + } + + with self.settings(FEATURE_BOUNTY_BOARD=True): + response = self.client.post(self.url, data) + + self.assertEqual(response.status_code, 200) # Form errors, no redirect + form = response.context['form'] + self.assertIn("You must either select a Card or provide a Title.", form.non_field_errors()) diff --git a/store/tests_smart_pricing.py b/store/tests_smart_pricing.py new file mode 100644 index 0000000..8c67148 --- /dev/null +++ b/store/tests_smart_pricing.py @@ -0,0 +1,95 @@ +from django.test import TestCase, Client +from django.contrib.auth import get_user_model +from .models import Seller, Game, Set, Card, CardListing, Cart, CartItem, Order + +User = get_user_model() + +class SmartPricingTests(TestCase): + def setUp(self): + # Create Seller User + self.seller_user = User.objects.create_user(username='seller', password='password') + self.seller = Seller.objects.create( + user=self.seller_user, + store_name='Smart Store', + slug='smart-store', + minimum_order_amount=10.00, + shipping_cost=2.50 + ) + + # Create Buyer User + self.buyer_user = User.objects.create_user(username='buyer', password='password') + from users.models import Buyer + self.buyer = Buyer.objects.create(user=self.buyer_user) + + # Create Products + self.game = Game.objects.create(name='Test Game', slug='test-game') + self.set = Set.objects.create(game=self.game, name='Test Set') + self.card = Card.objects.create(set=self.set, name='Test Card') + + self.listing_cheap = CardListing.objects.create( + card=self.card, seller=self.seller, price=5.00, quantity=10, condition='NM' + ) + self.listing_expensive = CardListing.objects.create( + card=self.card, seller=self.seller, price=15.00, quantity=10, condition='NM' + ) + + self.client = Client() + self.client.force_login(self.buyer_user) + + def test_cart_shipping_below_minimum(self): + from django.urls import reverse + # Add cheap item (5.00) < 10.00 minimum + url = reverse('store:add_to_cart', args=[self.listing_cheap.uuid]) + self.client.post(url) + + # Determine cart directly + cart, _ = Cart.objects.get_or_create(buyer=self.buyer) + + response = self.client.get(reverse('store:cart')) + self.assertEqual(response.status_code, 200) + + # Check context data + cart_data = response.context['cart_data'] + self.assertEqual(len(cart_data), 1) + data = cart_data[0] + + self.assertEqual(data['subtotal'], 5.00) + self.assertEqual(data['shipping_cost'], 2.50) + self.assertEqual(data['total'], 7.50) + self.assertEqual(data['free_shipping_needed'], 5.00) + + # Check Final Calculation + self.assertEqual(response.context['grand_total'], 7.50) + + def test_cart_shipping_above_minimum(self): + from django.urls import reverse + # Add expensive item (15.00) > 10.00 minimum + cart, _ = Cart.objects.get_or_create(buyer=self.buyer) + CartItem.objects.create(cart=cart, listing=self.listing_expensive, quantity=1) + + response = self.client.get(reverse('store:cart')) + + cart_data = response.context['cart_data'] + data = cart_data[0] + + self.assertEqual(data['subtotal'], 15.00) + self.assertEqual(data['shipping_cost'], 0) + self.assertEqual(data['total'], 15.00) + self.assertEqual(data['free_shipping_needed'], 0) + + def test_checkout_shipping_application(self): + from django.urls import reverse + # Setup cart below minimum + cart, _ = Cart.objects.get_or_create(buyer=self.buyer) + CartItem.objects.create(cart=cart, listing=self.listing_cheap, quantity=1) + + response = self.client.get(reverse('store:checkout')) + # Checkout redirects to my_packs (if virtual) or vault (users:vault) + # Since we have no virtual, it goes to vault. + # Check for 302 Found + self.assertEqual(response.status_code, 302) + + # Verify Order + order = Order.objects.filter(buyer=self.buyer).first() + self.assertIsNotNone(order) + self.assertEqual(order.total_price, 7.50) # 5.00 + 2.50 shipping diff --git a/store/urls.py b/store/urls.py index f292a06..ef79e7a 100644 --- a/store/urls.py +++ b/store/urls.py @@ -4,20 +4,44 @@ from . import views app_name = 'store' urlpatterns = [ - path('', views.card_list, name='card_list'), # Home page associated with 'card_list' view - path('home/', views.card_list, name='home'), # Explicit home alias for readability and templates using 'home' naming convention - path('card//', views.card_detail, name='card_detail'), + path('', views.index, name='index'), # Root redirect logic + path('browse/', views.card_list, name='card_list'), # Detailed browse page + path('home/', views.card_list, name='home'), # Explicit home alias for compatibility + path('card//', views.card_detail, name='card_detail'), path('cart/', views.cart_view, name='cart'), - path('cart/add//', views.add_to_cart, name='add_to_cart'), - path('cart/remove//', views.remove_from_cart, name='remove_from_cart'), - path('api/stock//', views.get_card_stock, name='get_card_stock'), + path('cart/add//', views.add_to_cart, name='add_to_cart'), + path('cart/remove//', views.remove_from_cart, name='remove_from_cart'), + path('api/stock//', views.get_card_stock, name='get_card_stock'), + path('api/card-autocomplete/', views.card_autocomplete, name='card_autocomplete'), + path('api/bounty-autocomplete/', views.bounty_autocomplete, name='bounty_autocomplete'), + path('api/card-variants/', views.card_variants, name='card_variants'), path('deck-buyer/', views.deck_buyer, name='deck_buyer'), path('cart/insurance/', views.toggle_insurance, name='toggle_insurance'), - path('bounty-board/', views.bounty_board, name='bounty_board'), + path('bounties/', views.bounty_list, name='bounty_list'), + path('bounties/create/', views.bounty_create, name='bounty_create'), + path('bounties//', views.bounty_detail, name='bounty_detail'), + path('bounties/offer///', views.bounty_process_offer, name='bounty_process_offer'), path('packs/', views.pack_list, name='pack_list'), - path('cart/add-pack//', views.add_pack_to_cart, name='add_pack_to_cart'), + path('cart/add-pack//', views.add_pack_to_cart, name='add_pack_to_cart'), path('checkout/', views.checkout, name='checkout'), path('my-packs/', views.my_packs, name='my_packs'), - path('packs/open//', views.open_pack, name='open_pack'), - path('order//', views.order_detail, name='order_detail'), + path('packs/open//', views.open_pack, name='open_pack'), + path('order//', views.order_detail, name='order_detail'), + path('sell/register/', views.seller_register, name='seller_register'), + path('sell/dashboard/', views.seller_dashboard, name='seller_dashboard'), + path('sell/profile/edit/', views.edit_seller_profile, name='edit_seller_profile'), + path('sell/listings/', views.manage_listings, name='manage_listings'), + path('sell/listings/card/add/', views.add_card_listing, name='add_card_listing'), + path('sell/listings/download-template//', views.download_listing_template, name='download_listing_template'), + path('sell/listings/card//edit/', views.edit_card_listing, name='edit_card_listing'), + path('sell/listings/card//delete/', views.delete_card_listing, name='delete_card_listing'), + path('sell/listings/pack/add/', views.add_pack_listing, name='add_pack_listing'), + path('sell/listings/pack//edit/', views.edit_pack_listing, name='edit_pack_listing'), + path('sell/listings/pack//delete/', views.delete_pack_listing, name='delete_pack_listing'), + path('sell/listings/pack//inventory/', views.manage_pack_inventory, name='manage_pack_inventory'), + path('sell/listings/pack//inventory/add/', views.add_virtual_pack_content, name='add_virtual_pack_content'), + path('store//', views.seller_profile, name='seller_profile'), + path('store//report/', views.report_seller, name='report_seller'), + path('platform-admin/revenue/', views.admin_revenue_dashboard, name='admin_revenue_dashboard'), + path('terms/', views.terms, name='terms'), ] diff --git a/store/utils.py b/store/utils.py index c514b79..416362f 100644 --- a/store/utils.py +++ b/store/utils.py @@ -1,12 +1,16 @@ import re from .models import Card, CardListing, Order, OrderItem, VaultItem from django.db.models import Min +import base64 +from cryptography.fernet import Fernet +from django.conf import settings +from decimal import Decimal -def add_to_vault(user, card, quantity=1): +def add_to_vault(buyer, card, quantity=1): """ - Adds a card to the user's vault. + Adds a card to the buyer's vault. """ - vault_item, created = VaultItem.objects.get_or_create(user=user, card=card) + vault_item, created = VaultItem.objects.get_or_create(buyer=buyer, card=card) if not created: vault_item.quantity += quantity else: @@ -96,10 +100,10 @@ def get_user_collection(user): Returns a dict {card_name: quantity} of cards in user's vault. """ owned = {} - if not user.is_authenticated: + if not user.is_authenticated or not hasattr(user, 'buyer_profile'): return owned - vault_items = VaultItem.objects.filter(user=user).select_related('card') + vault_items = VaultItem.objects.filter(buyer=user.buyer_profile).select_related('card') for item in vault_items: owned[item.card.name] = item.quantity @@ -130,4 +134,54 @@ def filter_deck_by_collection(parsed_cards, owned_cards): if remaining > 0: filtered.append({'name': name, 'quantity': remaining}) + return filtered + +class Encryptor: + """ + Utility for encrypting and decrypting sensitive data using Fernet. + Derives a key from settings.SECRET_KEY. + """ + _cipher = None + + @classmethod + def get_cipher(cls): + if cls._cipher is None: + # Derive a 32-byte key from SECRET_KEY + # Ensure key is url-safe base64-encoded 32-byte key + # We use hashlib to ensure we get a valid 32-byte key for Fernet, + # regardless of SECRET_KEY length. + import hashlib + key_hash = hashlib.sha256(settings.SECRET_KEY.encode('utf-8')).digest() + key_b64 = base64.urlsafe_b64encode(key_hash) + cls._cipher = Fernet(key_b64) + return cls._cipher + + @classmethod + def encrypt(cls, plaintext): + if not plaintext: + return None + if isinstance(plaintext, str): + plaintext = plaintext.encode('utf-8') + return cls.get_cipher().encrypt(plaintext) + + @classmethod + def decrypt(cls, ciphertext): + if not ciphertext: + return None + if isinstance(ciphertext, memoryview): + ciphertext = bytes(ciphertext) + try: + return cls.get_cipher().decrypt(ciphertext).decode('utf-8') + except Exception: + return None + +def calculate_platform_fee(total_amount): + """ + Calculates platform fee: 5% + $0.70, capped at $25. + """ + if not total_amount: + return Decimal('0.00') + + fee = (total_amount * Decimal('0.05')) + Decimal('0.70') + return min(fee, Decimal('25.00')) diff --git a/store/views.py b/store/views.py index c820848..b9627dc 100644 --- a/store/views.py +++ b/store/views.py @@ -1,9 +1,20 @@ 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 +from .models import Card, Game, Set, Cart, CartItem, CardListing, PackListing, VirtualPack, Order, OrderItem, Seller, Bounty, BountyOffer, SellerReport +from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm +from django.utils.text import slugify import random +import csv +from django.http import HttpResponse, JsonResponse + +def index(request): + if request.user.is_authenticated: + if (hasattr(request.user, 'profile') and request.user.profile.is_seller) or hasattr(request.user, 'seller_profile'): + return redirect('store:seller_dashboard') + return redirect('store:card_list') def card_list(request): cards = Card.objects.all().select_related('set', 'set__game').prefetch_related('listings') @@ -21,6 +32,19 @@ def card_list(request): if set_id: cards = cards.filter(set__id=set_id) + # hide_out_of_stock logic: + # Default to 'on' if no GET parameters (first visit), or if explicitly set to 'on'. + # If GET parameters exist but 'hide_out_of_stock' is missing, it means user unchecked it. + + hide_oos = 'off' + if not request.GET: + hide_oos = 'on' + elif request.GET.get('hide_out_of_stock') == 'on': + hide_oos = 'on' + + if hide_oos == 'on': + cards = cards.filter(listings__quantity__gt=0, listings__status='listed').distinct() + # Simple logic: only show cards that have listings or show all? # Let's show all for browsing, but indicate stock. @@ -38,17 +62,23 @@ def card_list(request): '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, id=card_id) - listings = card.listings.filter(quantity__gt=0).order_by('price') + 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, id=listing_id) - cart, _ = Cart.objects.get_or_create(user=request.user) + 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: @@ -60,30 +90,147 @@ def add_to_cart(request, listing_id): @login_required def cart_view(request): try: - cart = request.user.cart - except Cart.DoesNotExist: + cart = request.user.buyer_profile.cart + except (Cart.DoesNotExist, AttributeError): cart = None - return render(request, 'store/cart.html', {'cart': cart}) + + 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, 'cart'): - item = get_object_or_404(CartItem, id=item_id, cart=request.user.cart) + 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, 'cart'): - cart = request.user.cart + 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, id=card_id) + card = get_object_or_404(Card, uuid=card_id) listings = card.listings.all() stock_breakdown = {} total_stock = 0 @@ -97,7 +244,9 @@ def get_card_stock(request, card_id): 'breakdown': stock_breakdown }) -from .utils import parse_deck_list, find_best_listings_for_deck, get_user_collection, filter_deck_by_collection, add_to_vault +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): @@ -134,7 +283,10 @@ def deck_buyer(request): parsed = parse_deck_list(deck_text) found, _ = find_best_listings_for_deck(parsed) - cart, _ = Cart.objects.get_or_create(user=request.user) + 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: @@ -154,21 +306,180 @@ def deck_buyer(request): return render(request, 'store/deck_buyer.html') -from .models import Bounty +def bounty_list(request): + if not settings.FEATURE_BOUNTY_BOARD: + if not settings.DEBUG: + return redirect('store:index') -def bounty_board(request): - bounties = Bounty.objects.filter(is_active=True).select_related('card', 'card__set').order_by('-created_at') - return render(request, 'store/bounty_board.html', {'bounties': bounties}) + 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): - listing = get_object_or_404(PackListing, id=pack_listing_id) - cart, _ = Cart.objects.get_or_create(user=request.user) + 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 @@ -179,75 +490,133 @@ def add_pack_to_cart(request, pack_listing_id): @login_required def checkout(request): try: - cart = request.user.cart - except Cart.DoesNotExist: + cart = request.user.buyer_profile.cart + except (Cart.DoesNotExist, AttributeError): return redirect('store:cart') if not cart.items.exists(): return redirect('store:cart') - # Create Order - order = Order.objects.create( - user=request.user, - status='paid', - total_price=cart.total_price - ) - - # Move items + # Group items by seller and check for virtual packs + items_by_seller = {} + has_virtual_packs = False for item in cart.items.all(): - 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 + 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 ) - # Add single cards to vault - if item.listing: - add_to_vault(request.user, item.listing.card, item.quantity) - - # If it's a pack, assign VirtualPacks to user - if item.pack_listing: - # Find available sealed packs - available_packs = list(VirtualPack.objects.filter( - listing=item.pack_listing, - owner__isnull=True, - status='sealed' - )[:item.quantity]) + 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 + ) - # If not enough, create more - if len(available_packs) < item.quantity: - needed = item.quantity - len(available_packs) - game = item.pack_listing.game - all_game_cards = list(Card.objects.filter(set__game=game)) - if not all_game_cards: - # Fallback if no cards? Should not happen due to management command or basic setup - pass - - for _ in range(needed): - pack = VirtualPack.objects.create(listing=item.pack_listing) - if all_game_cards: - pack.cards.set(random.sample(all_game_cards, min(len(all_game_cards), 5))) - available_packs.append(pack) + # 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() - for pack in available_packs: - pack.owner = request.user - pack.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() - return redirect('store:my_packs') + if has_virtual_packs: + return redirect('store:my_packs') + return redirect('users:vault') @login_required def my_packs(request): - packs = VirtualPack.objects.filter(owner=request.user, status='sealed').select_related('listing') + 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): - pack = get_object_or_404(VirtualPack, id=pack_id, owner=request.user) + 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': @@ -256,7 +625,7 @@ def open_pack(request, pack_id): # Add cards to vault for card in pack.cards.all(): - add_to_vault(request.user, card) + add_to_vault(request.user.buyer_profile, card) data = { 'cards': [{ @@ -273,10 +642,883 @@ def open_pack(request, pack_id): @login_required def order_detail(request, order_id): - order = get_object_or_404(Order, id=order_id) + order = get_object_or_404(Order, uuid=order_id) # Security check: only allow viewing own orders (unless superuser) - if order.user != request.user and not request.user.is_superuser: + # Check if order belongs to current buyer + is_owner = False + if hasattr(request.user, 'buyer_profile') and order.buyer == request.user.buyer_profile: + is_owner = True + + if not is_owner and not request.user.is_superuser: return redirect('users:profile') + + # Handle rating submission + if request.method == 'POST' and is_owner: + rating_value = request.POST.get('rating') + if rating_value: + try: + rating_int = int(rating_value) + if 1 <= rating_int <= 5: + order.rating = rating_int + order.save() + return redirect('store:order_detail', order_id=order_id) + except (ValueError, TypeError): + pass return render(request, 'store/order_detail.html', {'order': order}) +from users.forms import CustomUserCreationForm +from django.contrib.auth import login + +def seller_register(request): + if request.user.is_authenticated: + if hasattr(request.user, 'seller_profile'): + return redirect('store:seller_dashboard') + + if request.method == 'POST': + if request.user.is_authenticated: + user_form = None + seller_form = SellerRegistrationForm(request.POST) + if seller_form.is_valid(): + seller = seller_form.save(commit=False) + seller.user = request.user + seller.slug = slugify(seller.store_name) + seller.save() + return redirect('store:seller_dashboard') + else: + user_form = CustomUserCreationForm(request.POST) + seller_form = SellerRegistrationForm(request.POST) + + if user_form.is_valid() and seller_form.is_valid(): + user = user_form.save() + login(request, user) + + seller = seller_form.save(commit=False) + seller.user = user + seller.slug = slugify(seller.store_name) + seller.save() + return redirect('store:seller_dashboard') + else: + if request.user.is_authenticated: + user_form = None + else: + user_form = CustomUserCreationForm() + seller_form = SellerRegistrationForm() + + return render(request, 'store/seller_register.html', { + 'user_form': user_form, + 'form': seller_form + }) + +@login_required +def edit_seller_profile(request): + try: + seller = request.user.seller_profile + except Seller.DoesNotExist: + return redirect('store:seller_register') + + if request.method == 'POST': + form = SellerEditForm(request.POST, request.FILES, instance=seller) + if form.is_valid(): + seller = form.save(commit=False) + if 'store_name' in form.changed_data: + seller.slug = slugify(seller.store_name) + seller.save() + return redirect('store:seller_profile', slug=seller.slug) + else: + form = SellerEditForm(instance=seller) + + return render(request, 'store/seller/edit_profile.html', {'form': form, 'title': 'Edit Store Profile'}) + +@login_required +def seller_dashboard(request): + if not hasattr(request.user, 'seller_profile'): + return redirect('store:seller_register') + + seller = request.user.seller_profile + + # Calculate stats + card_items = OrderItem.objects.filter(listing__seller=seller, order__status__in=['paid', 'shipped']) + pack_items = OrderItem.objects.filter(pack_listing__seller=seller, order__status__in=['paid', 'shipped']) + + total_revenue = 0 + items_sold = 0 + + for item in card_items: + total_revenue += item.price_at_purchase * item.quantity + items_sold += item.quantity + + for item in pack_items: + total_revenue += item.price_at_purchase * item.quantity + items_sold += item.quantity + + active_listings_count = CardListing.objects.filter(seller=seller).count() + PackListing.objects.filter(seller=seller).count() + + # Theme Form Handling + from .forms import SellerThemeForm + if request.method == 'POST' and 'theme_preference' in request.POST: + theme_form = SellerThemeForm(request.POST, instance=request.user.profile) + if theme_form.is_valid(): + theme_form.save() + return redirect('store:seller_dashboard') + else: + theme_form = SellerThemeForm(instance=request.user.profile) + + # QR Code Generation + # Construct the absolute URL for the store + store_path = redirect('store:seller_profile', slug=seller.slug).url + store_full_url = request.build_absolute_uri(store_path) + + # Using public API for QR Code + qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=150x150&data={store_full_url}" + + # Calculate Average Rating + from django.db.models import Avg + avg_rating = Order.objects.filter(seller=seller, rating__isnull=False).aggregate(Avg('rating'))['rating__avg'] + if avg_rating: + avg_rating = round(avg_rating, 1) + + context = { + 'seller': seller, + 'total_revenue': total_revenue, + 'items_sold': items_sold, + 'active_listings_count': active_listings_count, + 'theme_form': theme_form, + 'store_views': seller.store_views, + 'listing_clicks': seller.listing_clicks, + 'store_full_url': store_full_url, + 'qr_code_url': qr_code_url, + 'avg_rating': avg_rating, + } + + # Chart Data Preparation + from django.db.models import Sum, F, Count + from django.db.models.functions import TruncDay, TruncWeek, TruncMonth + from django.utils import timezone + from datetime import timedelta + import json + + +def terms(request): + return render(request, 'legal/terms.html') + + # Filter Handling + game_filter = request.GET.get('game') + + all_games = Game.objects.all().order_by('name') + + # Helper function to get chart data + def get_chart_data(period_type, days_back, trunc_func, periods_count): + start_date = now - timedelta(days=days_back) + + items = OrderItem.objects.filter( + order__created_at__gte=start_date, + order__status__in=['paid', 'shipped'] + ).filter( + Q(listing__seller=seller) | Q(pack_listing__seller=seller) + ).annotate( + period=trunc_func('order__created_at') + ).values('period').annotate( + revenue=Sum(F('price_at_purchase') * F('quantity')), + count=Sum('quantity') + ).order_by('period') + + dates_list = [] + rev_list = [] + count_list = [] + + data_map = {item['period'].strftime('%Y-%m-%d'): item for item in items} + + # Determine step size roughly (simplification for generating zero-filled lists) + # For strict correctness we'd iterate by day/week/month properly. + # Simplification: Just iterate by days and check trunc matches? + # Better: Iterate by the period type. + + current = start_date + seen_periods = set() + + # Loop to generate x-axis labels. + # Note: logic varies slightly by period, doing a simple loop for now. + + # 1. Day Iteration (Last 30 Days) + if period_type == 'day': + for i in range(periods_count): + d = (start_date + timedelta(days=i)).date() + label = d.strftime('%Y-%m-%d') + dates_list.append(label) + item = data_map.get(label) + rev_list.append(str(item['revenue']) if item else 0) + count_list.append(item['count'] if item else 0) + + # 2. Week Iteration (Last 12 Weeks) + elif period_type == 'week': + # Align start date to Monday? + # Or just jump 7 days from start_date + for i in range(periods_count): + d = (start_date + timedelta(weeks=i)).date() + # We need to match how TruncWeek formats? typically Monday + # Let's trust the data_map keys (which come from TruncWeek) match roughly + # But TruncWeek might not align exactly with start_date + N weeks if start_date isn't Monday. + # Hack: Just use the items returned? No, we need zero-filling. + # Let's iterate found items + missing? + # Simpler: Just rely on sorted items for now? No, graph looks bad without gaps. + + # Robust approach: Generate week starts + label = d.strftime('%Y-%m-%d') # TruncWeek returns date + # We will check approximate match or exact match if Trunc works well + # Actually, let's keep it simple: + # Just loop days, if new week (Monday), add point. + pass + + # Re-implementation for Week/Month using simpler list logic since strict calendar math in python is verbose + # We will just dump the sparse data for Week/Month if density is low, or try to fill. + # Let's just pass the sparse data for Week/Month for MVP to avoid complexity. + + dates_list = [item['period'].strftime('%Y-%m-%d') for item in items] + rev_list = [str(item['revenue']) for item in items] + count_list = [item['count'] for item in items] + + # 3. Month Iteration (Last 12 Months) + elif period_type == 'month': + dates_list = [item['period'].strftime('%Y-%m') for item in items] + rev_list = [str(item['revenue']) for item in items] + count_list = [item['count'] for item in items] + + return dates_list, rev_list, count_list + + # 1. Day Queries (Last 30 Days) -> Zero filled + # Re-using strict loop from before for Days as it looks best + day_items = OrderItem.objects.filter( + order__created_at__gte=now - timedelta(days=30), + order__status__in=['paid', 'shipped'] + ).filter( + Q(listing__seller=seller) | Q(pack_listing__seller=seller) + ).annotate( + day=TruncDay('order__created_at') + ).values('day').annotate( + daily_revenue=Sum(F('price_at_purchase') * F('quantity')), + daily_count=Sum('quantity') + ).order_by('day') + + day_map = {item['day'].strftime('%Y-%m-%d'): item for item in day_items} + dates_day, rev_day, count_day = [], [], [] + for i in range(30): + d = (now - timedelta(days=30) + timedelta(days=i)).date() + date_str = d.strftime('%Y-%m-%d') + dates_day.append(date_str) + if date_str in day_map: + rev_day.append(str(day_map[date_str]['daily_revenue'])) + count_day.append(day_map[date_str]['daily_count']) + else: + rev_day.append(0) + count_day.append(0) + + # 2. Week Queries (Last 12 Weeks) + week_items = OrderItem.objects.filter( + order__created_at__gte=now - timedelta(weeks=12), + order__status__in=['paid', 'shipped'] + ).filter( + Q(listing__seller=seller) | Q(pack_listing__seller=seller) + ).annotate( + week=TruncWeek('order__created_at') + ).values('week').annotate( + revenue=Sum(F('price_at_purchase') * F('quantity')), + count=Sum('quantity') + ).order_by('week') + + dates_week = [item['week'].strftime('%Y-%m-%d') for item in week_items] + rev_week = [str(item['revenue']) for item in week_items] + count_week = [item['count'] for item in week_items] + + # 3. Month Queries (Last 12 Months) + month_items = OrderItem.objects.filter( + order__created_at__gte=now - timedelta(days=365), + order__status__in=['paid', 'shipped'] + ).filter( + Q(listing__seller=seller) | Q(pack_listing__seller=seller) + ).annotate( + month=TruncMonth('order__created_at') + ).values('month').annotate( + revenue=Sum(F('price_at_purchase') * F('quantity')), + count=Sum('quantity') + ).order_by('month') + + dates_month = [item['month'].strftime('%Y-%m') for item in month_items] + rev_month = [str(item['revenue']) for item in month_items] + count_month = [item['count'] for item in month_items] + + context['chart_data_day'] = json.dumps({'labels': dates_day, 'revenue': rev_day, 'sales': count_day}) + context['chart_data_week'] = json.dumps({'labels': dates_week, 'revenue': rev_week, 'sales': count_week}) + context['chart_data_month'] = json.dumps({'labels': dates_month, 'revenue': rev_month, 'sales': count_month}) + + # 2. Sales by Game (Pie Chart) & Game Filtering application + # We apply filtering for the breakdown charts below, but maybe not the main timeline? + # User request: "you can filter down that games if you want as well" + # Typically filters apply to all valid charts. + # Let's apply game_filter to the breakdown queries below. + + # Base Queryset for Breakdowns (Card Items) + seller_card_items = OrderItem.objects.filter( + listing__seller=seller, + order__status__in=['paid', 'shipped'] + ).select_related('listing__card__set__game') + + # Base Queryset for Breakdowns (Pack Items) + seller_pack_items = OrderItem.objects.filter( + pack_listing__seller=seller, + order__status__in=['paid', 'shipped'] + ).select_related('pack_listing__game') + + if game_filter: + seller_card_items = seller_card_items.filter(listing__card__set__game__name=game_filter) + seller_pack_items = seller_pack_items.filter(pack_listing__game__name=game_filter) + + # A. Sales by Game + # Note: If filtered by game, this will show 100% for that game, which is correct behavior. + game_sales = {} + + for item in seller_card_items: + if item.listing: + game_name = item.listing.card.set.game.name + game_sales[game_name] = game_sales.get(game_name, 0) + item.quantity + + for item in seller_pack_items: + if item.pack_listing: + game_name = item.pack_listing.game.name + game_sales[game_name] = game_sales.get(game_name, 0) + item.quantity + + context['game_labels'] = json.dumps(list(game_sales.keys())) + context['game_data'] = json.dumps(list(game_sales.values())) + + # B. Sales by Condition (Cards Only) + condition_sales = {} + # We can iterate or aggregate. Iteration is fine given we have the QS. + for item in seller_card_items: + if item.listing: + cond = item.listing.get_condition_display() # Use display name e.g. "Near Mint" + condition_sales[cond] = condition_sales.get(cond, 0) + item.quantity + + context['condition_labels'] = json.dumps(list(condition_sales.keys())) + context['condition_data'] = json.dumps(list(condition_sales.values())) + + # C. Sales by Set (Cards Only) - Top 10 Sets + set_sales = {} + for item in seller_card_items: + if item.listing: + set_name = item.listing.card.set.name + set_sales[set_name] = set_sales.get(set_name, 0) + item.quantity + + # Sort and take top 10 + sorted_sets = sorted(set_sales.items(), key=lambda x: x[1], reverse=True)[:10] + context['set_labels'] = json.dumps([x[0] for x in sorted_sets]) + context['set_data'] = json.dumps([x[1] for x in sorted_sets]) + + context['all_games'] = all_games + context['selected_game'] = game_filter + + return render(request, 'store/seller/dashboard.html', context) + +@login_required +def manage_listings(request): + if not hasattr(request.user, 'seller_profile'): + return redirect('store:seller_register') + seller = request.user.seller_profile + + card_listings = CardListing.objects.filter(seller=seller).select_related('card', 'card__set') + pack_listings = PackListing.objects.filter(seller=seller).select_related('game') + + return render(request, 'store/seller/manage_listings.html', { + 'card_listings': card_listings, + 'pack_listings': pack_listings + }) + +from django.http import HttpResponse # Added import +import csv # Added import +import io # Added import + +# ... existing imports ... +from .forms import SellerRegistrationForm, CardListingForm, PackListingForm, AddCardListingForm, SellerEditForm, BountyForm, BountyOfferForm, BulkListingForm + +# ... [Keep existing code until add_card_listing] ... + +@login_required +def download_listing_template(request, type): + # Ensure legitimate type + if type not in ['card', 'pack']: + return redirect('store:manage_listings') + + # Define file path + # We can either serve a static file or generate it on the fly. + # Generating on the fly is robust. + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="{type}_listing_template.csv"' + + writer = csv.writer(response) + + if type == 'card': + writer.writerow(['Game', 'Set', 'Card Name', 'Collector Number', 'Condition', 'Price', 'Quantity', 'Image Filename']) + # Add sample row + writer.writerow(['Magic: The Gathering', 'Alpha', 'Black Lotus', '', 'NM', '10000.00', '1', 'black_lotus.jpg']) + elif type == 'pack': + writer.writerow(['Game', 'Name', 'Listing Type', 'Price', 'Quantity', 'Image Filename']) + writer.writerow(['Magic: The Gathering', 'Alpha Booster', 'physical', '1000.00', '1', 'alpha_booster.jpg']) + + return response + +@login_required +def add_card_listing(request): + try: + seller = request.user.seller_profile + except Seller.DoesNotExist: + return redirect('store:seller_register') + + bulk_form = BulkListingForm() # Initialize bulk form + + if request.method == 'POST': + if 'bulk_upload' in request.POST: + # Handle Bulk Upload + bulk_form = BulkListingForm(request.POST, request.FILES) + if bulk_form.is_valid(): + csv_file = request.FILES['csv_file'] + images = request.FILES.getlist('images') + + # Create a map of filename -> image file + image_map = {img.name: img for img in images} + + decoded_file = csv_file.read().decode('utf-8').splitlines() + reader = csv.DictReader(decoded_file) + + for row in reader: + # Basic validation/cleaning + try: + game_name = row.get('Game', '').strip() + set_name = row.get('Set', '').strip() + card_name = row.get('Card Name', '').strip() + collector_number = row.get('Collector Number', '').strip() + condition = row.get('Condition', 'NM').strip() + price = row.get('Price', '0').strip() + quantity = row.get('Quantity', '1').strip() + image_filename = row.get('Image Filename', '').strip() + + if not game_name or not card_name: + continue # Skip invalid rows + + # Get Game + game_obj, _ = Game.objects.get_or_create(name=game_name, defaults={'slug': slugify(game_name)}) + + # Get Set + set_obj, _ = Set.objects.get_or_create( + game=game_obj, + name=set_name, + defaults={'code': '', 'release_date': None} + ) + + # Get Card + card_obj, _ = Card.objects.get_or_create( + set=set_obj, + name=card_name, + collector_number=collector_number, + defaults={ + 'rarity': 'Unknown', + } + ) + + # Image handling + image_file = image_map.get(image_filename) + + # Create Listing + CardListing.objects.create( + card=card_obj, + seller=seller, + condition=condition if condition in dict(CardListing.CONDITION_CHOICES) else 'NM', # Basic fallback + price=price, + quantity=quantity, + image=image_file, + status='listed' + ) + + except Exception as e: + # Log error or skip + print(f"Error processing row: {e}") + continue + + return redirect('store:manage_listings') + + else: + # Handle Single Upload + form = AddCardListingForm(request.POST, request.FILES) + if form.is_valid(): + game = form.cleaned_data['game'] + set_name = form.cleaned_data['set_name'] + card_name = form.cleaned_data['card_name'] + condition = form.cleaned_data['condition'] + price = form.cleaned_data['price'] + quantity = form.cleaned_data['quantity'] + image = form.cleaned_data['image'] + + collector_number = form.cleaned_data.get('collector_number', '').strip() + + # Get or Create Set + # Note: Set code and release_date are left blank for now as we don't have them + set_obj, _ = Set.objects.get_or_create( + game=game, + name=set_name, + defaults={'code': '', 'release_date': None} + ) + + # Get or Create Card + # Providing defaults for fields we don't have + card_obj, _ = Card.objects.get_or_create( + set=set_obj, + name=card_name, + collector_number=collector_number, + defaults={ + 'rarity': 'Unknown', + 'scryfall_id': None, + 'tcgplayer_id': None + } + ) + + # Create Listing + CardListing.objects.create( + card=card_obj, + seller=seller, + condition=condition, + price=price, + quantity=quantity, + image=image, # Save the image + status='listed' + ) + + return redirect('store:manage_listings') + else: + form = AddCardListingForm() + + return render(request, 'store/add_card_listing.html', {'form': form, 'bulk_form': bulk_form}) + +@login_required +def edit_card_listing(request, listing_id): + seller = request.user.seller_profile + listing = get_object_or_404(CardListing, uuid=listing_id, seller=seller) + if request.method == 'POST': + form = CardListingForm(request.POST, request.FILES, instance=listing) + if form.is_valid(): + form.save() + return redirect('store:manage_listings') + else: + form = CardListingForm(instance=listing) + return render(request, 'store/seller/edit_listing.html', {'form': form, 'title': 'Edit Card Listing'}) + +@login_required +def delete_card_listing(request, listing_id): + seller = request.user.seller_profile + listing = get_object_or_404(CardListing, uuid=listing_id, seller=seller) + if request.method == 'POST': + listing.delete() + return redirect('store:manage_listings') + return render(request, 'store/seller/confirm_delete.html', {'item': listing}) + +@login_required +def add_pack_listing(request): + if not settings.FEATURE_VIRTUAL_PACKS: + return redirect('store:manage_listings') + seller = request.user.seller_profile + + bulk_form = BulkListingForm() + + if request.method == 'POST': + if 'bulk_upload' in request.POST: + bulk_form = BulkListingForm(request.POST, request.FILES) + if bulk_form.is_valid(): + csv_file = request.FILES['csv_file'] + images = request.FILES.getlist('images') + + image_map = {img.name: img for img in images} + + decoded_file = csv_file.read().decode('utf-8').splitlines() + reader = csv.DictReader(decoded_file) + + from django.core.files.storage import default_storage + from django.core.files.base import ContentFile + import os + + for row in reader: + try: + game_name = row.get('Game', '').strip() + name = row.get('Name', '').strip() + listing_type = row.get('Listing Type', 'physical').strip() + price = row.get('Price', '0').strip() + quantity = row.get('Quantity', '1').strip() + image_filename = row.get('Image Filename', '').strip() + + if not game_name or not name: + continue + + # Get Game + game_obj, _ = Game.objects.get_or_create(name=game_name, defaults={'slug': slugify(game_name)}) + + image_url = '' + if image_filename in image_map: + # Save image to storage and get URL + # We need to manually save since the model has URLField + img_file = image_map[image_filename] + path = default_storage.save(f'pack_images/{img_file.name}', ContentFile(img_file.read())) + image_url = default_storage.url(path) + + PackListing.objects.create( + game=game_obj, + seller=seller, + name=name, + listing_type=listing_type if listing_type in ['physical', 'virtual'] else 'physical', + price=price, + quantity=quantity, + image_url=image_url + ) + except Exception as e: + print(f"Error processing pack row: {e}") + continue + + return redirect('store:manage_listings') + else: + form = PackListingForm(request.POST) + if form.is_valid(): + listing = form.save(commit=False) + listing.seller = seller + listing.save() + return redirect('store:manage_listings') + else: + form = PackListingForm() + return render(request, 'store/add_pack_listing.html', {'form': form, 'bulk_form': bulk_form, 'title': 'Add Pack Listing'}) + +@login_required +def edit_pack_listing(request, listing_id): + if not settings.FEATURE_VIRTUAL_PACKS: + return redirect('store:manage_listings') + seller = request.user.seller_profile + listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller) + if request.method == 'POST': + form = PackListingForm(request.POST, instance=listing) + if form.is_valid(): + form.save() + return redirect('store:manage_listings') + else: + form = PackListingForm(instance=listing) + return render(request, 'store/seller/edit_listing.html', {'form': form, 'title': 'Edit Pack Listing'}) + +@login_required +def delete_pack_listing(request, listing_id): + if not settings.FEATURE_VIRTUAL_PACKS: + return redirect('store:manage_listings') + seller = request.user.seller_profile + listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller) + if request.method == 'POST': + listing.delete() + return redirect('store:manage_listings') + return render(request, 'store/seller/confirm_delete.html', {'item': listing}) + +def seller_profile(request, slug): + seller = get_object_or_404(Seller, slug=slug) + + # Increment views + # Use F expression to avoid race conditions + from django.db.models import F + Seller.objects.filter(slug=slug).update(store_views=F('store_views') + 1) + + # Refresh to show correct count if needed (though not displayed on profile usually) + seller.refresh_from_db() + + # Filter Logic + from django.db.models import Q + + # Get initial queryset + card_listings = CardListing.objects.filter(seller=seller, quantity__gt=0).select_related('card', 'card__set', 'card__set__game') + + # Get filter parameters + query = request.GET.get('q') + game_slug = request.GET.get('game') + set_code = request.GET.get('set') + condition = request.GET.get('condition') + min_price = request.GET.get('min_price') + max_price = request.GET.get('max_price') + min_qty = request.GET.get('min_qty') + + # Apply filters + if query: + card_listings = card_listings.filter(card__name__icontains=query) + + if game_slug: + card_listings = card_listings.filter(card__set__game__slug=game_slug) + + if set_code: + card_listings = card_listings.filter(card__set__code=set_code) + + if condition: + card_listings = card_listings.filter(condition=condition) + + if min_price: + try: + card_listings = card_listings.filter(price__gte=float(min_price)) + except (ValueError, TypeError): + pass + + if max_price: + try: + card_listings = card_listings.filter(price__lte=float(max_price)) + except (ValueError, TypeError): + pass + + if min_qty: + try: + card_listings = card_listings.filter(quantity__gte=int(min_qty)) + except (ValueError, TypeError): + pass + + # Get available options for filters (scoped to what the seller actually has) + seller_games = Game.objects.filter(sets__cards__listings__seller=seller).distinct() + seller_sets = Set.objects.filter(cards__listings__seller=seller).select_related('game').distinct() + + pack_listings = PackListing.objects.filter(seller=seller).select_related('game') + + # Calculate Average Rating + from django.db.models import Avg + avg_rating = Order.objects.filter(seller=seller, rating__isnull=False).aggregate(Avg('rating'))['rating__avg'] + if avg_rating: + avg_rating = round(avg_rating, 1) + + context = { + 'seller': seller, + 'card_listings': card_listings, + 'pack_listings': pack_listings, + 'games': seller_games, + 'sets': seller_sets, + 'conditions': CardListing.CONDITION_CHOICES, + 'avg_rating': avg_rating, + 'filters': { + 'q': query, + 'game': game_slug, + 'set': set_code, + 'condition': condition, + 'min_price': min_price, + 'max_price': max_price, + 'min_qty': min_qty, + } + } + + return render(request, 'store/seller/profile.html', context) + +@login_required +def manage_pack_inventory(request, listing_id): + if not settings.FEATURE_VIRTUAL_PACKS: + return redirect('store:manage_listings') + seller = request.user.seller_profile + listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller) + + # Only for virtual packs + if listing.listing_type != 'virtual': + return redirect('store:manage_listings') + + # Get all sealed packs for this listing that haven't been bought yet (owner=None) + # AND packs that have been bought but not opened? No, seller only manages unsold inventory usually. + # Actually, once bought, it belongs to buyer. + packs = VirtualPack.objects.filter(listing=listing, owner__isnull=True).prefetch_related('cards') + + return render(request, 'store/seller/manage_pack_inventory.html', { + 'listing': listing, + 'packs': packs + }) + +@login_required +def add_virtual_pack_content(request, listing_id): + if not settings.FEATURE_VIRTUAL_PACKS: + return redirect('store:manage_listings') + seller = request.user.seller_profile + listing = get_object_or_404(PackListing, uuid=listing_id, seller=seller) + + if listing.listing_type != 'virtual': + return redirect('store:manage_listings') + + if request.method == 'POST': + # Expect specific card IDs to be added + card_ids = request.POST.getlist('cards') + if card_ids: + pack = VirtualPack.objects.create(listing=listing, status='sealed') + cards = Card.objects.filter(id__in=card_ids) + pack.cards.set(cards) + + # Update listing quantity + # We count total sealed available packs + count = VirtualPack.objects.filter(listing=listing, owner__isnull=True, status='sealed').count() + listing.quantity = count + listing.save() + + listing.save() + + return redirect('store:manage_pack_inventory', listing_id=listing.uuid) + + # Search functionality for finding cards to add + query = request.GET.get('q') + cards = [] + if query: + cards = Card.objects.filter(name__icontains=query).select_related('set', 'set__game')[:50] + + return render(request, 'store/seller/add_virtual_pack_content.html', { + 'listing': listing, + 'cards': cards, + 'query': query + }) + +@login_required +def report_seller(request, slug): + """Handle AJAX POST to report a seller.""" + if request.method != 'POST': + return JsonResponse({'status': 'error', 'message': 'POST required'}, status=400) + + seller = get_object_or_404(Seller, slug=slug) + reason = request.POST.get('reason') + + valid_reasons = [choice[0] for choice in SellerReport.REASON_CHOICES] + if reason not in valid_reasons: + return JsonResponse({'status': 'error', 'message': 'Invalid reason'}, status=400) + + details = request.POST.get('details', '') + + SellerReport.objects.create( + reporter=request.user, + seller=seller, + reason=reason, + details=details, + ) + return JsonResponse({'status': 'success'}) + +@staff_member_required +def admin_revenue_dashboard(request): + sellers = Seller.objects.all() + seller_data = [] + total_platform_revenue = Decimal('0.00') + + for seller in sellers: + # Get paid/shipped orders for this seller + orders = seller.orders.filter(status__in=['paid', 'shipped']) + + seller_revenue = Decimal('0.00') + seller_fees = Decimal('0.00') + + for order in orders: + seller_revenue += order.total_price + fee = calculate_platform_fee(order.total_price) + seller_fees += fee + + total_platform_revenue += seller_fees + + seller_data.append({ + 'seller': seller, + 'total_revenue': seller_revenue, + 'platform_fees': seller_fees + }) + + return render(request, 'store/admin_revenue_dashboard.html', { + 'seller_data': seller_data, + 'total_platform_revenue': total_platform_revenue + }) + diff --git a/templates/base/layout.html b/templates/base/layout.html index d267f31..405f2ec 100644 --- a/templates/base/layout.html +++ b/templates/base/layout.html @@ -4,246 +4,98 @@ - {% block title %}Phantom Card Fam - Premium TCG Store{% endblock %} + {% block title %}TCGKof - Premium TCG Store{% endblock %} + + + + + + + + + + + + + + + + + + {% if not debug %} {% endif %} - + {% if FEATURE_DEMO_SITE %}
DEMO SITE: This is an example application. No real products, payments, or purchases are processed.
+ {% endif %} @@ -278,9 +133,27 @@ {% endblock %} -