Template
Populate the client website template with catalog feature flags.
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>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
"""Campaign draft/send helpers for direct mail."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from contacts.consent import ( # noqa: F401 — re-export for tests + providers
|
||||
channel_preferences,
|
||||
contact_may_receive,
|
||||
make_unsubscribe_token,
|
||||
one_click_unsubscribe_url,
|
||||
opted_in_contacts,
|
||||
parse_unsubscribe_token,
|
||||
preferences_url,
|
||||
process_unsubscribe_token,
|
||||
record_sms_stop,
|
||||
set_channel_consent,
|
||||
set_channel_preferences,
|
||||
unsubscribe_all,
|
||||
)
|
||||
from contacts.models import Channel, Contact
|
||||
from core.scheduling import parse_scheduled_for
|
||||
from directmail.models import Campaign, Message, MessageTemplate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
|
||||
AUDIENCE_CHANNEL = {
|
||||
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
||||
}
|
||||
|
||||
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
|
||||
_MERGE_TAG_RE = re.compile(
|
||||
r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}"
|
||||
r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
REMOVABLE_MESSAGE_STATUSES = frozenset(
|
||||
{
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.FAILED,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def render_merge_tags(text: str, contact: Contact | None) -> str:
|
||||
"""Replace personalization tags with contact field values."""
|
||||
if not text:
|
||||
return text or ""
|
||||
first = (getattr(contact, "first_name", None) or "").strip() if contact else ""
|
||||
last = (getattr(contact, "last_name", None) or "").strip() if contact else ""
|
||||
email = (getattr(contact, "email", None) or "").strip() if contact else ""
|
||||
phone = (getattr(contact, "phone", None) or "").strip() if contact else ""
|
||||
full = f"{first} {last}".strip()
|
||||
values = {
|
||||
"first_name": first,
|
||||
"last_name": last,
|
||||
"email": email,
|
||||
"phone": phone,
|
||||
"full_name": full,
|
||||
}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
key = (match.group(1) or match.group(2) or "").lower()
|
||||
return values.get(key, "")
|
||||
|
||||
return _MERGE_TAG_RE.sub(_replace, text)
|
||||
|
||||
|
||||
def message_is_removable(message: Message) -> bool:
|
||||
return message.status in REMOVABLE_MESSAGE_STATUSES
|
||||
|
||||
|
||||
def channel_for_audience(audience: str) -> str:
|
||||
try:
|
||||
return AUDIENCE_CHANNEL[audience]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown audience: {audience}") from exc
|
||||
|
||||
|
||||
def create_campaign_draft(
|
||||
*,
|
||||
name: str,
|
||||
audience: str,
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
scheduled_for=None,
|
||||
created_by: AbstractBaseUser | None = None,
|
||||
template: MessageTemplate | None = None,
|
||||
) -> Campaign:
|
||||
"""Persist a draft campaign and per-recipient Message stubs."""
|
||||
channel = channel_for_audience(audience)
|
||||
campaign = Campaign.objects.create(
|
||||
name=name,
|
||||
channel=channel,
|
||||
audience=audience,
|
||||
status=Campaign.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
subject_override=subject,
|
||||
body_override=body,
|
||||
created_by=created_by,
|
||||
template=template,
|
||||
)
|
||||
contacts = list(opted_in_contacts(channel))
|
||||
Message.objects.bulk_create(
|
||||
[
|
||||
Message(
|
||||
campaign=campaign,
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
status=Message.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
body_snapshot=body,
|
||||
)
|
||||
for contact in contacts
|
||||
]
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
def campaign_notify_recipient(campaign: Campaign) -> str:
|
||||
"""Email address for the realtor summary (created_by, else CONTACT_EMAIL)."""
|
||||
from django.conf import settings
|
||||
|
||||
user = campaign.created_by
|
||||
if user is not None:
|
||||
email = (getattr(user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
return (settings.CONTACT_EMAIL or "").strip()
|
||||
|
||||
|
||||
def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
||||
"""
|
||||
One-shot summary email when a campaign finishes sending.
|
||||
|
||||
Returns True if mail was sent (or already sent earlier).
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.db.models import Count, Q
|
||||
from django.template.loader import get_template
|
||||
from django.urls import reverse
|
||||
|
||||
from public.email_branding import email_brand_context
|
||||
|
||||
campaign.refresh_from_db()
|
||||
if campaign.notify_sent_at:
|
||||
return True
|
||||
if campaign.status != Campaign.Status.COMPLETED:
|
||||
return False
|
||||
|
||||
to_email = campaign_notify_recipient(campaign)
|
||||
if not to_email:
|
||||
return False
|
||||
|
||||
# Claim the notify slot atomically so concurrent refresh calls only send once.
|
||||
now = timezone.now()
|
||||
claimed = Campaign.objects.filter(
|
||||
pk=campaign.pk,
|
||||
status=Campaign.Status.COMPLETED,
|
||||
notify_sent_at__isnull=True,
|
||||
).update(notify_sent_at=now)
|
||||
if not claimed:
|
||||
return True
|
||||
campaign.notify_sent_at = now
|
||||
|
||||
counts = campaign.messages.aggregate(
|
||||
sent=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
status__in=[
|
||||
Message.Status.SENT,
|
||||
Message.Status.DELIVERED,
|
||||
Message.Status.OPENED,
|
||||
Message.Status.CLICKED,
|
||||
]
|
||||
),
|
||||
),
|
||||
delivered=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
status__in=[
|
||||
Message.Status.DELIVERED,
|
||||
Message.Status.OPENED,
|
||||
Message.Status.CLICKED,
|
||||
]
|
||||
),
|
||||
),
|
||||
failed=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
status__in=[
|
||||
Message.Status.FAILED,
|
||||
Message.Status.BOUNCED,
|
||||
]
|
||||
),
|
||||
),
|
||||
suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)),
|
||||
total=Count("id"),
|
||||
)
|
||||
report_path = reverse("directmail:campaign_detail", kwargs={"pk": campaign.pk})
|
||||
public = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
report_url = f"{public}{report_path}" if public else report_path
|
||||
|
||||
subject = f"Campaign sent: {campaign.name}"
|
||||
ctx = email_brand_context(
|
||||
subject=subject,
|
||||
campaign_name=campaign.name,
|
||||
channel_display=campaign.get_channel_display(),
|
||||
total=counts["total"],
|
||||
sent=counts["sent"],
|
||||
delivered=counts["delivered"],
|
||||
failed=counts["failed"],
|
||||
suppressed=counts["suppressed"],
|
||||
report_url=report_url,
|
||||
)
|
||||
text_content = get_template("emails/campaign_complete.txt").render(ctx)
|
||||
html_content = get_template("emails/campaign_complete.html").render(ctx)
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
try:
|
||||
email.send(fail_silently=False)
|
||||
except Exception: # noqa: BLE001 — don't block completion on mail errors
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"Campaign completion notify failed for %s", campaign.pk
|
||||
)
|
||||
Campaign.objects.filter(pk=campaign.pk).update(notify_sent_at=None)
|
||||
campaign.notify_sent_at = None
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def refresh_campaign_status(campaign: Campaign) -> Campaign:
|
||||
"""Set campaign to completed when no messages remain pending."""
|
||||
campaign.refresh_from_db()
|
||||
pending = campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.QUEUED,
|
||||
]
|
||||
).exists()
|
||||
if pending:
|
||||
return campaign
|
||||
if campaign.status == Campaign.Status.SENDING:
|
||||
campaign.status = Campaign.Status.COMPLETED
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
send_campaign_completion_notify(campaign)
|
||||
return campaign
|
||||
|
||||
|
||||
def enqueue_campaign_send(campaign: Campaign) -> int:
|
||||
"""
|
||||
Queue draft/scheduled/failed messages for send.
|
||||
|
||||
Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console.
|
||||
"""
|
||||
from directmail.tasks import send_campaign_message
|
||||
|
||||
sendable = list(
|
||||
campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.FAILED,
|
||||
]
|
||||
)
|
||||
)
|
||||
if not sendable:
|
||||
return 0
|
||||
|
||||
campaign.status = Campaign.Status.SENDING
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
|
||||
enqueued = 0
|
||||
for message in sendable:
|
||||
message.status = Message.Status.QUEUED
|
||||
message.save(update_fields=["status", "updated_at"])
|
||||
try:
|
||||
send_campaign_message.enqueue(message_id=str(message.pk))
|
||||
except Exception: # noqa: BLE001 — task already persisted FAILED
|
||||
pass
|
||||
enqueued += 1
|
||||
|
||||
refresh_campaign_status(campaign)
|
||||
return enqueued
|
||||
|
||||
|
||||
def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
|
||||
"""Send one preview copy to ``to_email`` without touching recipient rows."""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import get_template
|
||||
|
||||
from public.email_branding import (
|
||||
campaign_body_to_email_html,
|
||||
campaign_body_to_plain_text,
|
||||
email_brand_context,
|
||||
)
|
||||
|
||||
if campaign.channel != Channel.EMAIL:
|
||||
raise ValueError("Test send is only available for email campaigns.")
|
||||
subject = campaign.subject_override or (
|
||||
campaign.template.subject if campaign.template_id else f"Message from {settings.SITE_NAME}"
|
||||
)
|
||||
body = campaign.body_override or (
|
||||
campaign.template.body if campaign.template_id else ""
|
||||
)
|
||||
if not subject.strip():
|
||||
raise ValueError("Campaign has no subject.")
|
||||
if not body.strip():
|
||||
raise ValueError("Campaign has no body.")
|
||||
|
||||
# Preview merge tags using first recipient when available.
|
||||
sample = (
|
||||
campaign.messages.select_related("contact")
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
sample_contact = sample.contact if sample else None
|
||||
subject = render_merge_tags(subject, sample_contact)
|
||||
body = render_merge_tags(body, sample_contact)
|
||||
|
||||
notice = (
|
||||
"This is a test send from the portal. "
|
||||
"Recipient list was not notified."
|
||||
)
|
||||
ctx = email_brand_context(
|
||||
title=f"[TEST] {subject}",
|
||||
content=f"{campaign_body_to_plain_text(body)}\n\n{notice}",
|
||||
content_html=(
|
||||
f"{campaign_body_to_email_html(body)}"
|
||||
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
|
||||
),
|
||||
)
|
||||
text_content = get_template("emails/marketing_email.txt").render(ctx)
|
||||
html_content = get_template("emails/marketing_email.html").render(ctx)
|
||||
email = EmailMultiAlternatives(
|
||||
subject=f"[TEST] {subject}",
|
||||
body=text_content,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
email.attach_alternative(html_content, "text/html")
|
||||
email.send(fail_silently=False)
|
||||
Reference in New Issue
Block a user