Customer accounts, order tracking, and purchase reviews (#8)
Deploy Beta / docker (push) Successful in 37s
Deploy Beta / deploy-beta (push) Successful in 2m21s
Deploy Beta / unit-tests (push) Successful in 39s

## Summary
- Slim the public contact form to email, interest, and message. Name, phone, and address live on the customer profile instead.
- Customers can register, sign in, save shipping details, and view order history. Logged-in checkout creates a Stripe Customer and saves cards on Stripe (`setup_future_usage`); we only store `stripe_customer_id`.
- Shipment tracking: EasyPost tracker lookup + webhook, plus paste-in numbers from Pirate Ship/Shippo. Customers see carrier status on their orders; `dispatch_due` refreshes open shipments.
- Product reviews (1–5) only after a paid/fulfilled purchase of that product.

Fixes #7

## Test plan
- [ ] Contact form submits with only email + message; extra name/phone/address fields are ignored
- [ ] Register, sign in, save profile (name/phone/shipping)
- [ ] Guest checkout still works; after signup, prior orders with that email show in history
- [ ] Logged-in checkout prefills shipping and does not collect card data locally
- [ ] Portal: buy label or paste a Pirate Ship tracking number, confirm status/events; customer order page shows tracking
- [ ] Product page: non-buyers cannot review; buyers can leave one 1–5 star review
- [ ] Non-staff users hitting `/portal/` redirect to `/account/`

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-09-07 04:53:41 -07:00
parent 23a6035ba8
commit dd37a2a268
66 changed files with 2366 additions and 297 deletions
+8 -1
View File
@@ -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",)
+62
View File
@@ -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/<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"):
from shop import views as shop_views
urlpatterns += [
path("orders/", shop_views.account_order_list, name="orders"),
path(
"orders/<uuid:pk>/",
shop_views.account_order_detail,
name="order_detail",
),
]
+178
View File
@@ -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 "",
)
+27
View File
@@ -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)
@@ -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,
},
),
]
+16
View File
@@ -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()
+22
View File
@@ -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
)
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Account · {{ SITE_NAME }}{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Account" %}
<section class="section section-lg bg-default">
<div class="container">
<div class="row row-30">
<div class="col-lg-3">
<h5 class="title-6">Account</h5>
<ul class="list-marked">
{% if "shop" in enabled_features %}
<li><a href="{% url 'account:orders' %}">Orders</a></li>
{% endif %}
<li><a href="{% url 'account:profile' %}">Profile</a></li>
<li><a href="{% url 'account:logout' %}">Sign out</a></li>
</ul>
</div>
<div class="col-lg-9">
{% block account_content %}{% endblock %}
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Sign in · {{ SITE_NAME }}{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Sign in" %}
<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>Sign in</h3>
<p>View order history, shipping, and reviews.</p>
<form class="rd-form" method="post" action="{% url 'account:login' %}">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{{ 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.username.id_for_label }}">Email</label>
{{ form.username }}
{{ form.username.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password }}
{{ form.password.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Sign in</button>
</div>
</div>
</form>
<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>
</section>
{% endblock %}
@@ -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 }}
@@ -0,0 +1,84 @@
{% extends "accounts/account_base.html" %}
{% load static %}
{% block title %}Profile · {{ SITE_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block account_content %}
<h3>Profile &amp; shipping</h3>
<p>Name, phone, and a default shipping address. Payment cards stay on Stripe — we only keep a Stripe customer id, never card numbers.</p>
{% if profile.stripe_customer_id %}
<p class="text-gray-600">Stripe customer on file. Saved cards are offered at checkout by Stripe.</p>
{% endif %}
<form class="rd-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.first_name.id_for_label }}">First name</label>
{{ form.first_name }}
{{ form.first_name.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.last_name.id_for_label }}">Last name</label>
{{ form.last_name }}
{{ form.last_name.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.phone.id_for_label }}">Phone</label>
{{ form.phone }}
{{ form.phone.errors }}
</div>
</div>
<div class="col-12">
<p class="form-label-outside">Shipping address</p>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.address_line1.id_for_label }}">Street address</label>
{{ form.address_line1 }}
{{ form.address_line1.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.address_line2.id_for_label }}">Apt / suite</label>
{{ form.address_line2 }}
{{ form.address_line2.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.address_city.id_for_label }}">City</label>
{{ form.address_city }}
{{ form.address_city.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.address_state.id_for_label }}">State</label>
{{ form.address_state }}
{{ form.address_state.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.address_zip.id_for_label }}">ZIP</label>
{{ form.address_zip }}
{{ form.address_zip.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Save profile</button>
</div>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Create account · {{ SITE_NAME }}{% endblock %}
{% block content %}
{% include "public/_breadcrumbs.html" with title="Create account" %}
<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>Create an account</h3>
<p>Track orders, save a shipping address, and review products you bought. Card details stay with Stripe — we never store them.</p>
<form class="rd-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20">
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.first_name.id_for_label }}">First name</label>
{{ form.first_name }}
{{ form.first_name.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.last_name.id_for_label }}">Last name</label>
{{ form.last_name }}
{{ form.last_name.errors }}
</div>
</div>
<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">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1 }}
{{ form.password1.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.password2.id_for_label }}">Confirm password</label>
{{ form.password2 }}
{{ form.password2.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Create account</button>
</div>
</div>
</form>
<p>Already have an account? <a href="{% url 'account:login' %}">Sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
+128
View File
@@ -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"))
+3 -1
View File
@@ -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(
+119
View File
@@ -0,0 +1,119 @@
from django.contrib import messages
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import LoginView
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from accounts.forms import (
CustomerAuthenticationForm,
CustomerProfileForm,
CustomerRegisterForm,
)
from accounts.services import claim_orders_for_user, get_customer_profile
class PortalLoginView(LoginView):
template_name = "accounts/login.html"
def get_success_url(self):
url = self.get_redirect_url()
if url:
return url
if self.request.user.is_staff:
return reverse("dashboard:home")
return reverse("account:home")
class CustomerLoginView(LoginView):
template_name = "accounts/customer_login.html"
authentication_form = CustomerAuthenticationForm
redirect_authenticated_user = True
def get_success_url(self):
url = self.get_redirect_url()
if url:
return url
if self.request.user.is_staff:
return reverse("dashboard:home")
return reverse("account:home")
@require_http_methods(["GET", "POST"])
def customer_register(request):
if request.user.is_authenticated:
return redirect("account:home")
if request.method == "POST":
form = CustomerRegisterForm(request.POST)
if form.is_valid():
user = form.save()
get_customer_profile(user)
claimed = claim_orders_for_user(user)
login(request, user)
if claimed:
messages.success(
request,
f"Account created. {claimed} existing order(s) are now in your history.",
)
else:
messages.success(request, "Account created. Welcome.")
return redirect("account:home")
else:
initial = {}
email = (request.GET.get("email") or "").strip()
if email:
initial["email"] = email
form = CustomerRegisterForm(initial=initial)
return render(request, "accounts/register.html", {"form": form})
@require_http_methods(["GET", "POST"])
def customer_logout(request):
logout(request)
messages.success(request, "Signed out.")
return redirect("public:home")
@login_required(login_url="account:login")
def customer_home(request):
from django.apps import apps
if apps.is_installed("shop"):
return redirect("account:orders")
return redirect("account:profile")
@login_required(login_url="account:login")
@require_http_methods(["GET", "POST"])
def customer_profile(request):
profile = get_customer_profile(request.user)
addr = profile.shipping_address or {}
initial = {
"first_name": request.user.first_name,
"last_name": request.user.last_name,
"phone": profile.phone,
"address_line1": addr.get("line1") or "",
"address_line2": addr.get("line2") or "",
"address_city": addr.get("city") or "",
"address_state": addr.get("state") or "",
"address_zip": addr.get("zip") or "",
}
if request.method == "POST":
form = CustomerProfileForm(request.POST)
if form.is_valid():
request.user.first_name = form.cleaned_data.get("first_name") or ""
request.user.last_name = form.cleaned_data.get("last_name") or ""
request.user.save(update_fields=["first_name", "last_name"])
profile.phone = form.cleaned_data.get("phone") or ""
profile.shipping_address = form.shipping_address()
profile.save(update_fields=["phone", "shipping_address", "updated_at"])
messages.success(request, "Profile saved.")
return redirect("account:profile")
else:
form = CustomerProfileForm(initial=initial)
return render(
request,
"accounts/profile.html",
{"form": form, "profile": profile},
)