Improve campaign personalization and contact duplicate handling.
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:
+84
-47
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user