Improve outreach compose, contact merge, and email assets.
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / docker (push) Successful in 14s
Deploy Beta / deploy-beta (push) Successful in 1m40s

Add a Quill email editor with DB-backed image storage, selectable PCM designs, postcard defaults for addressed contacts, and merge-by-phone/address on the contact form.
This commit is contained in:
2026-08-09 10:42:22 -05:00
parent d3db6eed76
commit b3a6ee0cd0
19 changed files with 1158 additions and 129 deletions
+37 -8
View File
@@ -32,14 +32,29 @@ def contact_may_receive(contact: Contact, channel: str) -> bool:
).exists():
return False
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
if channel == Channel.POSTCARD:
# Address on file defaults to postcard-eligible until explicit opt-out.
if consent is None:
return Contact.postal_address_has_content(contact.postal_address)
return bool(consent.opted_in)
return bool(consent and consent.opted_in)
def channel_preferences(contact: Contact) -> dict[str, bool]:
"""Current opt-in flags for every channel (missing record = False)."""
"""Current opt-in flags for every channel (missing record = False).
Postcard: missing consent + postal address → shown as opted in (default).
"""
flags = {c.value: False for c in Channel}
seen: set[str] = set()
for record in contact.consents.all():
flags[record.channel] = record.opted_in
seen.add(record.channel)
if (
Channel.POSTCARD not in seen
and Contact.postal_address_has_content(contact.postal_address)
):
flags[Channel.POSTCARD] = True
return flags
@@ -165,6 +180,20 @@ def opted_in_contacts(channel: str) -> QuerySet[Contact]:
suppressed = Suppression.objects.filter(
channel=channel, active=True
).values_list("contact_id", flat=True)
if channel == Channel.POSTCARD:
# Explicit opt-in, or address on file with no postcard consent row yet.
with_address = Contact.objects.filter(
postal_address__has_key="line1",
).exclude(postal_address__line1="")
explicit = with_address.filter(
consents__channel=Channel.POSTCARD,
consents__opted_in=True,
)
implicit = with_address.exclude(consents__channel=Channel.POSTCARD)
qs = (explicit | implicit).exclude(pk__in=suppressed).distinct()
return qs.order_by("first_name", "last_name", "email")
qs = (
Contact.objects.filter(
consents__channel=channel,
@@ -174,10 +203,6 @@ def opted_in_contacts(channel: str) -> QuerySet[Contact]:
.distinct()
.order_by("first_name", "last_name", "email")
)
if channel == Channel.POSTCARD:
qs = qs.filter(postal_address__has_key="line1").exclude(
postal_address__line1=""
)
return qs
@@ -399,7 +424,11 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from public.email_branding import email_brand_context, plain_text_to_email_html
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.")
@@ -420,9 +449,9 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
)
ctx = email_brand_context(
title=f"[TEST] {subject}",
content=f"{body}\n\n{notice}",
content=f"{campaign_body_to_plain_text(body)}\n\n{notice}",
content_html=(
f"{plain_text_to_email_html(body)}"
f"{campaign_body_to_email_html(body)}"
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
),
)