Improve campaign personalization and contact duplicate handling.
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m44s

Add merge tags for email/SMS, paginated removable recipients, PCM event panels for postcard campaigns, and a portal modal when phone/address matches an existing contact.
This commit is contained in:
2026-08-10 09:47:04 -05:00
parent 58258f2875
commit 8680c082fe
13 changed files with 747 additions and 101 deletions
+84 -47
View File
@@ -60,6 +60,9 @@ def find_matching_contact(
email: str = "",
phone: str = "",
postal_address: dict | None = None,
match_email: bool = True,
match_phone: bool = True,
match_address: bool = True,
) -> tuple[Contact | None, str]:
"""
Resolve an existing contact.
@@ -68,20 +71,21 @@ def find_matching_contact(
Returns (contact, reason) where reason is email|phone|address|"".
"""
email_norm = (email or "").strip().lower()
if email_norm:
if match_email and 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 match_phone:
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):
if match_address and 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={})
@@ -110,45 +114,18 @@ def _merge_notes(existing: str, addition: str) -> str:
return f"{existing}\n{addition}".strip()
def upsert_contact(
def _apply_contact_fields(
existing: 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, ""
email_norm: str,
first_name: str,
last_name: str,
phone: str,
postal: dict,
has_postal: bool,
source: str,
notes_append: str,
) -> None:
changed: list[str] = []
if first_name and first_name.strip():
existing.first_name = first_name.strip()
@@ -199,4 +176,64 @@ def upsert_contact(
fields = sorted(set(changed) | {"updated_at"})
existing.save(update_fields=fields)
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 = "",
merge_phone_address: bool = True,
merge_into: Contact | None = None,
) -> tuple[Contact, bool, str]:
"""
Find or create a contact, merging on email / phone / address.
Returns (contact, created, match_reason).
``merge_phone_address=False`` only merges on exact email.
``merge_into`` forces merge into that contact row.
"""
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)
if merge_into is not None:
existing, reason = merge_into, "manual"
else:
existing, reason = find_matching_contact(
email=email_norm,
phone=phone,
postal_address=postal if has_postal else None,
match_phone=merge_phone_address,
match_address=merge_phone_address,
)
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, ""
_apply_contact_fields(
existing,
email_norm=email_norm,
first_name=first_name,
last_name=last_name,
phone=phone,
postal=postal,
has_postal=has_postal,
source=source,
notes_append=notes_append,
)
return existing, False, reason
+78 -3
View File
@@ -4,10 +4,32 @@
{% block topbar_title %}New contact{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
<style>
.portal-modal-backdrop {
position: fixed; inset: 0; background: rgba(17, 24, 39, 0.45);
display: flex; align-items: center; justify-content: center;
z-index: 1000; padding: 16px;
}
.portal-modal {
background: #fff; border: 1px solid var(--monica-border);
max-width: 480px; width: 100%; padding: 20px 22px;
box-shadow: 0 12px 40px rgba(0,0,0,0.18);
}
.portal-modal h3 { margin: 0 0 8px; font-size: 18px; }
.portal-modal p { margin: 0 0 12px; font-size: 14px; color: var(--monica-muted); line-height: 1.45; }
.portal-modal .match-card {
background: #f8fafc; border: 1px solid var(--monica-border);
padding: 12px; margin: 0 0 16px; font-size: 14px;
}
.portal-modal .match-card strong { display: block; margin-bottom: 4px; }
.portal-modal-actions { display: flex; gap: 8px; flex-wrap: wrap; }
</style>
{% endblock %}
{% block portal_content %}
<form method="post">
<form method="post" id="contact-create-form">
{% csrf_token %}
<input type="hidden" name="resolve_match" id="id_resolve_match" value="">
<input type="hidden" name="match_id" id="id_match_id" value="{% if match_prompt %}{{ match_prompt.contact.pk }}{% endif %}">
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Profile</h2></div>
@@ -87,14 +109,67 @@
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if form.consent_postcard %}checked{% endif %}> Postcard mailings</label>
</div>
<p class="hint-block" style="margin-top:16px">
Matches existing contacts by email, phone, or address when possible.
Postcard defaults on when an address is saved.
Same email always updates that contact. Same phone or address asks whether to update or create new.
</p>
</div>
</div>
</div>
</form>
{% if match_prompt %}
<div class="portal-modal-backdrop" id="match-modal" role="dialog" aria-modal="true" aria-labelledby="match-modal-title">
<div class="portal-modal">
<h3 id="match-modal-title">Possible duplicate</h3>
<p>
An existing contact shares this {{ match_prompt.reason_label }}.
Update that record, or create a separate contact anyway?
</p>
<div class="match-card">
<strong>{{ match_prompt.contact }}</strong>
{% if match_prompt.contact.email %}<div>{{ match_prompt.contact.email }}</div>{% endif %}
{% if match_prompt.contact.phone %}<div>{{ match_prompt.contact.phone }}</div>{% endif %}
{% if match_prompt.contact.postal_address.line1 %}
<div class="muted">
{{ match_prompt.contact.postal_address.line1 }}{% if match_prompt.contact.postal_address.city %}, {{ match_prompt.contact.postal_address.city }}{% endif %}
</div>
{% endif %}
<div style="margin-top:8px">
<a href="{% url 'contacts:detail' match_prompt.contact.pk %}" target="_blank" rel="noopener">Open existing contact</a>
</div>
</div>
<div class="portal-modal-actions">
<button class="btn btn-primary" type="button" id="match-update">Update existing</button>
<button class="btn btn-ghost" type="button" id="match-create">Create new contact</button>
<button class="btn btn-ghost" type="button" id="match-cancel">Cancel</button>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% if match_prompt %}
<script>
(function () {
var form = document.getElementById('contact-create-form');
var resolve = document.getElementById('id_resolve_match');
var modal = document.getElementById('match-modal');
function submitWith(choice) {
if (resolve) resolve.value = choice;
if (modal) modal.hidden = true;
form.submit();
}
document.getElementById('match-update')?.addEventListener('click', function () {
submitWith('update');
});
document.getElementById('match-create')?.addEventListener('click', function () {
submitWith('create');
});
document.getElementById('match-cancel')?.addEventListener('click', function () {
if (resolve) resolve.value = '';
if (modal) modal.remove();
});
})();
</script>
{% endif %}
{% endblock %}
+16 -4
View File
@@ -13,12 +13,24 @@
<div class="panel-h"><h2>Profile</h2></div>
<div class="panel-b form-grid">
<div class="form-grid cols-2">
<div class="field"><label>First name</label><input value="{{ contact.first_name }}" readonly></div>
<div class="field"><label>Last name</label><input value="{{ contact.last_name }}" readonly></div>
<div class="field">
<label for="id_first_name">First name</label>
<input id="id_first_name" name="first_name" value="{{ contact.first_name }}" required>
</div>
<div class="field">
<label for="id_last_name">Last name</label>
<input id="id_last_name" name="last_name" value="{{ contact.last_name }}">
</div>
</div>
<div class="form-grid cols-2">
<div class="field"><label>Email</label><input value="{{ contact.email }}" readonly></div>
<div class="field"><label>Phone</label><input value="{{ contact.phone }}" readonly></div>
<div class="field">
<label for="id_email">Email</label>
<input id="id_email" name="email" type="email" value="{{ contact.email }}" required>
</div>
<div class="field">
<label for="id_phone">Phone</label>
<input id="id_phone" name="phone" value="{{ contact.phone }}">
</div>
</div>
<div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div>
+67
View File
@@ -1,3 +1,4 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
@@ -152,3 +153,69 @@ class ContactFormMergeTests(TestCase):
self.assertEqual(lead.contact_id, self.existing.pk)
self.assertIn("alt@example.com", lead.message)
self.assertIn("merged by phone", lead.message)
class PortalCreateMatchPromptTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="adder", password="test-pass-123"
)
self.client = Client()
self.client.login(username="adder", password="test-pass-123")
self.existing = Contact.objects.create(
email="primary@example.com",
phone="6301112222",
first_name="Alex",
)
def test_phone_match_shows_prompt(self):
url = reverse("contacts:create")
response = self.client.post(
url,
{
"first_name": "Alex",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"consent_email": "1",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Possible duplicate")
self.assertContains(response, "Update existing")
self.assertEqual(Contact.objects.count(), 1)
def test_choose_create_new_keeps_both(self):
url = reverse("contacts:create")
response = self.client.post(
url,
{
"first_name": "Alex",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"consent_email": "1",
"resolve_match": "create",
"match_id": str(self.existing.pk),
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(Contact.objects.count(), 2)
def test_choose_update_merges(self):
url = reverse("contacts:create")
response = self.client.post(
url,
{
"first_name": "Alexander",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"consent_email": "1",
"resolve_match": "update",
"match_id": str(self.existing.pk),
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(Contact.objects.count(), 1)
self.existing.refresh_from_db()
self.assertEqual(self.existing.first_name, "Alexander")
self.assertIn("alt@example.com", self.existing.notes)
+121 -22
View File
@@ -9,7 +9,7 @@ from django.views.decorators.http import require_GET, require_http_methods
from contacts.models import Channel, ConsentRecord, Contact
from contacts.nominatim import NominatimError, suggest_addresses
from contacts.services import upsert_contact
from contacts.services import find_matching_contact, upsert_contact
from messaging.services import channel_preferences, set_channel_preferences
@@ -70,6 +70,7 @@ def contact_create(request):
"consent_sms": False,
"consent_postcard": True,
}
match_prompt = None
if request.method == "POST":
for key in list(form.keys()):
if key.startswith("consent_"):
@@ -77,6 +78,8 @@ def contact_create(request):
else:
form[key] = (request.POST.get(key) or "").strip()
email = form["email"].lower()
resolve = (request.POST.get("resolve_match") or "").strip()
match_id = (request.POST.get("match_id") or "").strip()
errors: list[str] = []
if not form["first_name"]:
errors.append("First name is required.")
@@ -92,34 +95,77 @@ def contact_create(request):
if form["consent_sms"] and not form["phone"]:
errors.append("Phone is required for SMS consent.")
if form["consent_postcard"] and not has_postal:
# Soft: allow save but clear postcard consent if no address
form["consent_postcard"] = False
if not errors:
contact, created, reason = upsert_contact(
existing, reason = find_matching_contact(
email=email,
first_name=form["first_name"],
last_name=form["last_name"],
phone=form["phone"],
postal_address=postal if has_postal else None,
source=Contact.Source.MANUAL,
notes_append=form["notes"],
)
set_channel_preferences(
contact,
{
Channel.EMAIL: form["consent_email"],
Channel.SMS: form["consent_sms"],
Channel.POSTCARD: form["consent_postcard"],
},
reason="portal_manual",
)
verb = "Added" if created else f"Updated (matched by {reason or 'email'})"
messages.success(request, f"{verb} {contact}.")
return redirect("contacts:detail", pk=contact.pk)
# Phone/address collision (different email): ask user unless they chose.
if (
existing
and reason in {"phone", "address"}
and resolve not in {"update", "create"}
):
match_prompt = {
"contact": existing,
"reason": reason,
"reason_label": "phone number"
if reason == "phone"
else "mailing address",
}
else:
merge_into = None
merge_phone_address = True
if resolve == "update" and match_id:
merge_into = Contact.objects.filter(pk=match_id).first()
if merge_into is None:
errors.append("Matched contact no longer exists.")
elif resolve == "create":
merge_phone_address = False
elif reason == "email" and existing:
merge_into = existing
if not errors:
contact, created, used_reason = upsert_contact(
email=email,
first_name=form["first_name"],
last_name=form["last_name"],
phone=form["phone"],
postal_address=postal if has_postal else None,
source=Contact.Source.MANUAL,
notes_append=form["notes"],
merge_phone_address=merge_phone_address,
merge_into=merge_into,
)
set_channel_preferences(
contact,
{
Channel.EMAIL: form["consent_email"],
Channel.SMS: form["consent_sms"],
Channel.POSTCARD: form["consent_postcard"],
},
reason="portal_manual",
)
if created:
verb = "Added"
elif used_reason == "email":
verb = "Updated (same email)"
elif resolve == "update":
verb = f"Updated (matched by {reason or used_reason})"
else:
verb = f"Updated (matched by {used_reason or 'email'})"
messages.success(request, f"{verb} {contact}.")
return redirect("contacts:detail", pk=contact.pk)
for err in errors:
messages.error(request, err)
return render(request, "contacts/create.html", {"form": form})
return render(
request,
"contacts/create.html",
{"form": form, "match_prompt": match_prompt},
)
@login_required
@require_http_methods(["GET", "POST"])
@@ -128,9 +174,62 @@ def contact_detail(request, pk):
Contact.objects.prefetch_related("consents"), pk=pk
)
if request.method == "POST":
first_name = (request.POST.get("first_name") or "").strip()
last_name = (request.POST.get("last_name") or "").strip()
email = (request.POST.get("email") or "").strip().lower()
phone = (request.POST.get("phone") or "").strip()
errors: list[str] = []
if not first_name:
errors.append("First name is required.")
if not email:
errors.append("Email is required.")
else:
try:
validate_email(email)
except ValidationError:
errors.append("Enter a valid email address.")
else:
taken = (
Contact.objects.filter(email__iexact=email)
.exclude(pk=contact.pk)
.exists()
)
if taken:
errors.append("Another contact already uses that email.")
if errors:
for err in errors:
messages.error(request, err)
prefs = _consent_flags(contact)
# Reflect submitted values so the user can fix them.
contact.first_name = first_name
contact.last_name = last_name
contact.email = email
contact.phone = phone
contact.postal_address = _postal_from_post(request.POST)
contact.notes = (request.POST.get("notes") or "").strip()
return render(
request,
"contacts/detail.html",
{"contact": contact, "prefs": prefs},
)
contact.first_name = first_name
contact.last_name = last_name
contact.email = email
contact.phone = phone
contact.postal_address = _postal_from_post(request.POST)
contact.notes = (request.POST.get("notes") or "").strip()
contact.save(update_fields=["postal_address", "notes", "updated_at"])
contact.save(
update_fields=[
"first_name",
"last_name",
"email",
"phone",
"postal_address",
"notes",
"updated_at",
]
)
set_channel_preferences(
contact,
{