Improve outreach compose, contact merge, and email assets.
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:
+219
-53
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user