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
+2
View File
@@ -88,6 +88,8 @@ POS_API_TOKEN=
# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works.
EASYPOST_API_KEY=
# Optional. If set, EasyPost tracker webhooks must send it as X-Webhook-Secret or ?token=.
EASYPOST_WEBHOOK_SECRET=
SHIP_FROM_LINE1=
SHIP_FROM_CITY=
SHIP_FROM_STATE=
+2
View File
@@ -77,6 +77,8 @@ POS_API_TOKEN=
# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP)
EASYPOST_API_KEY=
# Optional. If set, EasyPost tracker webhooks must send it as X-Webhook-Secret or ?token=.
EASYPOST_WEBHOOK_SECRET=
SHIP_FROM_LINE1=
SHIP_FROM_CITY=
SHIP_FROM_STATE=
+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},
)
+1 -1
View File
@@ -60,7 +60,7 @@ class AnalyticsReportTests(TestCase):
def setUp(self):
self.client = Client()
user = get_user_model().objects.create_user(
username="monica", password="pass-word-1"
username="monica", password="pass-word-1", is_staff=True
)
self.client.force_login(user)
+1 -1
View File
@@ -28,7 +28,7 @@ class BlogPublicTests(TestCase):
class BlogPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("editor", password="test-pass-123")
self.user = User.objects.create_user("editor", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="editor", password="test-pass-123")
+26 -13
View File
@@ -130,19 +130,12 @@ class ContactFormMergeTests(TestCase):
),
)
def test_contact_form_merges_on_phone(self):
def test_contact_form_merges_on_email(self):
url = reverse("public:contact")
response = self.client.post(
url,
{
"first_name": "Alex",
"last_name": "Lee",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"address_line1": "55 River Rd",
"address_city": "Aurora",
"address_state": "IL",
"address_zip": "60505",
"email": "primary@example.com",
"interest": "general",
"message": "Looking to buy",
},
@@ -151,15 +144,35 @@ class ContactFormMergeTests(TestCase):
self.assertEqual(Contact.objects.count(), 1)
lead = Lead.objects.get()
self.assertEqual(lead.contact_id, self.existing.pk)
self.assertIn("alt@example.com", lead.message)
self.assertIn("merged by phone", lead.message)
self.assertIn("Looking to buy", lead.message)
def test_contact_form_skips_name_phone_address(self):
url = reverse("public:contact")
response = self.client.post(
url,
{
"email": "newbuyer@example.com",
"interest": "custom",
"message": "Can you print a fox?",
"first_name": "Ignored",
"phone": "6301112222",
"address_line1": "55 River Rd",
},
)
self.assertEqual(response.status_code, 302)
from contacts.models import Contact as ContactModel
created = ContactModel.objects.get(email="newbuyer@example.com")
self.assertEqual(created.first_name, "")
self.assertEqual(created.phone, "")
self.assertEqual(created.postal_address, {})
class PortalCreateMatchPromptTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="adder", password="test-pass-123"
username="adder", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="adder", password="test-pass-123")
@@ -225,7 +238,7 @@ class ContactListTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="lister", password="test-pass-123"
username="lister", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="lister", password="test-pass-123")
+5
View File
@@ -35,6 +35,11 @@ class PublicSmokeTests(TestCase):
client = Client()
self.assertEqual(client.get(reverse("public:about")).status_code, 200)
self.assertEqual(client.get(reverse("public:contact")).status_code, 200)
contact = client.get(reverse("public:contact"))
self.assertContains(contact, "contact-email")
self.assertNotContains(contact, "contact-first-name")
self.assertNotContains(contact, "contact-phone")
self.assertNotContains(contact, "contact-address-line1")
class DispatchDueTests(TestCase):
+1 -1
View File
@@ -111,7 +111,7 @@ class CampaignShortLinkViewTests(TestCase):
User = get_user_model()
self.user = User.objects.create_user(
username="utm-composer", password="test-pass-123"
username="utm-composer", password="test-pass-123", is_staff=True
)
self.client.login(username="utm-composer", password="test-pass-123")
+2 -2
View File
@@ -239,7 +239,7 @@ class PostcardDesignPickTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="pcm-pick", password="test-pass-123"
username="pcm-pick", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="pcm-pick", password="test-pass-123")
@@ -283,7 +283,7 @@ class PostcardUtmPanelTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="utm-mail", password="test-pass-123"
username="utm-mail", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="utm-mail", password="test-pass-123")
+7 -7
View File
@@ -123,7 +123,7 @@ class PortalConsentToggleTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="monica", password="test-pass-123"
username="monica", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="monica", password="test-pass-123")
@@ -190,7 +190,7 @@ class CampaignDraftSaveTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="composer", password="test-pass-123"
username="composer", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="composer", password="test-pass-123")
@@ -260,7 +260,7 @@ class CampaignSendTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="sender", password="test-pass-123", email="sender@example.com"
username="sender", password="test-pass-123", email="sender@example.com", is_staff=True
)
self.client = Client()
self.client.login(username="sender", password="test-pass-123")
@@ -467,7 +467,7 @@ class Smtp2goEmailWebhookTests(TestCase):
def test_status_json_includes_opens(self):
User = get_user_model()
user = User.objects.create_user(username="viewer", password="test-pass-123")
user = User.objects.create_user(username="viewer", password="test-pass-123", is_staff=True)
self.client.login(username="viewer", password="test-pass-123")
ProviderEvent.objects.create(
message=self.message,
@@ -946,7 +946,7 @@ class StoredFileUploadTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="uploader", password="test-pass-123"
username="uploader", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="uploader", password="test-pass-123")
@@ -1030,7 +1030,7 @@ class CampaignRecipientTableTests(TestCase):
User = get_user_model()
self.user = User.objects.create_user(
username="recip", password="test-pass-123"
username="recip", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="recip", password="test-pass-123")
@@ -1169,7 +1169,7 @@ class CampaignUtmLinkTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="utm-composer", password="test-pass-123"
username="utm-composer", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="utm-composer", password="test-pass-123")
+1 -1
View File
@@ -68,7 +68,7 @@ class EventCapacityTests(TestCase):
class EventPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("host", password="test-pass-123")
self.user = User.objects.create_user("host", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="host", password="test-pass-123")
+1 -1
View File
@@ -12,7 +12,7 @@ from payments.services import next_invoice_number
class PaymentsPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("biller", password="test-pass-123")
self.user = User.objects.create_user("biller", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="biller", password="test-pass-123")
self.contact = Contact.objects.create(
+1 -1
View File
@@ -109,7 +109,7 @@ class POSPortalTests(TestCase):
def test_list_ok_when_logged_in(self):
User = get_user_model()
User.objects.create_user("clerk", password="test-pass-123")
User.objects.create_user("clerk", password="test-pass-123", is_staff=True)
client = Client()
client.login(username="clerk", password="test-pass-123")
self.assertEqual(client.get(reverse("pos_sync:event_list")).status_code, 200)
+2
View File
@@ -222,6 +222,7 @@ MIDDLEWARE = [
"analytics.middleware.UTMTrackingMiddleware",
"analytics.middleware.PublicPageViewMiddleware",
"public.middleware.UnderConstructionMiddleware",
"accounts.middleware.PortalStaffMiddleware",
]
ROOT_URLCONF = "print_forge.urls"
@@ -420,6 +421,7 @@ POS_WEBHOOK_SECRET = env("POS_WEBHOOK_SECRET", "")
POS_API_BASE_URL = env("POS_API_BASE_URL", "")
POS_API_TOKEN = env("POS_API_TOKEN", "")
EASYPOST_API_KEY = env("EASYPOST_API_KEY", "")
EASYPOST_WEBHOOK_SECRET = env("EASYPOST_WEBHOOK_SECRET", "")
SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "")
SHIP_FROM_CITY = env("SHIP_FROM_CITY", "")
SHIP_FROM_STATE = env("SHIP_FROM_STATE", "")
@@ -545,3 +545,20 @@
width: 100%;
}
}
.product-review-summary {
font-weight: 600;
margin-bottom: 1rem;
}
.product-review-form .form-wrap {
margin-bottom: 1rem;
}
.product-review-list {
list-style: none;
padding: 0;
margin: 1.5rem 0 0;
}
.product-review-list li {
border-top: 1px solid #e5e7eb;
padding: 1rem 0;
}
+15 -1
View File
@@ -121,6 +121,15 @@
<span>{{ cart_count }}</span>
</a>
{% endif %}
{% if user.is_authenticated %}
{% if user.is_staff %}
<a class="rd-nav-link" href="{% url 'dashboard:home' %}">Portal</a>
{% else %}
<a class="rd-nav-link" href="{% url 'account:home' %}">Account</a>
{% endif %}
{% elif "shop" in enabled_features %}
<a class="rd-nav-link" href="{% url 'account:login' %}">Sign in</a>
{% endif %}
<button class="sidebar-toggle sidebar-toggle-1 rd-navbar-fixed-element-1" data-multitoggle=".sidebar-wrap" data-multitoggle-blur=".rd-navbar-wrap" data-multitoggle-isolate=""><span></span></button>
</div>
</div>
@@ -239,9 +248,14 @@
{% endfor %}
<li><a href="{% url 'public:contact' %}">Contact Us</a></li>
<li><a href="{% url 'public:terms' %}">Terms</a></li>
{% if user.is_authenticated %}
{% if user.is_authenticated and not user.is_staff %}
<li><a href="{% url 'account:home' %}">Account</a></li>
{% elif user.is_authenticated %}
<li><a href="{% url 'dashboard:home' %}">Client portal</a></li>
{% else %}
{% if "shop" in enabled_features %}
<li><a href="{% url 'account:login' %}">Sign in</a></li>
{% endif %}
<li><a href="{% url 'accounts:login' %}">Client portal</a></li>
{% endif %}
</ul>
+1
View File
@@ -12,6 +12,7 @@ urlpatterns = [
path("api/address-suggest/", address_suggest, name="address_suggest"),
path("files/", include("core.urls")),
path("admin/", admin.site.urls),
path("account/", include("accounts.customer_urls")),
path("accounts/", include("accounts.urls")),
path("portal/", include("dashboard.urls")),
path("portal/leads/", include("leads.urls")),
-79
View File
@@ -11,88 +11,9 @@ class ContactForm(forms.Form):
("other", "Something else"),
]
first_name = forms.CharField(
max_length=100,
widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-first-name"}),
)
last_name = forms.CharField(
max_length=100,
required=False,
widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-last-name"}),
)
email = forms.EmailField(
widget=forms.EmailInput(attrs={"class": "form-input", "id": "contact-email"}),
)
phone = forms.CharField(
max_length=32,
required=False,
widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-phone"}),
)
address_line1 = forms.CharField(
max_length=200,
required=False,
label="Street address",
widget=forms.TextInput(
attrs={
"class": "form-input",
"id": "contact-address-line1",
"autocomplete": "off",
"data-ac": "line1",
}
),
)
address_line2 = forms.CharField(
max_length=200,
required=False,
label="Apt / suite",
widget=forms.TextInput(
attrs={
"class": "form-input",
"id": "contact-address-line2",
"autocomplete": "address-line2",
"data-ac": "line2",
}
),
)
address_city = forms.CharField(
max_length=100,
required=False,
label="City",
widget=forms.TextInput(
attrs={
"class": "form-input",
"id": "contact-address-city",
"autocomplete": "address-level2",
"data-ac": "city",
}
),
)
address_state = forms.CharField(
max_length=32,
required=False,
label="State",
widget=forms.TextInput(
attrs={
"class": "form-input",
"id": "contact-address-state",
"autocomplete": "address-level1",
"data-ac": "state",
}
),
)
address_zip = forms.CharField(
max_length=20,
required=False,
label="ZIP",
widget=forms.TextInput(
attrs={
"class": "form-input",
"id": "contact-address-zip",
"autocomplete": "postal-code",
"data-ac": "zip",
}
),
)
interest = forms.ChoiceField(
choices=INTEREST_CHOICES,
widget=forms.Select(attrs={"class": "form-input", "id": "contact-interest"}),
+3
View File
@@ -17,6 +17,9 @@ class UnderConstructionMiddleware:
"/admin/",
"/accounts/login",
"/accounts/logout",
"/account/login",
"/account/logout",
"/account/register",
"/under-construction",
"/portal/messaging/webhooks/",
"/unsubscribe/",
+1 -22
View File
@@ -26,8 +26,6 @@ def notify_admins_of_contact_form(lead: Lead) -> bool:
return False
contact = lead.contact
name = contact.full_name or "(no name)"
phone = contact.phone or "(none)"
email = contact.email or "(none)"
message = (lead.message or "").strip() or "(no message)"
@@ -35,27 +33,8 @@ def notify_admins_of_contact_form(lead: Lead) -> bool:
portal_path = reverse("leads:detail", kwargs={"pk": lead.pk})
portal_url = f"{site}{portal_path}" if site else portal_path
postal = contact.postal_address or {}
address_bits = [
postal.get("line1") or "",
postal.get("line2") or "",
", ".join(
part
for part in [
postal.get("city") or "",
postal.get("state") or "",
postal.get("zip") or "",
]
if part
),
]
address = "\n".join(bit for bit in address_bits if bit) or "(none)"
ctx = email_brand_context(
name=name,
email=email,
phone=phone,
address=address,
message=message,
portal_url=portal_url,
)
@@ -63,7 +42,7 @@ def notify_admins_of_contact_form(lead: Lead) -> bool:
html_content = get_template("emails/contact_email.html").render(ctx)
mail = EmailMultiAlternatives(
subject=f"New contact form inquiry from {name}",
subject=f"New contact form inquiry from {email}",
body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
@@ -6,20 +6,11 @@
<p style="margin:0 0 16px;color:#212121;">Hello,</p>
<p style="margin:0 0 24px;color:#212121;">A new contact request was submitted on the site.</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Name</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;"><strong>{{ name }}</strong></p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Email</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;">
<a href="mailto:{{ email }}" style="color:#00aeef;text-decoration:none;">{{ email }}</a>
</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Phone</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;">{{ phone }}</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Address</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;white-space:pre-wrap;">{{ address }}</p>
<p class="field-label" style="color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Message</p>
<p class="field-value" style="color:#212121;font-size:15px;margin:0 0 16px;white-space:pre-wrap;">{{ message }}</p>
@@ -1,10 +1,6 @@
New contact form inquiry — {{ brand_name|default:"Print Forge" }}
Name: {{ name }}
Email: {{ email }}
Phone: {{ phone }}
Address:
{{ address }}
Message:
{{ message }}
@@ -13,5 +9,5 @@ Message:
{% endif %}
{{ brand_name|default:"Print Forge" }}
{{ site_url|default:"https://mkdrealtor.com" }}
{{ site_url|default:"https://printforgeprints.com" }}
{% if brand_tagline %}{{ brand_tagline }}{% endif %}
+2 -70
View File
@@ -1,5 +1,4 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %}
{% block og_title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %}
{% block twitter_title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %}
@@ -60,74 +59,13 @@
<form class="rd-form" method="post" action="{% url 'public:contact' %}">
{% csrf_token %}
<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-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-sm-6">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.email.id_for_label }}">E-mail</label>
{{ form.email }}
{{ form.email.errors }}
</div>
</div>
<div class="col-12">
<p class="form-label-outside">Mailing address <span style="font-weight:400;color:#6b7280">(optional)</span></p>
</div>
<div class="col-12" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="form-wrap address-ac-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 class="row row-20 gutter-20">
<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>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.interest.id_for_label }}">I am interested in</label>
@@ -151,7 +89,7 @@
<div class="unit unit-spacing-md form-text">
<div class="unit-left"><span class="icon mdi mdi-information-outline"></span></div>
<div class="unit-body">
<p>By submitting, you agree we may contact you about your inquiry by email and SMS (if you provide a phone number). Unsubscribe anytime.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
<p>By submitting, you agree we may contact you about your inquiry by email. Unsubscribe anytime.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
</div>
</div>
</div>
@@ -165,9 +103,6 @@
</div>
</section>
{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block tracking_events %}
{% if "sent" in request.GET %}
<script>
@@ -177,6 +112,3 @@
</script>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
+3 -26
View File
@@ -60,6 +60,7 @@ def robots_txt(request):
"Allow: /",
"Disallow: /portal/",
"Disallow: /accounts/",
"Disallow: /account/",
"Disallow: /admin/",
"Disallow: /api/",
f"Sitemap: {site}/sitemap.xml",
@@ -110,48 +111,24 @@ def contact(request):
form = ContactForm(request.POST)
if form.is_valid():
data = form.cleaned_data
postal = 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 "",
)
submitted_email = data["email"].lower()
contact_obj, _created, match_reason = upsert_contact(
email=submitted_email,
first_name=data["first_name"],
last_name=data.get("last_name") or "",
phone=data.get("phone") or "",
postal_address=postal
if Contact.postal_address_has_content(postal)
else None,
source=Contact.Source.CONTACT_FORM,
merge_phone_address=False,
)
ConsentRecord.objects.update_or_create(
contact=contact_obj,
channel=Channel.EMAIL,
defaults={"opted_in": True, "reason": "contact_form"},
)
if (data.get("phone") or "").strip():
ConsentRecord.objects.update_or_create(
contact=contact_obj,
channel=Channel.SMS,
defaults={"opted_in": True, "reason": "contact_form"},
)
if Contact.postal_address_has_content(postal):
ConsentRecord.objects.get_or_create(
contact=contact_obj,
channel=Channel.POSTCARD,
defaults={"opted_in": True, "reason": "contact_form"},
)
interest = data.get("interest") or ""
interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest)
body = data.get("message") or ""
if interest_label:
body = f"Interest: {interest_label}\n\n{body}".strip()
if (
match_reason in {"phone", "address"}
match_reason
and (contact_obj.email or "").lower() != submitted_email
):
body = (
+9
View File
@@ -1,5 +1,6 @@
from core.registry import (
register_dashboard_collector,
register_dispatcher,
register_feature,
register_portal_nav,
)
@@ -15,6 +16,7 @@ def register() -> None:
order=50,
)
register_dashboard_collector(_dashboard)
register_dispatcher(_sync_tracking)
def _dashboard(request) -> dict:
@@ -29,3 +31,10 @@ def _dashboard(request) -> dict:
.exclude(pk__in=labeled)
.count()
}
def _sync_tracking() -> int:
from shipping.services import sync_open_tracking
return sync_open_tracking()
@@ -0,0 +1,54 @@
# Generated by Django 6.1
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shipping", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="shipment",
name="last_tracked_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="shipment",
name="tracker_id",
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name="shipment",
name="tracking_events",
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name="shipment",
name="tracking_status",
field=models.CharField(
blank=True,
choices=[
("unknown", "Unknown"),
("pre_transit", "Pre-transit"),
("in_transit", "In transit"),
("out_for_delivery", "Out for delivery"),
("delivered", "Delivered"),
("available_for_pickup", "Available for pickup"),
("return_to_sender", "Return to sender"),
("failure", "Exception"),
("cancelled", "Cancelled"),
("error", "Error"),
],
default="unknown",
max_length=32,
),
),
migrations.AddField(
model_name="shipment",
name="tracking_url",
field=models.URLField(blank=True),
),
]
+25 -1
View File
@@ -11,6 +11,18 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
LABELED = "labeled", "Labeled"
VOID = "void", "Void"
class TrackingStatus(models.TextChoices):
UNKNOWN = "unknown", "Unknown"
PRE_TRANSIT = "pre_transit", "Pre-transit"
IN_TRANSIT = "in_transit", "In transit"
OUT_FOR_DELIVERY = "out_for_delivery", "Out for delivery"
DELIVERED = "delivered", "Delivered"
AVAILABLE_FOR_PICKUP = "available_for_pickup", "Available for pickup"
RETURN_TO_SENDER = "return_to_sender", "Return to sender"
FAILURE = "failure", "Exception"
CANCELLED = "cancelled", "Cancelled"
ERROR = "error", "Error"
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments")
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
@@ -18,8 +30,20 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
carrier = models.CharField(max_length=32, blank=True)
service = models.CharField(max_length=64, blank=True)
tracking_number = models.CharField(max_length=64, blank=True)
tracking_status = models.CharField(
max_length=32,
choices=TrackingStatus.choices,
default=TrackingStatus.UNKNOWN,
blank=True,
)
tracking_url = models.URLField(blank=True)
tracker_id = models.CharField(max_length=255, blank=True)
tracking_events = models.JSONField(default=list, blank=True)
last_tracked_at = models.DateTimeField(null=True, blank=True)
label_url = models.URLField(blank=True)
rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
rate_amount = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True
)
currency = models.CharField(max_length=8, default="usd")
provider_shipment_id = models.CharField(max_length=255, blank=True)
rates = models.JSONField(default=list, blank=True)
+172
View File
@@ -6,9 +6,11 @@ import csv
import io
import logging
from decimal import Decimal
from urllib.parse import quote
import requests
from django.conf import settings
from django.utils import timezone
from shipping.models import Shipment
from shop.models import Order
@@ -135,6 +137,10 @@ def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment:
"updated_at",
]
)
try:
refresh_tracking(shipment)
except Exception:
logger.exception("tracking refresh failed after label buy for %s", shipment.pk)
return shipment
@@ -182,3 +188,169 @@ def pirate_ship_csv(orders=None) -> str:
]
)
return buffer.getvalue()
def public_tracking_url(carrier: str, tracking_number: str) -> str:
code = quote((tracking_number or "").strip())
if not code:
return ""
name = (carrier or "").upper()
if "USPS" in name:
return f"https://tools.usps.com/go/TrackConfirmAction?tLabels={code}"
if "UPS" in name:
return f"https://www.ups.com/track?tracknum={code}"
if "FEDEX" in name or "FDX" in name:
return f"https://www.fedex.com/fedextrack/?trknbr={code}"
if "DHL" in name:
return f"https://www.dhl.com/en/express/tracking.html?AWB={code}"
return f"https://www.google.com/search?q={quote((tracking_number or '') + ' tracking')}"
def _normalize_tracking_status(raw: str) -> str:
value = (raw or "").strip().lower().replace(" ", "_")
aliases = {
"pretransit": Shipment.TrackingStatus.PRE_TRANSIT,
"pre_transit": Shipment.TrackingStatus.PRE_TRANSIT,
"in_transit": Shipment.TrackingStatus.IN_TRANSIT,
"out_for_delivery": Shipment.TrackingStatus.OUT_FOR_DELIVERY,
"delivered": Shipment.TrackingStatus.DELIVERED,
"available_for_pickup": Shipment.TrackingStatus.AVAILABLE_FOR_PICKUP,
"return_to_sender": Shipment.TrackingStatus.RETURN_TO_SENDER,
"failure": Shipment.TrackingStatus.FAILURE,
"cancelled": Shipment.TrackingStatus.CANCELLED,
"canceled": Shipment.TrackingStatus.CANCELLED,
"error": Shipment.TrackingStatus.ERROR,
"unknown": Shipment.TrackingStatus.UNKNOWN,
}
return aliases.get(value, Shipment.TrackingStatus.UNKNOWN)
def apply_tracker_payload(shipment: Shipment, payload: dict) -> Shipment:
"""Apply EasyPost tracker (or compatible) JSON onto a shipment."""
data = payload or {}
tracking = (data.get("tracking_code") or data.get("tracking_number") or "").strip()
if tracking:
shipment.tracking_number = tracking
tracker_id = (data.get("id") or "").strip()
if tracker_id.startswith("trk_"):
shipment.tracker_id = tracker_id
carrier = (data.get("carrier") or "").strip()
if carrier and not shipment.carrier:
shipment.carrier = carrier
shipment.tracking_status = _normalize_tracking_status(data.get("status") or "")
public_url = (data.get("public_url") or "").strip()
shipment.tracking_url = public_url or public_tracking_url(
shipment.carrier, shipment.tracking_number
)
events = []
for item in data.get("tracking_details") or []:
loc = item.get("tracking_location") or {}
place = " ".join(
part
for part in [loc.get("city") or "", loc.get("state") or ""]
if part
).strip()
events.append(
{
"status": item.get("status") or "",
"message": item.get("message") or "",
"datetime": item.get("datetime") or "",
"location": place,
}
)
if events:
shipment.tracking_events = events
shipment.last_tracked_at = timezone.now()
shipment.save(
update_fields=[
"tracking_number",
"tracker_id",
"carrier",
"tracking_status",
"tracking_url",
"tracking_events",
"last_tracked_at",
"updated_at",
]
)
return shipment
def refresh_tracking(shipment: Shipment) -> Shipment:
"""Pull latest scan events from EasyPost, or stub status without an API key.
Works for EasyPost-bought labels and for tracking numbers pasted from
Pirate Ship / Shippo / the carrier — EasyPost's tracker API looks up
USPS, UPS, FedEx, and DHL by number.
"""
tracking = (shipment.tracking_number or "").strip()
if not tracking:
return shipment
key = _easypost_key()
if not key:
if not shipment.tracking_status or shipment.tracking_status == Shipment.TrackingStatus.UNKNOWN:
shipment.tracking_status = Shipment.TrackingStatus.PRE_TRANSIT
shipment.tracking_url = shipment.tracking_url or public_tracking_url(
shipment.carrier, tracking
)
shipment.last_tracked_at = timezone.now()
shipment.save(
update_fields=[
"tracking_status",
"tracking_url",
"last_tracked_at",
"updated_at",
]
)
return shipment
payload = {"tracker": {"tracking_code": tracking}}
if shipment.carrier:
payload["tracker"]["carrier"] = shipment.carrier
response = requests.post(
"https://api.easypost.com/v2/trackers",
auth=(key, ""),
json=payload,
timeout=20,
)
response.raise_for_status()
return apply_tracker_payload(shipment, response.json())
def attach_tracking(
shipment: Shipment, *, tracking_number: str, carrier: str = ""
) -> Shipment:
tracking_number = (tracking_number or "").strip()
if not tracking_number:
raise ShippingError("Tracking number is required.")
shipment.tracking_number = tracking_number
if carrier:
shipment.carrier = carrier.strip()
if shipment.status != Shipment.Status.LABELED:
shipment.status = Shipment.Status.LABELED
shipment.save(
update_fields=["tracking_number", "carrier", "status", "updated_at"]
)
return refresh_tracking(shipment)
def sync_open_tracking() -> int:
"""Refresh labeled, not-yet-delivered shipments (dispatch_due)."""
done = 0
qs = (
Shipment.objects.filter(status=Shipment.Status.LABELED)
.exclude(tracking_number="")
.exclude(
tracking_status__in=[
Shipment.TrackingStatus.DELIVERED,
Shipment.TrackingStatus.CANCELLED,
]
)[:50]
)
for shipment in qs:
try:
refresh_tracking(shipment)
done += 1
except Exception:
logger.exception("tracking sync failed for %s", shipment.pk)
return done
+45 -1
View File
@@ -3,8 +3,52 @@
{% block topbar_title %}{{ shipment.order.number }}{% endblock %}
{% block portal_content %}
<p>{{ shipment.order.email }} · {{ shipment.get_status_display }}</p>
{% if shipment.tracking_number %}<p>Tracking: {{ shipment.tracking_number }}</p>{% endif %}
{% if shipment.tracking_number %}
<p>
Tracking: {{ shipment.tracking_number }}
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
</p>
{% if shipment.tracking_url %}<p><a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a></p>{% endif %}
<form method="post" action="{% url 'shipping:shipment_refresh_tracking' shipment.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-sm" type="submit">Refresh tracking</button>
</form>
{% endif %}
{% if shipment.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
<h3>Add tracking</h3>
<p class="hint-block">Paste a number from Pirate Ship, Shippo, or the carrier. EasyPost looks up scan events when an API key is set.</p>
<form method="post" action="{% url 'shipping:shipment_attach_tracking' shipment.pk %}">
{% csrf_token %}
<div class="form-grid">
<div class="field">
<label for="id_tracking_number">Tracking number</label>
<input id="id_tracking_number" name="tracking_number" value="{{ shipment.tracking_number }}" required>
</div>
<div class="field">
<label for="id_carrier">Carrier</label>
<input id="id_carrier" name="carrier" value="{{ shipment.carrier }}" placeholder="USPS, UPS, FedEx">
</div>
</div>
<button class="btn btn-primary" type="submit">Save tracking</button>
</form>
{% if shipment.tracking_events %}
<h3>Scan history</h3>
<table class="table">
<thead><tr><th>When</th><th>Status</th><th>Detail</th></tr></thead>
<tbody>
{% for event in shipment.tracking_events %}
<tr>
<td>{{ event.datetime }}</td>
<td>{{ event.status }}</td>
<td>{{ event.message }}{% if event.location %} · {{ event.location }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if shipment.status != 'labeled' %}
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
{% csrf_token %}
+54 -1
View File
@@ -1,4 +1,5 @@
from decimal import Decimal
import json
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
@@ -49,11 +50,63 @@ class ShippingServiceTests(TestCase):
buy_label(shipment)
self.assertNotIn(self.order.number, pirate_ship_csv())
def test_stub_buy_sets_pre_transit_tracking(self):
shipment = create_shipment_for_order(self.order)
quote_rates(shipment)
buy_label(shipment)
shipment.refresh_from_db()
self.assertTrue(shipment.tracking_number)
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
self.assertTrue(shipment.tracking_url)
def test_attach_tracking_from_pirate_ship(self):
from shipping.services import attach_tracking
shipment = create_shipment_for_order(self.order)
attach_tracking(shipment, tracking_number="9400111899223197428490", carrier="USPS")
shipment.refresh_from_db()
self.assertEqual(shipment.status, Shipment.Status.LABELED)
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
self.assertIn("usps.com", shipment.tracking_url.lower())
def test_easypost_webhook_updates_status(self):
shipment = create_shipment_for_order(self.order)
shipment.tracking_number = "EZ1000000001"
shipment.status = Shipment.Status.LABELED
shipment.save()
payload = {
"description": "tracker.updated",
"result": {
"id": "trk_test",
"tracking_code": "EZ1000000001",
"status": "in_transit",
"public_url": "https://track.easypost.com/djE0",
"tracking_details": [
{
"status": "in_transit",
"message": "Departed facility",
"datetime": "2026-09-07T12:00:00Z",
"tracking_location": {"city": "Chicago", "state": "IL"},
}
],
},
}
response = Client().post(
reverse("shipping:easypost_webhook"),
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
shipment.refresh_from_db()
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.IN_TRANSIT)
self.assertEqual(shipment.tracker_id, "trk_test")
self.assertEqual(shipment.tracking_events[0]["location"], "Chicago IL")
class ShippingPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("shipper", password="test-pass-123")
self.user = User.objects.create_user("shipper", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="shipper", password="test-pass-123")
self.order = Order.objects.create(
+11
View File
@@ -8,6 +8,17 @@ urlpatterns = [
path("", views.shipment_list, name="shipment_list"),
path("new/", views.shipment_create, name="shipment_create"),
path("export/pirate-ship.csv", views.pirate_ship_export, name="pirate_ship_export"),
path("webhooks/easypost/", views.easypost_webhook, name="easypost_webhook"),
path("<uuid:pk>/", views.shipment_detail, name="shipment_detail"),
path("<uuid:pk>/buy/", views.shipment_buy, name="shipment_buy"),
path(
"<uuid:pk>/tracking/",
views.shipment_attach_tracking,
name="shipment_attach_tracking",
),
path(
"<uuid:pk>/tracking/refresh/",
views.shipment_refresh_tracking,
name="shipment_refresh_tracking",
),
]
+74 -1
View File
@@ -1,18 +1,24 @@
import json
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from shipping.models import Shipment
from shipping.services import (
ShippingError,
apply_tracker_payload,
attach_tracking,
buy_label,
create_shipment_for_order,
pirate_ship_csv,
quote_rates,
refresh_tracking,
)
from shop.models import Order
@@ -77,6 +83,40 @@ def shipment_buy(request, pk):
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
@require_POST
def shipment_attach_tracking(request, pk):
shipment = get_object_or_404(Shipment, pk=pk)
try:
attach_tracking(
shipment,
tracking_number=request.POST.get("tracking_number") or "",
carrier=request.POST.get("carrier") or "",
)
except ShippingError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("attach tracking failed")
messages.error(request, f"Could not save tracking: {exc}")
else:
messages.success(request, "Tracking saved.")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
@require_POST
def shipment_refresh_tracking(request, pk):
shipment = get_object_or_404(Shipment, pk=pk)
try:
refresh_tracking(shipment)
except Exception as exc: # noqa: BLE001
logger.exception("refresh tracking failed")
messages.error(request, f"Could not refresh tracking: {exc}")
else:
messages.success(request, "Tracking updated.")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
@require_http_methods(["GET"])
def pirate_ship_export(request):
@@ -84,3 +124,36 @@ def pirate_ship_export(request):
response = HttpResponse(body, content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"'
return response
@csrf_exempt
@require_http_methods(["POST"])
def easypost_webhook(request):
secret = (getattr(settings, "EASYPOST_WEBHOOK_SECRET", "") or "").strip()
if secret:
got = (
request.headers.get("X-Webhook-Secret")
or request.GET.get("token")
or ""
).strip()
if got != secret:
return HttpResponseBadRequest("invalid secret")
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return HttpResponseBadRequest("invalid json")
result = payload.get("result") or payload
if not isinstance(result, dict):
return HttpResponse("ok")
tracker_id = (result.get("id") or "").strip()
tracking = (result.get("tracking_code") or result.get("tracking_number") or "").strip()
shipment = None
if tracker_id:
shipment = Shipment.objects.filter(tracker_id=tracker_id).first()
if shipment is None and tracking:
shipment = Shipment.objects.filter(tracking_number=tracking).first()
if shipment is None:
return HttpResponse("ok")
apply_tracker_payload(shipment, result)
logger.info("easypost tracker updated shipment %s", shipment.pk)
return HttpResponse("ok")
+16 -1
View File
@@ -1,6 +1,13 @@
from django.contrib import admin
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
from shop.models import (
Order,
OrderItem,
Product,
ProductColor,
ProductImage,
ProductReview,
)
class ProductColorInline(admin.TabularInline):
@@ -22,6 +29,13 @@ class ProductAdmin(admin.ModelAdmin):
inlines = [ProductColorInline, ProductImageInline]
@admin.register(ProductReview)
class ProductReviewAdmin(admin.ModelAdmin):
list_display = ("product", "user", "rating", "created_at")
list_filter = ("rating",)
search_fields = ("product__name", "user__email", "title")
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
@@ -34,4 +48,5 @@ class OrderAdmin(admin.ModelAdmin):
list_display = ("number", "email", "amount", "status", "created_at")
list_filter = ("status",)
search_fields = ("number", "email", "customer_name")
raw_id_fields = ("user",)
inlines = [OrderItemInline]
@@ -0,0 +1,97 @@
# Generated by Django 6.1
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shop", "0004_productimage_and_color_stock"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="order",
name="user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shop_orders",
to=settings.AUTH_USER_MODEL,
),
),
migrations.CreateModel(
name="ProductReview",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"rating",
models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
]
),
),
("title", models.CharField(blank=True, max_length=120)),
("body", models.TextField(blank=True)),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to="shop.order",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to="shop.product",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="product_reviews",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddConstraint(
model_name="productreview",
constraint=models.UniqueConstraint(
fields=("user", "product"),
name="shop_review_user_product",
),
),
migrations.AddConstraint(
model_name="productreview",
constraint=models.CheckConstraint(
condition=models.Q(("rating__gte", 1), ("rating__lte", 5)),
name="shop_review_rating_range",
),
),
]
+44
View File
@@ -1,5 +1,7 @@
from decimal import Decimal
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
@@ -187,6 +189,13 @@ class Order(UUIDPrimaryKeyModel, TimeStampedModel):
CANCELLED = "cancelled", "Cancelled"
number = models.CharField(max_length=32, unique=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="shop_orders",
)
email = models.EmailField()
customer_name = models.CharField(max_length=200, blank=True)
status = models.CharField(
@@ -243,3 +252,38 @@ class OrderItem(UUIDPrimaryKeyModel, TimeStampedModel):
@property
def line_total(self) -> Decimal:
return self.unit_price * self.quantity
class ProductReview(UUIDPrimaryKeyModel, TimeStampedModel):
product = models.ForeignKey(
Product, on_delete=models.CASCADE, related_name="reviews"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="product_reviews",
)
order = models.ForeignKey(
Order, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
title = models.CharField(max_length=120, blank=True)
body = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "product"],
name="shop_review_user_product",
),
models.CheckConstraint(
condition=models.Q(rating__gte=1) & models.Q(rating__lte=5),
name="shop_review_rating_range",
),
]
def __str__(self) -> str:
return f"{self.rating}{self.product.name}"
+1
View File
@@ -12,5 +12,6 @@ urlpatterns = [
path("checkout/", views.checkout, name="checkout"),
path("checkout/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
path("checkout/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
path("<slug:slug>/review/", views.product_review, name="review"),
path("<slug:slug>/", views.product_detail, name="detail"),
]
+90 -11
View File
@@ -10,11 +10,11 @@ from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.db.models import F, Sum
from django.db.models import F, Q, Sum
from django.utils import timezone
from core.models import StoredFile
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage, ProductReview
logger = logging.getLogger(__name__)
@@ -254,6 +254,7 @@ def create_order_from_cart(
customer_name: str = "",
shipping_address: dict | None = None,
notes: str = "",
user=None,
) -> Order:
lines = cart_lines(session)
if not lines:
@@ -267,6 +268,7 @@ def create_order_from_cart(
with transaction.atomic():
order = Order.objects.create(
number=next_order_number(),
user=user if getattr(user, "is_authenticated", False) else None,
email=email,
customer_name=(customer_name or "").strip(),
status=Order.Status.DRAFT,
@@ -295,6 +297,42 @@ def create_order_from_cart(
return order
def ensure_stripe_customer(user) -> str:
"""Create or reuse a Stripe Customer. Card data never leaves Stripe."""
if not user or not getattr(user, "is_authenticated", False):
return ""
from accounts.services import get_customer_profile
profile = get_customer_profile(user)
if profile.stripe_customer_id:
return profile.stripe_customer_id
stripe = _stripe()
customer = stripe.Customer.create(
email=(user.email or user.username or "") or None,
name=(user.get_full_name() or "") or None,
metadata={"user_id": str(user.pk)},
)
customer_id = getattr(customer, "id", None) or customer.get("id") or ""
if not customer_id:
raise ShopError("Stripe did not return a customer id.")
profile.stripe_customer_id = customer_id
profile.save(update_fields=["stripe_customer_id", "updated_at"])
return customer_id
def remember_stripe_customer(order: Order, customer_id: str) -> None:
customer_id = (customer_id or "").strip()
if not customer_id or not order.user_id:
return
from accounts.services import get_customer_profile
profile = get_customer_profile(order.user)
if profile.stripe_customer_id:
return
profile.stripe_customer_id = customer_id
profile.save(update_fields=["stripe_customer_id", "updated_at"])
def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str:
stripe = _stripe()
line_items = [
@@ -312,14 +350,27 @@ def create_checkout_session(order: Order, *, success_url: str, cancel_url: str)
]
if not line_items:
raise ShopError("Order has no items.")
session = stripe.checkout.Session.create(
mode="payment",
customer_email=order.email or None,
line_items=line_items,
metadata={"shop_order_id": str(order.pk), "order_number": order.number},
success_url=success_url,
cancel_url=cancel_url,
)
params = {
"mode": "payment",
"line_items": line_items,
"metadata": {"shop_order_id": str(order.pk), "order_number": order.number},
"success_url": success_url,
"cancel_url": cancel_url,
}
customer_id = ""
if order.user_id:
try:
customer_id = ensure_stripe_customer(order.user)
except ShopError:
raise
except Exception:
logger.exception("stripe customer create failed for order %s", order.number)
if customer_id:
params["customer"] = customer_id
params["payment_intent_data"] = {"setup_future_usage": "on_session"}
else:
params["customer_email"] = order.email or None
session = stripe.checkout.Session.create(**params)
order.stripe_checkout_session_id = session.id
order.hosted_checkout_url = session.url or ""
order.status = Order.Status.OPEN
@@ -353,7 +404,9 @@ def _notify_pos(order: Order) -> None:
enqueue_online_sale(order)
def mark_paid(order: Order, *, stripe_id: str = "") -> None:
def mark_paid(
order: Order, *, stripe_id: str = "", stripe_customer_id: str = ""
) -> None:
if order.status == Order.Status.PAID:
return
with transaction.atomic():
@@ -365,6 +418,7 @@ def mark_paid(order: Order, *, stripe_id: str = "") -> None:
locked.paid_at = timezone.now()
locked.save(update_fields=["status", "paid_at", "updated_at"])
order.refresh_from_db()
remember_stripe_customer(order, stripe_customer_id)
try:
send_order_email(order)
except Exception:
@@ -614,3 +668,28 @@ def product_media_payload(product: Product, colors: list[ProductColor]) -> dict:
"available": available_qty(product, color),
}
return {"shared": shared, "stl": product.stl_url, "colors": color_data}
_REVIEWABLE_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
def qualifying_order_for_review(user, product: Product) -> Order | None:
if not user or not getattr(user, "is_authenticated", False):
return None
email = (user.email or user.username or "").strip()
qs = (
Order.objects.filter(
items__product=product,
status__in=_REVIEWABLE_STATUSES,
)
.filter(Q(user=user) | Q(email__iexact=email))
.distinct()
.order_by("-paid_at", "-created_at")
)
return qs.first()
def user_has_reviewed(user, product: Product) -> bool:
if not user or not getattr(user, "is_authenticated", False):
return False
return ProductReview.objects.filter(user=user, product=product).exists()
@@ -0,0 +1,49 @@
{% extends "accounts/account_base.html" %}
{% block title %}Order {{ order.number }} · {{ SITE_NAME }}{% endblock %}
{% block account_content %}
<h3>Order {{ order.number }}</h3>
<p>{{ order.get_status_display }} · ${{ order.amount }} {{ order.currency|upper }}</p>
{% if order.customer_name %}<p>{{ order.customer_name }}</p>{% endif %}
{% if order.shipping_address.line1 %}
<p>
{{ order.shipping_address.line1 }}{% if order.shipping_address.line2 %}, {{ order.shipping_address.line2 }}{% endif %}<br>
{{ order.shipping_address.city }} {{ order.shipping_address.state }} {{ order.shipping_address.zip }}
</p>
{% endif %}
<ul class="list-description">
{% for item in order.items.all %}
<li>
<span>{{ item.quantity }}× {{ item.name }}</span>
<span>${{ item.line_total }}</span>
</li>
{% endfor %}
</ul>
<h4>Shipments</h4>
{% for shipment in order.shipments.all %}
<div class="product-review-list">
<p>
{{ shipment.carrier }} {{ shipment.service }}
· {{ shipment.get_status_display }}
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
</p>
{% if shipment.tracking_number %}
<p>
Tracking {{ shipment.tracking_number }}
{% if shipment.tracking_url %}
· <a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a>
{% endif %}
</p>
{% endif %}
{% if shipment.tracking_events %}
<ul>
{% for event in shipment.tracking_events %}
<li>{{ event.datetime }} — {{ event.message|default:event.status }}{% if event.location %} ({{ event.location }}){% endif %}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% empty %}
<p>Not shipped yet. Tracking appears here once a label is bought or a tracking number is added.</p>
{% endfor %}
<p><a href="{% url 'account:orders' %}">← All orders</a></p>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "accounts/account_base.html" %}
{% block title %}Orders · {{ SITE_NAME }}{% endblock %}
{% block account_content %}
<h3>Order history</h3>
{% if orders %}
<ul class="list-description">
{% for order in orders %}
<li>
<span>
<a href="{% url 'account:order_detail' order.pk %}">{{ order.number }}</a>
· {{ order.get_status_display }}
{% for shipment in order.shipments.all %}
{% if forloop.first and shipment.tracking_number %}
· {{ shipment.get_tracking_status_display|default:"Shipped" }}
{% endif %}
{% endfor %}
</span>
<span>${{ order.amount }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p>No orders yet. <a href="{% url 'shop:list' %}">Browse the shop</a>.</p>
{% endif %}
{% endblock %}
+12 -7
View File
@@ -11,49 +11,54 @@
<div class="row row-50 justify-content-center">
<div class="col-md-10 col-lg-6">
<h3 class="font-base text-gray-800 text-uppercase">Shipping</h3>
{% if user.is_authenticated %}
<p>Signed in as {{ user.email }}. Cards stay on Stripe; we never store card numbers. <a href="{% url 'account:profile' %}">Edit profile</a></p>
{% else %}
<p>Have an account? <a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> to prefill shipping and save cards on Stripe. Or <a href="{% url 'account:register' %}">create one</a>.</p>
{% endif %}
<form class="rd-form form-checkout" method="post">
{% csrf_token %}
<div class="row row-20 gutter-20" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-name">Name</label>
<input class="form-input" id="checkout-name" type="text" name="customer_name">
<input class="form-input" id="checkout-name" type="text" name="customer_name" value="{{ checkout_initial.customer_name }}">
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-email">E-Mail</label>
<input class="form-input" id="checkout-email" type="email" name="email" required>
<input class="form-input" id="checkout-email" type="email" name="email" required value="{{ checkout_initial.email }}" {% if user.is_authenticated %}readonly{% endif %}>
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-address">Address</label>
<input class="form-input" id="checkout-address" type="text" name="address_line1" data-ac="line1" autocomplete="off">
<input class="form-input" id="checkout-address" type="text" name="address_line1" data-ac="line1" autocomplete="off" value="{{ checkout_initial.address.line1|default:'' }}">
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-address-2">Apt / suite</label>
<input class="form-input" id="checkout-address-2" type="text" name="address_line2" data-ac="line2" autocomplete="address-line2">
<input class="form-input" id="checkout-address-2" type="text" name="address_line2" data-ac="line2" autocomplete="address-line2" value="{{ checkout_initial.address.line2|default:'' }}">
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-city">City</label>
<input class="form-input" id="checkout-city" type="text" name="address_city" data-ac="city" autocomplete="address-level2">
<input class="form-input" id="checkout-city" type="text" name="address_city" data-ac="city" autocomplete="address-level2" value="{{ checkout_initial.address.city|default:'' }}">
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-state">State</label>
<input class="form-input" id="checkout-state" type="text" name="address_state" data-ac="state" autocomplete="address-level1">
<input class="form-input" id="checkout-state" type="text" name="address_state" data-ac="state" autocomplete="address-level1" value="{{ checkout_initial.address.state|default:'' }}">
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="checkout-zip">ZIP</label>
<input class="form-input" id="checkout-zip" type="text" name="address_zip" data-ac="zip" autocomplete="postal-code">
<input class="form-input" id="checkout-zip" type="text" name="address_zip" data-ac="zip" autocomplete="postal-code" value="{{ checkout_initial.address.zip|default:'' }}">
</div>
</div>
<div class="col-12">
+51
View File
@@ -94,6 +94,57 @@
</div>
</div>
</div>
<div class="row row-40">
<div class="col-lg-10">
<h4>Reviews</h4>
{% if review_count %}
<p class="product-review-summary">{{ review_avg|floatformat:1 }} / 5 · {{ review_count }} review{{ review_count|pluralize }}</p>
{% else %}
<p>No reviews yet.</p>
{% endif %}
{% if can_review %}
<form class="rd-form product-review-form" method="post" action="{% url 'shop:review' product.slug %}">
{% csrf_token %}
<div class="form-wrap">
<label class="form-label-outside" for="review-rating">Rating</label>
<select class="form-input" id="review-rating" name="rating" required>
<option value="">Choose 15</option>
<option value="5">5 — Excellent</option>
<option value="4">4 — Good</option>
<option value="3">3 — Okay</option>
<option value="2">2 — Fair</option>
<option value="1">1 — Poor</option>
</select>
</div>
<div class="form-wrap">
<label class="form-label-outside" for="review-title">Title (optional)</label>
<input class="form-input" id="review-title" type="text" name="title" maxlength="120">
</div>
<div class="form-wrap">
<label class="form-label-outside" for="review-body">Review (optional)</label>
<textarea class="form-input" id="review-body" name="body" rows="4"></textarea>
</div>
<button class="button button-primary" type="submit">Submit review</button>
</form>
{% elif already_reviewed %}
<p>You already reviewed this product.</p>
{% elif user.is_authenticated %}
<p>Buy this product to leave a review.</p>
{% else %}
<p><a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> after a purchase to leave a review.</p>
{% endif %}
<ul class="product-review-list">
{% for review in reviews %}
<li>
<strong>{{ review.rating }}/5</strong>
{% if review.title %} · {{ review.title }}{% endif %}
<span class="text-gray-600"> — {{ review.user.first_name|default:review.user.email }}</span>
{% if review.body %}<p>{{ review.body|linebreaks }}</p>{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -17,5 +17,26 @@
{% endfor %}
</tbody>
</table>
{% if order.shipments.all %}
<h3>Shipments</h3>
<table class="table">
<thead><tr><th>Carrier</th><th>Tracking</th><th>Status</th></tr></thead>
<tbody>
{% for shipment in order.shipments.all %}
<tr>
<td>{{ shipment.carrier }} {{ shipment.service }}</td>
<td>
{% if shipment.tracking_url %}
<a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">{{ shipment.tracking_number }}</a>
{% else %}
{{ shipment.tracking_number|default:"—" }}
{% endif %}
</td>
<td>{{ shipment.get_tracking_status_display|default:shipment.get_status_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p><a href="{% url 'shop_portal:order_list' %}">← All orders</a></p>
{% endblock %}
+7 -1
View File
@@ -7,7 +7,13 @@
<h2>Thank <span class="text-italic font-weight-thin">you</span></h2>
<p class="big">Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
<p>A confirmation will go to {{ order.email }}.</p>
<a class="button button-lg button-primary" href="{% url 'shop:list' %}">Continue shopping</a>
{% if user.is_authenticated %}
<p><a class="button button-lg button-primary" href="{% url 'account:order_detail' order.pk %}">View order</a></p>
{% else %}
<p>Create an account to track shipping and review products you bought. Card details stay with Stripe.</p>
<p><a class="button button-lg button-primary" href="{% url 'account:register' %}?email={{ order.email|urlencode }}">Create account</a></p>
{% endif %}
<a class="button button-lg button-default-outline" href="{% url 'shop:list' %}">Continue shopping</a>
</div>
</section>
{% endblock %}
+98 -2
View File
@@ -24,6 +24,7 @@ from shop.services import (
add_to_cart,
adjust_stock,
available_qty,
create_checkout_session,
create_order_from_cart,
looks_like_stl,
mark_paid,
@@ -370,7 +371,7 @@ class ShopInventoryTests(TestCase):
class ShopPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
@@ -663,7 +664,7 @@ def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
class ShopSalesDashboardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
@@ -741,3 +742,98 @@ class ShopSalesDashboardTests(TestCase):
sales = self.client.get(reverse("shop_portal:sales"))
self.assertContains(sales, 'class="active"')
class ShopAccountAndReviewTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="buyer@example.com",
email="buyer@example.com",
password="s3cure-pass-123",
)
self.product = _product()
self.client = Client()
self.client.login(username="buyer@example.com", password="s3cure-pass-123")
def test_checkout_attaches_user_and_uses_stripe_customer(self):
session = self.client.session
add_to_cart(session, self.product, 1)
session.save()
captured = {}
class FakeCustomer:
id = "cus_abc"
class FakeCheckout:
id = "cs_abc"
url = "https://stripe.test/pay"
class FakeStripe:
class Customer:
@staticmethod
def create(**kwargs):
captured["customer"] = kwargs
return FakeCustomer()
class checkout:
class Session:
@staticmethod
def create(**kwargs):
captured["session"] = kwargs
return FakeCheckout()
with patch("shop.services._stripe", return_value=FakeStripe):
order = create_order_from_cart(
self.client.session,
email="buyer@example.com",
user=self.user,
)
url = create_checkout_session(
order,
success_url="https://example.test/ok",
cancel_url="https://example.test/no",
)
self.assertEqual(url, "https://stripe.test/pay")
self.assertEqual(order.user, self.user)
self.assertEqual(captured["session"]["customer"], "cus_abc")
self.assertNotIn("customer_email", captured["session"])
self.assertEqual(
captured["session"]["payment_intent_data"]["setup_future_usage"],
"on_session",
)
self.user.customer_profile.refresh_from_db()
self.assertEqual(self.user.customer_profile.stripe_customer_id, "cus_abc")
def test_review_requires_purchase(self):
blocked = self.client.post(
reverse("shop:review", kwargs={"slug": self.product.slug}),
{"rating": "5", "title": "Nope", "body": "Did not buy"},
)
self.assertEqual(blocked.status_code, 302)
self.assertEqual(self.product.reviews.count(), 0)
order = _sold_order(self.product)
order.user = self.user
order.email = self.user.email
order.save(update_fields=["user", "email"])
ok = self.client.post(
reverse("shop:review", kwargs={"slug": self.product.slug}),
{"rating": "5", "title": "Great", "body": "Loved it"},
)
self.assertEqual(ok.status_code, 302)
review = self.product.reviews.get()
self.assertEqual(review.rating, 5)
self.assertEqual(review.user, self.user)
detail = self.client.get(self.product.get_absolute_url())
self.assertContains(detail, "Great")
self.assertContains(detail, "You already reviewed")
def test_order_history_hides_other_users(self):
mine = _sold_order(self.product, number="ORD-MINE")
mine.user = self.user
mine.save(update_fields=["user"])
_sold_order(self.product, number="ORD-THEIRS")
page = self.client.get(reverse("account:orders"))
self.assertContains(page, "ORD-MINE")
self.assertNotContains(page, "ORD-THEIRS")
+131 -5
View File
@@ -5,7 +5,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Prefetch
from django.db.models import Avg, Count, Prefetch
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -14,7 +14,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from contacts.models import Contact
from shop.models import Order, Product, ProductColor, ProductImage
from shop.models import Order, Product, ProductColor, ProductImage, ProductReview
from shop.services import (
ShopError,
add_to_cart,
@@ -27,12 +27,14 @@ from shop.services import (
create_order_from_cart,
mark_paid,
product_media_payload,
qualifying_order_for_review,
refresh_listing_image,
remove_product_images,
save_cart,
set_cart_qty,
store_product_stl,
sync_product_colors,
user_has_reviewed,
)
from shop.stats import sales_dashboard
@@ -85,6 +87,16 @@ def product_detail(request, slug):
)
colors = list(product.colors.all())
selected = colors[0] if colors else None
reviews = list(product.reviews.select_related("user").all()[:50])
stats = product.reviews.aggregate(avg=Avg("rating"), n=Count("id"))
can_review = False
already_reviewed = False
if request.user.is_authenticated:
already_reviewed = user_has_reviewed(request.user, product)
can_review = (
not already_reviewed
and qualifying_order_for_review(request.user, product) is not None
)
return render(
request,
"shop/detail.html",
@@ -96,10 +108,47 @@ def product_detail(request, slug):
"gallery_items": product.gallery_items(selected),
"gallery_photos": product.photos_for(selected),
"gallery_data": product_media_payload(product, colors),
"reviews": reviews,
"review_avg": stats["avg"],
"review_count": stats["n"] or 0,
"can_review": can_review,
"already_reviewed": already_reviewed,
},
)
@login_required(login_url="account:login")
@require_POST
def product_review(request, slug):
product = get_object_or_404(Product, slug=slug, is_published=True)
if user_has_reviewed(request.user, product):
messages.info(request, "You already reviewed this product.")
return redirect("shop:detail", slug=product.slug)
order = qualifying_order_for_review(request.user, product)
if order is None:
messages.error(request, "Only customers who purchased this product can review it.")
return redirect("shop:detail", slug=product.slug)
try:
rating = int(request.POST.get("rating") or "0")
except ValueError:
rating = 0
if rating < 1 or rating > 5:
messages.error(request, "Choose a rating from 1 to 5.")
return redirect("shop:detail", slug=product.slug)
title = (request.POST.get("title") or "").strip()[:120]
body = (request.POST.get("body") or "").strip()
ProductReview.objects.create(
product=product,
user=request.user,
order=order,
rating=rating,
title=title,
body=body,
)
messages.success(request, "Thanks for the review.")
return redirect("shop:detail", slug=product.slug)
def cart_view(request):
lines = cart_lines(request.session)
return render(
@@ -169,12 +218,18 @@ def checkout(request):
state=request.POST.get("address_state") or "",
zip_code=request.POST.get("address_zip") or "",
)
buyer = request.user if request.user.is_authenticated else None
if buyer:
email = (buyer.email or buyer.username or email).strip()
if not name:
name = buyer.get_full_name()
try:
order = create_order_from_cart(
request.session,
email=email,
customer_name=name,
shipping_address=address,
user=buyer,
)
base = _site_base(request)
success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk})
@@ -192,10 +247,28 @@ def checkout(request):
else:
save_cart(request.session, {})
return redirect(url)
checkout_initial = {
"email": "",
"customer_name": "",
"address": {},
}
if request.user.is_authenticated:
from accounts.services import get_customer_profile
profile = get_customer_profile(request.user)
checkout_initial = {
"email": request.user.email or request.user.username,
"customer_name": request.user.get_full_name(),
"address": profile.shipping_address or {},
}
return render(
request,
"shop/checkout.html",
{"lines": lines, "total": cart_total(lines)},
{
"lines": lines,
"total": cart_total(lines),
"checkout_initial": checkout_initial,
},
)
@@ -361,10 +434,59 @@ def portal_order_list(request):
@login_required
def portal_order_detail(request, pk):
order = get_object_or_404(Order.objects.prefetch_related("items"), pk=pk)
order = get_object_or_404(
Order.objects.prefetch_related("items", "shipments"), pk=pk
)
return render(request, "shop/portal/order_detail.html", {"order": order})
@login_required(login_url="account:login")
def account_order_list(request):
from accounts.services import claim_orders_for_user
claim_orders_for_user(request.user)
orders = (
Order.objects.filter(user=request.user)
.prefetch_related("items", "shipments")
.exclude(status=Order.Status.DRAFT)
)
return render(request, "shop/account/orders.html", {"orders": orders})
@login_required(login_url="account:login")
def account_order_detail(request, pk):
order = get_object_or_404(
Order.objects.prefetch_related("items", "shipments"),
pk=pk,
user=request.user,
)
from django.apps import apps as django_apps
if django_apps.is_installed("shipping"):
from datetime import timedelta
from django.utils import timezone
from shipping.models import Shipment
from shipping.services import refresh_tracking
stale_after = timezone.now() - timedelta(minutes=15)
for shipment in order.shipments.all():
if (
shipment.status == Shipment.Status.LABELED
and shipment.tracking_number
and shipment.tracking_status != Shipment.TrackingStatus.DELIVERED
and (
shipment.last_tracked_at is None
or shipment.last_tracked_at < stale_after
)
):
try:
refresh_tracking(shipment)
except Exception:
logger.exception("order tracking refresh failed for %s", order.number)
return render(request, "shop/account/order_detail.html", {"order": order})
@csrf_exempt
@require_http_methods(["POST"])
def stripe_webhook(request):
@@ -394,6 +516,10 @@ def stripe_webhook(request):
session_id = obj.get("id") or ""
order = Order.objects.filter(stripe_checkout_session_id=session_id).first()
if order and order.status != Order.Status.PAID:
mark_paid(order, stripe_id=obj.get("id") or "")
mark_paid(
order,
stripe_id=obj.get("id") or "",
stripe_customer_id=obj.get("customer") or "",
)
logger.info("shop order %s marked paid", order.number)
return HttpResponse("ok")
+2 -2
View File
@@ -49,7 +49,7 @@ class SocialComposerTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="composer", password="test-pass-123"
username="composer", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="composer", password="test-pass-123")
@@ -136,7 +136,7 @@ class SocialAccountRenameTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="renamer", password="test-pass-123"
username="renamer", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="renamer", password="test-pass-123")