Files
print_forge/site/public/views.py
T
westfarn 1ca5a757d9
Deploy Beta / unit-tests (push) Successful in 31s
Deploy Beta / docker (push) Failing after 34s
Deploy Beta / deploy-beta (push) Skipped
Ship Print Forge shop site (colors, photos, 3D viewer) (#2)
## Summary

- Rebrand the Django client template as Print Forge (`client_site` → `print_forge`) with shop + shipping enabled.
- Product listings support multiple photos, color variants (shared price/description/STL, per-color stock and photos), and a photo-first / 3D-second gallery.
- Public pages use 3D printer / printed-toy photography instead of leftover t-shirt mockups; beta CI deploys on `master`.

Closes #1
Infra: [server-infra#27](ai_ml_operations/server-infra#27) (easy deploy beta).

## Test plan

- [ ] Product page shows photo first, 3D model second; color swatches swap photos and stock
- [ ] Portal can upload multiple photos and per-color qty/images; STL stays shared
- [ ] Public home/about/gallery have no t-shirt mockups
- [ ] `manage.py test` passes
- [ ] After server-infra#27: beta deploy to `print-forge-preview.aimloperations.com`

Reviewed-on: #2
2026-09-06 18:40:42 -07:00

273 lines
9.4 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: /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
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()
contact_obj, _created, match_reason = upsert_contact(
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,
)
ConsentRecord.objects.update_or_create(
contact=contact_obj,
channel=Channel.EMAIL,
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_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 in {"phone", "address"}
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)