Add Django site, Docker packaging, and beta/prod Gitea deploys.
Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
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,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContactsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "contacts"
|
||||
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
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,85 @@
|
||||
{% 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>First name</label><input value="{{ contact.first_name }}" readonly></div>
|
||||
<div class="field"><label>Last name</label><input value="{{ contact.last_name }}" readonly></div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field"><label>Email</label><input value="{{ contact.email }}" readonly></div>
|
||||
<div class="field"><label>Phone</label><input value="{{ contact.phone }}" readonly></div>
|
||||
</div>
|
||||
<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 campaigns need a street address and postcard consent. 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,67 @@
|
||||
{% 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-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,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,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from contacts import views
|
||||
|
||||
app_name = "contacts"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.contact_list, name="list"),
|
||||
path("import/", views.contact_import, name="import"),
|
||||
path("<uuid:pk>/", views.contact_detail, name="detail"),
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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 messaging.services 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_detail(request, pk):
|
||||
contact = get_object_or_404(
|
||||
Contact.objects.prefetch_related("consents"), pk=pk
|
||||
)
|
||||
if request.method == "POST":
|
||||
contact.postal_address = _postal_from_post(request.POST)
|
||||
contact.notes = (request.POST.get("notes") or "").strip()
|
||||
contact.save(update_fields=["postal_address", "notes", "updated_at"])
|
||||
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