From 80a5f5dadf96cda600047f76601e6a06afcb2247 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 6 Sep 2026 06:29:50 -0500 Subject: [PATCH] Add shop, POS sync, event ticketing, and shipping catalog apps. Clients can run in-house retail without Shopify while keeping the same Docker/Django/Postgres stack and data-ownership promise. Closes #5. --- .env.example | 19 ++ .env.prod.example | 19 ++ README.md | 4 + docker-compose.yml | 4 + scripts/validate-env.sh | 35 ++ site/client_site/settings/base.py | 34 ++ site/client_site/urls.py | 14 + site/core/registry.py | 1 + site/core/tests_features.py | 37 ++- site/dashboard/templates/dashboard/home.html | 22 ++ site/events/__init__.py | 0 site/events/admin.py | 24 ++ site/events/apps.py | 12 + site/events/hooks.py | 33 ++ site/events/migrations/0001_initial.py | 74 +++++ site/events/migrations/__init__.py | 0 site/events/models.py | 111 +++++++ site/events/portal_urls.py | 13 + site/events/public_urls.py | 13 + site/events/services.py | 188 +++++++++++ site/events/templates/events/buy.html | 17 + site/events/templates/events/cancel.html | 8 + site/events/templates/events/detail.html | 18 ++ site/events/templates/events/list.html | 18 ++ .../templates/events/portal/detail.html | 23 ++ site/events/templates/events/portal/edit.html | 17 + site/events/templates/events/portal/list.html | 21 ++ site/events/templates/events/success.html | 9 + site/events/tests.py | 95 ++++++ site/events/views.py | 194 +++++++++++ site/pos_sync/__init__.py | 0 site/pos_sync/admin.py | 16 + site/pos_sync/apps.py | 12 + site/pos_sync/hooks.py | 35 ++ site/pos_sync/migrations/0001_initial.py | 52 +++ site/pos_sync/migrations/__init__.py | 0 site/pos_sync/models.py | 61 ++++ site/pos_sync/services.py | 131 ++++++++ site/pos_sync/templates/pos_sync/list.html | 22 ++ site/pos_sync/tests.py | 115 +++++++ site/pos_sync/urls.py | 10 + site/pos_sync/views.py | 70 ++++ site/public/views.py | 4 + site/shipping/__init__.py | 0 site/shipping/admin.py | 10 + site/shipping/apps.py | 12 + site/shipping/hooks.py | 31 ++ site/shipping/migrations/0001_initial.py | 40 +++ site/shipping/migrations/__init__.py | 0 site/shipping/models.py | 33 ++ site/shipping/services.py | 184 +++++++++++ site/shipping/templates/shipping/detail.html | 25 ++ site/shipping/templates/shipping/list.html | 43 +++ site/shipping/tests.py | 84 +++++ site/shipping/urls.py | 13 + site/shipping/views.py | 86 +++++ site/shop/__init__.py | 0 site/shop/admin.py | 24 ++ site/shop/apps.py | 12 + site/shop/hooks.py | 42 +++ site/shop/migrations/0001_initial.py | 80 +++++ site/shop/migrations/__init__.py | 0 site/shop/models.py | 118 +++++++ site/shop/portal_urls.py | 19 ++ site/shop/public_urls.py | 16 + site/shop/services.py | 305 ++++++++++++++++++ site/shop/templates/shop/cancel.html | 8 + site/shop/templates/shop/cart.html | 26 ++ site/shop/templates/shop/checkout.html | 21 ++ site/shop/templates/shop/detail.html | 20 ++ site/shop/templates/shop/list.html | 19 ++ .../templates/shop/portal/order_detail.html | 21 ++ site/shop/templates/shop/portal/orders.html | 20 ++ .../templates/shop/portal/product_edit.html | 33 ++ site/shop/templates/shop/portal/products.html | 22 ++ site/shop/templates/shop/success.html | 9 + site/shop/tests.py | 147 +++++++++ site/shop/views.py | 268 +++++++++++++++ 78 files changed, 3395 insertions(+), 1 deletion(-) create mode 100644 site/events/__init__.py create mode 100644 site/events/admin.py create mode 100644 site/events/apps.py create mode 100644 site/events/hooks.py create mode 100644 site/events/migrations/0001_initial.py create mode 100644 site/events/migrations/__init__.py create mode 100644 site/events/models.py create mode 100644 site/events/portal_urls.py create mode 100644 site/events/public_urls.py create mode 100644 site/events/services.py create mode 100644 site/events/templates/events/buy.html create mode 100644 site/events/templates/events/cancel.html create mode 100644 site/events/templates/events/detail.html create mode 100644 site/events/templates/events/list.html create mode 100644 site/events/templates/events/portal/detail.html create mode 100644 site/events/templates/events/portal/edit.html create mode 100644 site/events/templates/events/portal/list.html create mode 100644 site/events/templates/events/success.html create mode 100644 site/events/tests.py create mode 100644 site/events/views.py create mode 100644 site/pos_sync/__init__.py create mode 100644 site/pos_sync/admin.py create mode 100644 site/pos_sync/apps.py create mode 100644 site/pos_sync/hooks.py create mode 100644 site/pos_sync/migrations/0001_initial.py create mode 100644 site/pos_sync/migrations/__init__.py create mode 100644 site/pos_sync/models.py create mode 100644 site/pos_sync/services.py create mode 100644 site/pos_sync/templates/pos_sync/list.html create mode 100644 site/pos_sync/tests.py create mode 100644 site/pos_sync/urls.py create mode 100644 site/pos_sync/views.py create mode 100644 site/shipping/__init__.py create mode 100644 site/shipping/admin.py create mode 100644 site/shipping/apps.py create mode 100644 site/shipping/hooks.py create mode 100644 site/shipping/migrations/0001_initial.py create mode 100644 site/shipping/migrations/__init__.py create mode 100644 site/shipping/models.py create mode 100644 site/shipping/services.py create mode 100644 site/shipping/templates/shipping/detail.html create mode 100644 site/shipping/templates/shipping/list.html create mode 100644 site/shipping/tests.py create mode 100644 site/shipping/urls.py create mode 100644 site/shipping/views.py create mode 100644 site/shop/__init__.py create mode 100644 site/shop/admin.py create mode 100644 site/shop/apps.py create mode 100644 site/shop/hooks.py create mode 100644 site/shop/migrations/0001_initial.py create mode 100644 site/shop/migrations/__init__.py create mode 100644 site/shop/models.py create mode 100644 site/shop/portal_urls.py create mode 100644 site/shop/public_urls.py create mode 100644 site/shop/services.py create mode 100644 site/shop/templates/shop/cancel.html create mode 100644 site/shop/templates/shop/cart.html create mode 100644 site/shop/templates/shop/checkout.html create mode 100644 site/shop/templates/shop/detail.html create mode 100644 site/shop/templates/shop/list.html create mode 100644 site/shop/templates/shop/portal/order_detail.html create mode 100644 site/shop/templates/shop/portal/orders.html create mode 100644 site/shop/templates/shop/portal/product_edit.html create mode 100644 site/shop/templates/shop/portal/products.html create mode 100644 site/shop/templates/shop/success.html create mode 100644 site/shop/tests.py create mode 100644 site/shop/views.py diff --git a/.env.example b/.env.example index ea8f6c6..d889881 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,10 @@ FEATURE_BLOG=true FEATURE_PAYMENTS=true FEATURE_SOCIAL=true FEATURE_SOCIAL_AI=true +FEATURE_SHOP=true +FEATURE_POS_SYNC=true +FEATURE_EVENTS=true +FEATURE_SHIPPING=true # reCAPTCHA (optional locally — form skips captcha when empty) RECAPTCHA_PUBLIC_KEY= @@ -74,6 +78,21 @@ STRIPE_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_CURRENCY=usd +# Shop — FEATURE_SHOP (requires email/SMS + payments) +# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0 + +# POS sync — FEATURE_POS_SYNC (requires shop). Required in beta/prod. +POS_WEBHOOK_SECRET= +POS_API_BASE_URL= +POS_API_TOKEN= + +# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works. +EASYPOST_API_KEY= +SHIP_FROM_LINE1= +SHIP_FROM_CITY= +SHIP_FROM_STATE= +SHIP_FROM_ZIP= + # Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true # LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env). META_APP_ID= diff --git a/.env.prod.example b/.env.prod.example index ec97a1f..5e51741 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -32,6 +32,10 @@ FEATURE_BLOG=false FEATURE_PAYMENTS=false FEATURE_SOCIAL=false FEATURE_SOCIAL_AI=false +FEATURE_SHOP=false +FEATURE_POS_SYNC=false +FEATURE_EVENTS=false +FEATURE_SHIPPING=false # Shared external Postgres (10.0.0.230) DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site @@ -63,6 +67,21 @@ STRIPE_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_CURRENCY=usd +# Required when FEATURE_SHOP=true (also requires FEATURE_EMAIL_SMS + FEATURE_PAYMENTS) +# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0 + +# Required when FEATURE_POS_SYNC=true in beta/prod (also requires FEATURE_SHOP) +POS_WEBHOOK_SECRET= +POS_API_BASE_URL= +POS_API_TOKEN= + +# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP) +EASYPOST_API_KEY= +SHIP_FROM_LINE1= +SHIP_FROM_CITY= +SHIP_FROM_STATE= +SHIP_FROM_ZIP= + # Required when FEATURE_SOCIAL=true META_APP_ID= META_APP_SECRET= diff --git a/README.md b/README.md index 321c45e..33d18a6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ separate Django apps. A flag that is off keeps the app out of | Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` (requires email/SMS) | | Social consolidation | `FEATURE_SOCIAL` | `social` | | AI social generator | `FEATURE_SOCIAL_AI` | `social_ai` (requires social) | +| E-commerce storefront | `FEATURE_SHOP` | `shop` (requires email/SMS + payments) | +| POS API sync | `FEATURE_POS_SYNC` | `pos_sync` (requires shop) | +| Event ticketing | `FEATURE_EVENTS` | `events` (requires email/SMS + payments) | +| Shipping automation | `FEATURE_SHIPPING` | `shipping` (requires shop) | ## New client diff --git a/docker-compose.yml b/docker-compose.yml index c8cca2c..8e29046 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,10 @@ services: FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true} FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true} FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true} + FEATURE_SHOP: ${FEATURE_SHOP:-true} + FEATURE_POS_SYNC: ${FEATURE_POS_SYNC:-true} + FEATURE_EVENTS: ${FEATURE_EVENTS:-true} + FEATURE_SHIPPING: ${FEATURE_SHIPPING:-true} PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000} EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com} EMAIL_HOST_USER: ${EMAIL_HOST_USER:-} diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh index c8d7cc1..d24e86a 100755 --- a/scripts/validate-env.sh +++ b/scripts/validate-env.sh @@ -82,6 +82,41 @@ if _true "${FEATURE_SOCIAL_AI:-}"; then exit 1 fi fi +if _true "${FEATURE_SHOP:-}"; then + if ! _true "${FEATURE_EMAIL_SMS:-}"; then + echo "FEATURE_SHOP requires FEATURE_EMAIL_SMS" >&2 + exit 1 + fi + if ! _true "${FEATURE_PAYMENTS:-}"; then + echo "FEATURE_SHOP requires FEATURE_PAYMENTS" >&2 + exit 1 + fi +fi +if _true "${FEATURE_POS_SYNC:-}"; then + if ! _true "${FEATURE_SHOP:-}"; then + echo "FEATURE_POS_SYNC requires FEATURE_SHOP" >&2 + exit 1 + fi + if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then + required_vars+=(POS_WEBHOOK_SECRET) + fi +fi +if _true "${FEATURE_EVENTS:-}"; then + if ! _true "${FEATURE_EMAIL_SMS:-}"; then + echo "FEATURE_EVENTS requires FEATURE_EMAIL_SMS" >&2 + exit 1 + fi + if ! _true "${FEATURE_PAYMENTS:-}"; then + echo "FEATURE_EVENTS requires FEATURE_PAYMENTS" >&2 + exit 1 + fi +fi +if _true "${FEATURE_SHIPPING:-}"; then + if ! _true "${FEATURE_SHOP:-}"; then + echo "FEATURE_SHIPPING requires FEATURE_SHOP" >&2 + exit 1 + fi +fi missing=() for var in "${required_vars[@]}"; do diff --git a/site/client_site/settings/base.py b/site/client_site/settings/base.py index 5633140..ca089e7 100644 --- a/site/client_site/settings/base.py +++ b/site/client_site/settings/base.py @@ -115,11 +115,27 @@ FEATURE_BLOG = env_bool("FEATURE_BLOG", _feature_default) FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default) FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default) FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default) +FEATURE_SHOP = env_bool("FEATURE_SHOP", _feature_default) +FEATURE_POS_SYNC = env_bool("FEATURE_POS_SYNC", _feature_default) +FEATURE_EVENTS = env_bool("FEATURE_EVENTS", _feature_default) +FEATURE_SHIPPING = env_bool("FEATURE_SHIPPING", _feature_default) if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS: raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS") if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL: raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL") +if FEATURE_SHOP and not FEATURE_EMAIL_SMS: + raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_EMAIL_SMS") +if FEATURE_SHOP and not FEATURE_PAYMENTS: + raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_PAYMENTS") +if FEATURE_POS_SYNC and not FEATURE_SHOP: + raise ImproperlyConfigured("FEATURE_POS_SYNC requires FEATURE_SHOP") +if FEATURE_EVENTS and not FEATURE_EMAIL_SMS: + raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_EMAIL_SMS") +if FEATURE_EVENTS and not FEATURE_PAYMENTS: + raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_PAYMENTS") +if FEATURE_SHIPPING and not FEATURE_SHOP: + raise ImproperlyConfigured("FEATURE_SHIPPING requires FEATURE_SHOP") CORE_APPS = [ "core.apps.CoreConfig", @@ -137,6 +153,10 @@ OPTIONAL_APPS = [ (FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"), (FEATURE_SOCIAL, "social.apps.SocialConfig"), (FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"), + (FEATURE_SHOP, "shop.apps.ShopConfig"), + (FEATURE_POS_SYNC, "pos_sync.apps.PosSyncConfig"), + (FEATURE_EVENTS, "events.apps.EventsConfig"), + (FEATURE_SHIPPING, "shipping.apps.ShippingConfig"), ] DJANGO_APPS = [ "django.contrib.admin", @@ -352,6 +372,20 @@ STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "") STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "") STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd") +# --- Shop / POS / shipping (FEATURE_SHOP and add-ons) --- +# 0 = unlimited made-to-order print queue. +SHOP_PRINT_QUEUE_LIMIT_MINUTES = int( + env("SHOP_PRINT_QUEUE_LIMIT_MINUTES", "0") or "0" +) +POS_WEBHOOK_SECRET = env("POS_WEBHOOK_SECRET", "") +POS_API_BASE_URL = env("POS_API_BASE_URL", "") +POS_API_TOKEN = env("POS_API_TOKEN", "") +EASYPOST_API_KEY = env("EASYPOST_API_KEY", "") +SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "") +SHIP_FROM_CITY = env("SHIP_FROM_CITY", "") +SHIP_FROM_STATE = env("SHIP_FROM_STATE", "") +SHIP_FROM_ZIP = env("SHIP_FROM_ZIP", "") + # --- Nominatim (address suggest; server-side proxy only) --- # Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with # LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as diff --git a/site/client_site/urls.py b/site/client_site/urls.py index 8bdd7fb..eaaf271 100644 --- a/site/client_site/urls.py +++ b/site/client_site/urls.py @@ -48,5 +48,19 @@ if apps.is_installed("social_ai"): urlpatterns += [ path("portal/social/api/generate/", include("social_ai.urls")), ] +if apps.is_installed("shop"): + urlpatterns += [ + path("shop/", include("shop.public_urls")), + path("portal/shop/", include("shop.portal_urls")), + ] +if apps.is_installed("pos_sync"): + urlpatterns += [path("portal/pos/", include("pos_sync.urls"))] +if apps.is_installed("events"): + urlpatterns += [ + path("events/", include("events.public_urls")), + path("portal/events/", include("events.portal_urls")), + ] +if apps.is_installed("shipping"): + urlpatterns += [path("portal/shipping/", include("shipping.urls"))] handler404 = "public.views.page_not_found" diff --git a/site/core/registry.py b/site/core/registry.py index fc57481..bcec2be 100644 --- a/site/core/registry.py +++ b/site/core/registry.py @@ -22,6 +22,7 @@ GROUP_ORDER = { "Outreach": 20, "Content": 30, "Social": 40, + "Retail": 45, "Billing": 50, } diff --git a/site/core/tests_features.py b/site/core/tests_features.py index 41594de..0313841 100644 --- a/site/core/tests_features.py +++ b/site/core/tests_features.py @@ -22,16 +22,30 @@ class FeatureFlagSettingsTests(SimpleTestCase): self.assertIn("FEATURE_PAYMENTS", src) self.assertIn("FEATURE_SOCIAL", src) self.assertIn("FEATURE_SOCIAL_AI", src) + self.assertIn("FEATURE_SHOP", src) + self.assertIn("FEATURE_POS_SYNC", src) + self.assertIn("FEATURE_EVENTS", src) + self.assertIn("FEATURE_SHIPPING", src) self.assertIn("email_sms.apps.EmailSmsConfig", src) self.assertIn("directmail.apps.DirectmailConfig", src) self.assertIn("blog.apps.BlogConfig", src) self.assertIn("payments.apps.PaymentsConfig", src) self.assertIn("social_ai.apps.SocialAiConfig", src) + self.assertIn("shop.apps.ShopConfig", src) + self.assertIn("pos_sync.apps.PosSyncConfig", src) + self.assertIn("events.apps.EventsConfig", src) + self.assertIn("shipping.apps.ShippingConfig", src) def test_payments_requires_email_sms_in_source(self): src = _read_settings_source() self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src) self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", src) + self.assertIn("FEATURE_SHOP requires FEATURE_EMAIL_SMS", src) + self.assertIn("FEATURE_SHOP requires FEATURE_PAYMENTS", src) + self.assertIn("FEATURE_POS_SYNC requires FEATURE_SHOP", src) + self.assertIn("FEATURE_EVENTS requires FEATURE_EMAIL_SMS", src) + self.assertIn("FEATURE_EVENTS requires FEATURE_PAYMENTS", src) + self.assertIn("FEATURE_SHIPPING requires FEATURE_SHOP", src) class AlwaysOnImportIsolationTests(SimpleTestCase): @@ -44,6 +58,10 @@ class AlwaysOnImportIsolationTests(SimpleTestCase): "payments", "social", "social_ai", + "shop", + "pos_sync", + "events", + "shipping", } ALWAYS_ON = [ @@ -82,7 +100,18 @@ class InstalledOptionalAppsTests(TestCase): def test_dev_installs_catalog_apps(self): labels = {c.label for c in apps.get_app_configs()} - for label in ("email_sms", "directmail", "blog", "payments", "social", "social_ai"): + for label in ( + "email_sms", + "directmail", + "blog", + "payments", + "social", + "social_ai", + "shop", + "pos_sync", + "events", + "shipping", + ): self.assertIn(label, labels) def test_optional_urls_resolve_when_installed(self): @@ -92,3 +121,9 @@ class InstalledOptionalAppsTests(TestCase): self.assertTrue(reverse("payments:invoice_list").startswith("/portal/payments/")) self.assertTrue(reverse("social:composer").startswith("/portal/social/")) self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/")) + self.assertTrue(reverse("shop:list").startswith("/shop/")) + self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/")) + self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/")) + self.assertTrue(reverse("events:list").startswith("/events/")) + self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/")) + self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/")) diff --git a/site/dashboard/templates/dashboard/home.html b/site/dashboard/templates/dashboard/home.html index 1fd1366..d968119 100644 --- a/site/dashboard/templates/dashboard/home.html +++ b/site/dashboard/templates/dashboard/home.html @@ -27,6 +27,28 @@
{{ open_invoices|default:0 }}
{% endif %} + {% if 'shop' in enabled_features %} +
+
Open shop orders
+
{{ open_shop_orders|default:0 }}
+
+
+
Low stock
+
{{ low_stock_products|default:0 }}
+
+ {% endif %} + {% if 'events' in enabled_features %} +
+
Upcoming events
+
{{ upcoming_events|default:0 }}
+
+ {% endif %} + {% if 'shipping' in enabled_features %} +
+
Unshipped orders
+
{{ unshipped_orders|default:0 }}
+
+ {% endif %}
diff --git a/site/events/__init__.py b/site/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/events/admin.py b/site/events/admin.py new file mode 100644 index 0000000..990f224 --- /dev/null +++ b/site/events/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from events.models import Event, Ticket, TicketOrder + + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ("title", "starts_at", "capacity", "price", "is_published") + list_filter = ("is_published",) + search_fields = ("title", "venue") + prepopulated_fields = {"slug": ("title",)} + + +@admin.register(TicketOrder) +class TicketOrderAdmin(admin.ModelAdmin): + list_display = ("number", "event", "email", "quantity", "status") + list_filter = ("status",) + search_fields = ("number", "email") + + +@admin.register(Ticket) +class TicketAdmin(admin.ModelAdmin): + list_display = ("code", "event", "order") + search_fields = ("code",) diff --git a/site/events/apps.py b/site/events/apps.py new file mode 100644 index 0000000..0c54693 --- /dev/null +++ b/site/events/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "events" + verbose_name = "Event ticketing" + + def ready(self): + from events import hooks + + hooks.register() diff --git a/site/events/hooks.py b/site/events/hooks.py new file mode 100644 index 0000000..10a8dd7 --- /dev/null +++ b/site/events/hooks.py @@ -0,0 +1,33 @@ +from core.registry import ( + register_dashboard_collector, + register_feature, + register_portal_nav, + register_public_nav, +) + + +def register() -> None: + register_feature("events") + register_public_nav( + section="events", label="Events", url_name="events:list", order=35 + ) + register_portal_nav( + section="events", + label="Events", + url_name="events_portal:event_list", + group="Retail", + order=30, + ) + register_dashboard_collector(_dashboard) + + +def _dashboard(request) -> dict: + from django.utils import timezone + + from events.models import Event + + return { + "upcoming_events": Event.objects.filter( + is_published=True, starts_at__gte=timezone.now() + ).count() + } diff --git a/site/events/migrations/0001_initial.py b/site/events/migrations/0001_initial.py new file mode 100644 index 0000000..9d22256 --- /dev/null +++ b/site/events/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 6.1 on 2026-09-06 11:17 + +import django.db.models.deletion +import uuid +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=200)), + ('slug', models.SlugField(max_length=220, unique=True)), + ('description', models.TextField(blank=True)), + ('starts_at', models.DateTimeField()), + ('venue', models.CharField(blank=True, max_length=200)), + ('capacity', models.PositiveIntegerField(default=0)), + ('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)), + ('currency', models.CharField(default='usd', max_length=8)), + ('is_published', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['starts_at'], + }, + ), + migrations.CreateModel( + name='TicketOrder', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('number', models.CharField(max_length=32, unique=True)), + ('email', models.EmailField(max_length=254)), + ('customer_name', models.CharField(blank=True, max_length=200)), + ('quantity', models.PositiveIntegerField(default=1)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('currency', models.CharField(default='usd', max_length=8)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('cancelled', 'Cancelled')], default='draft', max_length=16)), + ('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)), + ('hosted_checkout_url', models.URLField(blank=True)), + ('paid_at', models.DateTimeField(blank=True, null=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_orders', to='events.event')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Ticket', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(max_length=16, unique=True)), + ('attendee_name', models.CharField(blank=True, max_length=200)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='events.event')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to='events.ticketorder')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + ] diff --git a/site/events/migrations/__init__.py b/site/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/events/models.py b/site/events/models.py new file mode 100644 index 0000000..35ea5f7 --- /dev/null +++ b/site/events/models.py @@ -0,0 +1,111 @@ +from decimal import Decimal + +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.text import slugify + +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class Event(UUIDPrimaryKeyModel, TimeStampedModel): + title = models.CharField(max_length=200) + slug = models.SlugField(max_length=220, unique=True) + description = models.TextField(blank=True) + starts_at = models.DateTimeField() + venue = models.CharField(max_length=200, blank=True) + capacity = models.PositiveIntegerField(default=0) + price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0")) + currency = models.CharField(max_length=8, default="usd") + is_published = models.BooleanField(default=False) + + class Meta: + ordering = ["starts_at"] + + def __str__(self) -> str: + return self.title + + def get_absolute_url(self) -> str: + return reverse("events:detail", kwargs={"slug": self.slug}) + + def save(self, *args, **kwargs): + if not self.slug: + base = slugify(self.title)[:200] or "event" + slug = base + n = 2 + while Event.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base}-{n}" + n += 1 + self.slug = slug + super().save(*args, **kwargs) + + def tickets_held(self, *, exclude_order_id=None) -> int: + qs = self.ticket_orders.filter( + status__in=[ + TicketOrder.Status.DRAFT, + TicketOrder.Status.OPEN, + TicketOrder.Status.PAID, + ] + ) + if exclude_order_id: + qs = qs.exclude(pk=exclude_order_id) + return qs.aggregate(total=models.Sum("quantity"))["total"] or 0 + + @property + def tickets_sold(self) -> int: + return self.tickets_held() + + @property + def seats_remaining(self) -> int | None: + if not self.capacity: + return None + return max(self.capacity - self.tickets_sold, 0) + + @property + def is_upcoming(self) -> bool: + return self.starts_at >= timezone.now() + + +class TicketOrder(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + OPEN = "open", "Open" + PAID = "paid", "Paid" + CANCELLED = "cancelled", "Cancelled" + + number = models.CharField(max_length=32, unique=True) + event = models.ForeignKey( + Event, on_delete=models.PROTECT, related_name="ticket_orders" + ) + email = models.EmailField() + customer_name = models.CharField(max_length=200, blank=True) + quantity = models.PositiveIntegerField(default=1) + amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=8, default="usd") + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + stripe_checkout_session_id = models.CharField(max_length=255, blank=True) + hosted_checkout_url = models.URLField(blank=True) + paid_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.number} · {self.event}" + + +class Ticket(UUIDPrimaryKeyModel, TimeStampedModel): + order = models.ForeignKey( + TicketOrder, on_delete=models.CASCADE, related_name="tickets" + ) + event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="tickets") + code = models.CharField(max_length=16, unique=True) + attendee_name = models.CharField(max_length=200, blank=True) + + class Meta: + ordering = ["created_at"] + + def __str__(self) -> str: + return self.code diff --git a/site/events/portal_urls.py b/site/events/portal_urls.py new file mode 100644 index 0000000..e9c20e0 --- /dev/null +++ b/site/events/portal_urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from events import views + +app_name = "events_portal" + +urlpatterns = [ + path("", views.portal_event_list, name="event_list"), + path("new/", views.portal_event_edit, name="event_new"), + path("/", views.portal_event_detail, name="event_detail"), + path("/edit/", views.portal_event_edit, name="event_edit"), + path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"), +] diff --git a/site/events/public_urls.py b/site/events/public_urls.py new file mode 100644 index 0000000..04dcbcb --- /dev/null +++ b/site/events/public_urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from events import views + +app_name = "events" + +urlpatterns = [ + path("", views.event_list, name="list"), + path("order//success/", views.checkout_success, name="checkout_success"), + path("order//cancel/", views.checkout_cancel, name="checkout_cancel"), + path("/", views.event_detail, name="detail"), + path("/buy/", views.buy_tickets, name="buy"), +] diff --git a/site/events/services.py b/site/events/services.py new file mode 100644 index 0000000..7ce393b --- /dev/null +++ b/site/events/services.py @@ -0,0 +1,188 @@ +"""Event capacity, Stripe ticket checkout, and confirmation email.""" + +from __future__ import annotations + +import logging +import secrets +from datetime import date +from decimal import Decimal + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from events.models import Event, Ticket, TicketOrder + +logger = logging.getLogger(__name__) + + +class EventsError(RuntimeError): + pass + + +def _stripe(): + secret = (settings.STRIPE_SECRET_KEY or "").strip() + if not secret: + raise EventsError("STRIPE_SECRET_KEY is not configured") + try: + import stripe + except ImportError as exc: + raise EventsError("stripe package is not installed") from exc + stripe.api_key = secret + return stripe + + +def next_ticket_order_number() -> str: + today = date.today().strftime("%Y%m%d") + prefix = f"TIX-{today}-" + existing = TicketOrder.objects.filter(number__startswith=prefix).count() + return f"{prefix}{existing + 1:03d}" + + +def new_ticket_code() -> str: + while True: + code = secrets.token_hex(4).upper() + if not Ticket.objects.filter(code=code).exists(): + return code + + +def assert_capacity( + event: Event, quantity: int, *, exclude_order_id=None +) -> None: + if quantity < 1: + raise EventsError("Quantity must be at least 1.") + if not event.capacity: + return + held = event.tickets_held(exclude_order_id=exclude_order_id) + remaining = max(event.capacity - held, 0) + if quantity > remaining: + raise EventsError(f"Only {remaining} seats left for {event.title}.") + + +def create_ticket_order( + event: Event, *, email: str, customer_name: str = "", quantity: int = 1 +) -> TicketOrder: + email = (email or "").strip() + if not email: + raise EventsError("Email is required.") + if not event.is_published: + raise EventsError("This event is not on sale.") + assert_capacity(event, quantity) + amount = event.price * quantity + return TicketOrder.objects.create( + number=next_ticket_order_number(), + event=event, + email=email, + customer_name=(customer_name or "").strip(), + quantity=quantity, + amount=amount, + currency=(event.currency or settings.STRIPE_CURRENCY or "usd").lower(), + status=TicketOrder.Status.DRAFT, + ) + + +def create_checkout_session( + order: TicketOrder, *, success_url: str, cancel_url: str +) -> str: + stripe = _stripe() + session = stripe.checkout.Session.create( + mode="payment", + customer_email=order.email or None, + line_items=[ + { + "quantity": order.quantity, + "price_data": { + "currency": (order.currency or "usd").lower(), + "unit_amount": int( + (order.event.price * Decimal("100")).quantize(Decimal("1")) + ), + "product_data": {"name": order.event.title}, + }, + } + ], + metadata={ + "ticket_order_id": str(order.pk), + "ticket_order_number": order.number, + }, + success_url=success_url, + cancel_url=cancel_url, + ) + order.stripe_checkout_session_id = session.id + order.hosted_checkout_url = session.url or "" + order.status = TicketOrder.Status.OPEN + order.save( + update_fields=[ + "stripe_checkout_session_id", + "hosted_checkout_url", + "status", + "updated_at", + ] + ) + return session.url or "" + + +def _issue_tickets(order: TicketOrder) -> list[Ticket]: + created = [] + for _ in range(order.quantity): + created.append( + Ticket.objects.create( + order=order, + event=order.event, + code=new_ticket_code(), + attendee_name=order.customer_name, + ) + ) + return created + + +def mark_paid(order: TicketOrder, *, stripe_id: str = "") -> None: + if order.status == TicketOrder.Status.PAID: + return + with transaction.atomic(): + locked = TicketOrder.objects.select_for_update().get(pk=order.pk) + if locked.status == TicketOrder.Status.PAID: + return + assert_capacity( + locked.event, locked.quantity, exclude_order_id=locked.pk + ) + _issue_tickets(locked) + locked.status = TicketOrder.Status.PAID + locked.paid_at = timezone.now() + locked.save(update_fields=["status", "paid_at", "updated_at"]) + order.refresh_from_db() + try: + send_ticket_email(order) + except Exception: + logger.exception("ticket email failed for %s", order.number) + + +def send_ticket_email(order: TicketOrder) -> bool: + to_email = (order.email or "").strip() + if not to_email: + raise EventsError("Order has no email address") + from django.core.mail import EmailMultiAlternatives + + codes = ", ".join(t.code for t in order.tickets.all()) + name = order.customer_name or "there" + when = timezone.localtime(order.event.starts_at).strftime("%b %d, %Y %-I:%M %p") + subject = f"Tickets for {order.event.title} — {settings.SITE_NAME}" + text = ( + f"Hi {name},\n\n" + f"Your tickets for {order.event.title} on {when}:\n" + f"{codes}\n\nOrder {order.number}\n" + ) + html = ( + f"

Hi {name},

" + f"

Tickets for {order.event.title} on {when}:

" + f"

{codes}

" + f"

Order {order.number}

" + ) + mail = EmailMultiAlternatives( + subject=subject, + body=text, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + ) + mail.attach_alternative(html, "text/html") + mail.send(fail_silently=False) + return True diff --git a/site/events/templates/events/buy.html b/site/events/templates/events/buy.html new file mode 100644 index 0000000..aa0ce9f --- /dev/null +++ b/site/events/templates/events/buy.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Tickets · {{ event.title }}{% endblock %} +{% block content %} +
+
+

Tickets · {{ event.title }}

+

{{ event.price }} {{ event.currency|upper }} each{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} left{% endif %}

+
+ {% csrf_token %} +

+

+

+ +
+
+
+{% endblock %} diff --git a/site/events/templates/events/cancel.html b/site/events/templates/events/cancel.html new file mode 100644 index 0000000..63e095a --- /dev/null +++ b/site/events/templates/events/cancel.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Checkout cancelled{% endblock %} +{% block content %} +
+

Checkout cancelled

+

No charge for {{ order.event.title }}. Try again.

+
+{% endblock %} diff --git a/site/events/templates/events/detail.html b/site/events/templates/events/detail.html new file mode 100644 index 0000000..4452dd8 --- /dev/null +++ b/site/events/templates/events/detail.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}{{ event.title }} · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

