Template
Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed. Refs #1 Refs #2 Co-authored-by: Cursor <cursoragent@cursor.com>
258 lines
8.8 KiB
Python
258 lines
8.8 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):
|
|
return render(request, "public/home.html")
|
|
|
|
|
|
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"))
|
|
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)
|