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
+219 -53
View File
@@ -1,5 +1,6 @@
import hashlib
import hmac
import io
import logging
from django.conf import settings
@@ -7,18 +8,19 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseForbidden, JsonResponse
from django.http import FileResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, 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, require_POST
from contacts.models import Channel
from messaging.models import Campaign, MessageTemplate, ProviderEvent
from messaging.models import Campaign, MessageTemplate, ProviderEvent, StoredFile
from messaging.providers.postcard.pcm import (
PCM_SIZE_CHOICES,
PcmApiError,
create_custom_design,
design_id_from_template,
get_design_embed_url,
list_designs,
)
@@ -41,6 +43,11 @@ from messaging.webhooks import (
logger = logging.getLogger(__name__)
_ALLOWED_IMAGE_TYPES = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"}
)
_MAX_IMAGE_BYTES = 5 * 1024 * 1024
def _audience_choices() -> list[tuple[str, str]]:
"""Labeled audience options with live opted-in counts."""
@@ -65,8 +72,165 @@ def _postcard_templates():
)[:50]
def _fetch_pcm_designs() -> tuple[list[dict], str]:
"""Return (normalized design rows, api_error)."""
designs: list[dict] = []
api_error = ""
try:
remote = list_designs(product_type="postcard")
for item in remote:
if not isinstance(item, dict):
continue
did = item.get("designID") or item.get("design_id") or item.get("id")
if did is None:
continue
size_info = item.get("size") or {}
size_key = (
size_info.get("key")
if isinstance(size_info, dict)
else size_info
) or ""
designs.append(
{
"design_id": str(did),
"name": item.get("friendlyName")
or item.get("name")
or f"Design {did}",
"size": str(size_key),
}
)
except PcmApiError as exc:
api_error = str(exc)
seen = {d["design_id"] for d in designs}
for tmpl in _postcard_templates():
front = tmpl.postcard_front or {}
did = front.get("design_id")
if did is None:
continue
did_s = str(did)
if did_s in seen:
continue
designs.insert(
0,
{
"design_id": did_s,
"name": tmpl.name,
"size": str(front.get("size") or ""),
},
)
seen.add(did_s)
return designs, api_error
def _postcard_design_choices() -> list[dict]:
"""Options for campaign compose: PCM designs + saved templates."""
designs, _ = _fetch_pcm_designs()
by_id = {d["design_id"]: d for d in designs}
choices: list[dict] = []
for tmpl in _postcard_templates():
did = design_id_from_template(tmpl)
if did is None:
continue
did_s = str(did)
choices.append(
{
"value": f"t:{tmpl.pk}",
"label": f"{tmpl.name} (design {did_s})",
"design_id": did_s,
}
)
by_id.pop(did_s, None)
for did_s, d in by_id.items():
choices.append(
{
"value": f"d:{did_s}",
"label": f"{d['name']} (design {did_s})",
"design_id": did_s,
"size": d.get("size") or "46",
"name": d["name"],
}
)
return choices
def _resolve_postcard_template(raw: str) -> MessageTemplate | None:
"""Resolve compose select value ``t:<uuid>`` or ``d:<design_id>``."""
value = (raw or "").strip()
if not value:
return None
if value.startswith("t:"):
return MessageTemplate.objects.filter(
pk=value[2:], channel=Channel.POSTCARD
).first()
if value.startswith("d:"):
design_raw = value[2:].strip()
try:
design_id = int(design_raw)
except ValueError:
return None
for tmpl in MessageTemplate.objects.filter(channel=Channel.POSTCARD):
if design_id_from_template(tmpl) == design_id:
return tmpl
name = f"PCM design {design_id}"
designs, _ = _fetch_pcm_designs()
match = next(
(d for d in designs if d["design_id"] == str(design_id)), None
)
size = (match or {}).get("size") or "46"
if match and match.get("name"):
name = match["name"]
front = {
"design_id": design_id,
"size": size,
"name": name,
"provider": "pcm",
}
return MessageTemplate.objects.create(
channel=Channel.POSTCARD,
name=name[:120],
subject="",
body=f"PCM design {design_id}",
postcard_front=front,
postcard_back={},
)
# Legacy: bare MessageTemplate pk
return MessageTemplate.objects.filter(
pk=value, channel=Channel.POSTCARD
).first()
def _format_postal_address(addr: dict | None) -> str:
if not addr:
return ""
line1 = (addr.get("line1") or "").strip()
line2 = (addr.get("line2") or "").strip()
city = (addr.get("city") or "").strip()
state = (addr.get("state") or "").strip()
zip_code = (addr.get("zip") or "").strip()
city_line = ", ".join(p for p in (city, state) if p)
if zip_code:
city_line = f"{city_line} {zip_code}".strip()
return ", ".join(p for p in (line1, line2, city_line) if p)
def _message_destination(message) -> str:
"""Channel-specific destination shown on the recipients table."""
contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL:
return (contact.email or "").strip()
if channel == Channel.SMS:
return (contact.phone or "").strip()
if channel == Channel.POSTCARD:
return _format_postal_address(contact.postal_address)
return ""
def _campaign_report(campaign: Campaign) -> dict:
messages_qs = list(campaign.messages.select_related("contact").all()[:200])
for msg in messages_qs:
msg.destination = _message_destination(msg)
stats = campaign_engagement_stats(campaign)
recent_events = (
ProviderEvent.objects.filter(message__campaign=campaign)
@@ -168,7 +332,7 @@ def campaign_list(request):
template = None
if template_id:
template = MessageTemplate.objects.filter(pk=template_id).first()
template = _resolve_postcard_template(template_id)
if not name:
form_errors.append("Campaign name is required.")
@@ -177,7 +341,7 @@ def campaign_list(request):
if audience == Campaign.Audience.POSTCARD_OPT_IN:
if not template or template.channel != Channel.POSTCARD:
form_errors.append(
"Choose a saved postcard template (design it under Postcard first)."
"Choose a postcard design (create one under Postcard design)."
)
if not body:
body = "Postcard mailing"
@@ -222,9 +386,10 @@ def campaign_list(request):
{
"campaigns": campaigns,
"audience_choices": _audience_choices(),
"postcard_templates": _postcard_templates(),
"postcard_designs": _postcard_design_choices(),
"form_data": form_data,
"form_errors": form_errors,
"image_upload_url": reverse("messaging:campaign_image_upload"),
},
)
@@ -274,6 +439,7 @@ def campaign_status_json(request, pk):
{
"id": str(m.pk),
"contact": str(m.contact),
"destination": getattr(m, "destination", "") or "",
"status": m.status,
"status_display": m.get_status_display(),
"provider_message_id": m.provider_message_id or "",
@@ -340,62 +506,62 @@ def campaign_test_send(request, pk):
return redirect("messaging:campaign_detail", pk=campaign.pk)
@login_required
@require_POST
def campaign_image_upload(request):
"""Upload an image for the email rich editor; store bytes in the DB."""
upload = request.FILES.get("image") or request.FILES.get("file")
if not upload:
return JsonResponse({"error": "No image uploaded."}, status=400)
content_type = (getattr(upload, "content_type", None) or "").lower()
if content_type not in _ALLOWED_IMAGE_TYPES:
return JsonResponse(
{"error": "Use a JPEG, PNG, GIF, or WebP image."}, status=400
)
if upload.size and upload.size > _MAX_IMAGE_BYTES:
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
data = upload.read()
if len(data) > _MAX_IMAGE_BYTES:
return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400)
original = (getattr(upload, "name", None) or "image")[:255]
stored = StoredFile.objects.create(
kind=StoredFile.Kind.CAMPAIGN_IMAGE,
filename=original,
content_type=content_type,
size=len(data),
data=data,
uploaded_by=request.user if request.user.is_authenticated else None,
)
path = reverse("messaging:stored_file", kwargs={"pk": stored.pk})
url = request.build_absolute_uri(path)
return JsonResponse({"url": url, "id": str(stored.pk)})
@require_GET
def stored_file(request, pk):
"""Public fetch for email clients / preview (UUID acts as capability token)."""
stored = get_object_or_404(StoredFile, pk=pk)
response = FileResponse(
io.BytesIO(bytes(stored.data)),
content_type=stored.content_type or "application/octet-stream",
)
if stored.filename:
response["Content-Disposition"] = f'inline; filename="{stored.filename}"'
response["Cache-Control"] = "public, max-age=86400"
return response
@login_required
def postcard_designer(request):
"""PCM Integrations designer — list designs + embed iframe."""
api_error = ""
designs: list[dict] = []
designs, api_error = _fetch_pcm_designs()
embed_url = ""
active_design_id = (request.GET.get("design_id") or "").strip()
active_name = ""
active_size = "46"
try:
remote = list_designs(product_type="postcard")
for item in remote:
if not isinstance(item, dict):
continue
did = item.get("designID") or item.get("design_id") or item.get("id")
if did is None:
continue
size_info = item.get("size") or {}
size_key = (
size_info.get("key")
if isinstance(size_info, dict)
else size_info
) or ""
designs.append(
{
"design_id": str(did),
"name": item.get("friendlyName")
or item.get("name")
or f"Design {did}",
"size": str(size_key),
}
)
except PcmApiError as exc:
api_error = str(exc)
# Merge saved local templates that may not appear in the remote page yet.
seen = {d["design_id"] for d in designs}
for tmpl in _postcard_templates():
front = tmpl.postcard_front or {}
did = front.get("design_id")
if did is None:
continue
did_s = str(did)
if did_s in seen:
continue
designs.insert(
0,
{
"design_id": did_s,
"name": tmpl.name,
"size": str(front.get("size") or ""),
},
)
seen.add(did_s)
if active_design_id:
match = next(
(d for d in designs if d["design_id"] == active_design_id), None