From 5b11cc18c7780412a03691bb16da7379833d9e10 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 7 Sep 2026 08:35:55 -0500 Subject: [PATCH] Add shopper accounts, reviews, tracking, and seed_demo (#9) Closes #9. Shop-gated buyer accounts, purchase reviews, Stripe customer ids, shipment tracking, slim public contact form, and a template-neutral seed_demo command. --- .env.example | 1 + .env.prod.example | 1 + site/accounts/admin.py | 9 +- site/accounts/customer_urls.py | 62 ++ site/accounts/forms.py | 178 ++++ site/accounts/middleware.py | 27 + .../migrations/0002_customerprofile.py | 49 + site/accounts/models.py | 16 + site/accounts/services.py | 22 + .../templates/accounts/account_base.html | 23 + .../templates/accounts/customer_login.html | 40 + .../accounts/password_reset_complete.html | 15 + .../accounts/password_reset_confirm.html | 40 + .../accounts/password_reset_done.html | 15 + .../accounts/password_reset_email.txt | 8 + .../accounts/password_reset_form.html | 31 + .../accounts/password_reset_subject.txt | 1 + site/accounts/templates/accounts/profile.html | 84 ++ .../accounts/templates/accounts/register.html | 59 ++ site/accounts/tests.py | 128 +++ site/accounts/urls.py | 4 +- site/accounts/views.py | 123 +++ site/analytics/tests.py | 3 +- site/blog/tests.py | 2 +- site/client_site/settings/base.py | 2 + site/client_site/templates/base.html | 20 +- site/client_site/urls.py | 1 + site/contacts/tests_merge.py | 35 +- site/core/management/commands/seed_demo.py | 848 ++++++++++++++++++ site/core/tests.py | 111 +++ site/core/tests_campaign_utm.py | 3 +- site/core/tests_features.py | 2 + site/directmail/tests.py | 6 +- site/email_sms/tests.py | 20 +- site/events/tests.py | 2 +- site/payments/tests.py | 2 +- site/pos_sync/tests.py | 2 +- site/public/forms.py | 79 -- site/public/middleware.py | 4 + site/public/notifications.py | 23 +- .../templates/emails/contact_email.html | 9 - .../public/templates/emails/contact_email.txt | 10 +- site/public/templates/public/contact.html | 71 +- site/public/views.py | 29 +- site/shipping/hooks.py | 8 + .../migrations/0002_shipment_tracking.py | 54 ++ site/shipping/models.py | 22 + site/shipping/services.py | 173 ++++ site/shipping/templates/shipping/detail.html | 46 +- site/shipping/tests.py | 57 +- site/shipping/urls.py | 11 + site/shipping/views.py | 75 +- site/shop/admin.py | 10 +- .../migrations/0002_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 | 48 + site/shop/templates/shop/account/orders.html | 18 + site/shop/templates/shop/checkout.html | 28 +- site/shop/templates/shop/detail.html | 41 + .../templates/shop/portal/order_detail.html | 21 + site/shop/templates/shop/success.html | 7 + site/shop/tests.py | 105 ++- site/shop/views.py | 143 ++- site/social/tests.py | 6 +- 66 files changed, 3051 insertions(+), 285 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/password_reset_complete.html create mode 100644 site/accounts/templates/accounts/password_reset_confirm.html create mode 100644 site/accounts/templates/accounts/password_reset_done.html create mode 100644 site/accounts/templates/accounts/password_reset_email.txt create mode 100644 site/accounts/templates/accounts/password_reset_form.html create mode 100644 site/accounts/templates/accounts/password_reset_subject.txt 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/core/management/commands/seed_demo.py create mode 100644 site/shipping/migrations/0002_shipment_tracking.py create mode 100644 site/shop/migrations/0002_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 d889881..19f0365 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,7 @@ POS_API_TOKEN= # Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works. EASYPOST_API_KEY= +EASYPOST_WEBHOOK_SECRET= SHIP_FROM_LINE1= SHIP_FROM_CITY= SHIP_FROM_STATE= diff --git a/.env.prod.example b/.env.prod.example index 5e51741..84f2235 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -77,6 +77,7 @@ POS_API_TOKEN= # Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP) EASYPOST_API_KEY= +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..c72d1cc --- /dev/null +++ b/site/accounts/customer_urls.py @@ -0,0 +1,62 @@ +from django.apps import apps +from django.contrib.auth import views as auth_views +from django.urls import path, reverse_lazy + +from accounts import views +from accounts.forms import CustomerPasswordResetForm, CustomerSetPasswordForm + +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"), + path( + "password-reset/", + auth_views.PasswordResetView.as_view( + template_name="accounts/password_reset_form.html", + email_template_name="accounts/password_reset_email.txt", + subject_template_name="accounts/password_reset_subject.txt", + form_class=CustomerPasswordResetForm, + success_url=reverse_lazy("account:password_reset_done"), + ), + name="password_reset", + ), + path( + "password-reset/done/", + auth_views.PasswordResetDoneView.as_view( + template_name="accounts/password_reset_done.html", + ), + name="password_reset_done", + ), + path( + "password-reset///", + auth_views.PasswordResetConfirmView.as_view( + template_name="accounts/password_reset_confirm.html", + form_class=CustomerSetPasswordForm, + success_url=reverse_lazy("account:password_reset_complete"), + ), + name="password_reset_confirm", + ), + path( + "password-reset/complete/", + auth_views.PasswordResetCompleteView.as_view( + template_name="accounts/password_reset_complete.html", + ), + name="password_reset_complete", + ), +] + +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..82370b5 --- /dev/null +++ b/site/accounts/forms.py @@ -0,0 +1,178 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import ( + AuthenticationForm, + PasswordResetForm, + SetPasswordForm, + 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 CustomerPasswordResetForm(PasswordResetForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["email"].widget.attrs.update({**_INPUT, "autocomplete": "email"}) + + +class CustomerSetPasswordForm(SetPasswordForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs.update(_INPUT) + + +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..609762b --- /dev/null +++ b/site/accounts/middleware.py @@ -0,0 +1,27 @@ +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) + ): + from django.apps import apps + + if apps.is_installed("shop"): + return redirect("account:home") + return redirect("public: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..9239c49 --- /dev/null +++ b/site/accounts/templates/accounts/account_base.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Account · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+
+
+
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..38f195b --- /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 %} +
+
+
+
+

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 + · Forgot password?

