From 03950fc9baae17a7c64da52330895b4d6813c3eb Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 7 Sep 2026 06:50:40 -0500 Subject: [PATCH] Add shopper password reset and extend demo seed for accounts, reviews, and tracking. Buyers can recover locked accounts, and seed_demo now walks the shopper path without live Stripe or EasyPost calls. --- site/accounts/customer_urls.py | 38 ++- site/accounts/forms.py | 20 +- site/accounts/middleware.py | 6 +- .../templates/accounts/customer_login.html | 3 +- .../accounts/password_reset_complete.html | 16 ++ .../accounts/password_reset_confirm.html | 41 ++++ .../accounts/password_reset_done.html | 16 ++ .../accounts/password_reset_email.txt | 8 + .../accounts/password_reset_form.html | 32 +++ .../accounts/password_reset_subject.txt | 1 + site/accounts/tests.py | 40 +++ site/core/management/commands/seed_demo.py | 231 ++++++++++++++++-- site/core/tests.py | 38 +++ site/print_forge/urls.py | 2 +- site/public/middleware.py | 1 + 15 files changed, 466 insertions(+), 27 deletions(-) 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 diff --git a/site/accounts/customer_urls.py b/site/accounts/customer_urls.py index 2365d10..c72d1cc 100644 --- a/site/accounts/customer_urls.py +++ b/site/accounts/customer_urls.py @@ -1,7 +1,9 @@ from django.apps import apps -from django.urls import path +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" @@ -11,6 +13,40 @@ urlpatterns = [ 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"): diff --git a/site/accounts/forms.py b/site/accounts/forms.py index b27740a..82370b5 100644 --- a/site/accounts/forms.py +++ b/site/accounts/forms.py @@ -1,6 +1,11 @@ from django import forms from django.contrib.auth import get_user_model -from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.contrib.auth.forms import ( + AuthenticationForm, + PasswordResetForm, + SetPasswordForm, + UserCreationForm, +) from django.core.exceptions import ValidationError from contacts.models import Contact @@ -72,6 +77,19 @@ class CustomerAuthenticationForm(AuthenticationForm): ) +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, diff --git a/site/accounts/middleware.py b/site/accounts/middleware.py index 8d08ff0..609762b 100644 --- a/site/accounts/middleware.py +++ b/site/accounts/middleware.py @@ -19,5 +19,9 @@ class PortalStaffMiddleware: and getattr(user, "is_authenticated", False) and not getattr(user, "is_staff", False) ): - return redirect("account:home") + 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/templates/accounts/customer_login.html b/site/accounts/templates/accounts/customer_login.html index ba9c07d..3ffc92e 100644 --- a/site/accounts/templates/accounts/customer_login.html +++ b/site/accounts/templates/accounts/customer_login.html @@ -32,7 +32,8 @@ -

New here? Create an account

+

