Compare commits

...
Author SHA1 Message Date
westfarn 3a1ef797b8 Add PCM carrier-route postcard pick-and-send (#11)
## Summary
Closes #11.
- Mirror Monica: compose can use the postcard mailing list or 1–2 USPS carrier routes, with estimated cost shown.
- Place selected routes through PCM list-count orders instead of importing occupant addresses.
2026-09-21 13:29:43 -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
westfarn 9cdce7a897 Add shop, POS sync, event ticketing, and shipping catalog apps (#6)
## Summary
- Closes #5
- Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags
- Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop
- Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping

## Test plan
- [ ] `manage.py test` (152 passed locally)
- [ ] Shop cart + paid order decrements stocked inventory
- [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve
- [ ] Event capacity blocks overbook; paid order emails ticket codes
- [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders
- [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop

Reviewed-on: #6
2026-09-06 14:00:22 -07:00
135 changed files with 8113 additions and 275 deletions

No files matched your search

+24
View File
@@ -35,6 +35,10 @@ FEATURE_BLOG=true
FEATURE_PAYMENTS=true FEATURE_PAYMENTS=true
FEATURE_SOCIAL=true FEATURE_SOCIAL=true
FEATURE_SOCIAL_AI=true FEATURE_SOCIAL_AI=true
FEATURE_SHOP=true
FEATURE_POS_SYNC=true
FEATURE_EVENTS=true
FEATURE_SHIPPING=true
# reCAPTCHA (optional locally — form skips captcha when empty) # reCAPTCHA (optional locally — form skips captcha when empty)
RECAPTCHA_PUBLIC_KEY= RECAPTCHA_PUBLIC_KEY=
@@ -67,6 +71,10 @@ PCM_WEBHOOK_SECRETS=
# Quote JSON (compose/shell break on bare {…, …}). # 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='{"firstName":"Jane","lastName":"Doe","address":"123 Main","city":"Town","state":"IL","zipCode":"60189"}'
PCM_RETURN_ADDRESS= 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 — required when FEATURE_PAYMENTS=true
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
@@ -74,6 +82,22 @@ STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
STRIPE_CURRENCY=usd STRIPE_CURRENCY=usd
# Shop — FEATURE_SHOP (requires email/SMS + payments)
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
# POS sync — FEATURE_POS_SYNC (requires shop). Required in beta/prod.
POS_WEBHOOK_SECRET=
POS_API_BASE_URL=
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=
SHIP_FROM_ZIP=
# Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true # Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env). # LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
META_APP_ID= META_APP_ID=
+23
View File
@@ -32,6 +32,10 @@ FEATURE_BLOG=false
FEATURE_PAYMENTS=false FEATURE_PAYMENTS=false
FEATURE_SOCIAL=false FEATURE_SOCIAL=false
FEATURE_SOCIAL_AI=false FEATURE_SOCIAL_AI=false
FEATURE_SHOP=false
FEATURE_POS_SYNC=false
FEATURE_EVENTS=false
FEATURE_SHIPPING=false
# Shared external Postgres (10.0.0.230) # Shared external Postgres (10.0.0.230)
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site
@@ -56,6 +60,9 @@ PCM_API_KEY=
PCM_API_SECRET= PCM_API_SECRET=
PCM_WEBHOOK_SECRETS= PCM_WEBHOOK_SECRETS=
PCM_RETURN_ADDRESS= 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) # Required when FEATURE_PAYMENTS=true (also requires FEATURE_EMAIL_SMS=true)
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
@@ -63,6 +70,22 @@ STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
STRIPE_CURRENCY=usd STRIPE_CURRENCY=usd
# Required when FEATURE_SHOP=true (also requires FEATURE_EMAIL_SMS + FEATURE_PAYMENTS)
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
# Required when FEATURE_POS_SYNC=true in beta/prod (also requires FEATURE_SHOP)
POS_WEBHOOK_SECRET=
POS_API_BASE_URL=
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=
SHIP_FROM_ZIP=
# Required when FEATURE_SOCIAL=true # Required when FEATURE_SOCIAL=true
META_APP_ID= META_APP_ID=
META_APP_SECRET= META_APP_SECRET=
+4
View File
@@ -23,6 +23,10 @@ separate Django apps. A flag that is off keeps the app out of
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` (requires email/SMS) | | Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` (requires email/SMS) |
| Social consolidation | `FEATURE_SOCIAL` | `social` | | Social consolidation | `FEATURE_SOCIAL` | `social` |
| AI social generator | `FEATURE_SOCIAL_AI` | `social_ai` (requires social) | | AI social generator | `FEATURE_SOCIAL_AI` | `social_ai` (requires social) |
| E-commerce storefront | `FEATURE_SHOP` | `shop` (requires email/SMS + payments) |
| POS API sync | `FEATURE_POS_SYNC` | `pos_sync` (requires shop) |
| Event ticketing | `FEATURE_EVENTS` | `events` (requires email/SMS + payments) |
| Shipping automation | `FEATURE_SHIPPING` | `shipping` (requires shop) |
## New client ## New client
+10
View File
@@ -40,6 +40,10 @@ services:
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true} FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true} FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true}
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true} FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true}
FEATURE_SHOP: ${FEATURE_SHOP:-true}
FEATURE_POS_SYNC: ${FEATURE_POS_SYNC:-true}
FEATURE_EVENTS: ${FEATURE_EVENTS:-true}
FEATURE_SHIPPING: ${FEATURE_SHIPPING:-true}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000} PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com} EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-} EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
@@ -64,6 +68,9 @@ services:
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} 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_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
@@ -107,6 +114,9 @@ services:
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} 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_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
+35
View File
@@ -82,6 +82,41 @@ if _true "${FEATURE_SOCIAL_AI:-}"; then
exit 1 exit 1
fi fi
fi fi
if _true "${FEATURE_SHOP:-}"; then
if ! _true "${FEATURE_EMAIL_SMS:-}"; then
echo "FEATURE_SHOP requires FEATURE_EMAIL_SMS" >&2
exit 1
fi
if ! _true "${FEATURE_PAYMENTS:-}"; then
echo "FEATURE_SHOP requires FEATURE_PAYMENTS" >&2
exit 1
fi
fi
if _true "${FEATURE_POS_SYNC:-}"; then
if ! _true "${FEATURE_SHOP:-}"; then
echo "FEATURE_POS_SYNC requires FEATURE_SHOP" >&2
exit 1
fi
if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
required_vars+=(POS_WEBHOOK_SECRET)
fi
fi
if _true "${FEATURE_EVENTS:-}"; then
if ! _true "${FEATURE_EMAIL_SMS:-}"; then
echo "FEATURE_EVENTS requires FEATURE_EMAIL_SMS" >&2
exit 1
fi
if ! _true "${FEATURE_PAYMENTS:-}"; then
echo "FEATURE_EVENTS requires FEATURE_PAYMENTS" >&2
exit 1
fi
fi
if _true "${FEATURE_SHIPPING:-}"; then
if ! _true "${FEATURE_SHOP:-}"; then
echo "FEATURE_SHIPPING requires FEATURE_SHOP" >&2
exit 1
fi
fi
missing=() missing=()
for var in "${required_vars[@]}"; do for var in "${required_vars[@]}"; do
+8 -1
View File
@@ -1,9 +1,16 @@
from django.contrib import admin from django.contrib import admin
from accounts.models import RealtorProfile from accounts.models import CustomerProfile, RealtorProfile
@admin.register(RealtorProfile) @admin.register(RealtorProfile)
class RealtorProfileAdmin(admin.ModelAdmin): class RealtorProfileAdmin(admin.ModelAdmin):
list_display = ("user", "display_name", "phone") list_display = ("user", "display_name", "phone")
search_fields = ("user__username", "display_name") 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: def __str__(self) -> str:
return self.display_name or self.user.get_username() 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.contrib.auth import views as auth_views
from django.urls import path from django.urls import path
from accounts.views import PortalLoginView
app_name = "accounts" app_name = "accounts"
urlpatterns = [ urlpatterns = [
path( path(
"login/", "login/",
auth_views.LoginView.as_view(template_name="accounts/login.html"), PortalLoginView.as_view(),
name="login", name="login",
), ),
path( 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): def setUp(self):
self.client = Client() self.client = Client()
user = get_user_model().objects.create_user( 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) self.client.force_login(user)
def test_views_card_uses_public_pageviews(self): def test_views_card_uses_public_pageviews(self):
+1 -1
View File
@@ -28,7 +28,7 @@ class BlogPublicTests(TestCase):
class BlogPortalTests(TestCase): class BlogPortalTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() 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 = Client()
self.client.login(username="editor", password="test-pass-123") self.client.login(username="editor", password="test-pass-123")
+39
View File
@@ -115,11 +115,27 @@ FEATURE_BLOG = env_bool("FEATURE_BLOG", _feature_default)
FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default) FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default)
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default) FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default) FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default)
FEATURE_SHOP = env_bool("FEATURE_SHOP", _feature_default)
FEATURE_POS_SYNC = env_bool("FEATURE_POS_SYNC", _feature_default)
FEATURE_EVENTS = env_bool("FEATURE_EVENTS", _feature_default)
FEATURE_SHIPPING = env_bool("FEATURE_SHIPPING", _feature_default)
if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS: if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS:
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS") raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL: if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL") raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL")
if FEATURE_SHOP and not FEATURE_EMAIL_SMS:
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_EMAIL_SMS")
if FEATURE_SHOP and not FEATURE_PAYMENTS:
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_PAYMENTS")
if FEATURE_POS_SYNC and not FEATURE_SHOP:
raise ImproperlyConfigured("FEATURE_POS_SYNC requires FEATURE_SHOP")
if FEATURE_EVENTS and not FEATURE_EMAIL_SMS:
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_EMAIL_SMS")
if FEATURE_EVENTS and not FEATURE_PAYMENTS:
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_PAYMENTS")
if FEATURE_SHIPPING and not FEATURE_SHOP:
raise ImproperlyConfigured("FEATURE_SHIPPING requires FEATURE_SHOP")
CORE_APPS = [ CORE_APPS = [
"core.apps.CoreConfig", "core.apps.CoreConfig",
@@ -137,6 +153,10 @@ OPTIONAL_APPS = [
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"), (FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
(FEATURE_SOCIAL, "social.apps.SocialConfig"), (FEATURE_SOCIAL, "social.apps.SocialConfig"),
(FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"), (FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"),
(FEATURE_SHOP, "shop.apps.ShopConfig"),
(FEATURE_POS_SYNC, "pos_sync.apps.PosSyncConfig"),
(FEATURE_EVENTS, "events.apps.EventsConfig"),
(FEATURE_SHIPPING, "shipping.apps.ShippingConfig"),
] ]
DJANGO_APPS = [ DJANGO_APPS = [
"django.contrib.admin", "django.contrib.admin",
@@ -163,6 +183,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"accounts.middleware.PortalStaffMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"analytics.middleware.UTMTrackingMiddleware", "analytics.middleware.UTMTrackingMiddleware",
@@ -329,6 +350,9 @@ PCM_RETURN_LINE2 = env("PCM_RETURN_LINE2", "")
PCM_RETURN_CITY = env("PCM_RETURN_CITY", "") PCM_RETURN_CITY = env("PCM_RETURN_CITY", "")
PCM_RETURN_STATE = env("PCM_RETURN_STATE", "") PCM_RETURN_STATE = env("PCM_RETURN_STATE", "")
PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "") 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) # Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid)
LOB_API_KEY = env("LOB_API_KEY", "") LOB_API_KEY = env("LOB_API_KEY", "")
CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "") CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "")
@@ -352,6 +376,21 @@ STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "") STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd") STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd")
# --- Shop / POS / shipping (FEATURE_SHOP and add-ons) ---
# 0 = unlimited made-to-order print queue.
SHOP_PRINT_QUEUE_LIMIT_MINUTES = int(
env("SHOP_PRINT_QUEUE_LIMIT_MINUTES", "0") or "0"
)
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", "")
SHIP_FROM_ZIP = env("SHIP_FROM_ZIP", "")
# --- Nominatim (address suggest; server-side proxy only) --- # --- Nominatim (address suggest; server-side proxy only) ---
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with # Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
# LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as # LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as
+57
View File
@@ -264,6 +264,63 @@ body.portal {
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
} }
.chart-placeholder.chart-daily {
gap: 3px;
padding: 16px 10px 8px;
overflow-x: auto;
}
.chart-placeholder.chart-daily .chart-bar-col {
flex: 1 0 8px;
min-width: 6px;
}
.chart-placeholder.chart-daily .chart-bar-col .bar {
min-height: 0;
}
.chart-placeholder.chart-daily .chart-bar-col .bar.is-zero {
height: 0 !important;
min-height: 0;
opacity: 0.2;
}
.chart-placeholder.chart-daily .chart-bar-label {
font-size: 10px;
}
.chart-placeholder.chart-revenue .chart-bar-col .bar {
background: var(--monica-primary-light);
}
.hbar-chart {
display: flex;
flex-direction: column;
gap: 12px;
}
.hbar-row {
display: grid;
grid-template-columns: minmax(72px, 160px) 1fr auto;
gap: 12px;
align-items: center;
}
.hbar-label {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hbar-track {
height: 16px;
background: #e8f4f5;
overflow: hidden;
}
.hbar-fill {
height: 100%;
background: var(--monica-primary);
opacity: 0.8;
min-width: 0;
}
.hbar-value {
font-size: 13px;
font-weight: 600;
min-width: 2.5ch;
text-align: right;
}
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; } .consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.preview-pane { .preview-pane {
@@ -0,0 +1,250 @@
(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 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 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>" + 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 (!(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> <a class="rd-nav-link" href="{% url item.url_name %}">{{ item.label }}</a>
</li> </li>
{% endfor %} {% 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> </ul>
</div> </div>
</div> </div>
@@ -134,9 +147,14 @@
{% for item in public_nav_extra %} {% for item in public_nav_extra %}
<li><a href="{% url item.url_name %}">{{ item.label }}</a></li> <li><a href="{% url item.url_name %}">{{ item.label }}</a></li>
{% endfor %} {% 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> <li><a href="{% url 'dashboard:home' %}">Client portal</a></li>
{% else %} {% 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> <li><a href="{% url 'accounts:login' %}">Client portal</a></li>
{% endif %} {% endif %}
</ul> </ul>
+15
View File
@@ -48,5 +48,20 @@ if apps.is_installed("social_ai"):
urlpatterns += [ urlpatterns += [
path("portal/social/api/generate/", include("social_ai.urls")), path("portal/social/api/generate/", include("social_ai.urls")),
] ]
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")),
]
if apps.is_installed("pos_sync"):
urlpatterns += [path("portal/pos/", include("pos_sync.urls"))]
if apps.is_installed("events"):
urlpatterns += [
path("events/", include("events.public_urls")),
path("portal/events/", include("events.portal_urls")),
]
if apps.is_installed("shipping"):
urlpatterns += [path("portal/shipping/", include("shipping.urls"))]
handler404 = "public.views.page_not_found" handler404 = "public.views.page_not_found"
+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") url = reverse("public:contact")
response = self.client.post( response = self.client.post(
url, url,
{ {
"first_name": "Alex", "email": "primary@example.com",
"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",
"interest": "general", "interest": "general",
"message": "Looking to buy", "message": "Looking to buy",
}, },
@@ -151,15 +144,33 @@ class ContactFormMergeTests(TestCase):
self.assertEqual(Contact.objects.count(), 1) self.assertEqual(Contact.objects.count(), 1)
lead = Lead.objects.get() lead = Lead.objects.get()
self.assertEqual(lead.contact_id, self.existing.pk) self.assertEqual(lead.contact_id, self.existing.pk)
self.assertIn("alt@example.com", lead.message) self.assertIn("Looking to buy", lead.message)
self.assertIn("merged by phone", 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): class PortalCreateMatchPromptTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="adder", password="test-pass-123") 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
+1
View File
@@ -22,6 +22,7 @@ GROUP_ORDER = {
"Outreach": 20, "Outreach": 20,
"Content": 30, "Content": 30,
"Social": 40, "Social": 40,
"Retail": 45,
"Billing": 50, "Billing": 50,
} }
+111
View File
@@ -1,4 +1,6 @@
import os
from io import StringIO from io import StringIO
from unittest.mock import patch
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
@@ -30,6 +32,11 @@ class PublicSmokeTests(TestCase):
client = Client() client = Client()
self.assertEqual(client.get(reverse("public:about")).status_code, 200) self.assertEqual(client.get(reverse("public:about")).status_code, 200)
self.assertEqual(client.get(reverse("public:contact")).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): class DispatchDueTests(TestCase):
@@ -37,3 +44,107 @@ class DispatchDueTests(TestCase):
out = StringIO() out = StringIO()
call_command("dispatch_due", stdout=out) call_command("dispatch_due", stdout=out)
self.assertEqual(out.getvalue(), "") 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() User = get_user_model()
self.user = User.objects.create_user( 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") self.client.login(username="utm-composer", password="test-pass-123")
def test_requires_login(self): def test_requires_login(self):
+39 -1
View File
@@ -22,16 +22,30 @@ class FeatureFlagSettingsTests(SimpleTestCase):
self.assertIn("FEATURE_PAYMENTS", src) self.assertIn("FEATURE_PAYMENTS", src)
self.assertIn("FEATURE_SOCIAL", src) self.assertIn("FEATURE_SOCIAL", src)
self.assertIn("FEATURE_SOCIAL_AI", src) self.assertIn("FEATURE_SOCIAL_AI", src)
self.assertIn("FEATURE_SHOP", src)
self.assertIn("FEATURE_POS_SYNC", src)
self.assertIn("FEATURE_EVENTS", src)
self.assertIn("FEATURE_SHIPPING", src)
self.assertIn("email_sms.apps.EmailSmsConfig", src) self.assertIn("email_sms.apps.EmailSmsConfig", src)
self.assertIn("directmail.apps.DirectmailConfig", src) self.assertIn("directmail.apps.DirectmailConfig", src)
self.assertIn("blog.apps.BlogConfig", src) self.assertIn("blog.apps.BlogConfig", src)
self.assertIn("payments.apps.PaymentsConfig", src) self.assertIn("payments.apps.PaymentsConfig", src)
self.assertIn("social_ai.apps.SocialAiConfig", src) self.assertIn("social_ai.apps.SocialAiConfig", src)
self.assertIn("shop.apps.ShopConfig", src)
self.assertIn("pos_sync.apps.PosSyncConfig", src)
self.assertIn("events.apps.EventsConfig", src)
self.assertIn("shipping.apps.ShippingConfig", src)
def test_payments_requires_email_sms_in_source(self): def test_payments_requires_email_sms_in_source(self):
src = _read_settings_source() src = _read_settings_source()
self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src) self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src)
self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", src) self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", src)
self.assertIn("FEATURE_SHOP requires FEATURE_EMAIL_SMS", src)
self.assertIn("FEATURE_SHOP requires FEATURE_PAYMENTS", src)
self.assertIn("FEATURE_POS_SYNC requires FEATURE_SHOP", src)
self.assertIn("FEATURE_EVENTS requires FEATURE_EMAIL_SMS", src)
self.assertIn("FEATURE_EVENTS requires FEATURE_PAYMENTS", src)
self.assertIn("FEATURE_SHIPPING requires FEATURE_SHOP", src)
class AlwaysOnImportIsolationTests(SimpleTestCase): class AlwaysOnImportIsolationTests(SimpleTestCase):
@@ -44,6 +58,10 @@ class AlwaysOnImportIsolationTests(SimpleTestCase):
"payments", "payments",
"social", "social",
"social_ai", "social_ai",
"shop",
"pos_sync",
"events",
"shipping",
} }
ALWAYS_ON = [ ALWAYS_ON = [
@@ -82,7 +100,18 @@ class InstalledOptionalAppsTests(TestCase):
def test_dev_installs_catalog_apps(self): def test_dev_installs_catalog_apps(self):
labels = {c.label for c in apps.get_app_configs()} labels = {c.label for c in apps.get_app_configs()}
for label in ("email_sms", "directmail", "blog", "payments", "social", "social_ai"): for label in (
"email_sms",
"directmail",
"blog",
"payments",
"social",
"social_ai",
"shop",
"pos_sync",
"events",
"shipping",
):
self.assertIn(label, labels) self.assertIn(label, labels)
def test_optional_urls_resolve_when_installed(self): def test_optional_urls_resolve_when_installed(self):
@@ -92,3 +121,12 @@ class InstalledOptionalAppsTests(TestCase):
self.assertTrue(reverse("payments:invoice_list").startswith("/portal/payments/")) self.assertTrue(reverse("payments:invoice_list").startswith("/portal/payments/"))
self.assertTrue(reverse("social:composer").startswith("/portal/social/")) self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/")) 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/"))
@@ -27,6 +27,36 @@
<div class="value">{{ open_invoices|default:0 }}</div> <div class="value">{{ open_invoices|default:0 }}</div>
</div> </div>
{% endif %} {% endif %}
{% if 'shop' in enabled_features %}
<div class="stat-card">
<div class="label">Open shop orders</div>
<div class="value">{{ open_shop_orders|default:0 }}</div>
</div>
<div class="stat-card">
<div class="label">Sales (30 days)</div>
<div class="value">{{ shop_sales_30d|default:0 }}</div>
</div>
<div class="stat-card">
<div class="label">Shop revenue (30 days)</div>
<div class="value" style="font-size:22px">{{ shop_revenue_30d|default:0 }}</div>
</div>
<div class="stat-card">
<div class="label">Low stock</div>
<div class="value">{{ low_stock_products|default:0 }}</div>
</div>
{% endif %}
{% if 'events' in enabled_features %}
<div class="stat-card">
<div class="label">Upcoming events</div>
<div class="value">{{ upcoming_events|default:0 }}</div>
</div>
{% endif %}
{% if 'shipping' in enabled_features %}
<div class="stat-card">
<div class="label">Unshipped orders</div>
<div class="value">{{ unshipped_orders|default:0 }}</div>
</div>
{% endif %}
</div> </div>
<div class="split"> <div class="split">
+13
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_SECRETS` | Comma-separated signature secrets (one per PCM subscription) |
| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) | | `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) |
| `PCM_RETURN_ADDRESS` | JSON return address on orders | | `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) | | `POSTCARD_PROVIDER` | `pcm` (default) |
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
@@ -93,6 +96,16 @@ HMAC-SHA256 of body) matching **any** listed secret.
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
echo that (or `orderID`, matched to `Message.provider_message_id`). 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.
### Campaign completion email ### Campaign completion email
When a campaign reaches **completed** (email, SMS, or postcard), one summary 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): class Audience(models.TextChoices):
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in" POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
CARRIER_ROUTE = "carrier_route", "USPS carrier routes"
name = models.CharField(max_length=120) name = models.CharField(max_length=120)
channel = models.CharField(max_length=16, choices=Channel.choices) channel = models.CharField(max_length=16, choices=Channel.choices)
@@ -58,6 +59,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
body_override = models.TextField(blank=True) body_override = models.TextField(blank=True)
# Set when completion summary email is sent (campaign COMPLETED). # Set when completion summary email is sent (campaign COMPLETED).
notify_sent_at = models.DateTimeField(null=True, blank=True) 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: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]
@@ -83,7 +86,11 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
Campaign, on_delete=models.CASCADE, related_name="messages" Campaign, on_delete=models.CASCADE, related_name="messages"
) )
contact = models.ForeignKey( 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) channel = models.CharField(max_length=16, choices=Channel.choices)
status = models.CharField( status = models.CharField(
@@ -100,7 +107,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
ordering = ["-created_at"] ordering = ["-created_at"]
def __str__(self) -> str: 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): 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 {} 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( def place_postcard_order(
*, *,
design_id: int, design_id: int,
@@ -358,13 +562,32 @@ def place_postcard_order(
data = pcm_request("POST", "/order/postcard", json_body=payload) data = pcm_request("POST", "/order/postcard", json_body=payload)
if not isinstance(data, dict): if not isinstance(data, dict):
raise PcmApiError("Unexpected order response from PCM") 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"]: def place_postcard_order_with_list_count(
order_id = data["results"][0].get("orderID") *,
if order_id is None: design_id: int,
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400]) list_count_id: int,
return str(order_id) 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: def design_id_from_template(template) -> int | None:
@@ -396,6 +619,24 @@ class PcmProvider:
"(save a design from the postcard designer first)" "(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( recipient = contact_to_pcm_recipient(
message.contact, ext_ref=str(message.pk) message.contact, ext_ref=str(message.pk)
) )
+219
View File
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
AUDIENCE_CHANNEL = { AUDIENCE_CHANNEL = {
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD, Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD,
} }
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy). # {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
@@ -84,6 +85,211 @@ def channel_for_audience(audience: str) -> str:
raise ValueError(f"Unknown audience: {audience}") from exc 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,
)
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 = extract_crrt_rows(data)
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,
"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
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,
"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( def create_campaign_draft(
*, *,
name: str, name: str,
@@ -93,9 +299,11 @@ def create_campaign_draft(
scheduled_for=None, scheduled_for=None,
created_by: AbstractBaseUser | None = None, created_by: AbstractBaseUser | None = None,
template: MessageTemplate | None = None, template: MessageTemplate | None = None,
route_targeting: dict | None = None,
) -> Campaign: ) -> Campaign:
"""Persist a draft campaign and per-recipient Message stubs.""" """Persist a draft campaign and per-recipient Message stubs."""
channel = channel_for_audience(audience) channel = channel_for_audience(audience)
targeting = route_targeting if isinstance(route_targeting, dict) else {}
campaign = Campaign.objects.create( campaign = Campaign.objects.create(
name=name, name=name,
channel=channel, channel=channel,
@@ -106,7 +314,18 @@ def create_campaign_draft(
body_override=body, body_override=body,
created_by=created_by, created_by=created_by,
template=template, 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)) contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create( Message.objects.bulk_create(
[ [
+3 -1
View File
@@ -18,7 +18,9 @@ def send_campaign_message(message_id: str) -> None:
campaign = message.campaign 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.status = Message.Status.SUPPRESSED
message.error = "Contact opted out or suppressed" message.error = "Contact opted out or suppressed"
message.save(update_fields=["status", "error", "updated_at"]) message.save(update_fields=["status", "error", "updated_at"])
@@ -0,0 +1,50 @@
<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" id="route-status" style="margin-top:8px"></p>
<div id="route-table-wrap" hidden style="margin-top:12px">
<table class="table">
<thead>
<tr>
<th>Route</th>
<th>Area</th>
<th>Households</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 %} {% endif %}
{% if can_send %} {% if can_send %}
<form method="post" action="{% url 'directmail:campaign_send' campaign.pk %}" <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 %} {% 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"> <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. Enqueues draft / scheduled / failed messages via PCM Integrations.
{% else %} {% else %}
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline). Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
@@ -52,6 +54,35 @@
</div> </div>
{% endif %} {% 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" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div> <div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b"> <div class="panel-b">
@@ -129,6 +129,7 @@
<div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div> <div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div>
</div> </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> <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" %} {% include "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %}
<button class="btn btn-primary" type="submit">Save draft</button> <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/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="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/campaign-utm.js' %}"></script>
<script src="{% static 'js/postcard-routes.js' %}"></script>
<script> <script>
(function () { (function () {
var uploadUrl = "{{ image_upload_url|escapejs }}"; var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -219,7 +221,7 @@
var subject = (document.getElementById('id_subject') || {}).value || ''; var subject = (document.getElementById('id_subject') || {}).value || '';
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in'; var isEmail = audience === 'email_opt_in';
var isPostcard = audience === 'postcard_opt_in'; var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
var body = ''; var body = '';
if (isEmail && quill) { if (isEmail && quill) {
body = quill.root.innerHTML; body = quill.root.innerHTML;
@@ -257,7 +259,7 @@
window.syncComposeChannel = function () { window.syncComposeChannel = function () {
var audience = (document.getElementById('id_audience') || {}).value || ''; 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 isSms = audience === 'sms_opt_in';
var isEmail = audience === 'email_opt_in'; var isEmail = audience === 'email_opt_in';
var tmplField = document.getElementById('postcard-template-field'); var tmplField = document.getElementById('postcard-template-field');
@@ -290,6 +292,9 @@
}); });
var panel = document.getElementById('utm-link-panel'); var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply(); if (panel && panel._utmApply) panel._utmApply();
if (window.MonicaPostcardRoutes && window.MonicaPostcardRoutes.syncVisibility) {
window.MonicaPostcardRoutes.syncVisibility();
}
syncCampaignPreview(); syncCampaignPreview();
}; };
@@ -353,7 +358,7 @@
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill(); if (audience === 'email_opt_in') syncBodyFromQuill();
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value; 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'); var pc = document.getElementById('id_body_pc');
bodyField.value = pc ? pc.value : ''; bodyField.value = pc ? pc.value : '';
} }
@@ -377,6 +382,10 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview); if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill(); initQuill();
window.initPostcardRoutes({
quoteUrl: "{{ postcard_quote_url|escapejs }}",
csrfToken: csrfToken
});
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), { window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () { getName: function () {
return (document.getElementById('id_name') || {}).value || ''; return (document.getElementById('id_name') || {}).value || '';
+204 -4
View File
@@ -239,8 +239,7 @@ class PostcardDesignPickTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="pcm-pick", password="test-pass-123") self.client.login(username="pcm-pick", password="test-pass-123")
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
@@ -283,8 +282,7 @@ class PostcardUtmPanelTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="utm-mail", password="test-pass-123") self.client.login(username="utm-mail", password="test-pass-123")
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
@@ -307,6 +305,208 @@ class PostcardUtmPanelTests(TestCase):
self.assertContains(response, "data-utm-qr") self.assertContains(response, "data-utm-qr")
self.assertContains(response, reverse("campaign_short_link")) self.assertContains(response, reverse("campaign_short_link"))
class CarrierRoutePostcardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="routes", password="test-pass-123"
)
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"),
)
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")
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): def test_postcard_does_not_inject_into_body(self):
campaign = create_campaign_draft( campaign = create_campaign_draft(
name="March mailer", name="March mailer",
+5
View File
@@ -6,6 +6,11 @@ app_name = "directmail"
urlpatterns = [ urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"), 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>/", views.campaign_detail, name="campaign_detail"),
path( path(
"campaigns/<uuid:pk>/status.json", "campaigns/<uuid:pk>/status.json",
+85 -5
View File
@@ -1,6 +1,7 @@
import hashlib import hashlib
import hmac import hmac
import io import io
import json
import logging import logging
from django.conf import settings from django.conf import settings
@@ -25,6 +26,7 @@ from directmail.providers.postcard.pcm import (
design_id_from_template, design_id_from_template,
get_design_embed_url, get_design_embed_url,
list_designs, list_designs,
mail_class_for_size,
) )
from contacts.consent import opted_in_contacts from contacts.consent import opted_in_contacts
from core.campaign_utm import utm_panel_context from core.campaign_utm import utm_panel_context
@@ -33,6 +35,8 @@ from directmail.services import (
create_campaign_draft, create_campaign_draft,
enqueue_campaign_send, enqueue_campaign_send,
message_is_removable, message_is_removable,
parse_route_targeting,
postcard_quote as build_postcard_quote,
) )
from directmail.webhooks import ( from directmail.webhooks import (
PROVIDER_EMAIL, PROVIDER_EMAIL,
@@ -68,6 +72,12 @@ def _audience_choices() -> list[tuple[str, str]]:
choices.append( choices.append(
(value, f"Mailing list · {label} opt-in ({count} {noun})") (value, f"Mailing list · {label} opt-in ({count} {noun})")
) )
choices.append(
(
Campaign.Audience.CARRIER_ROUTE,
"USPS carrier routes (find by ZIP)",
)
)
return choices return choices
@@ -224,10 +234,14 @@ def _message_destination(message) -> str:
contact = message.contact contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "") channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL: if channel == Channel.EMAIL:
return (contact.email or "").strip() return (contact.email or "").strip() if contact else ""
if channel == Channel.SMS: if channel == Channel.SMS:
return (contact.phone or "").strip() return (contact.phone or "").strip() if contact else ""
if channel == Channel.POSTCARD: 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 _format_postal_address(contact.postal_address)
return "" return ""
@@ -423,6 +437,7 @@ def campaign_list(request):
"audience": Campaign.Audience.POSTCARD_OPT_IN, "audience": Campaign.Audience.POSTCARD_OPT_IN,
"scheduled_for": "", "scheduled_for": "",
"template_id": "", "template_id": "",
"route_targeting": "",
} }
if request.method == "POST": if request.method == "POST":
@@ -432,6 +447,7 @@ def campaign_list(request):
audience = (request.POST.get("audience") or "").strip() audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or "" scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip() template_id = (request.POST.get("template_id") or "").strip()
route_raw = request.POST.get("route_targeting") or ""
form_data.update( form_data.update(
{ {
@@ -441,10 +457,12 @@ def campaign_list(request):
"audience": audience, "audience": audience,
"scheduled_for": scheduled_raw, "scheduled_for": scheduled_raw,
"template_id": template_id, "template_id": template_id,
"route_targeting": route_raw,
} }
) )
template = None template = None
targeting: dict = {}
if template_id: if template_id:
template = _resolve_postcard_template(template_id) template = _resolve_postcard_template(template_id)
@@ -459,6 +477,18 @@ def campaign_list(request):
) )
if not body: if not body:
body = "Postcard mailing" 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 scheduled_for = None
try: try:
@@ -475,7 +505,18 @@ def campaign_list(request):
scheduled_for=scheduled_for, scheduled_for=scheduled_for,
created_by=request.user, created_by=request.user,
template=template, 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() recipient_count = campaign.messages.count()
messages.success( messages.success(
request, request,
@@ -497,10 +538,46 @@ def campaign_list(request):
"form_errors": form_errors, "form_errors": form_errors,
"image_upload_url": reverse("directmail:campaign_image_upload"), "image_upload_url": reverse("directmail:campaign_image_upload"),
**utm_panel_context(live=True, medium=Channel.POSTCARD), **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 @login_required
def campaign_detail(request, pk): def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk) campaign = get_object_or_404(Campaign, pk=pk)
@@ -536,6 +613,7 @@ def campaign_detail(request, pk):
"suppressed", "suppressed",
} }
).exists(), ).exists(),
"route_targeting": campaign.route_targeting or {},
}, },
) )
@@ -566,7 +644,9 @@ def campaign_status_json(request, pk):
"messages": [ "messages": [
{ {
"id": str(m.pk), "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 "", "destination": getattr(m, "destination", "") or "",
"status": m.status, "status": m.status,
"status_display": m.get_status_display(), "status_display": m.get_status_display(),
@@ -579,7 +659,7 @@ def campaign_status_json(request, pk):
"events": [ "events": [
{ {
"event_type": e.event_type, "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(), "created_at": e.created_at.isoformat(),
} }
for e in ctx["recent_events"] 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) 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() message.delete()
messages.success(request, f"Removed {label} from this campaign.") messages.success(request, f"Removed {label} from this campaign.")
page = (request.POST.get("page") or request.GET.get("page") or "").strip() 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): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="monica", password="test-pass-123") self.client.login(username="monica", password="test-pass-123")
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
@@ -190,8 +189,7 @@ class CampaignDraftSaveTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="composer", password="test-pass-123") self.client.login(username="composer", password="test-pass-123")
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
@@ -260,8 +258,7 @@ class CampaignSendTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="sender", password="test-pass-123") self.client.login(username="sender", password="test-pass-123")
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
@@ -467,7 +464,7 @@ class Smtp2goEmailWebhookTests(TestCase):
def test_status_json_includes_opens(self): def test_status_json_includes_opens(self):
User = get_user_model() 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") self.client.login(username="viewer", password="test-pass-123")
ProviderEvent.objects.create( ProviderEvent.objects.create(
message=self.message, message=self.message,
@@ -946,8 +943,7 @@ class StoredFileUploadTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="uploader", password="test-pass-123") self.client.login(username="uploader", password="test-pass-123")
@@ -1030,8 +1026,7 @@ class CampaignRecipientTableTests(TestCase):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="recip", password="test-pass-123") self.client.login(username="recip", password="test-pass-123")
self.contacts = [] self.contacts = []
@@ -1169,8 +1164,7 @@ class CampaignUtmLinkTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user( 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 = Client()
self.client.login(username="utm-composer", password="test-pass-123") self.client.login(username="utm-composer", password="test-pass-123")
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
View File
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
from django.contrib import admin
from events.models import Event, Ticket, TicketOrder
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ("title", "starts_at", "capacity", "price", "is_published")
list_filter = ("is_published",)
search_fields = ("title", "venue")
prepopulated_fields = {"slug": ("title",)}
@admin.register(TicketOrder)
class TicketOrderAdmin(admin.ModelAdmin):
list_display = ("number", "event", "email", "quantity", "status")
list_filter = ("status",)
search_fields = ("number", "email")
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
list_display = ("code", "event", "order")
search_fields = ("code",)
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class EventsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "events"
verbose_name = "Event ticketing"
def ready(self):
from events import hooks
hooks.register()
+33
View File
@@ -0,0 +1,33 @@
from core.registry import (
register_dashboard_collector,
register_feature,
register_portal_nav,
register_public_nav,
)
def register() -> None:
register_feature("events")
register_public_nav(
section="events", label="Events", url_name="events:list", order=35
)
register_portal_nav(
section="events",
label="Events",
url_name="events_portal:event_list",
group="Retail",
order=30,
)
register_dashboard_collector(_dashboard)
def _dashboard(request) -> dict:
from django.utils import timezone
from events.models import Event
return {
"upcoming_events": Event.objects.filter(
is_published=True, starts_at__gte=timezone.now()
).count()
}
+74
View File
@@ -0,0 +1,74 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Event',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=220, unique=True)),
('description', models.TextField(blank=True)),
('starts_at', models.DateTimeField()),
('venue', models.CharField(blank=True, max_length=200)),
('capacity', models.PositiveIntegerField(default=0)),
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('is_published', models.BooleanField(default=False)),
],
options={
'ordering': ['starts_at'],
},
),
migrations.CreateModel(
name='TicketOrder',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(max_length=32, unique=True)),
('email', models.EmailField(max_length=254)),
('customer_name', models.CharField(blank=True, max_length=200)),
('quantity', models.PositiveIntegerField(default=1)),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)),
('hosted_checkout_url', models.URLField(blank=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_orders', to='events.event')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Ticket',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(max_length=16, unique=True)),
('attendee_name', models.CharField(blank=True, max_length=200)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='events.event')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to='events.ticketorder')),
],
options={
'ordering': ['created_at'],
},
),
]
View File
Whitespace-only changes.
+111
View File
@@ -0,0 +1,111 @@
from decimal import Decimal
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Event(UUIDPrimaryKeyModel, TimeStampedModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
description = models.TextField(blank=True)
starts_at = models.DateTimeField()
venue = models.CharField(max_length=200, blank=True)
capacity = models.PositiveIntegerField(default=0)
price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
currency = models.CharField(max_length=8, default="usd")
is_published = models.BooleanField(default=False)
class Meta:
ordering = ["starts_at"]
def __str__(self) -> str:
return self.title
def get_absolute_url(self) -> str:
return reverse("events:detail", kwargs={"slug": self.slug})
def save(self, *args, **kwargs):
if not self.slug:
base = slugify(self.title)[:200] or "event"
slug = base
n = 2
while Event.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{n}"
n += 1
self.slug = slug
super().save(*args, **kwargs)
def tickets_held(self, *, exclude_order_id=None) -> int:
qs = self.ticket_orders.filter(
status__in=[
TicketOrder.Status.DRAFT,
TicketOrder.Status.OPEN,
TicketOrder.Status.PAID,
]
)
if exclude_order_id:
qs = qs.exclude(pk=exclude_order_id)
return qs.aggregate(total=models.Sum("quantity"))["total"] or 0
@property
def tickets_sold(self) -> int:
return self.tickets_held()
@property
def seats_remaining(self) -> int | None:
if not self.capacity:
return None
return max(self.capacity - self.tickets_sold, 0)
@property
def is_upcoming(self) -> bool:
return self.starts_at >= timezone.now()
class TicketOrder(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
PAID = "paid", "Paid"
CANCELLED = "cancelled", "Cancelled"
number = models.CharField(max_length=32, unique=True)
event = models.ForeignKey(
Event, on_delete=models.PROTECT, related_name="ticket_orders"
)
email = models.EmailField()
customer_name = models.CharField(max_length=200, blank=True)
quantity = models.PositiveIntegerField(default=1)
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=8, default="usd")
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
stripe_checkout_session_id = models.CharField(max_length=255, blank=True)
hosted_checkout_url = models.URLField(blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.number} · {self.event}"
class Ticket(UUIDPrimaryKeyModel, TimeStampedModel):
order = models.ForeignKey(
TicketOrder, on_delete=models.CASCADE, related_name="tickets"
)
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="tickets")
code = models.CharField(max_length=16, unique=True)
attendee_name = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ["created_at"]
def __str__(self) -> str:
return self.code
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from events import views
app_name = "events_portal"
urlpatterns = [
path("", views.portal_event_list, name="event_list"),
path("new/", views.portal_event_edit, name="event_new"),
path("<uuid:pk>/", views.portal_event_detail, name="event_detail"),
path("<uuid:pk>/edit/", views.portal_event_edit, name="event_edit"),
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),
]
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from events import views
app_name = "events"
urlpatterns = [
path("", views.event_list, name="list"),
path("order/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
path("order/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
path("<slug:slug>/", views.event_detail, name="detail"),
path("<slug:slug>/buy/", views.buy_tickets, name="buy"),
]
+188
View File
@@ -0,0 +1,188 @@
"""Event capacity, Stripe ticket checkout, and confirmation email."""
from __future__ import annotations
import logging
import secrets
from datetime import date
from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from events.models import Event, Ticket, TicketOrder
logger = logging.getLogger(__name__)
class EventsError(RuntimeError):
pass
def _stripe():
secret = (settings.STRIPE_SECRET_KEY or "").strip()
if not secret:
raise EventsError("STRIPE_SECRET_KEY is not configured")
try:
import stripe
except ImportError as exc:
raise EventsError("stripe package is not installed") from exc
stripe.api_key = secret
return stripe
def next_ticket_order_number() -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"TIX-{today}-"
existing = TicketOrder.objects.filter(number__startswith=prefix).count()
return f"{prefix}{existing + 1:03d}"
def new_ticket_code() -> str:
while True:
code = secrets.token_hex(4).upper()
if not Ticket.objects.filter(code=code).exists():
return code
def assert_capacity(
event: Event, quantity: int, *, exclude_order_id=None
) -> None:
if quantity < 1:
raise EventsError("Quantity must be at least 1.")
if not event.capacity:
return
held = event.tickets_held(exclude_order_id=exclude_order_id)
remaining = max(event.capacity - held, 0)
if quantity > remaining:
raise EventsError(f"Only {remaining} seats left for {event.title}.")
def create_ticket_order(
event: Event, *, email: str, customer_name: str = "", quantity: int = 1
) -> TicketOrder:
email = (email or "").strip()
if not email:
raise EventsError("Email is required.")
if not event.is_published:
raise EventsError("This event is not on sale.")
assert_capacity(event, quantity)
amount = event.price * quantity
return TicketOrder.objects.create(
number=next_ticket_order_number(),
event=event,
email=email,
customer_name=(customer_name or "").strip(),
quantity=quantity,
amount=amount,
currency=(event.currency or settings.STRIPE_CURRENCY or "usd").lower(),
status=TicketOrder.Status.DRAFT,
)
def create_checkout_session(
order: TicketOrder, *, success_url: str, cancel_url: str
) -> str:
stripe = _stripe()
session = stripe.checkout.Session.create(
mode="payment",
customer_email=order.email or None,
line_items=[
{
"quantity": order.quantity,
"price_data": {
"currency": (order.currency or "usd").lower(),
"unit_amount": int(
(order.event.price * Decimal("100")).quantize(Decimal("1"))
),
"product_data": {"name": order.event.title},
},
}
],
metadata={
"ticket_order_id": str(order.pk),
"ticket_order_number": order.number,
},
success_url=success_url,
cancel_url=cancel_url,
)
order.stripe_checkout_session_id = session.id
order.hosted_checkout_url = session.url or ""
order.status = TicketOrder.Status.OPEN
order.save(
update_fields=[
"stripe_checkout_session_id",
"hosted_checkout_url",
"status",
"updated_at",
]
)
return session.url or ""
def _issue_tickets(order: TicketOrder) -> list[Ticket]:
created = []
for _ in range(order.quantity):
created.append(
Ticket.objects.create(
order=order,
event=order.event,
code=new_ticket_code(),
attendee_name=order.customer_name,
)
)
return created
def mark_paid(order: TicketOrder, *, stripe_id: str = "") -> None:
if order.status == TicketOrder.Status.PAID:
return
with transaction.atomic():
locked = TicketOrder.objects.select_for_update().get(pk=order.pk)
if locked.status == TicketOrder.Status.PAID:
return
assert_capacity(
locked.event, locked.quantity, exclude_order_id=locked.pk
)
_issue_tickets(locked)
locked.status = TicketOrder.Status.PAID
locked.paid_at = timezone.now()
locked.save(update_fields=["status", "paid_at", "updated_at"])
order.refresh_from_db()
try:
send_ticket_email(order)
except Exception:
logger.exception("ticket email failed for %s", order.number)
def send_ticket_email(order: TicketOrder) -> bool:
to_email = (order.email or "").strip()
if not to_email:
raise EventsError("Order has no email address")
from django.core.mail import EmailMultiAlternatives
codes = ", ".join(t.code for t in order.tickets.all())
name = order.customer_name or "there"
when = timezone.localtime(order.event.starts_at).strftime("%b %d, %Y %-I:%M %p")
subject = f"Tickets for {order.event.title} — {settings.SITE_NAME}"
text = (
f"Hi {name},\n\n"
f"Your tickets for {order.event.title} on {when}:\n"
f"{codes}\n\nOrder {order.number}\n"
)
html = (
f"<p>Hi {name},</p>"
f"<p>Tickets for <strong>{order.event.title}</strong> on {when}:</p>"
f"<p><strong>{codes}</strong></p>"
f"<p>Order {order.number}</p>"
)
mail = EmailMultiAlternatives(
subject=subject,
body=text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
mail.attach_alternative(html, "text/html")
mail.send(fail_silently=False)
return True
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Tickets · {{ event.title }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1>Tickets · {{ event.title }}</h1>
<p>{{ event.price }} {{ event.currency|upper }} each{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} left{% endif %}</p>
<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>Quantity <input name="quantity" type="number" min="1" value="1"></label></p>
<button class="button button-primary" type="submit">Pay with Stripe</button>
</form>
</div>
</section>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Checkout cancelled{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>Checkout cancelled</h1>
<p>No charge for {{ order.event.title }}. <a href="{% url 'events:buy' order.event.slug %}">Try again</a>.</p>
</div></section>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}{{ event.title }} · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<p><a href="{% url 'events:list' %}">← Events</a></p>
<h1>{{ event.title }}</h1>
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
<div>{{ event.description|linebreaks }}</div>
<p>{{ event.price }} {{ event.currency|upper }}</p>
{% if event.seats_remaining == 0 %}
<p>Sold out.</p>
{% else %}
<p><a class="button button-primary" href="{% url 'events:buy' event.slug %}">Buy tickets</a></p>
{% endif %}
</div>
</section>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Events · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1 class="text-uppercase">Events</h1>
{% for event in events %}
<article style="margin:0 0 32px">
<h2><a href="{{ event.get_absolute_url }}">{{ event.title }}</a></h2>
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
<p>{{ event.price }} {{ event.currency|upper }}{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} seats left{% endif %}</p>
</article>
{% empty %}
<p>No upcoming events.</p>
{% endfor %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends "portal_base.html" %}
{% block title %}{{ event.title }} · Portal{% endblock %}
{% block topbar_title %}{{ event.title }}{% endblock %}
{% block portal_content %}
<p><a class="btn btn-ghost" href="{% url 'events_portal:event_edit' event.pk %}">Edit</a></p>
<p>{{ event.starts_at }} · {{ event.tickets_sold }} / {{ event.capacity|default:"∞" }} seats · {{ event.price }} {{ event.currency|upper }}</p>
<table class="table">
<thead><tr><th>Order</th><th>Email</th><th>Qty</th><th>Status</th><th>Codes</th></tr></thead>
<tbody>
{% for order in orders %}
<tr>
<td>{{ order.number }}</td>
<td>{{ order.email }}</td>
<td>{{ order.quantity }}</td>
<td>{{ order.get_status_display }}</td>
<td>{% for ticket in order.tickets.all %}{{ ticket.code }}{% if not forloop.last %}, {% endif %}{% endfor %}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No ticket orders yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "portal_base.html" %}
{% block title %}{% if event %}Edit{% else %}New{% endif %} event · Portal{% endblock %}
{% block topbar_title %}{% if event %}Edit event{% else %}New event{% endif %}{% endblock %}
{% block portal_content %}
<form method="post" class="form-grid">
{% csrf_token %}
<div class="field"><label>Title</label><input name="title" required value="{{ event.title|default:'' }}"></div>
<div class="field"><label>Slug</label><input name="slug" value="{{ event.slug|default:'' }}" placeholder="auto from title"></div>
<div class="field"><label>Starts</label><input name="starts_at" type="datetime-local" required value="{% if event %}{{ event.starts_at|date:'Y-m-d\\TH:i' }}{% endif %}"></div>
<div class="field"><label>Venue</label><input name="venue" value="{{ event.venue|default:'' }}"></div>
<div class="field"><label>Capacity</label><input name="capacity" type="number" min="0" value="{{ event.capacity|default:0 }}"></div>
<div class="field"><label>Price</label><input name="price" type="number" step="0.01" min="0" required value="{{ event.price|default:'0' }}"></div>
<div class="field"><label>Description</label><textarea name="description" style="min-height:120px">{{ event.description|default:'' }}</textarea></div>
<label><input type="checkbox" name="is_published" {% if event.is_published %}checked{% endif %}> Published</label>
<button class="btn btn-primary" type="submit">Save</button>
</form>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "portal_base.html" %}
{% block title %}Events · Portal{% endblock %}
{% block topbar_title %}Events{% endblock %}
{% block portal_content %}
<p><a class="btn btn-primary" href="{% url 'events_portal:event_new' %}">New event</a></p>
<table class="table">
<thead><tr><th>Title</th><th>When</th><th>Capacity</th><th>Status</th></tr></thead>
<tbody>
{% for event in events %}
<tr>
<td><a href="{% url 'events_portal:event_detail' event.pk %}">{{ event.title }}</a></td>
<td>{{ event.starts_at }}</td>
<td>{{ event.tickets_sold }} / {{ event.capacity|default:"∞" }}</td>
<td>{% if event.is_published %}Published{% else %}Draft{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No events yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Tickets {{ order.number }}{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>You're in</h1>
<p>Order {{ order.number }} for {{ order.event.title }} is {{ order.get_status_display|lower }}.</p>
<p>Ticket codes will be emailed to {{ order.email }}.</p>
</div></section>
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from events.models import Event, Ticket, TicketOrder
from events.services import EventsError, create_ticket_order, mark_paid
def _event(**kwargs):
defaults = dict(
title="Friday Night Prerelease",
starts_at=timezone.now() + timezone.timedelta(days=7),
venue="Main shop",
capacity=8,
price=Decimal("25.00"),
is_published=True,
)
defaults.update(kwargs)
return Event.objects.create(**defaults)
class EventPublicTests(TestCase):
def test_list_hides_drafts_and_past(self):
_event(title="Live prerelease")
_event(title="Draft night", is_published=False)
_event(
title="Last week",
starts_at=timezone.now() - timezone.timedelta(days=2),
)
response = Client().get(reverse("events:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Live prerelease")
self.assertNotContains(response, "Draft night")
self.assertNotContains(response, "Last week")
def test_buy_form_renders(self):
event = _event()
response = Client().get(reverse("events:buy", kwargs={"slug": event.slug}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Pay with Stripe")
class EventCapacityTests(TestCase):
def test_capacity_blocks_overbook(self):
event = _event(capacity=2)
create_ticket_order(event, email="a@example.com", quantity=2)
with self.assertRaises(EventsError):
create_ticket_order(event, email="b@example.com", quantity=1)
def test_mark_paid_issues_tickets_and_email(self):
event = _event(capacity=4)
order = create_ticket_order(
event, email="fan@example.com", customer_name="Lee", quantity=2
)
mark_paid(order)
order.refresh_from_db()
self.assertEqual(order.status, TicketOrder.Status.PAID)
self.assertEqual(Ticket.objects.filter(order=order).count(), 2)
self.assertEqual(event.tickets_sold, 2)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(event.title, mail.outbox[0].subject)
class EventPortalTests(TestCase):
def setUp(self):
User = get_user_model()
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")
def test_list_requires_login(self):
self.assertEqual(Client().get(reverse("events_portal:event_list")).status_code, 302)
def test_create_published_event(self):
starts = (timezone.now() + timezone.timedelta(days=3)).strftime("%Y-%m-%dT%H:%M")
response = self.client.post(
reverse("events_portal:event_new"),
{
"title": "Commander night",
"starts_at": starts,
"venue": "Back room",
"capacity": "16",
"price": "10.00",
"is_published": "on",
},
)
self.assertEqual(response.status_code, 302)
event = Event.objects.get()
self.assertEqual(event.slug, "commander-night")
self.assertTrue(event.is_published)
self.assertEqual(event.capacity, 16)
+194
View File
@@ -0,0 +1,194 @@
import logging
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.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from datetime import datetime
from django.utils.text import slugify
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from events.models import Event, TicketOrder
from events.services import (
EventsError,
create_checkout_session,
create_ticket_order,
mark_paid,
)
logger = logging.getLogger(__name__)
def _site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
def event_list(request):
events = Event.objects.filter(is_published=True, starts_at__gte=timezone.now())
return render(request, "events/list.html", {"events": events})
def event_detail(request, slug):
event = get_object_or_404(Event, slug=slug, is_published=True)
return render(request, "events/detail.html", {"event": event})
@require_http_methods(["GET", "POST"])
def buy_tickets(request, slug):
event = get_object_or_404(Event, slug=slug, is_published=True)
if request.method == "POST":
email = (request.POST.get("email") or "").strip()
name = (request.POST.get("customer_name") or "").strip()
try:
quantity = int(request.POST.get("quantity") or "1")
except ValueError:
quantity = 0
try:
order = create_ticket_order(
event, email=email, customer_name=name, quantity=quantity
)
base = _site_base(request)
success = base + reverse(
"events:checkout_success", kwargs={"pk": order.pk}
)
cancel = base + reverse("events:checkout_cancel", kwargs={"pk": order.pk})
url = create_checkout_session(
order,
success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel,
)
except EventsError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("ticket checkout failed")
messages.error(request, f"Could not start checkout: {exc}")
else:
return redirect(url)
return render(request, "events/buy.html", {"event": event})
def checkout_success(request, pk):
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
return render(request, "events/success.html", {"order": order})
def checkout_cancel(request, pk):
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
return render(request, "events/cancel.html", {"order": order})
@login_required
def portal_event_list(request):
events = Event.objects.all().order_by("-starts_at")
return render(request, "events/portal/list.html", {"events": events})
@login_required
@require_http_methods(["GET", "POST"])
def portal_event_edit(request, pk=None):
event = get_object_or_404(Event, pk=pk) if pk else None
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
description = (request.POST.get("description") or "").strip()
venue = (request.POST.get("venue") or "").strip()
slug = (request.POST.get("slug") or "").strip()
starts_raw = (request.POST.get("starts_at") or "").strip()
errors = []
if not title:
errors.append("Title is required.")
starts_at = None
if starts_raw:
try:
starts_at = datetime.fromisoformat(starts_raw)
except ValueError:
starts_at = None
if starts_at is None:
errors.append("Start date/time is required.")
elif timezone.is_naive(starts_at):
starts_at = timezone.make_aware(starts_at, timezone.get_current_timezone())
try:
capacity = int(request.POST.get("capacity") or "0")
except ValueError:
capacity = 0
errors.append("Capacity must be a number.")
try:
price = Decimal(request.POST.get("price") or "0")
if price < 0:
raise InvalidOperation
except Exception:
price = None
errors.append("Enter a valid price.")
if errors:
for err in errors:
messages.error(request, err)
else:
if event is None:
event = Event()
event.title = title
event.description = description
event.venue = venue
event.slug = slugify(slug)[:220] if slug else ""
event.starts_at = starts_at
event.capacity = max(capacity, 0)
event.price = price
event.currency = (settings.STRIPE_CURRENCY or "usd").lower()
event.is_published = request.POST.get("is_published") == "on"
event.save()
messages.success(request, f"Saved {event.title}.")
return redirect("events_portal:event_list")
return render(request, "events/portal/edit.html", {"event": event})
@login_required
def portal_event_detail(request, pk):
event = get_object_or_404(Event, pk=pk)
orders = event.ticket_orders.select_related().prefetch_related("tickets")
return render(
request,
"events/portal/detail.html",
{"event": event, "orders": orders},
)
@csrf_exempt
@require_http_methods(["POST"])
def stripe_webhook(request):
secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip()
if not secret:
logger.error("STRIPE_WEBHOOK_SECRET unset")
return HttpResponseBadRequest("webhook not configured")
try:
import stripe
except ImportError:
return HttpResponseBadRequest("stripe not installed")
sig = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(request.body, sig, secret)
except Exception:
logger.exception("events stripe webhook signature failed")
return HttpResponseBadRequest("invalid signature")
obj = event.get("data", {}).get("object", {}) or {}
if event.get("type") != "checkout.session.completed":
return HttpResponse("ok")
order_id = (obj.get("metadata") or {}).get("ticket_order_id") or ""
order = None
if order_id:
order = TicketOrder.objects.filter(pk=order_id).first()
if order is None:
session_id = obj.get("id") or ""
order = TicketOrder.objects.filter(
stripe_checkout_session_id=session_id
).first()
if order and order.status != TicketOrder.Status.PAID:
mark_paid(order, stripe_id=obj.get("id") or "")
logger.info("ticket order %s marked paid", order.number)
return HttpResponse("ok")
+1 -1
View File
@@ -12,7 +12,7 @@ from payments.services import next_invoice_number
class PaymentsPortalTests(TestCase): class PaymentsPortalTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() 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 = Client()
self.client.login(username="biller", password="test-pass-123") self.client.login(username="biller", password="test-pass-123")
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
View File
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
from django.contrib import admin
from pos_sync.models import POSConnection, SyncEvent
@admin.register(POSConnection)
class POSConnectionAdmin(admin.ModelAdmin):
list_display = ("name", "provider", "is_active")
list_filter = ("provider", "is_active")
@admin.register(SyncEvent)
class SyncEventAdmin(admin.ModelAdmin):
list_display = ("direction", "sku", "quantity", "status", "created_at")
list_filter = ("direction", "status")
search_fields = ("sku", "external_id")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class PosSyncConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pos_sync"
verbose_name = "POS API sync"
def ready(self):
from pos_sync import hooks
hooks.register()
+35
View File
@@ -0,0 +1,35 @@
from core.registry import (
register_dashboard_collector,
register_dispatcher,
register_feature,
register_portal_nav,
)
def register() -> None:
register_feature("pos_sync")
register_portal_nav(
section="pos_sync",
label="POS sync",
url_name="pos_sync:event_list",
group="Retail",
order=40,
)
register_dashboard_collector(_dashboard)
register_dispatcher(_dispatch)
def _dashboard(request) -> dict:
from pos_sync.models import SyncEvent
return {
"pos_failed_syncs": SyncEvent.objects.filter(
status=SyncEvent.Status.FAILED
).count()
}
def _dispatch() -> int:
from pos_sync.services import dispatch_pending_outbound
return dispatch_pending_outbound()
+52
View File
@@ -0,0 +1,52 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='POSConnection',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=120)),
('provider', models.CharField(choices=[('generic', 'Generic REST'), ('square', 'Square'), ('lightspeed', 'Lightspeed'), ('binderpos', 'BinderPOS'), ('crystalcommerce', 'CrystalCommerce')], default='generic', max_length=32)),
('api_base_url', models.URLField(blank=True)),
('api_token', models.CharField(blank=True, max_length=255)),
('webhook_secret', models.CharField(blank=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='SyncEvent',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('direction', models.CharField(choices=[('inbound', 'POS → site'), ('outbound', 'Site → POS')], max_length=16)),
('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=16)),
('sku', models.CharField(max_length=64)),
('quantity', models.IntegerField(default=0)),
('external_id', models.CharField(blank=True, max_length=255)),
('payload', models.JSONField(blank=True, default=dict)),
('error', models.TextField(blank=True)),
('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='pos_sync.posconnection')),
],
options={
'ordering': ['-created_at'],
},
),
]
Whitespace-only changes.
+61
View File
@@ -0,0 +1,61 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class POSConnection(UUIDPrimaryKeyModel, TimeStampedModel):
class Provider(models.TextChoices):
GENERIC = "generic", "Generic REST"
SQUARE = "square", "Square"
LIGHTSPEED = "lightspeed", "Lightspeed"
BINDERPOS = "binderpos", "BinderPOS"
CRYSTALCOMMERCE = "crystalcommerce", "CrystalCommerce"
name = models.CharField(max_length=120)
provider = models.CharField(
max_length=32, choices=Provider.choices, default=Provider.GENERIC
)
api_base_url = models.URLField(blank=True)
api_token = models.CharField(max_length=255, blank=True)
webhook_secret = models.CharField(max_length=255, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class SyncEvent(UUIDPrimaryKeyModel, TimeStampedModel):
class Direction(models.TextChoices):
INBOUND = "inbound", "POS → site"
OUTBOUND = "outbound", "Site → POS"
class Status(models.TextChoices):
PENDING = "pending", "Pending"
DONE = "done", "Done"
FAILED = "failed", "Failed"
connection = models.ForeignKey(
POSConnection,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="events",
)
direction = models.CharField(max_length=16, choices=Direction.choices)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.PENDING
)
sku = models.CharField(max_length=64)
quantity = models.IntegerField(default=0)
external_id = models.CharField(max_length=255, blank=True)
payload = models.JSONField(default=dict, blank=True)
error = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.direction} {self.sku} × {self.quantity} · {self.status}"
+131
View File
@@ -0,0 +1,131 @@
"""POS inventory sync. Inbound webhooks decrement shop stock; outbound queues reserve POS."""
from __future__ import annotations
import logging
import requests
from django.conf import settings
from django.db import transaction
from pos_sync.models import POSConnection, SyncEvent
from shop.models import Product
from shop.services import ShopError, adjust_stock
logger = logging.getLogger(__name__)
class POSSyncError(RuntimeError):
pass
def active_connection() -> POSConnection | None:
return POSConnection.objects.filter(is_active=True).order_by("created_at").first()
def apply_inbound_sale(
*,
sku: str,
quantity: int,
external_id: str = "",
payload: dict | None = None,
connection: POSConnection | None = None,
) -> SyncEvent:
sku = (sku or "").strip()
if not sku:
raise POSSyncError("SKU is required.")
if quantity < 1:
raise POSSyncError("Quantity must be at least 1.")
product = Product.objects.filter(sku__iexact=sku).first()
if product is None:
raise POSSyncError(f"Unknown SKU {sku}.")
event = SyncEvent.objects.create(
connection=connection or active_connection(),
direction=SyncEvent.Direction.INBOUND,
status=SyncEvent.Status.PENDING,
sku=product.sku,
quantity=quantity,
external_id=external_id,
payload=payload or {},
)
try:
with transaction.atomic():
locked = Product.objects.select_for_update().get(pk=product.pk)
if locked.track_inventory and locked.fulfillment == Product.Fulfillment.STOCKED:
adjust_stock(locked, -quantity)
event.status = SyncEvent.Status.DONE
event.save(update_fields=["status", "updated_at"])
except ShopError as exc:
event.status = SyncEvent.Status.FAILED
event.error = str(exc)
event.save(update_fields=["status", "error", "updated_at"])
raise POSSyncError(str(exc)) from exc
return event
def enqueue_online_sale(order) -> list[SyncEvent]:
"""Queue outbound POS reserves after an online shop sale."""
events = []
connection = active_connection()
for item in order.items.all():
sku = item.sku
if not sku:
continue
events.append(
SyncEvent.objects.create(
connection=connection,
direction=SyncEvent.Direction.OUTBOUND,
status=SyncEvent.Status.PENDING,
sku=sku,
quantity=item.quantity,
external_id=order.number,
payload={"order_id": str(order.pk), "order_number": order.number},
)
)
return events
def _post_reserve(connection: POSConnection, event: SyncEvent) -> None:
base = (connection.api_base_url or settings.POS_API_BASE_URL or "").rstrip("/")
token = connection.api_token or (settings.POS_API_TOKEN or "")
if not base:
raise POSSyncError("POS API base URL is not configured.")
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
response = requests.post(
f"{base}/inventory/reserve",
json={
"sku": event.sku,
"quantity": event.quantity,
"external_id": event.external_id,
"source": "online",
},
headers=headers,
timeout=15,
)
response.raise_for_status()
def dispatch_pending_outbound() -> int:
sent = 0
for event in SyncEvent.objects.filter(
direction=SyncEvent.Direction.OUTBOUND,
status=SyncEvent.Status.PENDING,
)[:50]:
connection = event.connection or active_connection()
try:
if connection is None:
raise POSSyncError("No active POS connection.")
_post_reserve(connection, event)
except Exception as exc: # noqa: BLE001
logger.exception("POS outbound failed for %s", event.sku)
event.status = SyncEvent.Status.FAILED
event.error = str(exc)
event.save(update_fields=["status", "error", "updated_at"])
else:
event.status = SyncEvent.Status.DONE
event.error = ""
event.save(update_fields=["status", "error", "updated_at"])
sent += 1
return sent
@@ -0,0 +1,22 @@
{% extends "portal_base.html" %}
{% block title %}POS sync · Portal{% endblock %}
{% block topbar_title %}POS sync{% endblock %}
{% block portal_content %}
<p class="muted">Inbound POS sales decrement shop stock. Online sales queue outbound reserves.</p>
<table class="table">
<thead><tr><th>When</th><th>Direction</th><th>SKU</th><th>Qty</th><th>Status</th></tr></thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.created_at }}</td>
<td>{{ event.get_direction_display }}</td>
<td>{{ event.sku }}</td>
<td>{{ event.quantity }}</td>
<td>{{ event.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No sync events yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from pos_sync.models import POSConnection, SyncEvent
from pos_sync.services import apply_inbound_sale, dispatch_pending_outbound, enqueue_online_sale
from shop.models import Product
from shop.services import add_to_cart, create_order_from_cart, mark_paid
class POSInboundTests(TestCase):
def setUp(self):
self.product = Product.objects.create(
name="Booster pack",
sku="TCG-PACK",
price=Decimal("4.99"),
stock_qty=10,
is_published=True,
)
def test_inbound_sale_decrements_stock(self):
event = apply_inbound_sale(sku="tcg-pack", quantity=3, external_id="pos-1")
self.product.refresh_from_db()
self.assertEqual(self.product.stock_qty, 7)
self.assertEqual(event.status, SyncEvent.Status.DONE)
@override_settings(POS_WEBHOOK_SECRET="pos-secret")
def test_webhook_requires_secret_and_updates_stock(self):
url = reverse("pos_sync:inventory_webhook")
anon = Client().post(
url,
data='{"sku":"TCG-PACK","quantity":1}',
content_type="application/json",
)
self.assertEqual(anon.status_code, 401)
ok = Client().post(
url,
data='{"sku":"TCG-PACK","quantity":2,"external_id":"reg-9"}',
content_type="application/json",
HTTP_AUTHORIZATION="Bearer pos-secret",
)
self.assertEqual(ok.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.stock_qty, 8)
class POSOutboundTests(TestCase):
def setUp(self):
self.product = Product.objects.create(
name="Figure",
sku="FIG-1",
price=Decimal("12.00"),
stock_qty=5,
is_published=True,
)
self.connection = POSConnection.objects.create(
name="Counter",
api_base_url="https://pos.example.com",
api_token="tok",
is_active=True,
)
def test_paid_order_enqueues_outbound(self):
session = self.client.session
add_to_cart(session, self.product, 1)
session.save()
order = create_order_from_cart(self.client.session, email="a@example.com")
mark_paid(order)
event = SyncEvent.objects.get(direction=SyncEvent.Direction.OUTBOUND)
self.assertEqual(event.sku, "FIG-1")
self.assertEqual(event.status, SyncEvent.Status.PENDING)
def test_dispatch_posts_reserve(self):
enqueue_online_sale(
type(
"O",
(),
{
"number": "ORD-1",
"pk": "x",
"items": type(
"M",
(),
{
"all": lambda self: [
type("I", (), {"sku": "FIG-1", "quantity": 1})()
]
},
)(),
},
)()
)
with patch("pos_sync.services.requests.post") as post:
post.return_value.raise_for_status = lambda: None
sent = dispatch_pending_outbound()
self.assertEqual(sent, 1)
post.assert_called_once()
self.assertIn("/inventory/reserve", post.call_args.args[0])
event = SyncEvent.objects.get()
self.assertEqual(event.status, SyncEvent.Status.DONE)
class POSPortalTests(TestCase):
def test_list_requires_login(self):
self.assertEqual(Client().get(reverse("pos_sync:event_list")).status_code, 302)
def test_list_ok_when_logged_in(self):
User = get_user_model()
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)
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from pos_sync import views
app_name = "pos_sync"
urlpatterns = [
path("", views.event_list, name="event_list"),
path("webhooks/inventory/", views.inventory_webhook, name="inventory_webhook"),
]
+70
View File
@@ -0,0 +1,70 @@
import json
import logging
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from pos_sync.models import POSConnection, SyncEvent
from pos_sync.services import POSSyncError, apply_inbound_sale
logger = logging.getLogger(__name__)
def _authorized(request) -> bool:
expected = (settings.POS_WEBHOOK_SECRET or "").strip()
if not expected:
conn = POSConnection.objects.filter(is_active=True).exclude(
webhook_secret=""
).first()
expected = (conn.webhook_secret if conn else "").strip()
if not expected:
return False
header = request.headers.get("Authorization", "")
token = ""
if header.startswith("Bearer "):
token = header[7:].strip()
token = token or request.GET.get("token") or ""
return token == expected
@login_required
def event_list(request):
events = SyncEvent.objects.select_related("connection")[:200]
connections = POSConnection.objects.all()
return render(
request,
"pos_sync/list.html",
{"events": events, "connections": connections},
)
@csrf_exempt
@require_http_methods(["POST"])
def inventory_webhook(request):
if not _authorized(request):
return HttpResponse("unauthorized", status=401)
try:
payload = json.loads(request.body.decode() or "{}")
except json.JSONDecodeError:
return HttpResponseBadRequest("invalid json")
sku = payload.get("sku") or payload.get("SKU") or ""
try:
quantity = int(payload.get("quantity") or payload.get("qty") or 0)
except (TypeError, ValueError):
return HttpResponseBadRequest("invalid quantity")
external_id = str(payload.get("external_id") or payload.get("id") or "")
try:
event = apply_inbound_sale(
sku=sku,
quantity=quantity,
external_id=external_id,
payload=payload,
)
except POSSyncError as exc:
logger.warning("POS inbound rejected: %s", exc)
return JsonResponse({"ok": False, "error": str(exc)}, status=400)
return JsonResponse({"ok": True, "id": str(event.pk), "status": event.status})
+9
View File
@@ -63,6 +63,15 @@ def tianji_tracking(request):
nav_section = "social_accounts" nav_section = "social_accounts"
elif namespace == "social": elif namespace == "social":
nav_section = "social" nav_section = "social"
elif namespace == "shop_portal":
if url_name.startswith("product"):
nav_section = "shop_products"
elif url_name.startswith("order"):
nav_section = "shop_orders"
elif url_name == "sales":
nav_section = "shop_sales"
elif namespace == "shop":
nav_section = "shop"
return { return {
"tianji_enabled": getattr(settings, "TIANJI_ENABLED", False) "tianji_enabled": getattr(settings, "TIANJI_ENABLED", False)
-79
View File
@@ -10,88 +10,9 @@ class ContactForm(forms.Form):
("other", "Something else"), ("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( email = forms.EmailField(
widget=forms.EmailInput(attrs={"class": "form-input", "id": "contact-email"}), 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( interest = forms.ChoiceField(
choices=INTEREST_CHOICES, choices=INTEREST_CHOICES,
widget=forms.Select(attrs={"class": "form-input", "id": "contact-interest"}), widget=forms.Select(attrs={"class": "form-input", "id": "contact-interest"}),
+4
View File
@@ -17,6 +17,10 @@ class UnderConstructionMiddleware:
"/admin/", "/admin/",
"/accounts/login", "/accounts/login",
"/accounts/logout", "/accounts/logout",
"/account/login",
"/account/logout",
"/account/register",
"/account/password-reset",
"/under-construction", "/under-construction",
"/portal/messaging/webhooks/", "/portal/messaging/webhooks/",
"/unsubscribe/", "/unsubscribe/",
+1 -22
View File
@@ -26,8 +26,6 @@ def notify_admins_of_contact_form(lead: Lead) -> bool:
return False return False
contact = lead.contact contact = lead.contact
name = contact.full_name or "(no name)"
phone = contact.phone or "(none)"
email = contact.email or "(none)" email = contact.email or "(none)"
message = (lead.message or "").strip() or "(no message)" 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_path = reverse("leads:detail", kwargs={"pk": lead.pk})
portal_url = f"{site}{portal_path}" if site else portal_path 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( ctx = email_brand_context(
name=name,
email=email, email=email,
phone=phone,
address=address,
message=message, message=message,
portal_url=portal_url, 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) html_content = get_template("emails/contact_email.html").render(ctx)
mail = EmailMultiAlternatives( mail = EmailMultiAlternatives(
subject=f"New contact form inquiry from {name}", subject=f"New contact form inquiry from {email}",
body=text_content, body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email], to=[to_email],
@@ -6,20 +6,11 @@
<p style="margin:0 0 16px;color:#212121;">Hello,</p> <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 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-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;"> <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> <a href="mailto:{{ email }}" style="color:#00626c;text-decoration:none;">{{ email }}</a>
</p> </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-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> <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 }} Email: {{ email }}
Phone: {{ phone }}
Address:
{{ address }}
Message: Message:
{{ message }} {{ message }}
@@ -12,6 +8,6 @@ Message:
{% if portal_url %}View in portal: {{ portal_url }} {% if portal_url %}View in portal: {{ portal_url }}
{% endif %} {% endif %}
— —
{{ brand_name|default:"Monica Dhillon" }} {{ brand_name|default:"Your Company" }}
{{ site_url|default:"https://mkdrealtor.com" }} {{ site_url|default:"" }}
{% if brand_tagline %}{{ brand_tagline }}{% endif %} {% 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' %}"> <form class="rd-form contact-message-form" method="post" action="{% url 'public:contact' %}">
{% csrf_token %} {% csrf_token %}
<div class="row row-10"> <div class="row row-10">
<div class="col-md-6"> <div class="col-12">
<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="form-wrap"> <div class="form-wrap">
<label class="form-label-outside" for="{{ form.email.id_for_label }}">Email</label> <label class="form-label-outside" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }} {{ form.email }}
{{ form.email.errors }} {{ form.email.errors }}
</div> </div>
</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="col-12">
<div class="form-wrap"> <div class="form-wrap">
<label class="form-label-outside" for="{{ form.interest.id_for_label }}">I am interested in</label> <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> <div class="col-12">{{ form.captcha }}{{ form.captcha.errors }}</div>
{% endif %} {% endif %}
</div> </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> <button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button>
</form> </form>
</div> </div>
@@ -181,9 +120,6 @@
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block tracking_events %} {% block tracking_events %}
{% if "sent" in request.GET %} {% if "sent" in request.GET %}
<script> <script>
@@ -193,6 +129,3 @@
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
+7 -26
View File
@@ -49,6 +49,7 @@ def robots_txt(request):
"Allow: /", "Allow: /",
"Disallow: /portal/", "Disallow: /portal/",
"Disallow: /accounts/", "Disallow: /accounts/",
"Disallow: /account/",
"Disallow: /admin/", "Disallow: /admin/",
"Disallow: /api/", "Disallow: /api/",
f"Sitemap: {site}/sitemap.xml", f"Sitemap: {site}/sitemap.xml",
@@ -70,6 +71,10 @@ def sitemap_xml(request):
from django.apps import apps as django_apps from django.apps import apps as django_apps
if django_apps.is_installed("blog"): if django_apps.is_installed("blog"):
paths.append(("blog:list", "0.7", "weekly")) paths.append(("blog:list", "0.7", "weekly"))
if django_apps.is_installed("shop"):
paths.append(("shop:list", "0.8", "weekly"))
if django_apps.is_installed("events"):
paths.append(("events:list", "0.8", "weekly"))
urls = [] urls = []
for name, priority, changefreq in paths: for name, priority, changefreq in paths:
path = reverse(name) path = reverse(name)
@@ -95,48 +100,24 @@ def contact(request):
form = ContactForm(request.POST) form = ContactForm(request.POST)
if form.is_valid(): if form.is_valid():
data = form.cleaned_data 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() submitted_email = data["email"].lower()
contact_obj, _created, match_reason = upsert_contact( contact_obj, _created, match_reason = upsert_contact(
email=submitted_email, 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, source=Contact.Source.CONTACT_FORM,
merge_phone_address=False,
) )
ConsentRecord.objects.update_or_create( ConsentRecord.objects.update_or_create(
contact=contact_obj, contact=contact_obj,
channel=Channel.EMAIL, channel=Channel.EMAIL,
defaults={"opted_in": True, "reason": "contact_form"}, 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 = data.get("interest") or ""
interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest) interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest)
body = data.get("message") or "" body = data.get("message") or ""
if interest_label: if interest_label:
body = f"Interest: {interest_label}\n\n{body}".strip() body = f"Interest: {interest_label}\n\n{body}".strip()
if ( if (
match_reason in {"phone", "address"} match_reason
and (contact_obj.email or "").lower() != submitted_email and (contact_obj.email or "").lower() != submitted_email
): ):
body = ( body = (
View File
Whitespace-only changes.
+10
View File
@@ -0,0 +1,10 @@
from django.contrib import admin
from shipping.models import Shipment
@admin.register(Shipment)
class ShipmentAdmin(admin.ModelAdmin):
list_display = ("order", "status", "carrier", "tracking_number")
list_filter = ("status", "carrier")
search_fields = ("tracking_number", "order__number")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class ShippingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shipping"
verbose_name = "Shipping automation"
def ready(self):
from shipping import hooks
hooks.register()
+39
View File
@@ -0,0 +1,39 @@
from core.registry import (
register_dashboard_collector,
register_dispatcher,
register_feature,
register_portal_nav,
)
def register() -> None:
register_feature("shipping")
register_portal_nav(
section="shipping",
label="Shipping",
url_name="shipping:shipment_list",
group="Retail",
order=50,
)
register_dashboard_collector(_dashboard)
register_dispatcher(_sync_tracking)
def _dashboard(request) -> dict:
from shipping.models import Shipment
from shop.models import Order
labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list(
"order_id", flat=True
)
return {
"unshipped_orders": Order.objects.filter(status=Order.Status.PAID)
.exclude(pk__in=labeled)
.count()
}
def _sync_tracking() -> int:
from shipping.services import sync_open_tracking
return sync_open_tracking()
+40
View File
@@ -0,0 +1,40 @@
# Generated by Django 6.1 on 2026-09-06 11:17
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('shop', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Shipment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('draft', 'Draft'), ('rated', 'Rated'), ('labeled', 'Labeled'), ('void', 'Void')], default='draft', max_length=16)),
('carrier', models.CharField(blank=True, max_length=32)),
('service', models.CharField(blank=True, max_length=64)),
('tracking_number', models.CharField(blank=True, max_length=64)),
('label_url', models.URLField(blank=True)),
('rate_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('currency', models.CharField(default='usd', max_length=8)),
('provider_shipment_id', models.CharField(blank=True, max_length=255)),
('rates', models.JSONField(blank=True, default=list)),
('weight_oz', models.PositiveIntegerField(default=16)),
('notes', models.TextField(blank=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shipments', to='shop.order')),
],
options={
'ordering': ['-created_at'],
},
),
]
@@ -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),
),
]
Whitespace-only changes.
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more