+
+
+
+
+{% endblock %} diff --git a/site/accounts/templates/accounts/password_reset_complete.html b/site/accounts/templates/accounts/password_reset_complete.html new file mode 100644 index 0000000..59c433d --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_complete.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Password updated · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+
+
+

Password updated

+

You can sign in with your new password.

+

Sign in

+
+
+
+
+{% endblock %} diff --git a/site/accounts/templates/accounts/password_reset_confirm.html b/site/accounts/templates/accounts/password_reset_confirm.html new file mode 100644 index 0000000..d317432 --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_confirm.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Choose a new password · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+
+
+

Choose a new password

+ {% if validlink %} +
+ {% csrf_token %} + {{ form.non_field_errors }} +
+
+
+ + {{ form.new_password1 }} + {{ form.new_password1.errors }} +
+
+
+
+ + {{ form.new_password2 }} + {{ form.new_password2.errors }} +
+
+
+ +
+
+
+ {% else %} +

This reset link is invalid or expired. Request a new one.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/site/accounts/templates/accounts/password_reset_done.html b/site/accounts/templates/accounts/password_reset_done.html new file mode 100644 index 0000000..ccdbb9c --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_done.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Check your email · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+
+
+

Check your email

+

If an account exists for that address, a reset link is on its way. Check spam if you do not see it.

+

Back to sign in

+
+
+
+
+{% endblock %} diff --git a/site/accounts/templates/accounts/password_reset_email.txt b/site/accounts/templates/accounts/password_reset_email.txt new file mode 100644 index 0000000..91bb5ea --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_email.txt @@ -0,0 +1,8 @@ +{% load i18n %}{% autoescape off %} +Reset your {{ site_name }} password + +Use this link to choose a new password (it expires): +{{ protocol }}://{{ domain }}{% url 'account:password_reset_confirm' uidb64=uid token=token %} + +If you did not ask for a reset, ignore this email. +{% endautoescape %} diff --git a/site/accounts/templates/accounts/password_reset_form.html b/site/accounts/templates/accounts/password_reset_form.html new file mode 100644 index 0000000..c441b2a --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_form.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Reset password · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+
+
+

Reset password

+

Enter the email on your account. We will send a reset link if it matches.

+
+ {% csrf_token %} + {{ form.non_field_errors }} +
+
+
+ + {{ form.email }} + {{ form.email.errors }} +
+
+
+ +
+
+
+

Back to sign in