New here? Create an account + · Forgot password?

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..0146ae0 --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_complete.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Password updated · {{ SITE_NAME }}{% endblock %} +{% block content %} + {% include "public/_breadcrumbs.html" with title="Password updated" %} +
+
+
+
+

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..28ac911 --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_confirm.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Choose a new password · {{ SITE_NAME }}{% endblock %} +{% block content %} + {% include "public/_breadcrumbs.html" with title="New password" %} +
+
+
+
+

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..803e30b --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_done.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Check your email · {{ SITE_NAME }}{% endblock %} +{% block content %} + {% include "public/_breadcrumbs.html" with title="Reset password" %} +
+
+
+
+

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..ba51dd8 --- /dev/null +++ b/site/accounts/templates/accounts/password_reset_form.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Reset password · {{ SITE_NAME }}{% endblock %} +{% block content %} + {% include "public/_breadcrumbs.html" with title="Reset password" %} +
+
+
+
+

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/tests.py b/site/accounts/tests.py index e9e9987..693c440 100644 --- a/site/accounts/tests.py +++ b/site/accounts/tests.py @@ -1,6 +1,10 @@ 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 @@ -86,3 +90,39 @@ class CustomerAccountTests(TestCase): 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/core/management/commands/seed_demo.py b/site/core/management/commands/seed_demo.py index 208ca5d..cd15a69 100644 --- a/site/core/management/commands/seed_demo.py +++ b/site/core/management/commands/seed_demo.py @@ -26,6 +26,8 @@ DEMO_NOTE = "[demo-seed]" DEMO_UTM_CAMPAIGN = "printforge-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: @@ -94,8 +96,10 @@ def _stamp(model, pk, when) -> None: class Command(BaseCommand): help = ( - "Seed tagged fake catalog, contacts, leads, orders, campaigns, and " - "analytics for client demos. Allowed on DJANGO_ENV=dev and beta only." + "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): @@ -113,10 +117,17 @@ class Command(BaseCommand): ) self.verbosity = int(options.get("verbosity", 1)) self.now = timezone.now() - self.owner = get_user_model().objects.order_by("pk").first() 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(), @@ -124,7 +135,9 @@ class Command(BaseCommand): } 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"): @@ -133,6 +146,11 @@ class Command(BaseCommand): 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: @@ -178,6 +196,12 @@ class Command(BaseCommand): 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: @@ -586,6 +610,50 @@ class Command(BaseCommand): 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 @@ -635,12 +703,14 @@ class Command(BaseCommand): ), ) 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, _color, qty in lines), Decimal("0.00"), @@ -650,6 +720,7 @@ class Command(BaseCommand): order, was_created = Order.objects.update_or_create( number=number, defaults={ + "user": buyer, "email": contact.email, "customer_name": contact.full_name, "status": status, @@ -679,30 +750,146 @@ class Command(BaseCommand): 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-FLEXI-DRAGON", + 5, + "Wiggles like crazy", + "The cyan flexi dragon is the hit of the desk.", + ), + ( + "casey", + "DEMO-ORD-002", + "DEMO-PLANTER", + 4, + "Sturdy planter", + "Held up for STEM night. Would buy again.", + ), + ( + "sam", + "DEMO-ORD-003", + "DEMO-D20", + 5, + "Chunky and readable", + "Gold face reads well across the table.", + ), + ) + 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 - order = Order.objects.filter(number="DEMO-ORD-002").first() - if order is None: - return 0 - shipment, created = Shipment.objects.get_or_create( - order=order, - defaults={ - "status": Shipment.Status.LABELED, - "carrier": "USPS", - "service": "Priority", - "tracking_number": "940011189922DEMO02", - "rate_amount": Decimal("8.45"), - "currency": "usd", - "weight_oz": 18, - "notes": f"{DEMO_NOTE} Stub label. No EasyPost call.", - }, + 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.", + }, + ), ) - if created: - self._log(f"shipment {shipment.tracking_number}") - return 1 - return 0 + 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 diff --git a/site/core/tests.py b/site/core/tests.py index 65d2a4a..460003d 100644 --- a/site/core/tests.py +++ b/site/core/tests.py @@ -74,6 +74,39 @@ class SeedDemoTests(TestCase): listing = Client().get(reverse("shop:list")) self.assertContains(listing, "Articulated Flexi Dragon") + 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-FLEXI-DRAGON", 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") + def test_second_run_is_idempotent(self): call_command("seed_demo", stdout=StringIO()) from shop.models import Product @@ -89,6 +122,11 @@ class SeedDemoTests(TestCase): Product.objects.filter(sku="DEMO-FLEXI-DRAGON").delete() call_command("seed_demo", reset=True, stdout=StringIO()) self.assertTrue(Product.objects.filter(sku="DEMO-FLEXI-DRAGON").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): diff --git a/site/print_forge/urls.py b/site/print_forge/urls.py index 41ed7ee..d0d8e42 100644 --- a/site/print_forge/urls.py +++ b/site/print_forge/urls.py @@ -12,7 +12,6 @@ urlpatterns = [ path("api/address-suggest/", address_suggest, name="address_suggest"), path("files/", include("core.urls")), path("admin/", admin.site.urls), - path("account/", include("accounts.customer_urls")), path("accounts/", include("accounts.urls")), path("portal/", include("dashboard.urls")), path("portal/leads/", include("leads.urls")), @@ -51,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/public/middleware.py b/site/public/middleware.py index e8df547..9217ffd 100644 --- a/site/public/middleware.py +++ b/site/public/middleware.py @@ -20,6 +20,7 @@ class UnderConstructionMiddleware: "/account/login", "/account/logout", "/account/register", + "/account/password-reset", "/under-construction", "/portal/messaging/webhooks/", "/unsubscribe/",