Template
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1ef797b8 | ||
|
|
f3e34f5099 | ||
|
|
9cdce7a897 |
No files matched your search
@@ -35,6 +35,10 @@ FEATURE_BLOG=true
|
|||||||
FEATURE_PAYMENTS=true
|
FEATURE_PAYMENTS=true
|
||||||
FEATURE_SOCIAL=true
|
FEATURE_SOCIAL=true
|
||||||
FEATURE_SOCIAL_AI=true
|
FEATURE_SOCIAL_AI=true
|
||||||
|
FEATURE_SHOP=true
|
||||||
|
FEATURE_POS_SYNC=true
|
||||||
|
FEATURE_EVENTS=true
|
||||||
|
FEATURE_SHIPPING=true
|
||||||
|
|
||||||
# reCAPTCHA (optional locally — form skips captcha when empty)
|
# reCAPTCHA (optional locally — form skips captcha when empty)
|
||||||
RECAPTCHA_PUBLIC_KEY=
|
RECAPTCHA_PUBLIC_KEY=
|
||||||
@@ -67,6 +71,10 @@ PCM_WEBHOOK_SECRETS=
|
|||||||
# Quote JSON (compose/shell break on bare {…, …}).
|
# Quote JSON (compose/shell break on bare {…, …}).
|
||||||
# PCM_RETURN_ADDRESS='{"firstName":"Jane","lastName":"Doe","address":"123 Main","city":"Town","state":"IL","zipCode":"60189"}'
|
# PCM_RETURN_ADDRESS='{"firstName":"Jane","lastName":"Doe","address":"123 Main","city":"Town","state":"IL","zipCode":"60189"}'
|
||||||
PCM_RETURN_ADDRESS=
|
PCM_RETURN_ADDRESS=
|
||||||
|
# Fallback $/piece when PCM design has no pricing[]; used by compose cost quotes.
|
||||||
|
# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89
|
||||||
|
# PCM_POSTCARD_PRICE_STANDARD=0.55
|
||||||
|
# PCM_LIST_TYPE=IRL
|
||||||
|
|
||||||
# Stripe — required when FEATURE_PAYMENTS=true
|
# Stripe — required when FEATURE_PAYMENTS=true
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
@@ -74,6 +82,22 @@ STRIPE_PUBLISHABLE_KEY=
|
|||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_CURRENCY=usd
|
STRIPE_CURRENCY=usd
|
||||||
|
|
||||||
|
# Shop — FEATURE_SHOP (requires email/SMS + payments)
|
||||||
|
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
|
||||||
|
|
||||||
|
# POS sync — FEATURE_POS_SYNC (requires shop). Required in beta/prod.
|
||||||
|
POS_WEBHOOK_SECRET=
|
||||||
|
POS_API_BASE_URL=
|
||||||
|
POS_API_TOKEN=
|
||||||
|
|
||||||
|
# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works.
|
||||||
|
EASYPOST_API_KEY=
|
||||||
|
EASYPOST_WEBHOOK_SECRET=
|
||||||
|
SHIP_FROM_LINE1=
|
||||||
|
SHIP_FROM_CITY=
|
||||||
|
SHIP_FROM_STATE=
|
||||||
|
SHIP_FROM_ZIP=
|
||||||
|
|
||||||
# Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true
|
# Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true
|
||||||
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
|
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ FEATURE_BLOG=false
|
|||||||
FEATURE_PAYMENTS=false
|
FEATURE_PAYMENTS=false
|
||||||
FEATURE_SOCIAL=false
|
FEATURE_SOCIAL=false
|
||||||
FEATURE_SOCIAL_AI=false
|
FEATURE_SOCIAL_AI=false
|
||||||
|
FEATURE_SHOP=false
|
||||||
|
FEATURE_POS_SYNC=false
|
||||||
|
FEATURE_EVENTS=false
|
||||||
|
FEATURE_SHIPPING=false
|
||||||
|
|
||||||
# Shared external Postgres (10.0.0.230)
|
# Shared external Postgres (10.0.0.230)
|
||||||
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site
|
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site
|
||||||
@@ -56,6 +60,9 @@ PCM_API_KEY=
|
|||||||
PCM_API_SECRET=
|
PCM_API_SECRET=
|
||||||
PCM_WEBHOOK_SECRETS=
|
PCM_WEBHOOK_SECRETS=
|
||||||
PCM_RETURN_ADDRESS=
|
PCM_RETURN_ADDRESS=
|
||||||
|
# PCM_POSTCARD_PRICE_STANDARD=0.55
|
||||||
|
# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89
|
||||||
|
# PCM_LIST_TYPE=IRL
|
||||||
|
|
||||||
# Required when FEATURE_PAYMENTS=true (also requires FEATURE_EMAIL_SMS=true)
|
# Required when FEATURE_PAYMENTS=true (also requires FEATURE_EMAIL_SMS=true)
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
@@ -63,6 +70,22 @@ STRIPE_PUBLISHABLE_KEY=
|
|||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_CURRENCY=usd
|
STRIPE_CURRENCY=usd
|
||||||
|
|
||||||
|
# Required when FEATURE_SHOP=true (also requires FEATURE_EMAIL_SMS + FEATURE_PAYMENTS)
|
||||||
|
# SHOP_PRINT_QUEUE_LIMIT_MINUTES=0
|
||||||
|
|
||||||
|
# Required when FEATURE_POS_SYNC=true in beta/prod (also requires FEATURE_SHOP)
|
||||||
|
POS_WEBHOOK_SECRET=
|
||||||
|
POS_API_BASE_URL=
|
||||||
|
POS_API_TOKEN=
|
||||||
|
|
||||||
|
# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP)
|
||||||
|
EASYPOST_API_KEY=
|
||||||
|
EASYPOST_WEBHOOK_SECRET=
|
||||||
|
SHIP_FROM_LINE1=
|
||||||
|
SHIP_FROM_CITY=
|
||||||
|
SHIP_FROM_STATE=
|
||||||
|
SHIP_FROM_ZIP=
|
||||||
|
|
||||||
# Required when FEATURE_SOCIAL=true
|
# Required when FEATURE_SOCIAL=true
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
META_APP_SECRET=
|
META_APP_SECRET=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ services:
|
|||||||
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
|
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
|
||||||
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true}
|
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true}
|
||||||
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true}
|
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true}
|
||||||
|
FEATURE_SHOP: ${FEATURE_SHOP:-true}
|
||||||
|
FEATURE_POS_SYNC: ${FEATURE_POS_SYNC:-true}
|
||||||
|
FEATURE_EVENTS: ${FEATURE_EVENTS:-true}
|
||||||
|
FEATURE_SHIPPING: ${FEATURE_SHIPPING:-true}
|
||||||
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
|
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
|
||||||
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
|
||||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
||||||
@@ -64,6 +68,9 @@ services:
|
|||||||
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
|
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
|
||||||
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
|
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
|
||||||
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
|
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
|
||||||
|
PCM_POSTCARD_PRICE_FIRSTCLASS: ${PCM_POSTCARD_PRICE_FIRSTCLASS:-}
|
||||||
|
PCM_POSTCARD_PRICE_STANDARD: ${PCM_POSTCARD_PRICE_STANDARD:-}
|
||||||
|
PCM_LIST_TYPE: ${PCM_LIST_TYPE:-IRL}
|
||||||
META_APP_ID: ${META_APP_ID:-}
|
META_APP_ID: ${META_APP_ID:-}
|
||||||
META_APP_SECRET: ${META_APP_SECRET:-}
|
META_APP_SECRET: ${META_APP_SECRET:-}
|
||||||
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
|
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
|
||||||
@@ -107,6 +114,9 @@ services:
|
|||||||
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
|
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
|
||||||
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
|
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
|
||||||
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
|
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
|
||||||
|
PCM_POSTCARD_PRICE_FIRSTCLASS: ${PCM_POSTCARD_PRICE_FIRSTCLASS:-}
|
||||||
|
PCM_POSTCARD_PRICE_STANDARD: ${PCM_POSTCARD_PRICE_STANDARD:-}
|
||||||
|
PCM_LIST_TYPE: ${PCM_LIST_TYPE:-IRL}
|
||||||
META_APP_ID: ${META_APP_ID:-}
|
META_APP_ID: ${META_APP_ID:-}
|
||||||
META_APP_SECRET: ${META_APP_SECRET:-}
|
META_APP_SECRET: ${META_APP_SECRET:-}
|
||||||
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
|
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",)
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 "",
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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 & 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 %}
|
||||||
@@ -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"))
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,27 @@ FEATURE_BLOG = env_bool("FEATURE_BLOG", _feature_default)
|
|||||||
FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default)
|
FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default)
|
||||||
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
|
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
|
||||||
FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default)
|
FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default)
|
||||||
|
FEATURE_SHOP = env_bool("FEATURE_SHOP", _feature_default)
|
||||||
|
FEATURE_POS_SYNC = env_bool("FEATURE_POS_SYNC", _feature_default)
|
||||||
|
FEATURE_EVENTS = env_bool("FEATURE_EVENTS", _feature_default)
|
||||||
|
FEATURE_SHIPPING = env_bool("FEATURE_SHIPPING", _feature_default)
|
||||||
|
|
||||||
if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS:
|
if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS:
|
||||||
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
|
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
|
||||||
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
|
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
|
||||||
raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL")
|
raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL")
|
||||||
|
if FEATURE_SHOP and not FEATURE_EMAIL_SMS:
|
||||||
|
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_EMAIL_SMS")
|
||||||
|
if FEATURE_SHOP and not FEATURE_PAYMENTS:
|
||||||
|
raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_PAYMENTS")
|
||||||
|
if FEATURE_POS_SYNC and not FEATURE_SHOP:
|
||||||
|
raise ImproperlyConfigured("FEATURE_POS_SYNC requires FEATURE_SHOP")
|
||||||
|
if FEATURE_EVENTS and not FEATURE_EMAIL_SMS:
|
||||||
|
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_EMAIL_SMS")
|
||||||
|
if FEATURE_EVENTS and not FEATURE_PAYMENTS:
|
||||||
|
raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_PAYMENTS")
|
||||||
|
if FEATURE_SHIPPING and not FEATURE_SHOP:
|
||||||
|
raise ImproperlyConfigured("FEATURE_SHIPPING requires FEATURE_SHOP")
|
||||||
|
|
||||||
CORE_APPS = [
|
CORE_APPS = [
|
||||||
"core.apps.CoreConfig",
|
"core.apps.CoreConfig",
|
||||||
@@ -137,6 +153,10 @@ OPTIONAL_APPS = [
|
|||||||
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
|
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
|
||||||
(FEATURE_SOCIAL, "social.apps.SocialConfig"),
|
(FEATURE_SOCIAL, "social.apps.SocialConfig"),
|
||||||
(FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"),
|
(FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"),
|
||||||
|
(FEATURE_SHOP, "shop.apps.ShopConfig"),
|
||||||
|
(FEATURE_POS_SYNC, "pos_sync.apps.PosSyncConfig"),
|
||||||
|
(FEATURE_EVENTS, "events.apps.EventsConfig"),
|
||||||
|
(FEATURE_SHIPPING, "shipping.apps.ShippingConfig"),
|
||||||
]
|
]
|
||||||
DJANGO_APPS = [
|
DJANGO_APPS = [
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
@@ -163,6 +183,7 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"accounts.middleware.PortalStaffMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"analytics.middleware.UTMTrackingMiddleware",
|
"analytics.middleware.UTMTrackingMiddleware",
|
||||||
@@ -329,6 +350,9 @@ PCM_RETURN_LINE2 = env("PCM_RETURN_LINE2", "")
|
|||||||
PCM_RETURN_CITY = env("PCM_RETURN_CITY", "")
|
PCM_RETURN_CITY = env("PCM_RETURN_CITY", "")
|
||||||
PCM_RETURN_STATE = env("PCM_RETURN_STATE", "")
|
PCM_RETURN_STATE = env("PCM_RETURN_STATE", "")
|
||||||
PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "")
|
PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "")
|
||||||
|
PCM_POSTCARD_PRICE_FIRSTCLASS = env("PCM_POSTCARD_PRICE_FIRSTCLASS", "")
|
||||||
|
PCM_POSTCARD_PRICE_STANDARD = env("PCM_POSTCARD_PRICE_STANDARD", "")
|
||||||
|
PCM_LIST_TYPE = env("PCM_LIST_TYPE", "IRL")
|
||||||
# Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid)
|
# Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid)
|
||||||
LOB_API_KEY = env("LOB_API_KEY", "")
|
LOB_API_KEY = env("LOB_API_KEY", "")
|
||||||
CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "")
|
CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "")
|
||||||
@@ -352,6 +376,21 @@ STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "")
|
|||||||
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
|
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
|
||||||
STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd")
|
STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd")
|
||||||
|
|
||||||
|
# --- Shop / POS / shipping (FEATURE_SHOP and add-ons) ---
|
||||||
|
# 0 = unlimited made-to-order print queue.
|
||||||
|
SHOP_PRINT_QUEUE_LIMIT_MINUTES = int(
|
||||||
|
env("SHOP_PRINT_QUEUE_LIMIT_MINUTES", "0") or "0"
|
||||||
|
)
|
||||||
|
POS_WEBHOOK_SECRET = env("POS_WEBHOOK_SECRET", "")
|
||||||
|
POS_API_BASE_URL = env("POS_API_BASE_URL", "")
|
||||||
|
POS_API_TOKEN = env("POS_API_TOKEN", "")
|
||||||
|
EASYPOST_API_KEY = env("EASYPOST_API_KEY", "")
|
||||||
|
EASYPOST_WEBHOOK_SECRET = env("EASYPOST_WEBHOOK_SECRET", "")
|
||||||
|
SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "")
|
||||||
|
SHIP_FROM_CITY = env("SHIP_FROM_CITY", "")
|
||||||
|
SHIP_FROM_STATE = env("SHIP_FROM_STATE", "")
|
||||||
|
SHIP_FROM_ZIP = env("SHIP_FROM_ZIP", "")
|
||||||
|
|
||||||
# --- Nominatim (address suggest; server-side proxy only) ---
|
# --- Nominatim (address suggest; server-side proxy only) ---
|
||||||
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
|
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
|
||||||
# LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as
|
# LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as
|
||||||
|
|||||||
@@ -264,6 +264,63 @@ body.portal {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.chart-placeholder.chart-daily {
|
||||||
|
gap: 3px;
|
||||||
|
padding: 16px 10px 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.chart-placeholder.chart-daily .chart-bar-col {
|
||||||
|
flex: 1 0 8px;
|
||||||
|
min-width: 6px;
|
||||||
|
}
|
||||||
|
.chart-placeholder.chart-daily .chart-bar-col .bar {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.chart-placeholder.chart-daily .chart-bar-col .bar.is-zero {
|
||||||
|
height: 0 !important;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
.chart-placeholder.chart-daily .chart-bar-label {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.chart-placeholder.chart-revenue .chart-bar-col .bar {
|
||||||
|
background: var(--monica-primary-light);
|
||||||
|
}
|
||||||
|
.hbar-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.hbar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(72px, 160px) 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hbar-label {
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hbar-track {
|
||||||
|
height: 16px;
|
||||||
|
background: #e8f4f5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hbar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--monica-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hbar-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2.5ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
(function () {
|
||||||
|
function money(n) {
|
||||||
|
if (n === null || n === undefined || n === "") return "—";
|
||||||
|
var v = Number(n);
|
||||||
|
if (Number.isNaN(v)) return "—";
|
||||||
|
return "$" + v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPostcardRoutes(opts) {
|
||||||
|
var form = document.getElementById("campaign-compose");
|
||||||
|
if (!form) return;
|
||||||
|
var audience = document.getElementById("id_audience");
|
||||||
|
var picker = document.getElementById("postcard-route-picker");
|
||||||
|
var listCost = document.getElementById("postcard-list-cost");
|
||||||
|
var hidden = document.getElementById("id_route_targeting");
|
||||||
|
var findBtn = document.getElementById("route-find-btn");
|
||||||
|
var status = document.getElementById("route-status");
|
||||||
|
var tableWrap = document.getElementById("route-table-wrap");
|
||||||
|
var tbody = document.getElementById("route-table-body");
|
||||||
|
var selectedBox = document.getElementById("route-selected-cost");
|
||||||
|
var quoteUrl = opts.quoteUrl;
|
||||||
|
var csrfToken = opts.csrfToken;
|
||||||
|
var maxRoutes = 2;
|
||||||
|
var routes = [];
|
||||||
|
|
||||||
|
function isPostcardAudience() {
|
||||||
|
var v = (audience && audience.value) || "";
|
||||||
|
return v === "postcard_opt_in" || v === "carrier_route";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRouteAudience() {
|
||||||
|
return ((audience && audience.value) || "") === "carrier_route";
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateId() {
|
||||||
|
return (document.getElementById("id_template_id") || {}).value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, isError) {
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = text || "";
|
||||||
|
status.classList.toggle("error", !!isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCodes() {
|
||||||
|
return Array.prototype.map.call(
|
||||||
|
form.querySelectorAll('input[name="route_code"]:checked'),
|
||||||
|
function (el) { return el.value; }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postQuote(body) {
|
||||||
|
return fetch(quoteUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
Accept: "application/json"
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().then(function (json) {
|
||||||
|
if (!res.ok) throw new Error(json.detail || "Quote failed");
|
||||||
|
return json;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListCost(data) {
|
||||||
|
if (!listCost) return;
|
||||||
|
if (!isPostcardAudience() || isRouteAudience()) {
|
||||||
|
listCost.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listCost.hidden = false;
|
||||||
|
var src = data.price_source === "fallback" ? " (env fallback rate)" : "";
|
||||||
|
if (data.per_piece == null) {
|
||||||
|
listCost.innerHTML =
|
||||||
|
"<strong>Estimated cost:</strong> " + data.count +
|
||||||
|
" household" + (data.count === 1 ? "" : "s") +
|
||||||
|
" · per-piece price unavailable. Set PCM_POSTCARD_PRICE_STANDARD / FIRSTCLASS.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listCost.innerHTML =
|
||||||
|
"<strong>Estimated cost:</strong> " + data.count +
|
||||||
|
" × " + money(data.per_piece) + " = " + money(data.total) +
|
||||||
|
" (" + data.mail_class + src + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshListCost() {
|
||||||
|
if (!isPostcardAudience() || isRouteAudience()) {
|
||||||
|
if (listCost) listCost.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postQuote({ action: "mailing_list", template_id: templateId() })
|
||||||
|
.then(renderListCost)
|
||||||
|
.catch(function (err) {
|
||||||
|
if (listCost) {
|
||||||
|
listCost.hidden = false;
|
||||||
|
listCost.textContent = err.message || "Could not load mailing-list cost.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHidden(payload) {
|
||||||
|
if (hidden) hidden.value = payload ? JSON.stringify(payload) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelected(data) {
|
||||||
|
if (!selectedBox) return;
|
||||||
|
if (!data) {
|
||||||
|
selectedBox.hidden = true;
|
||||||
|
writeHidden("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedBox.hidden = false;
|
||||||
|
var src = data.price_source === "fallback" ? " using env fallback rate" : "";
|
||||||
|
var missing = data.per_piece == null
|
||||||
|
? " Per-piece price unavailable — set PCM_POSTCARD_PRICE_* before sending."
|
||||||
|
: "";
|
||||||
|
selectedBox.innerHTML =
|
||||||
|
"<strong>Selected:</strong> " + (data.record_count || 0) +
|
||||||
|
" household" + ((data.record_count || 0) === 1 ? "" : "s") +
|
||||||
|
" on " + (data.selected_codes || []).join(", ") +
|
||||||
|
" · " + (data.mail_class || "") +
|
||||||
|
" · est. " + money(data.total) + src + "." + missing;
|
||||||
|
writeHidden({
|
||||||
|
selected_codes: data.selected_codes,
|
||||||
|
list_count_id: data.list_count_id,
|
||||||
|
record_count: data.record_count,
|
||||||
|
mail_class: data.mail_class,
|
||||||
|
estimated: data.estimated,
|
||||||
|
route_rows: data.route_rows || data.routes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recount() {
|
||||||
|
var codes = selectedCodes();
|
||||||
|
if (!codes.length) {
|
||||||
|
renderSelected(null);
|
||||||
|
setStatus("Select one or two routes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("Updating quote…");
|
||||||
|
postQuote({
|
||||||
|
action: "select",
|
||||||
|
template_id: templateId(),
|
||||||
|
routes: codes
|
||||||
|
}).then(function (data) {
|
||||||
|
setStatus("");
|
||||||
|
renderSelected(data);
|
||||||
|
}).catch(function (err) {
|
||||||
|
renderSelected(null);
|
||||||
|
setStatus(err.message || "Could not quote selected routes.", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoutes(list) {
|
||||||
|
routes = list || [];
|
||||||
|
if (!tbody || !tableWrap) return;
|
||||||
|
if (!routes.length) {
|
||||||
|
tableWrap.hidden = true;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tableWrap.hidden = false;
|
||||||
|
tbody.innerHTML = routes.map(function (row) {
|
||||||
|
return "<tr>" +
|
||||||
|
"<td><label class=\"check-row\" style=\"margin:0\">" +
|
||||||
|
"<input type=\"checkbox\" name=\"route_code\" value=\"" +
|
||||||
|
String(row.code).replace(/"/g, """) + "\"> " +
|
||||||
|
String(row.code) + "</label></td>" +
|
||||||
|
"<td>" + String(row.text || "") + "</td>" +
|
||||||
|
"<td>" + String(row.total) + "</td>" +
|
||||||
|
"<td>" + money(row.cost) + "</td></tr>";
|
||||||
|
}).join("");
|
||||||
|
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
|
||||||
|
box.addEventListener("change", function () {
|
||||||
|
var checked = selectedCodes();
|
||||||
|
if (checked.length > maxRoutes) {
|
||||||
|
box.checked = false;
|
||||||
|
setStatus("Pick at most two routes.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRoutes() {
|
||||||
|
var zip = (document.getElementById("route-zip") || {}).value || "";
|
||||||
|
var address = (document.getElementById("route-address") || {}).value || "";
|
||||||
|
var city = (document.getElementById("route-city") || {}).value || "";
|
||||||
|
var state = (document.getElementById("route-state") || {}).value || "";
|
||||||
|
var radius = (document.getElementById("route-radius") || {}).value || "";
|
||||||
|
setStatus("Looking up routes…");
|
||||||
|
tableWrap.hidden = true;
|
||||||
|
renderSelected(null);
|
||||||
|
postQuote({
|
||||||
|
action: "search",
|
||||||
|
template_id: templateId(),
|
||||||
|
zip: zip,
|
||||||
|
address: address,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
radius: radius
|
||||||
|
}).then(function (data) {
|
||||||
|
renderRoutes(data.routes || []);
|
||||||
|
if (!(data.routes || []).length) {
|
||||||
|
setStatus("No carrier routes for that search.", true);
|
||||||
|
} else {
|
||||||
|
setStatus("Select one or two routes. Cost uses " + (data.mail_class || "Standard") + ".");
|
||||||
|
}
|
||||||
|
}).catch(function (err) {
|
||||||
|
renderRoutes([]);
|
||||||
|
setStatus(err.message || "Route lookup failed.", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVisibility() {
|
||||||
|
if (picker) picker.hidden = !isRouteAudience();
|
||||||
|
if (!isRouteAudience()) {
|
||||||
|
writeHidden("");
|
||||||
|
if (selectedBox) selectedBox.hidden = true;
|
||||||
|
}
|
||||||
|
refreshListCost();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findBtn) findBtn.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
findRoutes();
|
||||||
|
});
|
||||||
|
var tmpl = document.getElementById("id_template_id");
|
||||||
|
if (tmpl) tmpl.addEventListener("change", function () {
|
||||||
|
syncVisibility();
|
||||||
|
if (isRouteAudience() && selectedCodes().length) recount();
|
||||||
|
});
|
||||||
|
if (audience) audience.addEventListener("change", syncVisibility);
|
||||||
|
syncVisibility();
|
||||||
|
|
||||||
|
window.MonicaPostcardRoutes = {
|
||||||
|
isPostcardAudience: isPostcardAudience,
|
||||||
|
isRouteAudience: isRouteAudience,
|
||||||
|
syncVisibility: syncVisibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.initPostcardRoutes = initPostcardRoutes;
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,6 +22,7 @@ GROUP_ORDER = {
|
|||||||
"Outreach": 20,
|
"Outreach": 20,
|
||||||
"Content": 30,
|
"Content": 30,
|
||||||
"Social": 40,
|
"Social": 40,
|
||||||
|
"Retail": 45,
|
||||||
"Billing": 50,
|
"Billing": 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail
|
|||||||
| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) |
|
| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) |
|
||||||
| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) |
|
| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) |
|
||||||
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
|
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
|
||||||
|
| `PCM_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing |
|
||||||
|
| `PCM_POSTCARD_PRICE_STANDARD` | Fallback $/piece for Standard |
|
||||||
|
| `PCM_LIST_TYPE` | PCM purchased-list type (default `IRL`) |
|
||||||
| `POSTCARD_PROVIDER` | `pcm` (default) |
|
| `POSTCARD_PROVIDER` | `pcm` (default) |
|
||||||
|
|
||||||
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
|
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
|
||||||
@@ -93,6 +96,16 @@ HMAC-SHA256 of body) matching **any** listed secret.
|
|||||||
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
|
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
|
||||||
echo that (or `orderID`, matched to `Message.provider_message_id`).
|
echo that (or `orderID`, matched to `Message.provider_message_id`).
|
||||||
|
|
||||||
|
### Carrier-route (neighborhood) mailings
|
||||||
|
|
||||||
|
Postcard compose can target **mailing-list contacts** or **USPS carrier routes**.
|
||||||
|
Route mode calls PCM `POST /list/count/zipcode` (or `/radius`) with
|
||||||
|
`breakdownType=ZipCRRT`, then recounts selected routes via
|
||||||
|
`POST /list/count/carrier-route` and sends
|
||||||
|
`POST /order/postcard/with-list-count`. Estimated cost uses design `pricing[]`
|
||||||
|
or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default
|
||||||
|
purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage.
|
||||||
|
|
||||||
### Campaign completion email
|
### Campaign completion email
|
||||||
|
|
||||||
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
|
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("directmail", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="campaign",
|
||||||
|
name="route_targeting",
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="campaign",
|
||||||
|
name="audience",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("postcard_opt_in", "Mailing list · postcard opt-in"),
|
||||||
|
("carrier_route", "USPS carrier routes"),
|
||||||
|
],
|
||||||
|
default="",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="message",
|
||||||
|
name="contact",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="directmail_messages",
|
||||||
|
to="contacts.contact",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -27,6 +27,7 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
|
|
||||||
class Audience(models.TextChoices):
|
class Audience(models.TextChoices):
|
||||||
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
|
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
|
||||||
|
CARRIER_ROUTE = "carrier_route", "USPS carrier routes"
|
||||||
|
|
||||||
name = models.CharField(max_length=120)
|
name = models.CharField(max_length=120)
|
||||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||||
@@ -58,6 +59,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
body_override = models.TextField(blank=True)
|
body_override = models.TextField(blank=True)
|
||||||
# Set when completion summary email is sent (campaign COMPLETED).
|
# Set when completion summary email is sent (campaign COMPLETED).
|
||||||
notify_sent_at = models.DateTimeField(null=True, blank=True)
|
notify_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
# PCM purchased-list targeting (ZIP/CRRT codes, listCountID, quote).
|
||||||
|
route_targeting = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
@@ -83,7 +86,11 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
Campaign, on_delete=models.CASCADE, related_name="messages"
|
Campaign, on_delete=models.CASCADE, related_name="messages"
|
||||||
)
|
)
|
||||||
contact = models.ForeignKey(
|
contact = models.ForeignKey(
|
||||||
Contact, on_delete=models.CASCADE, related_name="directmail_messages"
|
Contact,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="directmail_messages",
|
||||||
)
|
)
|
||||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
@@ -100,7 +107,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.channel} → {self.contact} ({self.status})"
|
who = self.contact or "carrier-route mailing"
|
||||||
|
return f"{self.channel} → {who} ({self.status})"
|
||||||
|
|
||||||
|
|
||||||
class ProviderEvent(TimeStampedModel):
|
class ProviderEvent(TimeStampedModel):
|
||||||
|
|||||||
@@ -338,6 +338,210 @@ def get_order(order_id: int | str) -> dict[str, Any]:
|
|||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_design(design_id: int | str) -> dict[str, Any]:
|
||||||
|
data = pcm_request("GET", f"/design/{design_id}")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
FIRST_CLASS_ONLY_SIZES = frozenset({"46", "46S"})
|
||||||
|
MAX_SELECTED_ROUTES = 2
|
||||||
|
DEFAULT_LIST_TYPE = "IRL"
|
||||||
|
|
||||||
|
|
||||||
|
def mail_class_for_size(size: str | None) -> str:
|
||||||
|
key = str(size or "").strip()
|
||||||
|
if key in FIRST_CLASS_ONLY_SIZES:
|
||||||
|
return "FirstClass"
|
||||||
|
return "Standard"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_unit_price(mail_class: str) -> float | None:
|
||||||
|
attr = (
|
||||||
|
"PCM_POSTCARD_PRICE_FIRSTCLASS"
|
||||||
|
if mail_class == "FirstClass"
|
||||||
|
else "PCM_POSTCARD_PRICE_STANDARD"
|
||||||
|
)
|
||||||
|
raw = str(getattr(settings, attr, "") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return value if value >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def pick_unit_price(
|
||||||
|
pricing: list[dict] | None,
|
||||||
|
*,
|
||||||
|
mail_class: str,
|
||||||
|
quantity: int,
|
||||||
|
) -> tuple[float | None, str]:
|
||||||
|
"""Return (dollars per piece, source). Source is pcm|fallback|missing."""
|
||||||
|
rows: list[dict] = []
|
||||||
|
for item in pricing or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("mailClass") or item.get("mail_class") or "") != mail_class:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price = float(item.get("price"))
|
||||||
|
break_qty = int(item.get("breakQty") or item.get("break_qty") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
rows.append({"price": price, "breakQty": break_qty})
|
||||||
|
qty = max(int(quantity or 0), 0)
|
||||||
|
if rows:
|
||||||
|
eligible = [row for row in rows if qty >= row["breakQty"]]
|
||||||
|
chosen = max(eligible, key=lambda row: row["breakQty"]) if eligible else min(
|
||||||
|
rows, key=lambda row: row["breakQty"]
|
||||||
|
)
|
||||||
|
return chosen["price"], "pcm"
|
||||||
|
fallback = _env_unit_price(mail_class)
|
||||||
|
if fallback is not None:
|
||||||
|
return fallback, "fallback"
|
||||||
|
return None, "missing"
|
||||||
|
|
||||||
|
|
||||||
|
def design_pricing(design_id: int | None) -> list[dict]:
|
||||||
|
"""Load PCM per-piece pricing for a design (gallery payload preferred)."""
|
||||||
|
if not design_id:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
design = get_design(design_id)
|
||||||
|
except PcmApiError:
|
||||||
|
design = {}
|
||||||
|
pricing = design.get("pricing") if isinstance(design, dict) else None
|
||||||
|
if isinstance(pricing, list) and pricing:
|
||||||
|
return [row for row in pricing if isinstance(row, dict)]
|
||||||
|
try:
|
||||||
|
for item in list_designs(product_type="postcard"):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
did = item.get("designID") or item.get("design_id") or item.get("id")
|
||||||
|
if str(did) != str(design_id):
|
||||||
|
continue
|
||||||
|
extra = item.get("pricing")
|
||||||
|
if isinstance(extra, list):
|
||||||
|
return [row for row in extra if isinstance(row, dict)]
|
||||||
|
except PcmApiError:
|
||||||
|
logger.exception("PCM list_designs failed while loading pricing")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def extract_crrt_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Flatten ZipCRRT (or first) breakdown into {code, text, total} rows."""
|
||||||
|
breakdowns = payload.get("breakdown") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(breakdowns, list):
|
||||||
|
return []
|
||||||
|
preferred = None
|
||||||
|
fallback = None
|
||||||
|
for block in breakdowns:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
kind = str(block.get("breakdownType") or "").upper().replace("_", "")
|
||||||
|
data = block.get("data") if isinstance(block.get("data"), list) else []
|
||||||
|
rows = []
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
code = str(item.get("code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
total = int(item.get("total") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total = 0
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"text": str(item.get("text") or code).strip(),
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
if kind in {"ZIPCRRT", "CRRT", "CARRIERROUTE"}:
|
||||||
|
preferred = rows
|
||||||
|
break
|
||||||
|
if fallback is None:
|
||||||
|
fallback = rows
|
||||||
|
return preferred or fallback or []
|
||||||
|
|
||||||
|
|
||||||
|
def _list_type() -> str:
|
||||||
|
return (getattr(settings, "PCM_LIST_TYPE", None) or DEFAULT_LIST_TYPE).strip() or DEFAULT_LIST_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def list_count_zipcode(*, zip_codes: list[str], list_type: str | None = None) -> dict[str, Any]:
|
||||||
|
data = pcm_request(
|
||||||
|
"POST",
|
||||||
|
"/list/count/zipcode",
|
||||||
|
json_body={
|
||||||
|
"listType": list_type or _list_type(),
|
||||||
|
"breakdownType": "ZipCRRT",
|
||||||
|
"zipCodes": zip_codes,
|
||||||
|
"demographics": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def list_count_radius(
|
||||||
|
*,
|
||||||
|
address: str,
|
||||||
|
city: str,
|
||||||
|
state: str,
|
||||||
|
zip_code: str,
|
||||||
|
miles: float,
|
||||||
|
list_type: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = pcm_request(
|
||||||
|
"POST",
|
||||||
|
"/list/count/radius",
|
||||||
|
json_body={
|
||||||
|
"listType": list_type or _list_type(),
|
||||||
|
"breakdownType": "ZipCRRT",
|
||||||
|
"radius": {
|
||||||
|
"radius": miles,
|
||||||
|
"address": address,
|
||||||
|
"city": city,
|
||||||
|
"state": state,
|
||||||
|
"zip": zip_code,
|
||||||
|
},
|
||||||
|
"demographics": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def list_count_carrier_route(
|
||||||
|
*,
|
||||||
|
carrier_routes: list[str],
|
||||||
|
list_type: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = pcm_request(
|
||||||
|
"POST",
|
||||||
|
"/list/count/carrier-route",
|
||||||
|
json_body={
|
||||||
|
"listType": list_type or _list_type(),
|
||||||
|
"breakdownType": "ZipCRRT",
|
||||||
|
"carrierRoutes": carrier_routes,
|
||||||
|
"demographics": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _order_id_from_response(data: dict[str, Any]) -> str:
|
||||||
|
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
|
||||||
|
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
|
||||||
|
order_id = data["results"][0].get("orderID")
|
||||||
|
if order_id is None:
|
||||||
|
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
|
||||||
|
return str(order_id)
|
||||||
|
|
||||||
|
|
||||||
def place_postcard_order(
|
def place_postcard_order(
|
||||||
*,
|
*,
|
||||||
design_id: int,
|
design_id: int,
|
||||||
@@ -358,13 +562,32 @@ def place_postcard_order(
|
|||||||
data = pcm_request("POST", "/order/postcard", json_body=payload)
|
data = pcm_request("POST", "/order/postcard", json_body=payload)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise PcmApiError("Unexpected order response from PCM")
|
raise PcmApiError("Unexpected order response from PCM")
|
||||||
|
return _order_id_from_response(data)
|
||||||
|
|
||||||
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
|
|
||||||
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
|
def place_postcard_order_with_list_count(
|
||||||
order_id = data["results"][0].get("orderID")
|
*,
|
||||||
if order_id is None:
|
design_id: int,
|
||||||
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
|
list_count_id: int,
|
||||||
return str(order_id)
|
record_count: int,
|
||||||
|
ext_ref: str,
|
||||||
|
mail_class: str = "Standard",
|
||||||
|
) -> str:
|
||||||
|
"""Place a postcard order against a PCM purchased list count."""
|
||||||
|
payload = {
|
||||||
|
"designID": design_id,
|
||||||
|
"mailClass": mail_class,
|
||||||
|
"extRefNbr": ext_ref,
|
||||||
|
"listCountID": list_count_id,
|
||||||
|
"recordCount": record_count,
|
||||||
|
"returnAddress": return_address_from_settings(),
|
||||||
|
}
|
||||||
|
data = pcm_request(
|
||||||
|
"POST", "/order/postcard/with-list-count", json_body=payload
|
||||||
|
)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise PcmApiError("Unexpected list-count order response from PCM")
|
||||||
|
return _order_id_from_response(data)
|
||||||
|
|
||||||
|
|
||||||
def design_id_from_template(template) -> int | None:
|
def design_id_from_template(template) -> int | None:
|
||||||
@@ -396,6 +619,24 @@ class PcmProvider:
|
|||||||
"(save a design from the postcard designer first)"
|
"(save a design from the postcard designer first)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
targeting = {}
|
||||||
|
if message.campaign_id and isinstance(
|
||||||
|
getattr(message.campaign, "route_targeting", None), dict
|
||||||
|
):
|
||||||
|
targeting = message.campaign.route_targeting
|
||||||
|
list_count_id = targeting.get("list_count_id")
|
||||||
|
record_count = targeting.get("record_count")
|
||||||
|
if list_count_id and record_count:
|
||||||
|
mail_class = str(targeting.get("mail_class") or "Standard")
|
||||||
|
order_id = place_postcard_order_with_list_count(
|
||||||
|
design_id=design_id,
|
||||||
|
list_count_id=int(list_count_id),
|
||||||
|
record_count=int(record_count),
|
||||||
|
ext_ref=str(message.pk),
|
||||||
|
mail_class=mail_class,
|
||||||
|
)
|
||||||
|
return PostcardResult(provider_id=order_id)
|
||||||
|
|
||||||
recipient = contact_to_pcm_recipient(
|
recipient = contact_to_pcm_recipient(
|
||||||
message.contact, ext_ref=str(message.pk)
|
message.contact, ext_ref=str(message.pk)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
AUDIENCE_CHANNEL = {
|
AUDIENCE_CHANNEL = {
|
||||||
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
||||||
|
Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD,
|
||||||
}
|
}
|
||||||
|
|
||||||
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
|
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
|
||||||
@@ -84,6 +85,211 @@ def channel_for_audience(audience: str) -> str:
|
|||||||
raise ValueError(f"Unknown audience: {audience}") from exc
|
raise ValueError(f"Unknown audience: {audience}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_route_targeting(raw: str | dict | None) -> dict:
|
||||||
|
"""Parse compose JSON for a carrier-route campaign."""
|
||||||
|
from directmail.providers.postcard.pcm import MAX_SELECTED_ROUTES
|
||||||
|
|
||||||
|
data: dict = {}
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
data = raw
|
||||||
|
elif isinstance(raw, str) and raw.strip():
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("Carrier route selection is invalid JSON.") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Carrier route selection must be an object.")
|
||||||
|
data = parsed
|
||||||
|
codes = [
|
||||||
|
str(code).strip()
|
||||||
|
for code in (data.get("selected_codes") or data.get("routes") or [])
|
||||||
|
if str(code).strip()
|
||||||
|
]
|
||||||
|
# Allow routes as list of objects from the quote endpoint.
|
||||||
|
if not codes and isinstance(data.get("routes"), list):
|
||||||
|
codes = [
|
||||||
|
str(row.get("code") or "").strip()
|
||||||
|
for row in data["routes"]
|
||||||
|
if isinstance(row, dict)
|
||||||
|
]
|
||||||
|
codes = [c for c in codes if c]
|
||||||
|
codes = list(dict.fromkeys(codes))
|
||||||
|
if not codes:
|
||||||
|
raise ValueError("Select one or two USPS carrier routes.")
|
||||||
|
if len(codes) > MAX_SELECTED_ROUTES:
|
||||||
|
raise ValueError("Pick at most two carrier routes.")
|
||||||
|
try:
|
||||||
|
list_count_id = int(data.get("list_count_id") or 0)
|
||||||
|
record_count = int(data.get("record_count") or 0)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Route quote is missing list count.") from exc
|
||||||
|
if list_count_id <= 0 or record_count <= 0:
|
||||||
|
raise ValueError("Find routes and select them before saving (need a PCM quote).")
|
||||||
|
estimated = data.get("estimated") if isinstance(data.get("estimated"), dict) else {}
|
||||||
|
route_rows = data.get("route_rows") if isinstance(data.get("route_rows"), list) else []
|
||||||
|
return {
|
||||||
|
"selected_codes": codes,
|
||||||
|
"list_count_id": list_count_id,
|
||||||
|
"record_count": record_count,
|
||||||
|
"mail_class": str(data.get("mail_class") or "Standard"),
|
||||||
|
"list_type": str(data.get("list_type") or "IRL"),
|
||||||
|
"zip": str(data.get("zip") or ""),
|
||||||
|
"estimated": estimated,
|
||||||
|
"route_rows": route_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def money_total(per_piece: float | None, quantity: int) -> float | None:
|
||||||
|
if per_piece is None:
|
||||||
|
return None
|
||||||
|
return round(float(per_piece) * max(int(quantity or 0), 0), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def postcard_quote(*, action: str, payload: dict) -> dict:
|
||||||
|
"""Search routes or price a mailing-list / selected-route postcard."""
|
||||||
|
from directmail.providers.postcard.pcm import (
|
||||||
|
MAX_SELECTED_ROUTES,
|
||||||
|
design_pricing,
|
||||||
|
extract_crrt_rows,
|
||||||
|
list_count_carrier_route,
|
||||||
|
list_count_radius,
|
||||||
|
list_count_zipcode,
|
||||||
|
mail_class_for_size,
|
||||||
|
pick_unit_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
design_id = payload.get("design_id")
|
||||||
|
size = str(payload.get("size") or "").strip()
|
||||||
|
try:
|
||||||
|
design_id_int = int(design_id) if design_id not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
design_id_int = None
|
||||||
|
|
||||||
|
requested_class = str(payload.get("mail_class") or "").strip()
|
||||||
|
mail_class = requested_class or mail_class_for_size(size)
|
||||||
|
if mail_class not in {"FirstClass", "Standard"}:
|
||||||
|
mail_class = mail_class_for_size(size)
|
||||||
|
pricing = design_pricing(design_id_int)
|
||||||
|
|
||||||
|
def _price(qty: int) -> tuple[float | None, str]:
|
||||||
|
return pick_unit_price(pricing, mail_class=mail_class, quantity=qty)
|
||||||
|
|
||||||
|
action = (action or "mailing_list").strip()
|
||||||
|
if action == "mailing_list":
|
||||||
|
count = opted_in_contacts(Channel.POSTCARD).count()
|
||||||
|
per_piece, source = _price(count)
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"mail_class": mail_class,
|
||||||
|
"count": count,
|
||||||
|
"per_piece": per_piece,
|
||||||
|
"total": money_total(per_piece, count),
|
||||||
|
"price_source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "search":
|
||||||
|
zip_code = str(payload.get("zip") or "").strip()
|
||||||
|
address = str(payload.get("address") or "").strip()
|
||||||
|
city = str(payload.get("city") or "").strip()
|
||||||
|
state = str(payload.get("state") or "").strip()
|
||||||
|
try:
|
||||||
|
miles = float(payload.get("radius") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
miles = 0
|
||||||
|
if address and city and state and zip_code and miles > 0:
|
||||||
|
data = list_count_radius(
|
||||||
|
address=address,
|
||||||
|
city=city,
|
||||||
|
state=state,
|
||||||
|
zip_code=zip_code,
|
||||||
|
miles=miles,
|
||||||
|
)
|
||||||
|
elif zip_code:
|
||||||
|
data = list_count_zipcode(zip_codes=[zip_code])
|
||||||
|
else:
|
||||||
|
raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
|
||||||
|
rows = extract_crrt_rows(data)
|
||||||
|
priced = []
|
||||||
|
for row in rows:
|
||||||
|
per_piece, source = _price(row["total"])
|
||||||
|
priced.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"per_piece": per_piece,
|
||||||
|
"cost": money_total(per_piece, row["total"]),
|
||||||
|
"price_source": source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"mail_class": mail_class,
|
||||||
|
"zip": zip_code,
|
||||||
|
"routes": priced,
|
||||||
|
"discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "select":
|
||||||
|
codes = [
|
||||||
|
str(c).strip()
|
||||||
|
for c in (payload.get("routes") or [])
|
||||||
|
if str(c).strip()
|
||||||
|
]
|
||||||
|
codes = list(dict.fromkeys(codes))
|
||||||
|
if not codes:
|
||||||
|
raise ValueError("Select at least one carrier route.")
|
||||||
|
if len(codes) > MAX_SELECTED_ROUTES:
|
||||||
|
raise ValueError("Pick at most two carrier routes.")
|
||||||
|
data = list_count_carrier_route(carrier_routes=codes)
|
||||||
|
rows = extract_crrt_rows(data)
|
||||||
|
if not rows:
|
||||||
|
try:
|
||||||
|
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
record_count = 0
|
||||||
|
rows = [{"code": code, "text": code, "total": 0} for code in codes]
|
||||||
|
if record_count and len(rows) == 1:
|
||||||
|
rows[0]["total"] = record_count
|
||||||
|
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
||||||
|
if not record_count:
|
||||||
|
record_count = sum(int(r.get("total") or 0) for r in rows)
|
||||||
|
per_piece, source = _price(record_count)
|
||||||
|
priced = []
|
||||||
|
for row in rows:
|
||||||
|
total = int(row.get("total") or 0)
|
||||||
|
priced.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"per_piece": per_piece,
|
||||||
|
"cost": money_total(per_piece, total),
|
||||||
|
"price_source": source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
list_count_id = data.get("listCountID") or data.get("list_count_id")
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"mail_class": mail_class,
|
||||||
|
"selected_codes": codes,
|
||||||
|
"routes": priced,
|
||||||
|
"route_rows": priced,
|
||||||
|
"list_count_id": list_count_id,
|
||||||
|
"record_count": record_count,
|
||||||
|
"per_piece": per_piece,
|
||||||
|
"total": money_total(per_piece, record_count),
|
||||||
|
"price_source": source,
|
||||||
|
"estimated": {
|
||||||
|
"per_piece": per_piece,
|
||||||
|
"pieces": record_count,
|
||||||
|
"total": money_total(per_piece, record_count),
|
||||||
|
"currency": "USD",
|
||||||
|
"source": source,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown quote action: {action}")
|
||||||
|
|
||||||
|
|
||||||
def create_campaign_draft(
|
def create_campaign_draft(
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -93,9 +299,11 @@ def create_campaign_draft(
|
|||||||
scheduled_for=None,
|
scheduled_for=None,
|
||||||
created_by: AbstractBaseUser | None = None,
|
created_by: AbstractBaseUser | None = None,
|
||||||
template: MessageTemplate | None = None,
|
template: MessageTemplate | None = None,
|
||||||
|
route_targeting: dict | None = None,
|
||||||
) -> Campaign:
|
) -> Campaign:
|
||||||
"""Persist a draft campaign and per-recipient Message stubs."""
|
"""Persist a draft campaign and per-recipient Message stubs."""
|
||||||
channel = channel_for_audience(audience)
|
channel = channel_for_audience(audience)
|
||||||
|
targeting = route_targeting if isinstance(route_targeting, dict) else {}
|
||||||
campaign = Campaign.objects.create(
|
campaign = Campaign.objects.create(
|
||||||
name=name,
|
name=name,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -106,7 +314,18 @@ def create_campaign_draft(
|
|||||||
body_override=body,
|
body_override=body,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
template=template,
|
template=template,
|
||||||
|
route_targeting=targeting,
|
||||||
)
|
)
|
||||||
|
if audience == Campaign.Audience.CARRIER_ROUTE:
|
||||||
|
Message.objects.create(
|
||||||
|
campaign=campaign,
|
||||||
|
contact=None,
|
||||||
|
channel=channel,
|
||||||
|
status=Message.Status.DRAFT,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
body_snapshot=body,
|
||||||
|
)
|
||||||
|
return campaign
|
||||||
contacts = list(opted_in_contacts(channel))
|
contacts = list(opted_in_contacts(channel))
|
||||||
Message.objects.bulk_create(
|
Message.objects.bulk_create(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ def send_campaign_message(message_id: str) -> None:
|
|||||||
|
|
||||||
campaign = message.campaign
|
campaign = message.campaign
|
||||||
|
|
||||||
if not contact_may_receive(message.contact, message.channel):
|
if message.contact_id is None:
|
||||||
|
pass
|
||||||
|
elif not contact_may_receive(message.contact, message.channel):
|
||||||
message.status = Message.Status.SUPPRESSED
|
message.status = Message.Status.SUPPRESSED
|
||||||
message.error = "Contact opted out or suppressed"
|
message.error = "Contact opted out or suppressed"
|
||||||
message.save(update_fields=["status", "error", "updated_at"])
|
message.save(update_fields=["status", "error", "updated_at"])
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<div class="field" id="postcard-list-cost" hidden></div>
|
||||||
|
<div id="postcard-route-picker" hidden>
|
||||||
|
<p class="hint-block">
|
||||||
|
PCM mails a purchased occupant list on the USPS carrier routes you pick
|
||||||
|
(First Class / Standard — not official EDDM postage). Cost is per piece
|
||||||
|
times households.
|
||||||
|
</p>
|
||||||
|
<div class="form-grid cols-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="route-zip">ZIP code</label>
|
||||||
|
<input id="route-zip" type="text" inputmode="numeric" maxlength="10"
|
||||||
|
placeholder="60540" autocomplete="postal-code">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="route-radius">Radius miles <span class="muted">(optional)</span></label>
|
||||||
|
<input id="route-radius" type="number" min="0" step="0.5" placeholder="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid cols-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="route-address">Street <span class="muted">(optional, with radius)</span></label>
|
||||||
|
<input id="route-address" type="text" placeholder="1245 Butterfield Rd">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="route-city">City</label>
|
||||||
|
<input id="route-city" type="text" placeholder="Wheaton">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="max-width:120px">
|
||||||
|
<label for="route-state">State</label>
|
||||||
|
<input id="route-state" type="text" maxlength="2" placeholder="IL">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button>
|
||||||
|
<p class="hint" id="route-status" style="margin-top:8px"></p>
|
||||||
|
<div id="route-table-wrap" hidden style="margin-top:12px">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Route</th>
|
||||||
|
<th>Area</th>
|
||||||
|
<th>Households</th>
|
||||||
|
<th>Est. cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="route-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint-block" id="route-selected-cost" hidden style="margin-top:12px"></p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="route_targeting" id="id_route_targeting" value="{{ form_data.route_targeting }}">
|
||||||
@@ -36,11 +36,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_send %}
|
{% if can_send %}
|
||||||
<form method="post" action="{% url 'directmail:campaign_send' campaign.pk %}"
|
<form method="post" action="{% url 'directmail:campaign_send' campaign.pk %}"
|
||||||
onsubmit="return confirm('Send this campaign to all remaining recipients now?');">
|
onsubmit="return confirm('{% if campaign.audience == "carrier_route" %}Place this PCM carrier-route postcard order now?{% else %}Send this campaign to all remaining recipients now?{% endif %}');">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-primary" type="submit">Send now to recipients</button>
|
<button class="btn btn-primary" type="submit">{% if campaign.audience == "carrier_route" %}Place route order{% else %}Send now to recipients{% endif %}</button>
|
||||||
<p class="hint-block" style="margin-top:8px">
|
<p class="hint-block" style="margin-top:8px">
|
||||||
{% if campaign.channel == "postcard" %}
|
{% if campaign.audience == "carrier_route" %}
|
||||||
|
Places one PCM list-count order for the selected USPS routes (not per contact).
|
||||||
|
{% elif campaign.channel == "postcard" %}
|
||||||
Enqueues draft / scheduled / failed messages via PCM Integrations.
|
Enqueues draft / scheduled / failed messages via PCM Integrations.
|
||||||
{% else %}
|
{% else %}
|
||||||
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
|
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
|
||||||
@@ -52,6 +54,35 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if campaign.audience == "carrier_route" %}
|
||||||
|
<div class="panel" style="margin-bottom:16px">
|
||||||
|
<div class="panel-h"><h2>Carrier routes</h2></div>
|
||||||
|
<div class="panel-b">
|
||||||
|
{% with t=campaign.route_targeting %}
|
||||||
|
<p style="margin-top:0">
|
||||||
|
{{ t.record_count|default:0 }} household{{ t.record_count|default:0|pluralize }}
|
||||||
|
· {{ t.mail_class|default:"Standard" }}
|
||||||
|
{% if t.estimated.total != None %}
|
||||||
|
· est. ${{ t.estimated.total }}
|
||||||
|
{% if t.estimated.per_piece %} (${{ t.estimated.per_piece }} / piece){% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="margin:0">
|
||||||
|
Routes:
|
||||||
|
{% for code in t.selected_codes %}
|
||||||
|
<code>{{ code }}</code>{% if not forloop.last %}, {% endif %}
|
||||||
|
{% empty %}
|
||||||
|
—
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% if t.estimated.source == "fallback" %}
|
||||||
|
<p class="hint-block">Cost uses env fallback rates (PCM did not return design pricing).</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="panel" style="margin-bottom:16px">
|
<div class="panel" style="margin-bottom:16px">
|
||||||
<div class="panel-h"><h2>Tracked site link</h2></div>
|
<div class="panel-h"><h2>Tracked site link</h2></div>
|
||||||
<div class="panel-b">
|
<div class="panel-b">
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
<div class="hint">Date & time · leave blank to keep as unscheduled draft</div>
|
<div class="hint">Date & time · leave blank to keep as unscheduled draft</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% include "directmail/_postcard_route_picker.html" %}
|
||||||
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
||||||
{% include "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %}
|
{% include "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %}
|
||||||
<button class="btn btn-primary" type="submit">Save draft</button>
|
<button class="btn btn-primary" type="submit">Save draft</button>
|
||||||
@@ -184,6 +185,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||||
<script src="{% static 'js/campaign-utm.js' %}"></script>
|
<script src="{% static 'js/campaign-utm.js' %}"></script>
|
||||||
|
<script src="{% static 'js/postcard-routes.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var uploadUrl = "{{ image_upload_url|escapejs }}";
|
var uploadUrl = "{{ image_upload_url|escapejs }}";
|
||||||
@@ -219,7 +221,7 @@
|
|||||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
var isEmail = audience === 'email_opt_in';
|
var isEmail = audience === 'email_opt_in';
|
||||||
var isPostcard = audience === 'postcard_opt_in';
|
var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
|
||||||
var body = '';
|
var body = '';
|
||||||
if (isEmail && quill) {
|
if (isEmail && quill) {
|
||||||
body = quill.root.innerHTML;
|
body = quill.root.innerHTML;
|
||||||
@@ -257,7 +259,7 @@
|
|||||||
|
|
||||||
window.syncComposeChannel = function () {
|
window.syncComposeChannel = function () {
|
||||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
var isPostcard = audience === 'postcard_opt_in';
|
var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
|
||||||
var isSms = audience === 'sms_opt_in';
|
var isSms = audience === 'sms_opt_in';
|
||||||
var isEmail = audience === 'email_opt_in';
|
var isEmail = audience === 'email_opt_in';
|
||||||
var tmplField = document.getElementById('postcard-template-field');
|
var tmplField = document.getElementById('postcard-template-field');
|
||||||
@@ -290,6 +292,9 @@
|
|||||||
});
|
});
|
||||||
var panel = document.getElementById('utm-link-panel');
|
var panel = document.getElementById('utm-link-panel');
|
||||||
if (panel && panel._utmApply) panel._utmApply();
|
if (panel && panel._utmApply) panel._utmApply();
|
||||||
|
if (window.MonicaPostcardRoutes && window.MonicaPostcardRoutes.syncVisibility) {
|
||||||
|
window.MonicaPostcardRoutes.syncVisibility();
|
||||||
|
}
|
||||||
syncCampaignPreview();
|
syncCampaignPreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,7 +358,7 @@
|
|||||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
if (audience === 'email_opt_in') syncBodyFromQuill();
|
if (audience === 'email_opt_in') syncBodyFromQuill();
|
||||||
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
|
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
|
||||||
else if (audience === 'postcard_opt_in') {
|
else if (audience === 'postcard_opt_in' || audience === 'carrier_route') {
|
||||||
var pc = document.getElementById('id_body_pc');
|
var pc = document.getElementById('id_body_pc');
|
||||||
bodyField.value = pc ? pc.value : '';
|
bodyField.value = pc ? pc.value : '';
|
||||||
}
|
}
|
||||||
@@ -377,6 +382,10 @@
|
|||||||
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
||||||
|
|
||||||
initQuill();
|
initQuill();
|
||||||
|
window.initPostcardRoutes({
|
||||||
|
quoteUrl: "{{ postcard_quote_url|escapejs }}",
|
||||||
|
csrfToken: csrfToken
|
||||||
|
});
|
||||||
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
|
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
|
||||||
getName: function () {
|
getName: function () {
|
||||||
return (document.getElementById('id_name') || {}).value || '';
|
return (document.getElementById('id_name') || {}).value || '';
|
||||||
|
|||||||
+204
-4
@@ -239,8 +239,7 @@ class PostcardDesignPickTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="pcm-pick", password="test-pass-123"
|
username="pcm-pick", password="test-pass-123", is_staff=True)
|
||||||
)
|
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="pcm-pick", password="test-pass-123")
|
self.client.login(username="pcm-pick", password="test-pass-123")
|
||||||
self.contact = Contact.objects.create(
|
self.contact = Contact.objects.create(
|
||||||
@@ -283,8 +282,7 @@ class PostcardUtmPanelTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="utm-mail", password="test-pass-123"
|
username="utm-mail", password="test-pass-123", is_staff=True)
|
||||||
)
|
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="utm-mail", password="test-pass-123")
|
self.client.login(username="utm-mail", password="test-pass-123")
|
||||||
self.contact = Contact.objects.create(
|
self.contact = Contact.objects.create(
|
||||||
@@ -307,6 +305,208 @@ class PostcardUtmPanelTests(TestCase):
|
|||||||
self.assertContains(response, "data-utm-qr")
|
self.assertContains(response, "data-utm-qr")
|
||||||
self.assertContains(response, reverse("campaign_short_link"))
|
self.assertContains(response, reverse("campaign_short_link"))
|
||||||
|
|
||||||
|
|
||||||
|
class CarrierRoutePostcardTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="routes", password="test-pass-123"
|
||||||
|
)
|
||||||
|
self.client = Client()
|
||||||
|
self.client.login(username="routes", password="test-pass-123")
|
||||||
|
self.contact = Contact.objects.create(
|
||||||
|
email="mail@example.com",
|
||||||
|
postal_address=Contact.make_postal_address(line1="9 Main"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pick_unit_price_uses_break_qty_and_fallback(self):
|
||||||
|
from directmail.providers.postcard import pcm as pcm_mod
|
||||||
|
|
||||||
|
price, source = pcm_mod.pick_unit_price(
|
||||||
|
[
|
||||||
|
{"mailClass": "Standard", "price": 0.8, "breakQty": 1},
|
||||||
|
{"mailClass": "Standard", "price": 0.5, "breakQty": 200},
|
||||||
|
],
|
||||||
|
mail_class="Standard",
|
||||||
|
quantity=250,
|
||||||
|
)
|
||||||
|
self.assertEqual(price, 0.5)
|
||||||
|
self.assertEqual(source, "pcm")
|
||||||
|
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.42"):
|
||||||
|
price, source = pcm_mod.pick_unit_price(
|
||||||
|
[], mail_class="Standard", quantity=10
|
||||||
|
)
|
||||||
|
self.assertEqual(price, 0.42)
|
||||||
|
self.assertEqual(source, "fallback")
|
||||||
|
|
||||||
|
def test_quote_search_returns_route_costs(self):
|
||||||
|
payload = {
|
||||||
|
"listCountID": 11,
|
||||||
|
"recordCount": 300,
|
||||||
|
"breakdown": [
|
||||||
|
{
|
||||||
|
"breakdownType": "ZipCRRT",
|
||||||
|
"data": [
|
||||||
|
{"code": "60540:C001", "text": "NAPERVILLE", "total": 120},
|
||||||
|
{"code": "60540:C002", "text": "NAPERVILLE", "total": 180},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
|
||||||
|
with patch(
|
||||||
|
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"directmail.providers.postcard.pcm.list_count_zipcode",
|
||||||
|
return_value=payload,
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("directmail:postcard_quote"),
|
||||||
|
data='{"action":"search","zip":"60540","size":"68"}',
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data["routes"]), 2)
|
||||||
|
self.assertEqual(data["routes"][0]["cost"], 60.0)
|
||||||
|
self.assertEqual(data["mail_class"], "Standard")
|
||||||
|
|
||||||
|
def test_quote_select_and_save_draft(self):
|
||||||
|
import json
|
||||||
|
|
||||||
|
select_payload = {
|
||||||
|
"listCountID": 99,
|
||||||
|
"recordCount": 300,
|
||||||
|
"breakdown": [
|
||||||
|
{
|
||||||
|
"breakdownType": "ZipCRRT",
|
||||||
|
"data": [
|
||||||
|
{"code": "60540:C001", "text": "A", "total": 120},
|
||||||
|
{"code": "60540:C002", "text": "B", "total": 180},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
|
||||||
|
with patch(
|
||||||
|
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"directmail.providers.postcard.pcm.list_count_carrier_route",
|
||||||
|
return_value=select_payload,
|
||||||
|
):
|
||||||
|
quoted = self.client.post(
|
||||||
|
reverse("directmail:postcard_quote"),
|
||||||
|
data=(
|
||||||
|
'{"action":"select","size":"68",'
|
||||||
|
'"routes":["60540:C001","60540:C002"]}'
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(quoted.status_code, 200)
|
||||||
|
quote = quoted.json()
|
||||||
|
self.assertEqual(quote["list_count_id"], 99)
|
||||||
|
self.assertEqual(quote["total"], 150.0)
|
||||||
|
|
||||||
|
targeting = {
|
||||||
|
"selected_codes": quote["selected_codes"],
|
||||||
|
"list_count_id": quote["list_count_id"],
|
||||||
|
"record_count": quote["record_count"],
|
||||||
|
"mail_class": quote["mail_class"],
|
||||||
|
"estimated": quote["estimated"],
|
||||||
|
"route_rows": quote["routes"],
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"directmail.views._fetch_pcm_designs",
|
||||||
|
return_value=(
|
||||||
|
[{"design_id": "42", "name": "Spring card", "size": "68"}],
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("directmail:campaign_list"),
|
||||||
|
{
|
||||||
|
"name": "Farm drop",
|
||||||
|
"audience": Campaign.Audience.CARRIER_ROUTE,
|
||||||
|
"template_id": "d:42",
|
||||||
|
"route_targeting": json.dumps(targeting),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
campaign = Campaign.objects.get(name="Farm drop")
|
||||||
|
self.assertEqual(campaign.audience, Campaign.Audience.CARRIER_ROUTE)
|
||||||
|
self.assertEqual(campaign.route_targeting["list_count_id"], 99)
|
||||||
|
self.assertEqual(campaign.messages.count(), 1)
|
||||||
|
self.assertIsNone(campaign.messages.get().contact_id)
|
||||||
|
|
||||||
|
def test_send_uses_list_count_endpoint(self):
|
||||||
|
from directmail.providers.postcard import pcm as pcm_mod
|
||||||
|
from directmail.services import enqueue_campaign_send
|
||||||
|
|
||||||
|
template = MessageTemplate.objects.create(
|
||||||
|
name="Route design",
|
||||||
|
channel=Channel.POSTCARD,
|
||||||
|
body="Postcard",
|
||||||
|
postcard_front={"design_id": 42, "size": "68", "provider": "pcm"},
|
||||||
|
)
|
||||||
|
campaign = create_campaign_draft(
|
||||||
|
name="Routes",
|
||||||
|
audience=Campaign.Audience.CARRIER_ROUTE,
|
||||||
|
body="note",
|
||||||
|
created_by=self.user,
|
||||||
|
template=template,
|
||||||
|
route_targeting={
|
||||||
|
"selected_codes": ["60540:C001"],
|
||||||
|
"list_count_id": 77,
|
||||||
|
"record_count": 50,
|
||||||
|
"mail_class": "Standard",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pcm_mod.clear_token_cache()
|
||||||
|
with self.settings(
|
||||||
|
PCM_API_KEY="key",
|
||||||
|
PCM_API_SECRET="secret",
|
||||||
|
PCM_RETURN_ADDRESS=(
|
||||||
|
'{"firstName":"Mo","lastName":"D","address":"1 Main",'
|
||||||
|
'"city":"Naperville","state":"IL","zipCode":"60540"}'
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with patch("directmail.providers.postcard.pcm.login", return_value="tok"):
|
||||||
|
with patch(
|
||||||
|
"directmail.providers.postcard.pcm.pcm_request",
|
||||||
|
return_value={"orderID": 555},
|
||||||
|
) as req:
|
||||||
|
enqueue_campaign_send(campaign)
|
||||||
|
path = req.call_args.args[1]
|
||||||
|
self.assertEqual(path, "/order/postcard/with-list-count")
|
||||||
|
body = req.call_args.kwargs["json_body"]
|
||||||
|
self.assertEqual(body["listCountID"], 77)
|
||||||
|
self.assertEqual(body["recordCount"], 50)
|
||||||
|
campaign.refresh_from_db()
|
||||||
|
self.assertEqual(campaign.messages.get().provider_message_id, "555")
|
||||||
|
|
||||||
|
def test_mailing_list_postcard_still_creates_contacts(self):
|
||||||
|
with patch(
|
||||||
|
"directmail.views._fetch_pcm_designs",
|
||||||
|
return_value=(
|
||||||
|
[{"design_id": "42", "name": "Spring card", "size": "46"}],
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("directmail:campaign_list"),
|
||||||
|
{
|
||||||
|
"name": "List mailer",
|
||||||
|
"audience": Campaign.Audience.POSTCARD_OPT_IN,
|
||||||
|
"template_id": "d:42",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
campaign = Campaign.objects.get(name="List mailer")
|
||||||
|
self.assertEqual(campaign.messages.count(), 1)
|
||||||
|
self.assertEqual(campaign.messages.get().contact_id, self.contact.pk)
|
||||||
|
|
||||||
def test_postcard_does_not_inject_into_body(self):
|
def test_postcard_does_not_inject_into_body(self):
|
||||||
campaign = create_campaign_draft(
|
campaign = create_campaign_draft(
|
||||||
name="March mailer",
|
name="March mailer",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ app_name = "directmail"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("campaigns/", views.campaign_list, name="campaign_list"),
|
path("campaigns/", views.campaign_list, name="campaign_list"),
|
||||||
|
path(
|
||||||
|
"campaigns/postcard-quote/",
|
||||||
|
views.postcard_quote,
|
||||||
|
name="postcard_quote",
|
||||||
|
),
|
||||||
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
|
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
|
||||||
path(
|
path(
|
||||||
"campaigns/<uuid:pk>/status.json",
|
"campaigns/<uuid:pk>/status.json",
|
||||||
|
|||||||
+92
-12
@@ -1,6 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -25,6 +26,7 @@ from directmail.providers.postcard.pcm import (
|
|||||||
design_id_from_template,
|
design_id_from_template,
|
||||||
get_design_embed_url,
|
get_design_embed_url,
|
||||||
list_designs,
|
list_designs,
|
||||||
|
mail_class_for_size,
|
||||||
)
|
)
|
||||||
from contacts.consent import opted_in_contacts
|
from contacts.consent import opted_in_contacts
|
||||||
from core.campaign_utm import utm_panel_context
|
from core.campaign_utm import utm_panel_context
|
||||||
@@ -33,6 +35,8 @@ from directmail.services import (
|
|||||||
create_campaign_draft,
|
create_campaign_draft,
|
||||||
enqueue_campaign_send,
|
enqueue_campaign_send,
|
||||||
message_is_removable,
|
message_is_removable,
|
||||||
|
parse_route_targeting,
|
||||||
|
postcard_quote as build_postcard_quote,
|
||||||
)
|
)
|
||||||
from directmail.webhooks import (
|
from directmail.webhooks import (
|
||||||
PROVIDER_EMAIL,
|
PROVIDER_EMAIL,
|
||||||
@@ -68,6 +72,12 @@ def _audience_choices() -> list[tuple[str, str]]:
|
|||||||
choices.append(
|
choices.append(
|
||||||
(value, f"Mailing list · {label} opt-in ({count} {noun})")
|
(value, f"Mailing list · {label} opt-in ({count} {noun})")
|
||||||
)
|
)
|
||||||
|
choices.append(
|
||||||
|
(
|
||||||
|
Campaign.Audience.CARRIER_ROUTE,
|
||||||
|
"USPS carrier routes (find by ZIP)",
|
||||||
|
)
|
||||||
|
)
|
||||||
return choices
|
return choices
|
||||||
|
|
||||||
|
|
||||||
@@ -224,10 +234,14 @@ def _message_destination(message) -> str:
|
|||||||
contact = message.contact
|
contact = message.contact
|
||||||
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
|
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
|
||||||
if channel == Channel.EMAIL:
|
if channel == Channel.EMAIL:
|
||||||
return (contact.email or "").strip()
|
return (contact.email or "").strip() if contact else ""
|
||||||
if channel == Channel.SMS:
|
if channel == Channel.SMS:
|
||||||
return (contact.phone or "").strip()
|
return (contact.phone or "").strip() if contact else ""
|
||||||
if channel == Channel.POSTCARD:
|
if channel == Channel.POSTCARD:
|
||||||
|
if not contact:
|
||||||
|
targeting = getattr(message.campaign, "route_targeting", None) or {}
|
||||||
|
codes = targeting.get("selected_codes") or []
|
||||||
|
return ", ".join(str(c) for c in codes) or "Carrier routes"
|
||||||
return _format_postal_address(contact.postal_address)
|
return _format_postal_address(contact.postal_address)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -423,6 +437,7 @@ def campaign_list(request):
|
|||||||
"audience": Campaign.Audience.POSTCARD_OPT_IN,
|
"audience": Campaign.Audience.POSTCARD_OPT_IN,
|
||||||
"scheduled_for": "",
|
"scheduled_for": "",
|
||||||
"template_id": "",
|
"template_id": "",
|
||||||
|
"route_targeting": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -432,6 +447,7 @@ def campaign_list(request):
|
|||||||
audience = (request.POST.get("audience") or "").strip()
|
audience = (request.POST.get("audience") or "").strip()
|
||||||
scheduled_raw = request.POST.get("scheduled_for") or ""
|
scheduled_raw = request.POST.get("scheduled_for") or ""
|
||||||
template_id = (request.POST.get("template_id") or "").strip()
|
template_id = (request.POST.get("template_id") or "").strip()
|
||||||
|
route_raw = request.POST.get("route_targeting") or ""
|
||||||
|
|
||||||
form_data.update(
|
form_data.update(
|
||||||
{
|
{
|
||||||
@@ -441,10 +457,12 @@ def campaign_list(request):
|
|||||||
"audience": audience,
|
"audience": audience,
|
||||||
"scheduled_for": scheduled_raw,
|
"scheduled_for": scheduled_raw,
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
|
"route_targeting": route_raw,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
template = None
|
template = None
|
||||||
|
targeting: dict = {}
|
||||||
if template_id:
|
if template_id:
|
||||||
template = _resolve_postcard_template(template_id)
|
template = _resolve_postcard_template(template_id)
|
||||||
|
|
||||||
@@ -459,6 +477,18 @@ def campaign_list(request):
|
|||||||
)
|
)
|
||||||
if not body:
|
if not body:
|
||||||
body = "Postcard mailing"
|
body = "Postcard mailing"
|
||||||
|
elif audience == Campaign.Audience.CARRIER_ROUTE:
|
||||||
|
if not template or template.channel != Channel.POSTCARD:
|
||||||
|
form_errors.append(
|
||||||
|
"Choose a postcard design (create one under Postcard design)."
|
||||||
|
)
|
||||||
|
if not body:
|
||||||
|
body = "Carrier route postcard mailing"
|
||||||
|
try:
|
||||||
|
targeting = parse_route_targeting(route_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
form_errors.append(str(exc))
|
||||||
|
targeting = {}
|
||||||
|
|
||||||
scheduled_for = None
|
scheduled_for = None
|
||||||
try:
|
try:
|
||||||
@@ -475,14 +505,25 @@ def campaign_list(request):
|
|||||||
scheduled_for=scheduled_for,
|
scheduled_for=scheduled_for,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
template=template,
|
template=template,
|
||||||
|
route_targeting=targeting,
|
||||||
)
|
)
|
||||||
recipient_count = campaign.messages.count()
|
if audience == Campaign.Audience.CARRIER_ROUTE:
|
||||||
messages.success(
|
pieces = targeting.get("record_count") or 0
|
||||||
request,
|
total = (targeting.get("estimated") or {}).get("total")
|
||||||
f'Draft “{campaign.name}” saved '
|
cost_bit = f", est. ${total:.2f}" if isinstance(total, (int, float)) else ""
|
||||||
f"({recipient_count} recipient"
|
messages.success(
|
||||||
f"{'' if recipient_count == 1 else 's'}).",
|
request,
|
||||||
)
|
f'Draft “{campaign.name}” saved '
|
||||||
|
f"({pieces} household{'' if pieces == 1 else 's'}{cost_bit}).",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
recipient_count = campaign.messages.count()
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f'Draft “{campaign.name}” saved '
|
||||||
|
f"({recipient_count} recipient"
|
||||||
|
f"{'' if recipient_count == 1 else 's'}).",
|
||||||
|
)
|
||||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||||
|
|
||||||
campaigns = Campaign.objects.all()[:100]
|
campaigns = Campaign.objects.all()[:100]
|
||||||
@@ -497,10 +538,46 @@ def campaign_list(request):
|
|||||||
"form_errors": form_errors,
|
"form_errors": form_errors,
|
||||||
"image_upload_url": reverse("directmail:campaign_image_upload"),
|
"image_upload_url": reverse("directmail:campaign_image_upload"),
|
||||||
**utm_panel_context(live=True, medium=Channel.POSTCARD),
|
**utm_panel_context(live=True, medium=Channel.POSTCARD),
|
||||||
|
"postcard_quote_url": reverse("directmail:postcard_quote"),
|
||||||
|
"postcard_list_count": opted_in_contacts(Channel.POSTCARD).count(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def postcard_quote(request):
|
||||||
|
"""Search PCM carrier routes or price a postcard audience."""
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body.decode() or "{}")
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
payload = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
template_id = str(payload.get("template_id") or "").strip()
|
||||||
|
if template_id:
|
||||||
|
template = _resolve_postcard_template(template_id)
|
||||||
|
if template:
|
||||||
|
did = design_id_from_template(template)
|
||||||
|
if did and not payload.get("design_id"):
|
||||||
|
payload["design_id"] = did
|
||||||
|
front = template.postcard_front or {}
|
||||||
|
if not payload.get("size"):
|
||||||
|
payload["size"] = front.get("size") or ""
|
||||||
|
if not payload.get("mail_class") and front.get("mail_class"):
|
||||||
|
payload["mail_class"] = front.get("mail_class")
|
||||||
|
elif not payload.get("mail_class"):
|
||||||
|
payload["mail_class"] = mail_class_for_size(payload.get("size"))
|
||||||
|
action = str(payload.get("action") or "mailing_list").strip()
|
||||||
|
try:
|
||||||
|
result = build_postcard_quote(action=action, payload=payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
return JsonResponse({"detail": str(exc)}, status=400)
|
||||||
|
except PcmApiError as exc:
|
||||||
|
return JsonResponse({"detail": str(exc)}, status=502)
|
||||||
|
return JsonResponse(result)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def campaign_detail(request, pk):
|
def campaign_detail(request, pk):
|
||||||
campaign = get_object_or_404(Campaign, pk=pk)
|
campaign = get_object_or_404(Campaign, pk=pk)
|
||||||
@@ -536,6 +613,7 @@ def campaign_detail(request, pk):
|
|||||||
"suppressed",
|
"suppressed",
|
||||||
}
|
}
|
||||||
).exists(),
|
).exists(),
|
||||||
|
"route_targeting": campaign.route_targeting or {},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -566,7 +644,9 @@ def campaign_status_json(request, pk):
|
|||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"id": str(m.pk),
|
"id": str(m.pk),
|
||||||
"contact": str(m.contact),
|
"contact": (
|
||||||
|
str(m.contact) if m.contact_id else "Carrier route mailing"
|
||||||
|
),
|
||||||
"destination": getattr(m, "destination", "") or "",
|
"destination": getattr(m, "destination", "") or "",
|
||||||
"status": m.status,
|
"status": m.status,
|
||||||
"status_display": m.get_status_display(),
|
"status_display": m.get_status_display(),
|
||||||
@@ -579,7 +659,7 @@ def campaign_status_json(request, pk):
|
|||||||
"events": [
|
"events": [
|
||||||
{
|
{
|
||||||
"event_type": e.event_type,
|
"event_type": e.event_type,
|
||||||
"contact": str(e.message.contact) if e.message_id else "—",
|
"contact": str(e.message.contact) if e.message_id and e.message.contact_id else "—",
|
||||||
"created_at": e.created_at.isoformat(),
|
"created_at": e.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
for e in ctx["recent_events"]
|
for e in ctx["recent_events"]
|
||||||
@@ -601,7 +681,7 @@ def campaign_message_remove(request, pk, message_id):
|
|||||||
)
|
)
|
||||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||||
|
|
||||||
label = str(message.contact)
|
label = str(message.contact) if message.contact_id else "carrier-route mailing"
|
||||||
message.delete()
|
message.delete()
|
||||||
messages.success(request, f"Removed {label} from this campaign.")
|
messages.success(request, f"Removed {label} from this campaign.")
|
||||||
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
|
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
|
||||||
|
|||||||
+7
-13
@@ -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(
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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",)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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(
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 6.1 on 2026-09-06 11:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='POSConnection',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=120)),
|
||||||
|
('provider', models.CharField(choices=[('generic', 'Generic REST'), ('square', 'Square'), ('lightspeed', 'Lightspeed'), ('binderpos', 'BinderPOS'), ('crystalcommerce', 'CrystalCommerce')], default='generic', max_length=32)),
|
||||||
|
('api_base_url', models.URLField(blank=True)),
|
||||||
|
('api_token', models.CharField(blank=True, max_length=255)),
|
||||||
|
('webhook_secret', models.CharField(blank=True, max_length=255)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SyncEvent',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('direction', models.CharField(choices=[('inbound', 'POS → site'), ('outbound', 'Site → POS')], max_length=16)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||||
|
('sku', models.CharField(max_length=64)),
|
||||||
|
('quantity', models.IntegerField(default=0)),
|
||||||
|
('external_id', models.CharField(blank=True, max_length=255)),
|
||||||
|
('payload', models.JSONField(blank=True, default=dict)),
|
||||||
|
('error', models.TextField(blank=True)),
|
||||||
|
('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='pos_sync.posconnection')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
Whitespace-only changes.
@@ -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}"
|
||||||
@@ -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 %}
|
||||||
@@ -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)
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"}),
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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 = (
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 6.1 on 2026-09-06 11:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Shipment',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('rated', 'Rated'), ('labeled', 'Labeled'), ('void', 'Void')], default='draft', max_length=16)),
|
||||||
|
('carrier', models.CharField(blank=True, max_length=32)),
|
||||||
|
('service', models.CharField(blank=True, max_length=64)),
|
||||||
|
('tracking_number', models.CharField(blank=True, max_length=64)),
|
||||||
|
('label_url', models.URLField(blank=True)),
|
||||||
|
('rate_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('currency', models.CharField(default='usd', max_length=8)),
|
||||||
|
('provider_shipment_id', models.CharField(blank=True, max_length=255)),
|
||||||
|
('rates', models.JSONField(blank=True, default=list)),
|
||||||
|
('weight_oz', models.PositiveIntegerField(default=16)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shipments', to='shop.order')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 6.1
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("shipping", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="last_tracked_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracker_id",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracking_events",
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracking_status",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("unknown", "Unknown"),
|
||||||
|
("pre_transit", "Pre-transit"),
|
||||||
|
("in_transit", "In transit"),
|
||||||
|
("out_for_delivery", "Out for delivery"),
|
||||||
|
("delivered", "Delivered"),
|
||||||
|
("available_for_pickup", "Available for pickup"),
|
||||||
|
("return_to_sender", "Return to sender"),
|
||||||
|
("failure", "Exception"),
|
||||||
|
("cancelled", "Cancelled"),
|
||||||
|
("error", "Error"),
|
||||||
|
],
|
||||||
|
default="unknown",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracking_url",
|
||||||
|
field=models.URLField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
Whitespace-only changes.
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user