← Events

+

{{ event.title }}

+

{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}

+
{{ event.description|linebreaks }}
+

{{ event.price }} {{ event.currency|upper }}

+ {% if event.seats_remaining == 0 %} +

Sold out.

+ {% else %} +

Buy tickets

+ {% endif %} +
+
+{% endblock %} diff --git a/site/events/templates/events/list.html b/site/events/templates/events/list.html new file mode 100644 index 0000000..ac1b149 --- /dev/null +++ b/site/events/templates/events/list.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Events · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

Events

+ {% for event in events %} +
+

{{ event.title }}

+

{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}

+

{{ event.price }} {{ event.currency|upper }}{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} seats left{% endif %}

+
+ {% empty %} +

No upcoming events.

+ {% endfor %} +
+
+{% endblock %} diff --git a/site/events/templates/events/portal/detail.html b/site/events/templates/events/portal/detail.html new file mode 100644 index 0000000..c37b566 --- /dev/null +++ b/site/events/templates/events/portal/detail.html @@ -0,0 +1,23 @@ +{% extends "portal_base.html" %} +{% block title %}{{ event.title }} · Portal{% endblock %} +{% block topbar_title %}{{ event.title }}{% endblock %} +{% block portal_content %} +

Edit

+

{{ event.starts_at }} · {{ event.tickets_sold }} / {{ event.capacity|default:"∞" }} seats · {{ event.price }} {{ event.currency|upper }}

