Initial commit

This commit is contained in:
ai_ml_operations
2026-09-06 04:27:41 -07:00
commit 8a97e3fbe2
302 changed files with 34038 additions and 0 deletions
View File
+33
View File
@@ -0,0 +1,33 @@
from django.contrib import admin
from contacts.models import ConsentRecord, Contact, Suppression
class ConsentInline(admin.TabularInline):
model = ConsentRecord
extra = 0
class SuppressionInline(admin.TabularInline):
model = Suppression
extra = 0
@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
list_display = ("email", "first_name", "last_name", "phone", "source", "created_at")
search_fields = ("email", "first_name", "last_name", "phone")
list_filter = ("source",)
fields = (
"email",
"first_name",
"last_name",
"phone",
"postal_address",
"source",
"notes",
"created_at",
"updated_at",
)
readonly_fields = ("created_at", "updated_at")
inlines = [ConsentInline, SuppressionInline]
+17
View File
@@ -0,0 +1,17 @@
from django.apps import AppConfig
class ContactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "contacts"
def ready(self):
from core.registry import register_portal_nav
register_portal_nav(
section="contacts",
label="Contacts",
url_name="contacts:list",
group="Outreach",
order=10,
)
+179
View File
@@ -0,0 +1,179 @@
"""Consent, suppression, and unsubscribe helpers (always-on)."""
from __future__ import annotations
from django.core import signing
from django.db.models import QuerySet
from django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression
UNSUB_SALT = "client-site-unsubscribe"
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
def contact_may_receive(contact: Contact, channel: str) -> bool:
if Suppression.objects.filter(
contact=contact, channel=channel, active=True
).exists():
return False
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
if channel == Channel.POSTCARD:
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)
def channel_preferences(contact: Contact) -> dict[str, bool]:
"""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}
seen: set[str] = set()
for record in contact.consents.all():
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
def set_channel_consent(
contact: Contact,
channel: str,
*,
opted_in: bool,
reason: str = "",
) -> None:
if channel not in Channel.values:
raise ValueError(f"Unknown channel: {channel}")
ConsentRecord.objects.update_or_create(
contact=contact,
channel=channel,
defaults={"opted_in": opted_in, "reason": reason},
)
Suppression.objects.update_or_create(
contact=contact,
channel=channel,
defaults={
"active": not opted_in,
"reason": reason if not opted_in else "",
},
)
def set_channel_preferences(
contact: Contact,
preferences: dict[str, bool],
*,
reason: str = "",
) -> None:
for channel, opted_in in preferences.items():
if channel not in Channel.values:
continue
set_channel_consent(
contact, channel, opted_in=bool(opted_in), reason=reason
)
def unsubscribe_all(contact: Contact, *, reason: str = "unsubscribe_all") -> None:
for channel in Channel:
set_channel_consent(
contact, channel.value, opted_in=False, reason=reason
)
def make_unsubscribe_token(contact_id: str, channel: str = Channel.EMAIL) -> str:
return signing.dumps({"c": str(contact_id), "ch": channel}, salt=UNSUB_SALT)
def parse_unsubscribe_token(token: str) -> tuple[Contact | None, str]:
try:
data = signing.loads(token, salt=UNSUB_SALT, max_age=UNSUB_MAX_AGE)
except signing.BadSignature:
return None, ""
contact = (
Contact.objects.filter(pk=data.get("c"))
.prefetch_related("consents")
.first()
)
if not contact:
return None, ""
channel = data.get("ch") or Channel.EMAIL
if channel not in Channel.values:
channel = Channel.EMAIL
return contact, channel
def process_unsubscribe_token(token: str) -> bool:
contact, channel = parse_unsubscribe_token(token)
if not contact:
return False
set_channel_consent(
contact, channel, opted_in=False, reason="unsubscribe_link"
)
return True
def preferences_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
token = make_unsubscribe_token(contact_id, channel)
return reverse("public:unsubscribe", kwargs={"token": token})
def one_click_unsubscribe_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
token = make_unsubscribe_token(contact_id, channel)
return reverse("public:unsubscribe_one_click", kwargs={"token": token})
def record_sms_stop(phone: str) -> bool:
digits = "".join(ch for ch in (phone or "") if ch.isdigit())
if len(digits) < 7:
return False
tail = digits[-10:]
contact = None
for row in Contact.objects.exclude(phone="").iterator():
stored = "".join(ch for ch in row.phone if ch.isdigit())
if stored.endswith(tail) or tail.endswith(stored[-10:]):
contact = row
break
if not contact:
return False
set_channel_consent(
contact, Channel.SMS, opted_in=False, reason="sms_stop"
)
return True
def opted_in_contacts(channel: str) -> QuerySet[Contact]:
"""Contacts opted in for channel and not actively suppressed."""
suppressed = Suppression.objects.filter(
channel=channel, active=True
).values_list("contact_id", flat=True)
if channel == Channel.POSTCARD:
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 = (
Contact.objects.filter(
consents__channel=channel,
consents__opted_in=True,
)
.exclude(pk__in=suppressed)
.distinct()
.order_by("first_name", "last_name", "email")
)
return qs
+66
View File
@@ -0,0 +1,66 @@
# Generated by Django 6.1 on 2026-08-26 11:38
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Contact',
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)),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)),
('phone', models.CharField(blank=True, max_length=32)),
('first_name', models.CharField(blank=True, max_length=100)),
('last_name', models.CharField(blank=True, max_length=100)),
('postal_address', models.JSONField(blank=True, default=dict)),
('source', models.CharField(choices=[('contact_form', 'Contact form'), ('import', 'Import'), ('manual', 'Manual'), ('notify_me', 'Notify me'), ('other', 'Other')], default='other', max_length=32)),
('notes', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ConsentRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
('opted_in', models.BooleanField(default=False)),
('changed_at', models.DateTimeField(auto_now=True)),
('reason', models.CharField(blank=True, max_length=255)),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='contacts.contact')),
],
options={
'ordering': ['-changed_at'],
'unique_together': {('contact', 'channel')},
},
),
migrations.CreateModel(
name='Suppression',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
('reason', models.CharField(blank=True, max_length=255)),
('active', models.BooleanField(default=True)),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suppressions', to='contacts.contact')),
],
options={
'unique_together': {('contact', 'channel')},
},
),
]
+101
View File
@@ -0,0 +1,101 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Channel(models.TextChoices):
EMAIL = "email", "Email"
SMS = "sms", "SMS"
POSTCARD = "postcard", "Postcard"
class Contact(UUIDPrimaryKeyModel, TimeStampedModel):
class Source(models.TextChoices):
CONTACT_FORM = "contact_form", "Contact form"
IMPORT = "import", "Import"
MANUAL = "manual", "Manual"
NOTIFY_ME = "notify_me", "Notify me"
OTHER = "other", "Other"
email = models.EmailField(unique=True, blank=True, null=True)
phone = models.CharField(max_length=32, blank=True)
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
postal_address = models.JSONField(default=dict, blank=True)
source = models.CharField(
max_length=32, choices=Source.choices, default=Source.OTHER
)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
name = f"{self.first_name} {self.last_name}".strip()
return name or self.email or self.phone or str(self.pk)
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()
@staticmethod
def make_postal_address(
*,
line1: str = "",
line2: str = "",
city: str = "",
state: str = "",
zip_code: str = "",
country: str = "US",
) -> dict:
"""Normalize Lob-shaped postal address dict."""
return {
"line1": (line1 or "").strip(),
"line2": (line2 or "").strip(),
"city": (city or "").strip(),
"state": (state or "").strip(),
"zip": (zip_code or "").strip(),
"country": ((country or "").strip() or "US"),
}
@staticmethod
def postal_address_has_content(addr: dict | None) -> bool:
if not addr:
return False
return any(
(addr.get(key) or "").strip()
for key in ("line1", "line2", "city", "state", "zip")
)
class ConsentRecord(TimeStampedModel):
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="consents"
)
channel = models.CharField(max_length=16, choices=Channel.choices)
opted_in = models.BooleanField(default=False)
changed_at = models.DateTimeField(auto_now=True)
reason = models.CharField(max_length=255, blank=True)
class Meta:
unique_together = ("contact", "channel")
ordering = ["-changed_at"]
def __str__(self) -> str:
state = "in" if self.opted_in else "out"
return f"{self.contact} {self.channel} opt-{state}"
class Suppression(TimeStampedModel):
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="suppressions"
)
channel = models.CharField(max_length=16, choices=Channel.choices)
reason = models.CharField(max_length=255, blank=True)
active = models.BooleanField(default=True)
class Meta:
unique_together = ("contact", "channel")
def __str__(self) -> str:
return f"suppress {self.contact} {self.channel}"
+214
View File
@@ -0,0 +1,214 @@
"""Nominatim client — server-side only; browsers never call Nominatim directly."""
from __future__ import annotations
import logging
import re
from typing import Any
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
# ISO3166-2-lvl4 "US-OH" → "OH"; fall back to common full-name map.
_US_STATE_ABBREV = {
"alabama": "AL",
"alaska": "AK",
"arizona": "AZ",
"arkansas": "AR",
"california": "CA",
"colorado": "CO",
"connecticut": "CT",
"delaware": "DE",
"district of columbia": "DC",
"florida": "FL",
"georgia": "GA",
"hawaii": "HI",
"idaho": "ID",
"illinois": "IL",
"indiana": "IN",
"iowa": "IA",
"kansas": "KS",
"kentucky": "KY",
"louisiana": "LA",
"maine": "ME",
"maryland": "MD",
"massachusetts": "MA",
"michigan": "MI",
"minnesota": "MN",
"mississippi": "MS",
"missouri": "MO",
"montana": "MT",
"nebraska": "NE",
"nevada": "NV",
"new hampshire": "NH",
"new jersey": "NJ",
"new mexico": "NM",
"new york": "NY",
"north carolina": "NC",
"north dakota": "ND",
"ohio": "OH",
"oklahoma": "OK",
"oregon": "OR",
"pennsylvania": "PA",
"rhode island": "RI",
"south carolina": "SC",
"south dakota": "SD",
"tennessee": "TN",
"texas": "TX",
"utah": "UT",
"vermont": "VT",
"virginia": "VA",
"washington": "WA",
"west virginia": "WV",
"wisconsin": "WI",
"wyoming": "WY",
}
class NominatimError(RuntimeError):
pass
# Leading house / unit number from user query (e.g. "1968", "12A", "100-102").
_HOUSE_FROM_QUERY = re.compile(r"^(\d+[A-Za-z]?(?:-\d+[A-Za-z]?)?)\b")
def _house_from_query(query: str) -> str:
match = _HOUSE_FROM_QUERY.match((query or "").strip())
return match.group(1) if match else ""
def _state_code(addr: dict[str, Any]) -> str:
iso = (addr.get("ISO3166-2-lvl4") or "").strip()
if iso.startswith("US-") and len(iso) == 5:
return iso[3:]
raw = (addr.get("state") or "").strip()
if len(raw) == 2:
return raw.upper()
return _US_STATE_ABBREV.get(raw.lower(), raw)
def _city(addr: dict[str, Any]) -> str:
for key in ("city", "town", "village", "hamlet", "municipality", "suburb"):
val = (addr.get(key) or "").strip()
if val:
return val
return ""
def _line1(addr: dict[str, Any], display_name: str, *, query: str = "") -> str:
house = (addr.get("house_number") or "").strip()
road = (addr.get("road") or addr.get("pedestrian") or "").strip()
# Nominatim often returns road-level hits with no house_number even when the
# user typed one — keep that number so mailing street isn't incomplete.
if not house:
house = _house_from_query(query)
if house and road:
return f"{house} {road}"
if road:
return road
# Place-level hits (city only) — leave street empty for the user to fill.
if house or road:
return " ".join(p for p in (house, road) if p)
first = (display_name or "").split(",")[0].strip()
# Avoid stuffing "Akron" into street when it's a city result.
if first and first.lower() != _city(addr).lower():
return first
return ""
def normalize_hit(raw: dict[str, Any], *, query: str = "") -> dict[str, str]:
addr = raw.get("address") or {}
if not isinstance(addr, dict):
addr = {}
country_code = (addr.get("country_code") or "us").upper()
if country_code == "US":
country = "US"
else:
country = country_code[:2] or "US"
display = (raw.get("display_name") or "").strip()
line1 = _line1(addr, display, query=query)
label = display
# Surface recovered house number in the dropdown when OSM omitted it.
house = (addr.get("house_number") or "").strip() or _house_from_query(query)
if house and label and not re.match(rf"^{re.escape(house)}\b", label, re.I):
label = f"{house} {label}"
return {
"label": label,
"line1": line1,
"line2": "",
"city": _city(addr),
"state": _state_code(addr),
"zip": (addr.get("postcode") or "").strip().split(";")[0].strip(),
"country": country,
}
def suggest_addresses(query: str, *, limit: int = 5) -> list[dict[str, str]]:
"""
Proxy Nominatim /search. Returns normalized address dicts for the UI.
Nominatim itself has no API-key auth — LAN firewall + this Django proxy
gate access. Optional NOMINATIM_API_KEY is sent as X-API-Key if you put
a gateway in front of Nominatim later.
"""
base = (settings.NOMINATIM_BASE_URL or "").rstrip("/")
if not base:
raise NominatimError("NOMINATIM_BASE_URL is not configured")
q = (query or "").strip()
if len(q) < 3:
return []
limit = max(1, min(int(limit or 5), 8))
params: dict[str, str | int] = {
"q": q,
"format": "json",
"addressdetails": 1,
"limit": limit,
}
countrycodes = (settings.NOMINATIM_COUNTRY_CODES or "").strip()
if countrycodes:
params["countrycodes"] = countrycodes
headers = {
"User-Agent": settings.NOMINATIM_USER_AGENT,
"Accept": "application/json",
}
api_key = (settings.NOMINATIM_API_KEY or "").strip()
if api_key:
headers["X-API-Key"] = api_key
url = f"{base}/search"
try:
response = requests.get(
url,
params=params,
headers=headers,
timeout=settings.NOMINATIM_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
except requests.RequestException as exc:
logger.exception("Nominatim request failed")
raise NominatimError(f"Nominatim unreachable at {url}: {exc}") from exc
except ValueError as exc:
raise NominatimError("Nominatim returned invalid JSON") from exc
if not isinstance(payload, list):
return []
results: list[dict[str, str]] = []
seen: set[str] = set()
for item in payload:
if not isinstance(item, dict):
continue
normalized = normalize_hit(item, query=q)
key = re.sub(r"\s+", " ", normalized["label"].lower())
if not key or key in seen:
continue
seen.add(key)
results.append(normalized)
return results
+239
View File
@@ -0,0 +1,239 @@
"""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,
match_email: bool = True,
match_phone: bool = True,
match_address: bool = True,
) -> 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 match_email and email_norm:
hit = Contact.objects.filter(email__iexact=email_norm).first()
if hit:
return hit, "email"
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 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={})
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 _apply_contact_fields(
existing: Contact,
*,
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()
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)
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
@@ -0,0 +1,175 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}New contact · Portal{% endblock %}
{% 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" 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>
<div class="panel-b form-grid">
<div class="form-grid cols-2">
<div class="field">
<label for="id_first_name">First name</label>
<input id="id_first_name" name="first_name" required value="{{ form.first_name }}">
</div>
<div class="field">
<label for="id_last_name">Last name</label>
<input id="id_last_name" name="last_name" value="{{ form.last_name }}">
</div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="id_email">Email</label>
<input id="id_email" name="email" type="email" required value="{{ form.email }}">
</div>
<div class="field">
<label for="id_phone">Phone</label>
<input id="id_phone" name="phone" value="{{ form.phone }}">
</div>
</div>
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="field address-ac-wrap">
<label>Street address</label>
<input name="address_line1" data-ac="line1" value="{{ form.address_line1 }}" autocomplete="off">
</div>
<div class="field">
<label>Apt / suite</label>
<input name="address_line2" data-ac="line2" value="{{ form.address_line2 }}" autocomplete="address-line2">
</div>
<div class="form-grid cols-2">
<div class="field">
<label>City</label>
<input name="address_city" data-ac="city" value="{{ form.address_city }}" autocomplete="address-level2">
</div>
<div class="field">
<label>State</label>
<input name="address_state" data-ac="state" value="{{ form.address_state }}" autocomplete="address-level1" maxlength="32">
</div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label>ZIP</label>
<input name="address_zip" data-ac="zip" value="{{ form.address_zip }}" autocomplete="postal-code" maxlength="20">
</div>
<div class="field">
<label>Country</label>
<input name="address_country" data-ac="country" value="{{ form.address_country|default:'US' }}" autocomplete="country" maxlength="2">
</div>
</div>
</div>
<div class="field">
<label for="id_notes">Notes</label>
<textarea id="id_notes" name="notes">{{ form.notes }}</textarea>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary btn-sm" type="submit">Add to mailing list</button>
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:list' %}">Cancel</a>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Consent</h2></div>
<div class="panel-b">
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_email" value="1" {% if form.consent_email %}checked{% endif %}> Email marketing</label>
</div>
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_sms" value="1" {% if form.consent_sms %}checked{% endif %}> SMS updates</label>
</div>
<div class="field">
<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">
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 %}
@@ -0,0 +1,97 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ contact }} · Contact{% endblock %}
{% block topbar_title %}Contact · {{ contact }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block portal_content %}
<form method="post">
{% csrf_token %}
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Profile</h2></div>
<div class="panel-b form-grid">
<div class="form-grid cols-2">
<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 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>
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="field address-ac-wrap">
<label>Street address</label>
<input name="address_line1" data-ac="line1" value="{{ contact.postal_address.line1|default:'' }}" autocomplete="off">
</div>
<div class="field">
<label>Apt / suite</label>
<input name="address_line2" data-ac="line2" value="{{ contact.postal_address.line2|default:'' }}" autocomplete="address-line2">
</div>
<div class="form-grid cols-2">
<div class="field">
<label>City</label>
<input name="address_city" data-ac="city" value="{{ contact.postal_address.city|default:'' }}" autocomplete="address-level2">
</div>
<div class="field">
<label>State</label>
<input name="address_state" data-ac="state" value="{{ contact.postal_address.state|default:'' }}" autocomplete="address-level1" maxlength="32">
</div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label>ZIP</label>
<input name="address_zip" data-ac="zip" value="{{ contact.postal_address.zip|default:'' }}" autocomplete="postal-code" maxlength="20">
</div>
<div class="field">
<label>Country</label>
<input name="address_country" data-ac="country" value="{{ contact.postal_address.country|default:'US' }}" autocomplete="country" maxlength="2">
</div>
</div>
</div>
<div class="field"><label>Notes</label>
<textarea name="notes">{{ contact.notes }}</textarea>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:list' %}">← Mailing list</a>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Consent</h2></div>
<div class="panel-b">
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_email" value="1" {% if prefs.email %}checked{% endif %}> Email marketing</label>
</div>
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_sms" value="1" {% if prefs.sms %}checked{% endif %}> SMS updates</label>
</div>
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label>
</div>
<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>
</form>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "portal_base.html" %}
{% block title %}Import contacts · Portal{% endblock %}
{% block topbar_title %}Import contacts{% endblock %}
{% block portal_content %}
<div class="steps">
<div class="step active"><span>1</span> Upload</div>
<div class="step"><span>2</span> Map columns</div>
<div class="step"><span>3</span> Consent</div>
<div class="step"><span>4</span> Import</div>
</div>
<div class="split">
<div>
<div class="panel">
<div class="panel-h"><h2>Upload</h2></div>
<div class="panel-b">
<div class="dropzone">
<p style="margin:0 0 8px"><strong>Drop CSV or Excel here</strong></p>
<p class="muted" style="margin:0">Import processing wires up next. Accepted: .csv, .xlsx</p>
<p style="margin:16px 0 0"><button class="btn btn-ghost btn-sm" type="button" disabled>Choose file</button></p>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Column mapping</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>Your column</th><th>Maps to</th></tr></thead>
<tbody>
<tr><td>Email</td><td>email</td></tr>
<tr><td>First</td><td>first_name</td></tr>
<tr><td>Last</td><td>last_name</td></tr>
<tr><td>Phone</td><td>phone</td></tr>
<tr><td>Street</td><td>postal_address.line1</td></tr>
<tr><td>City</td><td>postal_address.city</td></tr>
<tr><td>State</td><td>postal_address.state</td></tr>
<tr><td>ZIP</td><td>postal_address.zip</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div>
<div class="panel">
<div class="panel-h"><h2>Consent defaults</h2></div>
<div class="panel-b form-grid">
<label class="check-row"><input type="checkbox" checked disabled> Email marketing</label>
<label class="check-row"><input type="checkbox" disabled> SMS</label>
<label class="check-row"><input type="checkbox" disabled> Postcard</label>
<div class="field"><label>Source</label><input value="Import" disabled></div>
<div class="field"><label>Duplicates</label><select disabled><option>Update existing by email</option></select></div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b">
<p class="muted">Sample rows appear after upload.</p>
<button class="btn btn-primary" type="button" disabled>Import contacts</button>
<p class="hint-block"><a href="{% url 'contacts:list' %}">← Back to mailing list</a></p>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,68 @@
{% extends "portal_base.html" %}
{% block title %}Mailing list · Portal{% endblock %}
{% block topbar_title %}Mailing list{% endblock %}
{% block portal_content %}
<div class="toolbar">
<form class="toolbar-filters" method="get">
<input type="search" name="q" value="{{ q }}" placeholder="Search contacts">
<button class="btn btn-sm btn-ghost" type="submit">Search</button>
</form>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:create' %}">New contact</a>
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
</div>
</div>
<div class="panel">
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th></th>
<th>Contact</th>
<th>Address</th>
<th>Consent</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr>
<td><input type="checkbox" disabled></td>
<td>
<a href="{% url 'contacts:detail' contact.pk %}">{{ contact }}</a><br>
<span class="muted">
{% if contact.email %}{{ contact.email }}{% endif %}
{% if contact.email and contact.phone %} · {% endif %}
{% if contact.phone %}{{ contact.phone }}{% endif %}
</span>
</td>
<td>
{% if contact.postal_address.line1 %}
{{ contact.postal_address.line1 }}{% if contact.postal_address.city %}, {{ contact.postal_address.city }}{% endif %}
{% else %}
{% endif %}
</td>
<td>
{% with c=contact.consent_flags %}
<span class="badge {% if c.email %}badge-optin{% else %}badge-optout{% endif %}">E</span>
<span class="badge {% if c.sms %}badge-optin{% else %}badge-optout{% endif %}">S</span>
<span class="badge {% if c.postcard %}badge-optin{% else %}badge-optout{% endif %}">P</span>
{% endwith %}
</td>
<td>{{ contact.get_source_display }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No contacts yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="muted" style="font-size:13px">
E = email · S = SMS · P = postcard.
<a href="{% url 'contacts:import' %}">Import contacts</a> for bulk CSV/Excel.
</p>
{% endblock %}
+221
View File
@@ -0,0 +1,221 @@
from django.contrib.auth import get_user_model
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": "general",
"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)
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)
+42
View File
@@ -0,0 +1,42 @@
from contacts.nominatim import normalize_hit
def test_line1_keeps_house_number_from_query_when_nominatim_omits_it():
raw = {
"display_name": (
"Greensboro Drive, Wheaton, DuPage County, Illinois, 60189, United States"
),
"address": {
"road": "Greensboro Drive",
"town": "Wheaton",
"county": "DuPage County",
"state": "Illinois",
"postcode": "60189",
"country_code": "us",
"ISO3166-2-lvl4": "US-IL",
},
}
hit = normalize_hit(raw, query="1968 Greensboro Drive, Wheaton")
assert hit["line1"] == "1968 Greensboro Drive"
assert hit["label"].startswith("1968 Greensboro Drive")
assert hit["city"] == "Wheaton"
assert hit["state"] == "IL"
assert hit["zip"] == "60189"
def test_line1_prefers_nominatim_house_number():
raw = {
"display_name": "1968 Greensboro Drive, Wheaton, Illinois, 60189, United States",
"address": {
"house_number": "1968",
"road": "Greensboro Drive",
"town": "Wheaton",
"state": "Illinois",
"postcode": "60189",
"country_code": "us",
"ISO3166-2-lvl4": "US-IL",
},
}
hit = normalize_hit(raw, query="1968 Greensboro Drive")
assert hit["line1"] == "1968 Greensboro Drive"
assert hit["label"] == raw["display_name"]
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from contacts import views
app_name = "contacts"
urlpatterns = [
path("", views.contact_list, name="list"),
path("new/", views.contact_create, name="create"),
path("import/", views.contact_import, name="import"),
path("<uuid:pk>/", views.contact_detail, name="detail"),
]
+274
View File
@@ -0,0 +1,274 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import Prefetch, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
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 find_matching_contact, upsert_contact
from contacts.consent import channel_preferences, set_channel_preferences
def _consent_flags(contact: Contact) -> dict[str, bool]:
return channel_preferences(contact)
def _postal_from_post(post) -> dict:
return Contact.make_postal_address(
line1=post.get("address_line1", ""),
line2=post.get("address_line2", ""),
city=post.get("address_city", ""),
state=post.get("address_state", ""),
zip_code=post.get("address_zip", ""),
country=post.get("address_country", "US"),
)
@login_required
def contact_list(request):
contacts = Contact.objects.prefetch_related(
Prefetch("consents", queryset=ConsentRecord.objects.all())
).all()
q = (request.GET.get("q") or "").strip()
if q:
contacts = contacts.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(phone__icontains=q)
)
rows = list(contacts[:200])
for contact in rows:
contact.consent_flags = _consent_flags(contact)
return render(
request,
"contacts/list.html",
{"contacts": rows, "q": q},
)
@login_required
@require_http_methods(["GET", "POST"])
def contact_create(request):
form = {
"first_name": "",
"last_name": "",
"email": "",
"phone": "",
"address_line1": "",
"address_line2": "",
"address_city": "",
"address_state": "",
"address_zip": "",
"address_country": "US",
"notes": "",
"consent_email": True,
"consent_sms": False,
"consent_postcard": True,
}
match_prompt = None
if request.method == "POST":
for key in list(form.keys()):
if key.startswith("consent_"):
form[key] = key in request.POST
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.")
if not email:
errors.append("Email is required.")
else:
try:
validate_email(email)
except ValidationError:
errors.append("Enter a valid email address.")
postal = _postal_from_post(request.POST)
has_postal = Contact.postal_address_has_content(postal)
if form["consent_sms"] and not form["phone"]:
errors.append("Phone is required for SMS consent.")
if form["consent_postcard"] and not has_postal:
form["consent_postcard"] = False
if not errors:
existing, reason = find_matching_contact(
email=email,
phone=form["phone"],
postal_address=postal if has_postal else None,
)
# 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, "match_prompt": match_prompt},
)
@login_required
@require_http_methods(["GET", "POST"])
def contact_detail(request, pk):
contact = get_object_or_404(
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=[
"first_name",
"last_name",
"email",
"phone",
"postal_address",
"notes",
"updated_at",
]
)
set_channel_preferences(
contact,
{
Channel.EMAIL: "consent_email" in request.POST,
Channel.SMS: "consent_sms" in request.POST,
Channel.POSTCARD: "consent_postcard" in request.POST,
},
reason="portal_manual",
)
messages.success(request, "Contact updated.")
return redirect("contacts:detail", pk=contact.pk)
prefs = _consent_flags(contact)
return render(
request,
"contacts/detail.html",
{"contact": contact, "prefs": prefs},
)
@login_required
def contact_import(request):
return render(request, "contacts/import.html")
@require_GET
def address_suggest(request):
"""
Backend proxy for Nominatim search. Browser JS must call this URL only —
never Nominatim directly.
"""
q = (request.GET.get("q") or "").strip()
if len(q) < 3:
return JsonResponse({"results": []})
try:
limit = int(request.GET.get("limit") or 5)
except (TypeError, ValueError):
limit = 5
try:
results = suggest_addresses(q, limit=limit)
except NominatimError as exc:
return JsonResponse({"error": str(exc), "results": []}, status=502)
return JsonResponse({"results": results})