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
+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,
{