+ + + + {% for order in orders %} + + + + + + + + {% empty %} + + {% endfor %} + +
OrderEmailQtyStatusCodes
{{ order.number }}{{ order.email }}{{ order.quantity }}{{ order.get_status_display }}{% for ticket in order.tickets.all %}{{ ticket.code }}{% if not forloop.last %}, {% endif %}{% endfor %}
No ticket orders yet.
+{% endblock %} diff --git a/site/events/templates/events/portal/edit.html b/site/events/templates/events/portal/edit.html new file mode 100644 index 0000000..79003ba --- /dev/null +++ b/site/events/templates/events/portal/edit.html @@ -0,0 +1,17 @@ +{% extends "portal_base.html" %} +{% block title %}{% if event %}Edit{% else %}New{% endif %} event · Portal{% endblock %} +{% block topbar_title %}{% if event %}Edit event{% else %}New event{% endif %}{% endblock %} +{% block portal_content %} +
+ {% csrf_token %} +
+
+
+
+
+
+
+ + +
+{% endblock %} diff --git a/site/events/templates/events/portal/list.html b/site/events/templates/events/portal/list.html new file mode 100644 index 0000000..efd9b68 --- /dev/null +++ b/site/events/templates/events/portal/list.html @@ -0,0 +1,21 @@ +{% extends "portal_base.html" %} +{% block title %}Events · Portal{% endblock %} +{% block topbar_title %}Events{% endblock %} +{% block portal_content %} +

New event

+ + + + {% for event in events %} + + + + + + + {% empty %} + + {% endfor %} + +
TitleWhenCapacityStatus
{{ event.title }}{{ event.starts_at }}{{ event.tickets_sold }} / {{ event.capacity|default:"∞" }}{% if event.is_published %}Published{% else %}Draft{% endif %}
No events yet.
+{% endblock %} diff --git a/site/events/templates/events/success.html b/site/events/templates/events/success.html new file mode 100644 index 0000000..d33b77b --- /dev/null +++ b/site/events/templates/events/success.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Tickets {{ order.number }}{% endblock %} +{% block content %} +
+

You're in

+

Order {{ order.number }} for {{ order.event.title }} is {{ order.get_status_display|lower }}.

+

Ticket codes will be emailed to {{ order.email }}.

