Add Django site, Docker packaging, and beta/prod Gitea deploys.
Deploy Beta / unit-tests (push) Successful in 9s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 2m31s

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:
2026-08-08 07:32:55 -05:00
parent 7dca98bbf6
commit 1f7d78de64
204 changed files with 21662 additions and 70 deletions
View File
+33
View File
@@ -0,0 +1,33 @@
from django.contrib import admin
from contacts.models import ConsentRecord, Contact, Suppression
class ConsentInline(admin.TabularInline):
model = ConsentRecord
extra = 0
class SuppressionInline(admin.TabularInline):
model = Suppression
extra = 0
@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
list_display = ("email", "first_name", "last_name", "phone", "source", "created_at")
search_fields = ("email", "first_name", "last_name", "phone")
list_filter = ("source",)
fields = (
"email",
"first_name",
"last_name",
"phone",
"postal_address",
"source",
"notes",
"created_at",
"updated_at",
)
readonly_fields = ("created_at", "updated_at")
inlines = [ConsentInline, SuppressionInline]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "contacts"
+66
View File
@@ -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')},
},
),
]
+101
View File
@@ -0,0 +1,101 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Channel(models.TextChoices):
EMAIL = "email", "Email"
SMS = "sms", "SMS"
POSTCARD = "postcard", "Postcard"
class Contact(UUIDPrimaryKeyModel, TimeStampedModel):
class Source(models.TextChoices):
CONTACT_FORM = "contact_form", "Contact form"
IMPORT = "import", "Import"
MANUAL = "manual", "Manual"
NOTIFY_ME = "notify_me", "Notify me"
OTHER = "other", "Other"
email = models.EmailField(unique=True, blank=True, null=True)
phone = models.CharField(max_length=32, blank=True)
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
postal_address = models.JSONField(default=dict, blank=True)
source = models.CharField(
max_length=32, choices=Source.choices, default=Source.OTHER
)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
name = f"{self.first_name} {self.last_name}".strip()
return name or self.email or self.phone or str(self.pk)
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()
@staticmethod
def make_postal_address(
*,
line1: str = "",
line2: str = "",
city: str = "",
state: str = "",
zip_code: str = "",
country: str = "US",
) -> dict:
"""Normalize Lob-shaped postal address dict."""
return {
"line1": (line1 or "").strip(),
"line2": (line2 or "").strip(),
"city": (city or "").strip(),
"state": (state or "").strip(),
"zip": (zip_code or "").strip(),
"country": ((country or "").strip() or "US"),
}
@staticmethod
def postal_address_has_content(addr: dict | None) -> bool:
if not addr:
return False
return any(
(addr.get(key) or "").strip()
for key in ("line1", "line2", "city", "state", "zip")
)
class ConsentRecord(TimeStampedModel):
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="consents"
)
channel = models.CharField(max_length=16, choices=Channel.choices)
opted_in = models.BooleanField(default=False)
changed_at = models.DateTimeField(auto_now=True)
reason = models.CharField(max_length=255, blank=True)
class Meta:
unique_together = ("contact", "channel")
ordering = ["-changed_at"]
def __str__(self) -> str:
state = "in" if self.opted_in else "out"
return f"{self.contact} {self.channel} opt-{state}"
class Suppression(TimeStampedModel):
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="suppressions"
)
channel = models.CharField(max_length=16, choices=Channel.choices)
reason = models.CharField(max_length=255, blank=True)
active = models.BooleanField(default=True)
class Meta:
unique_together = ("contact", "channel")
def __str__(self) -> str:
return f"suppress {self.contact} {self.channel}"
+214
View File
@@ -0,0 +1,214 @@
"""Nominatim client — server-side only; browsers never call Nominatim directly."""
from __future__ import annotations
import logging
import re
from typing import Any
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
# ISO3166-2-lvl4 "US-OH" → "OH"; fall back to common full-name map.
_US_STATE_ABBREV = {
"alabama": "AL",
"alaska": "AK",
"arizona": "AZ",
"arkansas": "AR",
"california": "CA",
"colorado": "CO",
"connecticut": "CT",
"delaware": "DE",
"district of columbia": "DC",
"florida": "FL",
"georgia": "GA",
"hawaii": "HI",
"idaho": "ID",
"illinois": "IL",
"indiana": "IN",
"iowa": "IA",
"kansas": "KS",
"kentucky": "KY",
"louisiana": "LA",
"maine": "ME",
"maryland": "MD",
"massachusetts": "MA",
"michigan": "MI",
"minnesota": "MN",
"mississippi": "MS",
"missouri": "MO",
"montana": "MT",
"nebraska": "NE",
"nevada": "NV",
"new hampshire": "NH",
"new jersey": "NJ",
"new mexico": "NM",
"new york": "NY",
"north carolina": "NC",
"north dakota": "ND",
"ohio": "OH",
"oklahoma": "OK",
"oregon": "OR",
"pennsylvania": "PA",
"rhode island": "RI",
"south carolina": "SC",
"south dakota": "SD",
"tennessee": "TN",
"texas": "TX",
"utah": "UT",
"vermont": "VT",
"virginia": "VA",
"washington": "WA",
"west virginia": "WV",
"wisconsin": "WI",
"wyoming": "WY",
}
class NominatimError(RuntimeError):
pass
# Leading house / unit number from user query (e.g. "1968", "12A", "100-102").
_HOUSE_FROM_QUERY = re.compile(r"^(\d+[A-Za-z]?(?:-\d+[A-Za-z]?)?)\b")
def _house_from_query(query: str) -> str:
match = _HOUSE_FROM_QUERY.match((query or "").strip())
return match.group(1) if match else ""
def _state_code(addr: dict[str, Any]) -> str:
iso = (addr.get("ISO3166-2-lvl4") or "").strip()
if iso.startswith("US-") and len(iso) == 5:
return iso[3:]
raw = (addr.get("state") or "").strip()
if len(raw) == 2:
return raw.upper()
return _US_STATE_ABBREV.get(raw.lower(), raw)
def _city(addr: dict[str, Any]) -> str:
for key in ("city", "town", "village", "hamlet", "municipality", "suburb"):
val = (addr.get(key) or "").strip()
if val:
return val
return ""
def _line1(addr: dict[str, Any], display_name: str, *, query: str = "") -> str:
house = (addr.get("house_number") or "").strip()
road = (addr.get("road") or addr.get("pedestrian") or "").strip()
# Nominatim often returns road-level hits with no house_number even when the
# user typed one — keep that number so mailing street isn't incomplete.
if not house:
house = _house_from_query(query)
if house and road:
return f"{house} {road}"
if road:
return road
# Place-level hits (city only) — leave street empty for the user to fill.
if house or road:
return " ".join(p for p in (house, road) if p)
first = (display_name or "").split(",")[0].strip()
# Avoid stuffing "Akron" into street when it's a city result.
if first and first.lower() != _city(addr).lower():
return first
return ""
def normalize_hit(raw: dict[str, Any], *, query: str = "") -> dict[str, str]:
addr = raw.get("address") or {}
if not isinstance(addr, dict):
addr = {}
country_code = (addr.get("country_code") or "us").upper()
if country_code == "US":
country = "US"
else:
country = country_code[:2] or "US"
display = (raw.get("display_name") or "").strip()
line1 = _line1(addr, display, query=query)
label = display
# Surface recovered house number in the dropdown when OSM omitted it.
house = (addr.get("house_number") or "").strip() or _house_from_query(query)
if house and label and not re.match(rf"^{re.escape(house)}\b", label, re.I):
label = f"{house} {label}"
return {
"label": label,
"line1": line1,
"line2": "",
"city": _city(addr),
"state": _state_code(addr),
"zip": (addr.get("postcode") or "").strip().split(";")[0].strip(),
"country": country,
}
def suggest_addresses(query: str, *, limit: int = 5) -> list[dict[str, str]]:
"""
Proxy Nominatim /search. Returns normalized address dicts for the UI.
Nominatim itself has no API-key auth — LAN firewall + this Django proxy
gate access. Optional NOMINATIM_API_KEY is sent as X-API-Key if you put
a gateway in front of Nominatim later.
"""
base = (settings.NOMINATIM_BASE_URL or "").rstrip("/")
if not base:
raise NominatimError("NOMINATIM_BASE_URL is not configured")
q = (query or "").strip()
if len(q) < 3:
return []
limit = max(1, min(int(limit or 5), 8))
params: dict[str, str | int] = {
"q": q,
"format": "json",
"addressdetails": 1,
"limit": limit,
}
countrycodes = (settings.NOMINATIM_COUNTRY_CODES or "").strip()
if countrycodes:
params["countrycodes"] = countrycodes
headers = {
"User-Agent": settings.NOMINATIM_USER_AGENT,
"Accept": "application/json",
}
api_key = (settings.NOMINATIM_API_KEY or "").strip()
if api_key:
headers["X-API-Key"] = api_key
url = f"{base}/search"
try:
response = requests.get(
url,
params=params,
headers=headers,
timeout=settings.NOMINATIM_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
except requests.RequestException as exc:
logger.exception("Nominatim request failed")
raise NominatimError(f"Nominatim unreachable at {url}: {exc}") from exc
except ValueError as exc:
raise NominatimError("Nominatim returned invalid JSON") from exc
if not isinstance(payload, list):
return []
results: list[dict[str, str]] = []
seen: set[str] = set()
for item in payload:
if not isinstance(item, dict):
continue
normalized = normalize_hit(item, query=q)
key = re.sub(r"\s+", " ", normalized["label"].lower())
if not key or key in seen:
continue
seen.add(key)
results.append(normalized)
return results
@@ -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 %}
+42
View File
@@ -0,0 +1,42 @@
from contacts.nominatim import normalize_hit
def test_line1_keeps_house_number_from_query_when_nominatim_omits_it():
raw = {
"display_name": (
"Greensboro Drive, Wheaton, DuPage County, Illinois, 60189, United States"
),
"address": {
"road": "Greensboro Drive",
"town": "Wheaton",
"county": "DuPage County",
"state": "Illinois",
"postcode": "60189",
"country_code": "us",
"ISO3166-2-lvl4": "US-IL",
},
}
hit = normalize_hit(raw, query="1968 Greensboro Drive, Wheaton")
assert hit["line1"] == "1968 Greensboro Drive"
assert hit["label"].startswith("1968 Greensboro Drive")
assert hit["city"] == "Wheaton"
assert hit["state"] == "IL"
assert hit["zip"] == "60189"
def test_line1_prefers_nominatim_house_number():
raw = {
"display_name": "1968 Greensboro Drive, Wheaton, Illinois, 60189, United States",
"address": {
"house_number": "1968",
"road": "Greensboro Drive",
"town": "Wheaton",
"state": "Illinois",
"postcode": "60189",
"country_code": "us",
"ISO3166-2-lvl4": "US-IL",
},
}
hit = normalize_hit(raw, query="1968 Greensboro Drive")
assert hit["line1"] == "1968 Greensboro Drive"
assert hit["label"] == raw["display_name"]
+11
View File
@@ -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"),
]
+101
View File
@@ -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})