Add customer accounts, shipment tracking, and purchase reviews.
CI / test (pull_request) Successful in 35s

Shoppers can register, save shipping details, and view order history while cards stay on Stripe. EasyPost tracker updates (including numbers from Pirate Ship) and 1–5 star reviews are limited to buyers. The contact form now only asks for email and a message.
This commit is contained in:
2026-09-07 06:37:52 -05:00
parent 23a6035ba8
commit c9b81ceed7
59 changed files with 1905 additions and 275 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",)
+26
View File
@@ -0,0 +1,26 @@
from django.apps import apps
from django.urls import path
from accounts import views
app_name = "account"
urlpatterns = [
path("", views.customer_home, name="home"),
path("login/", views.CustomerLoginView.as_view(), name="login"),
path("logout/", views.customer_logout, name="logout"),
path("register/", views.customer_register, name="register"),
path("profile/", views.customer_profile, name="profile"),
]
if apps.is_installed("shop"):
from shop import views as shop_views
urlpatterns += [
path("orders/", shop_views.account_order_list, name="orders"),
path(
"orders/<uuid:pk>/",
shop_views.account_order_detail,
name="order_detail",
),
]
+160
View File
@@ -0,0 +1,160 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.core.exceptions import ValidationError
from contacts.models import Contact
User = get_user_model()
_INPUT = {"class": "form-input"}
class CustomerRegisterForm(UserCreationForm):
email = forms.EmailField(
widget=forms.EmailInput(attrs={**_INPUT, "autocomplete": "email"})
)
first_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "given-name"}),
)
last_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "family-name"}),
)
class Meta:
model = User
fields = ("email", "first_name", "last_name", "password1", "password2")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["password1"].widget.attrs.update(_INPUT)
self.fields["password2"].widget.attrs.update(_INPUT)
def clean_email(self):
email = (self.cleaned_data.get("email") or "").strip().lower()
if not email:
raise ValidationError("Enter an email address.")
taken = User.objects.filter(email__iexact=email).exists() or User.objects.filter(
username__iexact=email
).exists()
if taken:
raise ValidationError("An account with that email already exists.")
return email
def save(self, commit=True):
user = super().save(commit=False)
email = self.cleaned_data["email"]
user.username = email
user.email = email
user.first_name = (self.cleaned_data.get("first_name") or "").strip()
user.last_name = (self.cleaned_data.get("last_name") or "").strip()
if commit:
user.save()
return user
class CustomerAuthenticationForm(AuthenticationForm):
username = forms.EmailField(
label="Email",
widget=forms.EmailInput(
attrs={**_INPUT, "id": "id_username", "autocomplete": "email"}
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["password"].widget.attrs.update(
{**_INPUT, "autocomplete": "current-password"}
)
class CustomerProfileForm(forms.Form):
first_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "given-name"}),
)
last_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "family-name"}),
)
phone = forms.CharField(
max_length=32,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "tel"}),
)
address_line1 = forms.CharField(
max_length=200,
required=False,
label="Street address",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "off",
"data-ac": "line1",
}
),
)
address_line2 = forms.CharField(
max_length=200,
required=False,
label="Apt / suite",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "address-line2",
"data-ac": "line2",
}
),
)
address_city = forms.CharField(
max_length=100,
required=False,
label="City",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "address-level2",
"data-ac": "city",
}
),
)
address_state = forms.CharField(
max_length=32,
required=False,
label="State",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "address-level1",
"data-ac": "state",
}
),
)
address_zip = forms.CharField(
max_length=20,
required=False,
label="ZIP",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "postal-code",
"data-ac": "zip",
}
),
)
def shipping_address(self) -> dict:
data = self.cleaned_data
return Contact.make_postal_address(
line1=data.get("address_line1") or "",
line2=data.get("address_line2") or "",
city=data.get("address_city") or "",
state=data.get("address_state") or "",
zip_code=data.get("address_zip") or "",
)
+23
View File
@@ -0,0 +1,23 @@
from django.shortcuts import redirect
class PortalStaffMiddleware:
"""Keep customer accounts out of the staff portal. Webhooks stay public."""
PORTAL_PREFIX = "/portal/"
WEBHOOK_INFIX = "/webhooks/"
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
path = request.path
if path.startswith(self.PORTAL_PREFIX) and self.WEBHOOK_INFIX not in path:
user = getattr(request, "user", None)
if (
user is not None
and getattr(user, "is_authenticated", False)
and not getattr(user, "is_staff", False)
):
return redirect("account:home")
return self.get_response(request)
@@ -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,40 @@
{% 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></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -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 %}
+88
View File
@@ -0,0 +1,88 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from accounts.models import CustomerProfile
from shop.models import Order
User = get_user_model()
class CustomerAccountTests(TestCase):
def test_register_login_and_profile(self):
client = Client()
response = client.post(
reverse("account:register"),
{
"email": "buyer@example.com",
"first_name": "Pat",
"last_name": "Lee",
"password1": "s3cure-pass-123",
"password2": "s3cure-pass-123",
},
)
self.assertEqual(response.status_code, 302)
user = User.objects.get(email="buyer@example.com")
self.assertEqual(user.username, "buyer@example.com")
self.assertFalse(user.is_staff)
self.assertTrue(CustomerProfile.objects.filter(user=user).exists())
client.logout()
login = client.post(
reverse("account:login"),
{"username": "buyer@example.com", "password": "s3cure-pass-123"},
)
self.assertEqual(login.status_code, 302)
save = client.post(
reverse("account:profile"),
{
"first_name": "Patricia",
"last_name": "Lee",
"phone": "6305550100",
"address_line1": "10 Main St",
"address_city": "Aurora",
"address_state": "IL",
"address_zip": "60505",
},
)
self.assertEqual(save.status_code, 302)
user.refresh_from_db()
profile = user.customer_profile
self.assertEqual(user.first_name, "Patricia")
self.assertEqual(profile.phone, "6305550100")
self.assertEqual(profile.shipping_address.get("line1"), "10 Main St")
def test_register_claims_guest_orders(self):
order = Order.objects.create(
number="ORD-CLAIM-001",
email="buyer@example.com",
status=Order.Status.PAID,
amount="18.00",
)
client = Client()
client.post(
reverse("account:register"),
{
"email": "buyer@example.com",
"password1": "s3cure-pass-123",
"password2": "s3cure-pass-123",
},
)
order.refresh_from_db()
self.assertEqual(order.user.email, "buyer@example.com")
history = client.get(reverse("account:orders"))
self.assertEqual(history.status_code, 200)
self.assertContains(history, "ORD-CLAIM-001")
def test_customer_cannot_open_portal(self):
User.objects.create_user(
username="buyer@example.com",
email="buyer@example.com",
password="s3cure-pass-123",
)
client = Client()
client.login(username="buyer@example.com", password="s3cure-pass-123")
response = client.get(reverse("dashboard:home"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("account:home"))
+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},
)