Template
Populate the client website template with catalog feature flags.
Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed. Refs #1 Refs #2 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user