Compare commits

...
3 Commits
Author SHA1 Message Date
westfarn 7ff52c776b Add EDDM size, income, businesses and USPS map link (#14)
## Summary
Closes #13.

- Mirror Monica: join USPS EDDM GIS stats onto the carrier-route picker (household size, median income, business count).
- Add Open USPS EDDM map link next to Find routes.
- GIS miss stays a dash and does not block Find routes. PCM households and cost unchanged.

Monica: ai_ml_operations/monica_site#17

## Test plan
- [ ] Find routes for a ZIP; table shows Size / Income / Businesses columns
- [ ] Missing GIS values render as —
- [ ] Open USPS EDDM map opens eddm.usps.com
- [ ] Quote and 1–2 route send still work
- [ ] `CarrierRoutePostcardTests` pass

Reviewed-on: #14
2026-09-22 12:40:24 -07:00
westfarn f2eb4b7152 Add PCM carrier-route postcard pick-and-send (#12)
## Summary
Closes #11.
Mirrors ai_ml_operations/monica_site#15.

- Direct-mail compose can target the postcard mailing list **or** find and select one or two USPS carrier routes by ZIP (optional radius).
- Estimated cost is shown per route and for the selection (PCM design pricing, else `PCM_POSTCARD_PRICE_*` fallback).
- Selected routes order through PCM `POST /order/postcard/with-list-count` — occupant addresses are not imported into Contacts.
- Not official EDDM postage; PCM FirstClass/Standard purchased occupant lists on carrier routes.

## Test plan
- [ ] Campaign compose: mailing-list audience still quotes list size × per-piece cost.
- [ ] Switch to USPS carrier routes, search a ZIP, pick 1–2 routes, confirm household count + estimated cost.
- [ ] Saving without a selection is rejected; saving with a quote creates one contact-less Message and shows the route panel on the report.
- [ ] Send places `/order/postcard/with-list-count` (not per-recipient `/order/postcard`).
- [ ] `uv run python manage.py test directmail.tests.CarrierRoutePostcardTests directmail.tests.PostcardDesignPickTests`

Reviewed-on: #12
2026-09-21 13:29:53 -07:00
westfarn f3e34f5099 Add shopper accounts, reviews, tracking, and seed_demo (#10)
## Summary
- Closes #9. Port shop-gated buyer accounts (register/login/profile, password reset, order history), purchase-gated reviews, and Stripe customer ids. Cards stay on Stripe.
- Slim public contact form to email + interest + message. Shipping tracking status, scan events, paste-in numbers, and EasyPost webhook when FEATURE_SHIPPING is on.
- Add template-neutral `seed_demo` (generic SKUs, demo shoppers, guest orders, tracking stubs). Prod refused. `--reset` deletes tagged demo rows after invoices.

## Test plan
- [ ] `cd site && uv run python manage.py test` (173 tests passed locally)
- [ ] Shop on: `/account/register`, login, profile, password reset, claim guest orders by email
- [ ] Shop off: `/account/` URLs absent; Sign in / Account links hidden
- [ ] Checkout as logged-in buyer stores `Order.user` and Stripe customer id; product review only after paid purchase
- [ ] Shipping on: portal paste-in tracking + EasyPost webhook updates status; shipping off: orders still work without tracking UI
- [ ] `seed_demo` refused in prod; `--reset` rebuilds tagged catalog/shoppers without deleting staff invoices

Reviewed-on: #10
2026-09-21 13:29:36 -07:00
80 changed files with 4487 additions and 305 deletions

No files matched your search

+5
View File
@@ -71,6 +71,10 @@ PCM_WEBHOOK_SECRETS=
# Quote JSON (compose/shell break on bare {…, …}).
# PCM_RETURN_ADDRESS='{"firstName":"Jane","lastName":"Doe","address":"123 Main","city":"Town","state":"IL","zipCode":"60189"}'
PCM_RETURN_ADDRESS=
# Fallback $/piece when PCM design has no pricing[]; used by compose cost quotes.
# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89
# PCM_POSTCARD_PRICE_STANDARD=0.55
# PCM_LIST_TYPE=IRL
# Stripe — required when FEATURE_PAYMENTS=true
STRIPE_SECRET_KEY=
@@ -88,6 +92,7 @@ POS_API_TOKEN=
# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works.
EASYPOST_API_KEY=
EASYPOST_WEBHOOK_SECRET=
SHIP_FROM_LINE1=
SHIP_FROM_CITY=
SHIP_FROM_STATE=
+4
View File
@@ -60,6 +60,9 @@ PCM_API_KEY=
PCM_API_SECRET=
PCM_WEBHOOK_SECRETS=
PCM_RETURN_ADDRESS=
# PCM_POSTCARD_PRICE_STANDARD=0.55
# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89
# PCM_LIST_TYPE=IRL
# Required when FEATURE_PAYMENTS=true (also requires FEATURE_EMAIL_SMS=true)
STRIPE_SECRET_KEY=
@@ -77,6 +80,7 @@ POS_API_TOKEN=
# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP)
EASYPOST_API_KEY=
EASYPOST_WEBHOOK_SECRET=
SHIP_FROM_LINE1=
SHIP_FROM_CITY=
SHIP_FROM_STATE=
+6
View File
@@ -68,6 +68,9 @@ services:
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
PCM_POSTCARD_PRICE_FIRSTCLASS: ${PCM_POSTCARD_PRICE_FIRSTCLASS:-}
PCM_POSTCARD_PRICE_STANDARD: ${PCM_POSTCARD_PRICE_STANDARD:-}
PCM_LIST_TYPE: ${PCM_LIST_TYPE:-IRL}
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
@@ -111,6 +114,9 @@ services:
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
PCM_POSTCARD_PRICE_FIRSTCLASS: ${PCM_POSTCARD_PRICE_FIRSTCLASS:-}
PCM_POSTCARD_PRICE_STANDARD: ${PCM_POSTCARD_PRICE_STANDARD:-}
PCM_LIST_TYPE: ${PCM_LIST_TYPE:-IRL}
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
+8 -1
View File
@@ -1,9 +1,16 @@
from django.contrib import admin
from accounts.models import RealtorProfile
from accounts.models import CustomerProfile, RealtorProfile
@admin.register(RealtorProfile)
class RealtorProfileAdmin(admin.ModelAdmin):
list_display = ("user", "display_name", "phone")
search_fields = ("user__username", "display_name")
@admin.register(CustomerProfile)
class CustomerProfileAdmin(admin.ModelAdmin):
list_display = ("user", "phone", "stripe_customer_id")
search_fields = ("user__username", "user__email", "phone", "stripe_customer_id")
readonly_fields = ("stripe_customer_id",)
+62
View File
@@ -0,0 +1,62 @@
from django.apps import apps
from django.contrib.auth import views as auth_views
from django.urls import path, reverse_lazy
from accounts import views
from accounts.forms import CustomerPasswordResetForm, CustomerSetPasswordForm
app_name = "account"
urlpatterns = [
path("", views.customer_home, name="home"),
path("login/", views.CustomerLoginView.as_view(), name="login"),
path("logout/", views.customer_logout, name="logout"),
path("register/", views.customer_register, name="register"),
path("profile/", views.customer_profile, name="profile"),
path(
"password-reset/",
auth_views.PasswordResetView.as_view(
template_name="accounts/password_reset_form.html",
email_template_name="accounts/password_reset_email.txt",
subject_template_name="accounts/password_reset_subject.txt",
form_class=CustomerPasswordResetForm,
success_url=reverse_lazy("account:password_reset_done"),
),
name="password_reset",
),
path(
"password-reset/done/",
auth_views.PasswordResetDoneView.as_view(
template_name="accounts/password_reset_done.html",
),
name="password_reset_done",
),
path(
"password-reset/<uidb64>/<token>/",
auth_views.PasswordResetConfirmView.as_view(
template_name="accounts/password_reset_confirm.html",
form_class=CustomerSetPasswordForm,
success_url=reverse_lazy("account:password_reset_complete"),
),
name="password_reset_confirm",
),
path(
"password-reset/complete/",
auth_views.PasswordResetCompleteView.as_view(
template_name="accounts/password_reset_complete.html",
),
name="password_reset_complete",
),
]
if apps.is_installed("shop"):
from shop import views as shop_views
urlpatterns += [
path("orders/", shop_views.account_order_list, name="orders"),
path(
"orders/<uuid:pk>/",
shop_views.account_order_detail,
name="order_detail",
),
]
+178
View File
@@ -0,0 +1,178 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import (
AuthenticationForm,
PasswordResetForm,
SetPasswordForm,
UserCreationForm,
)
from django.core.exceptions import ValidationError
from contacts.models import Contact
User = get_user_model()
_INPUT = {"class": "form-input"}
class CustomerRegisterForm(UserCreationForm):
email = forms.EmailField(
widget=forms.EmailInput(attrs={**_INPUT, "autocomplete": "email"})
)
first_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "given-name"}),
)
last_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "family-name"}),
)
class Meta:
model = User
fields = ("email", "first_name", "last_name", "password1", "password2")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["password1"].widget.attrs.update(_INPUT)
self.fields["password2"].widget.attrs.update(_INPUT)
def clean_email(self):
email = (self.cleaned_data.get("email") or "").strip().lower()
if not email:
raise ValidationError("Enter an email address.")
taken = User.objects.filter(email__iexact=email).exists() or User.objects.filter(
username__iexact=email
).exists()
if taken:
raise ValidationError("An account with that email already exists.")
return email
def save(self, commit=True):
user = super().save(commit=False)
email = self.cleaned_data["email"]
user.username = email
user.email = email
user.first_name = (self.cleaned_data.get("first_name") or "").strip()
user.last_name = (self.cleaned_data.get("last_name") or "").strip()
if commit:
user.save()
return user
class CustomerAuthenticationForm(AuthenticationForm):
username = forms.EmailField(
label="Email",
widget=forms.EmailInput(
attrs={**_INPUT, "id": "id_username", "autocomplete": "email"}
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["password"].widget.attrs.update(
{**_INPUT, "autocomplete": "current-password"}
)
class CustomerPasswordResetForm(PasswordResetForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["email"].widget.attrs.update({**_INPUT, "autocomplete": "email"})
class CustomerSetPasswordForm(SetPasswordForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs.update(_INPUT)
class CustomerProfileForm(forms.Form):
first_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "given-name"}),
)
last_name = forms.CharField(
max_length=150,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "family-name"}),
)
phone = forms.CharField(
max_length=32,
required=False,
widget=forms.TextInput(attrs={**_INPUT, "autocomplete": "tel"}),
)
address_line1 = forms.CharField(
max_length=200,
required=False,
label="Street address",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "off",
"data-ac": "line1",
}
),
)
address_line2 = forms.CharField(
max_length=200,
required=False,
label="Apt / suite",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "address-line2",
"data-ac": "line2",
}
),
)
address_city = forms.CharField(
max_length=100,
required=False,
label="City",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "address-level2",
"data-ac": "city",
}
),
)
address_state = forms.CharField(
max_length=32,
required=False,
label="State",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "address-level1",
"data-ac": "state",
}
),
)
address_zip = forms.CharField(
max_length=20,
required=False,
label="ZIP",
widget=forms.TextInput(
attrs={
**_INPUT,
"autocomplete": "postal-code",
"data-ac": "zip",
}
),
)
def shipping_address(self) -> dict:
data = self.cleaned_data
return Contact.make_postal_address(
line1=data.get("address_line1") or "",
line2=data.get("address_line2") or "",
city=data.get("address_city") or "",
state=data.get("address_state") or "",
zip_code=data.get("address_zip") or "",
)
+27
View File
@@ -0,0 +1,27 @@
from django.shortcuts import redirect
class PortalStaffMiddleware:
"""Keep customer accounts out of the staff portal. Webhooks stay public."""
PORTAL_PREFIX = "/portal/"
WEBHOOK_INFIX = "/webhooks/"
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
path = request.path
if path.startswith(self.PORTAL_PREFIX) and self.WEBHOOK_INFIX not in path:
user = getattr(request, "user", None)
if (
user is not None
and getattr(user, "is_authenticated", False)
and not getattr(user, "is_staff", False)
):
from django.apps import apps
if apps.is_installed("shop"):
return redirect("account:home")
return redirect("public:home")
return self.get_response(request)
@@ -0,0 +1,49 @@
# Generated by Django 6.1
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="CustomerProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("phone", models.CharField(blank=True, max_length=32)),
("shipping_address", models.JSONField(blank=True, default=dict)),
(
"stripe_customer_id",
models.CharField(blank=True, max_length=255),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="customer_profile",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]
+16
View File
@@ -17,3 +17,19 @@ class RealtorProfile(TimeStampedModel):
def __str__(self) -> str:
return self.display_name or self.user.get_username()
class CustomerProfile(TimeStampedModel):
"""Shop-buyer profile. Card numbers stay on Stripe; we only keep the customer id."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="customer_profile",
)
phone = models.CharField(max_length=32, blank=True)
shipping_address = models.JSONField(default=dict, blank=True)
stripe_customer_id = models.CharField(max_length=255, blank=True)
def __str__(self) -> str:
return self.user.get_username()
+22
View File
@@ -0,0 +1,22 @@
from accounts.models import CustomerProfile
def get_customer_profile(user) -> CustomerProfile:
profile, _created = CustomerProfile.objects.get_or_create(user=user)
return profile
def claim_orders_for_user(user) -> int:
"""Attach guest orders that used this email so history/reviews work after signup."""
from django.apps import apps
if not apps.is_installed("shop"):
return 0
from shop.models import Order
email = (user.email or user.username or "").strip()
if not email:
return 0
return Order.objects.filter(user__isnull=True, email__iexact=email).update(
user=user
)
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Account · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<div class="row row-30">
<div class="col-lg-3">
<h5 class="title-6">Account</h5>
<ul class="list-marked">
{% if "shop" in enabled_features %}
<li><a href="{% url 'account:orders' %}">Orders</a></li>
{% endif %}
<li><a href="{% url 'account:profile' %}">Profile</a></li>
<li><a href="{% url 'account:logout' %}">Sign out</a></li>
</ul>
</div>
<div class="col-lg-9">
{% block account_content %}{% endblock %}
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Sign in · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-5">
<h3>Sign in</h3>
<p>View order history, shipping, and reviews.</p>
<form class="rd-form" method="post" action="{% url 'account:login' %}">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.username.id_for_label }}">Email</label>
{{ form.username }}
{{ form.username.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password }}
{{ form.password.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Sign in</button>
</div>
</div>
</form>
<p>New here? <a href="{% url 'account:register' %}">Create an account</a>
· <a href="{% url 'account:password_reset' %}">Forgot password?</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Password updated · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<h3>Password updated</h3>
<p>You can sign in with your new password.</p>
<p><a class="button button-lg button-primary" href="{% url 'account:login' %}">Sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Choose a new password · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-5">
<h3>Choose a new password</h3>
{% if validlink %}
<form class="rd-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1 }}
{{ form.new_password1.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.new_password2.id_for_label }}">Confirm password</label>
{{ form.new_password2 }}
{{ form.new_password2.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Save password</button>
</div>
</div>
</form>
{% else %}
<p>This reset link is invalid or expired. <a href="{% url 'account:password_reset' %}">Request a new one</a>.</p>
{% endif %}
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Check your email · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<h3>Check your email</h3>
<p>If an account exists for that address, a reset link is on its way. Check spam if you do not see it.</p>
<p><a href="{% url 'account:login' %}">Back to sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,8 @@
{% load i18n %}{% autoescape off %}
Reset your {{ site_name }} password
Use this link to choose a new password (it expires):
{{ protocol }}://{{ domain }}{% url 'account:password_reset_confirm' uidb64=uid token=token %}
If you did not ask for a reset, ignore this email.
{% endautoescape %}
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Reset password · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-5">
<h3>Reset password</h3>
<p>Enter the email on your account. We will send a reset link if it matches.</p>
<form class="rd-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20">
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
{{ form.email.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Send reset link</button>
</div>
</div>
</form>
<p><a href="{% url 'account:login' %}">Back to sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1 @@
Password reset for {{ site_name }}
@@ -0,0 +1,84 @@
{% extends "accounts/account_base.html" %}
{% load static %}
{% block title %}Profile · {{ SITE_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block account_content %}
<h3>Profile &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,59 @@
{% extends "base.html" %}
{% block title %}Create account · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<h3>Create an account</h3>
<p>Track orders, save a shipping address, and review products you bought. Card details stay with Stripe — we never store them.</p>
<form class="rd-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row row-20 gutter-20">
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.first_name.id_for_label }}">First name</label>
{{ form.first_name }}
{{ form.first_name.errors }}
</div>
</div>
<div class="col-sm-6">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.last_name.id_for_label }}">Last name</label>
{{ form.last_name }}
{{ form.last_name.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
{{ form.email.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1 }}
{{ form.password1.errors }}
</div>
</div>
<div class="col-12">
<div class="form-wrap">
<label class="form-label-outside" for="{{ form.password2.id_for_label }}">Confirm password</label>
{{ form.password2 }}
{{ form.password2.errors }}
</div>
</div>
<div class="col-12">
<button class="button button-lg button-primary" type="submit">Create account</button>
</div>
</div>
</form>
<p>Already have an account? <a href="{% url 'account:login' %}">Sign in</a></p>
</div>
</div>
</div>
</section>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.tokens import default_token_generator
from django.core import mail
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from accounts.models import CustomerProfile
from shop.models import Order
User = get_user_model()
class CustomerAccountTests(TestCase):
def test_register_login_and_profile(self):
client = Client()
response = client.post(
reverse("account:register"),
{
"email": "buyer@example.com",
"first_name": "Pat",
"last_name": "Lee",
"password1": "s3cure-pass-123",
"password2": "s3cure-pass-123",
},
)
self.assertEqual(response.status_code, 302)
user = User.objects.get(email="buyer@example.com")
self.assertEqual(user.username, "buyer@example.com")
self.assertFalse(user.is_staff)
self.assertTrue(CustomerProfile.objects.filter(user=user).exists())
client.logout()
login = client.post(
reverse("account:login"),
{"username": "buyer@example.com", "password": "s3cure-pass-123"},
)
self.assertEqual(login.status_code, 302)
save = client.post(
reverse("account:profile"),
{
"first_name": "Patricia",
"last_name": "Lee",
"phone": "6305550100",
"address_line1": "10 Main St",
"address_city": "Aurora",
"address_state": "IL",
"address_zip": "60505",
},
)
self.assertEqual(save.status_code, 302)
user.refresh_from_db()
profile = user.customer_profile
self.assertEqual(user.first_name, "Patricia")
self.assertEqual(profile.phone, "6305550100")
self.assertEqual(profile.shipping_address.get("line1"), "10 Main St")
def test_register_claims_guest_orders(self):
order = Order.objects.create(
number="ORD-CLAIM-001",
email="buyer@example.com",
status=Order.Status.PAID,
amount="18.00",
)
client = Client()
client.post(
reverse("account:register"),
{
"email": "buyer@example.com",
"password1": "s3cure-pass-123",
"password2": "s3cure-pass-123",
},
)
order.refresh_from_db()
self.assertEqual(order.user.email, "buyer@example.com")
history = client.get(reverse("account:orders"))
self.assertEqual(history.status_code, 200)
self.assertContains(history, "ORD-CLAIM-001")
def test_customer_cannot_open_portal(self):
User.objects.create_user(
username="buyer@example.com",
email="buyer@example.com",
password="s3cure-pass-123",
)
client = Client()
client.login(username="buyer@example.com", password="s3cure-pass-123")
response = client.get(reverse("dashboard:home"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("account:home"))
def test_password_reset_sends_mail_and_sets_new_password(self):
user = User.objects.create_user(
username="buyer@example.com",
email="buyer@example.com",
password="s3cure-pass-123",
)
client = Client()
login_page = client.get(reverse("account:login"))
self.assertContains(login_page, reverse("account:password_reset"))
posted = client.post(
reverse("account:password_reset"),
{"email": "buyer@example.com"},
)
self.assertEqual(posted.status_code, 302)
self.assertEqual(len(mail.outbox), 1)
self.assertIn("password-reset", mail.outbox[0].body)
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = default_token_generator.make_token(user)
confirm_url = reverse(
"account:password_reset_confirm",
kwargs={"uidb64": uid, "token": token},
)
bounced = client.get(confirm_url)
self.assertEqual(bounced.status_code, 302)
set_url = bounced["Location"]
saved = client.post(
set_url,
{
"new_password1": "n3wer-pass-456",
"new_password2": "n3wer-pass-456",
},
)
self.assertEqual(saved.status_code, 302)
user.refresh_from_db()
self.assertTrue(user.check_password("n3wer-pass-456"))
+3 -1
View File
@@ -1,12 +1,14 @@
from django.contrib.auth import views as auth_views
from django.urls import path
from accounts.views import PortalLoginView
app_name = "accounts"
urlpatterns = [
path(
"login/",
auth_views.LoginView.as_view(template_name="accounts/login.html"),
PortalLoginView.as_view(),
name="login",
),
path(
+123
View File
@@ -0,0 +1,123 @@
from django.contrib import messages
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import LoginView
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from accounts.forms import (
CustomerAuthenticationForm,
CustomerProfileForm,
CustomerRegisterForm,
)
from accounts.services import claim_orders_for_user, get_customer_profile
class PortalLoginView(LoginView):
template_name = "accounts/login.html"
def get_success_url(self):
url = self.get_redirect_url()
if url:
return url
if self.request.user.is_staff:
return reverse("dashboard:home")
from django.apps import apps
if apps.is_installed("shop"):
return reverse("account:home")
return reverse("public:home")
class CustomerLoginView(LoginView):
template_name = "accounts/customer_login.html"
authentication_form = CustomerAuthenticationForm
redirect_authenticated_user = True
def get_success_url(self):
url = self.get_redirect_url()
if url:
return url
if self.request.user.is_staff:
return reverse("dashboard:home")
return reverse("account:home")
@require_http_methods(["GET", "POST"])
def customer_register(request):
if request.user.is_authenticated:
return redirect("account:home")
if request.method == "POST":
form = CustomerRegisterForm(request.POST)
if form.is_valid():
user = form.save()
get_customer_profile(user)
claimed = claim_orders_for_user(user)
login(request, user)
if claimed:
messages.success(
request,
f"Account created. {claimed} existing order(s) are now in your history.",
)
else:
messages.success(request, "Account created. Welcome.")
return redirect("account:home")
else:
initial = {}
email = (request.GET.get("email") or "").strip()
if email:
initial["email"] = email
form = CustomerRegisterForm(initial=initial)
return render(request, "accounts/register.html", {"form": form})
@require_http_methods(["GET", "POST"])
def customer_logout(request):
logout(request)
messages.success(request, "Signed out.")
return redirect("public:home")
@login_required(login_url="account:login")
def customer_home(request):
from django.apps import apps
if apps.is_installed("shop"):
return redirect("account:orders")
return redirect("account:profile")
@login_required(login_url="account:login")
@require_http_methods(["GET", "POST"])
def customer_profile(request):
profile = get_customer_profile(request.user)
addr = profile.shipping_address or {}
initial = {
"first_name": request.user.first_name,
"last_name": request.user.last_name,
"phone": profile.phone,
"address_line1": addr.get("line1") or "",
"address_line2": addr.get("line2") or "",
"address_city": addr.get("city") or "",
"address_state": addr.get("state") or "",
"address_zip": addr.get("zip") or "",
}
if request.method == "POST":
form = CustomerProfileForm(request.POST)
if form.is_valid():
request.user.first_name = form.cleaned_data.get("first_name") or ""
request.user.last_name = form.cleaned_data.get("last_name") or ""
request.user.save(update_fields=["first_name", "last_name"])
profile.phone = form.cleaned_data.get("phone") or ""
profile.shipping_address = form.shipping_address()
profile.save(update_fields=["phone", "shipping_address", "updated_at"])
messages.success(request, "Profile saved.")
return redirect("account:profile")
else:
form = CustomerProfileForm(initial=initial)
return render(
request,
"accounts/profile.html",
{"form": form, "profile": profile},
)
+1 -2
View File
@@ -60,8 +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)
def test_views_card_uses_public_pageviews(self):
+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")
+5
View File
@@ -183,6 +183,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"accounts.middleware.PortalStaffMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"analytics.middleware.UTMTrackingMiddleware",
@@ -349,6 +350,9 @@ PCM_RETURN_LINE2 = env("PCM_RETURN_LINE2", "")
PCM_RETURN_CITY = env("PCM_RETURN_CITY", "")
PCM_RETURN_STATE = env("PCM_RETURN_STATE", "")
PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "")
PCM_POSTCARD_PRICE_FIRSTCLASS = env("PCM_POSTCARD_PRICE_FIRSTCLASS", "")
PCM_POSTCARD_PRICE_STANDARD = env("PCM_POSTCARD_PRICE_STANDARD", "")
PCM_LIST_TYPE = env("PCM_LIST_TYPE", "IRL")
# Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid)
LOB_API_KEY = env("LOB_API_KEY", "")
CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "")
@@ -381,6 +385,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", "")
@@ -0,0 +1,278 @@
(function () {
function money(n) {
if (n === null || n === undefined || n === "") return "—";
var v = Number(n);
if (Number.isNaN(v)) return "—";
return "$" + v.toFixed(2);
}
function hhSize(n) {
if (n === null || n === undefined || n === "") return "—";
var v = Number(n);
if (Number.isNaN(v)) return "—";
return v.toFixed(1);
}
function income(n) {
if (n === null || n === undefined || n === "") return "—";
var v = Number(n);
if (Number.isNaN(v)) return "—";
return "$" + Math.round(v).toLocaleString("en-US");
}
function count(n) {
if (n === null || n === undefined || n === "") return "—";
var v = Number(n);
if (Number.isNaN(v)) return "—";
return String(v);
}
function initPostcardRoutes(opts) {
var form = document.getElementById("campaign-compose");
if (!form) return;
var audience = document.getElementById("id_audience");
var picker = document.getElementById("postcard-route-picker");
var listCost = document.getElementById("postcard-list-cost");
var hidden = document.getElementById("id_route_targeting");
var findBtn = document.getElementById("route-find-btn");
var status = document.getElementById("route-status");
var tableWrap = document.getElementById("route-table-wrap");
var tbody = document.getElementById("route-table-body");
var selectedBox = document.getElementById("route-selected-cost");
var eddmLink = document.getElementById("eddm-map-link");
var quoteUrl = opts.quoteUrl;
var csrfToken = opts.csrfToken;
var maxRoutes = 2;
var routes = [];
function isPostcardAudience() {
var v = (audience && audience.value) || "";
return v === "postcard_opt_in" || v === "carrier_route";
}
function isRouteAudience() {
return ((audience && audience.value) || "") === "carrier_route";
}
function templateId() {
return (document.getElementById("id_template_id") || {}).value || "";
}
function setStatus(text, isError) {
if (!status) return;
status.textContent = text || "";
status.classList.toggle("error", !!isError);
}
function selectedCodes() {
return Array.prototype.map.call(
form.querySelectorAll('input[name="route_code"]:checked'),
function (el) { return el.value; }
);
}
function postQuote(body) {
return fetch(quoteUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
Accept: "application/json"
},
credentials: "same-origin",
body: JSON.stringify(body)
}).then(function (res) {
return res.json().then(function (json) {
if (!res.ok) throw new Error(json.detail || "Quote failed");
return json;
});
});
}
function renderListCost(data) {
if (!listCost) return;
if (!isPostcardAudience() || isRouteAudience()) {
listCost.hidden = true;
return;
}
listCost.hidden = false;
var src = data.price_source === "fallback" ? " (env fallback rate)" : "";
if (data.per_piece == null) {
listCost.innerHTML =
"<strong>Estimated cost:</strong> " + data.count +
" household" + (data.count === 1 ? "" : "s") +
" · per-piece price unavailable. Set PCM_POSTCARD_PRICE_STANDARD / FIRSTCLASS.";
return;
}
listCost.innerHTML =
"<strong>Estimated cost:</strong> " + data.count +
" × " + money(data.per_piece) + " = " + money(data.total) +
" (" + data.mail_class + src + ")";
}
function refreshListCost() {
if (!isPostcardAudience() || isRouteAudience()) {
if (listCost) listCost.hidden = true;
return;
}
postQuote({ action: "mailing_list", template_id: templateId() })
.then(renderListCost)
.catch(function (err) {
if (listCost) {
listCost.hidden = false;
listCost.textContent = err.message || "Could not load mailing-list cost.";
}
});
}
function writeHidden(payload) {
if (hidden) hidden.value = payload ? JSON.stringify(payload) : "";
}
function renderSelected(data) {
if (!selectedBox) return;
if (!data) {
selectedBox.hidden = true;
writeHidden("");
return;
}
selectedBox.hidden = false;
var src = data.price_source === "fallback" ? " using env fallback rate" : "";
var missing = data.per_piece == null
? " Per-piece price unavailable — set PCM_POSTCARD_PRICE_* before sending."
: "";
selectedBox.innerHTML =
"<strong>Selected:</strong> " + (data.record_count || 0) +
" household" + ((data.record_count || 0) === 1 ? "" : "s") +
" on " + (data.selected_codes || []).join(", ") +
" · " + (data.mail_class || "") +
" · est. " + money(data.total) + src + "." + missing;
writeHidden({
selected_codes: data.selected_codes,
list_count_id: data.list_count_id,
record_count: data.record_count,
mail_class: data.mail_class,
estimated: data.estimated,
route_rows: data.route_rows || data.routes
});
}
function recount() {
var codes = selectedCodes();
if (!codes.length) {
renderSelected(null);
setStatus("Select one or two routes.");
return;
}
setStatus("Updating quote…");
postQuote({
action: "select",
template_id: templateId(),
routes: codes
}).then(function (data) {
setStatus("");
renderSelected(data);
}).catch(function (err) {
renderSelected(null);
setStatus(err.message || "Could not quote selected routes.", true);
});
}
function renderRoutes(list) {
routes = list || [];
if (!tbody || !tableWrap) return;
if (!routes.length) {
tableWrap.hidden = true;
tbody.innerHTML = "";
return;
}
tableWrap.hidden = false;
tbody.innerHTML = routes.map(function (row) {
return "<tr>" +
"<td><label class=\"check-row\" style=\"margin:0\">" +
"<input type=\"checkbox\" name=\"route_code\" value=\"" +
String(row.code).replace(/"/g, "&quot;") + "\"> " +
String(row.code) + "</label></td>" +
"<td>" + String(row.text || "") + "</td>" +
"<td>" + String(row.total) + "</td>" +
"<td>" + hhSize(row.household_size) + "</td>" +
"<td>" + income(row.median_income) + "</td>" +
"<td>" + count(row.businesses) + "</td>" +
"<td>" + money(row.cost) + "</td></tr>";
}).join("");
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
box.addEventListener("change", function () {
var checked = selectedCodes();
if (checked.length > maxRoutes) {
box.checked = false;
setStatus("Pick at most two routes.", true);
return;
}
recount();
});
});
}
function findRoutes() {
var zip = (document.getElementById("route-zip") || {}).value || "";
var address = (document.getElementById("route-address") || {}).value || "";
var city = (document.getElementById("route-city") || {}).value || "";
var state = (document.getElementById("route-state") || {}).value || "";
var radius = (document.getElementById("route-radius") || {}).value || "";
setStatus("Looking up routes…");
tableWrap.hidden = true;
renderSelected(null);
postQuote({
action: "search",
template_id: templateId(),
zip: zip,
address: address,
city: city,
state: state,
radius: radius
}).then(function (data) {
renderRoutes(data.routes || []);
if (eddmLink && data.eddm_map_url) {
eddmLink.href = data.eddm_map_url;
}
if (!(data.routes || []).length) {
setStatus("No carrier routes for that search.", true);
} else {
setStatus("Select one or two routes. Cost uses " + (data.mail_class || "Standard") + ".");
}
}).catch(function (err) {
renderRoutes([]);
setStatus(err.message || "Route lookup failed.", true);
});
}
function syncVisibility() {
if (picker) picker.hidden = !isRouteAudience();
if (!isRouteAudience()) {
writeHidden("");
if (selectedBox) selectedBox.hidden = true;
}
refreshListCost();
}
if (findBtn) findBtn.addEventListener("click", function (e) {
e.preventDefault();
findRoutes();
});
var tmpl = document.getElementById("id_template_id");
if (tmpl) tmpl.addEventListener("change", function () {
syncVisibility();
if (isRouteAudience() && selectedCodes().length) recount();
});
if (audience) audience.addEventListener("change", syncVisibility);
syncVisibility();
window.MonicaPostcardRoutes = {
isPostcardAudience: isPostcardAudience,
isRouteAudience: isRouteAudience,
syncVisibility: syncVisibility
};
}
window.initPostcardRoutes = initPostcardRoutes;
})();
+19 -1
View File
@@ -90,6 +90,19 @@
<a class="rd-nav-link" href="{% url item.url_name %}">{{ item.label }}</a>
</li>
{% endfor %}
{% if user.is_authenticated %}
<li class="rd-nav-item">
{% if user.is_staff %}
<a class="rd-nav-link" href="{% url 'dashboard:home' %}">Portal</a>
{% elif "shop" in enabled_features %}
<a class="rd-nav-link" href="{% url 'account:home' %}">Account</a>
{% endif %}
</li>
{% elif "shop" in enabled_features %}
<li class="rd-nav-item">
<a class="rd-nav-link" href="{% url 'account:login' %}">Sign in</a>
</li>
{% endif %}
</ul>
</div>
</div>
@@ -134,9 +147,14 @@
{% for item in public_nav_extra %}
<li><a href="{% url item.url_name %}">{{ item.label }}</a></li>
{% endfor %}
{% if user.is_authenticated %}
{% if user.is_authenticated and not user.is_staff and "shop" in enabled_features %}
<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
@@ -50,6 +50,7 @@ if apps.is_installed("social_ai"):
]
if apps.is_installed("shop"):
urlpatterns += [
path("account/", include("accounts.customer_urls")),
path("shop/", include("shop.public_urls")),
path("portal/shop/", include("shop.portal_urls")),
]
+23 -12
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,33 @@ 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": "quote",
"message": "Need a quote",
"first_name": "Ignored",
"phone": "6301112222",
"address_line1": "55 River Rd",
},
)
self.assertEqual(response.status_code, 302)
created = Contact.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")
+848
View File
@@ -0,0 +1,848 @@
"""Populate a demo catalog, portal lists, and shop traffic for client walkthroughs.
Safe on DJANGO_ENV=dev and beta only. Never runs against prod. Does not send
mail or call EasyPost/Stripe. Re-runs are idempotent (DEMO- / demo+ keys).
Pass --reset to wipe tagged demo rows and seed again.
uv run python manage.py seed_demo
docker compose exec web uv run python manage.py seed_demo --reset
"""
from __future__ import annotations
import os
from datetime import timedelta
from decimal import Decimal
from django.apps import apps
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
DEMO_NOTE = "[demo-seed]"
DEMO_UTM_CAMPAIGN = "site-demo"
DEMO_EMAIL_DOMAIN = "@example.com"
DEMO_EMAIL_PREFIX = "demo+"
DEMO_SHOPPER_PASSWORD = "Demo-buyer-pass-123"
DEMO_SHOPPER_SLUGS = ("jordan", "casey", "sam")
def _django_env() -> str:
return (os.environ.get("DJANGO_ENV") or "dev").lower()
def _stamp(model, pk, when) -> None:
model.objects.filter(pk=pk).update(created_at=when, updated_at=when)
class Command(BaseCommand):
help = (
"Seed tagged fake catalog, shoppers, reviews, tracking, contacts, "
"leads, orders, campaigns, and analytics for client demos. "
"Allowed on DJANGO_ENV=dev and beta only. Demo shoppers use "
f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}."
)
def add_arguments(self, parser):
parser.add_argument(
"--reset",
action="store_true",
help="Delete previously tagged demo rows, then seed again.",
)
def handle(self, *args, **options):
env = _django_env()
if env == "prod":
raise CommandError(
"seed_demo refuses DJANGO_ENV=prod. Run it on dev or beta."
)
self.verbosity = int(options.get("verbosity", 1))
self.now = timezone.now()
if options["reset"]:
self._reset()
self.stdout.write("Cleared tagged demo rows.")
User = get_user_model()
self.owner = User.objects.filter(is_staff=True).order_by("pk").first()
if self.owner is None:
self.owner = (
User.objects.exclude(username__startswith=DEMO_EMAIL_PREFIX)
.order_by("pk")
.first()
)
counts = {
"contacts": self._seed_contacts(),
"leads": self._seed_leads(),
"analytics": self._seed_analytics(),
}
if apps.is_installed("shop"):
counts["products"] = self._seed_products()
counts["shoppers"] = self._seed_shoppers()
counts["orders"] = self._seed_orders()
counts["reviews"] = self._seed_reviews()
if apps.is_installed("shipping"):
counts["shipments"] = self._seed_shipments()
if apps.is_installed("payments"):
counts["invoices"] = self._seed_invoices()
if apps.is_installed("email_sms"):
counts["campaigns"] = self._seed_campaigns()
summary = ", ".join(f"{key}={value}" for key, value in counts.items())
self.stdout.write(self.style.SUCCESS(f"Demo seed ready on {env} ({summary})."))
if apps.is_installed("shop"):
self.stdout.write(
"Demo shopper: "
f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}"
)
def _log(self, message: str) -> None:
if self.verbosity >= 2:
self.stdout.write(message)
def _reset(self) -> None:
from analytics.models import UTMVisit
from contacts.models import Contact
from leads.models import Lead
if apps.is_installed("shipping"):
from shipping.models import Shipment
Shipment.objects.filter(order__number__startswith="DEMO-").delete()
if apps.is_installed("shop"):
from shop.models import Order, Product
Order.objects.filter(number__startswith="DEMO-").delete()
Product.objects.filter(sku__startswith="DEMO-").delete()
if apps.is_installed("payments"):
from payments.models import Invoice
Invoice.objects.filter(number__startswith="DEMO-").delete()
if apps.is_installed("email_sms"):
from email_sms.models import Campaign
Campaign.objects.filter(name__startswith="DEMO:").delete()
Lead.objects.filter(message__startswith=DEMO_NOTE).delete()
Contact.objects.filter(
email__startswith=DEMO_EMAIL_PREFIX,
email__endswith=DEMO_EMAIL_DOMAIN,
).delete()
User = get_user_model()
User.objects.filter(
username__startswith=DEMO_EMAIL_PREFIX,
username__endswith=DEMO_EMAIL_DOMAIN,
is_staff=False,
).delete()
UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).delete()
def _seed_contacts(self) -> int:
from contacts.consent import set_channel_consent
from contacts.models import Channel, Contact
people = (
(
"maya",
"Maya",
"Chen",
"630-555-0142",
Contact.Source.CONTACT_FORM,
True,
True,
"Naperville",
"Asked about bulk pricing.",
),
(
"jordan",
"Jordan",
"Walsh",
"630-555-0198",
Contact.Source.MANUAL,
True,
False,
"Aurora",
"Repeat buyer.",
),
(
"priya",
"Priya",
"Shah",
"847-555-0110",
Contact.Source.IMPORT,
True,
True,
"Wheaton",
"Event favor quote.",
),
(
"evan",
"Evan",
"Brooks",
"312-555-0177",
Contact.Source.NOTIFY_ME,
False,
True,
"Chicago",
"Notify list from the coming-soon page.",
),
(
"sam",
"Sam",
"Ortiz",
"630-555-0166",
Contact.Source.CONTACT_FORM,
True,
True,
"Lisle",
"Team gifts.",
),
(
"riley",
"Riley",
"Nguyen",
"708-555-0133",
Contact.Source.OTHER,
True,
False,
"Downers Grove",
"Asked about made-to-order items.",
),
(
"alex",
"Alex",
"Patel",
"630-555-0121",
Contact.Source.MANUAL,
False,
False,
"Naperville",
"Opted out of email after one campaign.",
),
(
"casey",
"Casey",
"Miller",
"815-555-0188",
Contact.Source.IMPORT,
True,
True,
"Geneva",
"School fair bulk order.",
),
)
created = 0
for slug, first, last, phone, source, email_on, sms_on, city, note in people:
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
contact, was_created = Contact.objects.update_or_create(
email=email,
defaults={
"first_name": first,
"last_name": last,
"phone": phone,
"source": source,
"notes": f"{DEMO_NOTE} {note}",
"postal_address": Contact.make_postal_address(
line1=f"{100 + len(slug) * 17} Demo Ave",
city=city,
state="IL",
zip_code="60540",
),
},
)
set_channel_consent(
contact, Channel.EMAIL, opted_in=email_on, reason="demo_seed"
)
set_channel_consent(
contact, Channel.SMS, opted_in=sms_on, reason="demo_seed"
)
if was_created:
created += 1
self._log(f"contact {contact.email}")
return created
def _seed_leads(self) -> int:
from analytics.models import Attribution
from contacts.models import Contact
from leads.models import Lead, LeadNote
specs = (
(
"maya",
Lead.Status.NEW,
"Need a dozen units by next Friday.",
"instagram",
"social",
2,
),
(
"priya",
Lead.Status.CONTACTED,
"Quoted 40 keychains. Waiting on names.",
"google",
"cpc",
8,
),
(
"casey",
Lead.Status.WON,
"Fair kits — 30 tote bags, paid via invoice.",
"google",
"organic",
18,
),
(
"riley",
Lead.Status.LOST,
"Wanted 200 units at a price we could not hit.",
"",
"",
12,
),
)
created = 0
for slug, status, body, source, medium, days_ago in specs:
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
contact = Contact.objects.filter(email=email).first()
if contact is None:
continue
message = f"{DEMO_NOTE} {body}"
lead, was_created = Lead.objects.get_or_create(
contact=contact,
message=message,
defaults={"status": status, "owner": self.owner},
)
if not was_created:
if lead.status != status:
lead.status = status
lead.owner = self.owner
lead.save(update_fields=["status", "owner", "updated_at"])
else:
created += 1
LeadNote.objects.create(
lead=lead,
author=self.owner,
body=f"{DEMO_NOTE} Demo walkthrough note.",
)
when = self.now - timedelta(days=days_ago)
_stamp(Lead, lead.pk, when)
if source:
Attribution.objects.update_or_create(
lead=lead,
defaults={
"utm_source": source,
"utm_medium": medium,
"utm_campaign": DEMO_UTM_CAMPAIGN,
},
)
self._log(f"lead {slug} {status}")
return created
def _seed_analytics(self) -> int:
from analytics.models import PageView, UTMVisit
if UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).exists():
return 0
paths = (
"/",
"/",
"/",
"/shop/",
"/shop/",
"/shop/demo-notebook/",
"/shop/demo-tote/",
"/about/",
"/contact/",
)
created = 0
for offset, path in enumerate(paths * 6):
when = self.now - timedelta(hours=4 * offset + 3)
view = PageView.objects.create(path=path)
_stamp(PageView, view.pk, when)
created += 1
sources = (
("google", "cpc", "/shop/"),
("google", "organic", "/"),
("instagram", "social", "/shop/demo-notebook/"),
("facebook", "social", "/contact/"),
("direct", "", "/"),
)
for i, (source, medium, path) in enumerate(sources * 8):
when = self.now - timedelta(hours=6 * i + 2)
visit = UTMVisit.objects.create(
correlation_id=f"demo-{i:03d}",
path=path,
utm_source=source,
utm_medium=medium,
utm_campaign=DEMO_UTM_CAMPAIGN,
user_agent="Demo seed",
)
_stamp(UTMVisit, visit.pk, when)
created += 1
return created
def _seed_products(self) -> int:
from shop.models import Product
catalog = (
{
"sku": "DEMO-NOTEBOOK",
"name": "Notebook",
"price": Decimal("18.00"),
"fulfillment": Product.Fulfillment.STOCKED,
"stock_qty": 24,
"print_minutes": 0,
"filament_grams": 0,
"published": True,
"description": "Lined notebook. A simple catalog item for demos.",
},
{
"sku": "DEMO-STICKER",
"name": "Sticker pack",
"price": Decimal("8.00"),
"fulfillment": Product.Fulfillment.STOCKED,
"stock_qty": 40,
"print_minutes": 0,
"filament_grams": 0,
"published": True,
"description": "Pack of five stickers.",
},
{
"sku": "DEMO-TOTE",
"name": "Tote bag",
"price": Decimal("22.00"),
"fulfillment": Product.Fulfillment.STOCKED,
"stock_qty": 12,
"print_minutes": 0,
"filament_grams": 0,
"published": True,
"description": "Canvas tote. Good for a fulfilled demo order.",
},
{
"sku": "DEMO-MUG",
"name": "Ceramic mug",
"price": Decimal("16.00"),
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
"stock_qty": 0,
"print_minutes": 45,
"filament_grams": 0,
"published": True,
"description": "Printed to order. Used as the guest checkout demo.",
},
{
"sku": "DEMO-KEYCHAIN",
"name": "Custom keychain",
"price": Decimal("12.00"),
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
"stock_qty": 0,
"print_minutes": 20,
"filament_grams": 0,
"published": True,
"description": "Made to order with a name on the loop.",
},
{
"sku": "DEMO-PROTO",
"name": "Draft product (coming soon)",
"price": Decimal("45.00"),
"fulfillment": Product.Fulfillment.MADE_TO_ORDER,
"stock_qty": 0,
"print_minutes": 90,
"filament_grams": 0,
"published": False,
"description": "Unpublished so the portal list is not empty of drafts.",
},
)
created = 0
for spec in catalog:
product, was_created = Product.objects.update_or_create(
sku=spec["sku"],
defaults={
"name": spec["name"],
"slug": spec["sku"].lower(),
"description": spec["description"],
"price": spec["price"],
"currency": "usd",
"fulfillment": spec["fulfillment"],
"stock_qty": spec["stock_qty"],
"print_minutes": spec["print_minutes"],
"filament_grams": spec["filament_grams"],
"is_published": spec["published"],
"track_inventory": True,
},
)
if was_created:
created += 1
self._log(f"product {product.sku}")
return created
def _seed_shoppers(self) -> int:
from accounts.services import get_customer_profile
from contacts.models import Contact
User = get_user_model()
created = 0
for slug in DEMO_SHOPPER_SLUGS:
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
contact = Contact.objects.filter(email=email).first()
if contact is None:
continue
user = User.objects.filter(username__iexact=email).first()
if user is None:
user = User.objects.create_user(
username=email,
email=email,
password=DEMO_SHOPPER_PASSWORD,
first_name=contact.first_name,
last_name=contact.last_name,
is_staff=False,
)
created += 1
else:
user.first_name = contact.first_name
user.last_name = contact.last_name
user.email = email
user.is_staff = False
user.set_password(DEMO_SHOPPER_PASSWORD)
user.save(
update_fields=[
"first_name",
"last_name",
"email",
"is_staff",
"password",
]
)
profile = get_customer_profile(user)
profile.phone = contact.phone
profile.shipping_address = contact.postal_address or {}
profile.save(update_fields=["phone", "shipping_address", "updated_at"])
self._log(f"shopper {email}")
return created
def _seed_orders(self) -> int:
from contacts.models import Contact
from shop.models import Order, OrderItem, Product
notebook = Product.objects.filter(sku="DEMO-NOTEBOOK").first()
sticker = Product.objects.filter(sku="DEMO-STICKER").first()
tote = Product.objects.filter(sku="DEMO-TOTE").first()
mug = Product.objects.filter(sku="DEMO-MUG").first()
if not all([notebook, sticker, tote, mug]):
return 0
specs = (
(
"DEMO-ORD-001",
"jordan",
Order.Status.PAID,
((notebook, 1),),
3,
),
(
"DEMO-ORD-002",
"casey",
Order.Status.FULFILLED,
((tote, 2), (sticker, 1)),
11,
),
(
"DEMO-ORD-003",
"sam",
Order.Status.PAID,
((sticker, 2),),
1,
),
(
"DEMO-ORD-004",
"riley",
Order.Status.OPEN,
((mug, 1),),
0,
),
(
"DEMO-ORD-005",
"alex",
Order.Status.CANCELLED,
((notebook, 1),),
16,
),
)
created = 0
User = get_user_model()
for number, slug, status, lines, days_ago in specs:
contact = Contact.objects.filter(
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
).first()
if contact is None:
continue
buyer = User.objects.filter(username__iexact=contact.email).first()
amount = sum(
(product.price * qty for product, qty in lines),
Decimal("0.00"),
)
paid = status in {Order.Status.PAID, Order.Status.FULFILLED}
when = self.now - timedelta(days=days_ago, hours=5)
order, was_created = Order.objects.update_or_create(
number=number,
defaults={
"user": buyer,
"email": contact.email,
"customer_name": contact.full_name,
"status": status,
"amount": amount,
"currency": "usd",
"paid_at": when if paid else None,
"shipping_address": contact.postal_address,
"notes": f"{DEMO_NOTE} Demo order.",
},
)
if was_created:
created += 1
if not order.items.exists():
for product, qty in lines:
OrderItem.objects.create(
order=order,
product=product,
name=product.name,
sku=product.sku,
quantity=qty,
unit_price=product.price,
print_minutes=product.print_minutes,
)
_stamp(Order, order.pk, when)
self._log(f"order {order.number} {status}")
return created
def _seed_reviews(self) -> int:
from shop.models import Order, Product, ProductReview
User = get_user_model()
specs = (
(
"jordan",
"DEMO-ORD-001",
"DEMO-NOTEBOOK",
5,
"Solid notebook",
"Paper quality is great for daily notes.",
),
(
"casey",
"DEMO-ORD-002",
"DEMO-TOTE",
4,
"Sturdy tote",
"Held up for the school fair. Would buy again.",
),
(
"sam",
"DEMO-ORD-003",
"DEMO-STICKER",
5,
"Bright stickers",
"Colors popped. Kids loved them.",
),
)
created = 0
for slug, order_number, sku, rating, title, body in specs:
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
user = User.objects.filter(username__iexact=email).first()
order = Order.objects.filter(number=order_number, user=user).first()
product = Product.objects.filter(sku=sku).first()
if user is None or order is None or product is None:
continue
review, was_created = ProductReview.objects.update_or_create(
user=user,
product=product,
defaults={
"order": order,
"rating": rating,
"title": title,
"body": f"{DEMO_NOTE} {body}",
},
)
if was_created:
created += 1
self._log(f"review {sku} {rating}")
return created
def _seed_shipments(self) -> int:
from shipping.models import Shipment
from shop.models import Order
specs = (
(
"DEMO-ORD-002",
{
"status": Shipment.Status.LABELED,
"carrier": "USPS",
"service": "Priority",
"tracking_number": "940011189922DEMO02",
"tracking_status": Shipment.TrackingStatus.DELIVERED,
"tracking_url": (
"https://tools.usps.com/go/TrackConfirmAction"
"?tLabels=940011189922DEMO02"
),
"rate_amount": Decimal("8.45"),
"currency": "usd",
"weight_oz": 18,
"last_tracked_at": self.now - timedelta(hours=6),
"tracking_events": [
{
"status": "in_transit",
"message": "Departed USPS facility",
"datetime": (self.now - timedelta(days=2)).isoformat(),
"location": "Chicago IL",
},
{
"status": "delivered",
"message": "Delivered, front door",
"datetime": (self.now - timedelta(days=1)).isoformat(),
"location": "Geneva IL",
},
],
"notes": f"{DEMO_NOTE} Stub delivered label. No EasyPost call.",
},
),
(
"DEMO-ORD-001",
{
"status": Shipment.Status.LABELED,
"carrier": "USPS",
"service": "GroundAdvantage",
"tracking_number": "940011189922DEMO01",
"tracking_status": Shipment.TrackingStatus.IN_TRANSIT,
"tracking_url": (
"https://tools.usps.com/go/TrackConfirmAction"
"?tLabels=940011189922DEMO01"
),
"rate_amount": Decimal("5.40"),
"currency": "usd",
"weight_oz": 12,
"last_tracked_at": self.now - timedelta(hours=2),
"tracking_events": [
{
"status": "pre_transit",
"message": "Shipping label created",
"datetime": (self.now - timedelta(days=1)).isoformat(),
"location": "Aurora IL",
},
{
"status": "in_transit",
"message": "Arrived at USPS origin facility",
"datetime": (self.now - timedelta(hours=8)).isoformat(),
"location": "Chicago IL",
},
],
"notes": f"{DEMO_NOTE} Stub in-transit label. No EasyPost call.",
},
),
)
created = 0
for number, defaults in specs:
order = Order.objects.filter(number=number).first()
if order is None:
continue
shipment = Shipment.objects.filter(order=order).first()
if shipment is None:
Shipment.objects.create(order=order, **defaults)
created += 1
else:
for key, value in defaults.items():
setattr(shipment, key, value)
shipment.save()
self._log(f"shipment {number} {defaults['tracking_status']}")
return created
def _seed_invoices(self) -> int:
from contacts.models import Contact
from payments.models import Invoice
specs = (
(
"DEMO-INV-001",
"casey",
Invoice.Status.PAID,
Decimal("210.00"),
"Fair tote kits (30)",
14,
),
(
"DEMO-INV-002",
"priya",
Invoice.Status.OPEN,
Decimal("480.00"),
"Event keychains (40) — deposit due",
4,
),
)
created = 0
for number, slug, status, amount, description, days_ago in specs:
contact = Contact.objects.filter(
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
).first()
if contact is None:
continue
when = self.now - timedelta(days=days_ago)
paid = status == Invoice.Status.PAID
invoice, was_created = Invoice.objects.update_or_create(
number=number,
defaults={
"contact": contact,
"description": description,
"amount": amount,
"currency": "usd",
"status": status,
"paid_at": when if paid else None,
"created_by": self.owner,
"notes": f"{DEMO_NOTE} Demo invoice. No Stripe session.",
},
)
if was_created:
created += 1
_stamp(Invoice, invoice.pk, when)
self._log(f"invoice {invoice.number} {status}")
return created
def _seed_campaigns(self) -> int:
from email_sms.models import Campaign, Message
from email_sms.services import create_campaign_draft
created = 0
draft_name = "DEMO: New arrivals"
if not Campaign.objects.filter(name=draft_name).exists():
create_campaign_draft(
name=draft_name,
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="New items just landed",
body="<p>Restocked notebooks and tote bags. Shop the drop.</p>",
created_by=self.owner,
)
created += 1
done_name = "DEMO: Event recap"
if not Campaign.objects.filter(name=done_name).exists():
campaign = create_campaign_draft(
name=done_name,
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Thanks for coming",
body="<p>Your order is packed. Tracking goes out tomorrow.</p>",
created_by=self.owner,
)
campaign.status = Campaign.Status.COMPLETED
campaign.notify_sent_at = self.now - timedelta(days=9)
campaign.save(update_fields=["status", "notify_sent_at", "updated_at"])
messages = list(campaign.messages.all())
for index, message in enumerate(messages):
message.status = (
Message.Status.OPENED
if index % 3 == 0
else Message.Status.DELIVERED
)
message.sent_at = self.now - timedelta(days=10)
message.save(update_fields=["status", "sent_at", "updated_at"])
created += 1
self._log(f"campaign {done_name} completed")
sms_name = "DEMO: Order update SMS"
if not Campaign.objects.filter(name=sms_name).exists():
create_campaign_draft(
name=sms_name,
audience=Campaign.Audience.SMS_OPT_IN,
body="Your order is packing. We will text when it ships.",
created_by=self.owner,
)
created += 1
return created
+111
View File
@@ -1,4 +1,6 @@
import os
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import Client, TestCase, override_settings
@@ -30,6 +32,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):
@@ -37,3 +44,107 @@ class DispatchDueTests(TestCase):
out = StringIO()
call_command("dispatch_due", stdout=out)
self.assertEqual(out.getvalue(), "")
class SeedDemoTests(TestCase):
def test_refuses_prod(self):
from django.core.management.base import CommandError
with patch.dict(os.environ, {"DJANGO_ENV": "prod"}):
with self.assertRaises(CommandError) as ctx:
call_command("seed_demo", stdout=StringIO())
self.assertIn("prod", str(ctx.exception).lower())
def test_seeds_shop_and_portal_rows(self):
out = StringIO()
call_command("seed_demo", stdout=out)
self.assertIn("Demo seed ready", out.getvalue())
from contacts.models import Contact
from leads.models import Lead
from shop.models import Order, Product
self.assertTrue(Product.objects.filter(sku="DEMO-NOTEBOOK").exists())
self.assertTrue(Product.objects.filter(sku="DEMO-PROTO", is_published=False).exists())
self.assertGreaterEqual(Contact.objects.filter(email__startswith="demo+").count(), 8)
self.assertTrue(Lead.objects.filter(message__startswith="[demo-seed]").exists())
self.assertTrue(Order.objects.filter(number="DEMO-ORD-001", status="paid").exists())
listing = Client().get(reverse("shop:list"))
self.assertContains(listing, "Notebook")
from django.contrib.auth import get_user_model
from shop.models import ProductReview
from shipping.models import Shipment
User = get_user_model()
jordan = User.objects.get(username="demo+jordan@example.com")
self.assertFalse(jordan.is_staff)
self.assertTrue(jordan.check_password("Demo-buyer-pass-123"))
self.assertEqual(
Order.objects.get(number="DEMO-ORD-001").user_id, jordan.pk
)
self.assertIsNone(Order.objects.get(number="DEMO-ORD-004").user_id)
self.assertTrue(
ProductReview.objects.filter(
user=jordan, product__sku="DEMO-NOTEBOOK", rating=5
).exists()
)
delivered = Shipment.objects.get(order__number="DEMO-ORD-002")
self.assertEqual(delivered.tracking_status, "delivered")
self.assertGreaterEqual(len(delivered.tracking_events), 2)
transit = Shipment.objects.get(order__number="DEMO-ORD-001")
self.assertEqual(transit.tracking_status, "in_transit")
client = Client()
self.assertTrue(
client.login(
username="demo+jordan@example.com", password="Demo-buyer-pass-123"
)
)
history = client.get(reverse("account:orders"))
self.assertContains(history, "DEMO-ORD-001")
self.assertNotContains(history, "DEMO-ORD-004")
detail = client.get(reverse("shop:detail", kwargs={"slug": "demo-notebook"}))
self.assertContains(detail, "Solid notebook")
def test_second_run_is_idempotent(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
count = Product.objects.filter(sku__startswith="DEMO-").count()
call_command("seed_demo", stdout=StringIO())
self.assertEqual(Product.objects.filter(sku__startswith="DEMO-").count(), count)
def test_reset_rebuilds_tagged_rows(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
Product.objects.filter(sku="DEMO-NOTEBOOK").delete()
call_command("seed_demo", reset=True, stdout=StringIO())
self.assertTrue(Product.objects.filter(sku="DEMO-NOTEBOOK").exists())
from django.contrib.auth import get_user_model
self.assertTrue(
get_user_model().objects.filter(username="demo+jordan@example.com").exists()
)
@patch.dict(os.environ, {"DJANGO_ENV": "beta"})
def test_allows_beta(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
self.assertTrue(Product.objects.filter(sku__startswith="DEMO-").exists())
def test_succeeds_when_shop_not_installed(self):
from django.apps import apps
from contacts.models import Contact
real = apps.is_installed
def fake(name):
if name in {"shop", "shipping"}:
return False
return real(name)
with patch("django.apps.apps.is_installed", side_effect=fake):
call_command("seed_demo", stdout=StringIO())
self.assertTrue(Contact.objects.filter(email__startswith="demo+").exists())
+1 -2
View File
@@ -111,8 +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")
def test_requires_login(self):
+2
View File
@@ -122,9 +122,11 @@ class InstalledOptionalAppsTests(TestCase):
self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
self.assertTrue(reverse("shop:list").startswith("/shop/"))
self.assertTrue(reverse("account:login").startswith("/account/"))
self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/"))
self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/"))
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
self.assertTrue(reverse("events:list").startswith("/events/"))
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/"))
self.assertTrue(reverse("shipping:easypost_webhook").startswith("/portal/shipping/webhooks/"))
+18
View File
@@ -62,6 +62,9 @@ Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail
| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) |
| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) |
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
| `PCM_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing |
| `PCM_POSTCARD_PRICE_STANDARD` | Fallback $/piece for Standard |
| `PCM_LIST_TYPE` | PCM purchased-list type (default `IRL`) |
| `POSTCARD_PROVIDER` | `pcm` (default) |
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
@@ -93,6 +96,21 @@ HMAC-SHA256 of body) matching **any** listed secret.
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
echo that (or `orderID`, matched to `Message.provider_message_id`).
### Carrier-route (neighborhood) mailings
Postcard compose can target **mailing-list contacts** or **USPS carrier routes**.
Route mode calls PCM `POST /list/count/zipcode` (or `/radius`) with
`breakdownType=ZipCRRT`, then recounts selected routes via
`POST /list/count/carrier-route` and sends
`POST /order/postcard/with-list-count`. Estimated cost uses design `pricing[]`
or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default
purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage.
Compose also joins USPS EDDM GIS stats (businesses; household size / median
income when USPS fills them) and links to
[the EDDM map](https://eddm.usps.com/eddm/select-routes.htm) for the official
route table.
### Campaign completion email
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
@@ -0,0 +1,39 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("directmail", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="campaign",
name="route_targeting",
field=models.JSONField(blank=True, default=dict),
),
migrations.AlterField(
model_name="campaign",
name="audience",
field=models.CharField(
blank=True,
choices=[
("postcard_opt_in", "Mailing list · postcard opt-in"),
("carrier_route", "USPS carrier routes"),
],
default="",
max_length=32,
),
),
migrations.AlterField(
model_name="message",
name="contact",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.CASCADE,
related_name="directmail_messages",
to="contacts.contact",
),
),
]
+10 -2
View File
@@ -27,6 +27,7 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
class Audience(models.TextChoices):
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
CARRIER_ROUTE = "carrier_route", "USPS carrier routes"
name = models.CharField(max_length=120)
channel = models.CharField(max_length=16, choices=Channel.choices)
@@ -58,6 +59,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
body_override = models.TextField(blank=True)
# Set when completion summary email is sent (campaign COMPLETED).
notify_sent_at = models.DateTimeField(null=True, blank=True)
# PCM purchased-list targeting (ZIP/CRRT codes, listCountID, quote).
route_targeting = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
@@ -83,7 +86,11 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
Campaign, on_delete=models.CASCADE, related_name="messages"
)
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="directmail_messages"
Contact,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="directmail_messages",
)
channel = models.CharField(max_length=16, choices=Channel.choices)
status = models.CharField(
@@ -100,7 +107,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.channel} → {self.contact} ({self.status})"
who = self.contact or "carrier-route mailing"
return f"{self.channel} → {who} ({self.status})"
class ProviderEvent(TimeStampedModel):
+247 -6
View File
@@ -338,6 +338,210 @@ def get_order(order_id: int | str) -> dict[str, Any]:
return data if isinstance(data, dict) else {}
def get_design(design_id: int | str) -> dict[str, Any]:
data = pcm_request("GET", f"/design/{design_id}")
return data if isinstance(data, dict) else {}
FIRST_CLASS_ONLY_SIZES = frozenset({"46", "46S"})
MAX_SELECTED_ROUTES = 2
DEFAULT_LIST_TYPE = "IRL"
def mail_class_for_size(size: str | None) -> str:
key = str(size or "").strip()
if key in FIRST_CLASS_ONLY_SIZES:
return "FirstClass"
return "Standard"
def _env_unit_price(mail_class: str) -> float | None:
attr = (
"PCM_POSTCARD_PRICE_FIRSTCLASS"
if mail_class == "FirstClass"
else "PCM_POSTCARD_PRICE_STANDARD"
)
raw = str(getattr(settings, attr, "") or "").strip()
if not raw:
return None
try:
value = float(raw)
except ValueError:
return None
return value if value >= 0 else None
def pick_unit_price(
pricing: list[dict] | None,
*,
mail_class: str,
quantity: int,
) -> tuple[float | None, str]:
"""Return (dollars per piece, source). Source is pcm|fallback|missing."""
rows: list[dict] = []
for item in pricing or []:
if not isinstance(item, dict):
continue
if str(item.get("mailClass") or item.get("mail_class") or "") != mail_class:
continue
try:
price = float(item.get("price"))
break_qty = int(item.get("breakQty") or item.get("break_qty") or 0)
except (TypeError, ValueError):
continue
rows.append({"price": price, "breakQty": break_qty})
qty = max(int(quantity or 0), 0)
if rows:
eligible = [row for row in rows if qty >= row["breakQty"]]
chosen = max(eligible, key=lambda row: row["breakQty"]) if eligible else min(
rows, key=lambda row: row["breakQty"]
)
return chosen["price"], "pcm"
fallback = _env_unit_price(mail_class)
if fallback is not None:
return fallback, "fallback"
return None, "missing"
def design_pricing(design_id: int | None) -> list[dict]:
"""Load PCM per-piece pricing for a design (gallery payload preferred)."""
if not design_id:
return []
try:
design = get_design(design_id)
except PcmApiError:
design = {}
pricing = design.get("pricing") if isinstance(design, dict) else None
if isinstance(pricing, list) and pricing:
return [row for row in pricing if isinstance(row, dict)]
try:
for item in list_designs(product_type="postcard"):
if not isinstance(item, dict):
continue
did = item.get("designID") or item.get("design_id") or item.get("id")
if str(did) != str(design_id):
continue
extra = item.get("pricing")
if isinstance(extra, list):
return [row for row in extra if isinstance(row, dict)]
except PcmApiError:
logger.exception("PCM list_designs failed while loading pricing")
return []
def extract_crrt_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten ZipCRRT (or first) breakdown into {code, text, total} rows."""
breakdowns = payload.get("breakdown") if isinstance(payload, dict) else None
if not isinstance(breakdowns, list):
return []
preferred = None
fallback = None
for block in breakdowns:
if not isinstance(block, dict):
continue
kind = str(block.get("breakdownType") or "").upper().replace("_", "")
data = block.get("data") if isinstance(block.get("data"), list) else []
rows = []
for item in data:
if not isinstance(item, dict):
continue
code = str(item.get("code") or "").strip()
if not code:
continue
try:
total = int(item.get("total") or 0)
except (TypeError, ValueError):
total = 0
rows.append(
{
"code": code,
"text": str(item.get("text") or code).strip(),
"total": total,
}
)
if not rows:
continue
if kind in {"ZIPCRRT", "CRRT", "CARRIERROUTE"}:
preferred = rows
break
if fallback is None:
fallback = rows
return preferred or fallback or []
def _list_type() -> str:
return (getattr(settings, "PCM_LIST_TYPE", None) or DEFAULT_LIST_TYPE).strip() or DEFAULT_LIST_TYPE
def list_count_zipcode(*, zip_codes: list[str], list_type: str | None = None) -> dict[str, Any]:
data = pcm_request(
"POST",
"/list/count/zipcode",
json_body={
"listType": list_type or _list_type(),
"breakdownType": "ZipCRRT",
"zipCodes": zip_codes,
"demographics": [],
},
)
return data if isinstance(data, dict) else {}
def list_count_radius(
*,
address: str,
city: str,
state: str,
zip_code: str,
miles: float,
list_type: str | None = None,
) -> dict[str, Any]:
data = pcm_request(
"POST",
"/list/count/radius",
json_body={
"listType": list_type or _list_type(),
"breakdownType": "ZipCRRT",
"radius": {
"radius": miles,
"address": address,
"city": city,
"state": state,
"zip": zip_code,
},
"demographics": [],
},
)
return data if isinstance(data, dict) else {}
def list_count_carrier_route(
*,
carrier_routes: list[str],
list_type: str | None = None,
) -> dict[str, Any]:
data = pcm_request(
"POST",
"/list/count/carrier-route",
json_body={
"listType": list_type or _list_type(),
"breakdownType": "ZipCRRT",
"carrierRoutes": carrier_routes,
"demographics": [],
},
)
return data if isinstance(data, dict) else {}
def _order_id_from_response(data: dict[str, Any]) -> str:
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
order_id = data["results"][0].get("orderID")
if order_id is None:
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
return str(order_id)
def place_postcard_order(
*,
design_id: int,
@@ -358,13 +562,32 @@ def place_postcard_order(
data = pcm_request("POST", "/order/postcard", json_body=payload)
if not isinstance(data, dict):
raise PcmApiError("Unexpected order response from PCM")
return _order_id_from_response(data)
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
order_id = data["results"][0].get("orderID")
if order_id is None:
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
return str(order_id)
def place_postcard_order_with_list_count(
*,
design_id: int,
list_count_id: int,
record_count: int,
ext_ref: str,
mail_class: str = "Standard",
) -> str:
"""Place a postcard order against a PCM purchased list count."""
payload = {
"designID": design_id,
"mailClass": mail_class,
"extRefNbr": ext_ref,
"listCountID": list_count_id,
"recordCount": record_count,
"returnAddress": return_address_from_settings(),
}
data = pcm_request(
"POST", "/order/postcard/with-list-count", json_body=payload
)
if not isinstance(data, dict):
raise PcmApiError("Unexpected list-count order response from PCM")
return _order_id_from_response(data)
def design_id_from_template(template) -> int | None:
@@ -396,6 +619,24 @@ class PcmProvider:
"(save a design from the postcard designer first)"
)
targeting = {}
if message.campaign_id and isinstance(
getattr(message.campaign, "route_targeting", None), dict
):
targeting = message.campaign.route_targeting
list_count_id = targeting.get("list_count_id")
record_count = targeting.get("record_count")
if list_count_id and record_count:
mail_class = str(targeting.get("mail_class") or "Standard")
order_id = place_postcard_order_with_list_count(
design_id=design_id,
list_count_id=int(list_count_id),
record_count=int(record_count),
ext_ref=str(message.pk),
mail_class=mail_class,
)
return PostcardResult(provider_id=order_id)
recipient = contact_to_pcm_recipient(
message.contact, ext_ref=str(message.pk)
)
@@ -0,0 +1,151 @@
"""USPS EDDM map link + public GIS stats for carrier-route compose."""
from __future__ import annotations
import logging
import time
from typing import Any
import requests
logger = logging.getLogger(__name__)
USPS_EDDM_MAP_URL = "https://eddm.usps.com/eddm/select-routes.htm"
USPS_EDDM_GIS_URL = (
"https://gis.usps.com/arcgis/rest/services/EDDM/selectZIP/GPServer/routes/execute"
)
_CACHE_TTL_SECONDS = 1800
_cache: dict[str, tuple[float, dict[str, dict[str, Any]]]] = {}
def zip5(raw: str | None) -> str:
digits = "".join(ch for ch in str(raw or "") if ch.isdigit())
return digits[:5] if len(digits) >= 5 else ""
def pcm_code_from_zip_crid(*, zip_code: str, crid_id: str) -> str:
return f"{zip_code}:{crid_id}"
def zips_from_route_codes(codes: list[str], *, hint: str = "") -> list[str]:
found: list[str] = []
z = zip5(hint)
if z:
found.append(z)
for code in codes:
z = zip5(str(code).split(":", 1)[0])
if z:
found.append(z)
return list(dict.fromkeys(found))
def _as_int(raw: Any) -> int | None:
if raw in (None, ""):
return None
try:
return int(float(raw))
except (TypeError, ValueError):
return None
def _as_float(raw: Any) -> float | None:
if raw in (None, ""):
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _stats_from_attrs(attrs: dict[str, Any]) -> dict[str, Any] | None:
zip_code = zip5(attrs.get("ZIP_CODE"))
crid = str(attrs.get("CRID_ID") or "").strip().upper()
if not zip_code or not crid:
zip_crid = str(attrs.get("ZIP_CRID") or "").strip().upper()
if len(zip_crid) >= 9:
zip_code = zip_crid[:5]
crid = zip_crid[5:]
if not zip_code or not crid:
return None
return {
"code": pcm_code_from_zip_crid(zip_code=zip_code, crid_id=crid),
"businesses": _as_int(attrs.get("BUS_CNT")),
"residential": _as_int(attrs.get("RES_CNT")),
"usps_total": _as_int(attrs.get("TOT_CNT")),
"household_size": _as_float(attrs.get("AVG_HH_SIZ")),
"median_income": _as_int(attrs.get("MED_INCOME")),
"city_state": str(attrs.get("CITY_STATE") or "").strip(),
}
def _fetch_one_zip(zip_code: str) -> dict[str, dict[str, Any]]:
response = requests.get(
USPS_EDDM_GIS_URL,
params={"f": "json", "ZIP": zip_code, "Rte_Box": "R", "UserName": "EDDM"},
timeout=25,
headers={"Accept": "application/json"},
)
response.raise_for_status()
payload = response.json()
results = payload.get("results") if isinstance(payload, dict) else None
if not isinstance(results, list) or not results:
return {}
value = results[0].get("value") if isinstance(results[0], dict) else None
features = value.get("features") if isinstance(value, dict) else None
if not isinstance(features, list):
return {}
out: dict[str, dict[str, Any]] = {}
for feature in features:
if not isinstance(feature, dict):
continue
attrs = feature.get("attributes")
if not isinstance(attrs, dict):
continue
stats = _stats_from_attrs(attrs)
if not stats:
continue
out[stats["code"]] = stats
return out
def fetch_eddm_route_stats(zip_codes: list[str]) -> dict[str, dict[str, Any]]:
"""ZIP → PCM-style ``60540:C001`` stats from USPS EDDM GIS (fail open)."""
out: dict[str, dict[str, Any]] = {}
now = time.monotonic()
for zip_code in zip_codes:
z = zip5(zip_code)
if not z:
continue
cached = _cache.get(z)
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
out.update(cached[1])
continue
try:
part = _fetch_one_zip(z)
except Exception: # noqa: BLE001 — compose still works without GIS
logger.exception("USPS EDDM GIS lookup failed for ZIP %s", z)
part = {}
_cache[z] = (now, part)
out.update(part)
return out
def enrich_crrt_rows(rows: list[dict[str, Any]], *, zip_hint: str = "") -> list[dict[str, Any]]:
"""Attach USPS businesses / size / income onto PCM ZipCRRT rows."""
codes = [str(row.get("code") or "") for row in rows]
stats = fetch_eddm_route_stats(zips_from_route_codes(codes, hint=zip_hint))
enriched = []
for row in rows:
extra = stats.get(str(row.get("code") or ""), {})
merged = {
**row,
"businesses": extra.get("businesses"),
"residential": extra.get("residential"),
"household_size": extra.get("household_size"),
"median_income": extra.get("median_income"),
}
if extra.get("city_state") and not (row.get("text") or "").strip():
merged["text"] = extra["city_state"]
enriched.append(merged)
return enriched
+226
View File
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
AUDIENCE_CHANNEL = {
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD,
}
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
@@ -84,6 +85,218 @@ def channel_for_audience(audience: str) -> str:
raise ValueError(f"Unknown audience: {audience}") from exc
def parse_route_targeting(raw: str | dict | None) -> dict:
"""Parse compose JSON for a carrier-route campaign."""
from directmail.providers.postcard.pcm import MAX_SELECTED_ROUTES
data: dict = {}
if isinstance(raw, dict):
data = raw
elif isinstance(raw, str) and raw.strip():
import json
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("Carrier route selection is invalid JSON.") from exc
if not isinstance(parsed, dict):
raise ValueError("Carrier route selection must be an object.")
data = parsed
codes = [
str(code).strip()
for code in (data.get("selected_codes") or data.get("routes") or [])
if str(code).strip()
]
# Allow routes as list of objects from the quote endpoint.
if not codes and isinstance(data.get("routes"), list):
codes = [
str(row.get("code") or "").strip()
for row in data["routes"]
if isinstance(row, dict)
]
codes = [c for c in codes if c]
codes = list(dict.fromkeys(codes))
if not codes:
raise ValueError("Select one or two USPS carrier routes.")
if len(codes) > MAX_SELECTED_ROUTES:
raise ValueError("Pick at most two carrier routes.")
try:
list_count_id = int(data.get("list_count_id") or 0)
record_count = int(data.get("record_count") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("Route quote is missing list count.") from exc
if list_count_id <= 0 or record_count <= 0:
raise ValueError("Find routes and select them before saving (need a PCM quote).")
estimated = data.get("estimated") if isinstance(data.get("estimated"), dict) else {}
route_rows = data.get("route_rows") if isinstance(data.get("route_rows"), list) else []
return {
"selected_codes": codes,
"list_count_id": list_count_id,
"record_count": record_count,
"mail_class": str(data.get("mail_class") or "Standard"),
"list_type": str(data.get("list_type") or "IRL"),
"zip": str(data.get("zip") or ""),
"estimated": estimated,
"route_rows": route_rows,
}
def money_total(per_piece: float | None, quantity: int) -> float | None:
if per_piece is None:
return None
return round(float(per_piece) * max(int(quantity or 0), 0), 2)
def postcard_quote(*, action: str, payload: dict) -> dict:
"""Search routes or price a mailing-list / selected-route postcard."""
from directmail.providers.postcard.pcm import (
MAX_SELECTED_ROUTES,
design_pricing,
extract_crrt_rows,
list_count_carrier_route,
list_count_radius,
list_count_zipcode,
mail_class_for_size,
pick_unit_price,
)
from directmail.providers.postcard.usps_eddm import (
USPS_EDDM_MAP_URL,
enrich_crrt_rows,
)
design_id = payload.get("design_id")
size = str(payload.get("size") or "").strip()
try:
design_id_int = int(design_id) if design_id not in (None, "") else None
except (TypeError, ValueError):
design_id_int = None
requested_class = str(payload.get("mail_class") or "").strip()
mail_class = requested_class or mail_class_for_size(size)
if mail_class not in {"FirstClass", "Standard"}:
mail_class = mail_class_for_size(size)
pricing = design_pricing(design_id_int)
def _price(qty: int) -> tuple[float | None, str]:
return pick_unit_price(pricing, mail_class=mail_class, quantity=qty)
action = (action or "mailing_list").strip()
if action == "mailing_list":
count = opted_in_contacts(Channel.POSTCARD).count()
per_piece, source = _price(count)
return {
"action": action,
"mail_class": mail_class,
"count": count,
"per_piece": per_piece,
"total": money_total(per_piece, count),
"price_source": source,
}
if action == "search":
zip_code = str(payload.get("zip") or "").strip()
address = str(payload.get("address") or "").strip()
city = str(payload.get("city") or "").strip()
state = str(payload.get("state") or "").strip()
try:
miles = float(payload.get("radius") or 0)
except (TypeError, ValueError):
miles = 0
if address and city and state and zip_code and miles > 0:
data = list_count_radius(
address=address,
city=city,
state=state,
zip_code=zip_code,
miles=miles,
)
elif zip_code:
data = list_count_zipcode(zip_codes=[zip_code])
else:
raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
rows = enrich_crrt_rows(extract_crrt_rows(data), zip_hint=zip_code)
priced = []
for row in rows:
per_piece, source = _price(row["total"])
priced.append(
{
**row,
"per_piece": per_piece,
"cost": money_total(per_piece, row["total"]),
"price_source": source,
}
)
return {
"action": action,
"mail_class": mail_class,
"zip": zip_code,
"routes": priced,
"eddm_map_url": USPS_EDDM_MAP_URL,
"discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
}
if action == "select":
codes = [
str(c).strip()
for c in (payload.get("routes") or [])
if str(c).strip()
]
codes = list(dict.fromkeys(codes))
if not codes:
raise ValueError("Select at least one carrier route.")
if len(codes) > MAX_SELECTED_ROUTES:
raise ValueError("Pick at most two carrier routes.")
data = list_count_carrier_route(carrier_routes=codes)
rows = extract_crrt_rows(data)
if not rows:
try:
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
except (TypeError, ValueError):
record_count = 0
rows = [{"code": code, "text": code, "total": 0} for code in codes]
if record_count and len(rows) == 1:
rows[0]["total"] = record_count
rows = enrich_crrt_rows(rows, zip_hint=codes[0] if codes else "")
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
if not record_count:
record_count = sum(int(r.get("total") or 0) for r in rows)
per_piece, source = _price(record_count)
priced = []
for row in rows:
total = int(row.get("total") or 0)
priced.append(
{
**row,
"per_piece": per_piece,
"cost": money_total(per_piece, total),
"price_source": source,
}
)
list_count_id = data.get("listCountID") or data.get("list_count_id")
return {
"action": action,
"mail_class": mail_class,
"selected_codes": codes,
"routes": priced,
"route_rows": priced,
"list_count_id": list_count_id,
"record_count": record_count,
"per_piece": per_piece,
"total": money_total(per_piece, record_count),
"price_source": source,
"eddm_map_url": USPS_EDDM_MAP_URL,
"estimated": {
"per_piece": per_piece,
"pieces": record_count,
"total": money_total(per_piece, record_count),
"currency": "USD",
"source": source,
},
}
raise ValueError(f"Unknown quote action: {action}")
def create_campaign_draft(
*,
name: str,
@@ -93,9 +306,11 @@ def create_campaign_draft(
scheduled_for=None,
created_by: AbstractBaseUser | None = None,
template: MessageTemplate | None = None,
route_targeting: dict | None = None,
) -> Campaign:
"""Persist a draft campaign and per-recipient Message stubs."""
channel = channel_for_audience(audience)
targeting = route_targeting if isinstance(route_targeting, dict) else {}
campaign = Campaign.objects.create(
name=name,
channel=channel,
@@ -106,7 +321,18 @@ def create_campaign_draft(
body_override=body,
created_by=created_by,
template=template,
route_targeting=targeting,
)
if audience == Campaign.Audience.CARRIER_ROUTE:
Message.objects.create(
campaign=campaign,
contact=None,
channel=channel,
status=Message.Status.DRAFT,
scheduled_for=scheduled_for,
body_snapshot=body,
)
return campaign
contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create(
[
+3 -1
View File
@@ -18,7 +18,9 @@ def send_campaign_message(message_id: str) -> None:
campaign = message.campaign
if not contact_may_receive(message.contact, message.channel):
if message.contact_id is None:
pass
elif not contact_may_receive(message.contact, message.channel):
message.status = Message.Status.SUPPRESSED
message.error = "Contact opted out or suppressed"
message.save(update_fields=["status", "error", "updated_at"])
@@ -0,0 +1,59 @@
<div class="field" id="postcard-list-cost" hidden></div>
<div id="postcard-route-picker" hidden>
<p class="hint-block">
PCM mails a purchased occupant list on the USPS carrier routes you pick
(First Class / Standard — not official EDDM postage). Cost is per piece
times households.
</p>
<div class="form-grid cols-2">
<div class="field">
<label for="route-zip">ZIP code</label>
<input id="route-zip" type="text" inputmode="numeric" maxlength="10"
placeholder="60540" autocomplete="postal-code">
</div>
<div class="field">
<label for="route-radius">Radius miles <span class="muted">(optional)</span></label>
<input id="route-radius" type="number" min="0" step="0.5" placeholder="1">
</div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="route-address">Street <span class="muted">(optional, with radius)</span></label>
<input id="route-address" type="text" placeholder="1245 Butterfield Rd">
</div>
<div class="field">
<label for="route-city">City</label>
<input id="route-city" type="text" placeholder="Wheaton">
</div>
</div>
<div class="field" style="max-width:120px">
<label for="route-state">State</label>
<input id="route-state" type="text" maxlength="2" placeholder="IL">
</div>
<button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button>
<p class="hint" style="margin-top:8px">
<a id="eddm-map-link" href="https://eddm.usps.com/eddm/select-routes.htm"
target="_blank" rel="noopener noreferrer">Open USPS EDDM map</a>
— official route map. Use Show Table there for size / income filters.
PCM still mails a purchased occupant list, not EDDM postage.
</p>
<p class="hint" id="route-status" style="margin-top:8px"></p>
<div id="route-table-wrap" hidden style="margin-top:12px;overflow-x:auto">
<table class="table">
<thead>
<tr>
<th>Route</th>
<th>Area</th>
<th>Households</th>
<th>Size</th>
<th>Income</th>
<th>Businesses</th>
<th>Est. cost</th>
</tr>
</thead>
<tbody id="route-table-body"></tbody>
</table>
</div>
<p class="hint-block" id="route-selected-cost" hidden style="margin-top:12px"></p>
</div>
<input type="hidden" name="route_targeting" id="id_route_targeting" value="{{ form_data.route_targeting }}">
@@ -36,11 +36,13 @@
{% endif %}
{% if can_send %}
<form method="post" action="{% url 'directmail:campaign_send' campaign.pk %}"
onsubmit="return confirm('Send this campaign to all remaining recipients now?');">
onsubmit="return confirm('{% if campaign.audience == "carrier_route" %}Place this PCM carrier-route postcard order now?{% else %}Send this campaign to all remaining recipients now?{% endif %}');">
{% csrf_token %}
<button class="btn btn-primary" type="submit">Send now to recipients</button>
<button class="btn btn-primary" type="submit">{% if campaign.audience == "carrier_route" %}Place route order{% else %}Send now to recipients{% endif %}</button>
<p class="hint-block" style="margin-top:8px">
{% if campaign.channel == "postcard" %}
{% if campaign.audience == "carrier_route" %}
Places one PCM list-count order for the selected USPS routes (not per contact).
{% elif campaign.channel == "postcard" %}
Enqueues draft / scheduled / failed messages via PCM Integrations.
{% else %}
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
@@ -52,6 +54,35 @@
</div>
{% endif %}
{% if campaign.audience == "carrier_route" %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Carrier routes</h2></div>
<div class="panel-b">
{% with t=campaign.route_targeting %}
<p style="margin-top:0">
{{ t.record_count|default:0 }} household{{ t.record_count|default:0|pluralize }}
· {{ t.mail_class|default:"Standard" }}
{% if t.estimated.total != None %}
· est. ${{ t.estimated.total }}
{% if t.estimated.per_piece %} (${{ t.estimated.per_piece }} / piece){% endif %}
{% endif %}
</p>
<p class="muted" style="margin:0">
Routes:
{% for code in t.selected_codes %}
<code>{{ code }}</code>{% if not forloop.last %}, {% endif %}
{% empty %}
—
{% endfor %}
</p>
{% if t.estimated.source == "fallback" %}
<p class="hint-block">Cost uses env fallback rates (PCM did not return design pricing).</p>
{% endif %}
{% endwith %}
</div>
</div>
{% endif %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b">
@@ -129,6 +129,7 @@
<div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div>
</div>
</div>
{% include "directmail/_postcard_route_picker.html" %}
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
{% include "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %}
<button class="btn btn-primary" type="submit">Save draft</button>
@@ -184,6 +185,7 @@
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script src="{% static 'js/campaign-utm.js' %}"></script>
<script src="{% static 'js/postcard-routes.js' %}"></script>
<script>
(function () {
var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -219,7 +221,7 @@
var subject = (document.getElementById('id_subject') || {}).value || '';
var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in';
var isPostcard = audience === 'postcard_opt_in';
var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
var body = '';
if (isEmail && quill) {
body = quill.root.innerHTML;
@@ -257,7 +259,7 @@
window.syncComposeChannel = function () {
var audience = (document.getElementById('id_audience') || {}).value || '';
var isPostcard = audience === 'postcard_opt_in';
var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
var isSms = audience === 'sms_opt_in';
var isEmail = audience === 'email_opt_in';
var tmplField = document.getElementById('postcard-template-field');
@@ -290,6 +292,9 @@
});
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
if (window.MonicaPostcardRoutes && window.MonicaPostcardRoutes.syncVisibility) {
window.MonicaPostcardRoutes.syncVisibility();
}
syncCampaignPreview();
};
@@ -353,7 +358,7 @@
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill();
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
else if (audience === 'postcard_opt_in') {
else if (audience === 'postcard_opt_in' || audience === 'carrier_route') {
var pc = document.getElementById('id_body_pc');
bodyField.value = pc ? pc.value : '';
}
@@ -377,6 +382,10 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill();
window.initPostcardRoutes({
quoteUrl: "{{ postcard_quote_url|escapejs }}",
csrfToken: csrfToken
});
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';
+255 -4
View File
@@ -239,8 +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")
self.contact = Contact.objects.create(
@@ -283,8 +282,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")
self.contact = Contact.objects.create(
@@ -306,6 +304,259 @@ class PostcardUtmPanelTests(TestCase):
self.assertContains(response, "qrcode.min.js")
self.assertContains(response, "data-utm-qr")
self.assertContains(response, reverse("campaign_short_link"))
self.assertContains(response, "https://eddm.usps.com/eddm/select-routes.htm")
self.assertContains(response, "Open USPS EDDM map")
class CarrierRoutePostcardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="routes", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="routes", password="test-pass-123")
self.contact = Contact.objects.create(
email="mail@example.com",
postal_address=Contact.make_postal_address(line1="9 Main"),
)
self.eddm_patcher = patch(
"directmail.providers.postcard.usps_eddm.fetch_eddm_route_stats",
return_value={},
)
self.eddm_stats = self.eddm_patcher.start()
self.addCleanup(self.eddm_patcher.stop)
def test_pick_unit_price_uses_break_qty_and_fallback(self):
from directmail.providers.postcard import pcm as pcm_mod
price, source = pcm_mod.pick_unit_price(
[
{"mailClass": "Standard", "price": 0.8, "breakQty": 1},
{"mailClass": "Standard", "price": 0.5, "breakQty": 200},
],
mail_class="Standard",
quantity=250,
)
self.assertEqual(price, 0.5)
self.assertEqual(source, "pcm")
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.42"):
price, source = pcm_mod.pick_unit_price(
[], mail_class="Standard", quantity=10
)
self.assertEqual(price, 0.42)
self.assertEqual(source, "fallback")
def test_quote_search_returns_route_costs(self):
payload = {
"listCountID": 11,
"recordCount": 300,
"breakdown": [
{
"breakdownType": "ZipCRRT",
"data": [
{"code": "60540:C001", "text": "NAPERVILLE", "total": 120},
{"code": "60540:C002", "text": "NAPERVILLE", "total": 180},
],
}
],
}
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
with patch(
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
):
with patch(
"directmail.providers.postcard.pcm.list_count_zipcode",
return_value=payload,
):
response = self.client.post(
reverse("directmail:postcard_quote"),
data='{"action":"search","zip":"60540","size":"68"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["routes"]), 2)
self.assertEqual(data["routes"][0]["cost"], 60.0)
self.assertEqual(data["mail_class"], "Standard")
self.assertEqual(
data["eddm_map_url"], "https://eddm.usps.com/eddm/select-routes.htm"
)
def test_quote_search_includes_eddm_demographics(self):
self.eddm_stats.return_value = {
"60540:C001": {
"businesses": 22,
"household_size": 2.6,
"median_income": 120000,
"residential": 108,
}
}
payload = {
"listCountID": 11,
"recordCount": 120,
"breakdown": [
{
"breakdownType": "ZipCRRT",
"data": [
{"code": "60540:C001", "text": "NAPERVILLE", "total": 120},
],
}
],
}
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
with patch(
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
):
with patch(
"directmail.providers.postcard.pcm.list_count_zipcode",
return_value=payload,
):
response = self.client.post(
reverse("directmail:postcard_quote"),
data='{"action":"search","zip":"60540","size":"68"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
row = response.json()["routes"][0]
self.assertEqual(row["businesses"], 22)
self.assertEqual(row["household_size"], 2.6)
self.assertEqual(row["median_income"], 120000)
def test_quote_select_and_save_draft(self):
import json
select_payload = {
"listCountID": 99,
"recordCount": 300,
"breakdown": [
{
"breakdownType": "ZipCRRT",
"data": [
{"code": "60540:C001", "text": "A", "total": 120},
{"code": "60540:C002", "text": "B", "total": 180},
],
}
],
}
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
with patch(
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
):
with patch(
"directmail.providers.postcard.pcm.list_count_carrier_route",
return_value=select_payload,
):
quoted = self.client.post(
reverse("directmail:postcard_quote"),
data=(
'{"action":"select","size":"68",'
'"routes":["60540:C001","60540:C002"]}'
),
content_type="application/json",
)
self.assertEqual(quoted.status_code, 200)
quote = quoted.json()
self.assertEqual(quote["list_count_id"], 99)
self.assertEqual(quote["total"], 150.0)
targeting = {
"selected_codes": quote["selected_codes"],
"list_count_id": quote["list_count_id"],
"record_count": quote["record_count"],
"mail_class": quote["mail_class"],
"estimated": quote["estimated"],
"route_rows": quote["routes"],
}
with patch(
"directmail.views._fetch_pcm_designs",
return_value=(
[{"design_id": "42", "name": "Spring card", "size": "68"}],
"",
),
):
response = self.client.post(
reverse("directmail:campaign_list"),
{
"name": "Farm drop",
"audience": Campaign.Audience.CARRIER_ROUTE,
"template_id": "d:42",
"route_targeting": json.dumps(targeting),
},
)
self.assertEqual(response.status_code, 302)
campaign = Campaign.objects.get(name="Farm drop")
self.assertEqual(campaign.audience, Campaign.Audience.CARRIER_ROUTE)
self.assertEqual(campaign.route_targeting["list_count_id"], 99)
self.assertEqual(campaign.messages.count(), 1)
self.assertIsNone(campaign.messages.get().contact_id)
def test_send_uses_list_count_endpoint(self):
from directmail.providers.postcard import pcm as pcm_mod
from directmail.services import enqueue_campaign_send
template = MessageTemplate.objects.create(
name="Route design",
channel=Channel.POSTCARD,
body="Postcard",
postcard_front={"design_id": 42, "size": "68", "provider": "pcm"},
)
campaign = create_campaign_draft(
name="Routes",
audience=Campaign.Audience.CARRIER_ROUTE,
body="note",
created_by=self.user,
template=template,
route_targeting={
"selected_codes": ["60540:C001"],
"list_count_id": 77,
"record_count": 50,
"mail_class": "Standard",
},
)
pcm_mod.clear_token_cache()
with self.settings(
PCM_API_KEY="key",
PCM_API_SECRET="secret",
PCM_RETURN_ADDRESS=(
'{"firstName":"Mo","lastName":"D","address":"1 Main",'
'"city":"Naperville","state":"IL","zipCode":"60540"}'
),
):
with patch("directmail.providers.postcard.pcm.login", return_value="tok"):
with patch(
"directmail.providers.postcard.pcm.pcm_request",
return_value={"orderID": 555},
) as req:
enqueue_campaign_send(campaign)
path = req.call_args.args[1]
self.assertEqual(path, "/order/postcard/with-list-count")
body = req.call_args.kwargs["json_body"]
self.assertEqual(body["listCountID"], 77)
self.assertEqual(body["recordCount"], 50)
campaign.refresh_from_db()
self.assertEqual(campaign.messages.get().provider_message_id, "555")
def test_mailing_list_postcard_still_creates_contacts(self):
with patch(
"directmail.views._fetch_pcm_designs",
return_value=(
[{"design_id": "42", "name": "Spring card", "size": "46"}],
"",
),
):
response = self.client.post(
reverse("directmail:campaign_list"),
{
"name": "List mailer",
"audience": Campaign.Audience.POSTCARD_OPT_IN,
"template_id": "d:42",
},
)
self.assertEqual(response.status_code, 302)
campaign = Campaign.objects.get(name="List mailer")
self.assertEqual(campaign.messages.count(), 1)
self.assertEqual(campaign.messages.get().contact_id, self.contact.pk)
def test_postcard_does_not_inject_into_body(self):
campaign = create_campaign_draft(
+5
View File
@@ -6,6 +6,11 @@ app_name = "directmail"
urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"),
path(
"campaigns/postcard-quote/",
views.postcard_quote,
name="postcard_quote",
),
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
path(
"campaigns/<uuid:pk>/status.json",
+85 -5
View File
@@ -1,6 +1,7 @@
import hashlib
import hmac
import io
import json
import logging
from django.conf import settings
@@ -25,6 +26,7 @@ from directmail.providers.postcard.pcm import (
design_id_from_template,
get_design_embed_url,
list_designs,
mail_class_for_size,
)
from contacts.consent import opted_in_contacts
from core.campaign_utm import utm_panel_context
@@ -33,6 +35,8 @@ from directmail.services import (
create_campaign_draft,
enqueue_campaign_send,
message_is_removable,
parse_route_targeting,
postcard_quote as build_postcard_quote,
)
from directmail.webhooks import (
PROVIDER_EMAIL,
@@ -68,6 +72,12 @@ def _audience_choices() -> list[tuple[str, str]]:
choices.append(
(value, f"Mailing list · {label} opt-in ({count} {noun})")
)
choices.append(
(
Campaign.Audience.CARRIER_ROUTE,
"USPS carrier routes (find by ZIP)",
)
)
return choices
@@ -224,10 +234,14 @@ def _message_destination(message) -> str:
contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL:
return (contact.email or "").strip()
return (contact.email or "").strip() if contact else ""
if channel == Channel.SMS:
return (contact.phone or "").strip()
return (contact.phone or "").strip() if contact else ""
if channel == Channel.POSTCARD:
if not contact:
targeting = getattr(message.campaign, "route_targeting", None) or {}
codes = targeting.get("selected_codes") or []
return ", ".join(str(c) for c in codes) or "Carrier routes"
return _format_postal_address(contact.postal_address)
return ""
@@ -423,6 +437,7 @@ def campaign_list(request):
"audience": Campaign.Audience.POSTCARD_OPT_IN,
"scheduled_for": "",
"template_id": "",
"route_targeting": "",
}
if request.method == "POST":
@@ -432,6 +447,7 @@ def campaign_list(request):
audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip()
route_raw = request.POST.get("route_targeting") or ""
form_data.update(
{
@@ -441,10 +457,12 @@ def campaign_list(request):
"audience": audience,
"scheduled_for": scheduled_raw,
"template_id": template_id,
"route_targeting": route_raw,
}
)
template = None
targeting: dict = {}
if template_id:
template = _resolve_postcard_template(template_id)
@@ -459,6 +477,18 @@ def campaign_list(request):
)
if not body:
body = "Postcard mailing"
elif audience == Campaign.Audience.CARRIER_ROUTE:
if not template or template.channel != Channel.POSTCARD:
form_errors.append(
"Choose a postcard design (create one under Postcard design)."
)
if not body:
body = "Carrier route postcard mailing"
try:
targeting = parse_route_targeting(route_raw)
except ValueError as exc:
form_errors.append(str(exc))
targeting = {}
scheduled_for = None
try:
@@ -475,7 +505,18 @@ def campaign_list(request):
scheduled_for=scheduled_for,
created_by=request.user,
template=template,
route_targeting=targeting,
)
if audience == Campaign.Audience.CARRIER_ROUTE:
pieces = targeting.get("record_count") or 0
total = (targeting.get("estimated") or {}).get("total")
cost_bit = f", est. ${total:.2f}" if isinstance(total, (int, float)) else ""
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({pieces} household{'' if pieces == 1 else 's'}{cost_bit}).",
)
else:
recipient_count = campaign.messages.count()
messages.success(
request,
@@ -497,10 +538,46 @@ def campaign_list(request):
"form_errors": form_errors,
"image_upload_url": reverse("directmail:campaign_image_upload"),
**utm_panel_context(live=True, medium=Channel.POSTCARD),
"postcard_quote_url": reverse("directmail:postcard_quote"),
"postcard_list_count": opted_in_contacts(Channel.POSTCARD).count(),
},
)
@login_required
@require_POST
def postcard_quote(request):
"""Search PCM carrier routes or price a postcard audience."""
try:
payload = json.loads(request.body.decode() or "{}")
except (json.JSONDecodeError, UnicodeDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
template_id = str(payload.get("template_id") or "").strip()
if template_id:
template = _resolve_postcard_template(template_id)
if template:
did = design_id_from_template(template)
if did and not payload.get("design_id"):
payload["design_id"] = did
front = template.postcard_front or {}
if not payload.get("size"):
payload["size"] = front.get("size") or ""
if not payload.get("mail_class") and front.get("mail_class"):
payload["mail_class"] = front.get("mail_class")
elif not payload.get("mail_class"):
payload["mail_class"] = mail_class_for_size(payload.get("size"))
action = str(payload.get("action") or "mailing_list").strip()
try:
result = build_postcard_quote(action=action, payload=payload)
except ValueError as exc:
return JsonResponse({"detail": str(exc)}, status=400)
except PcmApiError as exc:
return JsonResponse({"detail": str(exc)}, status=502)
return JsonResponse(result)
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
@@ -536,6 +613,7 @@ def campaign_detail(request, pk):
"suppressed",
}
).exists(),
"route_targeting": campaign.route_targeting or {},
},
)
@@ -566,7 +644,9 @@ def campaign_status_json(request, pk):
"messages": [
{
"id": str(m.pk),
"contact": str(m.contact),
"contact": (
str(m.contact) if m.contact_id else "Carrier route mailing"
),
"destination": getattr(m, "destination", "") or "",
"status": m.status,
"status_display": m.get_status_display(),
@@ -579,7 +659,7 @@ def campaign_status_json(request, pk):
"events": [
{
"event_type": e.event_type,
"contact": str(e.message.contact) if e.message_id else "—",
"contact": str(e.message.contact) if e.message_id and e.message.contact_id else "—",
"created_at": e.created_at.isoformat(),
}
for e in ctx["recent_events"]
@@ -601,7 +681,7 @@ def campaign_message_remove(request, pk, message_id):
)
return redirect("directmail:campaign_detail", pk=campaign.pk)
label = str(message.contact)
label = str(message.contact) if message.contact_id else "carrier-route mailing"
message.delete()
messages.success(request, f"Removed {label} from this campaign.")
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
+7 -13
View File
@@ -123,8 +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")
self.contact = Contact.objects.create(
@@ -190,8 +189,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")
self.contact = Contact.objects.create(
@@ -260,8 +258,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")
self.contact = Contact.objects.create(
@@ -467,7 +464,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,8 +943,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,8 +1026,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")
self.contacts = []
@@ -1169,8 +1164,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")
self.contact = Contact.objects.create(
+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)
-79
View File
@@ -10,88 +10,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"}),
+4
View File
@@ -17,6 +17,10 @@ class UnderConstructionMiddleware:
"/admin/",
"/accounts/login",
"/accounts/logout",
"/account/login",
"/account/logout",
"/account/register",
"/account/password-reset",
"/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:#00626c;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:"Monica Dhillon" }}
New contact form inquiry — {{ brand_name|default:"Your Company" }}
Name: {{ name }}
Email: {{ email }}
Phone: {{ phone }}
Address:
{{ address }}
Message:
{{ message }}
@@ -12,6 +8,6 @@ Message:
{% if portal_url %}View in portal: {{ portal_url }}
{% endif %}
—
{{ brand_name|default:"Monica Dhillon" }}
{{ site_url|default:"https://mkdrealtor.com" }}
{{ brand_name|default:"Your Company" }}
{{ site_url|default:"" }}
{% if brand_tagline %}{{ brand_tagline }}{% endif %}
+2 -69
View File
@@ -87,74 +87,13 @@
<form class="rd-form contact-message-form" method="post" action="{% url 'public:contact' %}">
{% csrf_token %}
<div class="row row-10">
<div class="col-md-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-md-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-md-6">
<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-md-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" style="margin:8px 0 4px">Mailing address <span style="font-weight:400;color:#6b7280">(optional — for postcards)</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-10">
<div class="col-md-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-md-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-md-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-md-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>
@@ -173,7 +112,7 @@
<div class="col-12">{{ form.captcha }}{{ form.captcha.errors }}</div>
{% endif %}
</div>
<p style="font-size:13px;color:#6b7280;margin:12px 0 20px;">By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
<p style="font-size:13px;color:#6b7280;margin:12px 0 20px;">By submitting, you agree we may contact you about your inquiry by email. You can change preferences or unsubscribe anytime from links in messages.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
<button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button>
</form>
</div>
@@ -181,9 +120,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>
@@ -193,6 +129,3 @@
</script>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
+3 -26
View File
@@ -49,6 +49,7 @@ def robots_txt(request):
"Allow: /",
"Disallow: /portal/",
"Disallow: /accounts/",
"Disallow: /account/",
"Disallow: /admin/",
"Disallow: /api/",
f"Sitemap: {site}/sitemap.xml",
@@ -99,48 +100,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 = (
+8
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,9 @@ 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),
),
]
+22
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,6 +30,16 @@ 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)
currency = models.CharField(max_length=8, default="usd")
+173
View File
@@ -7,8 +7,11 @@ 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 +138,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 +189,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 / 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 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 %}
+56 -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,65 @@ 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")
+9 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from shop.models import Order, OrderItem, Product
from shop.models import Order, OrderItem, Product, ProductReview
@admin.register(Product)
@@ -11,6 +11,13 @@ class ProductAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
@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
@@ -21,4 +28,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", "0001_initial"),
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
@@ -68,6 +70,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(
@@ -116,3 +125,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
@@ -8,10 +8,10 @@ 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 shop.models import Order, OrderItem, Product
from shop.models import Order, OrderItem, Product, ProductReview
logger = logging.getLogger(__name__)
@@ -156,6 +156,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:
@@ -169,6 +170,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,
@@ -191,6 +193,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 = [
@@ -208,14 +246,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
@@ -249,7 +300,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():
@@ -261,6 +314,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:
@@ -303,3 +357,28 @@ def send_order_email(order: Order) -> bool:
mail.attach_alternative(html, "text/html")
mail.send(fail_silently=False)
return True
_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,48 @@
{% 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>
{% for item in order.items.all %}
<li>{{ item.quantity }}× {{ item.name }} — ${{ item.line_total }}</li>
{% endfor %}
</ul>
{% if "shipping" in enabled_features %}
<h4>Shipments</h4>
{% for shipment in order.shipments.all %}
<div>
<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 %}
{% endif %}
<p><a href="{% url 'account:orders' %}">← All orders</a></p>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "accounts/account_base.html" %}
{% block title %}Orders · {{ SITE_NAME }}{% endblock %}
{% block account_content %}
<h3>Order history</h3>
{% if orders %}
<ul>
{% for order in orders %}
<li>
<a href="{% url 'account:order_detail' order.pk %}">{{ order.number }}</a>
· {{ order.get_status_display }}
· {{ order.amount }}
</li>
{% endfor %}
</ul>
{% else %}
<p>No orders yet. <a href="{% url 'shop:list' %}">Browse the shop</a>.</p>
{% endif %}
{% endblock %}
+21 -7
View File
@@ -1,21 +1,35 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1>Checkout</h1>
<p>Total: {{ total }}</p>
{% 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 method="post">
{% csrf_token %}
<p><label>Email <input name="email" type="email" required></label></p>
<p><label>Name <input name="customer_name"></label></p>
<p><label>Address <input name="address_line1"></label></p>
<p><label>Address 2 <input name="address_line2"></label></p>
<p><label>City <input name="address_city"></label></p>
<p><label>State <input name="address_state"></label></p>
<p><label>ZIP <input name="address_zip"></label></p>
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<p><label>Email <input class="form-input" name="email" type="email" required value="{{ checkout_initial.email }}" {% if user.is_authenticated %}readonly{% endif %}></label></p>
<p><label>Name <input class="form-input" name="customer_name" value="{{ checkout_initial.customer_name }}"></label></p>
<p><label>Address <input class="form-input" name="address_line1" data-ac="line1" autocomplete="off" value="{{ checkout_initial.address.line1|default:'' }}"></label></p>
<p><label>Address 2 <input class="form-input" name="address_line2" data-ac="line2" autocomplete="address-line2" value="{{ checkout_initial.address.line2|default:'' }}"></label></p>
<p><label>City <input class="form-input" name="address_city" data-ac="city" autocomplete="address-level2" value="{{ checkout_initial.address.city|default:'' }}"></label></p>
<p><label>State <input class="form-input" name="address_state" data-ac="state" autocomplete="address-level1" value="{{ checkout_initial.address.state|default:'' }}"></label></p>
<p><label>ZIP <input class="form-input" name="address_zip" data-ac="zip" autocomplete="postal-code" value="{{ checkout_initial.address.zip|default:'' }}"></label></p>
</div>
<button class="button button-primary" type="submit">Pay with Stripe</button>
</form>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
+41
View File
@@ -15,6 +15,47 @@
<label>Qty <input name="quantity" type="number" min="1" value="1"></label>
<button class="button button-primary" type="submit">Add to cart</button>
</form>
<h2>Reviews</h2>
{% if review_count %}
<p>{{ review_avg|floatformat:1 }} / 5 · {{ review_count }} review{{ review_count|pluralize }}</p>
{% else %}
<p>No reviews yet.</p>
{% endif %}
{% if can_review %}
<form method="post" action="{% url 'shop:review' product.slug %}">
{% csrf_token %}
<p>
<label for="review-rating">Rating</label>
<select id="review-rating" name="rating" required>
<option value="">Choose 1–5</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>
</p>
<p><label>Title (optional) <input name="title" maxlength="120"></label></p>
<p><label>Review (optional) <textarea name="body" rows="4"></textarea></label></p>
<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>
{% for review in reviews %}
<li>
<strong>{{ review.rating }}/5</strong>
{% if review.title %} · {{ review.title }}{% endif %}
<span> — {{ review.user.first_name|default:review.user.email }}</span>
{% if review.body %}<p>{{ review.body|linebreaks }}</p>{% endif %}
</li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}
@@ -17,5 +17,26 @@
{% endfor %}
</tbody>
</table>
{% if "shipping" in enabled_features and 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
View File
@@ -5,5 +5,12 @@
<h1>Thank you</h1>
<p>Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
<p>A confirmation will go to {{ order.email }}.</p>
{% if user.is_authenticated %}
<p><a class="button 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-primary" href="{% url 'account:register' %}?email={{ order.email|urlencode }}">Create account</a></p>
{% endif %}
<p><a href="{% url 'shop:list' %}">Continue shopping</a></p>
</div></section>
{% endblock %}
+103 -2
View File
@@ -1,5 +1,6 @@
from datetime import timedelta
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core import mail
@@ -13,6 +14,7 @@ from shop.services import (
add_to_cart,
adjust_stock,
available_qty,
create_checkout_session,
create_order_from_cart,
mark_paid,
next_order_number,
@@ -116,7 +118,9 @@ 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")
@@ -175,7 +179,9 @@ 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"))
@@ -252,3 +258,98 @@ class ShopSalesDashboardTests(TestCase):
self.assertContains(products, "Sales")
sales = self.client.get(reverse("shop_portal:sales"))
self.assertContains(sales, 'class="active"')
class ShopCustomerAccountTests(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")
+138 -5
View File
@@ -4,6 +4,7 @@ from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -12,7 +13,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
from shop.models import Order, Product, ProductReview
from shop.services import (
ShopError,
add_to_cart,
@@ -23,8 +24,10 @@ from shop.services import (
create_checkout_session,
create_order_from_cart,
mark_paid,
qualifying_order_for_review,
save_cart,
set_cart_qty,
user_has_reviewed,
)
from shop.stats import sales_dashboard
@@ -45,13 +48,63 @@ def product_list(request):
def product_detail(request, slug):
product = get_object_or_404(Product, slug=slug, is_published=True)
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",
{"product": product, "available": available_qty(product)},
{
"product": product,
"available": available_qty(product),
"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(
@@ -104,12 +157,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})
@@ -127,10 +186,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,
},
)
@@ -236,10 +313,62 @@ 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)
qs = Order.objects.prefetch_related("items")
from django.apps import apps as django_apps
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
order = get_object_or_404(qs, 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
from django.apps import apps as django_apps
claim_orders_for_user(request.user)
qs = Order.objects.filter(user=request.user).prefetch_related("items")
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
orders = qs.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):
from django.apps import apps as django_apps
qs = Order.objects.prefetch_related("items")
if django_apps.is_installed("shipping"):
qs = qs.prefetch_related("shipments")
order = get_object_or_404(qs, pk=pk, user=request.user)
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):
@@ -269,6 +398,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 -4
View File
@@ -49,8 +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")
self.account = SocialAccount.objects.create(
@@ -136,8 +135,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")
self.account = SocialAccount.objects.create(