+
+{% endblock %} diff --git a/site/events/tests.py b/site/events/tests.py new file mode 100644 index 0000000..cd07cb7 --- /dev/null +++ b/site/events/tests.py @@ -0,0 +1,95 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from events.models import Event, Ticket, TicketOrder +from events.services import EventsError, create_ticket_order, mark_paid + + +def _event(**kwargs): + defaults = dict( + title="Friday Night Prerelease", + starts_at=timezone.now() + timezone.timedelta(days=7), + venue="Main shop", + capacity=8, + price=Decimal("25.00"), + is_published=True, + ) + defaults.update(kwargs) + return Event.objects.create(**defaults) + + +class EventPublicTests(TestCase): + def test_list_hides_drafts_and_past(self): + _event(title="Live prerelease") + _event(title="Draft night", is_published=False) + _event( + title="Last week", + starts_at=timezone.now() - timezone.timedelta(days=2), + ) + response = Client().get(reverse("events:list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Live prerelease") + self.assertNotContains(response, "Draft night") + self.assertNotContains(response, "Last week") + + def test_buy_form_renders(self): + event = _event() + response = Client().get(reverse("events:buy", kwargs={"slug": event.slug})) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Pay with Stripe") + + +class EventCapacityTests(TestCase): + def test_capacity_blocks_overbook(self): + event = _event(capacity=2) + create_ticket_order(event, email="a@example.com", quantity=2) + with self.assertRaises(EventsError): + create_ticket_order(event, email="b@example.com", quantity=1) + + def test_mark_paid_issues_tickets_and_email(self): + event = _event(capacity=4) + order = create_ticket_order( + event, email="fan@example.com", customer_name="Lee", quantity=2 + ) + mark_paid(order) + order.refresh_from_db() + self.assertEqual(order.status, TicketOrder.Status.PAID) + self.assertEqual(Ticket.objects.filter(order=order).count(), 2) + self.assertEqual(event.tickets_sold, 2) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(event.title, mail.outbox[0].subject) + + +class EventPortalTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user("host", password="test-pass-123") + self.client = Client() + self.client.login(username="host", password="test-pass-123") + + def test_list_requires_login(self): + self.assertEqual(Client().get(reverse("events_portal:event_list")).status_code, 302) + + def test_create_published_event(self): + starts = (timezone.now() + timezone.timedelta(days=3)).strftime("%Y-%m-%dT%H:%M") + response = self.client.post( + reverse("events_portal:event_new"), + { + "title": "Commander night", + "starts_at": starts, + "venue": "Back room", + "capacity": "16", + "price": "10.00", + "is_published": "on", + }, + ) + self.assertEqual(response.status_code, 302) + event = Event.objects.get() + self.assertEqual(event.slug, "commander-night") + self.assertTrue(event.is_published) + self.assertEqual(event.capacity, 16) diff --git a/site/events/views.py b/site/events/views.py new file mode 100644 index 0000000..8dac5fa --- /dev/null +++ b/site/events/views.py @@ -0,0 +1,194 @@ +import logging +from decimal import Decimal, InvalidOperation + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone +from datetime import datetime +from django.utils.text import slugify +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from events.models import Event, TicketOrder +from events.services import ( + EventsError, + create_checkout_session, + create_ticket_order, + mark_paid, +) + +logger = logging.getLogger(__name__) + + +def _site_base(request) -> str: + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if base: + return base + return request.build_absolute_uri("/").rstrip("/") + + +def event_list(request): + events = Event.objects.filter(is_published=True, starts_at__gte=timezone.now()) + return render(request, "events/list.html", {"events": events}) + + +def event_detail(request, slug): + event = get_object_or_404(Event, slug=slug, is_published=True) + return render(request, "events/detail.html", {"event": event}) + + +@require_http_methods(["GET", "POST"]) +def buy_tickets(request, slug): + event = get_object_or_404(Event, slug=slug, is_published=True) + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + name = (request.POST.get("customer_name") or "").strip() + try: + quantity = int(request.POST.get("quantity") or "1") + except ValueError: + quantity = 0 + try: + order = create_ticket_order( + event, email=email, customer_name=name, quantity=quantity + ) + base = _site_base(request) + success = base + reverse( + "events:checkout_success", kwargs={"pk": order.pk} + ) + cancel = base + reverse("events:checkout_cancel", kwargs={"pk": order.pk}) + url = create_checkout_session( + order, + success_url=success + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel, + ) + except EventsError as exc: + messages.error(request, str(exc)) + except Exception as exc: # noqa: BLE001 + logger.exception("ticket checkout failed") + messages.error(request, f"Could not start checkout: {exc}") + else: + return redirect(url) + return render(request, "events/buy.html", {"event": event}) + + +def checkout_success(request, pk): + order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk) + return render(request, "events/success.html", {"order": order}) + + +def checkout_cancel(request, pk): + order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk) + return render(request, "events/cancel.html", {"order": order}) + + +@login_required +def portal_event_list(request): + events = Event.objects.all().order_by("-starts_at") + return render(request, "events/portal/list.html", {"events": events}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def portal_event_edit(request, pk=None): + event = get_object_or_404(Event, pk=pk) if pk else None + if request.method == "POST": + title = (request.POST.get("title") or "").strip() + description = (request.POST.get("description") or "").strip() + venue = (request.POST.get("venue") or "").strip() + slug = (request.POST.get("slug") or "").strip() + starts_raw = (request.POST.get("starts_at") or "").strip() + errors = [] + if not title: + errors.append("Title is required.") + starts_at = None + if starts_raw: + try: + starts_at = datetime.fromisoformat(starts_raw) + except ValueError: + starts_at = None + if starts_at is None: + errors.append("Start date/time is required.") + elif timezone.is_naive(starts_at): + starts_at = timezone.make_aware(starts_at, timezone.get_current_timezone()) + try: + capacity = int(request.POST.get("capacity") or "0") + except ValueError: + capacity = 0 + errors.append("Capacity must be a number.") + try: + price = Decimal(request.POST.get("price") or "0") + if price < 0: + raise InvalidOperation + except Exception: + price = None + errors.append("Enter a valid price.") + if errors: + for err in errors: + messages.error(request, err) + else: + if event is None: + event = Event() + event.title = title + event.description = description + event.venue = venue + event.slug = slugify(slug)[:220] if slug else "" + event.starts_at = starts_at + event.capacity = max(capacity, 0) + event.price = price + event.currency = (settings.STRIPE_CURRENCY or "usd").lower() + event.is_published = request.POST.get("is_published") == "on" + event.save() + messages.success(request, f"Saved {event.title}.") + return redirect("events_portal:event_list") + return render(request, "events/portal/edit.html", {"event": event}) + + +@login_required +def portal_event_detail(request, pk): + event = get_object_or_404(Event, pk=pk) + orders = event.ticket_orders.select_related().prefetch_related("tickets") + return render( + request, + "events/portal/detail.html", + {"event": event, "orders": orders}, + ) + + +@csrf_exempt +@require_http_methods(["POST"]) +def stripe_webhook(request): + secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip() + if not secret: + logger.error("STRIPE_WEBHOOK_SECRET unset") + return HttpResponseBadRequest("webhook not configured") + try: + import stripe + except ImportError: + return HttpResponseBadRequest("stripe not installed") + sig = request.headers.get("Stripe-Signature", "") + try: + event = stripe.Webhook.construct_event(request.body, sig, secret) + except Exception: + logger.exception("events stripe webhook signature failed") + return HttpResponseBadRequest("invalid signature") + + obj = event.get("data", {}).get("object", {}) or {} + if event.get("type") != "checkout.session.completed": + return HttpResponse("ok") + order_id = (obj.get("metadata") or {}).get("ticket_order_id") or "" + order = None + if order_id: + order = TicketOrder.objects.filter(pk=order_id).first() + if order is None: + session_id = obj.get("id") or "" + order = TicketOrder.objects.filter( + stripe_checkout_session_id=session_id + ).first() + if order and order.status != TicketOrder.Status.PAID: + mark_paid(order, stripe_id=obj.get("id") or "") + logger.info("ticket order %s marked paid", order.number) + return HttpResponse("ok") diff --git a/site/pos_sync/__init__.py b/site/pos_sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/pos_sync/admin.py b/site/pos_sync/admin.py new file mode 100644 index 0000000..2df3897 --- /dev/null +++ b/site/pos_sync/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from pos_sync.models import POSConnection, SyncEvent + + +@admin.register(POSConnection) +class POSConnectionAdmin(admin.ModelAdmin): + list_display = ("name", "provider", "is_active") + list_filter = ("provider", "is_active") + + +@admin.register(SyncEvent) +class SyncEventAdmin(admin.ModelAdmin): + list_display = ("direction", "sku", "quantity", "status", "created_at") + list_filter = ("direction", "status") + search_fields = ("sku", "external_id") diff --git a/site/pos_sync/apps.py b/site/pos_sync/apps.py new file mode 100644 index 0000000..dd5df8b --- /dev/null +++ b/site/pos_sync/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class PosSyncConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pos_sync" + verbose_name = "POS API sync" + + def ready(self): + from pos_sync import hooks + + hooks.register() diff --git a/site/pos_sync/hooks.py b/site/pos_sync/hooks.py new file mode 100644 index 0000000..b7fa197 --- /dev/null +++ b/site/pos_sync/hooks.py @@ -0,0 +1,35 @@ +from core.registry import ( + register_dashboard_collector, + register_dispatcher, + register_feature, + register_portal_nav, +) + + +def register() -> None: + register_feature("pos_sync") + register_portal_nav( + section="pos_sync", + label="POS sync", + url_name="pos_sync:event_list", + group="Retail", + order=40, + ) + register_dashboard_collector(_dashboard) + register_dispatcher(_dispatch) + + +def _dashboard(request) -> dict: + from pos_sync.models import SyncEvent + + return { + "pos_failed_syncs": SyncEvent.objects.filter( + status=SyncEvent.Status.FAILED + ).count() + } + + +def _dispatch() -> int: + from pos_sync.services import dispatch_pending_outbound + + return dispatch_pending_outbound() diff --git a/site/pos_sync/migrations/0001_initial.py b/site/pos_sync/migrations/0001_initial.py new file mode 100644 index 0000000..144b685 --- /dev/null +++ b/site/pos_sync/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 6.1 on 2026-09-06 11:17 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='POSConnection', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=120)), + ('provider', models.CharField(choices=[('generic', 'Generic REST'), ('square', 'Square'), ('lightspeed', 'Lightspeed'), ('binderpos', 'BinderPOS'), ('crystalcommerce', 'CrystalCommerce')], default='generic', max_length=32)), + ('api_base_url', models.URLField(blank=True)), + ('api_token', models.CharField(blank=True, max_length=255)), + ('webhook_secret', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='SyncEvent', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('direction', models.CharField(choices=[('inbound', 'POS → site'), ('outbound', 'Site → POS')], max_length=16)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=16)), + ('sku', models.CharField(max_length=64)), + ('quantity', models.IntegerField(default=0)), + ('external_id', models.CharField(blank=True, max_length=255)), + ('payload', models.JSONField(blank=True, default=dict)), + ('error', models.TextField(blank=True)), + ('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='pos_sync.posconnection')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/site/pos_sync/migrations/__init__.py b/site/pos_sync/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/pos_sync/models.py b/site/pos_sync/models.py new file mode 100644 index 0000000..40759e5 --- /dev/null +++ b/site/pos_sync/models.py @@ -0,0 +1,61 @@ +from django.db import models + +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class POSConnection(UUIDPrimaryKeyModel, TimeStampedModel): + class Provider(models.TextChoices): + GENERIC = "generic", "Generic REST" + SQUARE = "square", "Square" + LIGHTSPEED = "lightspeed", "Lightspeed" + BINDERPOS = "binderpos", "BinderPOS" + CRYSTALCOMMERCE = "crystalcommerce", "CrystalCommerce" + + name = models.CharField(max_length=120) + provider = models.CharField( + max_length=32, choices=Provider.choices, default=Provider.GENERIC + ) + api_base_url = models.URLField(blank=True) + api_token = models.CharField(max_length=255, blank=True) + webhook_secret = models.CharField(max_length=255, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + +class SyncEvent(UUIDPrimaryKeyModel, TimeStampedModel): + class Direction(models.TextChoices): + INBOUND = "inbound", "POS → site" + OUTBOUND = "outbound", "Site → POS" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + DONE = "done", "Done" + FAILED = "failed", "Failed" + + connection = models.ForeignKey( + POSConnection, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="events", + ) + direction = models.CharField(max_length=16, choices=Direction.choices) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.PENDING + ) + sku = models.CharField(max_length=64) + quantity = models.IntegerField(default=0) + external_id = models.CharField(max_length=255, blank=True) + payload = models.JSONField(default=dict, blank=True) + error = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.direction} {self.sku} × {self.quantity} · {self.status}" diff --git a/site/pos_sync/services.py b/site/pos_sync/services.py new file mode 100644 index 0000000..3fd3f96 --- /dev/null +++ b/site/pos_sync/services.py @@ -0,0 +1,131 @@ +"""POS inventory sync. Inbound webhooks decrement shop stock; outbound queues reserve POS.""" + +from __future__ import annotations + +import logging + +import requests +from django.conf import settings +from django.db import transaction + +from pos_sync.models import POSConnection, SyncEvent +from shop.models import Product +from shop.services import ShopError, adjust_stock + +logger = logging.getLogger(__name__) + + +class POSSyncError(RuntimeError): + pass + + +def active_connection() -> POSConnection | None: + return POSConnection.objects.filter(is_active=True).order_by("created_at").first() + + +def apply_inbound_sale( + *, + sku: str, + quantity: int, + external_id: str = "", + payload: dict | None = None, + connection: POSConnection | None = None, +) -> SyncEvent: + sku = (sku or "").strip() + if not sku: + raise POSSyncError("SKU is required.") + if quantity < 1: + raise POSSyncError("Quantity must be at least 1.") + product = Product.objects.filter(sku__iexact=sku).first() + if product is None: + raise POSSyncError(f"Unknown SKU {sku}.") + event = SyncEvent.objects.create( + connection=connection or active_connection(), + direction=SyncEvent.Direction.INBOUND, + status=SyncEvent.Status.PENDING, + sku=product.sku, + quantity=quantity, + external_id=external_id, + payload=payload or {}, + ) + try: + with transaction.atomic(): + locked = Product.objects.select_for_update().get(pk=product.pk) + if locked.track_inventory and locked.fulfillment == Product.Fulfillment.STOCKED: + adjust_stock(locked, -quantity) + event.status = SyncEvent.Status.DONE + event.save(update_fields=["status", "updated_at"]) + except ShopError as exc: + event.status = SyncEvent.Status.FAILED + event.error = str(exc) + event.save(update_fields=["status", "error", "updated_at"]) + raise POSSyncError(str(exc)) from exc + return event + + +def enqueue_online_sale(order) -> list[SyncEvent]: + """Queue outbound POS reserves after an online shop sale.""" + events = [] + connection = active_connection() + for item in order.items.all(): + sku = item.sku + if not sku: + continue + events.append( + SyncEvent.objects.create( + connection=connection, + direction=SyncEvent.Direction.OUTBOUND, + status=SyncEvent.Status.PENDING, + sku=sku, + quantity=item.quantity, + external_id=order.number, + payload={"order_id": str(order.pk), "order_number": order.number}, + ) + ) + return events + + +def _post_reserve(connection: POSConnection, event: SyncEvent) -> None: + base = (connection.api_base_url or settings.POS_API_BASE_URL or "").rstrip("/") + token = connection.api_token or (settings.POS_API_TOKEN or "") + if not base: + raise POSSyncError("POS API base URL is not configured.") + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + response = requests.post( + f"{base}/inventory/reserve", + json={ + "sku": event.sku, + "quantity": event.quantity, + "external_id": event.external_id, + "source": "online", + }, + headers=headers, + timeout=15, + ) + response.raise_for_status() + + +def dispatch_pending_outbound() -> int: + sent = 0 + for event in SyncEvent.objects.filter( + direction=SyncEvent.Direction.OUTBOUND, + status=SyncEvent.Status.PENDING, + )[:50]: + connection = event.connection or active_connection() + try: + if connection is None: + raise POSSyncError("No active POS connection.") + _post_reserve(connection, event) + except Exception as exc: # noqa: BLE001 + logger.exception("POS outbound failed for %s", event.sku) + event.status = SyncEvent.Status.FAILED + event.error = str(exc) + event.save(update_fields=["status", "error", "updated_at"]) + else: + event.status = SyncEvent.Status.DONE + event.error = "" + event.save(update_fields=["status", "error", "updated_at"]) + sent += 1 + return sent diff --git a/site/pos_sync/templates/pos_sync/list.html b/site/pos_sync/templates/pos_sync/list.html new file mode 100644 index 0000000..08213b2 --- /dev/null +++ b/site/pos_sync/templates/pos_sync/list.html @@ -0,0 +1,22 @@ +{% extends "portal_base.html" %} +{% block title %}POS sync · Portal{% endblock %} +{% block topbar_title %}POS sync{% endblock %} +{% block portal_content %} +

Inbound POS sales decrement shop stock. Online sales queue outbound reserves.

+ + + + {% for event in events %} + + + + + + + + {% empty %} + + {% endfor %} + +
WhenDirectionSKUQtyStatus
{{ event.created_at }}{{ event.get_direction_display }}{{ event.sku }}{{ event.quantity }}{{ event.get_status_display }}
No sync events yet.
+{% endblock %} diff --git a/site/pos_sync/tests.py b/site/pos_sync/tests.py new file mode 100644 index 0000000..aec0b38 --- /dev/null +++ b/site/pos_sync/tests.py @@ -0,0 +1,115 @@ +from decimal import Decimal +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase, override_settings +from django.urls import reverse + +from pos_sync.models import POSConnection, SyncEvent +from pos_sync.services import apply_inbound_sale, dispatch_pending_outbound, enqueue_online_sale +from shop.models import Product +from shop.services import add_to_cart, create_order_from_cart, mark_paid + + +class POSInboundTests(TestCase): + def setUp(self): + self.product = Product.objects.create( + name="Booster pack", + sku="TCG-PACK", + price=Decimal("4.99"), + stock_qty=10, + is_published=True, + ) + + def test_inbound_sale_decrements_stock(self): + event = apply_inbound_sale(sku="tcg-pack", quantity=3, external_id="pos-1") + self.product.refresh_from_db() + self.assertEqual(self.product.stock_qty, 7) + self.assertEqual(event.status, SyncEvent.Status.DONE) + + @override_settings(POS_WEBHOOK_SECRET="pos-secret") + def test_webhook_requires_secret_and_updates_stock(self): + url = reverse("pos_sync:inventory_webhook") + anon = Client().post( + url, + data='{"sku":"TCG-PACK","quantity":1}', + content_type="application/json", + ) + self.assertEqual(anon.status_code, 401) + ok = Client().post( + url, + data='{"sku":"TCG-PACK","quantity":2,"external_id":"reg-9"}', + content_type="application/json", + HTTP_AUTHORIZATION="Bearer pos-secret", + ) + self.assertEqual(ok.status_code, 200) + self.product.refresh_from_db() + self.assertEqual(self.product.stock_qty, 8) + + +class POSOutboundTests(TestCase): + def setUp(self): + self.product = Product.objects.create( + name="Figure", + sku="FIG-1", + price=Decimal("12.00"), + stock_qty=5, + is_published=True, + ) + self.connection = POSConnection.objects.create( + name="Counter", + api_base_url="https://pos.example.com", + api_token="tok", + is_active=True, + ) + + def test_paid_order_enqueues_outbound(self): + session = self.client.session + add_to_cart(session, self.product, 1) + session.save() + order = create_order_from_cart(self.client.session, email="a@example.com") + mark_paid(order) + event = SyncEvent.objects.get(direction=SyncEvent.Direction.OUTBOUND) + self.assertEqual(event.sku, "FIG-1") + self.assertEqual(event.status, SyncEvent.Status.PENDING) + + def test_dispatch_posts_reserve(self): + enqueue_online_sale( + type( + "O", + (), + { + "number": "ORD-1", + "pk": "x", + "items": type( + "M", + (), + { + "all": lambda self: [ + type("I", (), {"sku": "FIG-1", "quantity": 1})() + ] + }, + )(), + }, + )() + ) + with patch("pos_sync.services.requests.post") as post: + post.return_value.raise_for_status = lambda: None + sent = dispatch_pending_outbound() + self.assertEqual(sent, 1) + post.assert_called_once() + self.assertIn("/inventory/reserve", post.call_args.args[0]) + event = SyncEvent.objects.get() + self.assertEqual(event.status, SyncEvent.Status.DONE) + + +class POSPortalTests(TestCase): + def test_list_requires_login(self): + self.assertEqual(Client().get(reverse("pos_sync:event_list")).status_code, 302) + + def test_list_ok_when_logged_in(self): + User = get_user_model() + User.objects.create_user("clerk", password="test-pass-123") + client = Client() + client.login(username="clerk", password="test-pass-123") + self.assertEqual(client.get(reverse("pos_sync:event_list")).status_code, 200) diff --git a/site/pos_sync/urls.py b/site/pos_sync/urls.py new file mode 100644 index 0000000..1fa1e4e --- /dev/null +++ b/site/pos_sync/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from pos_sync import views + +app_name = "pos_sync" + +urlpatterns = [ + path("", views.event_list, name="event_list"), + path("webhooks/inventory/", views.inventory_webhook, name="inventory_webhook"), +] diff --git a/site/pos_sync/views.py b/site/pos_sync/views.py new file mode 100644 index 0000000..18f93f0 --- /dev/null +++ b/site/pos_sync/views.py @@ -0,0 +1,70 @@ +import json +import logging + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from pos_sync.models import POSConnection, SyncEvent +from pos_sync.services import POSSyncError, apply_inbound_sale + +logger = logging.getLogger(__name__) + + +def _authorized(request) -> bool: + expected = (settings.POS_WEBHOOK_SECRET or "").strip() + if not expected: + conn = POSConnection.objects.filter(is_active=True).exclude( + webhook_secret="" + ).first() + expected = (conn.webhook_secret if conn else "").strip() + if not expected: + return False + header = request.headers.get("Authorization", "") + token = "" + if header.startswith("Bearer "): + token = header[7:].strip() + token = token or request.GET.get("token") or "" + return token == expected + + +@login_required +def event_list(request): + events = SyncEvent.objects.select_related("connection")[:200] + connections = POSConnection.objects.all() + return render( + request, + "pos_sync/list.html", + {"events": events, "connections": connections}, + ) + + +@csrf_exempt +@require_http_methods(["POST"]) +def inventory_webhook(request): + if not _authorized(request): + return HttpResponse("unauthorized", status=401) + try: + payload = json.loads(request.body.decode() or "{}") + except json.JSONDecodeError: + return HttpResponseBadRequest("invalid json") + sku = payload.get("sku") or payload.get("SKU") or "" + try: + quantity = int(payload.get("quantity") or payload.get("qty") or 0) + except (TypeError, ValueError): + return HttpResponseBadRequest("invalid quantity") + external_id = str(payload.get("external_id") or payload.get("id") or "") + try: + event = apply_inbound_sale( + sku=sku, + quantity=quantity, + external_id=external_id, + payload=payload, + ) + except POSSyncError as exc: + logger.warning("POS inbound rejected: %s", exc) + return JsonResponse({"ok": False, "error": str(exc)}, status=400) + return JsonResponse({"ok": True, "id": str(event.pk), "status": event.status}) diff --git a/site/public/views.py b/site/public/views.py index 8f79903..a33e880 100644 --- a/site/public/views.py +++ b/site/public/views.py @@ -70,6 +70,10 @@ def sitemap_xml(request): from django.apps import apps as django_apps if django_apps.is_installed("blog"): paths.append(("blog:list", "0.7", "weekly")) + if django_apps.is_installed("shop"): + paths.append(("shop:list", "0.8", "weekly")) + if django_apps.is_installed("events"): + paths.append(("events:list", "0.8", "weekly")) urls = [] for name, priority, changefreq in paths: path = reverse(name) diff --git a/site/shipping/__init__.py b/site/shipping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/shipping/admin.py b/site/shipping/admin.py new file mode 100644 index 0000000..291cc75 --- /dev/null +++ b/site/shipping/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from shipping.models import Shipment + + +@admin.register(Shipment) +class ShipmentAdmin(admin.ModelAdmin): + list_display = ("order", "status", "carrier", "tracking_number") + list_filter = ("status", "carrier") + search_fields = ("tracking_number", "order__number") diff --git a/site/shipping/apps.py b/site/shipping/apps.py new file mode 100644 index 0000000..7e9ba77 --- /dev/null +++ b/site/shipping/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class ShippingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "shipping" + verbose_name = "Shipping automation" + + def ready(self): + from shipping import hooks + + hooks.register() diff --git a/site/shipping/hooks.py b/site/shipping/hooks.py new file mode 100644 index 0000000..7d8e918 --- /dev/null +++ b/site/shipping/hooks.py @@ -0,0 +1,31 @@ +from core.registry import ( + register_dashboard_collector, + register_feature, + register_portal_nav, +) + + +def register() -> None: + register_feature("shipping") + register_portal_nav( + section="shipping", + label="Shipping", + url_name="shipping:shipment_list", + group="Retail", + order=50, + ) + register_dashboard_collector(_dashboard) + + +def _dashboard(request) -> dict: + from shipping.models import Shipment + from shop.models import Order + + labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list( + "order_id", flat=True + ) + return { + "unshipped_orders": Order.objects.filter(status=Order.Status.PAID) + .exclude(pk__in=labeled) + .count() + } diff --git a/site/shipping/migrations/0001_initial.py b/site/shipping/migrations/0001_initial.py new file mode 100644 index 0000000..f1f8e87 --- /dev/null +++ b/site/shipping/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 6.1 on 2026-09-06 11:17 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('shop', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Shipment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('rated', 'Rated'), ('labeled', 'Labeled'), ('void', 'Void')], default='draft', max_length=16)), + ('carrier', models.CharField(blank=True, max_length=32)), + ('service', models.CharField(blank=True, max_length=64)), + ('tracking_number', models.CharField(blank=True, max_length=64)), + ('label_url', models.URLField(blank=True)), + ('rate_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('currency', models.CharField(default='usd', max_length=8)), + ('provider_shipment_id', models.CharField(blank=True, max_length=255)), + ('rates', models.JSONField(blank=True, default=list)), + ('weight_oz', models.PositiveIntegerField(default=16)), + ('notes', models.TextField(blank=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shipments', to='shop.order')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/site/shipping/migrations/__init__.py b/site/shipping/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/shipping/models.py b/site/shipping/models.py new file mode 100644 index 0000000..4892da8 --- /dev/null +++ b/site/shipping/models.py @@ -0,0 +1,33 @@ +from django.db import models + +from core.models import TimeStampedModel, UUIDPrimaryKeyModel +from shop.models import Order + + +class Shipment(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + RATED = "rated", "Rated" + LABELED = "labeled", "Labeled" + VOID = "void", "Void" + + order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments") + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + carrier = models.CharField(max_length=32, blank=True) + service = models.CharField(max_length=64, blank=True) + tracking_number = models.CharField(max_length=64, blank=True) + label_url = models.URLField(blank=True) + rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + currency = models.CharField(max_length=8, default="usd") + provider_shipment_id = models.CharField(max_length=255, blank=True) + rates = models.JSONField(default=list, blank=True) + weight_oz = models.PositiveIntegerField(default=16) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.order.number} · {self.tracking_number or self.status}" diff --git a/site/shipping/services.py b/site/shipping/services.py new file mode 100644 index 0000000..f81a44d --- /dev/null +++ b/site/shipping/services.py @@ -0,0 +1,184 @@ +"""EasyPost-style rates/labels plus Pirate Ship CSV export.""" + +from __future__ import annotations + +import csv +import io +import logging +from decimal import Decimal + +import requests +from django.conf import settings + +from shipping.models import Shipment +from shop.models import Order + +logger = logging.getLogger(__name__) + + +class ShippingError(RuntimeError): + pass + + +def _easypost_key() -> str: + return (getattr(settings, "EASYPOST_API_KEY", "") or "").strip() + + +def quote_rates(shipment: Shipment) -> list[dict]: + """Return carrier rates. Uses EasyPost when configured; otherwise a USPS stub.""" + key = _easypost_key() + if not key: + rates = [ + { + "id": "stub-usps-ground", + "carrier": "USPS", + "service": "GroundAdvantage", + "rate": "5.40", + "currency": "USD", + }, + { + "id": "stub-usps-priority", + "carrier": "USPS", + "service": "Priority", + "rate": "9.80", + "currency": "USD", + }, + ] + shipment.rates = rates + shipment.status = Shipment.Status.RATED + shipment.save(update_fields=["rates", "status", "updated_at"]) + return rates + + addr = shipment.order.shipping_address or {} + response = requests.post( + "https://api.easypost.com/v2/shipments", + auth=(key, ""), + json={ + "shipment": { + "to_address": { + "name": shipment.order.customer_name or shipment.order.email, + "street1": addr.get("line1") or "", + "street2": addr.get("line2") or "", + "city": addr.get("city") or "", + "state": addr.get("state") or "", + "zip": addr.get("zip") or "", + "country": addr.get("country") or "US", + }, + "from_address": { + "name": settings.SITE_NAME, + "street1": getattr(settings, "SHIP_FROM_LINE1", "") or "", + "city": getattr(settings, "SHIP_FROM_CITY", "") or "", + "state": getattr(settings, "SHIP_FROM_STATE", "") or "", + "zip": getattr(settings, "SHIP_FROM_ZIP", "") or "", + "country": "US", + }, + "parcel": {"weight": shipment.weight_oz}, + } + }, + timeout=20, + ) + response.raise_for_status() + data = response.json() + rates = data.get("rates") or [] + shipment.provider_shipment_id = data.get("id") or "" + shipment.rates = rates + shipment.status = Shipment.Status.RATED + shipment.save( + update_fields=["provider_shipment_id", "rates", "status", "updated_at"] + ) + return rates + + +def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment: + rates = shipment.rates or quote_rates(shipment) + chosen = None + if rate_id: + chosen = next((r for r in rates if str(r.get("id")) == str(rate_id)), None) + if chosen is None and rates: + chosen = rates[0] + if chosen is None: + raise ShippingError("No shipping rates available.") + + key = _easypost_key() + if key and shipment.provider_shipment_id: + response = requests.post( + f"https://api.easypost.com/v2/shipments/{shipment.provider_shipment_id}/buy", + auth=(key, ""), + json={"rate": {"id": chosen.get("id")}}, + timeout=20, + ) + response.raise_for_status() + data = response.json() + postage = data.get("postage_label") or {} + tracking = data.get("tracking_code") or "" + shipment.label_url = postage.get("label_url") or "" + shipment.tracking_number = tracking + else: + shipment.tracking_number = f"STUB{shipment.order.number[-6:]}" + shipment.label_url = "" + + shipment.carrier = chosen.get("carrier") or "" + shipment.service = chosen.get("service") or "" + try: + shipment.rate_amount = Decimal(str(chosen.get("rate") or "0")) + except Exception: + shipment.rate_amount = None + shipment.status = Shipment.Status.LABELED + shipment.save( + update_fields=[ + "carrier", + "service", + "tracking_number", + "label_url", + "rate_amount", + "status", + "updated_at", + ] + ) + return shipment + + +def create_shipment_for_order(order: Order, *, weight_oz: int = 16) -> Shipment: + if order.status not in {Order.Status.PAID, Order.Status.FULFILLED}: + raise ShippingError("Ship paid orders only.") + return Shipment.objects.create(order=order, weight_oz=weight_oz) + + +def pirate_ship_csv(orders=None) -> str: + """CSV Pirate Ship can map: name, address, city, state, zip, email.""" + if orders is None: + shipped = Shipment.objects.filter( + status=Shipment.Status.LABELED + ).values_list("order_id", flat=True) + orders = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=shipped) + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow( + [ + "Order Number", + "Name", + "Address 1", + "Address 2", + "City", + "State", + "Zip", + "Country", + "Email", + ] + ) + for order in orders: + addr = order.shipping_address or {} + writer.writerow( + [ + order.number, + order.customer_name or order.email, + addr.get("line1") or "", + addr.get("line2") or "", + addr.get("city") or "", + addr.get("state") or "", + addr.get("zip") or "", + addr.get("country") or "US", + order.email, + ] + ) + return buffer.getvalue() diff --git a/site/shipping/templates/shipping/detail.html b/site/shipping/templates/shipping/detail.html new file mode 100644 index 0000000..c70de17 --- /dev/null +++ b/site/shipping/templates/shipping/detail.html @@ -0,0 +1,25 @@ +{% extends "portal_base.html" %} +{% block title %}{{ shipment.order.number }} · Shipping{% endblock %} +{% block topbar_title %}{{ shipment.order.number }}{% endblock %} +{% block portal_content %} +

