Add shopper password reset and extend demo seed for accounts, reviews, and tracking.
CI / test (pull_request) Successful in 39s

Buyers can recover locked accounts, and seed_demo now walks the shopper path without live Stripe or EasyPost calls.
This commit is contained in:
2026-09-07 06:50:40 -05:00
parent c9b81ceed7
commit 03950fc9ba
15 changed files with 466 additions and 27 deletions
+37 -1
View File
@@ -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/<uidb64>/<token>/",
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"):
+19 -1
View File
@@ -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,
+5 -1
View File
@@ -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)
@@ -32,7 +32,8 @@
</div>
</div>
</form>
<p>New here? <a href="{% url 'account:register' %}">Create an account</a></p>
<p>New here? <a href="{% url 'account:register' %}">Create an account</a>
· <a href="{% url 'account:password_reset' %}">Forgot password?</a></p>
</div>
</div>
</div>
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Password updated · {{ SITE_NAME }}{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Password updated" %}
<section class="section section-lg bg-default">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<h3>Password updated</h3>
<p>You can sign in with your new password.</p>
<p><a class="button button-lg button-primary" href="{% url 'account:login' %}">Sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -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" %}
<section class="section section-lg bg-default">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-5">
<h3>Choose a new password</h3>
{% if validlink %}
<form class="rd-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1 }}
{{ form.new_password1.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.new_password2.id_for_label }}">Confirm password</label>
{{ form.new_password2 }}
{{ form.new_password2.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Save password</button>
</div>
</div>
</form>
{% else %}
<p>This reset link is invalid or expired. <a href="{% url 'account:password_reset' %}">Request a new one</a>.</p>
{% endif %}
</div>
</div>
</div>
</section>
{% endblock %}
@@ -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" %}
<section class="section section-lg bg-default">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<h3>Check your email</h3>
<p>If an account exists for that address, a reset link is on its way. Check spam if you do not see it.</p>
<p><a href="{% url 'account:login' %}">Back to sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -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 %}
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Reset password · {{ SITE_NAME }}{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Reset password" %}
<section class="section section-lg bg-default">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-5">
<h3>Reset password</h3>
<p>Enter the email on your account. We will send a reset link if it matches.</p>
<form class="rd-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
{{ form.email.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Send reset link</button>
</div>
</div>
</form>
<p><a href="{% url 'account:login' %}">Back to sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1 @@
Password reset for {{ site_name }}
+40
View File
@@ -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"))