From c9b81ceed73916d9c9b8e6a272790d98b6116417 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 7 Sep 2026 06:37:52 -0500 Subject: [PATCH] Add customer accounts, shipment tracking, and purchase reviews. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shoppers can register, save shipping details, and view order history while cards stay on Stripe. EasyPost tracker updates (including numbers from Pirate Ship) and 1–5 star reviews are limited to buyers. The contact form now only asks for email and a message. --- .env.example | 2 + .env.prod.example | 2 + site/accounts/admin.py | 9 +- site/accounts/customer_urls.py | 26 +++ site/accounts/forms.py | 160 ++++++++++++++++ site/accounts/middleware.py | 23 +++ .../migrations/0002_customerprofile.py | 49 +++++ site/accounts/models.py | 16 ++ site/accounts/services.py | 22 +++ .../templates/accounts/account_base.html | 24 +++ .../templates/accounts/customer_login.html | 40 ++++ site/accounts/templates/accounts/profile.html | 84 +++++++++ .../accounts/templates/accounts/register.html | 60 ++++++ site/accounts/tests.py | 88 +++++++++ site/accounts/urls.py | 4 +- site/accounts/views.py | 119 ++++++++++++ site/analytics/tests.py | 2 +- site/blog/tests.py | 2 +- site/contacts/tests_merge.py | 39 ++-- site/core/tests.py | 5 + site/core/tests_campaign_utm.py | 2 +- site/directmail/tests.py | 4 +- site/email_sms/tests.py | 14 +- site/events/tests.py | 2 +- site/payments/tests.py | 2 +- site/pos_sync/tests.py | 2 +- site/print_forge/settings/base.py | 2 + .../static/css/theme-overrides.css | 17 ++ site/print_forge/templates/base.html | 16 +- site/print_forge/urls.py | 1 + site/public/forms.py | 79 -------- site/public/middleware.py | 3 + site/public/notifications.py | 23 +-- .../templates/emails/contact_email.html | 9 - .../public/templates/emails/contact_email.txt | 6 +- site/public/templates/public/contact.html | 72 +------- site/public/views.py | 29 +-- site/shipping/hooks.py | 9 + .../migrations/0002_shipment_tracking.py | 54 ++++++ site/shipping/models.py | 26 ++- site/shipping/services.py | 172 ++++++++++++++++++ site/shipping/templates/shipping/detail.html | 46 ++++- site/shipping/tests.py | 55 +++++- site/shipping/urls.py | 11 ++ site/shipping/views.py | 75 +++++++- site/shop/admin.py | 17 +- .../migrations/0005_order_user_and_review.py | 97 ++++++++++ site/shop/models.py | 44 +++++ site/shop/public_urls.py | 1 + site/shop/services.py | 101 ++++++++-- .../templates/shop/account/order_detail.html | 49 +++++ site/shop/templates/shop/account/orders.html | 25 +++ site/shop/templates/shop/checkout.html | 19 +- site/shop/templates/shop/detail.html | 51 ++++++ .../templates/shop/portal/order_detail.html | 21 +++ site/shop/templates/shop/success.html | 8 +- site/shop/tests.py | 100 +++++++++- site/shop/views.py | 136 +++++++++++++- site/social/tests.py | 4 +- 59 files changed, 1905 insertions(+), 275 deletions(-) create mode 100644 site/accounts/customer_urls.py create mode 100644 site/accounts/forms.py create mode 100644 site/accounts/middleware.py create mode 100644 site/accounts/migrations/0002_customerprofile.py create mode 100644 site/accounts/services.py create mode 100644 site/accounts/templates/accounts/account_base.html create mode 100644 site/accounts/templates/accounts/customer_login.html create mode 100644 site/accounts/templates/accounts/profile.html create mode 100644 site/accounts/templates/accounts/register.html create mode 100644 site/accounts/tests.py create mode 100644 site/accounts/views.py create mode 100644 site/shipping/migrations/0002_shipment_tracking.py create mode 100644 site/shop/migrations/0005_order_user_and_review.py create mode 100644 site/shop/templates/shop/account/order_detail.html create mode 100644 site/shop/templates/shop/account/orders.html diff --git a/.env.example b/.env.example index 8f607dd..8bdcab8 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,8 @@ POS_API_TOKEN= # Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works. EASYPOST_API_KEY= +# Optional. If set, EasyPost tracker webhooks must send it as X-Webhook-Secret or ?token=. +EASYPOST_WEBHOOK_SECRET= SHIP_FROM_LINE1= SHIP_FROM_CITY= SHIP_FROM_STATE= diff --git a/.env.prod.example b/.env.prod.example index 2e830f3..f223a7e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -77,6 +77,8 @@ POS_API_TOKEN= # Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP) EASYPOST_API_KEY= +# Optional. If set, EasyPost tracker webhooks must send it as X-Webhook-Secret or ?token=. +EASYPOST_WEBHOOK_SECRET= SHIP_FROM_LINE1= SHIP_FROM_CITY= SHIP_FROM_STATE= diff --git a/site/accounts/admin.py b/site/accounts/admin.py index 139290f..08fae00 100644 --- a/site/accounts/admin.py +++ b/site/accounts/admin.py @@ -1,9 +1,16 @@ from django.contrib import admin -from accounts.models import RealtorProfile +from accounts.models import CustomerProfile, RealtorProfile @admin.register(RealtorProfile) class RealtorProfileAdmin(admin.ModelAdmin): list_display = ("user", "display_name", "phone") search_fields = ("user__username", "display_name") + + +@admin.register(CustomerProfile) +class CustomerProfileAdmin(admin.ModelAdmin): + list_display = ("user", "phone", "stripe_customer_id") + search_fields = ("user__username", "user__email", "phone", "stripe_customer_id") + readonly_fields = ("stripe_customer_id",) diff --git a/site/accounts/customer_urls.py b/site/accounts/customer_urls.py new file mode 100644 index 0000000..2365d10 --- /dev/null +++ b/site/accounts/customer_urls.py @@ -0,0 +1,26 @@ +from django.apps import apps +from django.urls import path + +from accounts import views + +app_name = "account" + +urlpatterns = [ + path("", views.customer_home, name="home"), + path("login/", views.CustomerLoginView.as_view(), name="login"), + path("logout/", views.customer_logout, name="logout"), + path("register/", views.customer_register, name="register"), + path("profile/", views.customer_profile, name="profile"), +] + +if apps.is_installed("shop"): + from shop import views as shop_views + + urlpatterns += [ + path("orders/", shop_views.account_order_list, name="orders"), + path( + "orders//", + shop_views.account_order_detail, + name="order_detail", + ), + ] diff --git a/site/accounts/forms.py b/site/accounts/forms.py new file mode 100644 index 0000000..b27740a --- /dev/null +++ b/site/accounts/forms.py @@ -0,0 +1,160 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.core.exceptions import ValidationError + +from contacts.models import Contact + +User = get_user_model() + +_INPUT = {"class": "form-input"} + + +class CustomerRegisterForm(UserCreationForm): + email = forms.EmailField( + widget=forms.EmailInput(attrs={**_INPUT, "autocomplete": "email"}) + ) + first_name = forms.CharField( + max_length=150, + required=False, + widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "given-name"}), + ) + last_name = forms.CharField( + max_length=150, + required=False, + widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "family-name"}), + ) + + class Meta: + model = User + fields = ("email", "first_name", "last_name", "password1", "password2") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["password1"].widget.attrs.update(_INPUT) + self.fields["password2"].widget.attrs.update(_INPUT) + + def clean_email(self): + email = (self.cleaned_data.get("email") or "").strip().lower() + if not email: + raise ValidationError("Enter an email address.") + taken = User.objects.filter(email__iexact=email).exists() or User.objects.filter( + username__iexact=email + ).exists() + if taken: + raise ValidationError("An account with that email already exists.") + return email + + def save(self, commit=True): + user = super().save(commit=False) + email = self.cleaned_data["email"] + user.username = email + user.email = email + user.first_name = (self.cleaned_data.get("first_name") or "").strip() + user.last_name = (self.cleaned_data.get("last_name") or "").strip() + if commit: + user.save() + return user + + +class CustomerAuthenticationForm(AuthenticationForm): + username = forms.EmailField( + label="Email", + widget=forms.EmailInput( + attrs={**_INPUT, "id": "id_username", "autocomplete": "email"} + ), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["password"].widget.attrs.update( + {**_INPUT, "autocomplete": "current-password"} + ) + + +class CustomerProfileForm(forms.Form): + first_name = forms.CharField( + max_length=150, + required=False, + widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "given-name"}), + ) + last_name = forms.CharField( + max_length=150, + required=False, + widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "family-name"}), + ) + phone = forms.CharField( + max_length=32, + required=False, + widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "tel"}), + ) + address_line1 = forms.CharField( + max_length=200, + required=False, + label="Street address", + widget=forms.TextInput( + attrs={ + **_INPUT, + "autocomplete": "off", + "data-ac": "line1", + } + ), + ) + address_line2 = forms.CharField( + max_length=200, + required=False, + label="Apt / suite", + widget=forms.TextInput( + attrs={ + **_INPUT, + "autocomplete": "address-line2", + "data-ac": "line2", + } + ), + ) + address_city = forms.CharField( + max_length=100, + required=False, + label="City", + widget=forms.TextInput( + attrs={ + **_INPUT, + "autocomplete": "address-level2", + "data-ac": "city", + } + ), + ) + address_state = forms.CharField( + max_length=32, + required=False, + label="State", + widget=forms.TextInput( + attrs={ + **_INPUT, + "autocomplete": "address-level1", + "data-ac": "state", + } + ), + ) + address_zip = forms.CharField( + max_length=20, + required=False, + label="ZIP", + widget=forms.TextInput( + attrs={ + **_INPUT, + "autocomplete": "postal-code", + "data-ac": "zip", + } + ), + ) + + def shipping_address(self) -> dict: + data = self.cleaned_data + return Contact.make_postal_address( + line1=data.get("address_line1") or "", + line2=data.get("address_line2") or "", + city=data.get("address_city") or "", + state=data.get("address_state") or "", + zip_code=data.get("address_zip") or "", + ) diff --git a/site/accounts/middleware.py b/site/accounts/middleware.py new file mode 100644 index 0000000..8d08ff0 --- /dev/null +++ b/site/accounts/middleware.py @@ -0,0 +1,23 @@ +from django.shortcuts import redirect + + +class PortalStaffMiddleware: + """Keep customer accounts out of the staff portal. Webhooks stay public.""" + + PORTAL_PREFIX = "/portal/" + WEBHOOK_INFIX = "/webhooks/" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + path = request.path + if path.startswith(self.PORTAL_PREFIX) and self.WEBHOOK_INFIX not in path: + user = getattr(request, "user", None) + if ( + user is not None + and getattr(user, "is_authenticated", False) + and not getattr(user, "is_staff", False) + ): + return redirect("account:home") + return self.get_response(request) diff --git a/site/accounts/migrations/0002_customerprofile.py b/site/accounts/migrations/0002_customerprofile.py new file mode 100644 index 0000000..b4642c6 --- /dev/null +++ b/site/accounts/migrations/0002_customerprofile.py @@ -0,0 +1,49 @@ +# Generated by Django 6.1 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CustomerProfile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("phone", models.CharField(blank=True, max_length=32)), + ("shipping_address", models.JSONField(blank=True, default=dict)), + ( + "stripe_customer_id", + models.CharField(blank=True, max_length=255), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="customer_profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/site/accounts/models.py b/site/accounts/models.py index 5143b5f..43bd628 100644 --- a/site/accounts/models.py +++ b/site/accounts/models.py @@ -17,3 +17,19 @@ class RealtorProfile(TimeStampedModel): def __str__(self) -> str: return self.display_name or self.user.get_username() + + +class CustomerProfile(TimeStampedModel): + """Shop-buyer profile. Card numbers stay on Stripe; we only keep the customer id.""" + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="customer_profile", + ) + phone = models.CharField(max_length=32, blank=True) + shipping_address = models.JSONField(default=dict, blank=True) + stripe_customer_id = models.CharField(max_length=255, blank=True) + + def __str__(self) -> str: + return self.user.get_username() diff --git a/site/accounts/services.py b/site/accounts/services.py new file mode 100644 index 0000000..2fddeb5 --- /dev/null +++ b/site/accounts/services.py @@ -0,0 +1,22 @@ +from accounts.models import CustomerProfile + + +def get_customer_profile(user) -> CustomerProfile: + profile, _created = CustomerProfile.objects.get_or_create(user=user) + return profile + + +def claim_orders_for_user(user) -> int: + """Attach guest orders that used this email so history/reviews work after signup.""" + from django.apps import apps + + if not apps.is_installed("shop"): + return 0 + from shop.models import Order + + email = (user.email or user.username or "").strip() + if not email: + return 0 + return Order.objects.filter(user__isnull=True, email__iexact=email).update( + user=user + ) diff --git a/site/accounts/templates/accounts/account_base.html b/site/accounts/templates/accounts/account_base.html new file mode 100644 index 0000000..61f7f7e --- /dev/null +++ b/site/accounts/templates/accounts/account_base.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Account · {{ SITE_NAME }}{% endblock %} +{% block content %} + {% include "public/_breadcrumbs.html" with title="Account" %} +
+
+
+
+
Account
+ +
+
+ {% block account_content %}{% endblock %} +
+
+
+
+{% endblock %} diff --git a/site/accounts/templates/accounts/customer_login.html b/site/accounts/templates/accounts/customer_login.html new file mode 100644 index 0000000..ba9c07d --- /dev/null +++ b/site/accounts/templates/accounts/customer_login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Sign in · {{ SITE_NAME }}{% endblock %} +{% block content %} + {% include "public/_breadcrumbs.html" with title="Sign in" %} +
+
+
+
+

