Template
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.
This commit is contained in:
@@ -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",)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Account · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<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 %}
|
||||
<section class="section section-lg">
|
||||
<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,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Password updated · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<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,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Choose a new password · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<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,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Check your email · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<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,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Reset password · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<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 & 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,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Create account · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<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 %}
|
||||
@@ -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"))
|
||||
@@ -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(
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
Reference in New Issue
Block a user