{{ shipment.order.email }} · {{ shipment.get_status_display }}

+{% if shipment.tracking_number %}

Tracking: {{ shipment.tracking_number }}

{% endif %} +{% if shipment.label_url %}

Download label

{% endif %} +{% if shipment.status != 'labeled' %} +
+ {% csrf_token %} + {% for rate in shipment.rates %} +
+ {% empty %} +

No rates yet.

+ {% endfor %} + {% if shipment.rates %} + + {% endif %} +
+{% endif %} +

← All shipments

+{% endblock %} diff --git a/site/shipping/templates/shipping/list.html b/site/shipping/templates/shipping/list.html new file mode 100644 index 0000000..ed685e2 --- /dev/null +++ b/site/shipping/templates/shipping/list.html @@ -0,0 +1,43 @@ +{% extends "portal_base.html" %} +{% block title %}Shipping · Portal{% endblock %} +{% block topbar_title %}Shipping{% endblock %} +{% block portal_content %} +

Pirate Ship CSV

+

Unshipped paid orders

+ + + + {% for order in unshipped %} + + + + + + {% empty %} + + {% endfor %} + +
OrderEmail
{{ order.number }}{{ order.email }} +
+ {% csrf_token %} + + oz + +
+
No paid orders waiting to ship.
+

Shipments

