generated from westfarn/web_django_template
Add customer accounts, shipment tracking, and purchase reviews.
CI / test (pull_request) Successful in 35s
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:
@@ -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,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",
|
||||
),
|
||||
]
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,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 & 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 %}
|
||||
@@ -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"))
|
||||
@@ -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,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},
|
||||
)
|
||||
Reference in New Issue
Block a user