Template
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b11cc18c7 | ||
|
|
9cdce7a897 |
@@ -88,6 +88,7 @@ POS_API_TOKEN=
|
|||||||
|
|
||||||
# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works.
|
# Shipping — FEATURE_SHIPPING (requires shop). EasyPost optional; CSV export always works.
|
||||||
EASYPOST_API_KEY=
|
EASYPOST_API_KEY=
|
||||||
|
EASYPOST_WEBHOOK_SECRET=
|
||||||
SHIP_FROM_LINE1=
|
SHIP_FROM_LINE1=
|
||||||
SHIP_FROM_CITY=
|
SHIP_FROM_CITY=
|
||||||
SHIP_FROM_STATE=
|
SHIP_FROM_STATE=
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ POS_API_TOKEN=
|
|||||||
|
|
||||||
# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP)
|
# Optional when FEATURE_SHIPPING=true (also requires FEATURE_SHOP)
|
||||||
EASYPOST_API_KEY=
|
EASYPOST_API_KEY=
|
||||||
|
EASYPOST_WEBHOOK_SECRET=
|
||||||
SHIP_FROM_LINE1=
|
SHIP_FROM_LINE1=
|
||||||
SHIP_FROM_CITY=
|
SHIP_FROM_CITY=
|
||||||
SHIP_FROM_STATE=
|
SHIP_FROM_STATE=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: Deploy Beta
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit-tests:
|
unit-tests:
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||||
DJANGO_ALLOWED_HOSTS || true
|
DJANGO_ALLOWED_HOSTS || true
|
||||||
|
|
||||||
PROJECT="client_site-ci-${{ gitea.sha }}"
|
PROJECT="client-ci-${{ gitea.sha }}"
|
||||||
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -66,7 +66,6 @@ jobs:
|
|||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||||
steps:
|
steps:
|
||||||
# --app MUST match server-infra app_catalog (this package slug).
|
|
||||||
- name: Deploy client_site beta to all webservers
|
- name: Deploy client_site beta to all webservers
|
||||||
run: |
|
run: |
|
||||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||||
DJANGO_ALLOWED_HOSTS || true
|
DJANGO_ALLOWED_HOSTS || true
|
||||||
|
|
||||||
PROJECT="client_site-ci-${{ gitea.sha }}"
|
PROJECT="client-ci-${{ gitea.sha }}"
|
||||||
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -64,7 +64,6 @@ jobs:
|
|||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||||
steps:
|
steps:
|
||||||
# --app MUST match server-infra app_catalog (this package slug).
|
|
||||||
- name: Deploy client_site prod to all webservers
|
- name: Deploy client_site prod to all webservers
|
||||||
run: |
|
run: |
|
||||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ cp .env.prod.example ~/Documents/secrets/acme_site/acme_site_prod.env
|
|||||||
# Flip only purchased FEATURE_* flags. Fill provider secrets.
|
# Flip only purchased FEATURE_* flags. Fill provider secrets.
|
||||||
./scripts/validate-env.sh ~/Documents/secrets/acme_site/acme_site_prod.env
|
./scripts/validate-env.sh ~/Documents/secrets/acme_site/acme_site_prod.env
|
||||||
|
|
||||||
# 5. Infra: add `<slug>` to server-infra app_catalog (same key CI uses as
|
# 5. Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM, CI.
|
||||||
# `--app` after bootstrap, e.g. print_forge). Postgres on 10.0.0.230, NPM.
|
|
||||||
# Secrets: ~/Documents/secrets/<slug>/<slug>_{prod,beta}.env
|
|
||||||
# 6. Deploy. Create a portal user (createsuperuser).
|
# 6. Deploy. Create a portal user (createsuperuser).
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ done
|
|||||||
if ((${#missing[@]} > 0)); then
|
if ((${#missing[@]} > 0)); then
|
||||||
echo "Missing required environment variables in $ENV_FILE:" >&2
|
echo "Missing required environment variables in $ENV_FILE:" >&2
|
||||||
printf ' - %s\n' "${missing[@]}" >&2
|
printf ' - %s\n' "${missing[@]}" >&2
|
||||||
echo "Copy .env.prod.example to ~/Documents/secrets/<slug>/<slug>_${DJANGO_ENV}.env and set values." >&2
|
echo "Copy .env.prod.example to ~/Documents/secrets/<slug>_site/<slug>_site_${DJANGO_ENV}.env and set values." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -183,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",
|
||||||
@@ -381,6 +382,7 @@ POS_WEBHOOK_SECRET = env("POS_WEBHOOK_SECRET", "")
|
|||||||
POS_API_BASE_URL = env("POS_API_BASE_URL", "")
|
POS_API_BASE_URL = env("POS_API_BASE_URL", "")
|
||||||
POS_API_TOKEN = env("POS_API_TOKEN", "")
|
POS_API_TOKEN = env("POS_API_TOKEN", "")
|
||||||
EASYPOST_API_KEY = env("EASYPOST_API_KEY", "")
|
EASYPOST_API_KEY = env("EASYPOST_API_KEY", "")
|
||||||
|
EASYPOST_WEBHOOK_SECRET = env("EASYPOST_WEBHOOK_SECRET", "")
|
||||||
SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "")
|
SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "")
|
||||||
SHIP_FROM_CITY = env("SHIP_FROM_CITY", "")
|
SHIP_FROM_CITY = env("SHIP_FROM_CITY", "")
|
||||||
SHIP_FROM_STATE = env("SHIP_FROM_STATE", "")
|
SHIP_FROM_STATE = env("SHIP_FROM_STATE", "")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ if apps.is_installed("social_ai"):
|
|||||||
]
|
]
|
||||||
if apps.is_installed("shop"):
|
if apps.is_installed("shop"):
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
|
path("account/", include("accounts.customer_urls")),
|
||||||
path("shop/", include("shop.public_urls")),
|
path("shop/", include("shop.public_urls")),
|
||||||
path("portal/shop/", include("shop.portal_urls")),
|
path("portal/shop/", include("shop.portal_urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -122,9 +122,11 @@ class InstalledOptionalAppsTests(TestCase):
|
|||||||
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("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:product_list").startswith("/portal/shop/"))
|
||||||
self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/"))
|
self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/"))
|
||||||
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
|
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
|
||||||
self.assertTrue(reverse("events:list").startswith("/events/"))
|
self.assertTrue(reverse("events:list").startswith("/events/"))
|
||||||
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
|
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
|
||||||
self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/"))
|
self.assertTrue(reverse("shipping:shipment_list").startswith("/portal/shipping/"))
|
||||||
|
self.assertTrue(reverse("shipping:easypost_webhook").startswith("/portal/shipping/webhooks/"))
|
||||||
|
|||||||
@@ -239,8 +239,7 @@ class PostcardDesignPickTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="pcm-pick", password="test-pass-123"
|
username="pcm-pick", password="test-pass-123", is_staff=True)
|
||||||
)
|
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="pcm-pick", password="test-pass-123")
|
self.client.login(username="pcm-pick", password="test-pass-123")
|
||||||
self.contact = Contact.objects.create(
|
self.contact = Contact.objects.create(
|
||||||
@@ -283,8 +282,7 @@ class PostcardUtmPanelTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="utm-mail", password="test-pass-123"
|
username="utm-mail", password="test-pass-123", is_staff=True)
|
||||||
)
|
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="utm-mail", password="test-pass-123")
|
self.client.login(username="utm-mail", password="test-pass-123")
|
||||||
self.contact = Contact.objects.create(
|
self.contact = Contact.objects.create(
|
||||||
|
|||||||
+7
-13
@@ -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(
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class EventCapacityTests(TestCase):
|
|||||||
class EventPortalTests(TestCase):
|
class EventPortalTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user("host", password="test-pass-123")
|
self.user = User.objects.create_user("host", password="test-pass-123", is_staff=True)
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="host", password="test-pass-123")
|
self.client.login(username="host", password="test-pass-123")
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class POSPortalTests(TestCase):
|
|||||||
|
|
||||||
def test_list_ok_when_logged_in(self):
|
def test_list_ok_when_logged_in(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
User.objects.create_user("clerk", password="test-pass-123")
|
User.objects.create_user("clerk", password="test-pass-123", is_staff=True)
|
||||||
client = Client()
|
client = Client()
|
||||||
client.login(username="clerk", password="test-pass-123")
|
client.login(username="clerk", password="test-pass-123")
|
||||||
self.assertEqual(client.get(reverse("pos_sync:event_list")).status_code, 200)
|
self.assertEqual(client.get(reverse("pos_sync:event_list")).status_code, 200)
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
|
|||||||
+3
-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",
|
||||||
@@ -99,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 = (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from core.registry import (
|
from core.registry import (
|
||||||
register_dashboard_collector,
|
register_dashboard_collector,
|
||||||
|
register_dispatcher,
|
||||||
register_feature,
|
register_feature,
|
||||||
register_portal_nav,
|
register_portal_nav,
|
||||||
)
|
)
|
||||||
@@ -15,6 +16,7 @@ def register() -> None:
|
|||||||
order=50,
|
order=50,
|
||||||
)
|
)
|
||||||
register_dashboard_collector(_dashboard)
|
register_dashboard_collector(_dashboard)
|
||||||
|
register_dispatcher(_sync_tracking)
|
||||||
|
|
||||||
|
|
||||||
def _dashboard(request) -> dict:
|
def _dashboard(request) -> dict:
|
||||||
@@ -29,3 +31,9 @@ def _dashboard(request) -> dict:
|
|||||||
.exclude(pk__in=labeled)
|
.exclude(pk__in=labeled)
|
||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_tracking() -> int:
|
||||||
|
from shipping.services import sync_open_tracking
|
||||||
|
|
||||||
|
return sync_open_tracking()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 6.1
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("shipping", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="last_tracked_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracker_id",
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracking_events",
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracking_status",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("unknown", "Unknown"),
|
||||||
|
("pre_transit", "Pre-transit"),
|
||||||
|
("in_transit", "In transit"),
|
||||||
|
("out_for_delivery", "Out for delivery"),
|
||||||
|
("delivered", "Delivered"),
|
||||||
|
("available_for_pickup", "Available for pickup"),
|
||||||
|
("return_to_sender", "Return to sender"),
|
||||||
|
("failure", "Exception"),
|
||||||
|
("cancelled", "Cancelled"),
|
||||||
|
("error", "Error"),
|
||||||
|
],
|
||||||
|
default="unknown",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="shipment",
|
||||||
|
name="tracking_url",
|
||||||
|
field=models.URLField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,6 +11,18 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
LABELED = "labeled", "Labeled"
|
LABELED = "labeled", "Labeled"
|
||||||
VOID = "void", "Void"
|
VOID = "void", "Void"
|
||||||
|
|
||||||
|
class TrackingStatus(models.TextChoices):
|
||||||
|
UNKNOWN = "unknown", "Unknown"
|
||||||
|
PRE_TRANSIT = "pre_transit", "Pre-transit"
|
||||||
|
IN_TRANSIT = "in_transit", "In transit"
|
||||||
|
OUT_FOR_DELIVERY = "out_for_delivery", "Out for delivery"
|
||||||
|
DELIVERED = "delivered", "Delivered"
|
||||||
|
AVAILABLE_FOR_PICKUP = "available_for_pickup", "Available for pickup"
|
||||||
|
RETURN_TO_SENDER = "return_to_sender", "Return to sender"
|
||||||
|
FAILURE = "failure", "Exception"
|
||||||
|
CANCELLED = "cancelled", "Cancelled"
|
||||||
|
ERROR = "error", "Error"
|
||||||
|
|
||||||
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments")
|
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments")
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||||
@@ -18,6 +30,16 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
carrier = models.CharField(max_length=32, blank=True)
|
carrier = models.CharField(max_length=32, blank=True)
|
||||||
service = models.CharField(max_length=64, blank=True)
|
service = models.CharField(max_length=64, blank=True)
|
||||||
tracking_number = models.CharField(max_length=64, blank=True)
|
tracking_number = models.CharField(max_length=64, blank=True)
|
||||||
|
tracking_status = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=TrackingStatus.choices,
|
||||||
|
default=TrackingStatus.UNKNOWN,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
tracking_url = models.URLField(blank=True)
|
||||||
|
tracker_id = models.CharField(max_length=255, blank=True)
|
||||||
|
tracking_events = models.JSONField(default=list, blank=True)
|
||||||
|
last_tracked_at = models.DateTimeField(null=True, blank=True)
|
||||||
label_url = models.URLField(blank=True)
|
label_url = models.URLField(blank=True)
|
||||||
rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
currency = models.CharField(max_length=8, default="usd")
|
currency = models.CharField(max_length=8, default="usd")
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from shipping.models import Shipment
|
from shipping.models import Shipment
|
||||||
from shop.models import Order
|
from shop.models import Order
|
||||||
@@ -135,6 +138,10 @@ def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment:
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
refresh_tracking(shipment)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("tracking refresh failed after label buy for %s", shipment.pk)
|
||||||
return shipment
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
@@ -182,3 +189,169 @@ def pirate_ship_csv(orders=None) -> str:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def public_tracking_url(carrier: str, tracking_number: str) -> str:
|
||||||
|
code = quote((tracking_number or "").strip())
|
||||||
|
if not code:
|
||||||
|
return ""
|
||||||
|
name = (carrier or "").upper()
|
||||||
|
if "USPS" in name:
|
||||||
|
return f"https://tools.usps.com/go/TrackConfirmAction?tLabels={code}"
|
||||||
|
if "UPS" in name:
|
||||||
|
return f"https://www.ups.com/track?tracknum={code}"
|
||||||
|
if "FEDEX" in name or "FDX" in name:
|
||||||
|
return f"https://www.fedex.com/fedextrack/?trknbr={code}"
|
||||||
|
if "DHL" in name:
|
||||||
|
return f"https://www.dhl.com/en/express/tracking.html?AWB={code}"
|
||||||
|
return f"https://www.google.com/search?q={quote((tracking_number or '') + ' tracking')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tracking_status(raw: str) -> str:
|
||||||
|
value = (raw or "").strip().lower().replace(" ", "_")
|
||||||
|
aliases = {
|
||||||
|
"pretransit": Shipment.TrackingStatus.PRE_TRANSIT,
|
||||||
|
"pre_transit": Shipment.TrackingStatus.PRE_TRANSIT,
|
||||||
|
"in_transit": Shipment.TrackingStatus.IN_TRANSIT,
|
||||||
|
"out_for_delivery": Shipment.TrackingStatus.OUT_FOR_DELIVERY,
|
||||||
|
"delivered": Shipment.TrackingStatus.DELIVERED,
|
||||||
|
"available_for_pickup": Shipment.TrackingStatus.AVAILABLE_FOR_PICKUP,
|
||||||
|
"return_to_sender": Shipment.TrackingStatus.RETURN_TO_SENDER,
|
||||||
|
"failure": Shipment.TrackingStatus.FAILURE,
|
||||||
|
"cancelled": Shipment.TrackingStatus.CANCELLED,
|
||||||
|
"canceled": Shipment.TrackingStatus.CANCELLED,
|
||||||
|
"error": Shipment.TrackingStatus.ERROR,
|
||||||
|
"unknown": Shipment.TrackingStatus.UNKNOWN,
|
||||||
|
}
|
||||||
|
return aliases.get(value, Shipment.TrackingStatus.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tracker_payload(shipment: Shipment, payload: dict) -> Shipment:
|
||||||
|
"""Apply EasyPost tracker (or compatible) JSON onto a shipment."""
|
||||||
|
data = payload or {}
|
||||||
|
tracking = (data.get("tracking_code") or data.get("tracking_number") or "").strip()
|
||||||
|
if tracking:
|
||||||
|
shipment.tracking_number = tracking
|
||||||
|
tracker_id = (data.get("id") or "").strip()
|
||||||
|
if tracker_id.startswith("trk_"):
|
||||||
|
shipment.tracker_id = tracker_id
|
||||||
|
carrier = (data.get("carrier") or "").strip()
|
||||||
|
if carrier and not shipment.carrier:
|
||||||
|
shipment.carrier = carrier
|
||||||
|
shipment.tracking_status = _normalize_tracking_status(data.get("status") or "")
|
||||||
|
public_url = (data.get("public_url") or "").strip()
|
||||||
|
shipment.tracking_url = public_url or public_tracking_url(
|
||||||
|
shipment.carrier, shipment.tracking_number
|
||||||
|
)
|
||||||
|
events = []
|
||||||
|
for item in data.get("tracking_details") or []:
|
||||||
|
loc = item.get("tracking_location") or {}
|
||||||
|
place = " ".join(
|
||||||
|
part
|
||||||
|
for part in [loc.get("city") or "", loc.get("state") or ""]
|
||||||
|
if part
|
||||||
|
).strip()
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"status": item.get("status") or "",
|
||||||
|
"message": item.get("message") or "",
|
||||||
|
"datetime": item.get("datetime") or "",
|
||||||
|
"location": place,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if events:
|
||||||
|
shipment.tracking_events = events
|
||||||
|
shipment.last_tracked_at = timezone.now()
|
||||||
|
shipment.save(
|
||||||
|
update_fields=[
|
||||||
|
"tracking_number",
|
||||||
|
"tracker_id",
|
||||||
|
"carrier",
|
||||||
|
"tracking_status",
|
||||||
|
"tracking_url",
|
||||||
|
"tracking_events",
|
||||||
|
"last_tracked_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_tracking(shipment: Shipment) -> Shipment:
|
||||||
|
"""Pull latest scan events from EasyPost, or stub status without an API key.
|
||||||
|
|
||||||
|
Works for EasyPost-bought labels and for tracking numbers pasted from
|
||||||
|
Pirate Ship / the carrier — EasyPost's tracker API looks up
|
||||||
|
USPS, UPS, FedEx, and DHL by number.
|
||||||
|
"""
|
||||||
|
tracking = (shipment.tracking_number or "").strip()
|
||||||
|
if not tracking:
|
||||||
|
return shipment
|
||||||
|
key = _easypost_key()
|
||||||
|
if not key:
|
||||||
|
if not shipment.tracking_status or shipment.tracking_status == Shipment.TrackingStatus.UNKNOWN:
|
||||||
|
shipment.tracking_status = Shipment.TrackingStatus.PRE_TRANSIT
|
||||||
|
shipment.tracking_url = shipment.tracking_url or public_tracking_url(
|
||||||
|
shipment.carrier, tracking
|
||||||
|
)
|
||||||
|
shipment.last_tracked_at = timezone.now()
|
||||||
|
shipment.save(
|
||||||
|
update_fields=[
|
||||||
|
"tracking_status",
|
||||||
|
"tracking_url",
|
||||||
|
"last_tracked_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
payload = {"tracker": {"tracking_code": tracking}}
|
||||||
|
if shipment.carrier:
|
||||||
|
payload["tracker"]["carrier"] = shipment.carrier
|
||||||
|
response = requests.post(
|
||||||
|
"https://api.easypost.com/v2/trackers",
|
||||||
|
auth=(key, ""),
|
||||||
|
json=payload,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return apply_tracker_payload(shipment, response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def attach_tracking(
|
||||||
|
shipment: Shipment, *, tracking_number: str, carrier: str = ""
|
||||||
|
) -> Shipment:
|
||||||
|
tracking_number = (tracking_number or "").strip()
|
||||||
|
if not tracking_number:
|
||||||
|
raise ShippingError("Tracking number is required.")
|
||||||
|
shipment.tracking_number = tracking_number
|
||||||
|
if carrier:
|
||||||
|
shipment.carrier = carrier.strip()
|
||||||
|
if shipment.status != Shipment.Status.LABELED:
|
||||||
|
shipment.status = Shipment.Status.LABELED
|
||||||
|
shipment.save(
|
||||||
|
update_fields=["tracking_number", "carrier", "status", "updated_at"]
|
||||||
|
)
|
||||||
|
return refresh_tracking(shipment)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_open_tracking() -> int:
|
||||||
|
"""Refresh labeled, not-yet-delivered shipments (dispatch_due)."""
|
||||||
|
done = 0
|
||||||
|
qs = (
|
||||||
|
Shipment.objects.filter(status=Shipment.Status.LABELED)
|
||||||
|
.exclude(tracking_number="")
|
||||||
|
.exclude(
|
||||||
|
tracking_status__in=[
|
||||||
|
Shipment.TrackingStatus.DELIVERED,
|
||||||
|
Shipment.TrackingStatus.CANCELLED,
|
||||||
|
]
|
||||||
|
)[:50]
|
||||||
|
)
|
||||||
|
for shipment in qs:
|
||||||
|
try:
|
||||||
|
refresh_tracking(shipment)
|
||||||
|
done += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception("tracking sync failed for %s", shipment.pk)
|
||||||
|
return done
|
||||||
|
|||||||
@@ -3,8 +3,52 @@
|
|||||||
{% block topbar_title %}{{ shipment.order.number }}{% endblock %}
|
{% block topbar_title %}{{ shipment.order.number }}{% endblock %}
|
||||||
{% block portal_content %}
|
{% block portal_content %}
|
||||||
<p>{{ shipment.order.email }} · {{ shipment.get_status_display }}</p>
|
<p>{{ shipment.order.email }} · {{ shipment.get_status_display }}</p>
|
||||||
{% if shipment.tracking_number %}<p>Tracking: {{ shipment.tracking_number }}</p>{% endif %}
|
{% if shipment.tracking_number %}
|
||||||
|
<p>
|
||||||
|
Tracking: {{ shipment.tracking_number }}
|
||||||
|
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if shipment.tracking_url %}<p><a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a></p>{% endif %}
|
||||||
|
<form method="post" action="{% url 'shipping:shipment_refresh_tracking' shipment.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Refresh tracking</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% if shipment.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
|
{% if shipment.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
|
||||||
|
|
||||||
|
<h3>Add tracking</h3>
|
||||||
|
<p class="hint-block">Paste a number from Pirate Ship or the carrier. EasyPost looks up scan events when an API key is set.</p>
|
||||||
|
<form method="post" action="{% url 'shipping:shipment_attach_tracking' shipment.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_tracking_number">Tracking number</label>
|
||||||
|
<input id="id_tracking_number" name="tracking_number" value="{{ shipment.tracking_number }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_carrier">Carrier</label>
|
||||||
|
<input id="id_carrier" name="carrier" value="{{ shipment.carrier }}" placeholder="USPS, UPS, FedEx">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Save tracking</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if shipment.tracking_events %}
|
||||||
|
<h3>Scan history</h3>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>When</th><th>Status</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in shipment.tracking_events %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ event.datetime }}</td>
|
||||||
|
<td>{{ event.status }}</td>
|
||||||
|
<td>{{ event.message }}{% if event.location %} · {{ event.location }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if shipment.status != 'labeled' %}
|
{% if shipment.status != 'labeled' %}
|
||||||
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
|
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
+56
-1
@@ -1,4 +1,5 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import json
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
@@ -49,11 +50,65 @@ class ShippingServiceTests(TestCase):
|
|||||||
buy_label(shipment)
|
buy_label(shipment)
|
||||||
self.assertNotIn(self.order.number, pirate_ship_csv())
|
self.assertNotIn(self.order.number, pirate_ship_csv())
|
||||||
|
|
||||||
|
def test_stub_buy_sets_pre_transit_tracking(self):
|
||||||
|
shipment = create_shipment_for_order(self.order)
|
||||||
|
quote_rates(shipment)
|
||||||
|
buy_label(shipment)
|
||||||
|
shipment.refresh_from_db()
|
||||||
|
self.assertTrue(shipment.tracking_number)
|
||||||
|
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
|
||||||
|
self.assertTrue(shipment.tracking_url)
|
||||||
|
|
||||||
|
def test_attach_tracking_from_pirate_ship(self):
|
||||||
|
from shipping.services import attach_tracking
|
||||||
|
|
||||||
|
shipment = create_shipment_for_order(self.order)
|
||||||
|
attach_tracking(shipment, tracking_number="9400111899223197428490", carrier="USPS")
|
||||||
|
shipment.refresh_from_db()
|
||||||
|
self.assertEqual(shipment.status, Shipment.Status.LABELED)
|
||||||
|
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
|
||||||
|
self.assertIn("usps.com", shipment.tracking_url.lower())
|
||||||
|
|
||||||
|
def test_easypost_webhook_updates_status(self):
|
||||||
|
shipment = create_shipment_for_order(self.order)
|
||||||
|
shipment.tracking_number = "EZ1000000001"
|
||||||
|
shipment.status = Shipment.Status.LABELED
|
||||||
|
shipment.save()
|
||||||
|
payload = {
|
||||||
|
"description": "tracker.updated",
|
||||||
|
"result": {
|
||||||
|
"id": "trk_test",
|
||||||
|
"tracking_code": "EZ1000000001",
|
||||||
|
"status": "in_transit",
|
||||||
|
"public_url": "https://track.easypost.com/djE0",
|
||||||
|
"tracking_details": [
|
||||||
|
{
|
||||||
|
"status": "in_transit",
|
||||||
|
"message": "Departed facility",
|
||||||
|
"datetime": "2026-09-07T12:00:00Z",
|
||||||
|
"tracking_location": {"city": "Chicago", "state": "IL"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response = Client().post(
|
||||||
|
reverse("shipping:easypost_webhook"),
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
shipment.refresh_from_db()
|
||||||
|
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.IN_TRANSIT)
|
||||||
|
self.assertEqual(shipment.tracker_id, "trk_test")
|
||||||
|
self.assertEqual(shipment.tracking_events[0]["location"], "Chicago IL")
|
||||||
|
|
||||||
|
|
||||||
class ShippingPortalTests(TestCase):
|
class ShippingPortalTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user("shipper", password="test-pass-123")
|
self.user = User.objects.create_user(
|
||||||
|
"shipper", password="test-pass-123", is_staff=True
|
||||||
|
)
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="shipper", password="test-pass-123")
|
self.client.login(username="shipper", password="test-pass-123")
|
||||||
self.order = Order.objects.create(
|
self.order = Order.objects.create(
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ urlpatterns = [
|
|||||||
path("", views.shipment_list, name="shipment_list"),
|
path("", views.shipment_list, name="shipment_list"),
|
||||||
path("new/", views.shipment_create, name="shipment_create"),
|
path("new/", views.shipment_create, name="shipment_create"),
|
||||||
path("export/pirate-ship.csv", views.pirate_ship_export, name="pirate_ship_export"),
|
path("export/pirate-ship.csv", views.pirate_ship_export, name="pirate_ship_export"),
|
||||||
|
path("webhooks/easypost/", views.easypost_webhook, name="easypost_webhook"),
|
||||||
path("<uuid:pk>/", views.shipment_detail, name="shipment_detail"),
|
path("<uuid:pk>/", views.shipment_detail, name="shipment_detail"),
|
||||||
path("<uuid:pk>/buy/", views.shipment_buy, name="shipment_buy"),
|
path("<uuid:pk>/buy/", views.shipment_buy, name="shipment_buy"),
|
||||||
|
path(
|
||||||
|
"<uuid:pk>/tracking/",
|
||||||
|
views.shipment_attach_tracking,
|
||||||
|
name="shipment_attach_tracking",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<uuid:pk>/tracking/refresh/",
|
||||||
|
views.shipment_refresh_tracking,
|
||||||
|
name="shipment_refresh_tracking",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
+74
-1
@@ -1,18 +1,24 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, HttpResponseBadRequest
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods, require_POST
|
from django.views.decorators.http import require_http_methods, require_POST
|
||||||
|
|
||||||
from shipping.models import Shipment
|
from shipping.models import Shipment
|
||||||
from shipping.services import (
|
from shipping.services import (
|
||||||
ShippingError,
|
ShippingError,
|
||||||
|
apply_tracker_payload,
|
||||||
|
attach_tracking,
|
||||||
buy_label,
|
buy_label,
|
||||||
create_shipment_for_order,
|
create_shipment_for_order,
|
||||||
pirate_ship_csv,
|
pirate_ship_csv,
|
||||||
quote_rates,
|
quote_rates,
|
||||||
|
refresh_tracking,
|
||||||
)
|
)
|
||||||
from shop.models import Order
|
from shop.models import Order
|
||||||
|
|
||||||
@@ -77,6 +83,40 @@ def shipment_buy(request, pk):
|
|||||||
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def shipment_attach_tracking(request, pk):
|
||||||
|
shipment = get_object_or_404(Shipment, pk=pk)
|
||||||
|
try:
|
||||||
|
attach_tracking(
|
||||||
|
shipment,
|
||||||
|
tracking_number=request.POST.get("tracking_number") or "",
|
||||||
|
carrier=request.POST.get("carrier") or "",
|
||||||
|
)
|
||||||
|
except ShippingError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("attach tracking failed")
|
||||||
|
messages.error(request, f"Could not save tracking: {exc}")
|
||||||
|
else:
|
||||||
|
messages.success(request, "Tracking saved.")
|
||||||
|
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def shipment_refresh_tracking(request, pk):
|
||||||
|
shipment = get_object_or_404(Shipment, pk=pk)
|
||||||
|
try:
|
||||||
|
refresh_tracking(shipment)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("refresh tracking failed")
|
||||||
|
messages.error(request, f"Could not refresh tracking: {exc}")
|
||||||
|
else:
|
||||||
|
messages.success(request, "Tracking updated.")
|
||||||
|
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def pirate_ship_export(request):
|
def pirate_ship_export(request):
|
||||||
@@ -84,3 +124,36 @@ def pirate_ship_export(request):
|
|||||||
response = HttpResponse(body, content_type="text/csv")
|
response = HttpResponse(body, content_type="text/csv")
|
||||||
response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"'
|
response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def easypost_webhook(request):
|
||||||
|
secret = (getattr(settings, "EASYPOST_WEBHOOK_SECRET", "") or "").strip()
|
||||||
|
if secret:
|
||||||
|
got = (
|
||||||
|
request.headers.get("X-Webhook-Secret")
|
||||||
|
or request.GET.get("token")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if got != secret:
|
||||||
|
return HttpResponseBadRequest("invalid secret")
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return HttpResponseBadRequest("invalid json")
|
||||||
|
result = payload.get("result") or payload
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return HttpResponse("ok")
|
||||||
|
tracker_id = (result.get("id") or "").strip()
|
||||||
|
tracking = (result.get("tracking_code") or result.get("tracking_number") or "").strip()
|
||||||
|
shipment = None
|
||||||
|
if tracker_id:
|
||||||
|
shipment = Shipment.objects.filter(tracker_id=tracker_id).first()
|
||||||
|
if shipment is None and tracking:
|
||||||
|
shipment = Shipment.objects.filter(tracking_number=tracking).first()
|
||||||
|
if shipment is None:
|
||||||
|
return HttpResponse("ok")
|
||||||
|
apply_tracker_payload(shipment, result)
|
||||||
|
logger.info("easypost tracker updated shipment %s", shipment.pk)
|
||||||
|
return HttpResponse("ok")
|
||||||
|
|||||||
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from shop.models import Order, OrderItem, Product
|
from shop.models import Order, OrderItem, Product, ProductReview
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Product)
|
@admin.register(Product)
|
||||||
@@ -11,6 +11,13 @@ class ProductAdmin(admin.ModelAdmin):
|
|||||||
prepopulated_fields = {"slug": ("name",)}
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProductReview)
|
||||||
|
class ProductReviewAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("product", "user", "rating", "created_at")
|
||||||
|
list_filter = ("rating",)
|
||||||
|
search_fields = ("product__name", "user__email", "title")
|
||||||
|
|
||||||
|
|
||||||
class OrderItemInline(admin.TabularInline):
|
class OrderItemInline(admin.TabularInline):
|
||||||
model = OrderItem
|
model = OrderItem
|
||||||
extra = 0
|
extra = 0
|
||||||
@@ -21,4 +28,5 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
list_display = ("number", "email", "amount", "status", "created_at")
|
list_display = ("number", "email", "amount", "status", "created_at")
|
||||||
list_filter = ("status",)
|
list_filter = ("status",)
|
||||||
search_fields = ("number", "email", "customer_name")
|
search_fields = ("number", "email", "customer_name")
|
||||||
|
raw_id_fields = ("user",)
|
||||||
inlines = [OrderItemInline]
|
inlines = [OrderItemInline]
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Generated by Django 6.1
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="order",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="shop_orders",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ProductReview",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"rating",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(5),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(blank=True, max_length=120)),
|
||||||
|
("body", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="reviews",
|
||||||
|
to="shop.order",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"product",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="reviews",
|
||||||
|
to="shop.product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="product_reviews",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="productreview",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user", "product"),
|
||||||
|
name="shop_review_user_product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="productreview",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(("rating__gte", 1), ("rating__lte", 5)),
|
||||||
|
name="shop_review_rating_range",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
@@ -68,6 +70,13 @@ class Order(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
CANCELLED = "cancelled", "Cancelled"
|
CANCELLED = "cancelled", "Cancelled"
|
||||||
|
|
||||||
number = models.CharField(max_length=32, unique=True)
|
number = models.CharField(max_length=32, unique=True)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="shop_orders",
|
||||||
|
)
|
||||||
email = models.EmailField()
|
email = models.EmailField()
|
||||||
customer_name = models.CharField(max_length=200, blank=True)
|
customer_name = models.CharField(max_length=200, blank=True)
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
@@ -116,3 +125,38 @@ class OrderItem(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
@property
|
@property
|
||||||
def line_total(self) -> Decimal:
|
def line_total(self) -> Decimal:
|
||||||
return self.unit_price * self.quantity
|
return self.unit_price * self.quantity
|
||||||
|
|
||||||
|
|
||||||
|
class ProductReview(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||||
|
product = models.ForeignKey(
|
||||||
|
Product, on_delete=models.CASCADE, related_name="reviews"
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="product_reviews",
|
||||||
|
)
|
||||||
|
order = models.ForeignKey(
|
||||||
|
Order, on_delete=models.CASCADE, related_name="reviews"
|
||||||
|
)
|
||||||
|
rating = models.PositiveSmallIntegerField(
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=120, blank=True)
|
||||||
|
body = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "product"],
|
||||||
|
name="shop_review_user_product",
|
||||||
|
),
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=models.Q(rating__gte=1) & models.Q(rating__lte=5),
|
||||||
|
name="shop_review_rating_range",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.rating}★ {self.product.name}"
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ urlpatterns = [
|
|||||||
path("checkout/", views.checkout, name="checkout"),
|
path("checkout/", views.checkout, name="checkout"),
|
||||||
path("checkout/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
|
path("checkout/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
|
||||||
path("checkout/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
|
path("checkout/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
|
||||||
|
path("<slug:slug>/review/", views.product_review, name="review"),
|
||||||
path("<slug:slug>/", views.product_detail, name="detail"),
|
path("<slug:slug>/", views.product_detail, name="detail"),
|
||||||
]
|
]
|
||||||
|
|||||||
+90
-11
@@ -8,10 +8,10 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Sum
|
from django.db.models import F, Q, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from shop.models import Order, OrderItem, Product
|
from shop.models import Order, OrderItem, Product, ProductReview
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -156,6 +156,7 @@ def create_order_from_cart(
|
|||||||
customer_name: str = "",
|
customer_name: str = "",
|
||||||
shipping_address: dict | None = None,
|
shipping_address: dict | None = None,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
|
user=None,
|
||||||
) -> Order:
|
) -> Order:
|
||||||
lines = cart_lines(session)
|
lines = cart_lines(session)
|
||||||
if not lines:
|
if not lines:
|
||||||
@@ -169,6 +170,7 @@ def create_order_from_cart(
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
order = Order.objects.create(
|
order = Order.objects.create(
|
||||||
number=next_order_number(),
|
number=next_order_number(),
|
||||||
|
user=user if getattr(user, "is_authenticated", False) else None,
|
||||||
email=email,
|
email=email,
|
||||||
customer_name=(customer_name or "").strip(),
|
customer_name=(customer_name or "").strip(),
|
||||||
status=Order.Status.DRAFT,
|
status=Order.Status.DRAFT,
|
||||||
@@ -191,6 +193,42 @@ def create_order_from_cart(
|
|||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_stripe_customer(user) -> str:
|
||||||
|
"""Create or reuse a Stripe Customer. Card data never leaves Stripe."""
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return ""
|
||||||
|
from accounts.services import get_customer_profile
|
||||||
|
|
||||||
|
profile = get_customer_profile(user)
|
||||||
|
if profile.stripe_customer_id:
|
||||||
|
return profile.stripe_customer_id
|
||||||
|
stripe = _stripe()
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=(user.email or user.username or "") or None,
|
||||||
|
name=(user.get_full_name() or "") or None,
|
||||||
|
metadata={"user_id": str(user.pk)},
|
||||||
|
)
|
||||||
|
customer_id = getattr(customer, "id", None) or customer.get("id") or ""
|
||||||
|
if not customer_id:
|
||||||
|
raise ShopError("Stripe did not return a customer id.")
|
||||||
|
profile.stripe_customer_id = customer_id
|
||||||
|
profile.save(update_fields=["stripe_customer_id", "updated_at"])
|
||||||
|
return customer_id
|
||||||
|
|
||||||
|
|
||||||
|
def remember_stripe_customer(order: Order, customer_id: str) -> None:
|
||||||
|
customer_id = (customer_id or "").strip()
|
||||||
|
if not customer_id or not order.user_id:
|
||||||
|
return
|
||||||
|
from accounts.services import get_customer_profile
|
||||||
|
|
||||||
|
profile = get_customer_profile(order.user)
|
||||||
|
if profile.stripe_customer_id:
|
||||||
|
return
|
||||||
|
profile.stripe_customer_id = customer_id
|
||||||
|
profile.save(update_fields=["stripe_customer_id", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str:
|
def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str:
|
||||||
stripe = _stripe()
|
stripe = _stripe()
|
||||||
line_items = [
|
line_items = [
|
||||||
@@ -208,14 +246,27 @@ def create_checkout_session(order: Order, *, success_url: str, cancel_url: str)
|
|||||||
]
|
]
|
||||||
if not line_items:
|
if not line_items:
|
||||||
raise ShopError("Order has no items.")
|
raise ShopError("Order has no items.")
|
||||||
session = stripe.checkout.Session.create(
|
params = {
|
||||||
mode="payment",
|
"mode": "payment",
|
||||||
customer_email=order.email or None,
|
"line_items": line_items,
|
||||||
line_items=line_items,
|
"metadata": {"shop_order_id": str(order.pk), "order_number": order.number},
|
||||||
metadata={"shop_order_id": str(order.pk), "order_number": order.number},
|
"success_url": success_url,
|
||||||
success_url=success_url,
|
"cancel_url": cancel_url,
|
||||||
cancel_url=cancel_url,
|
}
|
||||||
)
|
customer_id = ""
|
||||||
|
if order.user_id:
|
||||||
|
try:
|
||||||
|
customer_id = ensure_stripe_customer(order.user)
|
||||||
|
except ShopError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("stripe customer create failed for order %s", order.number)
|
||||||
|
if customer_id:
|
||||||
|
params["customer"] = customer_id
|
||||||
|
params["payment_intent_data"] = {"setup_future_usage": "on_session"}
|
||||||
|
else:
|
||||||
|
params["customer_email"] = order.email or None
|
||||||
|
session = stripe.checkout.Session.create(**params)
|
||||||
order.stripe_checkout_session_id = session.id
|
order.stripe_checkout_session_id = session.id
|
||||||
order.hosted_checkout_url = session.url or ""
|
order.hosted_checkout_url = session.url or ""
|
||||||
order.status = Order.Status.OPEN
|
order.status = Order.Status.OPEN
|
||||||
@@ -249,7 +300,9 @@ def _notify_pos(order: Order) -> None:
|
|||||||
enqueue_online_sale(order)
|
enqueue_online_sale(order)
|
||||||
|
|
||||||
|
|
||||||
def mark_paid(order: Order, *, stripe_id: str = "") -> None:
|
def mark_paid(
|
||||||
|
order: Order, *, stripe_id: str = "", stripe_customer_id: str = ""
|
||||||
|
) -> None:
|
||||||
if order.status == Order.Status.PAID:
|
if order.status == Order.Status.PAID:
|
||||||
return
|
return
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -261,6 +314,7 @@ def mark_paid(order: Order, *, stripe_id: str = "") -> None:
|
|||||||
locked.paid_at = timezone.now()
|
locked.paid_at = timezone.now()
|
||||||
locked.save(update_fields=["status", "paid_at", "updated_at"])
|
locked.save(update_fields=["status", "paid_at", "updated_at"])
|
||||||
order.refresh_from_db()
|
order.refresh_from_db()
|
||||||
|
remember_stripe_customer(order, stripe_customer_id)
|
||||||
try:
|
try:
|
||||||
send_order_email(order)
|
send_order_email(order)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -303,3 +357,28 @@ def send_order_email(order: Order) -> bool:
|
|||||||
mail.attach_alternative(html, "text/html")
|
mail.attach_alternative(html, "text/html")
|
||||||
mail.send(fail_silently=False)
|
mail.send(fail_silently=False)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_REVIEWABLE_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
|
||||||
|
|
||||||
|
|
||||||
|
def qualifying_order_for_review(user, product: Product) -> Order | None:
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return None
|
||||||
|
email = (user.email or user.username or "").strip()
|
||||||
|
qs = (
|
||||||
|
Order.objects.filter(
|
||||||
|
items__product=product,
|
||||||
|
status__in=_REVIEWABLE_STATUSES,
|
||||||
|
)
|
||||||
|
.filter(Q(user=user) | Q(email__iexact=email))
|
||||||
|
.distinct()
|
||||||
|
.order_by("-paid_at", "-created_at")
|
||||||
|
)
|
||||||
|
return qs.first()
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_reviewed(user, product: Product) -> bool:
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return False
|
||||||
|
return ProductReview.objects.filter(user=user, product=product).exists()
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "accounts/account_base.html" %}
|
||||||
|
{% block title %}Order {{ order.number }} · {{ SITE_NAME }}{% endblock %}
|
||||||
|
{% block account_content %}
|
||||||
|
<h3>Order {{ order.number }}</h3>
|
||||||
|
<p>{{ order.get_status_display }} · ${{ order.amount }} {{ order.currency|upper }}</p>
|
||||||
|
{% if order.customer_name %}<p>{{ order.customer_name }}</p>{% endif %}
|
||||||
|
{% if order.shipping_address.line1 %}
|
||||||
|
<p>
|
||||||
|
{{ order.shipping_address.line1 }}{% if order.shipping_address.line2 %}, {{ order.shipping_address.line2 }}{% endif %}<br>
|
||||||
|
{{ order.shipping_address.city }} {{ order.shipping_address.state }} {{ order.shipping_address.zip }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<ul>
|
||||||
|
{% for item in order.items.all %}
|
||||||
|
<li>{{ item.quantity }}× {{ item.name }} — ${{ item.line_total }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if "shipping" in enabled_features %}
|
||||||
|
<h4>Shipments</h4>
|
||||||
|
{% for shipment in order.shipments.all %}
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{{ shipment.carrier }} {{ shipment.service }}
|
||||||
|
· {{ shipment.get_status_display }}
|
||||||
|
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if shipment.tracking_number %}
|
||||||
|
<p>
|
||||||
|
Tracking {{ shipment.tracking_number }}
|
||||||
|
{% if shipment.tracking_url %}
|
||||||
|
· <a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if shipment.tracking_events %}
|
||||||
|
<ul>
|
||||||
|
{% for event in shipment.tracking_events %}
|
||||||
|
<li>{{ event.datetime }} — {{ event.message|default:event.status }}{% if event.location %} ({{ event.location }}){% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p>Not shipped yet. Tracking appears here once a label is bought or a tracking number is added.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="{% url 'account:orders' %}">← All orders</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "accounts/account_base.html" %}
|
||||||
|
{% block title %}Orders · {{ SITE_NAME }}{% endblock %}
|
||||||
|
{% block account_content %}
|
||||||
|
<h3>Order history</h3>
|
||||||
|
{% if orders %}
|
||||||
|
<ul>
|
||||||
|
{% for order in orders %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'account:order_detail' order.pk %}">{{ order.number }}</a>
|
||||||
|
· {{ order.get_status_display }}
|
||||||
|
· {{ order.amount }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No orders yet. <a href="{% url 'shop:list' %}">Browse the shop</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,21 +1,35 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
|
{% block title %}Checkout · {{ SITE_NAME }}{% endblock %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section section-lg">
|
<section class="section section-lg">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Checkout</h1>
|
<h1>Checkout</h1>
|
||||||
<p>Total: {{ total }}</p>
|
<p>Total: {{ total }}</p>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<p>Signed in as {{ user.email }}. Cards stay on Stripe; we never store card numbers. <a href="{% url 'account:profile' %}">Edit profile</a></p>
|
||||||
|
{% else %}
|
||||||
|
<p>Have an account? <a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> to prefill shipping and save cards on Stripe. Or <a href="{% url 'account:register' %}">create one</a>.</p>
|
||||||
|
{% endif %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<p><label>Email <input name="email" type="email" required></label></p>
|
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
|
||||||
<p><label>Name <input name="customer_name"></label></p>
|
<p><label>Email <input class="form-input" name="email" type="email" required value="{{ checkout_initial.email }}" {% if user.is_authenticated %}readonly{% endif %}></label></p>
|
||||||
<p><label>Address <input name="address_line1"></label></p>
|
<p><label>Name <input class="form-input" name="customer_name" value="{{ checkout_initial.customer_name }}"></label></p>
|
||||||
<p><label>Address 2 <input name="address_line2"></label></p>
|
<p><label>Address <input class="form-input" name="address_line1" data-ac="line1" autocomplete="off" value="{{ checkout_initial.address.line1|default:'' }}"></label></p>
|
||||||
<p><label>City <input name="address_city"></label></p>
|
<p><label>Address 2 <input class="form-input" name="address_line2" data-ac="line2" autocomplete="address-line2" value="{{ checkout_initial.address.line2|default:'' }}"></label></p>
|
||||||
<p><label>State <input name="address_state"></label></p>
|
<p><label>City <input class="form-input" name="address_city" data-ac="city" autocomplete="address-level2" value="{{ checkout_initial.address.city|default:'' }}"></label></p>
|
||||||
<p><label>ZIP <input name="address_zip"></label></p>
|
<p><label>State <input class="form-input" name="address_state" data-ac="state" autocomplete="address-level1" value="{{ checkout_initial.address.state|default:'' }}"></label></p>
|
||||||
|
<p><label>ZIP <input class="form-input" name="address_zip" data-ac="zip" autocomplete="postal-code" value="{{ checkout_initial.address.zip|default:'' }}"></label></p>
|
||||||
|
</div>
|
||||||
<button class="button button-primary" type="submit">Pay with Stripe</button>
|
<button class="button button-primary" type="submit">Pay with Stripe</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'js/address-autocomplete.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,6 +15,47 @@
|
|||||||
<label>Qty <input name="quantity" type="number" min="1" value="1"></label>
|
<label>Qty <input name="quantity" type="number" min="1" value="1"></label>
|
||||||
<button class="button button-primary" type="submit">Add to cart</button>
|
<button class="button button-primary" type="submit">Add to cart</button>
|
||||||
</form>
|
</form>
|
||||||
|
<h2>Reviews</h2>
|
||||||
|
{% if review_count %}
|
||||||
|
<p>{{ review_avg|floatformat:1 }} / 5 · {{ review_count }} review{{ review_count|pluralize }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>No reviews yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_review %}
|
||||||
|
<form method="post" action="{% url 'shop:review' product.slug %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>
|
||||||
|
<label for="review-rating">Rating</label>
|
||||||
|
<select id="review-rating" name="rating" required>
|
||||||
|
<option value="">Choose 1–5</option>
|
||||||
|
<option value="5">5 — Excellent</option>
|
||||||
|
<option value="4">4 — Good</option>
|
||||||
|
<option value="3">3 — Okay</option>
|
||||||
|
<option value="2">2 — Fair</option>
|
||||||
|
<option value="1">1 — Poor</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p><label>Title (optional) <input name="title" maxlength="120"></label></p>
|
||||||
|
<p><label>Review (optional) <textarea name="body" rows="4"></textarea></label></p>
|
||||||
|
<button class="button button-primary" type="submit">Submit review</button>
|
||||||
|
</form>
|
||||||
|
{% elif already_reviewed %}
|
||||||
|
<p>You already reviewed this product.</p>
|
||||||
|
{% elif user.is_authenticated %}
|
||||||
|
<p>Buy this product to leave a review.</p>
|
||||||
|
{% else %}
|
||||||
|
<p><a href="{% url 'account:login' %}?next={{ request.path }}">Sign in</a> after a purchase to leave a review.</p>
|
||||||
|
{% endif %}
|
||||||
|
<ul>
|
||||||
|
{% for review in reviews %}
|
||||||
|
<li>
|
||||||
|
<strong>{{ review.rating }}/5</strong>
|
||||||
|
{% if review.title %} · {{ review.title }}{% endif %}
|
||||||
|
<span> — {{ review.user.first_name|default:review.user.email }}</span>
|
||||||
|
{% if review.body %}<p>{{ review.body|linebreaks }}</p>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -17,5 +17,26 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{% if "shipping" in enabled_features and order.shipments.all %}
|
||||||
|
<h3>Shipments</h3>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Carrier</th><th>Tracking</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for shipment in order.shipments.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ shipment.carrier }} {{ shipment.service }}</td>
|
||||||
|
<td>
|
||||||
|
{% if shipment.tracking_url %}
|
||||||
|
<a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">{{ shipment.tracking_number }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ shipment.tracking_number|default:"—" }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ shipment.get_tracking_status_display|default:shipment.get_status_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
<p><a href="{% url 'shop_portal:order_list' %}">← All orders</a></p>
|
<p><a href="{% url 'shop_portal:order_list' %}">← All orders</a></p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,5 +5,12 @@
|
|||||||
<h1>Thank you</h1>
|
<h1>Thank you</h1>
|
||||||
<p>Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
|
<p>Order {{ order.number }} is {{ order.get_status_display|lower }}.</p>
|
||||||
<p>A confirmation will go to {{ order.email }}.</p>
|
<p>A confirmation will go to {{ order.email }}.</p>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<p><a class="button button-primary" href="{% url 'account:order_detail' order.pk %}">View order</a></p>
|
||||||
|
{% else %}
|
||||||
|
<p>Create an account to track shipping and review products you bought. Card details stay with Stripe.</p>
|
||||||
|
<p><a class="button button-primary" href="{% url 'account:register' %}?email={{ order.email|urlencode }}">Create account</a></p>
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="{% url 'shop:list' %}">Continue shopping</a></p>
|
||||||
</div></section>
|
</div></section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+103
-2
@@ -1,5 +1,6 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
@@ -13,6 +14,7 @@ from shop.services import (
|
|||||||
add_to_cart,
|
add_to_cart,
|
||||||
adjust_stock,
|
adjust_stock,
|
||||||
available_qty,
|
available_qty,
|
||||||
|
create_checkout_session,
|
||||||
create_order_from_cart,
|
create_order_from_cart,
|
||||||
mark_paid,
|
mark_paid,
|
||||||
next_order_number,
|
next_order_number,
|
||||||
@@ -116,7 +118,9 @@ class ShopInventoryTests(TestCase):
|
|||||||
class ShopPortalTests(TestCase):
|
class ShopPortalTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user("merchant", password="test-pass-123")
|
self.user = User.objects.create_user(
|
||||||
|
"merchant", password="test-pass-123", is_staff=True
|
||||||
|
)
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="merchant", password="test-pass-123")
|
self.client.login(username="merchant", password="test-pass-123")
|
||||||
|
|
||||||
@@ -175,7 +179,9 @@ def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
|
|||||||
class ShopSalesDashboardTests(TestCase):
|
class ShopSalesDashboardTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user("merchant", password="test-pass-123")
|
self.user = User.objects.create_user(
|
||||||
|
"merchant", password="test-pass-123", is_staff=True
|
||||||
|
)
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="merchant", password="test-pass-123")
|
self.client.login(username="merchant", password="test-pass-123")
|
||||||
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
|
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
|
||||||
@@ -252,3 +258,98 @@ class ShopSalesDashboardTests(TestCase):
|
|||||||
self.assertContains(products, "Sales")
|
self.assertContains(products, "Sales")
|
||||||
sales = self.client.get(reverse("shop_portal:sales"))
|
sales = self.client.get(reverse("shop_portal:sales"))
|
||||||
self.assertContains(sales, 'class="active"')
|
self.assertContains(sales, 'class="active"')
|
||||||
|
|
||||||
|
|
||||||
|
class ShopCustomerAccountTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="buyer@example.com",
|
||||||
|
email="buyer@example.com",
|
||||||
|
password="s3cure-pass-123",
|
||||||
|
)
|
||||||
|
self.product = _product()
|
||||||
|
self.client = Client()
|
||||||
|
self.client.login(username="buyer@example.com", password="s3cure-pass-123")
|
||||||
|
|
||||||
|
def test_checkout_attaches_user_and_uses_stripe_customer(self):
|
||||||
|
session = self.client.session
|
||||||
|
add_to_cart(session, self.product, 1)
|
||||||
|
session.save()
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeCustomer:
|
||||||
|
id = "cus_abc"
|
||||||
|
|
||||||
|
class FakeCheckout:
|
||||||
|
id = "cs_abc"
|
||||||
|
url = "https://stripe.test/pay"
|
||||||
|
|
||||||
|
class FakeStripe:
|
||||||
|
class Customer:
|
||||||
|
@staticmethod
|
||||||
|
def create(**kwargs):
|
||||||
|
captured["customer"] = kwargs
|
||||||
|
return FakeCustomer()
|
||||||
|
|
||||||
|
class checkout:
|
||||||
|
class Session:
|
||||||
|
@staticmethod
|
||||||
|
def create(**kwargs):
|
||||||
|
captured["session"] = kwargs
|
||||||
|
return FakeCheckout()
|
||||||
|
|
||||||
|
with patch("shop.services._stripe", return_value=FakeStripe):
|
||||||
|
order = create_order_from_cart(
|
||||||
|
self.client.session,
|
||||||
|
email="buyer@example.com",
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
url = create_checkout_session(
|
||||||
|
order,
|
||||||
|
success_url="https://example.test/ok",
|
||||||
|
cancel_url="https://example.test/no",
|
||||||
|
)
|
||||||
|
self.assertEqual(url, "https://stripe.test/pay")
|
||||||
|
self.assertEqual(order.user, self.user)
|
||||||
|
self.assertEqual(captured["session"]["customer"], "cus_abc")
|
||||||
|
self.assertNotIn("customer_email", captured["session"])
|
||||||
|
self.assertEqual(
|
||||||
|
captured["session"]["payment_intent_data"]["setup_future_usage"],
|
||||||
|
"on_session",
|
||||||
|
)
|
||||||
|
self.user.customer_profile.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.customer_profile.stripe_customer_id, "cus_abc")
|
||||||
|
|
||||||
|
def test_review_requires_purchase(self):
|
||||||
|
blocked = self.client.post(
|
||||||
|
reverse("shop:review", kwargs={"slug": self.product.slug}),
|
||||||
|
{"rating": "5", "title": "Nope", "body": "Did not buy"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked.status_code, 302)
|
||||||
|
self.assertEqual(self.product.reviews.count(), 0)
|
||||||
|
|
||||||
|
order = _sold_order(self.product)
|
||||||
|
order.user = self.user
|
||||||
|
order.email = self.user.email
|
||||||
|
order.save(update_fields=["user", "email"])
|
||||||
|
ok = self.client.post(
|
||||||
|
reverse("shop:review", kwargs={"slug": self.product.slug}),
|
||||||
|
{"rating": "5", "title": "Great", "body": "Loved it"},
|
||||||
|
)
|
||||||
|
self.assertEqual(ok.status_code, 302)
|
||||||
|
review = self.product.reviews.get()
|
||||||
|
self.assertEqual(review.rating, 5)
|
||||||
|
self.assertEqual(review.user, self.user)
|
||||||
|
detail = self.client.get(self.product.get_absolute_url())
|
||||||
|
self.assertContains(detail, "Great")
|
||||||
|
self.assertContains(detail, "You already reviewed")
|
||||||
|
|
||||||
|
def test_order_history_hides_other_users(self):
|
||||||
|
mine = _sold_order(self.product, number="ORD-MINE")
|
||||||
|
mine.user = self.user
|
||||||
|
mine.save(update_fields=["user"])
|
||||||
|
_sold_order(self.product, number="ORD-THEIRS")
|
||||||
|
page = self.client.get(reverse("account:orders"))
|
||||||
|
self.assertContains(page, "ORD-MINE")
|
||||||
|
self.assertNotContains(page, "ORD-THEIRS")
|
||||||
|
|||||||
+138
-5
@@ -4,6 +4,7 @@ from decimal import Decimal, InvalidOperation
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Avg, Count
|
||||||
from django.http import HttpResponse, HttpResponseBadRequest
|
from django.http import HttpResponse, HttpResponseBadRequest
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -12,7 +13,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.views.decorators.http import require_http_methods, require_POST
|
from django.views.decorators.http import require_http_methods, require_POST
|
||||||
|
|
||||||
from contacts.models import Contact
|
from contacts.models import Contact
|
||||||
from shop.models import Order, Product
|
from shop.models import Order, Product, ProductReview
|
||||||
from shop.services import (
|
from shop.services import (
|
||||||
ShopError,
|
ShopError,
|
||||||
add_to_cart,
|
add_to_cart,
|
||||||
@@ -23,8 +24,10 @@ from shop.services import (
|
|||||||
create_checkout_session,
|
create_checkout_session,
|
||||||
create_order_from_cart,
|
create_order_from_cart,
|
||||||
mark_paid,
|
mark_paid,
|
||||||
|
qualifying_order_for_review,
|
||||||
save_cart,
|
save_cart,
|
||||||
set_cart_qty,
|
set_cart_qty,
|
||||||
|
user_has_reviewed,
|
||||||
)
|
)
|
||||||
from shop.stats import sales_dashboard
|
from shop.stats import sales_dashboard
|
||||||
|
|
||||||
@@ -45,13 +48,63 @@ def product_list(request):
|
|||||||
|
|
||||||
def product_detail(request, slug):
|
def product_detail(request, slug):
|
||||||
product = get_object_or_404(Product, slug=slug, is_published=True)
|
product = get_object_or_404(Product, slug=slug, is_published=True)
|
||||||
|
reviews = list(product.reviews.select_related("user").all()[:50])
|
||||||
|
stats = product.reviews.aggregate(avg=Avg("rating"), n=Count("id"))
|
||||||
|
can_review = False
|
||||||
|
already_reviewed = False
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
already_reviewed = user_has_reviewed(request.user, product)
|
||||||
|
can_review = (
|
||||||
|
not already_reviewed
|
||||||
|
and qualifying_order_for_review(request.user, product) is not None
|
||||||
|
)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"shop/detail.html",
|
"shop/detail.html",
|
||||||
{"product": product, "available": available_qty(product)},
|
{
|
||||||
|
"product": product,
|
||||||
|
"available": available_qty(product),
|
||||||
|
"reviews": reviews,
|
||||||
|
"review_avg": stats["avg"],
|
||||||
|
"review_count": stats["n"] or 0,
|
||||||
|
"can_review": can_review,
|
||||||
|
"already_reviewed": already_reviewed,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="account:login")
|
||||||
|
@require_POST
|
||||||
|
def product_review(request, slug):
|
||||||
|
product = get_object_or_404(Product, slug=slug, is_published=True)
|
||||||
|
if user_has_reviewed(request.user, product):
|
||||||
|
messages.info(request, "You already reviewed this product.")
|
||||||
|
return redirect("shop:detail", slug=product.slug)
|
||||||
|
order = qualifying_order_for_review(request.user, product)
|
||||||
|
if order is None:
|
||||||
|
messages.error(request, "Only customers who purchased this product can review it.")
|
||||||
|
return redirect("shop:detail", slug=product.slug)
|
||||||
|
try:
|
||||||
|
rating = int(request.POST.get("rating") or "0")
|
||||||
|
except ValueError:
|
||||||
|
rating = 0
|
||||||
|
if rating < 1 or rating > 5:
|
||||||
|
messages.error(request, "Choose a rating from 1 to 5.")
|
||||||
|
return redirect("shop:detail", slug=product.slug)
|
||||||
|
title = (request.POST.get("title") or "").strip()[:120]
|
||||||
|
body = (request.POST.get("body") or "").strip()
|
||||||
|
ProductReview.objects.create(
|
||||||
|
product=product,
|
||||||
|
user=request.user,
|
||||||
|
order=order,
|
||||||
|
rating=rating,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
messages.success(request, "Thanks for the review.")
|
||||||
|
return redirect("shop:detail", slug=product.slug)
|
||||||
|
|
||||||
|
|
||||||
def cart_view(request):
|
def cart_view(request):
|
||||||
lines = cart_lines(request.session)
|
lines = cart_lines(request.session)
|
||||||
return render(
|
return render(
|
||||||
@@ -104,12 +157,18 @@ def checkout(request):
|
|||||||
state=request.POST.get("address_state") or "",
|
state=request.POST.get("address_state") or "",
|
||||||
zip_code=request.POST.get("address_zip") or "",
|
zip_code=request.POST.get("address_zip") or "",
|
||||||
)
|
)
|
||||||
|
buyer = request.user if request.user.is_authenticated else None
|
||||||
|
if buyer:
|
||||||
|
email = (buyer.email or buyer.username or email).strip()
|
||||||
|
if not name:
|
||||||
|
name = buyer.get_full_name()
|
||||||
try:
|
try:
|
||||||
order = create_order_from_cart(
|
order = create_order_from_cart(
|
||||||
request.session,
|
request.session,
|
||||||
email=email,
|
email=email,
|
||||||
customer_name=name,
|
customer_name=name,
|
||||||
shipping_address=address,
|
shipping_address=address,
|
||||||
|
user=buyer,
|
||||||
)
|
)
|
||||||
base = _site_base(request)
|
base = _site_base(request)
|
||||||
success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk})
|
success = base + reverse("shop:checkout_success", kwargs={"pk": order.pk})
|
||||||
@@ -127,10 +186,28 @@ def checkout(request):
|
|||||||
else:
|
else:
|
||||||
save_cart(request.session, {})
|
save_cart(request.session, {})
|
||||||
return redirect(url)
|
return redirect(url)
|
||||||
|
checkout_initial = {
|
||||||
|
"email": "",
|
||||||
|
"customer_name": "",
|
||||||
|
"address": {},
|
||||||
|
}
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
from accounts.services import get_customer_profile
|
||||||
|
|
||||||
|
profile = get_customer_profile(request.user)
|
||||||
|
checkout_initial = {
|
||||||
|
"email": request.user.email or request.user.username,
|
||||||
|
"customer_name": request.user.get_full_name(),
|
||||||
|
"address": profile.shipping_address or {},
|
||||||
|
}
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"shop/checkout.html",
|
"shop/checkout.html",
|
||||||
{"lines": lines, "total": cart_total(lines)},
|
{
|
||||||
|
"lines": lines,
|
||||||
|
"total": cart_total(lines),
|
||||||
|
"checkout_initial": checkout_initial,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -236,10 +313,62 @@ def portal_order_list(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def portal_order_detail(request, pk):
|
def portal_order_detail(request, pk):
|
||||||
order = get_object_or_404(Order.objects.prefetch_related("items"), pk=pk)
|
qs = Order.objects.prefetch_related("items")
|
||||||
|
from django.apps import apps as django_apps
|
||||||
|
|
||||||
|
if django_apps.is_installed("shipping"):
|
||||||
|
qs = qs.prefetch_related("shipments")
|
||||||
|
order = get_object_or_404(qs, pk=pk)
|
||||||
return render(request, "shop/portal/order_detail.html", {"order": order})
|
return render(request, "shop/portal/order_detail.html", {"order": order})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="account:login")
|
||||||
|
def account_order_list(request):
|
||||||
|
from accounts.services import claim_orders_for_user
|
||||||
|
from django.apps import apps as django_apps
|
||||||
|
|
||||||
|
claim_orders_for_user(request.user)
|
||||||
|
qs = Order.objects.filter(user=request.user).prefetch_related("items")
|
||||||
|
if django_apps.is_installed("shipping"):
|
||||||
|
qs = qs.prefetch_related("shipments")
|
||||||
|
orders = qs.exclude(status=Order.Status.DRAFT)
|
||||||
|
return render(request, "shop/account/orders.html", {"orders": orders})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="account:login")
|
||||||
|
def account_order_detail(request, pk):
|
||||||
|
from django.apps import apps as django_apps
|
||||||
|
|
||||||
|
qs = Order.objects.prefetch_related("items")
|
||||||
|
if django_apps.is_installed("shipping"):
|
||||||
|
qs = qs.prefetch_related("shipments")
|
||||||
|
order = get_object_or_404(qs, pk=pk, user=request.user)
|
||||||
|
|
||||||
|
if django_apps.is_installed("shipping"):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from shipping.models import Shipment
|
||||||
|
from shipping.services import refresh_tracking
|
||||||
|
|
||||||
|
stale_after = timezone.now() - timedelta(minutes=15)
|
||||||
|
for shipment in order.shipments.all():
|
||||||
|
if (
|
||||||
|
shipment.status == Shipment.Status.LABELED
|
||||||
|
and shipment.tracking_number
|
||||||
|
and shipment.tracking_status != Shipment.TrackingStatus.DELIVERED
|
||||||
|
and (
|
||||||
|
shipment.last_tracked_at is None
|
||||||
|
or shipment.last_tracked_at < stale_after
|
||||||
|
)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
refresh_tracking(shipment)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("order tracking refresh failed for %s", order.number)
|
||||||
|
return render(request, "shop/account/order_detail.html", {"order": order})
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def stripe_webhook(request):
|
def stripe_webhook(request):
|
||||||
@@ -269,6 +398,10 @@ def stripe_webhook(request):
|
|||||||
session_id = obj.get("id") or ""
|
session_id = obj.get("id") or ""
|
||||||
order = Order.objects.filter(stripe_checkout_session_id=session_id).first()
|
order = Order.objects.filter(stripe_checkout_session_id=session_id).first()
|
||||||
if order and order.status != Order.Status.PAID:
|
if order and order.status != Order.Status.PAID:
|
||||||
mark_paid(order, stripe_id=obj.get("id") or "")
|
mark_paid(
|
||||||
|
order,
|
||||||
|
stripe_id=obj.get("id") or "",
|
||||||
|
stripe_customer_id=obj.get("customer") or "",
|
||||||
|
)
|
||||||
logger.info("shop order %s marked paid", order.number)
|
logger.info("shop order %s marked paid", order.number)
|
||||||
return HttpResponse("ok")
|
return HttpResponse("ok")
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ class SocialComposerTests(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.account = SocialAccount.objects.create(
|
self.account = SocialAccount.objects.create(
|
||||||
@@ -136,8 +135,7 @@ class SocialAccountRenameTests(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="renamer", password="test-pass-123"
|
username="renamer", password="test-pass-123", is_staff=True)
|
||||||
)
|
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.client.login(username="renamer", password="test-pass-123")
|
self.client.login(username="renamer", password="test-pass-123")
|
||||||
self.account = SocialAccount.objects.create(
|
self.account = SocialAccount.objects.create(
|
||||||
|
|||||||
Reference in New Issue
Block a user