+ + + + {% for shipment in shipments %} + + + + + + {% empty %} + + {% endfor %} + +
OrderStatusTracking
{{ shipment.order.number }}{{ shipment.get_status_display }}{{ shipment.tracking_number|default:"—" }}
No shipments yet.
+{% endblock %} diff --git a/site/shipping/tests.py b/site/shipping/tests.py new file mode 100644 index 0000000..4818d04 --- /dev/null +++ b/site/shipping/tests.py @@ -0,0 +1,84 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from shipping.models import Shipment +from shipping.services import buy_label, create_shipment_for_order, pirate_ship_csv, quote_rates +from shop.models import Order + + +class ShippingServiceTests(TestCase): + def setUp(self): + self.order = Order.objects.create( + number="ORD-20260101-001", + email="buyer@example.com", + customer_name="Pat Lee", + status=Order.Status.PAID, + amount=Decimal("18.00"), + shipping_address={ + "line1": "123 Main", + "city": "Aurora", + "state": "IL", + "zip": "60506", + "country": "US", + }, + ) + + def test_quote_and_buy_stub_label(self): + shipment = create_shipment_for_order(self.order) + rates = quote_rates(shipment) + self.assertGreaterEqual(len(rates), 1) + shipment.refresh_from_db() + self.assertEqual(shipment.status, Shipment.Status.RATED) + buy_label(shipment, rate_id=rates[0]["id"]) + shipment.refresh_from_db() + self.assertEqual(shipment.status, Shipment.Status.LABELED) + self.assertTrue(shipment.tracking_number) + + def test_pirate_ship_csv_includes_unshipped(self): + csv_body = pirate_ship_csv() + self.assertIn("ORD-20260101-001", csv_body) + self.assertIn("123 Main", csv_body) + self.assertIn("buyer@example.com", csv_body) + + def test_csv_omits_labeled_orders(self): + shipment = create_shipment_for_order(self.order) + quote_rates(shipment) + buy_label(shipment) + self.assertNotIn(self.order.number, pirate_ship_csv()) + + +class ShippingPortalTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user("shipper", password="test-pass-123") + self.client = Client() + self.client.login(username="shipper", password="test-pass-123") + self.order = Order.objects.create( + number="ORD-20260101-002", + email="a@example.com", + status=Order.Status.PAID, + amount=Decimal("9.00"), + shipping_address={"line1": "9 Oak", "city": "Town", "state": "IL", "zip": "60189"}, + ) + + def test_list_requires_login(self): + self.assertEqual(Client().get(reverse("shipping:shipment_list")).status_code, 302) + + def test_create_shipment_from_portal(self): + response = self.client.post( + reverse("shipping:shipment_create"), + {"order": str(self.order.pk), "weight_oz": "8"}, + ) + self.assertEqual(response.status_code, 302) + shipment = Shipment.objects.get() + self.assertEqual(shipment.weight_oz, 8) + self.assertEqual(shipment.status, Shipment.Status.RATED) + + def test_csv_export(self): + response = self.client.get(reverse("shipping:pirate_ship_export")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + self.assertIn(b"ORD-20260101-002", response.content) diff --git a/site/shipping/urls.py b/site/shipping/urls.py new file mode 100644 index 0000000..5d26350 --- /dev/null +++ b/site/shipping/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from shipping import views + +app_name = "shipping" + +urlpatterns = [ + path("", views.shipment_list, name="shipment_list"), + path("new/", views.shipment_create, name="shipment_create"), + path("export/pirate-ship.csv", views.pirate_ship_export, name="pirate_ship_export"), + path("/", views.shipment_detail, name="shipment_detail"), + path("/buy/", views.shipment_buy, name="shipment_buy"), +] diff --git a/site/shipping/views.py b/site/shipping/views.py new file mode 100644 index 0000000..dae8018 --- /dev/null +++ b/site/shipping/views.py @@ -0,0 +1,86 @@ +import logging + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_http_methods, require_POST + +from shipping.models import Shipment +from shipping.services import ( + ShippingError, + buy_label, + create_shipment_for_order, + pirate_ship_csv, + quote_rates, +) +from shop.models import Order + +logger = logging.getLogger(__name__) + + +@login_required +def shipment_list(request): + shipments = Shipment.objects.select_related("order")[:200] + labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list( + "order_id", flat=True + ) + unshipped = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=labeled) + return render( + request, + "shipping/list.html", + {"shipments": shipments, "unshipped": unshipped}, + ) + + +@login_required +@require_POST +def shipment_create(request): + order = get_object_or_404(Order, pk=request.POST.get("order")) + try: + weight = int(request.POST.get("weight_oz") or "16") + except ValueError: + weight = 16 + try: + shipment = create_shipment_for_order(order, weight_oz=weight) + quote_rates(shipment) + except ShippingError as exc: + messages.error(request, str(exc)) + return redirect("shipping:shipment_list") + except Exception as exc: # noqa: BLE001 + logger.exception("rate quote failed") + messages.error(request, f"Could not quote rates: {exc}") + return redirect("shipping:shipment_list") + return redirect("shipping:shipment_detail", pk=shipment.pk) + + +@login_required +def shipment_detail(request, pk): + shipment = get_object_or_404(Shipment.objects.select_related("order"), pk=pk) + return render(request, "shipping/detail.html", {"shipment": shipment}) + + +@login_required +@require_POST +def shipment_buy(request, pk): + shipment = get_object_or_404(Shipment, pk=pk) + rate_id = (request.POST.get("rate_id") or "").strip() + try: + buy_label(shipment, rate_id=rate_id) + except ShippingError as exc: + messages.error(request, str(exc)) + except Exception as exc: # noqa: BLE001 + logger.exception("buy label failed") + messages.error(request, f"Could not buy label: {exc}") + else: + messages.success(request, f"Label created for {shipment.order.number}.") + return redirect("shipping:shipment_detail", pk=shipment.pk) + + +@login_required +@require_http_methods(["GET"]) +def pirate_ship_export(request): + body = pirate_ship_csv() + response = HttpResponse(body, content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"' + return response diff --git a/site/shop/__init__.py b/site/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/shop/admin.py b/site/shop/admin.py new file mode 100644 index 0000000..ae6377c --- /dev/null +++ b/site/shop/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from shop.models import Order, OrderItem, Product + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ("name", "sku", "price", "stock_qty", "fulfillment", "is_published") + list_filter = ("fulfillment", "is_published") + search_fields = ("name", "sku") + prepopulated_fields = {"slug": ("name",)} + + +class OrderItemInline(admin.TabularInline): + model = OrderItem + extra = 0 + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ("number", "email", "amount", "status", "created_at") + list_filter = ("status",) + search_fields = ("number", "email", "customer_name") + inlines = [OrderItemInline] diff --git a/site/shop/apps.py b/site/shop/apps.py new file mode 100644 index 0000000..acd925a --- /dev/null +++ b/site/shop/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class ShopConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "shop" + verbose_name = "E-commerce" + + def ready(self): + from shop import hooks + + hooks.register() diff --git a/site/shop/hooks.py b/site/shop/hooks.py new file mode 100644 index 0000000..1479597 --- /dev/null +++ b/site/shop/hooks.py @@ -0,0 +1,42 @@ +from core.registry import ( + register_dashboard_collector, + register_feature, + register_portal_nav, + register_public_nav, +) + + +def register() -> None: + register_feature("shop") + register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30) + register_portal_nav( + section="shop_products", + label="Products", + url_name="shop_portal:product_list", + group="Retail", + order=10, + ) + register_portal_nav( + section="shop_orders", + label="Orders", + url_name="shop_portal:order_list", + group="Retail", + order=20, + ) + register_dashboard_collector(_dashboard) + + +def _dashboard(request) -> dict: + from shop.models import Order, Product + + return { + "open_shop_orders": Order.objects.filter( + status__in=[Order.Status.OPEN, Order.Status.PAID] + ).count(), + "low_stock_products": Product.objects.filter( + is_published=True, + track_inventory=True, + fulfillment=Product.Fulfillment.STOCKED, + stock_qty__lte=3, + ).count(), + } diff --git a/site/shop/migrations/0001_initial.py b/site/shop/migrations/0001_initial.py new file mode 100644 index 0000000..5e35ce2 --- /dev/null +++ b/site/shop/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 6.1 on 2026-09-06 11:17 + +import django.db.models.deletion +import uuid +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('number', models.CharField(max_length=32, unique=True)), + ('email', models.EmailField(max_length=254)), + ('customer_name', models.CharField(blank=True, max_length=200)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('cancelled', 'Cancelled')], default='draft', max_length=16)), + ('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)), + ('currency', models.CharField(default='usd', max_length=8)), + ('shipping_address', models.JSONField(blank=True, default=dict)), + ('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)), + ('hosted_checkout_url', models.URLField(blank=True)), + ('paid_at', models.DateTimeField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('slug', models.SlugField(max_length=220, unique=True)), + ('sku', models.CharField(max_length=64, unique=True)), + ('description', models.TextField(blank=True)), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('currency', models.CharField(default='usd', max_length=8)), + ('fulfillment', models.CharField(choices=[('stocked', 'On-hand stock'), ('made_to_order', 'Made to order')], default='stocked', max_length=16)), + ('stock_qty', models.IntegerField(default=0)), + ('print_minutes', models.PositiveIntegerField(default=0, help_text='Estimated print time per unit (made-to-order).')), + ('filament_grams', models.PositiveIntegerField(default=0)), + ('is_published', models.BooleanField(default=False)), + ('track_inventory', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='OrderItem', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('sku', models.CharField(max_length=64)), + ('quantity', models.PositiveIntegerField(default=1)), + ('unit_price', models.DecimalField(decimal_places=2, max_digits=10)), + ('print_minutes', models.PositiveIntegerField(default=0)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.order')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='shop.product')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + ] diff --git a/site/shop/migrations/__init__.py b/site/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/shop/models.py b/site/shop/models.py new file mode 100644 index 0000000..a1aa4bf --- /dev/null +++ b/site/shop/models.py @@ -0,0 +1,118 @@ +from decimal import Decimal + +from django.db import models +from django.urls import reverse +from django.utils.text import slugify + +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class Product(UUIDPrimaryKeyModel, TimeStampedModel): + class Fulfillment(models.TextChoices): + STOCKED = "stocked", "On-hand stock" + MADE_TO_ORDER = "made_to_order", "Made to order" + + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=220, unique=True) + sku = models.CharField(max_length=64, unique=True) + description = models.TextField(blank=True) + price = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=8, default="usd") + fulfillment = models.CharField( + max_length=16, + choices=Fulfillment.choices, + default=Fulfillment.STOCKED, + ) + stock_qty = models.IntegerField(default=0) + print_minutes = models.PositiveIntegerField( + default=0, + help_text="Estimated print time per unit (made-to-order).", + ) + filament_grams = models.PositiveIntegerField(default=0) + is_published = models.BooleanField(default=False) + track_inventory = models.BooleanField(default=True) + + class Meta: + ordering = ["name"] + + def __str__(self) -> str: + return f"{self.name} ({self.sku})" + + def get_absolute_url(self) -> str: + return reverse("shop:detail", kwargs={"slug": self.slug}) + + @property + def amount_cents(self) -> int: + return int((self.price * Decimal("100")).quantize(Decimal("1"))) + + def save(self, *args, **kwargs): + if not self.slug: + base = slugify(self.name)[:200] or "product" + slug = base + n = 2 + while Product.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base}-{n}" + n += 1 + self.slug = slug + if not self.sku: + self.sku = (self.slug or "sku").upper().replace("-", "")[:64] + super().save(*args, **kwargs) + + +class Order(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + OPEN = "open", "Open" + PAID = "paid", "Paid" + FULFILLED = "fulfilled", "Fulfilled" + CANCELLED = "cancelled", "Cancelled" + + number = models.CharField(max_length=32, unique=True) + email = models.EmailField() + customer_name = models.CharField(max_length=200, blank=True) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + amount = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0")) + currency = models.CharField(max_length=8, default="usd") + shipping_address = models.JSONField(default=dict, blank=True) + stripe_checkout_session_id = models.CharField(max_length=255, blank=True) + hosted_checkout_url = models.URLField(blank=True) + paid_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.number} · {self.email} · {self.amount}" + + @property + def amount_cents(self) -> int: + return int((self.amount * Decimal("100")).quantize(Decimal("1"))) + + +class OrderItem(UUIDPrimaryKeyModel, TimeStampedModel): + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items") + product = models.ForeignKey( + Product, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="order_items", + ) + name = models.CharField(max_length=200) + sku = models.CharField(max_length=64) + quantity = models.PositiveIntegerField(default=1) + unit_price = models.DecimalField(max_digits=10, decimal_places=2) + print_minutes = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["created_at"] + + def __str__(self) -> str: + return f"{self.quantity}× {self.name}" + + @property + def line_total(self) -> Decimal: + return self.unit_price * self.quantity diff --git a/site/shop/portal_urls.py b/site/shop/portal_urls.py new file mode 100644 index 0000000..a9b8434 --- /dev/null +++ b/site/shop/portal_urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from shop import views + +app_name = "shop_portal" + +urlpatterns = [ + path("products/", views.portal_product_list, name="product_list"), + path("products/new/", views.portal_product_edit, name="product_new"), + path("products//", views.portal_product_edit, name="product_edit"), + path( + "products//stock/", + views.portal_stock_adjust, + name="product_stock", + ), + path("orders/", views.portal_order_list, name="order_list"), + path("orders//", views.portal_order_detail, name="order_detail"), + path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"), +] diff --git a/site/shop/public_urls.py b/site/shop/public_urls.py new file mode 100644 index 0000000..dc4f327 --- /dev/null +++ b/site/shop/public_urls.py @@ -0,0 +1,16 @@ +from django.urls import path + +from shop import views + +app_name = "shop" + +urlpatterns = [ + path("", views.product_list, name="list"), + path("cart/", views.cart_view, name="cart"), + path("cart/add//", views.cart_add, name="cart_add"), + path("cart/update//", views.cart_update, name="cart_update"), + path("checkout/", views.checkout, name="checkout"), + path("checkout//success/", views.checkout_success, name="checkout_success"), + path("checkout//cancel/", views.checkout_cancel, name="checkout_cancel"), + path("/", views.product_detail, name="detail"), +] diff --git a/site/shop/services.py b/site/shop/services.py new file mode 100644 index 0000000..bb8639b --- /dev/null +++ b/site/shop/services.py @@ -0,0 +1,305 @@ +"""Catalog, cart, inventory, and Stripe checkout for FEATURE_SHOP.""" + +from __future__ import annotations + +import logging +from datetime import date +from decimal import Decimal + +from django.conf import settings +from django.db import transaction +from django.db.models import F, Sum +from django.utils import timezone + +from shop.models import Order, OrderItem, Product + +logger = logging.getLogger(__name__) + +CART_SESSION_KEY = "shop_cart" + + +class ShopError(RuntimeError): + pass + + +def _stripe(): + secret = (settings.STRIPE_SECRET_KEY or "").strip() + if not secret: + raise ShopError("STRIPE_SECRET_KEY is not configured") + try: + import stripe + except ImportError as exc: + raise ShopError("stripe package is not installed") from exc + stripe.api_key = secret + return stripe + + +def next_order_number() -> str: + today = date.today().strftime("%Y%m%d") + prefix = f"ORD-{today}-" + existing = Order.objects.filter(number__startswith=prefix).count() + return f"{prefix}{existing + 1:03d}" + + +def get_cart(session) -> dict[str, int]: + raw = session.get(CART_SESSION_KEY) or {} + cart: dict[str, int] = {} + for key, qty in raw.items(): + try: + n = int(qty) + except (TypeError, ValueError): + continue + if n > 0: + cart[str(key)] = n + return cart + + +def save_cart(session, cart: dict[str, int]) -> None: + session[CART_SESSION_KEY] = cart + session.modified = True + + +def add_to_cart(session, product: Product, quantity: int = 1) -> dict[str, int]: + if quantity < 1: + raise ShopError("Quantity must be at least 1.") + cart = get_cart(session) + pid = str(product.pk) + cart[pid] = cart.get(pid, 0) + quantity + save_cart(session, cart) + return cart + + +def set_cart_qty(session, product: Product, quantity: int) -> dict[str, int]: + cart = get_cart(session) + pid = str(product.pk) + if quantity < 1: + cart.pop(pid, None) + else: + cart[pid] = quantity + save_cart(session, cart) + return cart + + +def cart_lines(session) -> list[dict]: + cart = get_cart(session) + products = { + str(p.pk): p + for p in Product.objects.filter(pk__in=cart.keys(), is_published=True) + } + lines = [] + for pid, qty in cart.items(): + product = products.get(pid) + if not product: + continue + lines.append( + { + "product": product, + "quantity": qty, + "unit_price": product.price, + "line_total": product.price * qty, + } + ) + return lines + + +def cart_total(lines: list[dict]) -> Decimal: + return sum((line["line_total"] for line in lines), Decimal("0")) + + +def queued_print_minutes() -> int: + total = ( + OrderItem.objects.filter( + order__status__in=[Order.Status.OPEN, Order.Status.PAID], + product__fulfillment=Product.Fulfillment.MADE_TO_ORDER, + ).aggregate(total=Sum(F("print_minutes") * F("quantity")))["total"] + or 0 + ) + return int(total) + + +def available_qty(product: Product) -> int | None: + """Units that can be sold. None means unlimited (untracked made-to-order).""" + if not product.track_inventory: + return None + if product.fulfillment == Product.Fulfillment.STOCKED: + return max(product.stock_qty, 0) + limit = int(getattr(settings, "SHOP_PRINT_QUEUE_LIMIT_MINUTES", 0) or 0) + if not limit or not product.print_minutes: + return None + remaining_minutes = max(limit - queued_print_minutes(), 0) + return remaining_minutes // product.print_minutes + + +def assert_can_sell(product: Product, quantity: int) -> None: + if quantity < 1: + raise ShopError("Quantity must be at least 1.") + if not product.is_published: + raise ShopError("This product is not available.") + avail = available_qty(product) + if avail is not None and quantity > avail: + raise ShopError(f"Only {avail} of {product.name} available.") + + +def adjust_stock(product: Product, delta: int) -> Product: + """Increment (positive) or decrement (negative) on-hand stock.""" + product.stock_qty = product.stock_qty + delta + if product.stock_qty < 0: + raise ShopError(f"Insufficient stock for {product.sku}.") + product.save(update_fields=["stock_qty", "updated_at"]) + return product + + +def create_order_from_cart( + session, + *, + email: str, + customer_name: str = "", + shipping_address: dict | None = None, + notes: str = "", +) -> Order: + lines = cart_lines(session) + if not lines: + raise ShopError("Cart is empty.") + email = (email or "").strip() + if not email: + raise ShopError("Email is required.") + for line in lines: + assert_can_sell(line["product"], line["quantity"]) + currency = (settings.STRIPE_CURRENCY or "usd").lower() + with transaction.atomic(): + order = Order.objects.create( + number=next_order_number(), + email=email, + customer_name=(customer_name or "").strip(), + status=Order.Status.DRAFT, + amount=cart_total(lines), + currency=currency, + shipping_address=shipping_address or {}, + notes=notes, + ) + for line in lines: + product = line["product"] + OrderItem.objects.create( + order=order, + product=product, + name=product.name, + sku=product.sku, + quantity=line["quantity"], + unit_price=product.price, + print_minutes=product.print_minutes, + ) + return order + + +def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str: + stripe = _stripe() + line_items = [ + { + "quantity": item.quantity, + "price_data": { + "currency": (order.currency or "usd").lower(), + "unit_amount": int( + (item.unit_price * Decimal("100")).quantize(Decimal("1")) + ), + "product_data": {"name": item.name}, + }, + } + for item in order.items.all() + ] + if not line_items: + raise ShopError("Order has no items.") + session = stripe.checkout.Session.create( + mode="payment", + customer_email=order.email or None, + line_items=line_items, + metadata={"shop_order_id": str(order.pk), "order_number": order.number}, + success_url=success_url, + cancel_url=cancel_url, + ) + order.stripe_checkout_session_id = session.id + order.hosted_checkout_url = session.url or "" + order.status = Order.Status.OPEN + order.save( + update_fields=[ + "stripe_checkout_session_id", + "hosted_checkout_url", + "status", + "updated_at", + ] + ) + return session.url or "" + + +def _reserve_inventory(order: Order) -> None: + for item in order.items.select_related("product"): + product = item.product + if product is None or not product.track_inventory: + continue + if product.fulfillment == Product.Fulfillment.STOCKED: + adjust_stock(product, -item.quantity) + + +def _notify_pos(order: Order) -> None: + from django.apps import apps + + if not apps.is_installed("pos_sync"): + return + from pos_sync.services import enqueue_online_sale + + enqueue_online_sale(order) + + +def mark_paid(order: Order, *, stripe_id: str = "") -> None: + if order.status == Order.Status.PAID: + return + with transaction.atomic(): + locked = Order.objects.select_for_update().get(pk=order.pk) + if locked.status == Order.Status.PAID: + return + _reserve_inventory(locked) + locked.status = Order.Status.PAID + locked.paid_at = timezone.now() + locked.save(update_fields=["status", "paid_at", "updated_at"]) + order.refresh_from_db() + try: + send_order_email(order) + except Exception: + logger.exception("order confirmation email failed for %s", order.number) + try: + _notify_pos(order) + except Exception: + logger.exception("POS notify failed for %s", order.number) + + +def send_order_email(order: Order) -> bool: + to_email = (order.email or "").strip() + if not to_email: + raise ShopError("Order has no email address") + from django.core.mail import EmailMultiAlternatives + + name = order.customer_name or "there" + amount = f"{order.amount} {order.currency.upper()}" + lines = "\n".join( + f"- {item.quantity}× {item.name} ({item.sku})" for item in order.items.all() + ) + subject = f"Order {order.number} from {settings.SITE_NAME}" + text = ( + f"Hi {name},\n\n" + f"We received order {order.number} for {amount}.\n\n" + f"{lines}\n\nThank you.\n" + ) + html = ( + f"

