Files
westfarn dd37a2a268
Deploy Beta / docker (push) Successful in 37s
Deploy Beta / deploy-beta (push) Successful in 2m21s
Deploy Beta / unit-tests (push) Successful in 39s
Customer accounts, order tracking, and purchase reviews (#8)
## Summary
- Slim the public contact form to email, interest, and message. Name, phone, and address live on the customer profile instead.
- Customers can register, sign in, save shipping details, and view order history. Logged-in checkout creates a Stripe Customer and saves cards on Stripe (`setup_future_usage`); we only store `stripe_customer_id`.
- Shipment tracking: EasyPost tracker lookup + webhook, plus paste-in numbers from Pirate Ship/Shippo. Customers see carrier status on their orders; `dispatch_due` refreshes open shipments.
- Product reviews (1–5) only after a paid/fulfilled purchase of that product.

Fixes #7

## Test plan
- [ ] Contact form submits with only email + message; extra name/phone/address fields are ignored
- [ ] Register, sign in, save profile (name/phone/shipping)
- [ ] Guest checkout still works; after signup, prior orders with that email show in history
- [ ] Logged-in checkout prefills shipping and does not collect card data locally
- [ ] Portal: buy label or paste a Pirate Ship tracking number, confirm status/events; customer order page shows tracking
- [ ] Product page: non-buyers cannot review; buyers can leave one 1–5 star review
- [ ] Non-staff users hitting `/portal/` redirect to `/account/`

Reviewed-on: #8
2026-09-07 04:53:41 -07:00

250 lines
8.3 KiB
Python

from django.conf import settings
from django.contrib import messages
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods
from analytics.services import attribute_lead_from_request
from contacts.models import Channel, ConsentRecord, Contact
from contacts.services import upsert_contact
from leads.models import Lead
from contacts.consent import (
channel_preferences,
parse_unsubscribe_token,
process_unsubscribe_token,
set_channel_preferences,
unsubscribe_all,
)
from public.forms import ContactForm, NotifyForm
from public.notifications import notify_admins_of_contact_form
def _public_site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
def home(request):
featured = []
from django.apps import apps as django_apps
if django_apps.is_installed("shop"):
from shop.models import Product
featured = list(
Product.objects.filter(is_published=True)
.select_related("image")
.prefetch_related("colors")[:6]
)
return render(request, "public/home.html", {"featured_products": featured})
def about(request):
return render(request, "public/about.html")
def terms(request):
return render(request, "public/terms.html")
@require_GET
def robots_txt(request):
site = _public_site_base(request)
body = "\n".join(
[
"User-agent: *",
"Allow: /",
"Disallow: /portal/",
"Disallow: /accounts/",
"Disallow: /account/",
"Disallow: /admin/",
"Disallow: /api/",
f"Sitemap: {site}/sitemap.xml",
"",
]
)
return HttpResponse(body, content_type="text/plain; charset=utf-8")
@require_GET
def sitemap_xml(request):
site = _public_site_base(request)
paths = [
("public:home", "1.0", "weekly"),
("public:about", "0.8", "monthly"),
("public:contact", "0.9", "monthly"),
("public:terms", "0.5", "yearly"),
]
from django.apps import apps as django_apps
if django_apps.is_installed("blog"):
paths.append(("blog:list", "0.7", "weekly"))
if django_apps.is_installed("shop"):
paths.append(("shop:list", "0.8", "weekly"))
if django_apps.is_installed("events"):
paths.append(("events:list", "0.8", "weekly"))
urls = []
for name, priority, changefreq in paths:
path = reverse(name)
urls.append(
" <url>\n"
f" <loc>{site}{path}</loc>\n"
f" <changefreq>{changefreq}</changefreq>\n"
f" <priority>{priority}</priority>\n"
" </url>"
)
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(urls)
+ "\n</urlset>\n"
)
return HttpResponse(body, content_type="application/xml; charset=utf-8")
@require_http_methods(["GET", "POST"])
def contact(request):
if request.method == "POST":
form = ContactForm(request.POST)
if form.is_valid():
data = form.cleaned_data
submitted_email = data["email"].lower()
contact_obj, _created, match_reason = upsert_contact(
email=submitted_email,
source=Contact.Source.CONTACT_FORM,
merge_phone_address=False,
)
ConsentRecord.objects.update_or_create(
contact=contact_obj,
channel=Channel.EMAIL,
defaults={"opted_in": True, "reason": "contact_form"},
)
interest = data.get("interest") or ""
interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest)
body = data.get("message") or ""
if interest_label:
body = f"Interest: {interest_label}\n\n{body}".strip()
if (
match_reason
and (contact_obj.email or "").lower() != submitted_email
):
body = (
f"Submitted email: {submitted_email} "
f"(merged by {match_reason} with "
f"{contact_obj.email or 'existing contact'})\n\n{body}"
).strip()
lead = Lead.objects.create(
contact=contact_obj,
message=body,
status=Lead.Status.NEW,
)
attribute_lead_from_request(request, lead)
notify_admins_of_contact_form(lead)
messages.success(request, "Thanks — we will be in touch soon.")
return redirect(f"{reverse('public:contact')}?sent=1")
else:
form = ContactForm()
return render(request, "public/contact.html", {"form": form})
@require_http_methods(["GET", "POST"])
def under_construction(request):
"""Direct route (also used by middleware). Accepts notify-me emails."""
if request.method == "POST":
form = NotifyForm(request.POST)
if form.is_valid():
email = form.cleaned_data["email"].lower()
Contact.objects.get_or_create(
email=email,
defaults={
"first_name": "",
"source": Contact.Source.NOTIFY_ME,
},
)
messages.success(request, "You're on the list — we'll email when we launch.")
return redirect("public:under_construction")
else:
form = NotifyForm()
return render(request, "public/under_construction.html", {"form": form})
@csrf_exempt
@require_http_methods(["GET", "POST"])
def unsubscribe_one_click(request, token: str):
"""
One-click opt-out for the channel encoded in the token.
CSRF-exempt so mail clients can POST List-Unsubscribe=One-Click (RFC 8058).
"""
ok = process_unsubscribe_token(token)
if not ok:
return render(request, "public/unsubscribe.html", {"valid": False})
return redirect("public:unsubscribe", token=token)
@require_http_methods(["GET", "POST"])
def unsubscribe(request, token: str):
"""
Signed-token preference center for email / SMS / postcard.
GET ?one_click=1 opts out the token channel then redirects here.
POST saves checkboxes or unsubscribes from all channels.
"""
contact, token_channel = parse_unsubscribe_token(token)
if not contact:
return render(
request,
"public/unsubscribe.html",
{"valid": False},
)
if request.method == "GET" and request.GET.get("one_click") in {
"1",
"true",
"yes",
}:
process_unsubscribe_token(token)
return redirect("public:unsubscribe", token=token)
if request.method == "POST":
action = (request.POST.get("action") or "save").strip()
if action == "unsubscribe_all":
unsubscribe_all(contact, reason="preferences_unsubscribe_all")
messages.success(
request, "You are unsubscribed from all marketing channels."
)
else:
prefs = {
Channel.EMAIL: "consent_email" in request.POST,
Channel.SMS: "consent_sms" in request.POST,
Channel.POSTCARD: "consent_postcard" in request.POST,
}
set_channel_preferences(
contact, prefs, reason="preferences_save"
)
messages.success(request, "Your communication preferences were saved.")
return redirect("public:unsubscribe", token=token)
contact = Contact.objects.prefetch_related("consents").get(pk=contact.pk)
prefs = channel_preferences(contact)
identity = contact.email or contact.phone or contact.full_name or "your profile"
return render(
request,
"public/unsubscribe.html",
{
"valid": True,
"contact": contact,
"identity": identity,
"prefs": prefs,
"token_channel": token_channel,
"token": token,
},
)
def page_not_found(request, exception):
return render(request, "public/404.html", status=404)