Author SHA1 Message Date
westfarn 5b11cc18c7 Add shopper accounts, reviews, tracking, and seed_demo (#9)
Closes #9. Shop-gated buyer accounts, purchase reviews, Stripe customer ids, shipment tracking, slim public contact form, and a template-neutral seed_demo command.
2026-09-07 08:35:55 -05: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
123 changed files with 6928 additions and 255 deletions
+20
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=
@@ -74,6 +78,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=
+20
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
@@ -63,6 +67,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
+4
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:-}
+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")
+36
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",
@@ -352,6 +373,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 {
+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">
+2 -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(
+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
+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
+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
+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'],
},
),
]
+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
+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),
),
]
+55
View File
@@ -0,0 +1,55 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
from shop.models import Order
class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
RATED = "rated", "Rated"
LABELED = "labeled", "Labeled"
VOID = "void", "Void"
class TrackingStatus(models.TextChoices):
UNKNOWN = "unknown", "Unknown"
PRE_TRANSIT = "pre_transit", "Pre-transit"
IN_TRANSIT = "in_transit", "In transit"
OUT_FOR_DELIVERY = "out_for_delivery", "Out for delivery"
DELIVERED = "delivered", "Delivered"
AVAILABLE_FOR_PICKUP = "available_for_pickup", "Available for pickup"
RETURN_TO_SENDER = "return_to_sender", "Return to sender"
FAILURE = "failure", "Exception"
CANCELLED = "cancelled", "Cancelled"
ERROR = "error", "Error"
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments")
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
carrier = models.CharField(max_length=32, blank=True)
service = models.CharField(max_length=64, blank=True)
tracking_number = models.CharField(max_length=64, blank=True)
tracking_status = models.CharField(
max_length=32,
choices=TrackingStatus.choices,
default=TrackingStatus.UNKNOWN,
blank=True,
)
tracking_url = models.URLField(blank=True)
tracker_id = models.CharField(max_length=255, blank=True)
tracking_events = models.JSONField(default=list, blank=True)
last_tracked_at = models.DateTimeField(null=True, blank=True)
label_url = models.URLField(blank=True)
rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
currency = models.CharField(max_length=8, default="usd")
provider_shipment_id = models.CharField(max_length=255, blank=True)
rates = models.JSONField(default=list, blank=True)
weight_oz = models.PositiveIntegerField(default=16)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.order.number} · {self.tracking_number or self.status}"
+357
View File
@@ -0,0 +1,357 @@
"""EasyPost-style rates/labels plus Pirate Ship CSV export."""
from __future__ import annotations
import csv
import io
import logging
from decimal import Decimal
from urllib.parse import quote
import requests
from django.conf import settings
from django.utils import timezone
from shipping.models import Shipment
from shop.models import Order
logger = logging.getLogger(__name__)
class ShippingError(RuntimeError):
pass
def _easypost_key() -> str:
return (getattr(settings, "EASYPOST_API_KEY", "") or "").strip()
def quote_rates(shipment: Shipment) -> list[dict]:
"""Return carrier rates. Uses EasyPost when configured; otherwise a USPS stub."""
key = _easypost_key()
if not key:
rates = [
{
"id": "stub-usps-ground",
"carrier": "USPS",
"service": "GroundAdvantage",
"rate": "5.40",
"currency": "USD",
},
{
"id": "stub-usps-priority",
"carrier": "USPS",
"service": "Priority",
"rate": "9.80",
"currency": "USD",
},
]
shipment.rates = rates
shipment.status = Shipment.Status.RATED
shipment.save(update_fields=["rates", "status", "updated_at"])
return rates
addr = shipment.order.shipping_address or {}
response = requests.post(
"https://api.easypost.com/v2/shipments",
auth=(key, ""),
json={
"shipment": {
"to_address": {
"name": shipment.order.customer_name or shipment.order.email,
"street1": addr.get("line1") or "",
"street2": addr.get("line2") or "",
"city": addr.get("city") or "",
"state": addr.get("state") or "",
"zip": addr.get("zip") or "",
"country": addr.get("country") or "US",
},
"from_address": {
"name": settings.SITE_NAME,
"street1": getattr(settings, "SHIP_FROM_LINE1", "") or "",
"city": getattr(settings, "SHIP_FROM_CITY", "") or "",
"state": getattr(settings, "SHIP_FROM_STATE", "") or "",
"zip": getattr(settings, "SHIP_FROM_ZIP", "") or "",
"country": "US",
},
"parcel": {"weight": shipment.weight_oz},
}
},
timeout=20,
)
response.raise_for_status()
data = response.json()
rates = data.get("rates") or []
shipment.provider_shipment_id = data.get("id") or ""
shipment.rates = rates
shipment.status = Shipment.Status.RATED
shipment.save(
update_fields=["provider_shipment_id", "rates", "status", "updated_at"]
)
return rates
def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment:
rates = shipment.rates or quote_rates(shipment)
chosen = None
if rate_id:
chosen = next((r for r in rates if str(r.get("id")) == str(rate_id)), None)
if chosen is None and rates:
chosen = rates[0]
if chosen is None:
raise ShippingError("No shipping rates available.")
key = _easypost_key()
if key and shipment.provider_shipment_id:
response = requests.post(
f"https://api.easypost.com/v2/shipments/{shipment.provider_shipment_id}/buy",
auth=(key, ""),
json={"rate": {"id": chosen.get("id")}},
timeout=20,
)
response.raise_for_status()
data = response.json()
postage = data.get("postage_label") or {}
tracking = data.get("tracking_code") or ""
shipment.label_url = postage.get("label_url") or ""
shipment.tracking_number = tracking
else:
shipment.tracking_number = f"STUB{shipment.order.number[-6:]}"
shipment.label_url = ""
shipment.carrier = chosen.get("carrier") or ""
shipment.service = chosen.get("service") or ""
try:
shipment.rate_amount = Decimal(str(chosen.get("rate") or "0"))
except Exception:
shipment.rate_amount = None
shipment.status = Shipment.Status.LABELED
shipment.save(
update_fields=[
"carrier",
"service",
"tracking_number",
"label_url",
"rate_amount",
"status",
"updated_at",
]
)
try:
refresh_tracking(shipment)
except Exception:
logger.exception("tracking refresh failed after label buy for %s", shipment.pk)
return shipment
def create_shipment_for_order(order: Order, *, weight_oz: int = 16) -> Shipment:
if order.status not in {Order.Status.PAID, Order.Status.FULFILLED}:
raise ShippingError("Ship paid orders only.")
return Shipment.objects.create(order=order, weight_oz=weight_oz)
def pirate_ship_csv(orders=None) -> str:
"""CSV Pirate Ship can map: name, address, city, state, zip, email."""
if orders is None:
shipped = Shipment.objects.filter(
status=Shipment.Status.LABELED
).values_list("order_id", flat=True)
orders = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=shipped)
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"Order Number",
"Name",
"Address 1",
"Address 2",
"City",
"State",
"Zip",
"Country",
"Email",
]
)
for order in orders:
addr = order.shipping_address or {}
writer.writerow(
[
order.number,
order.customer_name or order.email,
addr.get("line1") or "",
addr.get("line2") or "",
addr.get("city") or "",
addr.get("state") or "",
addr.get("zip") or "",
addr.get("country") or "US",
order.email,
]
)
return buffer.getvalue()
def public_tracking_url(carrier: str, tracking_number: str) -> str:
code = quote((tracking_number or "").strip())
if not code:
return ""
name = (carrier or "").upper()
if "USPS" in name:
return f"https://tools.usps.com/go/TrackConfirmAction?tLabels={code}"
if "UPS" in name:
return f"https://www.ups.com/track?tracknum={code}"
if "FEDEX" in name or "FDX" in name:
return f"https://www.fedex.com/fedextrack/?trknbr={code}"
if "DHL" in name:
return f"https://www.dhl.com/en/express/tracking.html?AWB={code}"
return f"https://www.google.com/search?q={quote((tracking_number or '') + ' tracking')}"
def _normalize_tracking_status(raw: str) -> str:
value = (raw or "").strip().lower().replace(" ", "_")
aliases = {
"pretransit": Shipment.TrackingStatus.PRE_TRANSIT,
"pre_transit": Shipment.TrackingStatus.PRE_TRANSIT,
"in_transit": Shipment.TrackingStatus.IN_TRANSIT,
"out_for_delivery": Shipment.TrackingStatus.OUT_FOR_DELIVERY,
"delivered": Shipment.TrackingStatus.DELIVERED,
"available_for_pickup": Shipment.TrackingStatus.AVAILABLE_FOR_PICKUP,
"return_to_sender": Shipment.TrackingStatus.RETURN_TO_SENDER,
"failure": Shipment.TrackingStatus.FAILURE,
"cancelled": Shipment.TrackingStatus.CANCELLED,
"canceled": Shipment.TrackingStatus.CANCELLED,
"error": Shipment.TrackingStatus.ERROR,
"unknown": Shipment.TrackingStatus.UNKNOWN,
}
return aliases.get(value, Shipment.TrackingStatus.UNKNOWN)
def apply_tracker_payload(shipment: Shipment, payload: dict) -> Shipment:
"""Apply EasyPost tracker (or compatible) JSON onto a shipment."""
data = payload or {}
tracking = (data.get("tracking_code") or data.get("tracking_number") or "").strip()
if tracking:
shipment.tracking_number = tracking
tracker_id = (data.get("id") or "").strip()
if tracker_id.startswith("trk_"):
shipment.tracker_id = tracker_id
carrier = (data.get("carrier") or "").strip()
if carrier and not shipment.carrier:
shipment.carrier = carrier
shipment.tracking_status = _normalize_tracking_status(data.get("status") or "")
public_url = (data.get("public_url") or "").strip()
shipment.tracking_url = public_url or public_tracking_url(
shipment.carrier, shipment.tracking_number
)
events = []
for item in data.get("tracking_details") or []:
loc = item.get("tracking_location") or {}
place = " ".join(
part
for part in [loc.get("city") or "", loc.get("state") or ""]
if part
).strip()
events.append(
{
"status": item.get("status") or "",
"message": item.get("message") or "",
"datetime": item.get("datetime") or "",
"location": place,
}
)
if events:
shipment.tracking_events = events
shipment.last_tracked_at = timezone.now()
shipment.save(
update_fields=[
"tracking_number",
"tracker_id",
"carrier",
"tracking_status",
"tracking_url",
"tracking_events",
"last_tracked_at",
"updated_at",
]
)
return shipment
def refresh_tracking(shipment: Shipment) -> Shipment:
"""Pull latest scan events from EasyPost, or stub status without an API key.
Works for EasyPost-bought labels and for tracking numbers pasted from
Pirate Ship / the carrier — EasyPost's tracker API looks up
USPS, UPS, FedEx, and DHL by number.
"""
tracking = (shipment.tracking_number or "").strip()
if not tracking:
return shipment
key = _easypost_key()
if not key:
if not shipment.tracking_status or shipment.tracking_status == Shipment.TrackingStatus.UNKNOWN:
shipment.tracking_status = Shipment.TrackingStatus.PRE_TRANSIT
shipment.tracking_url = shipment.tracking_url or public_tracking_url(
shipment.carrier, tracking
)
shipment.last_tracked_at = timezone.now()
shipment.save(
update_fields=[
"tracking_status",
"tracking_url",
"last_tracked_at",
"updated_at",
]
)
return shipment
payload = {"tracker": {"tracking_code": tracking}}
if shipment.carrier:
payload["tracker"]["carrier"] = shipment.carrier
response = requests.post(
"https://api.easypost.com/v2/trackers",
auth=(key, ""),
json=payload,
timeout=20,
)
response.raise_for_status()
return apply_tracker_payload(shipment, response.json())
def attach_tracking(
shipment: Shipment, *, tracking_number: str, carrier: str = ""
) -> Shipment:
tracking_number = (tracking_number or "").strip()
if not tracking_number:
raise ShippingError("Tracking number is required.")
shipment.tracking_number = tracking_number
if carrier:
shipment.carrier = carrier.strip()
if shipment.status != Shipment.Status.LABELED:
shipment.status = Shipment.Status.LABELED
shipment.save(
update_fields=["tracking_number", "carrier", "status", "updated_at"]
)
return refresh_tracking(shipment)
def sync_open_tracking() -> int:
"""Refresh labeled, not-yet-delivered shipments (dispatch_due)."""
done = 0
qs = (
Shipment.objects.filter(status=Shipment.Status.LABELED)
.exclude(tracking_number="")
.exclude(
tracking_status__in=[
Shipment.TrackingStatus.DELIVERED,
Shipment.TrackingStatus.CANCELLED,
]
)[:50]
)
for shipment in qs:
try:
refresh_tracking(shipment)
done += 1
except Exception:
logger.exception("tracking sync failed for %s", shipment.pk)
return done
@@ -0,0 +1,69 @@
{% extends "portal_base.html" %}
{% block title %}{{ shipment.order.number }} · Shipping{% endblock %}
{% block topbar_title %}{{ shipment.order.number }}{% endblock %}
{% block portal_content %}
<p>{{ shipment.order.email }} · {{ shipment.get_status_display }}</p>
{% if shipment.tracking_number %}
<p>
Tracking: {{ shipment.tracking_number }}
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
</p>
{% if shipment.tracking_url %}<p><a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a></p>{% endif %}
<form method="post" action="{% url 'shipping:shipment_refresh_tracking' shipment.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-sm" type="submit">Refresh tracking</button>
</form>
{% endif %}
{% if shipment.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
<h3>Add tracking</h3>
<p class="hint-block">Paste a number from Pirate Ship or the carrier. EasyPost looks up scan events when an API key is set.</p>
<form method="post" action="{% url 'shipping:shipment_attach_tracking' shipment.pk %}">
{% csrf_token %}
<div class="form-grid">
<div class="field">
<label for="id_tracking_number">Tracking number</label>
<input id="id_tracking_number" name="tracking_number" value="{{ shipment.tracking_number }}" required>
</div>
<div class="field">
<label for="id_carrier">Carrier</label>
<input id="id_carrier" name="carrier" value="{{ shipment.carrier }}" placeholder="USPS, UPS, FedEx">
</div>
</div>
<button class="btn btn-primary" type="submit">Save tracking</button>
</form>
{% if shipment.tracking_events %}
<h3>Scan history</h3>
<table class="table">
<thead><tr><th>When</th><th>Status</th><th>Detail</th></tr></thead>
<tbody>
{% for event in shipment.tracking_events %}
<tr>
<td>{{ event.datetime }}</td>
<td>{{ event.status }}</td>
<td>{{ event.message }}{% if event.location %} · {{ event.location }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if shipment.status != 'labeled' %}
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
{% csrf_token %}
{% for rate in shipment.rates %}
<label>
<input type="radio" name="rate_id" value="{{ rate.id }}" {% if forloop.first %}checked{% endif %}>
{{ rate.carrier }} {{ rate.service }} — {{ rate.rate }} {{ rate.currency }}
</label><br>
{% empty %}
<p>No rates yet.</p>
{% endfor %}
{% if shipment.rates %}
<button class="btn btn-primary" type="submit">Buy label</button>
{% endif %}
</form>
{% endif %}
<p><a href="{% url 'shipping:shipment_list' %}">← All shipments</a></p>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "portal_base.html" %}
{% block title %}Shipping · Portal{% endblock %}
{% block topbar_title %}Shipping{% endblock %}
{% block portal_content %}
<p><a class="btn btn-ghost" href="{% url 'shipping:pirate_ship_export' %}">Pirate Ship CSV</a></p>
<h2>Unshipped paid orders</h2>
<table class="table">
<thead><tr><th>Order</th><th>Email</th><th></th></tr></thead>
<tbody>
{% for order in unshipped %}
<tr>
<td>{{ order.number }}</td>
<td>{{ order.email }}</td>
<td>
<form method="post" action="{% url 'shipping:shipment_create' %}">
{% csrf_token %}
<input type="hidden" name="order" value="{{ order.pk }}">
<input name="weight_oz" type="number" min="1" value="16" style="width:5rem"> oz
<button class="btn btn-primary" type="submit">Quote rates</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No paid orders waiting to ship.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Shipments</h2>
<table class="table">
<thead><tr><th>Order</th><th>Status</th><th>Tracking</th></tr></thead>
<tbody>
{% for shipment in shipments %}
<tr>
<td><a href="{% url 'shipping:shipment_detail' shipment.pk %}">{{ shipment.order.number }}</a></td>
<td>{{ shipment.get_status_display }}</td>
<td>{{ shipment.tracking_number|default:"—" }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No shipments yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+139
View File
@@ -0,0 +1,139 @@
from decimal import Decimal
import json
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from shipping.models import Shipment
from shipping.services import buy_label, create_shipment_for_order, pirate_ship_csv, quote_rates
from shop.models import Order
class ShippingServiceTests(TestCase):
def setUp(self):
self.order = Order.objects.create(
number="ORD-20260101-001",
email="buyer@example.com",
customer_name="Pat Lee",
status=Order.Status.PAID,
amount=Decimal("18.00"),
shipping_address={
"line1": "123 Main",
"city": "Aurora",
"state": "IL",
"zip": "60506",
"country": "US",
},
)
def test_quote_and_buy_stub_label(self):
shipment = create_shipment_for_order(self.order)
rates = quote_rates(shipment)
self.assertGreaterEqual(len(rates), 1)
shipment.refresh_from_db()
self.assertEqual(shipment.status, Shipment.Status.RATED)
buy_label(shipment, rate_id=rates[0]["id"])
shipment.refresh_from_db()
self.assertEqual(shipment.status, Shipment.Status.LABELED)
self.assertTrue(shipment.tracking_number)
def test_pirate_ship_csv_includes_unshipped(self):
csv_body = pirate_ship_csv()
self.assertIn("ORD-20260101-001", csv_body)
self.assertIn("123 Main", csv_body)
self.assertIn("buyer@example.com", csv_body)
def test_csv_omits_labeled_orders(self):
shipment = create_shipment_for_order(self.order)
quote_rates(shipment)
buy_label(shipment)
self.assertNotIn(self.order.number, pirate_ship_csv())
def test_stub_buy_sets_pre_transit_tracking(self):
shipment = create_shipment_for_order(self.order)
quote_rates(shipment)
buy_label(shipment)
shipment.refresh_from_db()
self.assertTrue(shipment.tracking_number)
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
self.assertTrue(shipment.tracking_url)
def test_attach_tracking_from_pirate_ship(self):
from shipping.services import attach_tracking
shipment = create_shipment_for_order(self.order)
attach_tracking(shipment, tracking_number="9400111899223197428490", carrier="USPS")
shipment.refresh_from_db()
self.assertEqual(shipment.status, Shipment.Status.LABELED)
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
self.assertIn("usps.com", shipment.tracking_url.lower())
def test_easypost_webhook_updates_status(self):
shipment = create_shipment_for_order(self.order)
shipment.tracking_number = "EZ1000000001"
shipment.status = Shipment.Status.LABELED
shipment.save()
payload = {
"description": "tracker.updated",
"result": {
"id": "trk_test",
"tracking_code": "EZ1000000001",
"status": "in_transit",
"public_url": "https://track.easypost.com/djE0",
"tracking_details": [
{
"status": "in_transit",
"message": "Departed facility",
"datetime": "2026-09-07T12:00:00Z",
"tracking_location": {"city": "Chicago", "state": "IL"},
}
],
},
}
response = Client().post(
reverse("shipping:easypost_webhook"),
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
shipment.refresh_from_db()
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.IN_TRANSIT)
self.assertEqual(shipment.tracker_id, "trk_test")
self.assertEqual(shipment.tracking_events[0]["location"], "Chicago IL")
class ShippingPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
"shipper", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="shipper", password="test-pass-123")
self.order = Order.objects.create(
number="ORD-20260101-002",
email="a@example.com",
status=Order.Status.PAID,
amount=Decimal("9.00"),
shipping_address={"line1": "9 Oak", "city": "Town", "state": "IL", "zip": "60189"},
)
def test_list_requires_login(self):
self.assertEqual(Client().get(reverse("shipping:shipment_list")).status_code, 302)
def test_create_shipment_from_portal(self):
response = self.client.post(
reverse("shipping:shipment_create"),
{"order": str(self.order.pk), "weight_oz": "8"},
)
self.assertEqual(response.status_code, 302)
shipment = Shipment.objects.get()
self.assertEqual(shipment.weight_oz, 8)
self.assertEqual(shipment.status, Shipment.Status.RATED)
def test_csv_export(self):
response = self.client.get(reverse("shipping:pirate_ship_export"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/csv")
self.assertIn(b"ORD-20260101-002", response.content)
+24
View File
@@ -0,0 +1,24 @@
from django.urls import path
from shipping import views
app_name = "shipping"
urlpatterns = [
path("", views.shipment_list, name="shipment_list"),
path("new/", views.shipment_create, name="shipment_create"),
path("export/pirate-ship.csv", views.pirate_ship_export, name="pirate_ship_export"),
path("webhooks/easypost/", views.easypost_webhook, name="easypost_webhook"),
path("<uuid:pk>/", views.shipment_detail, name="shipment_detail"),
path("<uuid:pk>/buy/", views.shipment_buy, name="shipment_buy"),
path(
"<uuid:pk>/tracking/",
views.shipment_attach_tracking,
name="shipment_attach_tracking",
),
path(
"<uuid:pk>/tracking/refresh/",
views.shipment_refresh_tracking,
name="shipment_refresh_tracking",
),
]
+159
View File
@@ -0,0 +1,159 @@
import json
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from shipping.models import Shipment
from shipping.services import (
ShippingError,
apply_tracker_payload,
attach_tracking,
buy_label,
create_shipment_for_order,
pirate_ship_csv,
quote_rates,
refresh_tracking,
)
from shop.models import Order
logger = logging.getLogger(__name__)
@login_required
def shipment_list(request):
shipments = Shipment.objects.select_related("order")[:200]
labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list(
"order_id", flat=True
)
unshipped = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=labeled)
return render(
request,
"shipping/list.html",
{"shipments": shipments, "unshipped": unshipped},
)
@login_required
@require_POST
def shipment_create(request):
order = get_object_or_404(Order, pk=request.POST.get("order"))
try:
weight = int(request.POST.get("weight_oz") or "16")
except ValueError:
weight = 16
try:
shipment = create_shipment_for_order(order, weight_oz=weight)
quote_rates(shipment)
except ShippingError as exc:
messages.error(request, str(exc))
return redirect("shipping:shipment_list")
except Exception as exc: # noqa: BLE001
logger.exception("rate quote failed")
messages.error(request, f"Could not quote rates: {exc}")
return redirect("shipping:shipment_list")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
def shipment_detail(request, pk):
shipment = get_object_or_404(Shipment.objects.select_related("order"), pk=pk)
return render(request, "shipping/detail.html", {"shipment": shipment})
@login_required
@require_POST
def shipment_buy(request, pk):
shipment = get_object_or_404(Shipment, pk=pk)
rate_id = (request.POST.get("rate_id") or "").strip()
try:
buy_label(shipment, rate_id=rate_id)
except ShippingError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("buy label failed")
messages.error(request, f"Could not buy label: {exc}")
else:
messages.success(request, f"Label created for {shipment.order.number}.")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
@require_POST
def shipment_attach_tracking(request, pk):
shipment = get_object_or_404(Shipment, pk=pk)
try:
attach_tracking(
shipment,
tracking_number=request.POST.get("tracking_number") or "",
carrier=request.POST.get("carrier") or "",
)
except ShippingError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("attach tracking failed")
messages.error(request, f"Could not save tracking: {exc}")
else:
messages.success(request, "Tracking saved.")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
@require_POST
def shipment_refresh_tracking(request, pk):
shipment = get_object_or_404(Shipment, pk=pk)
try:
refresh_tracking(shipment)
except Exception as exc: # noqa: BLE001
logger.exception("refresh tracking failed")
messages.error(request, f"Could not refresh tracking: {exc}")
else:
messages.success(request, "Tracking updated.")
return redirect("shipping:shipment_detail", pk=shipment.pk)
@login_required
@require_http_methods(["GET"])
def pirate_ship_export(request):
body = pirate_ship_csv()
response = HttpResponse(body, content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"'
return response
@csrf_exempt
@require_http_methods(["POST"])
def easypost_webhook(request):
secret = (getattr(settings, "EASYPOST_WEBHOOK_SECRET", "") or "").strip()
if secret:
got = (
request.headers.get("X-Webhook-Secret")
or request.GET.get("token")
or ""
).strip()
if got != secret:
return HttpResponseBadRequest("invalid secret")
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return HttpResponseBadRequest("invalid json")
result = payload.get("result") or payload
if not isinstance(result, dict):
return HttpResponse("ok")
tracker_id = (result.get("id") or "").strip()
tracking = (result.get("tracking_code") or result.get("tracking_number") or "").strip()
shipment = None
if tracker_id:
shipment = Shipment.objects.filter(tracker_id=tracker_id).first()
if shipment is None and tracking:
shipment = Shipment.objects.filter(tracking_number=tracking).first()
if shipment is None:
return HttpResponse("ok")
apply_tracker_payload(shipment, result)
logger.info("easypost tracker updated shipment %s", shipment.pk)
return HttpResponse("ok")
View File
+32
View File
@@ -0,0 +1,32 @@
from django.contrib import admin
from shop.models import Order, OrderItem, Product, ProductReview
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "sku", "price", "stock_qty", "fulfillment", "is_published")
list_filter = ("fulfillment", "is_published")
search_fields = ("name", "sku")
prepopulated_fields = {"slug": ("name",)}
@admin.register(ProductReview)
class ProductReviewAdmin(admin.ModelAdmin):
list_display = ("product", "user", "rating", "created_at")
list_filter = ("rating",)
search_fields = ("product__name", "user__email", "title")
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("number", "email", "amount", "status", "created_at")
list_filter = ("status",)
search_fields = ("number", "email", "customer_name")
raw_id_fields = ("user",)
inlines = [OrderItemInline]
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shop"
verbose_name = "E-commerce"
def ready(self):
from shop import hooks
hooks.register()
+53
View File
@@ -0,0 +1,53 @@
from core.registry import (
register_dashboard_collector,
register_feature,
register_portal_nav,
register_public_nav,
)
def register() -> None:
register_feature("shop")
register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30)
register_portal_nav(
section="shop_sales",
label="Sales",
url_name="shop_portal:sales",
group="Retail",
order=5,
)
register_portal_nav(
section="shop_products",
label="Products",
url_name="shop_portal:product_list",
group="Retail",
order=10,
)
register_portal_nav(
section="shop_orders",
label="Orders",
url_name="shop_portal:order_list",
group="Retail",
order=20,
)
register_dashboard_collector(_dashboard)
def _dashboard(request) -> dict:
from shop.models import Order, Product
from shop.stats import sales_summary
sales = sales_summary()
return {
"open_shop_orders": Order.objects.filter(
status__in=[Order.Status.OPEN, Order.Status.PAID]
).count(),
"low_stock_products": Product.objects.filter(
is_published=True,
track_inventory=True,
fulfillment=Product.Fulfillment.STOCKED,
stock_qty__lte=3,
).count(),
"shop_sales_30d": sales["order_count"],
"shop_revenue_30d": sales["revenue"],
}
+80
View File
@@ -0,0 +1,80 @@
# 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='Order',
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)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('fulfilled', 'Fulfilled'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('shipping_address', models.JSONField(blank=True, default=dict)),
('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)),
('notes', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Product',
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=200)),
('slug', models.SlugField(max_length=220, unique=True)),
('sku', models.CharField(max_length=64, unique=True)),
('description', models.TextField(blank=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('fulfillment', models.CharField(choices=[('stocked', 'On-hand stock'), ('made_to_order', 'Made to order')], default='stocked', max_length=16)),
('stock_qty', models.IntegerField(default=0)),
('print_minutes', models.PositiveIntegerField(default=0, help_text='Estimated print time per unit (made-to-order).')),
('filament_grams', models.PositiveIntegerField(default=0)),
('is_published', models.BooleanField(default=False)),
('track_inventory', models.BooleanField(default=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='OrderItem',
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=200)),
('sku', models.CharField(max_length=64)),
('quantity', models.PositiveIntegerField(default=1)),
('unit_price', models.DecimalField(decimal_places=2, max_digits=10)),
('print_minutes', models.PositiveIntegerField(default=0)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.order')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='shop.product')),
],
options={
'ordering': ['created_at'],
},
),
]

Some files were not shown because too many files have changed in this diff Show More