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
+142
View File
@@ -5,9 +5,11 @@ from django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
from messaging.services import (
channel_preferences,
contact_may_receive,
create_campaign_draft,
make_unsubscribe_token,
opted_in_contacts,
set_channel_consent,
)
@@ -757,3 +759,143 @@ class PcmAuthTests(TestCase):
with self.assertRaises(pcm_mod.PcmApiError) as ctx:
pcm_mod.login(force=True)
self.assertIn("PCM_API_SECRET", str(ctx.exception))
class PostcardAddressDefaultConsentTests(TestCase):
def test_address_without_consent_is_postcard_eligible(self):
contact = Contact.objects.create(
email="addr@example.com",
first_name="Ann",
postal_address=Contact.make_postal_address(
line1="1 Oak St",
city="Naperville",
state="IL",
zip_code="60540",
),
)
self.assertTrue(contact_may_receive(contact, Channel.POSTCARD))
prefs = channel_preferences(contact)
self.assertTrue(prefs[Channel.POSTCARD])
self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 1)
def test_explicit_postcard_opt_out_respected(self):
contact = Contact.objects.create(
email="out@example.com",
postal_address=Contact.make_postal_address(line1="2 Oak St"),
)
set_channel_consent(
contact, Channel.POSTCARD, opted_in=False, reason="opt_out"
)
self.assertFalse(contact_may_receive(contact, Channel.POSTCARD))
self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 0)
class CampaignHtmlBodyTests(TestCase):
def test_html_body_preserved_in_email(self):
from django.core import mail
User = get_user_model()
user = User.objects.create_user(
username="htmlsender", password="test-pass-123", email="s@example.com"
)
contact = Contact.objects.create(email="pat@example.com", first_name="Pat")
set_channel_consent(contact, Channel.EMAIL, opted_in=True, reason="test")
campaign = create_campaign_draft(
name="HTML tip",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Styled",
body='<p><strong>Hello</strong> <img src="https://example.com/a.png"></p>',
created_by=user,
)
from messaging.providers.email.smtp2go import send_email
send_email(campaign.messages.get())
self.assertEqual(len(mail.outbox), 1)
html = mail.outbox[0].alternatives[0][0]
self.assertIn("<strong>Hello</strong>", html)
self.assertIn('src="https://example.com/a.png"', html)
class PostcardDesignPickTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="pcm-pick", password="test-pass-123"
)
self.client = Client()
self.client.login(username="pcm-pick", password="test-pass-123")
self.contact = Contact.objects.create(
email="mail@example.com",
postal_address=Contact.make_postal_address(line1="9 Main"),
)
def test_compose_can_pick_pcm_design_id(self):
from unittest.mock import patch
with patch(
"messaging.views._fetch_pcm_designs",
return_value=(
[{"design_id": "42", "name": "Spring card", "size": "46"}],
"",
),
):
url = reverse("messaging:campaign_list")
response = self.client.post(
url,
{
"name": "Mailer",
"subject": "",
"body": "",
"audience": Campaign.Audience.POSTCARD_OPT_IN,
"template_id": "d:42",
},
)
campaign = Campaign.objects.get(name="Mailer")
self.assertEqual(response.status_code, 302)
self.assertEqual(campaign.channel, Channel.POSTCARD)
self.assertIsNotNone(campaign.template_id)
self.assertEqual(
campaign.template.postcard_front.get("design_id"), 42
)
self.assertEqual(campaign.messages.count(), 1)
class StoredFileUploadTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="uploader", password="test-pass-123"
)
self.client = Client()
self.client.login(username="uploader", password="test-pass-123")
def test_upload_stores_bytes_in_database(self):
from django.core.files.uploadedfile import SimpleUploadedFile
from messaging.models import StoredFile
png = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00"
b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18"
b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
)
upload = SimpleUploadedFile("dot.png", png, content_type="image/png")
response = self.client.post(
reverse("messaging:campaign_image_upload"),
{"image": upload},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertIn("url", payload)
stored = StoredFile.objects.get()
self.assertEqual(bytes(stored.data), png)
self.assertEqual(stored.content_type, "image/png")
self.assertEqual(stored.uploaded_by, self.user)
fetch = self.client.get(
reverse("messaging:stored_file", kwargs={"pk": stored.pk})
)
self.assertEqual(fetch.status_code, 200)
self.assertEqual(fetch["Content-Type"], "image/png")
self.assertEqual(b"".join(fetch.streaming_content), png)