Sign in

+

View order history, shipping, and reviews.

+
+ {% csrf_token %} + {% if next %}{% endif %} + {{ form.non_field_errors }} +
+
+
+ + {{ form.username }} + {{ form.username.errors }} +
+
+
+
+ + {{ form.password }} + {{ form.password.errors }} +
+
+
+ +
+
+
+

New here? Create an account

+
+
+
+
+{% endblock %} diff --git a/site/accounts/templates/accounts/profile.html b/site/accounts/templates/accounts/profile.html new file mode 100644 index 0000000..188ea65 --- /dev/null +++ b/site/accounts/templates/accounts/profile.html @@ -0,0 +1,84 @@ +{% extends "accounts/account_base.html" %} +{% load static %} +{% block title %}Profile · {{ SITE_NAME }}{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block account_content %} +

Profile & shipping

+

Name, phone, and a default shipping address. Payment cards stay on Stripe — we only keep a Stripe customer id, never card numbers.

+ {% if profile.stripe_customer_id %} +

Stripe customer on file. Saved cards are offered at checkout by Stripe.

+ {% endif %} +
+ {% csrf_token %} + {{ form.non_field_errors }} +
+
+
+ + {{ form.first_name }} + {{ form.first_name.errors }} +
+
+
+
+ + {{ form.last_name }} + {{ form.last_name.errors }} +
+
+
+
+ + {{ form.phone }} + {{ form.phone.errors }} +
+
+
+

Shipping address

+
+
+
+ + {{ form.address_line1 }} + {{ form.address_line1.errors }} +
+
+
+
+ + {{ form.address_line2 }} + {{ form.address_line2.errors }} +
+
+
+
+ + {{ form.address_city }} + {{ form.address_city.errors }} +
+
+
+
+ + {{ form.address_state }} + {{ form.address_state.errors }} +
+
+
+
+ + {{ form.address_zip }} + {{ form.address_zip.errors }} +
+
+
+ +
+
+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/accounts/templates/accounts/register.html b/site/accounts/templates/accounts/register.html new file mode 100644 index 0000000..a59f3df --- /dev/null +++ b/site/accounts/templates/accounts/register.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Create account · {{ SITE_NAME }}{% endblock %} +{% block content %} + {% include "public/_breadcrumbs.html" with title="Create account" %} +
+
+
+
+

Create an account

+

Track orders, save a shipping address, and review products you bought. Card details stay with Stripe — we never store them.

+
+ {% csrf_token %} + {{ form.non_field_errors }} +
+
+
+ + {{ form.first_name }} + {{ form.first_name.errors }} +
+
+
+
+ + {{ form.last_name }} + {{ form.last_name.errors }} +
+
+
+
+ + {{ form.email }} + {{ form.email.errors }} +
+
+
+
+ + {{ form.password1 }} + {{ form.password1.errors }} +
+
+
+
+ + {{ form.password2 }} + {{ form.password2.errors }} +
+
+
+ +
+
+
+

Already have an account? Sign in