Hi {name},

" + f"

We received order {order.number} for " + f"{amount}.

" + f"
{lines}
" + ) + mail = EmailMultiAlternatives( + subject=subject, + body=text, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + ) + mail.attach_alternative(html, "text/html") + mail.send(fail_silently=False) + return True diff --git a/site/shop/templates/shop/cancel.html b/site/shop/templates/shop/cancel.html new file mode 100644 index 0000000..263f88d --- /dev/null +++ b/site/shop/templates/shop/cancel.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Checkout cancelled{% endblock %} +{% block content %} +
+

Checkout cancelled

+

Order {{ order.number }} was not paid. You can return to the cart.

+
+{% endblock %} diff --git a/site/shop/templates/shop/cart.html b/site/shop/templates/shop/cart.html new file mode 100644 index 0000000..11ac102 --- /dev/null +++ b/site/shop/templates/shop/cart.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Cart · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

Cart

+ {% for line in lines %} +

+ {{ line.product.name }} + · {{ line.quantity }} × {{ line.unit_price }} = {{ line.line_total }} +

+
+ {% csrf_token %} + + +
+ {% empty %} +

Cart is empty.

+ {% endfor %} + {% if lines %} +

Total: {{ total }}

+

Checkout

+ {% endif %} +
+
+{% endblock %} diff --git a/site/shop/templates/shop/checkout.html b/site/shop/templates/shop/checkout.html new file mode 100644 index 0000000..b68f0f5 --- /dev/null +++ b/site/shop/templates/shop/checkout.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Checkout · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

Checkout

+

Total: {{ total }}

+
+ {% csrf_token %} +

+

+

+

+

+

+

+ +
+
+
+{% endblock %} diff --git a/site/shop/templates/shop/detail.html b/site/shop/templates/shop/detail.html new file mode 100644 index 0000000..6c80131 --- /dev/null +++ b/site/shop/templates/shop/detail.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}{{ product.name }} · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

← Shop

+

{{ product.name }}

+

{{ product.price }} {{ product.currency|upper }} · {{ product.sku }}

+
{{ product.description|linebreaks }}
+ {% if available is not None %} +

{{ available }} in stock

+ {% endif %} +
+ {% csrf_token %} + + +
+
+
+{% endblock %} diff --git a/site/shop/templates/shop/list.html b/site/shop/templates/shop/list.html new file mode 100644 index 0000000..ee6a71d --- /dev/null +++ b/site/shop/templates/shop/list.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Shop · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

Shop

+

View cart

+ {% for product in products %} +
+

{{ product.name }}

+

{{ product.price }} {{ product.currency|upper }} · {{ product.sku }}

+

{{ product.description|truncatewords:40 }}

+
+ {% empty %} +

No products yet.

+ {% endfor %} +
+
+{% endblock %} diff --git a/site/shop/templates/shop/portal/order_detail.html b/site/shop/templates/shop/portal/order_detail.html new file mode 100644 index 0000000..b8ba9e8 --- /dev/null +++ b/site/shop/templates/shop/portal/order_detail.html @@ -0,0 +1,21 @@ +{% extends "portal_base.html" %} +{% block title %}{{ order.number }} · Portal{% endblock %} +{% block topbar_title %}{{ order.number }}{% endblock %} +{% block portal_content %} +

{{ order.email }} · {{ order.amount }} {{ order.currency|upper }} · {{ order.get_status_display }}

+{% if order.customer_name %}

{{ order.customer_name }}

{% endif %} + + + + {% for item in order.items.all %} + + + + + + + {% endfor %} + +
ItemSKUQtyPrice
{{ item.name }}{{ item.sku }}{{ item.quantity }}{{ item.unit_price }}
+

← All orders