+
+
+
+
+{% endblock %} diff --git a/site/accounts/templates/accounts/password_reset_subject.txt b/site/accounts/templates/accounts/password_reset_subject.txt new file mode 100644 index 0000000..03ff23c --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_subject.txt @@ -0,0 +1 @@ +Password reset for {{ site_name }} 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..b60a731 --- /dev/null +++ b/site/accounts/templates/accounts/register.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Create account · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+
+
+

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..693c440 --- /dev/null +++ b/site/accounts/tests.py @@ -0,0 +1,128 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.core import mail +from django.test import Client, TestCase +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +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")) + + def test_password_reset_sends_mail_and_sets_new_password(self): + user = User.objects.create_user( + username="buyer@example.com", + email="buyer@example.com", + password="s3cure-pass-123", + ) + client = Client() + login_page = client.get(reverse("account:login")) + self.assertContains(login_page, reverse("account:password_reset")) + posted = client.post( + reverse("account:password_reset"), + {"email": "buyer@example.com"}, + ) + self.assertEqual(posted.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn("password-reset", mail.outbox[0].body) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = default_token_generator.make_token(user) + confirm_url = reverse( + "account:password_reset_confirm", + kwargs={"uidb64": uid, "token": token}, + ) + bounced = client.get(confirm_url) + self.assertEqual(bounced.status_code, 302) + set_url = bounced["Location"] + saved = client.post( + set_url, + { + "new_password1": "n3wer-pass-456", + "new_password2": "n3wer-pass-456", + }, + ) + self.assertEqual(saved.status_code, 302) + user.refresh_from_db() + self.assertTrue(user.check_password("n3wer-pass-456")) 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..2864e6c --- /dev/null +++ b/site/accounts/views.py @@ -0,0 +1,123 @@ +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") + from django.apps import apps + + if apps.is_installed("shop"): + return reverse("account:home") + return reverse("public: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..295da51 100644 --- a/site/analytics/tests.py +++ b/site/analytics/tests.py @@ -60,8 +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) def test_views_card_uses_public_pageviews(self): 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/client_site/settings/base.py b/site/client_site/settings/base.py index ca089e7..e045a26 100644 --- a/site/client_site/settings/base.py +++ b/site/client_site/settings/base.py @@ -183,6 +183,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "accounts.middleware.PortalStaffMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "analytics.middleware.UTMTrackingMiddleware", @@ -381,6 +382,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/client_site/templates/base.html b/site/client_site/templates/base.html index 63badf9..f3e8f13 100644 --- a/site/client_site/templates/base.html +++ b/site/client_site/templates/base.html @@ -90,6 +90,19 @@ {{ item.label }} {% endfor %} + {% if user.is_authenticated %} +
  • + {% if user.is_staff %} + Portal + {% elif "shop" in enabled_features %} + Account + {% endif %} +
  • + {% elif "shop" in enabled_features %} +
  • + Sign in +
  • + {% endif %} @@ -134,9 +147,14 @@ {% for item in public_nav_extra %}
  • {{ item.label }}
  • {% endfor %} - {% if user.is_authenticated %} + {% if user.is_authenticated and not user.is_staff and "shop" in enabled_features %} +
  • Account
  • + {% elif user.is_authenticated %}
  • Client portal
  • {% else %} + {% if "shop" in enabled_features %} +
  • Sign in
  • + {% endif %}
  • Client portal
  • {% endif %} diff --git a/site/client_site/urls.py b/site/client_site/urls.py index eaaf271..d0d8e42 100644 --- a/site/client_site/urls.py +++ b/site/client_site/urls.py @@ -50,6 +50,7 @@ if apps.is_installed("social_ai"): ] if apps.is_installed("shop"): urlpatterns += [ + path("account/", include("accounts.customer_urls")), path("shop/", include("shop.public_urls")), path("portal/shop/", include("shop.portal_urls")), ] diff --git a/site/contacts/tests_merge.py b/site/contacts/tests_merge.py index 86b35a7..9515935 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,33 @@ 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": "quote", + "message": "Need a quote", + "first_name": "Ignored", + "phone": "6301112222", + "address_line1": "55 River Rd", + }, + ) + self.assertEqual(response.status_code, 302) + created = Contact.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") diff --git a/site/core/management/commands/seed_demo.py b/site/core/management/commands/seed_demo.py new file mode 100644 index 0000000..fe448f9 --- /dev/null +++ b/site/core/management/commands/seed_demo.py @@ -0,0 +1,848 @@ +"""Populate a demo catalog, portal lists, and shop traffic for client walkthroughs. + +Safe on DJANGO_ENV=dev and beta only. Never runs against prod. Does not send +mail or call EasyPost/Stripe. Re-runs are idempotent (DEMO- / demo+ keys). +Pass --reset to wipe tagged demo rows and seed again. + + uv run python manage.py seed_demo + docker compose exec web uv run python manage.py seed_demo --reset +""" + +from __future__ import annotations + +import os +from datetime import timedelta +from decimal import Decimal + +from django.apps import apps +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +DEMO_NOTE = "[demo-seed]" +DEMO_UTM_CAMPAIGN = "site-demo" +DEMO_EMAIL_DOMAIN = "@example.com" +DEMO_EMAIL_PREFIX = "demo+" +DEMO_SHOPPER_PASSWORD = "Demo-buyer-pass-123" +DEMO_SHOPPER_SLUGS = ("jordan", "casey", "sam") + + +def _django_env() -> str: + return (os.environ.get("DJANGO_ENV") or "dev").lower() + + +def _stamp(model, pk, when) -> None: + model.objects.filter(pk=pk).update(created_at=when, updated_at=when) + + +class Command(BaseCommand): + help = ( + "Seed tagged fake catalog, shoppers, reviews, tracking, contacts, " + "leads, orders, campaigns, and analytics for client demos. " + "Allowed on DJANGO_ENV=dev and beta only. Demo shoppers use " + f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--reset", + action="store_true", + help="Delete previously tagged demo rows, then seed again.", + ) + + def handle(self, *args, **options): + env = _django_env() + if env == "prod": + raise CommandError( + "seed_demo refuses DJANGO_ENV=prod. Run it on dev or beta." + ) + self.verbosity = int(options.get("verbosity", 1)) + self.now = timezone.now() + if options["reset"]: + self._reset() + self.stdout.write("Cleared tagged demo rows.") + User = get_user_model() + self.owner = User.objects.filter(is_staff=True).order_by("pk").first() + if self.owner is None: + self.owner = ( + User.objects.exclude(username__startswith=DEMO_EMAIL_PREFIX) + .order_by("pk") + .first() + ) + counts = { + "contacts": self._seed_contacts(), + "leads": self._seed_leads(), + "analytics": self._seed_analytics(), + } + if apps.is_installed("shop"): + counts["products"] = self._seed_products() + counts["shoppers"] = self._seed_shoppers() + counts["orders"] = self._seed_orders() + counts["reviews"] = self._seed_reviews() + if apps.is_installed("shipping"): + counts["shipments"] = self._seed_shipments() + if apps.is_installed("payments"): + counts["invoices"] = self._seed_invoices() + if apps.is_installed("email_sms"): + counts["campaigns"] = self._seed_campaigns() + summary = ", ".join(f"{key}={value}" for key, value in counts.items()) + self.stdout.write(self.style.SUCCESS(f"Demo seed ready on {env} ({summary}).")) + if apps.is_installed("shop"): + self.stdout.write( + "Demo shopper: " + f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}" + ) + + def _log(self, message: str) -> None: + if self.verbosity >= 2: + self.stdout.write(message) + + def _reset(self) -> None: + from analytics.models import UTMVisit + from contacts.models import Contact + from leads.models import Lead + + if apps.is_installed("shipping"): + from shipping.models import Shipment + + Shipment.objects.filter(order__number__startswith="DEMO-").delete() + if apps.is_installed("shop"): + from shop.models import Order, Product + + Order.objects.filter(number__startswith="DEMO-").delete() + Product.objects.filter(sku__startswith="DEMO-").delete() + if apps.is_installed("payments"): + from payments.models import Invoice + + Invoice.objects.filter(number__startswith="DEMO-").delete() + if apps.is_installed("email_sms"): + from email_sms.models import Campaign + + Campaign.objects.filter(name__startswith="DEMO:").delete() + Lead.objects.filter(message__startswith=DEMO_NOTE).delete() + Contact.objects.filter( + email__startswith=DEMO_EMAIL_PREFIX, + email__endswith=DEMO_EMAIL_DOMAIN, + ).delete() + User = get_user_model() + User.objects.filter( + username__startswith=DEMO_EMAIL_PREFIX, + username__endswith=DEMO_EMAIL_DOMAIN, + is_staff=False, + ).delete() + UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).delete() + + def _seed_contacts(self) -> int: + from contacts.consent import set_channel_consent + from contacts.models import Channel, Contact + + people = ( + ( + "maya", + "Maya", + "Chen", + "630-555-0142", + Contact.Source.CONTACT_FORM, + True, + True, + "Naperville", + "Asked about bulk pricing.", + ), + ( + "jordan", + "Jordan", + "Walsh", + "630-555-0198", + Contact.Source.MANUAL, + True, + False, + "Aurora", + "Repeat buyer.", + ), + ( + "priya", + "Priya", + "Shah", + "847-555-0110", + Contact.Source.IMPORT, + True, + True, + "Wheaton", + "Event favor quote.", + ), + ( + "evan", + "Evan", + "Brooks", + "312-555-0177", + Contact.Source.NOTIFY_ME, + False, + True, + "Chicago", + "Notify list from the coming-soon page.", + ), + ( + "sam", + "Sam", + "Ortiz", + "630-555-0166", + Contact.Source.CONTACT_FORM, + True, + True, + "Lisle", + "Team gifts.", + ), + ( + "riley", + "Riley", + "Nguyen", + "708-555-0133", + Contact.Source.OTHER, + True, + False, + "Downers Grove", + "Asked about made-to-order items.", + ), + ( + "alex", + "Alex", + "Patel", + "630-555-0121", + Contact.Source.MANUAL, + False, + False, + "Naperville", + "Opted out of email after one campaign.", + ), + ( + "casey", + "Casey", + "Miller", + "815-555-0188", + Contact.Source.IMPORT, + True, + True, + "Geneva", + "School fair bulk order.", + ), + ) + created = 0 + for slug, first, last, phone, source, email_on, sms_on, city, note in people: + email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}" + contact, was_created = Contact.objects.update_or_create( + email=email, + defaults={ + "first_name": first, + "last_name": last, + "phone": phone, + "source": source, + "notes": f"{DEMO_NOTE} {note}", + "postal_address": Contact.make_postal_address( + line1=f"{100 + len(slug) * 17} Demo Ave", + city=city, + state="IL", + zip_code="60540", + ), + }, + ) + set_channel_consent( + contact, Channel.EMAIL, opted_in=email_on, reason="demo_seed" + ) + set_channel_consent( + contact, Channel.SMS, opted_in=sms_on, reason="demo_seed" + ) + if was_created: + created += 1 + self._log(f"contact {contact.email}") + return created + + def _seed_leads(self) -> int: + from analytics.models import Attribution + from contacts.models import Contact + from leads.models import Lead, LeadNote + + specs = ( + ( + "maya", + Lead.Status.NEW, + "Need a dozen units by next Friday.", + "instagram", + "social", + 2, + ), + ( + "priya", + Lead.Status.CONTACTED, + "Quoted 40 keychains. Waiting on names.", + "google", + "cpc", + 8, + ), + ( + "casey", + Lead.Status.WON, + "Fair kits — 30 tote bags, paid via invoice.", + "google", + "organic", + 18, + ), + ( + "riley", + Lead.Status.LOST, + "Wanted 200 units at a price we could not hit.", + "", + "", + 12, + ), + ) + created = 0 + for slug, status, body, source, medium, days_ago in specs: + email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}" + contact = Contact.objects.filter(email=email).first() + if contact is None: + continue + message = f"{DEMO_NOTE} {body}" + lead, was_created = Lead.objects.get_or_create( + contact=contact, + message=message, + defaults={"status": status, "owner": self.owner}, + ) + if not was_created: + if lead.status != status: + lead.status = status + lead.owner = self.owner + lead.save(update_fields=["status", "owner", "updated_at"]) + else: + created += 1 + LeadNote.objects.create( + lead=lead, + author=self.owner, + body=f"{DEMO_NOTE} Demo walkthrough note.", + ) + when = self.now - timedelta(days=days_ago) + _stamp(Lead, lead.pk, when) + if source: + Attribution.objects.update_or_create( + lead=lead, + defaults={ + "utm_source": source, + "utm_medium": medium, + "utm_campaign": DEMO_UTM_CAMPAIGN, + }, + ) + self._log(f"lead {slug} {status}") + return created + + def _seed_analytics(self) -> int: + from analytics.models import PageView, UTMVisit + + if UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).exists(): + return 0 + paths = ( + "/", + "/", + "/", + "/shop/", + "/shop/", + "/shop/demo-notebook/", + "/shop/demo-tote/", + "/about/", + "/contact/", + ) + created = 0 + for offset, path in enumerate(paths * 6): + when = self.now - timedelta(hours=4 * offset + 3) + view = PageView.objects.create(path=path) + _stamp(PageView, view.pk, when) + created += 1 + sources = ( + ("google", "cpc", "/shop/"), + ("google", "organic", "/"), + ("instagram", "social", "/shop/demo-notebook/"), + ("facebook", "social", "/contact/"), + ("direct", "", "/"), + ) + for i, (source, medium, path) in enumerate(sources * 8): + when = self.now - timedelta(hours=6 * i + 2) + visit = UTMVisit.objects.create( + correlation_id=f"demo-{i:03d}", + path=path, + utm_source=source, + utm_medium=medium, + utm_campaign=DEMO_UTM_CAMPAIGN, + user_agent="Demo seed", + ) + _stamp(UTMVisit, visit.pk, when) + created += 1 + return created + + def _seed_products(self) -> int: + from shop.models import Product + + catalog = ( + { + "sku": "DEMO-NOTEBOOK", + "name": "Notebook", + "price": Decimal("18.00"), + "fulfillment": Product.Fulfillment.STOCKED, + "stock_qty": 24, + "print_minutes": 0, + "filament_grams": 0, + "published": True, + "description": "Lined notebook. A simple catalog item for demos.", + }, + { + "sku": "DEMO-STICKER", + "name": "Sticker pack", + "price": Decimal("8.00"), + "fulfillment": Product.Fulfillment.STOCKED, + "stock_qty": 40, + "print_minutes": 0, + "filament_grams": 0, + "published": True, + "description": "Pack of five stickers.", + }, + { + "sku": "DEMO-TOTE", + "name": "Tote bag", + "price": Decimal("22.00"), + "fulfillment": Product.Fulfillment.STOCKED, + "stock_qty": 12, + "print_minutes": 0, + "filament_grams": 0, + "published": True, + "description": "Canvas tote. Good for a fulfilled demo order.", + }, + { + "sku": "DEMO-MUG", + "name": "Ceramic mug", + "price": Decimal("16.00"), + "fulfillment": Product.Fulfillment.MADE_TO_ORDER, + "stock_qty": 0, + "print_minutes": 45, + "filament_grams": 0, + "published": True, + "description": "Printed to order. Used as the guest checkout demo.", + }, + { + "sku": "DEMO-KEYCHAIN", + "name": "Custom keychain", + "price": Decimal("12.00"), + "fulfillment": Product.Fulfillment.MADE_TO_ORDER, + "stock_qty": 0, + "print_minutes": 20, + "filament_grams": 0, + "published": True, + "description": "Made to order with a name on the loop.", + }, + { + "sku": "DEMO-PROTO", + "name": "Draft product (coming soon)", + "price": Decimal("45.00"), + "fulfillment": Product.Fulfillment.MADE_TO_ORDER, + "stock_qty": 0, + "print_minutes": 90, + "filament_grams": 0, + "published": False, + "description": "Unpublished so the portal list is not empty of drafts.", + }, + ) + created = 0 + for spec in catalog: + product, was_created = Product.objects.update_or_create( + sku=spec["sku"], + defaults={ + "name": spec["name"], + "slug": spec["sku"].lower(), + "description": spec["description"], + "price": spec["price"], + "currency": "usd", + "fulfillment": spec["fulfillment"], + "stock_qty": spec["stock_qty"], + "print_minutes": spec["print_minutes"], + "filament_grams": spec["filament_grams"], + "is_published": spec["published"], + "track_inventory": True, + }, + ) + if was_created: + created += 1 + self._log(f"product {product.sku}") + return created + + def _seed_shoppers(self) -> int: + from accounts.services import get_customer_profile + from contacts.models import Contact + + User = get_user_model() + created = 0 + for slug in DEMO_SHOPPER_SLUGS: + email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}" + contact = Contact.objects.filter(email=email).first() + if contact is None: + continue + user = User.objects.filter(username__iexact=email).first() + if user is None: + user = User.objects.create_user( + username=email, + email=email, + password=DEMO_SHOPPER_PASSWORD, + first_name=contact.first_name, + last_name=contact.last_name, + is_staff=False, + ) + created += 1 + else: + user.first_name = contact.first_name + user.last_name = contact.last_name + user.email = email + user.is_staff = False + user.set_password(DEMO_SHOPPER_PASSWORD) + user.save( + update_fields=[ + "first_name", + "last_name", + "email", + "is_staff", + "password", + ] + ) + profile = get_customer_profile(user) + profile.phone = contact.phone + profile.shipping_address = contact.postal_address or {} + profile.save(update_fields=["phone", "shipping_address", "updated_at"]) + self._log(f"shopper {email}") + return created + + def _seed_orders(self) -> int: + from contacts.models import Contact + from shop.models import Order, OrderItem, Product + + notebook = Product.objects.filter(sku="DEMO-NOTEBOOK").first() + sticker = Product.objects.filter(sku="DEMO-STICKER").first() + tote = Product.objects.filter(sku="DEMO-TOTE").first() + mug = Product.objects.filter(sku="DEMO-MUG").first() + if not all([notebook, sticker, tote, mug]): + return 0 + specs = ( + ( + "DEMO-ORD-001", + "jordan", + Order.Status.PAID, + ((notebook, 1),), + 3, + ), + ( + "DEMO-ORD-002", + "casey", + Order.Status.FULFILLED, + ((tote, 2), (sticker, 1)), + 11, + ), + ( + "DEMO-ORD-003", + "sam", + Order.Status.PAID, + ((sticker, 2),), + 1, + ), + ( + "DEMO-ORD-004", + "riley", + Order.Status.OPEN, + ((mug, 1),), + 0, + ), + ( + "DEMO-ORD-005", + "alex", + Order.Status.CANCELLED, + ((notebook, 1),), + 16, + ), + ) + created = 0 + User = get_user_model() + for number, slug, status, lines, days_ago in specs: + contact = Contact.objects.filter( + email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}" + ).first() + if contact is None: + continue + buyer = User.objects.filter(username__iexact=contact.email).first() + amount = sum( + (product.price * qty for product, qty in lines), + Decimal("0.00"), + ) + paid = status in {Order.Status.PAID, Order.Status.FULFILLED} + when = self.now - timedelta(days=days_ago, hours=5) + order, was_created = Order.objects.update_or_create( + number=number, + defaults={ + "user": buyer, + "email": contact.email, + "customer_name": contact.full_name, + "status": status, + "amount": amount, + "currency": "usd", + "paid_at": when if paid else None, + "shipping_address": contact.postal_address, + "notes": f"{DEMO_NOTE} Demo order.", + }, + ) + if was_created: + created += 1 + if not order.items.exists(): + for product, qty in lines: + OrderItem.objects.create( + order=order, + product=product, + name=product.name, + sku=product.sku, + quantity=qty, + unit_price=product.price, + print_minutes=product.print_minutes, + ) + _stamp(Order, order.pk, when) + self._log(f"order {order.number} {status}") + return created + + def _seed_reviews(self) -> int: + from shop.models import Order, Product, ProductReview + + User = get_user_model() + specs = ( + ( + "jordan", + "DEMO-ORD-001", + "DEMO-NOTEBOOK", + 5, + "Solid notebook", + "Paper quality is great for daily notes.", + ), + ( + "casey", + "DEMO-ORD-002", + "DEMO-TOTE", + 4, + "Sturdy tote", + "Held up for the school fair. Would buy again.", + ), + ( + "sam", + "DEMO-ORD-003", + "DEMO-STICKER", + 5, + "Bright stickers", + "Colors popped. Kids loved them.", + ), + ) + created = 0 + for slug, order_number, sku, rating, title, body in specs: + email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}" + user = User.objects.filter(username__iexact=email).first() + order = Order.objects.filter(number=order_number, user=user).first() + product = Product.objects.filter(sku=sku).first() + if user is None or order is None or product is None: + continue + review, was_created = ProductReview.objects.update_or_create( + user=user, + product=product, + defaults={ + "order": order, + "rating": rating, + "title": title, + "body": f"{DEMO_NOTE} {body}", + }, + ) + if was_created: + created += 1 + self._log(f"review {sku} {rating}") + return created + + def _seed_shipments(self) -> int: + from shipping.models import Shipment + from shop.models import Order + + specs = ( + ( + "DEMO-ORD-002", + { + "status": Shipment.Status.LABELED, + "carrier": "USPS", + "service": "Priority", + "tracking_number": "940011189922DEMO02", + "tracking_status": Shipment.TrackingStatus.DELIVERED, + "tracking_url": ( + "https://tools.usps.com/go/TrackConfirmAction" + "?tLabels=940011189922DEMO02" + ), + "rate_amount": Decimal("8.45"), + "currency": "usd", + "weight_oz": 18, + "last_tracked_at": self.now - timedelta(hours=6), + "tracking_events": [ + { + "status": "in_transit", + "message": "Departed USPS facility", + "datetime": (self.now - timedelta(days=2)).isoformat(), + "location": "Chicago IL", + }, + { + "status": "delivered", + "message": "Delivered, front door", + "datetime": (self.now - timedelta(days=1)).isoformat(), + "location": "Geneva IL", + }, + ], + "notes": f"{DEMO_NOTE} Stub delivered label. No EasyPost call.", + }, + ), + ( + "DEMO-ORD-001", + { + "status": Shipment.Status.LABELED, + "carrier": "USPS", + "service": "GroundAdvantage", + "tracking_number": "940011189922DEMO01", + "tracking_status": Shipment.TrackingStatus.IN_TRANSIT, + "tracking_url": ( + "https://tools.usps.com/go/TrackConfirmAction" + "?tLabels=940011189922DEMO01" + ), + "rate_amount": Decimal("5.40"), + "currency": "usd", + "weight_oz": 12, + "last_tracked_at": self.now - timedelta(hours=2), + "tracking_events": [ + { + "status": "pre_transit", + "message": "Shipping label created", + "datetime": (self.now - timedelta(days=1)).isoformat(), + "location": "Aurora IL", + }, + { + "status": "in_transit", + "message": "Arrived at USPS origin facility", + "datetime": (self.now - timedelta(hours=8)).isoformat(), + "location": "Chicago IL", + }, + ], + "notes": f"{DEMO_NOTE} Stub in-transit label. No EasyPost call.", + }, + ), + ) + created = 0 + for number, defaults in specs: + order = Order.objects.filter(number=number).first() + if order is None: + continue + shipment = Shipment.objects.filter(order=order).first() + if shipment is None: + Shipment.objects.create(order=order, **defaults) + created += 1 + else: + for key, value in defaults.items(): + setattr(shipment, key, value) + shipment.save() + self._log(f"shipment {number} {defaults['tracking_status']}") + return created + + def _seed_invoices(self) -> int: + from contacts.models import Contact + from payments.models import Invoice + + specs = ( + ( + "DEMO-INV-001", + "casey", + Invoice.Status.PAID, + Decimal("210.00"), + "Fair tote kits (30)", + 14, + ), + ( + "DEMO-INV-002", + "priya", + Invoice.Status.OPEN, + Decimal("480.00"), + "Event keychains (40) — deposit due", + 4, + ), + ) + created = 0 + for number, slug, status, amount, description, days_ago in specs: + contact = Contact.objects.filter( + email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}" + ).first() + if contact is None: + continue + when = self.now - timedelta(days=days_ago) + paid = status == Invoice.Status.PAID + invoice, was_created = Invoice.objects.update_or_create( + number=number, + defaults={ + "contact": contact, + "description": description, + "amount": amount, + "currency": "usd", + "status": status, + "paid_at": when if paid else None, + "created_by": self.owner, + "notes": f"{DEMO_NOTE} Demo invoice. No Stripe session.", + }, + ) + if was_created: + created += 1 + _stamp(Invoice, invoice.pk, when) + self._log(f"invoice {invoice.number} {status}") + return created + + def _seed_campaigns(self) -> int: + from email_sms.models import Campaign, Message + from email_sms.services import create_campaign_draft + + created = 0 + draft_name = "DEMO: New arrivals" + if not Campaign.objects.filter(name=draft_name).exists(): + create_campaign_draft( + name=draft_name, + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="New items just landed", + body="

    Restocked notebooks and tote bags. Shop the drop.

    ", + created_by=self.owner, + ) + created += 1 + done_name = "DEMO: Event recap" + if not Campaign.objects.filter(name=done_name).exists(): + campaign = create_campaign_draft( + name=done_name, + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Thanks for coming", + body="

    Your order is packed. Tracking goes out tomorrow.

    ", + created_by=self.owner, + ) + campaign.status = Campaign.Status.COMPLETED + campaign.notify_sent_at = self.now - timedelta(days=9) + campaign.save(update_fields=["status", "notify_sent_at", "updated_at"]) + messages = list(campaign.messages.all()) + for index, message in enumerate(messages): + message.status = ( + Message.Status.OPENED + if index % 3 == 0 + else Message.Status.DELIVERED + ) + message.sent_at = self.now - timedelta(days=10) + message.save(update_fields=["status", "sent_at", "updated_at"]) + created += 1 + self._log(f"campaign {done_name} completed") + sms_name = "DEMO: Order update SMS" + if not Campaign.objects.filter(name=sms_name).exists(): + create_campaign_draft( + name=sms_name, + audience=Campaign.Audience.SMS_OPT_IN, + body="Your order is packing. We will text when it ships.", + created_by=self.owner, + ) + created += 1 + return created diff --git a/site/core/tests.py b/site/core/tests.py index bd5c606..d972d06 100644 --- a/site/core/tests.py +++ b/site/core/tests.py @@ -1,4 +1,6 @@ +import os from io import StringIO +from unittest.mock import patch from django.core.management import call_command from django.test import Client, TestCase, override_settings @@ -30,6 +32,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): @@ -37,3 +44,107 @@ class DispatchDueTests(TestCase): out = StringIO() call_command("dispatch_due", stdout=out) self.assertEqual(out.getvalue(), "") + + +class SeedDemoTests(TestCase): + def test_refuses_prod(self): + from django.core.management.base import CommandError + + with patch.dict(os.environ, {"DJANGO_ENV": "prod"}): + with self.assertRaises(CommandError) as ctx: + call_command("seed_demo", stdout=StringIO()) + self.assertIn("prod", str(ctx.exception).lower()) + + def test_seeds_shop_and_portal_rows(self): + out = StringIO() + call_command("seed_demo", stdout=out) + self.assertIn("Demo seed ready", out.getvalue()) + from contacts.models import Contact + from leads.models import Lead + from shop.models import Order, Product + + self.assertTrue(Product.objects.filter(sku="DEMO-NOTEBOOK").exists()) + self.assertTrue(Product.objects.filter(sku="DEMO-PROTO", is_published=False).exists()) + self.assertGreaterEqual(Contact.objects.filter(email__startswith="demo+").count(), 8) + self.assertTrue(Lead.objects.filter(message__startswith="[demo-seed]").exists()) + self.assertTrue(Order.objects.filter(number="DEMO-ORD-001", status="paid").exists()) + listing = Client().get(reverse("shop:list")) + self.assertContains(listing, "Notebook") + + from django.contrib.auth import get_user_model + from shop.models import ProductReview + from shipping.models import Shipment + + User = get_user_model() + jordan = User.objects.get(username="demo+jordan@example.com") + self.assertFalse(jordan.is_staff) + self.assertTrue(jordan.check_password("Demo-buyer-pass-123")) + self.assertEqual( + Order.objects.get(number="DEMO-ORD-001").user_id, jordan.pk + ) + self.assertIsNone(Order.objects.get(number="DEMO-ORD-004").user_id) + self.assertTrue( + ProductReview.objects.filter( + user=jordan, product__sku="DEMO-NOTEBOOK", rating=5 + ).exists() + ) + delivered = Shipment.objects.get(order__number="DEMO-ORD-002") + self.assertEqual(delivered.tracking_status, "delivered") + self.assertGreaterEqual(len(delivered.tracking_events), 2) + transit = Shipment.objects.get(order__number="DEMO-ORD-001") + self.assertEqual(transit.tracking_status, "in_transit") + + client = Client() + self.assertTrue( + client.login( + username="demo+jordan@example.com", password="Demo-buyer-pass-123" + ) + ) + history = client.get(reverse("account:orders")) + self.assertContains(history, "DEMO-ORD-001") + self.assertNotContains(history, "DEMO-ORD-004") + detail = client.get(reverse("shop:detail", kwargs={"slug": "demo-notebook"})) + self.assertContains(detail, "Solid notebook") + + def test_second_run_is_idempotent(self): + call_command("seed_demo", stdout=StringIO()) + from shop.models import Product + + count = Product.objects.filter(sku__startswith="DEMO-").count() + call_command("seed_demo", stdout=StringIO()) + self.assertEqual(Product.objects.filter(sku__startswith="DEMO-").count(), count) + + def test_reset_rebuilds_tagged_rows(self): + call_command("seed_demo", stdout=StringIO()) + from shop.models import Product + + Product.objects.filter(sku="DEMO-NOTEBOOK").delete() + call_command("seed_demo", reset=True, stdout=StringIO()) + self.assertTrue(Product.objects.filter(sku="DEMO-NOTEBOOK").exists()) + from django.contrib.auth import get_user_model + + self.assertTrue( + get_user_model().objects.filter(username="demo+jordan@example.com").exists() + ) + + @patch.dict(os.environ, {"DJANGO_ENV": "beta"}) + def test_allows_beta(self): + call_command("seed_demo", stdout=StringIO()) + from shop.models import Product + + self.assertTrue(Product.objects.filter(sku__startswith="DEMO-").exists()) + + def test_succeeds_when_shop_not_installed(self): + from django.apps import apps + from contacts.models import Contact + + real = apps.is_installed + + def fake(name): + if name in {"shop", "shipping"}: + return False + return real(name) + + with patch("django.apps.apps.is_installed", side_effect=fake): + call_command("seed_demo", stdout=StringIO()) + self.assertTrue(Contact.objects.filter(email__startswith="demo+").exists()) diff --git a/site/core/tests_campaign_utm.py b/site/core/tests_campaign_utm.py index 4d5ce75..c43f003 100644 --- a/site/core/tests_campaign_utm.py +++ b/site/core/tests_campaign_utm.py @@ -111,8 +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") def test_requires_login(self): diff --git a/site/core/tests_features.py b/site/core/tests_features.py index e10f5ea..c81362c 100644 --- a/site/core/tests_features.py +++ b/site/core/tests_features.py @@ -122,9 +122,11 @@ class InstalledOptionalAppsTests(TestCase): self.assertTrue(reverse("social:composer").startswith("/portal/social/")) self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/")) self.assertTrue(reverse("shop:list").startswith("/shop/")) + self.assertTrue(reverse("account:login").startswith("/account/")) self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/")) self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/")) self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/")) self.assertTrue(reverse("events:list").startswith("/events/")) self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/")) self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/")) + self.assertTrue(reverse("shipping:easypost_webhook").startswith("/portal/shipping/webhooks/")) diff --git a/site/directmail/tests.py b/site/directmail/tests.py index b549321..51e6825 100644 --- a/site/directmail/tests.py +++ b/site/directmail/tests.py @@ -239,8 +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") self.contact = Contact.objects.create( @@ -283,8 +282,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") self.contact = Contact.objects.create( diff --git a/site/email_sms/tests.py b/site/email_sms/tests.py index e24012d..5ff77b6 100644 --- a/site/email_sms/tests.py +++ b/site/email_sms/tests.py @@ -123,8 +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") self.contact = Contact.objects.create( @@ -190,8 +189,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") self.contact = Contact.objects.create( @@ -260,8 +258,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") self.contact = Contact.objects.create( @@ -467,7 +464,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,8 +943,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,8 +1026,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") self.contacts = [] @@ -1169,8 +1164,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") self.contact = Contact.objects.create( 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/public/forms.py b/site/public/forms.py index 361b92b..6fbfb5f 100644 --- a/site/public/forms.py +++ b/site/public/forms.py @@ -10,88 +10,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..9217ffd 100644 --- a/site/public/middleware.py +++ b/site/public/middleware.py @@ -17,6 +17,10 @@ class UnderConstructionMiddleware: "/admin/", "/accounts/login", "/accounts/logout", + "/account/login", + "/account/logout", + "/account/register", + "/account/password-reset", "/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 fc697e0..eefd631 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 ae466b1..63ba9c7 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:"Monica Dhillon" }} +New contact form inquiry — {{ brand_name|default:"Your Company" }} -Name: {{ name }} Email: {{ email }} -Phone: {{ phone }} -Address: -{{ address }} Message: {{ message }} @@ -12,6 +8,6 @@ Message: {% if portal_url %}View in portal: {{ portal_url }} {% endif %} — -{{ brand_name|default:"Monica Dhillon" }} -{{ site_url|default:"https://mkdrealtor.com" }} +{{ brand_name|default:"Your Company" }} +{{ site_url|default:"" }} {% if brand_tagline %}{{ brand_tagline }}{% endif %} diff --git a/site/public/templates/public/contact.html b/site/public/templates/public/contact.html index fa35eff..57623b8 100644 --- a/site/public/templates/public/contact.html +++ b/site/public/templates/public/contact.html @@ -87,74 +87,13 @@
    {% csrf_token %}
    -
    -
    - - {{ form.first_name }} - {{ form.first_name.errors }} -
    -
    -
    -
    - - {{ form.last_name }} - {{ form.last_name.errors }} -
    -
    -
    +
    {{ form.email }} {{ form.email.errors }}
    -
    -
    - - {{ form.phone }} - {{ form.phone.errors }} -
    -
    -
    -

    Mailing address (optional — for postcards)

    -
    -
    -
    - - {{ 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 }} -
    -
    -
    -
    @@ -173,7 +112,7 @@
    {{ form.captcha }}{{ form.captcha.errors }}
    {% endif %}
    -

    By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}

    +

    By submitting, you agree we may contact you about your inquiry by email. You can change preferences or unsubscribe anytime from links in messages.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}

    @@ -181,9 +120,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 a33e880..a589a7d 100644 --- a/site/public/views.py +++ b/site/public/views.py @@ -49,6 +49,7 @@ def robots_txt(request): "Allow: /", "Disallow: /portal/", "Disallow: /accounts/", + "Disallow: /account/", "Disallow: /admin/", "Disallow: /api/", f"Sitemap: {site}/sitemap.xml", @@ -99,48 +100,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..5d25079 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,9 @@ 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..29989a2 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,6 +30,16 @@ 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) currency = models.CharField(max_length=8, default="usd") diff --git a/site/shipping/services.py b/site/shipping/services.py index f81a44d..09525fc 100644 --- a/site/shipping/services.py +++ b/site/shipping/services.py @@ -7,8 +7,11 @@ 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 +138,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 +189,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 / 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..97881e2 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 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..fac1b38 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,65 @@ 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 ae6377c..a8f5db5 100644 --- a/site/shop/admin.py +++ b/site/shop/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from shop.models import Order, OrderItem, Product +from shop.models import Order, OrderItem, Product, ProductReview @admin.register(Product) @@ -11,6 +11,13 @@ class ProductAdmin(admin.ModelAdmin): prepopulated_fields = {"slug": ("name",)} +@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 @@ -21,4 +28,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/0002_order_user_and_review.py b/site/shop/migrations/0002_order_user_and_review.py new file mode 100644 index 0000000..82b7647 --- /dev/null +++ b/site/shop/migrations/0002_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", "0001_initial"), + 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 a1aa4bf..78e047c 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 @@ -68,6 +70,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( @@ -116,3 +125,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 bb8639b..bc70ec9 100644 --- a/site/shop/services.py +++ b/site/shop/services.py @@ -8,10 +8,10 @@ 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 shop.models import Order, OrderItem, Product +from shop.models import Order, OrderItem, Product, ProductReview logger = logging.getLogger(__name__) @@ -156,6 +156,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: @@ -169,6 +170,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, @@ -191,6 +193,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 = [ @@ -208,14 +246,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 @@ -249,7 +300,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(): @@ -261,6 +314,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: @@ -303,3 +357,28 @@ def send_order_email(order: Order) -> bool: mail.attach_alternative(html, "text/html") mail.send(fail_silently=False) return True + + +_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..ef852f8 --- /dev/null +++ b/site/shop/templates/shop/account/order_detail.html @@ -0,0 +1,48 @@ +{% 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 %} +
    + {% if "shipping" in enabled_features %} +

    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 %} + {% endif %} +

    ← 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..a34ca57 --- /dev/null +++ b/site/shop/templates/shop/account/orders.html @@ -0,0 +1,18 @@ +{% 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 }} + · {{ 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 b68f0f5..1a7fc5f 100644 --- a/site/shop/templates/shop/checkout.html +++ b/site/shop/templates/shop/checkout.html @@ -1,21 +1,35 @@ {% extends "base.html" %} +{% load static %} {% block title %}Checkout · {{ SITE_NAME }}{% endblock %} +{% block extra_head %} + +{% endblock %} {% block content %}

    Checkout

    Total: {{ total }}

    + {% 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 %} -

    -

    -

    -

    -

    -

    -

    +
    +

    +

    +

    +

    +

    +

    +

    +
    {% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/shop/templates/shop/detail.html b/site/shop/templates/shop/detail.html index 6c80131..6a7ec66 100644 --- a/site/shop/templates/shop/detail.html +++ b/site/shop/templates/shop/detail.html @@ -15,6 +15,47 @@ +

    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..3437f4a 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 "shipping" in enabled_features and 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 b94ca08..0a01c18 100644 --- a/site/shop/templates/shop/success.html +++ b/site/shop/templates/shop/success.html @@ -5,5 +5,12 @@

    Thank you

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

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

    +{% 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 18ded05..5ea6a81 100644 --- a/site/shop/tests.py +++ b/site/shop/tests.py @@ -1,5 +1,6 @@ from datetime import timedelta from decimal import Decimal +from unittest.mock import patch from django.contrib.auth import get_user_model from django.core import mail @@ -13,6 +14,7 @@ from shop.services import ( add_to_cart, adjust_stock, available_qty, + create_checkout_session, create_order_from_cart, mark_paid, next_order_number, @@ -116,7 +118,9 @@ 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") @@ -175,7 +179,9 @@ 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")) @@ -252,3 +258,98 @@ class ShopSalesDashboardTests(TestCase): self.assertContains(products, "Sales") sales = self.client.get(reverse("shop_portal:sales")) self.assertContains(sales, 'class="active"') + + +class ShopCustomerAccountTests(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 1828cf3..d5d19eb 100644 --- a/site/shop/views.py +++ b/site/shop/views.py @@ -4,6 +4,7 @@ from decimal import Decimal, InvalidOperation from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.db.models import Avg, Count from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -12,7 +13,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 +from shop.models import Order, Product, ProductReview from shop.services import ( ShopError, add_to_cart, @@ -23,8 +24,10 @@ from shop.services import ( create_checkout_session, create_order_from_cart, mark_paid, + qualifying_order_for_review, save_cart, set_cart_qty, + user_has_reviewed, ) from shop.stats import sales_dashboard @@ -45,13 +48,63 @@ def product_list(request): def product_detail(request, slug): product = get_object_or_404(Product, slug=slug, is_published=True) + 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", - {"product": product, "available": available_qty(product)}, + { + "product": product, + "available": available_qty(product), + "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( @@ -104,12 +157,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}) @@ -127,10 +186,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, + }, ) @@ -236,10 +313,62 @@ 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) + qs = Order.objects.prefetch_related("items") + from django.apps import apps as django_apps + + if django_apps.is_installed("shipping"): + qs = qs.prefetch_related("shipments") + order = get_object_or_404(qs, 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 + from django.apps import apps as django_apps + + claim_orders_for_user(request.user) + qs = Order.objects.filter(user=request.user).prefetch_related("items") + if django_apps.is_installed("shipping"): + qs = qs.prefetch_related("shipments") + orders = qs.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): + from django.apps import apps as django_apps + + qs = Order.objects.prefetch_related("items") + if django_apps.is_installed("shipping"): + qs = qs.prefetch_related("shipments") + order = get_object_or_404(qs, pk=pk, user=request.user) + + 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): @@ -269,6 +398,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..1beafb9 100644 --- a/site/social/tests.py +++ b/site/social/tests.py @@ -49,8 +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") self.account = SocialAccount.objects.create( @@ -136,8 +135,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") self.account = SocialAccount.objects.create(