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:
@@ -0,0 +1,202 @@
|
|||||||
|
"""Contact identity matching and merge helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from contacts.models import Contact
|
||||||
|
|
||||||
|
_PHONE_MIN_DIGITS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone_digits(value: str) -> str:
|
||||||
|
return "".join(ch for ch in (value or "") if ch.isdigit())
|
||||||
|
|
||||||
|
|
||||||
|
def phones_match(a: str, b: str) -> bool:
|
||||||
|
"""True when both phones have ≥10 digits and last-10 match."""
|
||||||
|
da = normalize_phone_digits(a)
|
||||||
|
db = normalize_phone_digits(b)
|
||||||
|
if len(da) < _PHONE_MIN_DIGITS or len(db) < _PHONE_MIN_DIGITS:
|
||||||
|
return False
|
||||||
|
return da[-10:] == db[-10:]
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_addr_part(value: str) -> str:
|
||||||
|
text = (value or "").strip().lower()
|
||||||
|
text = re.sub(r"[.,#]", " ", text)
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def addresses_match(a: dict | None, b: dict | None) -> bool:
|
||||||
|
"""
|
||||||
|
Strong postal match: same street line1 + ZIP, or line1 + city + state.
|
||||||
|
|
||||||
|
Empty / partial addresses never match.
|
||||||
|
"""
|
||||||
|
if not a or not b:
|
||||||
|
return False
|
||||||
|
line1_a = _norm_addr_part(a.get("line1") or "")
|
||||||
|
line1_b = _norm_addr_part(b.get("line1") or "")
|
||||||
|
if not line1_a or not line1_b or line1_a != line1_b:
|
||||||
|
return False
|
||||||
|
|
||||||
|
zip_a = _norm_addr_part(a.get("zip") or "")
|
||||||
|
zip_b = _norm_addr_part(b.get("zip") or "")
|
||||||
|
if zip_a and zip_b:
|
||||||
|
return zip_a[:5] == zip_b[:5]
|
||||||
|
|
||||||
|
city_a = _norm_addr_part(a.get("city") or "")
|
||||||
|
city_b = _norm_addr_part(b.get("city") or "")
|
||||||
|
state_a = _norm_addr_part(a.get("state") or "")
|
||||||
|
state_b = _norm_addr_part(b.get("state") or "")
|
||||||
|
if city_a and city_b and state_a and state_b:
|
||||||
|
return city_a == city_b and state_a == state_b
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_matching_contact(
|
||||||
|
*,
|
||||||
|
email: str = "",
|
||||||
|
phone: str = "",
|
||||||
|
postal_address: dict | None = None,
|
||||||
|
) -> tuple[Contact | None, str]:
|
||||||
|
"""
|
||||||
|
Resolve an existing contact.
|
||||||
|
|
||||||
|
Priority: email → phone (last 10) → mailing address.
|
||||||
|
Returns (contact, reason) where reason is email|phone|address|"".
|
||||||
|
"""
|
||||||
|
email_norm = (email or "").strip().lower()
|
||||||
|
if email_norm:
|
||||||
|
hit = Contact.objects.filter(email__iexact=email_norm).first()
|
||||||
|
if hit:
|
||||||
|
return hit, "email"
|
||||||
|
|
||||||
|
phone_digits = normalize_phone_digits(phone)
|
||||||
|
if len(phone_digits) >= _PHONE_MIN_DIGITS:
|
||||||
|
tail = phone_digits[-10:]
|
||||||
|
for row in Contact.objects.exclude(phone="").only("id", "phone").iterator():
|
||||||
|
other = normalize_phone_digits(row.phone)
|
||||||
|
if len(other) >= _PHONE_MIN_DIGITS and other[-10:] == tail:
|
||||||
|
return Contact.objects.get(pk=row.pk), "phone"
|
||||||
|
|
||||||
|
if Contact.postal_address_has_content(postal_address):
|
||||||
|
line1 = (postal_address.get("line1") or "").strip()
|
||||||
|
zip_code = (postal_address.get("zip") or "").strip()
|
||||||
|
candidates = Contact.objects.exclude(postal_address={})
|
||||||
|
if zip_code:
|
||||||
|
candidates = candidates.filter(
|
||||||
|
postal_address__zip__istartswith=zip_code[:5]
|
||||||
|
)
|
||||||
|
elif line1:
|
||||||
|
candidates = candidates.filter(postal_address__line1__iexact=line1)
|
||||||
|
for row in candidates.iterator():
|
||||||
|
if addresses_match(postal_address, row.postal_address):
|
||||||
|
return row, "address"
|
||||||
|
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_notes(existing: str, addition: str) -> str:
|
||||||
|
existing = (existing or "").strip()
|
||||||
|
addition = (addition or "").strip()
|
||||||
|
if not addition:
|
||||||
|
return existing
|
||||||
|
if not existing:
|
||||||
|
return addition
|
||||||
|
if addition in existing:
|
||||||
|
return existing
|
||||||
|
return f"{existing}\n{addition}".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_contact(
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
first_name: str = "",
|
||||||
|
last_name: str = "",
|
||||||
|
phone: str = "",
|
||||||
|
postal_address: dict | None = None,
|
||||||
|
source: str = Contact.Source.CONTACT_FORM,
|
||||||
|
notes_append: str = "",
|
||||||
|
) -> tuple[Contact, bool, str]:
|
||||||
|
"""
|
||||||
|
Find or create a contact, merging on email / phone / address.
|
||||||
|
|
||||||
|
Returns (contact, created, match_reason).
|
||||||
|
When merged onto a different email, the submitted email is noted in ``notes``.
|
||||||
|
"""
|
||||||
|
email_norm = (email or "").strip().lower()
|
||||||
|
phone = (phone or "").strip()
|
||||||
|
postal = postal_address if isinstance(postal_address, dict) else {}
|
||||||
|
has_postal = Contact.postal_address_has_content(postal)
|
||||||
|
|
||||||
|
existing, reason = find_matching_contact(
|
||||||
|
email=email_norm,
|
||||||
|
phone=phone,
|
||||||
|
postal_address=postal if has_postal else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
contact = Contact.objects.create(
|
||||||
|
email=email_norm or None,
|
||||||
|
first_name=(first_name or "").strip(),
|
||||||
|
last_name=(last_name or "").strip(),
|
||||||
|
phone=phone,
|
||||||
|
postal_address=postal if has_postal else {},
|
||||||
|
source=source,
|
||||||
|
notes=(notes_append or "").strip(),
|
||||||
|
)
|
||||||
|
return contact, True, ""
|
||||||
|
|
||||||
|
changed: list[str] = []
|
||||||
|
if first_name and first_name.strip():
|
||||||
|
existing.first_name = first_name.strip()
|
||||||
|
changed.append("first_name")
|
||||||
|
if last_name is not None and str(last_name).strip() != "":
|
||||||
|
existing.last_name = last_name.strip()
|
||||||
|
changed.append("last_name")
|
||||||
|
if phone:
|
||||||
|
existing.phone = phone
|
||||||
|
changed.append("phone")
|
||||||
|
if has_postal:
|
||||||
|
existing.postal_address = postal
|
||||||
|
changed.append("postal_address")
|
||||||
|
|
||||||
|
existing_email = (existing.email or "").strip().lower()
|
||||||
|
if email_norm and email_norm != existing_email:
|
||||||
|
if not existing_email:
|
||||||
|
taken = (
|
||||||
|
Contact.objects.filter(email__iexact=email_norm)
|
||||||
|
.exclude(pk=existing.pk)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
if not taken:
|
||||||
|
existing.email = email_norm
|
||||||
|
changed.append("email")
|
||||||
|
else:
|
||||||
|
existing.notes = _merge_notes(
|
||||||
|
existing.notes,
|
||||||
|
f"Alternate email from form (owned elsewhere): {email_norm}",
|
||||||
|
)
|
||||||
|
changed.append("notes")
|
||||||
|
else:
|
||||||
|
existing.notes = _merge_notes(
|
||||||
|
existing.notes,
|
||||||
|
f"Alternate email from form: {email_norm}",
|
||||||
|
)
|
||||||
|
changed.append("notes")
|
||||||
|
|
||||||
|
if notes_append:
|
||||||
|
existing.notes = _merge_notes(existing.notes, notes_append)
|
||||||
|
changed.append("notes")
|
||||||
|
|
||||||
|
if source and existing.source == Contact.Source.OTHER:
|
||||||
|
existing.source = source
|
||||||
|
changed.append("source")
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
fields = sorted(set(changed) | {"updated_at"})
|
||||||
|
existing.save(update_fields=fields)
|
||||||
|
|
||||||
|
return existing, False, reason
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label>
|
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint-block" style="margin-top:16px">Postcard campaigns need a street address and postcard consent. Opt-outs also write a suppression so campaigns skip this contact.</p>
|
<p class="hint-block" style="margin-top:16px">Postcard mailings default on when a street address is on file. Uncheck to opt out. Opt-outs also write a suppression so campaigns skip this contact.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from contacts.models import Contact
|
||||||
|
from contacts.services import (
|
||||||
|
addresses_match,
|
||||||
|
find_matching_contact,
|
||||||
|
phones_match,
|
||||||
|
upsert_contact,
|
||||||
|
)
|
||||||
|
from leads.models import Lead
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMatchHelpersTests(TestCase):
|
||||||
|
def test_phones_match_ignores_formatting(self):
|
||||||
|
self.assertTrue(phones_match("(630) 452-4443", "+1-630-452-4443"))
|
||||||
|
self.assertFalse(phones_match("6304524443", "6304524444"))
|
||||||
|
self.assertFalse(phones_match("4524443", "6304524443")) # too short
|
||||||
|
|
||||||
|
def test_addresses_match_by_line1_and_zip(self):
|
||||||
|
a = Contact.make_postal_address(
|
||||||
|
line1="123 Main St.",
|
||||||
|
city="Naperville",
|
||||||
|
state="IL",
|
||||||
|
zip_code="60540-1234",
|
||||||
|
)
|
||||||
|
b = Contact.make_postal_address(
|
||||||
|
line1="123 Main St",
|
||||||
|
city="Elsewhere",
|
||||||
|
state="IL",
|
||||||
|
zip_code="60540",
|
||||||
|
)
|
||||||
|
self.assertTrue(addresses_match(a, b))
|
||||||
|
|
||||||
|
def test_find_by_phone_then_address(self):
|
||||||
|
existing = Contact.objects.create(
|
||||||
|
email="one@example.com",
|
||||||
|
phone="6305551212",
|
||||||
|
first_name="Pat",
|
||||||
|
postal_address=Contact.make_postal_address(
|
||||||
|
line1="9 Oak Ave",
|
||||||
|
city="Wheaton",
|
||||||
|
state="IL",
|
||||||
|
zip_code="60187",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
by_phone, reason = find_matching_contact(
|
||||||
|
email="other@example.com",
|
||||||
|
phone="(630) 555-1212",
|
||||||
|
)
|
||||||
|
self.assertEqual(by_phone, existing)
|
||||||
|
self.assertEqual(reason, "phone")
|
||||||
|
|
||||||
|
by_addr, reason = find_matching_contact(
|
||||||
|
email="third@example.com",
|
||||||
|
phone="9995550000",
|
||||||
|
postal_address=Contact.make_postal_address(
|
||||||
|
line1="9 Oak Ave",
|
||||||
|
city="Wheaton",
|
||||||
|
state="IL",
|
||||||
|
zip_code="60187",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(by_addr, existing)
|
||||||
|
self.assertEqual(reason, "address")
|
||||||
|
|
||||||
|
|
||||||
|
class UpsertContactMergeTests(TestCase):
|
||||||
|
def test_merge_by_phone_keeps_one_contact(self):
|
||||||
|
original = Contact.objects.create(
|
||||||
|
email="ryan@example.com",
|
||||||
|
phone="6305559999",
|
||||||
|
first_name="Ryan",
|
||||||
|
)
|
||||||
|
contact, created, reason = upsert_contact(
|
||||||
|
email="ryan.alt@example.com",
|
||||||
|
first_name="Ryan",
|
||||||
|
last_name="Westfall",
|
||||||
|
phone="630-555-9999",
|
||||||
|
)
|
||||||
|
self.assertFalse(created)
|
||||||
|
self.assertEqual(reason, "phone")
|
||||||
|
self.assertEqual(contact.pk, original.pk)
|
||||||
|
self.assertEqual(Contact.objects.count(), 1)
|
||||||
|
contact.refresh_from_db()
|
||||||
|
self.assertEqual(contact.email, "ryan@example.com")
|
||||||
|
self.assertIn("ryan.alt@example.com", contact.notes)
|
||||||
|
self.assertEqual(contact.last_name, "Westfall")
|
||||||
|
|
||||||
|
def test_merge_by_address(self):
|
||||||
|
original = Contact.objects.create(
|
||||||
|
email="home@example.com",
|
||||||
|
first_name="Sam",
|
||||||
|
postal_address=Contact.make_postal_address(
|
||||||
|
line1="100 Lake St",
|
||||||
|
city="Naperville",
|
||||||
|
state="IL",
|
||||||
|
zip_code="60540",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
contact, created, reason = upsert_contact(
|
||||||
|
email="new@example.com",
|
||||||
|
first_name="Sam",
|
||||||
|
postal_address=Contact.make_postal_address(
|
||||||
|
line1="100 Lake St",
|
||||||
|
city="Naperville",
|
||||||
|
state="IL",
|
||||||
|
zip_code="60540",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(created)
|
||||||
|
self.assertEqual(reason, "address")
|
||||||
|
self.assertEqual(contact.pk, original.pk)
|
||||||
|
self.assertEqual(Contact.objects.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactFormMergeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.existing = Contact.objects.create(
|
||||||
|
email="primary@example.com",
|
||||||
|
phone="6301112222",
|
||||||
|
first_name="Alex",
|
||||||
|
postal_address=Contact.make_postal_address(
|
||||||
|
line1="55 River Rd",
|
||||||
|
city="Aurora",
|
||||||
|
state="IL",
|
||||||
|
zip_code="60505",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contact_form_merges_on_phone(self):
|
||||||
|
url = reverse("public:contact")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"first_name": "Alex",
|
||||||
|
"last_name": "Lee",
|
||||||
|
"email": "alt@example.com",
|
||||||
|
"phone": "(630) 111-2222",
|
||||||
|
"address_line1": "55 River Rd",
|
||||||
|
"address_city": "Aurora",
|
||||||
|
"address_state": "IL",
|
||||||
|
"address_zip": "60505",
|
||||||
|
"interest": "buying",
|
||||||
|
"message": "Looking to buy",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(Contact.objects.count(), 1)
|
||||||
|
lead = Lead.objects.get()
|
||||||
|
self.assertEqual(lead.contact_id, self.existing.pk)
|
||||||
|
self.assertIn("alt@example.com", lead.message)
|
||||||
|
self.assertIn("merged by phone", lead.message)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
|
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent, StoredFile
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MessageTemplate)
|
@admin.register(MessageTemplate)
|
||||||
@@ -38,3 +38,10 @@ class MessageAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(ProviderEvent)
|
@admin.register(ProviderEvent)
|
||||||
class ProviderEventAdmin(admin.ModelAdmin):
|
class ProviderEventAdmin(admin.ModelAdmin):
|
||||||
list_display = ("provider", "event_type", "created_at")
|
list_display = ("provider", "event_type", "created_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StoredFile)
|
||||||
|
class StoredFileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("filename", "kind", "content_type", "size", "created_at")
|
||||||
|
list_filter = ("kind", "content_type")
|
||||||
|
readonly_fields = ("size", "content_type", "created_at", "updated_at")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.1 on 2026-08-09 13:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('messaging', '0003_campaign_notify_sent_at'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StoredFile',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('kind', models.CharField(choices=[('campaign_image', 'Campaign image')], default='campaign_image', max_length=32)),
|
||||||
|
('filename', models.CharField(blank=True, max_length=255)),
|
||||||
|
('content_type', models.CharField(max_length=128)),
|
||||||
|
('size', models.PositiveIntegerField(default=0)),
|
||||||
|
('data', models.BinaryField()),
|
||||||
|
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -113,3 +113,31 @@ class ProviderEvent(TimeStampedModel):
|
|||||||
provider = models.CharField(max_length=64)
|
provider = models.CharField(max_length=64)
|
||||||
event_type = models.CharField(max_length=64)
|
event_type = models.CharField(max_length=64)
|
||||||
payload = models.JSONField(default=dict, blank=True)
|
payload = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||||
|
"""Binary file blob in the database (no filesystem media storage)."""
|
||||||
|
|
||||||
|
class Kind(models.TextChoices):
|
||||||
|
CAMPAIGN_IMAGE = "campaign_image", "Campaign image"
|
||||||
|
|
||||||
|
kind = models.CharField(
|
||||||
|
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
|
||||||
|
)
|
||||||
|
filename = models.CharField(max_length=255, blank=True)
|
||||||
|
content_type = models.CharField(max_length=128)
|
||||||
|
size = models.PositiveIntegerField(default=0)
|
||||||
|
data = models.BinaryField()
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="uploaded_files",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.filename or str(self.pk)
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ from django.template.loader import get_template
|
|||||||
|
|
||||||
from contacts.models import Channel
|
from contacts.models import Channel
|
||||||
from messaging.services import one_click_unsubscribe_url, preferences_url
|
from messaging.services import one_click_unsubscribe_url, preferences_url
|
||||||
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,
|
||||||
|
)
|
||||||
|
|
||||||
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
|
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
|
||||||
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
|
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
|
||||||
@@ -33,8 +37,8 @@ def send_email(message) -> str:
|
|||||||
|
|
||||||
ctx = email_brand_context(
|
ctx = email_brand_context(
|
||||||
title=subject,
|
title=subject,
|
||||||
content=body,
|
content=campaign_body_to_plain_text(body),
|
||||||
content_html=plain_text_to_email_html(body),
|
content_html=campaign_body_to_email_html(body),
|
||||||
prefs_url=prefs_url,
|
prefs_url=prefs_url,
|
||||||
one_click_url=one_click_url,
|
one_click_url=one_click_url,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,14 +32,29 @@ def contact_may_receive(contact: Contact, channel: str) -> bool:
|
|||||||
).exists():
|
).exists():
|
||||||
return False
|
return False
|
||||||
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
|
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)
|
return bool(consent and consent.opted_in)
|
||||||
|
|
||||||
|
|
||||||
def channel_preferences(contact: Contact) -> dict[str, bool]:
|
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}
|
flags = {c.value: False for c in Channel}
|
||||||
|
seen: set[str] = set()
|
||||||
for record in contact.consents.all():
|
for record in contact.consents.all():
|
||||||
flags[record.channel] = record.opted_in
|
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
|
return flags
|
||||||
|
|
||||||
|
|
||||||
@@ -165,6 +180,20 @@ def opted_in_contacts(channel: str) -> QuerySet[Contact]:
|
|||||||
suppressed = Suppression.objects.filter(
|
suppressed = Suppression.objects.filter(
|
||||||
channel=channel, active=True
|
channel=channel, active=True
|
||||||
).values_list("contact_id", flat=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 = (
|
qs = (
|
||||||
Contact.objects.filter(
|
Contact.objects.filter(
|
||||||
consents__channel=channel,
|
consents__channel=channel,
|
||||||
@@ -174,10 +203,6 @@ def opted_in_contacts(channel: str) -> QuerySet[Contact]:
|
|||||||
.distinct()
|
.distinct()
|
||||||
.order_by("first_name", "last_name", "email")
|
.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
|
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.core.mail import EmailMultiAlternatives
|
||||||
from django.template.loader import get_template
|
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:
|
if campaign.channel != Channel.EMAIL:
|
||||||
raise ValueError("Test send is only available for email campaigns.")
|
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(
|
ctx = email_brand_context(
|
||||||
title=f"[TEST] {subject}",
|
title=f"[TEST] {subject}",
|
||||||
content=f"{body}\n\n{notice}",
|
content=f"{campaign_body_to_plain_text(body)}\n\n{notice}",
|
||||||
content_html=(
|
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>'
|
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
|
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="3" class="empty-state">No provider events yet. Configure the SMTP2GO webhook after first send.</td></tr>
|
<tr><td colspan="3" class="empty-state">No webhook events yet. SMTP2GO must POST opens/clicks to <code>/portal/messaging/webhooks/email/</code> (see messaging README). SMTP2GO’s own “Clicked” feed does not fill this table by itself.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -126,7 +126,12 @@
|
|||||||
<tbody id="recipients-body">
|
<tbody id="recipients-body">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<tr data-message-id="{{ message.pk }}">
|
<tr data-message-id="{{ message.pk }}">
|
||||||
<td>{{ message.contact }}</td>
|
<td>
|
||||||
|
<div>{{ message.contact }}</div>
|
||||||
|
{% if message.destination %}
|
||||||
|
<div class="muted" style="font-size:12px;margin-top:2px">{{ message.destination }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
|
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
|
||||||
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
|
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
|
||||||
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
|
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
|
||||||
@@ -165,8 +170,11 @@
|
|||||||
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
|
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
body.innerHTML = data.messages.map(function (m) {
|
body.innerHTML = data.messages.map(function (m) {
|
||||||
|
var dest = m.destination
|
||||||
|
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>'
|
||||||
|
: '';
|
||||||
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
|
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
|
||||||
"<td>" + esc(m.contact) + "</td>" +
|
"<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
|
||||||
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
|
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
|
||||||
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
|
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
|
||||||
"<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>";
|
"<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>";
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
{% extends "portal_base.html" %}
|
{% extends "portal_base.html" %}
|
||||||
{% block title %}Campaigns · Portal{% endblock %}
|
{% block title %}Campaigns · Portal{% endblock %}
|
||||||
{% block topbar_title %}Campaign composer{% endblock %}
|
{% block topbar_title %}Campaign composer{% endblock %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.ql-editor { min-height: 160px; font-family: Georgia, "Times New Roman", serif; font-size: 15px; }
|
||||||
|
.ql-toolbar.ql-snow { border-color: var(--monica-border); border-radius: 4px 4px 0 0; }
|
||||||
|
.ql-container.ql-snow { border-color: var(--monica-border); border-radius: 0 0 4px 4px; background: #fff; }
|
||||||
|
#preview-body img { max-width: 100%; height: auto; }
|
||||||
|
#preview-body { line-height: 1.55; color: #212121; }
|
||||||
|
#email-editor-wrap[hidden], #sms-body-wrap[hidden] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
{% block portal_content %}
|
{% block portal_content %}
|
||||||
<div class="channel-tabs">
|
<div class="channel-tabs" id="compose-channel-tabs">
|
||||||
<a class="active" href="#compose-email">Email</a>
|
<a class="active" href="#compose-email" data-channel="email">Email</a>
|
||||||
<a href="#compose-sms">SMS</a>
|
<a href="#compose-sms" data-channel="sms">SMS</a>
|
||||||
<a href="{% url 'messaging:postcard_designer' %}">Postcard</a>
|
<a href="{% url 'messaging:postcard_designer' %}" data-channel="postcard">Postcard</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
@@ -26,7 +37,7 @@
|
|||||||
<input id="id_name" name="name" type="text" required
|
<input id="id_name" name="name" type="text" required
|
||||||
placeholder="Spring seller tips" value="{{ form_data.name }}">
|
placeholder="Spring seller tips" value="{{ form_data.name }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field" id="subject-field">
|
||||||
<label for="id_subject">Subject</label>
|
<label for="id_subject">Subject</label>
|
||||||
<input id="id_subject" name="subject" type="text"
|
<input id="id_subject" name="subject" type="text"
|
||||||
placeholder="A quick tip for sellers this week"
|
placeholder="A quick tip for sellers this week"
|
||||||
@@ -34,26 +45,32 @@
|
|||||||
oninput="syncCampaignPreview()">
|
oninput="syncCampaignPreview()">
|
||||||
<div class="hint">Email only — ignored for SMS / postcard</div>
|
<div class="hint">Email only — ignored for SMS / postcard</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field" id="email-editor-wrap">
|
||||||
<label for="id_body">Body</label>
|
<label>Body</label>
|
||||||
<textarea id="id_body" name="body" style="min-height:140px"
|
<div id="email-editor"></div>
|
||||||
|
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea>
|
||||||
|
<div class="hint">Bold, fonts, sizes, links, images · merge tags: {first_name}, {last_name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="sms-body-wrap" hidden>
|
||||||
|
<label for="id_body_sms">Body</label>
|
||||||
|
<textarea id="id_body_sms" style="min-height:140px"
|
||||||
placeholder="Hi {first_name}, …"
|
placeholder="Hi {first_name}, …"
|
||||||
oninput="syncCampaignPreview()">{{ form_data.body }}</textarea>
|
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
||||||
<div class="hint">Merge tags: first_name, last_name, unsubscribe_url · optional for postcard</div>
|
<div class="hint">Plain text for SMS · keep it short</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" id="postcard-template-field">
|
<div class="field" id="postcard-template-field">
|
||||||
<label for="id_template_id">Postcard template</label>
|
<label for="id_template_id">Postcard design</label>
|
||||||
<select id="id_template_id" name="template_id">
|
<select id="id_template_id" name="template_id">
|
||||||
<option value="">— Select saved design —</option>
|
<option value="">— Select a PCM design —</option>
|
||||||
{% for t in postcard_templates %}
|
{% for d in postcard_designs %}
|
||||||
<option value="{{ t.pk }}"{% if form_data.template_id == t.pk|stringformat:"s" %} selected{% endif %}>
|
<option value="{{ d.value }}"{% if form_data.template_id == d.value %} selected{% endif %}>
|
||||||
{{ t.name }} (design {{ t.postcard_front.design_id }})
|
{{ d.label }}
|
||||||
</option>
|
</option>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<option value="" disabled>No templates yet — use Postcard designer</option>
|
<option value="" disabled>No designs yet — open Postcard design</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a></div>
|
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a> to create or edit designs</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid cols-2">
|
<div class="form-grid cols-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -83,7 +100,7 @@
|
|||||||
<div class="muted" id="preview-empty">Preview updates as you type.</div>
|
<div class="muted" id="preview-empty">Preview updates as you type.</div>
|
||||||
<div id="preview-content" hidden>
|
<div id="preview-content" hidden>
|
||||||
<div class="hint" id="preview-subject"></div>
|
<div class="hint" id="preview-subject"></div>
|
||||||
<div id="preview-body" style="white-space:pre-wrap;margin-top:8px"></div>
|
<div id="preview-body" style="margin-top:8px"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint-block">After send → open the campaign report for delivery & engagement.</p>
|
<p class="hint-block">After send → open the campaign report for delivery & engagement.</p>
|
||||||
@@ -122,10 +139,43 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function syncCampaignPreview() {
|
(function () {
|
||||||
|
var uploadUrl = "{{ image_upload_url|escapejs }}";
|
||||||
|
var csrfToken = (document.querySelector('#campaign-compose [name=csrfmiddlewaretoken]') || {}).value || '';
|
||||||
|
var bodyField = document.getElementById('id_body');
|
||||||
|
var smsField = document.getElementById('id_body_sms');
|
||||||
|
var quill = null;
|
||||||
|
|
||||||
|
function csrfHeader() {
|
||||||
|
return { 'X-CSRFToken': csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBodyFromQuill() {
|
||||||
|
if (!quill || !bodyField) return;
|
||||||
|
var html = quill.root.innerHTML;
|
||||||
|
if (html === '<p><br></p>' || html === '<p></p>') html = '';
|
||||||
|
bodyField.value = html;
|
||||||
|
syncCampaignPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.syncSmsBody = function () {
|
||||||
|
if (smsField && bodyField) bodyField.value = smsField.value;
|
||||||
|
syncCampaignPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.syncCampaignPreview = function () {
|
||||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||||
var body = (document.getElementById('id_body') || {}).value || '';
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
|
var isEmail = audience === 'email_opt_in';
|
||||||
|
var body = '';
|
||||||
|
if (isEmail && quill) {
|
||||||
|
body = quill.root.innerHTML;
|
||||||
|
if (body === '<p><br></p>' || body === '<p></p>') body = '';
|
||||||
|
} else {
|
||||||
|
body = (bodyField && bodyField.value) || '';
|
||||||
|
}
|
||||||
var empty = document.getElementById('preview-empty');
|
var empty = document.getElementById('preview-empty');
|
||||||
var content = document.getElementById('preview-content');
|
var content = document.getElementById('preview-content');
|
||||||
var subEl = document.getElementById('preview-subject');
|
var subEl = document.getElementById('preview-subject');
|
||||||
@@ -139,20 +189,122 @@ function syncCampaignPreview() {
|
|||||||
empty.hidden = true;
|
empty.hidden = true;
|
||||||
content.hidden = false;
|
content.hidden = false;
|
||||||
subEl.textContent = subject ? ('Subject: ' + subject) : '';
|
subEl.textContent = subject ? ('Subject: ' + subject) : '';
|
||||||
|
if (isEmail) {
|
||||||
|
bodyEl.style.whiteSpace = 'normal';
|
||||||
|
bodyEl.innerHTML = body;
|
||||||
|
} else {
|
||||||
|
bodyEl.style.whiteSpace = 'pre-wrap';
|
||||||
bodyEl.textContent = body;
|
bodyEl.textContent = body;
|
||||||
}
|
}
|
||||||
function syncComposeChannel() {
|
};
|
||||||
|
|
||||||
|
window.syncComposeChannel = function () {
|
||||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
var isPostcard = audience === 'postcard_opt_in';
|
var isPostcard = audience === 'postcard_opt_in';
|
||||||
|
var isSms = audience === 'sms_opt_in';
|
||||||
|
var isEmail = audience === 'email_opt_in';
|
||||||
var tmplField = document.getElementById('postcard-template-field');
|
var tmplField = document.getElementById('postcard-template-field');
|
||||||
var body = document.getElementById('id_body');
|
var emailWrap = document.getElementById('email-editor-wrap');
|
||||||
|
var smsWrap = document.getElementById('sms-body-wrap');
|
||||||
|
var subjectField = document.getElementById('subject-field');
|
||||||
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
||||||
if (body) {
|
if (subjectField) subjectField.style.display = isEmail ? '' : 'none';
|
||||||
if (isPostcard) body.removeAttribute('required');
|
if (emailWrap) emailWrap.hidden = !isEmail;
|
||||||
else body.setAttribute('required', 'required');
|
if (smsWrap) smsWrap.hidden = isEmail;
|
||||||
|
if (isEmail && quill) {
|
||||||
|
syncBodyFromQuill();
|
||||||
|
} else if (smsField && bodyField) {
|
||||||
|
// Keep SMS/postcard plain body in hidden field
|
||||||
|
if (!isEmail && smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
|
||||||
|
smsField.value = bodyField.value;
|
||||||
}
|
}
|
||||||
}
|
bodyField.value = smsField ? smsField.value : bodyField.value;
|
||||||
syncCampaignPreview();
|
bodyField.removeAttribute('required');
|
||||||
syncComposeChannel();
|
}
|
||||||
|
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
|
||||||
|
var ch = a.getAttribute('data-channel');
|
||||||
|
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
|
||||||
|
a.classList.toggle('active', active);
|
||||||
|
});
|
||||||
|
syncCampaignPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
function initQuill() {
|
||||||
|
var initial = (bodyField && bodyField.value) || '';
|
||||||
|
quill = new Quill('#email-editor', {
|
||||||
|
theme: 'snow',
|
||||||
|
placeholder: 'Hi {first_name}, …',
|
||||||
|
modules: {
|
||||||
|
toolbar: {
|
||||||
|
container: [
|
||||||
|
[{ font: [] }, { size: ['small', false, 'large', 'huge'] }],
|
||||||
|
['bold', 'italic', 'underline'],
|
||||||
|
[{ color: [] }, { background: [] }],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
image: function () {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.setAttribute('type', 'file');
|
||||||
|
input.setAttribute('accept', 'image/png,image/jpeg,image/gif,image/webp');
|
||||||
|
input.click();
|
||||||
|
input.onchange = function () {
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
var data = new FormData();
|
||||||
|
data.append('image', file);
|
||||||
|
fetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: csrfHeader(),
|
||||||
|
body: data,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().then(function (json) {
|
||||||
|
if (!res.ok) throw new Error(json.error || 'Upload failed');
|
||||||
|
return json;
|
||||||
|
});
|
||||||
|
}).then(function (json) {
|
||||||
|
var range = quill.getSelection(true);
|
||||||
|
quill.insertEmbed(range.index, 'image', json.url, 'user');
|
||||||
|
quill.setSelection(range.index + 1);
|
||||||
|
syncBodyFromQuill();
|
||||||
|
}).catch(function (err) {
|
||||||
|
alert(err.message || 'Image upload failed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (initial && initial.indexOf('<') !== -1) {
|
||||||
|
quill.root.innerHTML = initial;
|
||||||
|
} else if (initial) {
|
||||||
|
quill.setText(initial);
|
||||||
|
}
|
||||||
|
quill.on('text-change', syncBodyFromQuill);
|
||||||
|
document.getElementById('campaign-compose').addEventListener('submit', function () {
|
||||||
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
|
if (audience === 'email_opt_in') syncBodyFromQuill();
|
||||||
|
else if (smsField) bodyField.value = smsField.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#compose-channel-tabs a[data-channel="email"], #compose-channel-tabs a[data-channel="sms"]').forEach(function (a) {
|
||||||
|
a.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var ch = a.getAttribute('data-channel');
|
||||||
|
var audience = document.getElementById('id_audience');
|
||||||
|
if (!audience) return;
|
||||||
|
audience.value = ch === 'sms' ? 'sms_opt_in' : 'email_opt_in';
|
||||||
|
syncComposeChannel();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
initQuill();
|
||||||
|
syncComposeChannel();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
<button class="btn btn-primary" type="submit">Save as postcard template</button>
|
<button class="btn btn-primary" type="submit">Save as postcard template</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="library-hint" style="margin-top:12px">
|
<p class="library-hint" style="margin-top:12px">
|
||||||
Saved templates appear when composing a postcard campaign.
|
Designs listed above are selectable in Campaigns when Recipients is postcard.
|
||||||
|
Saving as a template keeps a named local copy.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ from django.urls import reverse
|
|||||||
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
||||||
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
|
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
|
||||||
from messaging.services import (
|
from messaging.services import (
|
||||||
|
channel_preferences,
|
||||||
contact_may_receive,
|
contact_may_receive,
|
||||||
create_campaign_draft,
|
create_campaign_draft,
|
||||||
make_unsubscribe_token,
|
make_unsubscribe_token,
|
||||||
|
opted_in_contacts,
|
||||||
set_channel_consent,
|
set_channel_consent,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -757,3 +759,143 @@ class PcmAuthTests(TestCase):
|
|||||||
with self.assertRaises(pcm_mod.PcmApiError) as ctx:
|
with self.assertRaises(pcm_mod.PcmApiError) as ctx:
|
||||||
pcm_mod.login(force=True)
|
pcm_mod.login(force=True)
|
||||||
self.assertIn("PCM_API_SECRET", str(ctx.exception))
|
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)
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ urlpatterns = [
|
|||||||
views.campaign_test_send,
|
views.campaign_test_send,
|
||||||
name="campaign_test_send",
|
name="campaign_test_send",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"campaigns/upload-image/",
|
||||||
|
views.campaign_image_upload,
|
||||||
|
name="campaign_image_upload",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"files/<uuid:pk>/",
|
||||||
|
views.stored_file,
|
||||||
|
name="stored_file",
|
||||||
|
),
|
||||||
path("postcard/", views.postcard_designer, name="postcard_designer"),
|
path("postcard/", views.postcard_designer, name="postcard_designer"),
|
||||||
path(
|
path(
|
||||||
"postcard/create/",
|
"postcard/create/",
|
||||||
|
|||||||
+219
-53
@@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -7,18 +8,19 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
|
|
||||||
from contacts.models import Channel
|
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 (
|
from messaging.providers.postcard.pcm import (
|
||||||
PCM_SIZE_CHOICES,
|
PCM_SIZE_CHOICES,
|
||||||
PcmApiError,
|
PcmApiError,
|
||||||
create_custom_design,
|
create_custom_design,
|
||||||
|
design_id_from_template,
|
||||||
get_design_embed_url,
|
get_design_embed_url,
|
||||||
list_designs,
|
list_designs,
|
||||||
)
|
)
|
||||||
@@ -41,6 +43,11 @@ from messaging.webhooks import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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]]:
|
def _audience_choices() -> list[tuple[str, str]]:
|
||||||
"""Labeled audience options with live opted-in counts."""
|
"""Labeled audience options with live opted-in counts."""
|
||||||
@@ -65,8 +72,165 @@ def _postcard_templates():
|
|||||||
)[:50]
|
)[: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:
|
def _campaign_report(campaign: Campaign) -> dict:
|
||||||
messages_qs = list(campaign.messages.select_related("contact").all()[:200])
|
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)
|
stats = campaign_engagement_stats(campaign)
|
||||||
recent_events = (
|
recent_events = (
|
||||||
ProviderEvent.objects.filter(message__campaign=campaign)
|
ProviderEvent.objects.filter(message__campaign=campaign)
|
||||||
@@ -168,7 +332,7 @@ def campaign_list(request):
|
|||||||
|
|
||||||
template = None
|
template = None
|
||||||
if template_id:
|
if template_id:
|
||||||
template = MessageTemplate.objects.filter(pk=template_id).first()
|
template = _resolve_postcard_template(template_id)
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
form_errors.append("Campaign name is required.")
|
form_errors.append("Campaign name is required.")
|
||||||
@@ -177,7 +341,7 @@ def campaign_list(request):
|
|||||||
if audience == Campaign.Audience.POSTCARD_OPT_IN:
|
if audience == Campaign.Audience.POSTCARD_OPT_IN:
|
||||||
if not template or template.channel != Channel.POSTCARD:
|
if not template or template.channel != Channel.POSTCARD:
|
||||||
form_errors.append(
|
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:
|
if not body:
|
||||||
body = "Postcard mailing"
|
body = "Postcard mailing"
|
||||||
@@ -222,9 +386,10 @@ def campaign_list(request):
|
|||||||
{
|
{
|
||||||
"campaigns": campaigns,
|
"campaigns": campaigns,
|
||||||
"audience_choices": _audience_choices(),
|
"audience_choices": _audience_choices(),
|
||||||
"postcard_templates": _postcard_templates(),
|
"postcard_designs": _postcard_design_choices(),
|
||||||
"form_data": form_data,
|
"form_data": form_data,
|
||||||
"form_errors": form_errors,
|
"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),
|
"id": str(m.pk),
|
||||||
"contact": str(m.contact),
|
"contact": str(m.contact),
|
||||||
|
"destination": getattr(m, "destination", "") or "",
|
||||||
"status": m.status,
|
"status": m.status,
|
||||||
"status_display": m.get_status_display(),
|
"status_display": m.get_status_display(),
|
||||||
"provider_message_id": m.provider_message_id or "",
|
"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)
|
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
|
@login_required
|
||||||
def postcard_designer(request):
|
def postcard_designer(request):
|
||||||
"""PCM Integrations designer — list designs + embed iframe."""
|
"""PCM Integrations designer — list designs + embed iframe."""
|
||||||
api_error = ""
|
designs, api_error = _fetch_pcm_designs()
|
||||||
designs: list[dict] = []
|
|
||||||
embed_url = ""
|
embed_url = ""
|
||||||
active_design_id = (request.GET.get("design_id") or "").strip()
|
active_design_id = (request.GET.get("design_id") or "").strip()
|
||||||
active_name = ""
|
active_name = ""
|
||||||
active_size = "46"
|
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:
|
if active_design_id:
|
||||||
match = next(
|
match = next(
|
||||||
(d for d in designs if d["design_id"] == active_design_id), None
|
(d for d in designs if d["design_id"] == active_design_id), None
|
||||||
|
|||||||
@@ -186,7 +186,12 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
|
|||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
BASE_DIR / "monica_site" / "static",
|
BASE_DIR / "monica_site" / "static",
|
||||||
]
|
]
|
||||||
|
# Uploaded blobs live in the DB (messaging.StoredFile). Default storage is
|
||||||
|
# in-memory only so nothing is written to disk accidentally.
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
|
||||||
|
},
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False) # noqa: F4
|
|||||||
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405
|
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
|
||||||
|
},
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ def _absolute_static_url(site_url: str, relative: str) -> str:
|
|||||||
return f"{site_url}{path}"
|
return f"{site_url}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tagline_with_site_link(tagline: str, site_url: str) -> str:
|
||||||
|
"""Turn leading MKDRealtor.com (or similar) into a link to the public site."""
|
||||||
|
raw = (tagline or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
# Match "MKDRealtor.com" (any case) at the start, optional trailing " · rest"
|
||||||
|
match = re.match(
|
||||||
|
r"(?i)^(MKDRealtor\.com)(\s*[·•\-–—]\s*.*)?$",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return html.escape(raw)
|
||||||
|
label = html.escape(match.group(1))
|
||||||
|
rest = html.escape(match.group(2) or "")
|
||||||
|
href = html.escape(site_url, quote=True)
|
||||||
|
return (
|
||||||
|
f'<a href="{href}" style="color:#00626c;text-decoration:none;">{label}</a>'
|
||||||
|
f"{rest}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def email_brand_context(**extra):
|
def email_brand_context(**extra):
|
||||||
site_url = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/")
|
site_url = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/")
|
||||||
if not site_url:
|
if not site_url:
|
||||||
@@ -57,6 +78,7 @@ def email_brand_context(**extra):
|
|||||||
"brand_name": brand_name,
|
"brand_name": brand_name,
|
||||||
"brand_legal": brand_legal,
|
"brand_legal": brand_legal,
|
||||||
"brand_tagline": tagline,
|
"brand_tagline": tagline,
|
||||||
|
"brand_tagline_html": _tagline_with_site_link(tagline, site_url),
|
||||||
"host_label": host_label,
|
"host_label": host_label,
|
||||||
**extra,
|
**extra,
|
||||||
}
|
}
|
||||||
@@ -81,3 +103,44 @@ def plain_text_to_email_html(text: str) -> str:
|
|||||||
f'line-height:1.6;">{joined}</p>'
|
f'line-height:1.6;">{joined}</p>'
|
||||||
)
|
)
|
||||||
return "\n".join(blocks)
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
_HTML_TAG_RE = re.compile(
|
||||||
|
r"<\s*(p|div|br|span|strong|em|b|i|u|a|img|h[1-6]|ul|ol|li|font|table)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_email_html(raw: str) -> str:
|
||||||
|
"""Light cleanup for staff-authored HTML (Quill) before sending."""
|
||||||
|
text = raw or ""
|
||||||
|
text = re.sub(r"(?is)<script[^>]*>.*?</script>", "", text)
|
||||||
|
text = re.sub(r"(?is)<iframe[^>]*>.*?</iframe>", "", text)
|
||||||
|
text = re.sub(r"(?is)<object[^>]*>.*?</object>", "", text)
|
||||||
|
text = re.sub(r"(?i)\son\w+\s*=\s*([\"']).*?\1", "", text)
|
||||||
|
text = re.sub(r"(?i)\son\w+\s*=\s*[^\s>]+", "", text)
|
||||||
|
text = re.sub(r"(?i)javascript:", "", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_body_to_email_html(body: str) -> str:
|
||||||
|
"""Render campaign body for email — HTML as-is when Quill markup, else plain."""
|
||||||
|
raw = (body or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if _HTML_TAG_RE.search(raw):
|
||||||
|
return sanitize_email_html(raw)
|
||||||
|
return plain_text_to_email_html(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_body_to_plain_text(body: str) -> str:
|
||||||
|
"""Plain-text alternative for multipart emails."""
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
|
||||||
|
raw = (body or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if _HTML_TAG_RE.search(raw):
|
||||||
|
text = strip_tags(sanitize_email_html(raw))
|
||||||
|
return html.unescape(re.sub(r"[ \t]+\n", "\n", text)).strip()
|
||||||
|
return raw
|
||||||
|
|||||||
@@ -73,7 +73,9 @@
|
|||||||
{% if brand_name %}
|
{% if brand_name %}
|
||||||
<p style="margin:12px 0 0;font-size:15px;font-weight:600;color:#212121;">{{ brand_name }}</p>
|
<p style="margin:12px 0 0;font-size:15px;font-weight:600;color:#212121;">{{ brand_name }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if brand_tagline %}
|
{% if brand_tagline_html %}
|
||||||
|
<p style="margin:4px 0 0;font-size:12px;color:#6b7280;">{{ brand_tagline_html|safe }}</p>
|
||||||
|
{% elif brand_tagline %}
|
||||||
<p style="margin:4px 0 0;font-size:12px;color:#6b7280;">{{ brand_tagline }}</p>
|
<p style="margin:4px 0 0;font-size:12px;color:#6b7280;">{{ brand_tagline }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% block header_extra %}{% endblock %}
|
{% block header_extra %}{% endblock %}
|
||||||
@@ -97,10 +99,14 @@
|
|||||||
© {% now "Y" %} {{ brand_name|default:"Monica Dhillon" }}. All rights reserved.
|
© {% now "Y" %} {{ brand_name|default:"Monica Dhillon" }}. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;color:#6b7280;">
|
<p style="margin:0;color:#6b7280;">
|
||||||
|
{% if brand_tagline_html %}
|
||||||
|
{{ brand_tagline_html|safe }}
|
||||||
|
{% else %}
|
||||||
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00626c;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a>
|
<a href="{{ site_url|default:'https://mkdrealtor.com' }}" style="color:#00626c;text-decoration:none;">{{ host_label|default:"mkdrealtor.com" }}</a>
|
||||||
{% if brand_tagline %}
|
{% if brand_tagline %}
|
||||||
· {{ brand_tagline }}
|
· {{ brand_tagline }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+26
-11
@@ -8,6 +8,7 @@ from django.views.decorators.http import require_GET, require_http_methods
|
|||||||
|
|
||||||
from analytics.services import attribute_lead_from_request
|
from analytics.services import attribute_lead_from_request
|
||||||
from contacts.models import Channel, ConsentRecord, Contact
|
from contacts.models import Channel, ConsentRecord, Contact
|
||||||
|
from contacts.services import upsert_contact
|
||||||
from leads.models import Lead
|
from leads.models import Lead
|
||||||
from messaging.services import (
|
from messaging.services import (
|
||||||
channel_preferences,
|
channel_preferences,
|
||||||
@@ -91,12 +92,6 @@ def contact(request):
|
|||||||
form = ContactForm(request.POST)
|
form = ContactForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
defaults = {
|
|
||||||
"first_name": data["first_name"],
|
|
||||||
"last_name": data.get("last_name") or "",
|
|
||||||
"phone": data.get("phone") or "",
|
|
||||||
"source": Contact.Source.CONTACT_FORM,
|
|
||||||
}
|
|
||||||
postal = Contact.make_postal_address(
|
postal = Contact.make_postal_address(
|
||||||
line1=data.get("address_line1") or "",
|
line1=data.get("address_line1") or "",
|
||||||
line2=data.get("address_line2") or "",
|
line2=data.get("address_line2") or "",
|
||||||
@@ -104,11 +99,16 @@ def contact(request):
|
|||||||
state=data.get("address_state") or "",
|
state=data.get("address_state") or "",
|
||||||
zip_code=data.get("address_zip") or "",
|
zip_code=data.get("address_zip") or "",
|
||||||
)
|
)
|
||||||
if Contact.postal_address_has_content(postal):
|
submitted_email = data["email"].lower()
|
||||||
defaults["postal_address"] = postal
|
contact_obj, _created, match_reason = upsert_contact(
|
||||||
contact_obj, _ = Contact.objects.update_or_create(
|
email=submitted_email,
|
||||||
email=data["email"].lower(),
|
first_name=data["first_name"],
|
||||||
defaults=defaults,
|
last_name=data.get("last_name") or "",
|
||||||
|
phone=data.get("phone") or "",
|
||||||
|
postal_address=postal
|
||||||
|
if Contact.postal_address_has_content(postal)
|
||||||
|
else None,
|
||||||
|
source=Contact.Source.CONTACT_FORM,
|
||||||
)
|
)
|
||||||
ConsentRecord.objects.update_or_create(
|
ConsentRecord.objects.update_or_create(
|
||||||
contact=contact_obj,
|
contact=contact_obj,
|
||||||
@@ -121,11 +121,26 @@ def contact(request):
|
|||||||
channel=Channel.SMS,
|
channel=Channel.SMS,
|
||||||
defaults={"opted_in": True, "reason": "contact_form"},
|
defaults={"opted_in": True, "reason": "contact_form"},
|
||||||
)
|
)
|
||||||
|
if Contact.postal_address_has_content(postal):
|
||||||
|
ConsentRecord.objects.get_or_create(
|
||||||
|
contact=contact_obj,
|
||||||
|
channel=Channel.POSTCARD,
|
||||||
|
defaults={"opted_in": True, "reason": "contact_form"},
|
||||||
|
)
|
||||||
interest = data.get("interest") or ""
|
interest = data.get("interest") or ""
|
||||||
interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest)
|
interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest)
|
||||||
body = data.get("message") or ""
|
body = data.get("message") or ""
|
||||||
if interest_label:
|
if interest_label:
|
||||||
body = f"Interest: {interest_label}\n\n{body}".strip()
|
body = f"Interest: {interest_label}\n\n{body}".strip()
|
||||||
|
if (
|
||||||
|
match_reason in {"phone", "address"}
|
||||||
|
and (contact_obj.email or "").lower() != submitted_email
|
||||||
|
):
|
||||||
|
body = (
|
||||||
|
f"Submitted email: {submitted_email} "
|
||||||
|
f"(merged by {match_reason} with "
|
||||||
|
f"{contact_obj.email or 'existing contact'})\n\n{body}"
|
||||||
|
).strip()
|
||||||
lead = Lead.objects.create(
|
lead = Lead.objects.create(
|
||||||
contact=contact_obj,
|
contact=contact_obj,
|
||||||
message=body,
|
message=body,
|
||||||
|
|||||||
Reference in New Issue
Block a user