+{% endblock %} diff --git a/site/shop/templates/shop/portal/orders.html b/site/shop/templates/shop/portal/orders.html new file mode 100644 index 0000000..63af63f --- /dev/null +++ b/site/shop/templates/shop/portal/orders.html @@ -0,0 +1,20 @@ +{% extends "portal_base.html" %} +{% block title %}Orders · Portal{% endblock %} +{% block topbar_title %}Orders{% endblock %} +{% block portal_content %} + + + + {% for order in orders %} + + + + + + + {% empty %} + + {% endfor %} + +
NumberEmailAmountStatus
{{ order.number }}{{ order.email }}{{ order.amount }} {{ order.currency|upper }}{{ order.get_status_display }}
No orders yet.
+{% endblock %} diff --git a/site/shop/templates/shop/portal/product_edit.html b/site/shop/templates/shop/portal/product_edit.html new file mode 100644 index 0000000..e2d7d4d --- /dev/null +++ b/site/shop/templates/shop/portal/product_edit.html @@ -0,0 +1,33 @@ +{% extends "portal_base.html" %} +{% block title %}{% if product %}Edit{% else %}New{% endif %} product · Portal{% endblock %} +{% block topbar_title %}{% if product %}Edit product{% else %}New product{% endif %}{% endblock %} +{% block portal_content %} +
+ {% csrf_token %} +
+
+
+
+
+ + +
+
+
+
+
+ + + +
+{% if product %} +
+ {% csrf_token %} +
+ +
+{% endif %} +{% endblock %} diff --git a/site/shop/templates/shop/portal/products.html b/site/shop/templates/shop/portal/products.html new file mode 100644 index 0000000..8ddde93 --- /dev/null +++ b/site/shop/templates/shop/portal/products.html @@ -0,0 +1,22 @@ +{% extends "portal_base.html" %} +{% block title %}Products · Portal{% endblock %} +{% block topbar_title %}Products{% endblock %} +{% block portal_content %} +

New product

+ + + + {% for product in products %} + + + + + + + + {% empty %} + + {% endfor %} + +
NameSKUPriceStockStatus
{{ product.name }}{{ product.sku }}{{ product.price }} {{ product.currency|upper }}{{ product.stock_qty }}{% if product.is_published %}Published{% else %}Draft{% endif %}
No products yet.
+{% endblock %} diff --git a/site/shop/templates/shop/success.html b/site/shop/templates/shop/success.html new file mode 100644 index 0000000..b94ca08 --- /dev/null +++ b/site/shop/templates/shop/success.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Order {{ order.number }}{% endblock %} +{% block content %} +
+

Thank you

+

Order {{ order.number }} is {{ order.get_status_display|lower }}.

+

A confirmation will go to {{ order.email }}.

+
+{% endblock %} diff --git a/site/shop/tests.py b/site/shop/tests.py new file mode 100644 index 0000000..204f93e --- /dev/null +++ b/site/shop/tests.py @@ -0,0 +1,147 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import Client, TestCase, override_settings +from django.urls import reverse + +from shop.models import Order, Product +from shop.services import ( + ShopError, + add_to_cart, + adjust_stock, + available_qty, + create_order_from_cart, + mark_paid, + next_order_number, +) + + +def _product(**kwargs): + defaults = dict( + name="Dragon figurine", + sku="TOY-001", + price=Decimal("18.00"), + stock_qty=5, + is_published=True, + fulfillment=Product.Fulfillment.STOCKED, + ) + defaults.update(kwargs) + return Product.objects.create(**defaults) + + +class ShopPublicTests(TestCase): + def test_list_hides_unpublished(self): + _product(name="Live", sku="LIVE-1") + _product(name="Draft", sku="DRAFT-1", is_published=False) + response = Client().get(reverse("shop:list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Live") + self.assertNotContains(response, "Draft") + + def test_add_to_cart_then_checkout_form(self): + product = _product() + client = Client() + response = client.post( + reverse("shop:cart_add", kwargs={"slug": product.slug}), + {"quantity": "2"}, + ) + self.assertEqual(response.status_code, 302) + cart = client.get(reverse("shop:cart")) + self.assertContains(cart, "Dragon figurine") + self.assertContains(cart, "36.00") + checkout = client.get(reverse("shop:checkout")) + self.assertEqual(checkout.status_code, 200) + self.assertContains(checkout, "Pay with Stripe") + + +class ShopInventoryTests(TestCase): + def test_stocked_availability_and_adjust(self): + product = _product(stock_qty=4) + self.assertEqual(available_qty(product), 4) + adjust_stock(product, -2) + product.refresh_from_db() + self.assertEqual(product.stock_qty, 2) + with self.assertRaises(ShopError): + adjust_stock(product, -5) + + def test_mark_paid_decrements_stock_and_emails(self): + product = _product(stock_qty=3) + session = self.client.session + add_to_cart(session, product, 2) + session.save() + order = create_order_from_cart( + self.client.session, email="buyer@example.com", customer_name="Pat" + ) + mark_paid(order) + product.refresh_from_db() + self.assertEqual(product.stock_qty, 1) + order.refresh_from_db() + self.assertEqual(order.status, Order.Status.PAID) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(order.number, mail.outbox[0].subject) + + def test_insufficient_stock_blocks_order(self): + product = _product(stock_qty=1) + session = self.client.session + add_to_cart(session, product, 3) + session.save() + with self.assertRaises(ShopError): + create_order_from_cart(self.client.session, email="buyer@example.com") + + @override_settings(SHOP_PRINT_QUEUE_LIMIT_MINUTES=60) + def test_made_to_order_queue_limit(self): + product = _product( + sku="MTO-1", + fulfillment=Product.Fulfillment.MADE_TO_ORDER, + print_minutes=30, + stock_qty=0, + ) + self.assertEqual(available_qty(product), 2) + + def test_next_number_increments(self): + n1 = next_order_number() + Order.objects.create( + number=n1, + email="a@example.com", + amount=Decimal("1.00"), + ) + n2 = next_order_number() + self.assertNotEqual(n1, n2) + + +class ShopPortalTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user("merchant", password="test-pass-123") + self.client = Client() + self.client.login(username="merchant", password="test-pass-123") + + def test_list_requires_login(self): + anon = Client() + self.assertEqual(anon.get(reverse("shop_portal:product_list")).status_code, 302) + + def test_create_and_adjust_stock(self): + response = self.client.post( + reverse("shop_portal:product_new"), + { + "name": "Booster box", + "sku": "TCG-BOX", + "price": "89.99", + "stock_qty": "10", + "fulfillment": "stocked", + "is_published": "on", + "track_inventory": "on", + }, + ) + self.assertEqual(response.status_code, 302) + product = Product.objects.get(sku="TCG-BOX") + self.assertTrue(product.is_published) + self.assertEqual(product.slug, "booster-box") + adjust = self.client.post( + reverse("shop_portal:product_stock", kwargs={"pk": product.pk}), + {"delta": "-3"}, + ) + self.assertEqual(adjust.status_code, 302) + product.refresh_from_db() + self.assertEqual(product.stock_qty, 7) diff --git a/site/shop/views.py b/site/shop/views.py new file mode 100644 index 0000000..38b30bc --- /dev/null +++ b/site/shop/views.py @@ -0,0 +1,268 @@ +import logging +from decimal import Decimal, InvalidOperation + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.text import slugify +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods, require_POST + +from contacts.models import Contact +from shop.models import Order, Product +from shop.services import ( + ShopError, + add_to_cart, + adjust_stock, + available_qty, + cart_lines, + cart_total, + create_checkout_session, + create_order_from_cart, + mark_paid, + save_cart, + set_cart_qty, +) + +logger = logging.getLogger(__name__) + + +def _site_base(request) -> str: + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if base: + return base + return request.build_absolute_uri("/").rstrip("/") + + +def product_list(request): + products = Product.objects.filter(is_published=True) + return render(request, "shop/list.html", {"products": products}) + + +def product_detail(request, slug): + product = get_object_or_404(Product, slug=slug, is_published=True) + return render( + request, + "shop/detail.html", + {"product": product, "available": available_qty(product)}, + ) + + +def cart_view(request): + lines = cart_lines(request.session) + return render( + request, + "shop/cart.html", + {"lines": lines, "total": cart_total(lines)}, + ) + + +@require_POST +def cart_add(request, slug): + product = get_object_or_404(Product, slug=slug, is_published=True) + try: + qty = int(request.POST.get("quantity") or "1") + except ValueError: + qty = 1 + try: + add_to_cart(request.session, product, qty) + except ShopError as exc: + messages.error(request, str(exc)) + return redirect("shop:detail", slug=product.slug) + messages.success(request, f"Added {product.name} to cart.") + return redirect("shop:cart") + + +@require_POST +def cart_update(request, slug): + product = get_object_or_404(Product, slug=slug) + try: + qty = int(request.POST.get("quantity") or "0") + except ValueError: + qty = 0 + set_cart_qty(request.session, product, qty) + return redirect("shop:cart") + + +@require_http_methods(["GET", "POST"]) +def checkout(request): + lines = cart_lines(request.session) + if not lines: + messages.error(request, "Cart is empty.") + return redirect("shop:cart") + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + name = (request.POST.get("customer_name") or "").strip() + address = Contact.make_postal_address( + line1=request.POST.get("address_line1") or "", + line2=request.POST.get("address_line2") or "", + city=request.POST.get("address_city") or "", + state=request.POST.get("address_state") or "", + zip_code=request.POST.get("address_zip") or "", + ) + try: + order = create_order_from_cart( + request.session, + email=email, + customer_name=name, + shipping_address=address, + ) + base = _site_base(request) + success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk}) + cancel = base + reverse("shop:checkout_cancel", kwargs={"pk": order.pk}) + url = create_checkout_session( + order, + success_url=success + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel, + ) + except ShopError as exc: + messages.error(request, str(exc)) + except Exception as exc: # noqa: BLE001 + logger.exception("shop checkout failed") + messages.error(request, f"Could not start checkout: {exc}") + else: + save_cart(request.session, {}) + return redirect(url) + return render( + request, + "shop/checkout.html", + {"lines": lines, "total": cart_total(lines)}, + ) + + +def checkout_success(request, pk): + order = get_object_or_404(Order, pk=pk) + return render(request, "shop/success.html", {"order": order}) + + +def checkout_cancel(request, pk): + order = get_object_or_404(Order, pk=pk) + return render(request, "shop/cancel.html", {"order": order}) + + +@login_required +def portal_product_list(request): + products = Product.objects.all() + return render(request, "shop/portal/products.html", {"products": products}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def portal_product_edit(request, pk=None): + product = get_object_or_404(Product, pk=pk) if pk else None + if request.method == "POST": + name = (request.POST.get("name") or "").strip() + sku = (request.POST.get("sku") or "").strip() + description = (request.POST.get("description") or "").strip() + slug = (request.POST.get("slug") or "").strip() + fulfillment = request.POST.get("fulfillment") or Product.Fulfillment.STOCKED + errors = [] + if not name: + errors.append("Name is required.") + try: + price = Decimal(request.POST.get("price") or "") + if price < 0: + raise InvalidOperation + except Exception: + price = None + errors.append("Enter a valid price.") + try: + stock_qty = int(request.POST.get("stock_qty") or "0") + except ValueError: + stock_qty = 0 + errors.append("Stock must be a number.") + try: + print_minutes = int(request.POST.get("print_minutes") or "0") + filament_grams = int(request.POST.get("filament_grams") or "0") + except ValueError: + print_minutes = 0 + filament_grams = 0 + if errors: + for err in errors: + messages.error(request, err) + else: + if product is None: + product = Product() + product.name = name + product.sku = sku + product.description = description + product.slug = slugify(slug)[:220] if slug else "" + product.price = price + product.currency = (settings.STRIPE_CURRENCY or "usd").lower() + product.fulfillment = fulfillment + product.stock_qty = stock_qty + product.print_minutes = max(print_minutes, 0) + product.filament_grams = max(filament_grams, 0) + product.is_published = request.POST.get("is_published") == "on" + product.track_inventory = request.POST.get("track_inventory") == "on" + product.save() + messages.success(request, f"Saved {product.name}.") + return redirect("shop_portal:product_list") + return render(request, "shop/portal/product_edit.html", {"product": product}) + + +@login_required +@require_POST +def portal_stock_adjust(request, pk): + product = get_object_or_404(Product, pk=pk) + try: + delta = int(request.POST.get("delta") or "0") + except ValueError: + messages.error(request, "Enter a whole-number adjustment.") + return redirect("shop_portal:product_edit", pk=product.pk) + try: + adjust_stock(product, delta) + except ShopError as exc: + messages.error(request, str(exc)) + else: + messages.success(request, f"{product.sku} stock is now {product.stock_qty}.") + return redirect("shop_portal:product_edit", pk=product.pk) + + +@login_required +def portal_order_list(request): + orders = Order.objects.all()[:200] + return render(request, "shop/portal/orders.html", {"orders": orders}) + + +@login_required +def portal_order_detail(request, pk): + order = get_object_or_404(Order.objects.prefetch_related("items"), pk=pk) + return render(request, "shop/portal/order_detail.html", {"order": order}) + + +@csrf_exempt +@require_http_methods(["POST"]) +def stripe_webhook(request): + secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip() + if not secret: + logger.error("STRIPE_WEBHOOK_SECRET unset") + return HttpResponseBadRequest("webhook not configured") + try: + import stripe + except ImportError: + return HttpResponseBadRequest("stripe not installed") + sig = request.headers.get("Stripe-Signature", "") + try: + event = stripe.Webhook.construct_event(request.body, sig, secret) + except Exception: + logger.exception("shop stripe webhook signature failed") + return HttpResponseBadRequest("invalid signature") + + obj = event.get("data", {}).get("object", {}) or {} + if event.get("type") != "checkout.session.completed": + return HttpResponse("ok") + order_id = (obj.get("metadata") or {}).get("shop_order_id") or "" + order = None + if order_id: + order = Order.objects.filter(pk=order_id).first() + if order is None: + session_id = obj.get("id") or "" + order = Order.objects.filter(stripe_checkout_session_id=session_id).first() + if order and order.status != Order.Status.PAID: + mark_paid(order, stripe_id=obj.get("id") or "") + logger.info("shop order %s marked paid", order.number) + return HttpResponse("ok")