+
+
+
+
+{% endblock %} diff --git a/site/accounts/tests.py b/site/accounts/tests.py new file mode 100644 index 0000000..e9e9987 --- /dev/null +++ b/site/accounts/tests.py @@ -0,0 +1,88 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from accounts.models import CustomerProfile +from shop.models import Order + +User = get_user_model() + + +class CustomerAccountTests(TestCase): + def test_register_login_and_profile(self): + client = Client() + response = client.post( + reverse("account:register"), + { + "email": "buyer@example.com", + "first_name": "Pat", + "last_name": "Lee", + "password1": "s3cure-pass-123", + "password2": "s3cure-pass-123", + }, + ) + self.assertEqual(response.status_code, 302) + user = User.objects.get(email="buyer@example.com") + self.assertEqual(user.username, "buyer@example.com") + self.assertFalse(user.is_staff) + self.assertTrue(CustomerProfile.objects.filter(user=user).exists()) + + client.logout() + login = client.post( + reverse("account:login"), + {"username": "buyer@example.com", "password": "s3cure-pass-123"}, + ) + self.assertEqual(login.status_code, 302) + + save = client.post( + reverse("account:profile"), + { + "first_name": "Patricia", + "last_name": "Lee", + "phone": "6305550100", + "address_line1": "10 Main St", + "address_city": "Aurora", + "address_state": "IL", + "address_zip": "60505", + }, + ) + self.assertEqual(save.status_code, 302) + user.refresh_from_db() + profile = user.customer_profile + self.assertEqual(user.first_name, "Patricia") + self.assertEqual(profile.phone, "6305550100") + self.assertEqual(profile.shipping_address.get("line1"), "10 Main St") + + def test_register_claims_guest_orders(self): + order = Order.objects.create( + number="ORD-CLAIM-001", + email="buyer@example.com", + status=Order.Status.PAID, + amount="18.00", + ) + client = Client() + client.post( + reverse("account:register"), + { + "email": "buyer@example.com", + "password1": "s3cure-pass-123", + "password2": "s3cure-pass-123", + }, + ) + order.refresh_from_db() + self.assertEqual(order.user.email, "buyer@example.com") + history = client.get(reverse("account:orders")) + self.assertEqual(history.status_code, 200) + self.assertContains(history, "ORD-CLAIM-001") + + def test_customer_cannot_open_portal(self): + User.objects.create_user( + username="buyer@example.com", + email="buyer@example.com", + password="s3cure-pass-123", + ) + client = Client() + client.login(username="buyer@example.com", password="s3cure-pass-123") + response = client.get(reverse("dashboard:home")) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], reverse("account:home")) diff --git a/site/accounts/urls.py b/site/accounts/urls.py index bee2c7f..9fe1107 100644 --- a/site/accounts/urls.py +++ b/site/accounts/urls.py @@ -1,12 +1,14 @@ from django.contrib.auth import views as auth_views from django.urls import path +from accounts.views import PortalLoginView + app_name = "accounts" urlpatterns = [ path( "login/", - auth_views.LoginView.as_view(template_name="accounts/login.html"), + PortalLoginView.as_view(), name="login", ), path( diff --git a/site/accounts/views.py b/site/accounts/views.py new file mode 100644 index 0000000..e043d1a --- /dev/null +++ b/site/accounts/views.py @@ -0,0 +1,119 @@ +from django.contrib import messages +from django.contrib.auth import login, logout +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import LoginView +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views.decorators.http import require_http_methods + +from accounts.forms import ( + CustomerAuthenticationForm, + CustomerProfileForm, + CustomerRegisterForm, +) +from accounts.services import claim_orders_for_user, get_customer_profile + + +class PortalLoginView(LoginView): + template_name = "accounts/login.html" + + def get_success_url(self): + url = self.get_redirect_url() + if url: + return url + if self.request.user.is_staff: + return reverse("dashboard:home") + return reverse("account:home") + + +class CustomerLoginView(LoginView): + template_name = "accounts/customer_login.html" + authentication_form = CustomerAuthenticationForm + redirect_authenticated_user = True + + def get_success_url(self): + url = self.get_redirect_url() + if url: + return url + if self.request.user.is_staff: + return reverse("dashboard:home") + return reverse("account:home") + + +@require_http_methods(["GET", "POST"]) +def customer_register(request): + if request.user.is_authenticated: + return redirect("account:home") + if request.method == "POST": + form = CustomerRegisterForm(request.POST) + if form.is_valid(): + user = form.save() + get_customer_profile(user) + claimed = claim_orders_for_user(user) + login(request, user) + if claimed: + messages.success( + request, + f"Account created. {claimed} existing order(s) are now in your history.", + ) + else: + messages.success(request, "Account created. Welcome.") + return redirect("account:home") + else: + initial = {} + email = (request.GET.get("email") or "").strip() + if email: + initial["email"] = email + form = CustomerRegisterForm(initial=initial) + return render(request, "accounts/register.html", {"form": form}) + + +@require_http_methods(["GET", "POST"]) +def customer_logout(request): + logout(request) + messages.success(request, "Signed out.") + return redirect("public:home") + + +@login_required(login_url="account:login") +def customer_home(request): + from django.apps import apps + + if apps.is_installed("shop"): + return redirect("account:orders") + return redirect("account:profile") + + +@login_required(login_url="account:login") +@require_http_methods(["GET", "POST"]) +def customer_profile(request): + profile = get_customer_profile(request.user) + addr = profile.shipping_address or {} + initial = { + "first_name": request.user.first_name, + "last_name": request.user.last_name, + "phone": profile.phone, + "address_line1": addr.get("line1") or "", + "address_line2": addr.get("line2") or "", + "address_city": addr.get("city") or "", + "address_state": addr.get("state") or "", + "address_zip": addr.get("zip") or "", + } + if request.method == "POST": + form = CustomerProfileForm(request.POST) + if form.is_valid(): + request.user.first_name = form.cleaned_data.get("first_name") or "" + request.user.last_name = form.cleaned_data.get("last_name") or "" + request.user.save(update_fields=["first_name", "last_name"]) + profile.phone = form.cleaned_data.get("phone") or "" + profile.shipping_address = form.shipping_address() + profile.save(update_fields=["phone", "shipping_address", "updated_at"]) + messages.success(request, "Profile saved.") + return redirect("account:profile") + else: + form = CustomerProfileForm(initial=initial) + return render( + request, + "accounts/profile.html", + {"form": form, "profile": profile}, + ) diff --git a/site/analytics/tests.py b/site/analytics/tests.py index 7667416..0c04eea 100644 --- a/site/analytics/tests.py +++ b/site/analytics/tests.py @@ -60,7 +60,7 @@ class AnalyticsReportTests(TestCase): def setUp(self): self.client = Client() user = get_user_model().objects.create_user( - username="monica", password="pass-word-1" + username="monica", password="pass-word-1", is_staff=True ) self.client.force_login(user) diff --git a/site/blog/tests.py b/site/blog/tests.py index d13dbb8..fadb8d2 100644 --- a/site/blog/tests.py +++ b/site/blog/tests.py @@ -28,7 +28,7 @@ class BlogPublicTests(TestCase): class BlogPortalTests(TestCase): def setUp(self): User = get_user_model() - self.user = User.objects.create_user("editor", password="test-pass-123") + self.user = User.objects.create_user("editor", password="test-pass-123", is_staff=True) self.client = Client() self.client.login(username="editor", password="test-pass-123") diff --git a/site/contacts/tests_merge.py b/site/contacts/tests_merge.py index 3015d8d..3c2946b 100644 --- a/site/contacts/tests_merge.py +++ b/site/contacts/tests_merge.py @@ -130,19 +130,12 @@ class ContactFormMergeTests(TestCase): ), ) - def test_contact_form_merges_on_phone(self): + def test_contact_form_merges_on_email(self): url = reverse("public:contact") response = self.client.post( url, { - "first_name": "Alex", - "last_name": "Lee", - "email": "alt@example.com", - "phone": "(630) 111-2222", - "address_line1": "55 River Rd", - "address_city": "Aurora", - "address_state": "IL", - "address_zip": "60505", + "email": "primary@example.com", "interest": "general", "message": "Looking to buy", }, @@ -151,15 +144,35 @@ class ContactFormMergeTests(TestCase): self.assertEqual(Contact.objects.count(), 1) lead = Lead.objects.get() self.assertEqual(lead.contact_id, self.existing.pk) - self.assertIn("alt@example.com", lead.message) - self.assertIn("merged by phone", lead.message) + self.assertIn("Looking to buy", lead.message) + + def test_contact_form_skips_name_phone_address(self): + url = reverse("public:contact") + response = self.client.post( + url, + { + "email": "newbuyer@example.com", + "interest": "custom", + "message": "Can you print a fox?", + "first_name": "Ignored", + "phone": "6301112222", + "address_line1": "55 River Rd", + }, + ) + self.assertEqual(response.status_code, 302) + from contacts.models import Contact as ContactModel + + created = ContactModel.objects.get(email="newbuyer@example.com") + self.assertEqual(created.first_name, "") + self.assertEqual(created.phone, "") + self.assertEqual(created.postal_address, {}) class PortalCreateMatchPromptTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="adder", password="test-pass-123" + username="adder", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="adder", password="test-pass-123") @@ -225,7 +238,7 @@ class ContactListTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="lister", password="test-pass-123" + username="lister", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="lister", password="test-pass-123") diff --git a/site/core/tests.py b/site/core/tests.py index d0c012d..65d2a4a 100644 --- a/site/core/tests.py +++ b/site/core/tests.py @@ -35,6 +35,11 @@ class PublicSmokeTests(TestCase): client = Client() self.assertEqual(client.get(reverse("public:about")).status_code, 200) self.assertEqual(client.get(reverse("public:contact")).status_code, 200) + contact = client.get(reverse("public:contact")) + self.assertContains(contact, "contact-email") + self.assertNotContains(contact, "contact-first-name") + self.assertNotContains(contact, "contact-phone") + self.assertNotContains(contact, "contact-address-line1") class DispatchDueTests(TestCase): diff --git a/site/core/tests_campaign_utm.py b/site/core/tests_campaign_utm.py index 4d5ce75..265f2c6 100644 --- a/site/core/tests_campaign_utm.py +++ b/site/core/tests_campaign_utm.py @@ -111,7 +111,7 @@ class CampaignShortLinkViewTests(TestCase): User = get_user_model() self.user = User.objects.create_user( - username="utm-composer", password="test-pass-123" + username="utm-composer", password="test-pass-123", is_staff=True ) self.client.login(username="utm-composer", password="test-pass-123") diff --git a/site/directmail/tests.py b/site/directmail/tests.py index b549321..51db160 100644 --- a/site/directmail/tests.py +++ b/site/directmail/tests.py @@ -239,7 +239,7 @@ class PostcardDesignPickTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="pcm-pick", password="test-pass-123" + username="pcm-pick", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="pcm-pick", password="test-pass-123") @@ -283,7 +283,7 @@ class PostcardUtmPanelTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="utm-mail", password="test-pass-123" + username="utm-mail", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="utm-mail", password="test-pass-123") diff --git a/site/email_sms/tests.py b/site/email_sms/tests.py index 82c24af..ae0076b 100644 --- a/site/email_sms/tests.py +++ b/site/email_sms/tests.py @@ -123,7 +123,7 @@ class PortalConsentToggleTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="monica", password="test-pass-123" + username="monica", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="monica", password="test-pass-123") @@ -190,7 +190,7 @@ class CampaignDraftSaveTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="composer", password="test-pass-123" + username="composer", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="composer", password="test-pass-123") @@ -260,7 +260,7 @@ class CampaignSendTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="sender", password="test-pass-123", email="sender@example.com" + username="sender", password="test-pass-123", email="sender@example.com", is_staff=True ) self.client = Client() self.client.login(username="sender", password="test-pass-123") @@ -467,7 +467,7 @@ class Smtp2goEmailWebhookTests(TestCase): def test_status_json_includes_opens(self): User = get_user_model() - user = User.objects.create_user(username="viewer", password="test-pass-123") + user = User.objects.create_user(username="viewer", password="test-pass-123", is_staff=True) self.client.login(username="viewer", password="test-pass-123") ProviderEvent.objects.create( message=self.message, @@ -946,7 +946,7 @@ class StoredFileUploadTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="uploader", password="test-pass-123" + username="uploader", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="uploader", password="test-pass-123") @@ -1030,7 +1030,7 @@ class CampaignRecipientTableTests(TestCase): User = get_user_model() self.user = User.objects.create_user( - username="recip", password="test-pass-123" + username="recip", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="recip", password="test-pass-123") @@ -1169,7 +1169,7 @@ class CampaignUtmLinkTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="utm-composer", password="test-pass-123" + username="utm-composer", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="utm-composer", password="test-pass-123") diff --git a/site/events/tests.py b/site/events/tests.py index cd07cb7..fe8f3ba 100644 --- a/site/events/tests.py +++ b/site/events/tests.py @@ -68,7 +68,7 @@ class EventCapacityTests(TestCase): class EventPortalTests(TestCase): def setUp(self): User = get_user_model() - self.user = User.objects.create_user("host", password="test-pass-123") + self.user = User.objects.create_user("host", password="test-pass-123", is_staff=True) self.client = Client() self.client.login(username="host", password="test-pass-123") diff --git a/site/payments/tests.py b/site/payments/tests.py index 8b9113d..6ff5171 100644 --- a/site/payments/tests.py +++ b/site/payments/tests.py @@ -12,7 +12,7 @@ from payments.services import next_invoice_number class PaymentsPortalTests(TestCase): def setUp(self): User = get_user_model() - self.user = User.objects.create_user("biller", password="test-pass-123") + self.user = User.objects.create_user("biller", password="test-pass-123", is_staff=True) self.client = Client() self.client.login(username="biller", password="test-pass-123") self.contact = Contact.objects.create( diff --git a/site/pos_sync/tests.py b/site/pos_sync/tests.py index aec0b38..ffb66e6 100644 --- a/site/pos_sync/tests.py +++ b/site/pos_sync/tests.py @@ -109,7 +109,7 @@ class POSPortalTests(TestCase): def test_list_ok_when_logged_in(self): User = get_user_model() - User.objects.create_user("clerk", password="test-pass-123") + User.objects.create_user("clerk", password="test-pass-123", is_staff=True) 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/print_forge/settings/base.py b/site/print_forge/settings/base.py index 96aac06..3faacb6 100644 --- a/site/print_forge/settings/base.py +++ b/site/print_forge/settings/base.py @@ -222,6 +222,7 @@ MIDDLEWARE = [ "analytics.middleware.UTMTrackingMiddleware", "analytics.middleware.PublicPageViewMiddleware", "public.middleware.UnderConstructionMiddleware", + "accounts.middleware.PortalStaffMiddleware", ] ROOT_URLCONF = "print_forge.urls" @@ -420,6 +421,7 @@ 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", "") +EASYPOST_WEBHOOK_SECRET = env("EASYPOST_WEBHOOK_SECRET", "") SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "") SHIP_FROM_CITY = env("SHIP_FROM_CITY", "") SHIP_FROM_STATE = env("SHIP_FROM_STATE", "") diff --git a/site/print_forge/static/css/theme-overrides.css b/site/print_forge/static/css/theme-overrides.css index 0372f28..d3f307c 100644 --- a/site/print_forge/static/css/theme-overrides.css +++ b/site/print_forge/static/css/theme-overrides.css @@ -545,3 +545,20 @@ width: 100%; } } + +.product-review-summary { + font-weight: 600; + margin-bottom: 1rem; +} +.product-review-form .form-wrap { + margin-bottom: 1rem; +} +.product-review-list { + list-style: none; + padding: 0; + margin: 1.5rem 0 0; +} +.product-review-list li { + border-top: 1px solid #e5e7eb; + padding: 1rem 0; +} diff --git a/site/print_forge/templates/base.html b/site/print_forge/templates/base.html index 4d20d24..07202da 100644 --- a/site/print_forge/templates/base.html +++ b/site/print_forge/templates/base.html @@ -121,6 +121,15 @@ {{ cart_count }} {% endif %} + {% if user.is_authenticated %} + {% if user.is_staff %} + Portal + {% else %} + Account + {% endif %} + {% elif "shop" in enabled_features %} + Sign in + {% endif %} @@ -239,9 +248,14 @@ {% endfor %}
  • Contact Us
  • Terms
  • - {% if user.is_authenticated %} + {% if user.is_authenticated and not user.is_staff %} +
  • Account
  • + {% elif user.is_authenticated %}
  • Client portal
  • {% else %} + {% if "shop" in enabled_features %} +
  • Sign in
  • + {% endif %}
  • Client portal
  • {% endif %} diff --git a/site/print_forge/urls.py b/site/print_forge/urls.py index eaaf271..41ed7ee 100644 --- a/site/print_forge/urls.py +++ b/site/print_forge/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path("api/address-suggest/", address_suggest, name="address_suggest"), path("files/", include("core.urls")), path("admin/", admin.site.urls), + path("account/", include("accounts.customer_urls")), path("accounts/", include("accounts.urls")), path("portal/", include("dashboard.urls")), path("portal/leads/", include("leads.urls")), diff --git a/site/public/forms.py b/site/public/forms.py index 37be75a..12ab04b 100644 --- a/site/public/forms.py +++ b/site/public/forms.py @@ -11,88 +11,9 @@ class ContactForm(forms.Form): ("other", "Something else"), ] - first_name = forms.CharField( - max_length=100, - widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-first-name"}), - ) - last_name = forms.CharField( - max_length=100, - required=False, - widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-last-name"}), - ) email = forms.EmailField( widget=forms.EmailInput(attrs={"class": "form-input", "id": "contact-email"}), ) - phone = forms.CharField( - max_length=32, - required=False, - widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-phone"}), - ) - address_line1 = forms.CharField( - max_length=200, - required=False, - label="Street address", - widget=forms.TextInput( - attrs={ - "class": "form-input", - "id": "contact-address-line1", - "autocomplete": "off", - "data-ac": "line1", - } - ), - ) - address_line2 = forms.CharField( - max_length=200, - required=False, - label="Apt / suite", - widget=forms.TextInput( - attrs={ - "class": "form-input", - "id": "contact-address-line2", - "autocomplete": "address-line2", - "data-ac": "line2", - } - ), - ) - address_city = forms.CharField( - max_length=100, - required=False, - label="City", - widget=forms.TextInput( - attrs={ - "class": "form-input", - "id": "contact-address-city", - "autocomplete": "address-level2", - "data-ac": "city", - } - ), - ) - address_state = forms.CharField( - max_length=32, - required=False, - label="State", - widget=forms.TextInput( - attrs={ - "class": "form-input", - "id": "contact-address-state", - "autocomplete": "address-level1", - "data-ac": "state", - } - ), - ) - address_zip = forms.CharField( - max_length=20, - required=False, - label="ZIP", - widget=forms.TextInput( - attrs={ - "class": "form-input", - "id": "contact-address-zip", - "autocomplete": "postal-code", - "data-ac": "zip", - } - ), - ) interest = forms.ChoiceField( choices=INTEREST_CHOICES, widget=forms.Select(attrs={"class": "form-input", "id": "contact-interest"}), diff --git a/site/public/middleware.py b/site/public/middleware.py index a1e71a1..e8df547 100644 --- a/site/public/middleware.py +++ b/site/public/middleware.py @@ -17,6 +17,9 @@ class UnderConstructionMiddleware: "/admin/", "/accounts/login", "/accounts/logout", + "/account/login", + "/account/logout", + "/account/register", "/under-construction", "/portal/messaging/webhooks/", "/unsubscribe/", diff --git a/site/public/notifications.py b/site/public/notifications.py index b8a5352..f53c6f9 100644 --- a/site/public/notifications.py +++ b/site/public/notifications.py @@ -26,8 +26,6 @@ def notify_admins_of_contact_form(lead: Lead) -> bool: return False contact = lead.contact - name = contact.full_name or "(no name)" - phone = contact.phone or "(none)" email = contact.email or "(none)" message = (lead.message or "").strip() or "(no message)" @@ -35,27 +33,8 @@ def notify_admins_of_contact_form(lead: Lead) -> bool: portal_path = reverse("leads:detail", kwargs={"pk": lead.pk}) portal_url = f"{site}{portal_path}" if site else portal_path - postal = contact.postal_address or {} - address_bits = [ - postal.get("line1") or "", - postal.get("line2") or "", - ", ".join( - part - for part in [ - postal.get("city") or "", - postal.get("state") or "", - postal.get("zip") or "", - ] - if part - ), - ] - address = "\n".join(bit for bit in address_bits if bit) or "(none)" - ctx = email_brand_context( - name=name, email=email, - phone=phone, - address=address, message=message, portal_url=portal_url, ) @@ -63,7 +42,7 @@ def notify_admins_of_contact_form(lead: Lead) -> bool: html_content = get_template("emails/contact_email.html").render(ctx) mail = EmailMultiAlternatives( - subject=f"New contact form inquiry from {name}", + subject=f"New contact form inquiry from {email}", body=text_content, from_email=settings.DEFAULT_FROM_EMAIL, to=[to_email], diff --git a/site/public/templates/emails/contact_email.html b/site/public/templates/emails/contact_email.html index 61e9821..1e48038 100644 --- a/site/public/templates/emails/contact_email.html +++ b/site/public/templates/emails/contact_email.html @@ -6,20 +6,11 @@

    Hello,

    A new contact request was submitted on the site.

    -

    Name

    -

    {{ name }}

    -

    Email

    {{ email }}

    -

    Phone

    -

    {{ phone }}

    - -

    Address

    -

    {{ address }}

    -

    Message

    {{ message }}

    diff --git a/site/public/templates/emails/contact_email.txt b/site/public/templates/emails/contact_email.txt index efecc69..623d438 100644 --- a/site/public/templates/emails/contact_email.txt +++ b/site/public/templates/emails/contact_email.txt @@ -1,10 +1,6 @@ New contact form inquiry — {{ brand_name|default:"Print Forge" }} -Name: {{ name }} Email: {{ email }} -Phone: {{ phone }} -Address: -{{ address }} Message: {{ message }} @@ -13,5 +9,5 @@ Message: {% endif %} — {{ brand_name|default:"Print Forge" }} -{{ site_url|default:"https://mkdrealtor.com" }} +{{ site_url|default:"https://printforgeprints.com" }} {% if brand_tagline %}{{ brand_tagline }}{% endif %} diff --git a/site/public/templates/public/contact.html b/site/public/templates/public/contact.html index 78a9bbc..503cb81 100644 --- a/site/public/templates/public/contact.html +++ b/site/public/templates/public/contact.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% load static %} {% block title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %} {% block og_title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %} {% block twitter_title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %} @@ -60,74 +59,13 @@
    {% csrf_token %}
    -
    -
    - - {{ form.first_name }} - {{ form.first_name.errors }} -
    -
    -
    -
    - - {{ form.last_name }} - {{ form.last_name.errors }} -
    -
    -
    -
    - - {{ form.phone }} - {{ form.phone.errors }} -
    -
    -
    +
    {{ form.email }} {{ form.email.errors }}
    -
    -

    Mailing address (optional)

    -
    -
    -
    - - {{ form.address_line1 }} - {{ form.address_line1.errors }} -
    -
    -
    -
    - - {{ form.address_line2 }} - {{ form.address_line2.errors }} -
    -
    -
    -
    - - {{ form.address_city }} - {{ form.address_city.errors }} -
    -
    -
    -
    - - {{ form.address_state }} - {{ form.address_state.errors }} -
    -
    -
    -
    - - {{ form.address_zip }} - {{ form.address_zip.errors }} -
    -
    -
    -
    @@ -151,7 +89,7 @@
    -

    By submitting, you agree we may contact you about your inquiry by email and SMS (if you provide a phone number). Unsubscribe anytime.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}

    +

    By submitting, you agree we may contact you about your inquiry by email. Unsubscribe anytime.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}

    @@ -165,9 +103,6 @@
    {% endblock %} -{% block extra_head %} - -{% endblock %} {% block tracking_events %} {% if "sent" in request.GET %} {% endif %} {% endblock %} -{% block extra_js %} - -{% endblock %} diff --git a/site/public/views.py b/site/public/views.py index 30f6690..3928e9f 100644 --- a/site/public/views.py +++ b/site/public/views.py @@ -60,6 +60,7 @@ def robots_txt(request): "Allow: /", "Disallow: /portal/", "Disallow: /accounts/", + "Disallow: /account/", "Disallow: /admin/", "Disallow: /api/", f"Sitemap: {site}/sitemap.xml", @@ -110,48 +111,24 @@ def contact(request): form = ContactForm(request.POST) if form.is_valid(): data = form.cleaned_data - postal = Contact.make_postal_address( - line1=data.get("address_line1") or "", - line2=data.get("address_line2") or "", - city=data.get("address_city") or "", - state=data.get("address_state") or "", - zip_code=data.get("address_zip") or "", - ) submitted_email = data["email"].lower() contact_obj, _created, match_reason = upsert_contact( email=submitted_email, - first_name=data["first_name"], - last_name=data.get("last_name") or "", - phone=data.get("phone") or "", - postal_address=postal - if Contact.postal_address_has_content(postal) - else None, source=Contact.Source.CONTACT_FORM, + merge_phone_address=False, ) ConsentRecord.objects.update_or_create( contact=contact_obj, channel=Channel.EMAIL, defaults={"opted_in": True, "reason": "contact_form"}, ) - if (data.get("phone") or "").strip(): - ConsentRecord.objects.update_or_create( - contact=contact_obj, - channel=Channel.SMS, - defaults={"opted_in": True, "reason": "contact_form"}, - ) - if Contact.postal_address_has_content(postal): - ConsentRecord.objects.get_or_create( - contact=contact_obj, - channel=Channel.POSTCARD, - defaults={"opted_in": True, "reason": "contact_form"}, - ) interest = data.get("interest") or "" interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest) body = data.get("message") or "" if interest_label: body = f"Interest: {interest_label}\n\n{body}".strip() if ( - match_reason in {"phone", "address"} + match_reason and (contact_obj.email or "").lower() != submitted_email ): body = ( diff --git a/site/shipping/hooks.py b/site/shipping/hooks.py index 7d8e918..517ae4e 100644 --- a/site/shipping/hooks.py +++ b/site/shipping/hooks.py @@ -1,5 +1,6 @@ from core.registry import ( register_dashboard_collector, + register_dispatcher, register_feature, register_portal_nav, ) @@ -15,6 +16,7 @@ def register() -> None: order=50, ) register_dashboard_collector(_dashboard) + register_dispatcher(_sync_tracking) def _dashboard(request) -> dict: @@ -29,3 +31,10 @@ def _dashboard(request) -> dict: .exclude(pk__in=labeled) .count() } + + +def _sync_tracking() -> int: + from shipping.services import sync_open_tracking + + return sync_open_tracking() + diff --git a/site/shipping/migrations/0002_shipment_tracking.py b/site/shipping/migrations/0002_shipment_tracking.py new file mode 100644 index 0000000..fa4dc8a --- /dev/null +++ b/site/shipping/migrations/0002_shipment_tracking.py @@ -0,0 +1,54 @@ +# Generated by Django 6.1 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("shipping", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="shipment", + name="last_tracked_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="shipment", + name="tracker_id", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="shipment", + name="tracking_events", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="shipment", + name="tracking_status", + field=models.CharField( + blank=True, + choices=[ + ("unknown", "Unknown"), + ("pre_transit", "Pre-transit"), + ("in_transit", "In transit"), + ("out_for_delivery", "Out for delivery"), + ("delivered", "Delivered"), + ("available_for_pickup", "Available for pickup"), + ("return_to_sender", "Return to sender"), + ("failure", "Exception"), + ("cancelled", "Cancelled"), + ("error", "Error"), + ], + default="unknown", + max_length=32, + ), + ), + migrations.AddField( + model_name="shipment", + name="tracking_url", + field=models.URLField(blank=True), + ), + ] diff --git a/site/shipping/models.py b/site/shipping/models.py index 4892da8..0dca348 100644 --- a/site/shipping/models.py +++ b/site/shipping/models.py @@ -11,6 +11,18 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel): LABELED = "labeled", "Labeled" VOID = "void", "Void" + class TrackingStatus(models.TextChoices): + UNKNOWN = "unknown", "Unknown" + PRE_TRANSIT = "pre_transit", "Pre-transit" + IN_TRANSIT = "in_transit", "In transit" + OUT_FOR_DELIVERY = "out_for_delivery", "Out for delivery" + DELIVERED = "delivered", "Delivered" + AVAILABLE_FOR_PICKUP = "available_for_pickup", "Available for pickup" + RETURN_TO_SENDER = "return_to_sender", "Return to sender" + FAILURE = "failure", "Exception" + CANCELLED = "cancelled", "Cancelled" + ERROR = "error", "Error" + order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments") status = models.CharField( max_length=16, choices=Status.choices, default=Status.DRAFT @@ -18,8 +30,20 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel): 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) + tracking_status = models.CharField( + max_length=32, + choices=TrackingStatus.choices, + default=TrackingStatus.UNKNOWN, + blank=True, + ) + tracking_url = models.URLField(blank=True) + tracker_id = models.CharField(max_length=255, blank=True) + tracking_events = models.JSONField(default=list, blank=True) + last_tracked_at = models.DateTimeField(null=True, blank=True) label_url = models.URLField(blank=True) - rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, 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) diff --git a/site/shipping/services.py b/site/shipping/services.py index f81a44d..466ef46 100644 --- a/site/shipping/services.py +++ b/site/shipping/services.py @@ -6,9 +6,11 @@ import csv import io import logging from decimal import Decimal +from urllib.parse import quote import requests from django.conf import settings +from django.utils import timezone from shipping.models import Shipment from shop.models import Order @@ -135,6 +137,10 @@ def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment: "updated_at", ] ) + try: + refresh_tracking(shipment) + except Exception: + logger.exception("tracking refresh failed after label buy for %s", shipment.pk) return shipment @@ -182,3 +188,169 @@ def pirate_ship_csv(orders=None) -> str: ] ) return buffer.getvalue() + + +def public_tracking_url(carrier: str, tracking_number: str) -> str: + code = quote((tracking_number or "").strip()) + if not code: + return "" + name = (carrier or "").upper() + if "USPS" in name: + return f"https://tools.usps.com/go/TrackConfirmAction?tLabels={code}" + if "UPS" in name: + return f"https://www.ups.com/track?tracknum={code}" + if "FEDEX" in name or "FDX" in name: + return f"https://www.fedex.com/fedextrack/?trknbr={code}" + if "DHL" in name: + return f"https://www.dhl.com/en/express/tracking.html?AWB={code}" + return f"https://www.google.com/search?q={quote((tracking_number or '') + ' tracking')}" + + +def _normalize_tracking_status(raw: str) -> str: + value = (raw or "").strip().lower().replace(" ", "_") + aliases = { + "pretransit": Shipment.TrackingStatus.PRE_TRANSIT, + "pre_transit": Shipment.TrackingStatus.PRE_TRANSIT, + "in_transit": Shipment.TrackingStatus.IN_TRANSIT, + "out_for_delivery": Shipment.TrackingStatus.OUT_FOR_DELIVERY, + "delivered": Shipment.TrackingStatus.DELIVERED, + "available_for_pickup": Shipment.TrackingStatus.AVAILABLE_FOR_PICKUP, + "return_to_sender": Shipment.TrackingStatus.RETURN_TO_SENDER, + "failure": Shipment.TrackingStatus.FAILURE, + "cancelled": Shipment.TrackingStatus.CANCELLED, + "canceled": Shipment.TrackingStatus.CANCELLED, + "error": Shipment.TrackingStatus.ERROR, + "unknown": Shipment.TrackingStatus.UNKNOWN, + } + return aliases.get(value, Shipment.TrackingStatus.UNKNOWN) + + +def apply_tracker_payload(shipment: Shipment, payload: dict) -> Shipment: + """Apply EasyPost tracker (or compatible) JSON onto a shipment.""" + data = payload or {} + tracking = (data.get("tracking_code") or data.get("tracking_number") or "").strip() + if tracking: + shipment.tracking_number = tracking + tracker_id = (data.get("id") or "").strip() + if tracker_id.startswith("trk_"): + shipment.tracker_id = tracker_id + carrier = (data.get("carrier") or "").strip() + if carrier and not shipment.carrier: + shipment.carrier = carrier + shipment.tracking_status = _normalize_tracking_status(data.get("status") or "") + public_url = (data.get("public_url") or "").strip() + shipment.tracking_url = public_url or public_tracking_url( + shipment.carrier, shipment.tracking_number + ) + events = [] + for item in data.get("tracking_details") or []: + loc = item.get("tracking_location") or {} + place = " ".join( + part + for part in [loc.get("city") or "", loc.get("state") or ""] + if part + ).strip() + events.append( + { + "status": item.get("status") or "", + "message": item.get("message") or "", + "datetime": item.get("datetime") or "", + "location": place, + } + ) + if events: + shipment.tracking_events = events + shipment.last_tracked_at = timezone.now() + shipment.save( + update_fields=[ + "tracking_number", + "tracker_id", + "carrier", + "tracking_status", + "tracking_url", + "tracking_events", + "last_tracked_at", + "updated_at", + ] + ) + return shipment + + +def refresh_tracking(shipment: Shipment) -> Shipment: + """Pull latest scan events from EasyPost, or stub status without an API key. + + Works for EasyPost-bought labels and for tracking numbers pasted from + Pirate Ship / Shippo / the carrier — EasyPost's tracker API looks up + USPS, UPS, FedEx, and DHL by number. + """ + tracking = (shipment.tracking_number or "").strip() + if not tracking: + return shipment + key = _easypost_key() + if not key: + if not shipment.tracking_status or shipment.tracking_status == Shipment.TrackingStatus.UNKNOWN: + shipment.tracking_status = Shipment.TrackingStatus.PRE_TRANSIT + shipment.tracking_url = shipment.tracking_url or public_tracking_url( + shipment.carrier, tracking + ) + shipment.last_tracked_at = timezone.now() + shipment.save( + update_fields=[ + "tracking_status", + "tracking_url", + "last_tracked_at", + "updated_at", + ] + ) + return shipment + + payload = {"tracker": {"tracking_code": tracking}} + if shipment.carrier: + payload["tracker"]["carrier"] = shipment.carrier + response = requests.post( + "https://api.easypost.com/v2/trackers", + auth=(key, ""), + json=payload, + timeout=20, + ) + response.raise_for_status() + return apply_tracker_payload(shipment, response.json()) + + +def attach_tracking( + shipment: Shipment, *, tracking_number: str, carrier: str = "" +) -> Shipment: + tracking_number = (tracking_number or "").strip() + if not tracking_number: + raise ShippingError("Tracking number is required.") + shipment.tracking_number = tracking_number + if carrier: + shipment.carrier = carrier.strip() + if shipment.status != Shipment.Status.LABELED: + shipment.status = Shipment.Status.LABELED + shipment.save( + update_fields=["tracking_number", "carrier", "status", "updated_at"] + ) + return refresh_tracking(shipment) + + +def sync_open_tracking() -> int: + """Refresh labeled, not-yet-delivered shipments (dispatch_due).""" + done = 0 + qs = ( + Shipment.objects.filter(status=Shipment.Status.LABELED) + .exclude(tracking_number="") + .exclude( + tracking_status__in=[ + Shipment.TrackingStatus.DELIVERED, + Shipment.TrackingStatus.CANCELLED, + ] + )[:50] + ) + for shipment in qs: + try: + refresh_tracking(shipment) + done += 1 + except Exception: + logger.exception("tracking sync failed for %s", shipment.pk) + return done diff --git a/site/shipping/templates/shipping/detail.html b/site/shipping/templates/shipping/detail.html index c70de17..8c1312b 100644 --- a/site/shipping/templates/shipping/detail.html +++ b/site/shipping/templates/shipping/detail.html @@ -3,8 +3,52 @@ {% 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.tracking_number %} +

    + Tracking: {{ shipment.tracking_number }} + {% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %} +

    +{% if shipment.tracking_url %}

    Track package

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

    Download label

    {% endif %} + +

    Add tracking

    +

    Paste a number from Pirate Ship, Shippo, or the carrier. EasyPost looks up scan events when an API key is set.

    +
    + {% csrf_token %} +
    +
    + + +
    +
    + + +
    +
    + +
    + +{% if shipment.tracking_events %} +

    Scan history

    + + + + {% for event in shipment.tracking_events %} + + + + + + {% endfor %} + +
    WhenStatusDetail
    {{ event.datetime }}{{ event.status }}{{ event.message }}{% if event.location %} · {{ event.location }}{% endif %}
    +{% endif %} + {% if shipment.status != 'labeled' %}
    {% csrf_token %} diff --git a/site/shipping/tests.py b/site/shipping/tests.py index 4818d04..f8ffa4d 100644 --- a/site/shipping/tests.py +++ b/site/shipping/tests.py @@ -1,4 +1,5 @@ from decimal import Decimal +import json from django.contrib.auth import get_user_model from django.test import Client, TestCase @@ -49,11 +50,63 @@ class ShippingServiceTests(TestCase): buy_label(shipment) self.assertNotIn(self.order.number, pirate_ship_csv()) + def test_stub_buy_sets_pre_transit_tracking(self): + shipment = create_shipment_for_order(self.order) + quote_rates(shipment) + buy_label(shipment) + shipment.refresh_from_db() + self.assertTrue(shipment.tracking_number) + self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT) + self.assertTrue(shipment.tracking_url) + + def test_attach_tracking_from_pirate_ship(self): + from shipping.services import attach_tracking + + shipment = create_shipment_for_order(self.order) + attach_tracking(shipment, tracking_number="9400111899223197428490", carrier="USPS") + shipment.refresh_from_db() + self.assertEqual(shipment.status, Shipment.Status.LABELED) + self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT) + self.assertIn("usps.com", shipment.tracking_url.lower()) + + def test_easypost_webhook_updates_status(self): + shipment = create_shipment_for_order(self.order) + shipment.tracking_number = "EZ1000000001" + shipment.status = Shipment.Status.LABELED + shipment.save() + payload = { + "description": "tracker.updated", + "result": { + "id": "trk_test", + "tracking_code": "EZ1000000001", + "status": "in_transit", + "public_url": "https://track.easypost.com/djE0", + "tracking_details": [ + { + "status": "in_transit", + "message": "Departed facility", + "datetime": "2026-09-07T12:00:00Z", + "tracking_location": {"city": "Chicago", "state": "IL"}, + } + ], + }, + } + response = Client().post( + reverse("shipping:easypost_webhook"), + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + shipment.refresh_from_db() + self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.IN_TRANSIT) + self.assertEqual(shipment.tracker_id, "trk_test") + self.assertEqual(shipment.tracking_events[0]["location"], "Chicago IL") + class ShippingPortalTests(TestCase): def setUp(self): User = get_user_model() - self.user = User.objects.create_user("shipper", password="test-pass-123") + self.user = User.objects.create_user("shipper", password="test-pass-123", is_staff=True) self.client = Client() self.client.login(username="shipper", password="test-pass-123") self.order = Order.objects.create( diff --git a/site/shipping/urls.py b/site/shipping/urls.py index 5d26350..25b539c 100644 --- a/site/shipping/urls.py +++ b/site/shipping/urls.py @@ -8,6 +8,17 @@ 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("webhooks/easypost/", views.easypost_webhook, name="easypost_webhook"), path("/", views.shipment_detail, name="shipment_detail"), path("/buy/", views.shipment_buy, name="shipment_buy"), + path( + "/tracking/", + views.shipment_attach_tracking, + name="shipment_attach_tracking", + ), + path( + "/tracking/refresh/", + views.shipment_refresh_tracking, + name="shipment_refresh_tracking", + ), ] diff --git a/site/shipping/views.py b/site/shipping/views.py index dae8018..409d861 100644 --- a/site/shipping/views.py +++ b/site/shipping/views.py @@ -1,18 +1,24 @@ +import json import logging +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods, require_POST from shipping.models import Shipment from shipping.services import ( ShippingError, + apply_tracker_payload, + attach_tracking, buy_label, create_shipment_for_order, pirate_ship_csv, quote_rates, + refresh_tracking, ) from shop.models import Order @@ -77,6 +83,40 @@ def shipment_buy(request, pk): return redirect("shipping:shipment_detail", pk=shipment.pk) +@login_required +@require_POST +def shipment_attach_tracking(request, pk): + shipment = get_object_or_404(Shipment, pk=pk) + try: + attach_tracking( + shipment, + tracking_number=request.POST.get("tracking_number") or "", + carrier=request.POST.get("carrier") or "", + ) + except ShippingError as exc: + messages.error(request, str(exc)) + except Exception as exc: # noqa: BLE001 + logger.exception("attach tracking failed") + messages.error(request, f"Could not save tracking: {exc}") + else: + messages.success(request, "Tracking saved.") + return redirect("shipping:shipment_detail", pk=shipment.pk) + + +@login_required +@require_POST +def shipment_refresh_tracking(request, pk): + shipment = get_object_or_404(Shipment, pk=pk) + try: + refresh_tracking(shipment) + except Exception as exc: # noqa: BLE001 + logger.exception("refresh tracking failed") + messages.error(request, f"Could not refresh tracking: {exc}") + else: + messages.success(request, "Tracking updated.") + return redirect("shipping:shipment_detail", pk=shipment.pk) + + @login_required @require_http_methods(["GET"]) def pirate_ship_export(request): @@ -84,3 +124,36 @@ def pirate_ship_export(request): response = HttpResponse(body, content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"' return response + + +@csrf_exempt +@require_http_methods(["POST"]) +def easypost_webhook(request): + secret = (getattr(settings, "EASYPOST_WEBHOOK_SECRET", "") or "").strip() + if secret: + got = ( + request.headers.get("X-Webhook-Secret") + or request.GET.get("token") + or "" + ).strip() + if got != secret: + return HttpResponseBadRequest("invalid secret") + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return HttpResponseBadRequest("invalid json") + result = payload.get("result") or payload + if not isinstance(result, dict): + return HttpResponse("ok") + tracker_id = (result.get("id") or "").strip() + tracking = (result.get("tracking_code") or result.get("tracking_number") or "").strip() + shipment = None + if tracker_id: + shipment = Shipment.objects.filter(tracker_id=tracker_id).first() + if shipment is None and tracking: + shipment = Shipment.objects.filter(tracking_number=tracking).first() + if shipment is None: + return HttpResponse("ok") + apply_tracker_payload(shipment, result) + logger.info("easypost tracker updated shipment %s", shipment.pk) + return HttpResponse("ok") diff --git a/site/shop/admin.py b/site/shop/admin.py index 8d0c1fb..a468d0b 100644 --- a/site/shop/admin.py +++ b/site/shop/admin.py @@ -1,6 +1,13 @@ from django.contrib import admin -from shop.models import Order, OrderItem, Product, ProductColor, ProductImage +from shop.models import ( + Order, + OrderItem, + Product, + ProductColor, + ProductImage, + ProductReview, +) class ProductColorInline(admin.TabularInline): @@ -22,6 +29,13 @@ class ProductAdmin(admin.ModelAdmin): inlines = [ProductColorInline, ProductImageInline] +@admin.register(ProductReview) +class ProductReviewAdmin(admin.ModelAdmin): + list_display = ("product", "user", "rating", "created_at") + list_filter = ("rating",) + search_fields = ("product__name", "user__email", "title") + + class OrderItemInline(admin.TabularInline): model = OrderItem extra = 0 @@ -34,4 +48,5 @@ class OrderAdmin(admin.ModelAdmin): list_display = ("number", "email", "amount", "status", "created_at") list_filter = ("status",) search_fields = ("number", "email", "customer_name") + raw_id_fields = ("user",) inlines = [OrderItemInline] diff --git a/site/shop/migrations/0005_order_user_and_review.py b/site/shop/migrations/0005_order_user_and_review.py new file mode 100644 index 0000000..85cc291 --- /dev/null +++ b/site/shop/migrations/0005_order_user_and_review.py @@ -0,0 +1,97 @@ +# Generated by Django 6.1 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("shop", "0004_productimage_and_color_stock"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shop_orders", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.CreateModel( + name="ProductReview", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "rating", + models.PositiveSmallIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(5), + ] + ), + ), + ("title", models.CharField(blank=True, max_length=120)), + ("body", models.TextField(blank=True)), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reviews", + to="shop.order", + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reviews", + to="shop.product", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="product_reviews", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.AddConstraint( + model_name="productreview", + constraint=models.UniqueConstraint( + fields=("user", "product"), + name="shop_review_user_product", + ), + ), + migrations.AddConstraint( + model_name="productreview", + constraint=models.CheckConstraint( + condition=models.Q(("rating__gte", 1), ("rating__lte", 5)), + name="shop_review_rating_range", + ), + ), + ] diff --git a/site/shop/models.py b/site/shop/models.py index 23073c5..047e78a 100644 --- a/site/shop/models.py +++ b/site/shop/models.py @@ -1,5 +1,7 @@ from decimal import Decimal +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from django.utils.text import slugify @@ -187,6 +189,13 @@ class Order(UUIDPrimaryKeyModel, TimeStampedModel): CANCELLED = "cancelled", "Cancelled" number = models.CharField(max_length=32, unique=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="shop_orders", + ) email = models.EmailField() customer_name = models.CharField(max_length=200, blank=True) status = models.CharField( @@ -243,3 +252,38 @@ class OrderItem(UUIDPrimaryKeyModel, TimeStampedModel): @property def line_total(self) -> Decimal: return self.unit_price * self.quantity + + +class ProductReview(UUIDPrimaryKeyModel, TimeStampedModel): + product = models.ForeignKey( + Product, on_delete=models.CASCADE, related_name="reviews" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="product_reviews", + ) + order = models.ForeignKey( + Order, on_delete=models.CASCADE, related_name="reviews" + ) + rating = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(5)] + ) + title = models.CharField(max_length=120, blank=True) + body = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "product"], + name="shop_review_user_product", + ), + models.CheckConstraint( + condition=models.Q(rating__gte=1) & models.Q(rating__lte=5), + name="shop_review_rating_range", + ), + ] + + def __str__(self) -> str: + return f"{self.rating}★ {self.product.name}" diff --git a/site/shop/public_urls.py b/site/shop/public_urls.py index dc4f327..8b475fb 100644 --- a/site/shop/public_urls.py +++ b/site/shop/public_urls.py @@ -12,5 +12,6 @@ urlpatterns = [ 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("/review/", views.product_review, name="review"), path("/", views.product_detail, name="detail"), ] diff --git a/site/shop/services.py b/site/shop/services.py index bd3b530..8478ce6 100644 --- a/site/shop/services.py +++ b/site/shop/services.py @@ -10,11 +10,11 @@ from decimal import Decimal from django.conf import settings from django.db import transaction -from django.db.models import F, Sum +from django.db.models import F, Q, Sum from django.utils import timezone from core.models import StoredFile -from shop.models import Order, OrderItem, Product, ProductColor, ProductImage +from shop.models import Order, OrderItem, Product, ProductColor, ProductImage, ProductReview logger = logging.getLogger(__name__) @@ -254,6 +254,7 @@ def create_order_from_cart( customer_name: str = "", shipping_address: dict | None = None, notes: str = "", + user=None, ) -> Order: lines = cart_lines(session) if not lines: @@ -267,6 +268,7 @@ def create_order_from_cart( with transaction.atomic(): order = Order.objects.create( number=next_order_number(), + user=user if getattr(user, "is_authenticated", False) else None, email=email, customer_name=(customer_name or "").strip(), status=Order.Status.DRAFT, @@ -295,6 +297,42 @@ def create_order_from_cart( return order +def ensure_stripe_customer(user) -> str: + """Create or reuse a Stripe Customer. Card data never leaves Stripe.""" + if not user or not getattr(user, "is_authenticated", False): + return "" + from accounts.services import get_customer_profile + + profile = get_customer_profile(user) + if profile.stripe_customer_id: + return profile.stripe_customer_id + stripe = _stripe() + customer = stripe.Customer.create( + email=(user.email or user.username or "") or None, + name=(user.get_full_name() or "") or None, + metadata={"user_id": str(user.pk)}, + ) + customer_id = getattr(customer, "id", None) or customer.get("id") or "" + if not customer_id: + raise ShopError("Stripe did not return a customer id.") + profile.stripe_customer_id = customer_id + profile.save(update_fields=["stripe_customer_id", "updated_at"]) + return customer_id + + +def remember_stripe_customer(order: Order, customer_id: str) -> None: + customer_id = (customer_id or "").strip() + if not customer_id or not order.user_id: + return + from accounts.services import get_customer_profile + + profile = get_customer_profile(order.user) + if profile.stripe_customer_id: + return + profile.stripe_customer_id = customer_id + profile.save(update_fields=["stripe_customer_id", "updated_at"]) + + def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str: stripe = _stripe() line_items = [ @@ -312,14 +350,27 @@ def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) ] 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, - ) + params = { + "mode": "payment", + "line_items": line_items, + "metadata": {"shop_order_id": str(order.pk), "order_number": order.number}, + "success_url": success_url, + "cancel_url": cancel_url, + } + customer_id = "" + if order.user_id: + try: + customer_id = ensure_stripe_customer(order.user) + except ShopError: + raise + except Exception: + logger.exception("stripe customer create failed for order %s", order.number) + if customer_id: + params["customer"] = customer_id + params["payment_intent_data"] = {"setup_future_usage": "on_session"} + else: + params["customer_email"] = order.email or None + session = stripe.checkout.Session.create(**params) order.stripe_checkout_session_id = session.id order.hosted_checkout_url = session.url or "" order.status = Order.Status.OPEN @@ -353,7 +404,9 @@ def _notify_pos(order: Order) -> None: enqueue_online_sale(order) -def mark_paid(order: Order, *, stripe_id: str = "") -> None: +def mark_paid( + order: Order, *, stripe_id: str = "", stripe_customer_id: str = "" +) -> None: if order.status == Order.Status.PAID: return with transaction.atomic(): @@ -365,6 +418,7 @@ def mark_paid(order: Order, *, stripe_id: str = "") -> None: locked.paid_at = timezone.now() locked.save(update_fields=["status", "paid_at", "updated_at"]) order.refresh_from_db() + remember_stripe_customer(order, stripe_customer_id) try: send_order_email(order) except Exception: @@ -614,3 +668,28 @@ def product_media_payload(product: Product, colors: list[ProductColor]) -> dict: "available": available_qty(product, color), } return {"shared": shared, "stl": product.stl_url, "colors": color_data} + + +_REVIEWABLE_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED) + + +def qualifying_order_for_review(user, product: Product) -> Order | None: + if not user or not getattr(user, "is_authenticated", False): + return None + email = (user.email or user.username or "").strip() + qs = ( + Order.objects.filter( + items__product=product, + status__in=_REVIEWABLE_STATUSES, + ) + .filter(Q(user=user) | Q(email__iexact=email)) + .distinct() + .order_by("-paid_at", "-created_at") + ) + return qs.first() + + +def user_has_reviewed(user, product: Product) -> bool: + if not user or not getattr(user, "is_authenticated", False): + return False + return ProductReview.objects.filter(user=user, product=product).exists() diff --git a/site/shop/templates/shop/account/order_detail.html b/site/shop/templates/shop/account/order_detail.html new file mode 100644 index 0000000..9e0d7a4 --- /dev/null +++ b/site/shop/templates/shop/account/order_detail.html @@ -0,0 +1,49 @@ +{% extends "accounts/account_base.html" %} +{% block title %}Order {{ order.number }} · {{ SITE_NAME }}{% endblock %} +{% block account_content %} +

    Order {{ order.number }}

    +

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

    + {% if order.customer_name %}

    {{ order.customer_name }}

    {% endif %} + {% if order.shipping_address.line1 %} +

    + {{ order.shipping_address.line1 }}{% if order.shipping_address.line2 %}, {{ order.shipping_address.line2 }}{% endif %}
    + {{ order.shipping_address.city }} {{ order.shipping_address.state }} {{ order.shipping_address.zip }} +

    + {% endif %} +
      + {% for item in order.items.all %} +
    • + {{ item.quantity }}× {{ item.name }} + ${{ item.line_total }} +
    • + {% endfor %} +
    +

    Shipments

    + {% for shipment in order.shipments.all %} +
    +

    + {{ shipment.carrier }} {{ shipment.service }} + · {{ shipment.get_status_display }} + {% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %} +

    + {% if shipment.tracking_number %} +

    + Tracking {{ shipment.tracking_number }} + {% if shipment.tracking_url %} + · Track package + {% endif %} +

    + {% endif %} + {% if shipment.tracking_events %} +
      + {% for event in shipment.tracking_events %} +
    • {{ event.datetime }} — {{ event.message|default:event.status }}{% if event.location %} ({{ event.location }}){% endif %}
    • + {% endfor %} +
    + {% endif %} +
    + {% empty %} +

    Not shipped yet. Tracking appears here once a label is bought or a tracking number is added.

    + {% endfor %} +

    ← All orders

    +{% endblock %} diff --git a/site/shop/templates/shop/account/orders.html b/site/shop/templates/shop/account/orders.html new file mode 100644 index 0000000..505b2c9 --- /dev/null +++ b/site/shop/templates/shop/account/orders.html @@ -0,0 +1,25 @@ +{% extends "accounts/account_base.html" %} +{% block title %}Orders · {{ SITE_NAME }}{% endblock %} +{% block account_content %} +

    Order history

    + {% if orders %} +
      + {% for order in orders %} +
    • + + {{ order.number }} + · {{ order.get_status_display }} + {% for shipment in order.shipments.all %} + {% if forloop.first and shipment.tracking_number %} + · {{ shipment.get_tracking_status_display|default:"Shipped" }} + {% endif %} + {% endfor %} + + ${{ order.amount }} +
    • + {% endfor %} +
    + {% else %} +

    No orders yet. Browse the shop.

    + {% endif %} +{% endblock %} diff --git a/site/shop/templates/shop/checkout.html b/site/shop/templates/shop/checkout.html index 1034f03..ea40793 100644 --- a/site/shop/templates/shop/checkout.html +++ b/site/shop/templates/shop/checkout.html @@ -11,49 +11,54 @@

    Shipping

    + {% if user.is_authenticated %} +

    Signed in as {{ user.email }}. Cards stay on Stripe; we never store card numbers. Edit profile

    + {% else %} +

    Have an account? Sign in to prefill shipping and save cards on Stripe. Or create one.

    + {% endif %} {% csrf_token %}
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    diff --git a/site/shop/templates/shop/detail.html b/site/shop/templates/shop/detail.html index 8af3899..6e09b08 100644 --- a/site/shop/templates/shop/detail.html +++ b/site/shop/templates/shop/detail.html @@ -94,6 +94,57 @@
    +
    +
    +

    Reviews

    + {% if review_count %} +

    {{ review_avg|floatformat:1 }} / 5 · {{ review_count }} review{{ review_count|pluralize }}

    + {% else %} +

    No reviews yet.

    + {% endif %} + {% if can_review %} + + {% csrf_token %} +
    + + +
    +
    + + +
    +
    + + +
    + + + {% elif already_reviewed %} +

    You already reviewed this product.

    + {% elif user.is_authenticated %} +

    Buy this product to leave a review.

    + {% else %} +

    Sign in after a purchase to leave a review.

    + {% endif %} +
      + {% for review in reviews %} +
    • + {{ review.rating }}/5 + {% if review.title %} · {{ review.title }}{% endif %} + — {{ review.user.first_name|default:review.user.email }} + {% if review.body %}

      {{ review.body|linebreaks }}

      {% endif %} +
    • + {% endfor %} +
    +
    +
    {% endblock %} diff --git a/site/shop/templates/shop/portal/order_detail.html b/site/shop/templates/shop/portal/order_detail.html index b8ba9e8..db1721f 100644 --- a/site/shop/templates/shop/portal/order_detail.html +++ b/site/shop/templates/shop/portal/order_detail.html @@ -17,5 +17,26 @@ {% endfor %} +{% if order.shipments.all %} +

    Shipments

    + + + + {% for shipment in order.shipments.all %} + + + + + + {% endfor %} + +
    CarrierTrackingStatus
    {{ shipment.carrier }} {{ shipment.service }} + {% if shipment.tracking_url %} + {{ shipment.tracking_number }} + {% else %} + {{ shipment.tracking_number|default:"—" }} + {% endif %} + {{ shipment.get_tracking_status_display|default:shipment.get_status_display }}
    +{% endif %}

    ← All orders

    {% endblock %} diff --git a/site/shop/templates/shop/success.html b/site/shop/templates/shop/success.html index 1e41098..f508b25 100644 --- a/site/shop/templates/shop/success.html +++ b/site/shop/templates/shop/success.html @@ -7,7 +7,13 @@

    Thank you

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

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

    - Continue shopping + {% if user.is_authenticated %} +

    View order

    + {% else %} +

    Create an account to track shipping and review products you bought. Card details stay with Stripe.

    +

    Create account

    + {% endif %} + Continue shopping
    {% endblock %} diff --git a/site/shop/tests.py b/site/shop/tests.py index ee26efb..f7eaf06 100644 --- a/site/shop/tests.py +++ b/site/shop/tests.py @@ -24,6 +24,7 @@ from shop.services import ( add_to_cart, adjust_stock, available_qty, + create_checkout_session, create_order_from_cart, looks_like_stl, mark_paid, @@ -370,7 +371,7 @@ class ShopInventoryTests(TestCase): class ShopPortalTests(TestCase): def setUp(self): User = get_user_model() - self.user = User.objects.create_user("merchant", password="test-pass-123") + self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True) self.client = Client() self.client.login(username="merchant", password="test-pass-123") @@ -663,7 +664,7 @@ def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None): class ShopSalesDashboardTests(TestCase): def setUp(self): User = get_user_model() - self.user = User.objects.create_user("merchant", password="test-pass-123") + self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True) self.client = Client() self.client.login(username="merchant", password="test-pass-123") self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00")) @@ -741,3 +742,98 @@ class ShopSalesDashboardTests(TestCase): sales = self.client.get(reverse("shop_portal:sales")) self.assertContains(sales, 'class="active"') + +class ShopAccountAndReviewTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="buyer@example.com", + email="buyer@example.com", + password="s3cure-pass-123", + ) + self.product = _product() + self.client = Client() + self.client.login(username="buyer@example.com", password="s3cure-pass-123") + + def test_checkout_attaches_user_and_uses_stripe_customer(self): + session = self.client.session + add_to_cart(session, self.product, 1) + session.save() + captured = {} + + class FakeCustomer: + id = "cus_abc" + + class FakeCheckout: + id = "cs_abc" + url = "https://stripe.test/pay" + + class FakeStripe: + class Customer: + @staticmethod + def create(**kwargs): + captured["customer"] = kwargs + return FakeCustomer() + + class checkout: + class Session: + @staticmethod + def create(**kwargs): + captured["session"] = kwargs + return FakeCheckout() + + with patch("shop.services._stripe", return_value=FakeStripe): + order = create_order_from_cart( + self.client.session, + email="buyer@example.com", + user=self.user, + ) + url = create_checkout_session( + order, + success_url="https://example.test/ok", + cancel_url="https://example.test/no", + ) + self.assertEqual(url, "https://stripe.test/pay") + self.assertEqual(order.user, self.user) + self.assertEqual(captured["session"]["customer"], "cus_abc") + self.assertNotIn("customer_email", captured["session"]) + self.assertEqual( + captured["session"]["payment_intent_data"]["setup_future_usage"], + "on_session", + ) + self.user.customer_profile.refresh_from_db() + self.assertEqual(self.user.customer_profile.stripe_customer_id, "cus_abc") + + def test_review_requires_purchase(self): + blocked = self.client.post( + reverse("shop:review", kwargs={"slug": self.product.slug}), + {"rating": "5", "title": "Nope", "body": "Did not buy"}, + ) + self.assertEqual(blocked.status_code, 302) + self.assertEqual(self.product.reviews.count(), 0) + + order = _sold_order(self.product) + order.user = self.user + order.email = self.user.email + order.save(update_fields=["user", "email"]) + ok = self.client.post( + reverse("shop:review", kwargs={"slug": self.product.slug}), + {"rating": "5", "title": "Great", "body": "Loved it"}, + ) + self.assertEqual(ok.status_code, 302) + review = self.product.reviews.get() + self.assertEqual(review.rating, 5) + self.assertEqual(review.user, self.user) + detail = self.client.get(self.product.get_absolute_url()) + self.assertContains(detail, "Great") + self.assertContains(detail, "You already reviewed") + + def test_order_history_hides_other_users(self): + mine = _sold_order(self.product, number="ORD-MINE") + mine.user = self.user + mine.save(update_fields=["user"]) + _sold_order(self.product, number="ORD-THEIRS") + page = self.client.get(reverse("account:orders")) + self.assertContains(page, "ORD-MINE") + self.assertNotContains(page, "ORD-THEIRS") + diff --git a/site/shop/views.py b/site/shop/views.py index 91e59f3..faa9417 100644 --- a/site/shop/views.py +++ b/site/shop/views.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.db import transaction -from django.db.models import Prefetch +from django.db.models import Avg, Count, Prefetch from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -14,7 +14,7 @@ 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, ProductColor, ProductImage +from shop.models import Order, Product, ProductColor, ProductImage, ProductReview from shop.services import ( ShopError, add_to_cart, @@ -27,12 +27,14 @@ from shop.services import ( create_order_from_cart, mark_paid, product_media_payload, + qualifying_order_for_review, refresh_listing_image, remove_product_images, save_cart, set_cart_qty, store_product_stl, sync_product_colors, + user_has_reviewed, ) from shop.stats import sales_dashboard @@ -85,6 +87,16 @@ def product_detail(request, slug): ) colors = list(product.colors.all()) selected = colors[0] if colors else None + reviews = list(product.reviews.select_related("user").all()[:50]) + stats = product.reviews.aggregate(avg=Avg("rating"), n=Count("id")) + can_review = False + already_reviewed = False + if request.user.is_authenticated: + already_reviewed = user_has_reviewed(request.user, product) + can_review = ( + not already_reviewed + and qualifying_order_for_review(request.user, product) is not None + ) return render( request, "shop/detail.html", @@ -96,10 +108,47 @@ def product_detail(request, slug): "gallery_items": product.gallery_items(selected), "gallery_photos": product.photos_for(selected), "gallery_data": product_media_payload(product, colors), + "reviews": reviews, + "review_avg": stats["avg"], + "review_count": stats["n"] or 0, + "can_review": can_review, + "already_reviewed": already_reviewed, }, ) +@login_required(login_url="account:login") +@require_POST +def product_review(request, slug): + product = get_object_or_404(Product, slug=slug, is_published=True) + if user_has_reviewed(request.user, product): + messages.info(request, "You already reviewed this product.") + return redirect("shop:detail", slug=product.slug) + order = qualifying_order_for_review(request.user, product) + if order is None: + messages.error(request, "Only customers who purchased this product can review it.") + return redirect("shop:detail", slug=product.slug) + try: + rating = int(request.POST.get("rating") or "0") + except ValueError: + rating = 0 + if rating < 1 or rating > 5: + messages.error(request, "Choose a rating from 1 to 5.") + return redirect("shop:detail", slug=product.slug) + title = (request.POST.get("title") or "").strip()[:120] + body = (request.POST.get("body") or "").strip() + ProductReview.objects.create( + product=product, + user=request.user, + order=order, + rating=rating, + title=title, + body=body, + ) + messages.success(request, "Thanks for the review.") + return redirect("shop:detail", slug=product.slug) + + def cart_view(request): lines = cart_lines(request.session) return render( @@ -169,12 +218,18 @@ def checkout(request): state=request.POST.get("address_state") or "", zip_code=request.POST.get("address_zip") or "", ) + buyer = request.user if request.user.is_authenticated else None + if buyer: + email = (buyer.email or buyer.username or email).strip() + if not name: + name = buyer.get_full_name() try: order = create_order_from_cart( request.session, email=email, customer_name=name, shipping_address=address, + user=buyer, ) base = _site_base(request) success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk}) @@ -192,10 +247,28 @@ def checkout(request): else: save_cart(request.session, {}) return redirect(url) + checkout_initial = { + "email": "", + "customer_name": "", + "address": {}, + } + if request.user.is_authenticated: + from accounts.services import get_customer_profile + + profile = get_customer_profile(request.user) + checkout_initial = { + "email": request.user.email or request.user.username, + "customer_name": request.user.get_full_name(), + "address": profile.shipping_address or {}, + } return render( request, "shop/checkout.html", - {"lines": lines, "total": cart_total(lines)}, + { + "lines": lines, + "total": cart_total(lines), + "checkout_initial": checkout_initial, + }, ) @@ -361,10 +434,59 @@ def portal_order_list(request): @login_required def portal_order_detail(request, pk): - order = get_object_or_404(Order.objects.prefetch_related("items"), pk=pk) + order = get_object_or_404( + Order.objects.prefetch_related("items", "shipments"), pk=pk + ) return render(request, "shop/portal/order_detail.html", {"order": order}) +@login_required(login_url="account:login") +def account_order_list(request): + from accounts.services import claim_orders_for_user + + claim_orders_for_user(request.user) + orders = ( + Order.objects.filter(user=request.user) + .prefetch_related("items", "shipments") + .exclude(status=Order.Status.DRAFT) + ) + return render(request, "shop/account/orders.html", {"orders": orders}) + + +@login_required(login_url="account:login") +def account_order_detail(request, pk): + order = get_object_or_404( + Order.objects.prefetch_related("items", "shipments"), + pk=pk, + user=request.user, + ) + from django.apps import apps as django_apps + + if django_apps.is_installed("shipping"): + from datetime import timedelta + + from django.utils import timezone + from shipping.models import Shipment + from shipping.services import refresh_tracking + + stale_after = timezone.now() - timedelta(minutes=15) + for shipment in order.shipments.all(): + if ( + shipment.status == Shipment.Status.LABELED + and shipment.tracking_number + and shipment.tracking_status != Shipment.TrackingStatus.DELIVERED + and ( + shipment.last_tracked_at is None + or shipment.last_tracked_at < stale_after + ) + ): + try: + refresh_tracking(shipment) + except Exception: + logger.exception("order tracking refresh failed for %s", order.number) + return render(request, "shop/account/order_detail.html", {"order": order}) + + @csrf_exempt @require_http_methods(["POST"]) def stripe_webhook(request): @@ -394,6 +516,10 @@ def stripe_webhook(request): 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 "") + mark_paid( + order, + stripe_id=obj.get("id") or "", + stripe_customer_id=obj.get("customer") or "", + ) logger.info("shop order %s marked paid", order.number) return HttpResponse("ok") diff --git a/site/social/tests.py b/site/social/tests.py index 95aed9e..06e81fd 100644 --- a/site/social/tests.py +++ b/site/social/tests.py @@ -49,7 +49,7 @@ class SocialComposerTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="composer", password="test-pass-123" + username="composer", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="composer", password="test-pass-123") @@ -136,7 +136,7 @@ class SocialAccountRenameTests(TestCase): def setUp(self): User = get_user_model() self.user = User.objects.create_user( - username="renamer", password="test-pass-123" + username="renamer", password="test-pass-123", is_staff=True ) self.client = Client() self.client.login(username="renamer", password="test-pass-123")