Instrument webhooks, fix compose layout, and ship social connect.
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / docker (push) Successful in 15s
Deploy Beta / deploy-beta (push) Successful in 1m40s

Add Grafana-friendly webhook request logging, Quill overflow fix, New Contact flow, in-place postcard campaign compose, and functional social account connect + composer.
This commit is contained in:
2026-08-10 05:15:49 -05:00
parent b3a6ee0cd0
commit 58258f2875
10 changed files with 778 additions and 311 deletions
@@ -0,0 +1,100 @@
{% 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' %}">
{% 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" 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">
Matches existing contacts by email, phone, or address when possible.
Postcard defaults on when an address is saved.
</p>
</div>
</div>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
@@ -9,6 +9,7 @@
</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>
+1
View File
@@ -6,6 +6,7 @@ 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"),
]
+74
View File
@@ -1,5 +1,7 @@
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
@@ -7,6 +9,7 @@ from django.views.decorators.http import require_GET, require_http_methods
from contacts.models import Channel, ConsentRecord, Contact
from contacts.nominatim import NominatimError, suggest_addresses
from contacts.services import upsert_contact
from messaging.services import channel_preferences, set_channel_preferences
@@ -48,6 +51,76 @@ def contact_list(request):
)
@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,
}
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()
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:
# Soft: allow save but clear postcard consent if no address
form["consent_postcard"] = False
if not errors:
contact, created, 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"],
)
set_channel_preferences(
contact,
{
Channel.EMAIL: form["consent_email"],
Channel.SMS: form["consent_sms"],
Channel.POSTCARD: form["consent_postcard"],
},
reason="portal_manual",
)
verb = "Added" if created else f"Updated (matched by {reason or 'email'})"
messages.success(request, f"{verb} {contact}.")
return redirect("contacts:detail", pk=contact.pk)
for err in errors:
messages.error(request, err)
return render(request, "contacts/create.html", {"form": form})
@login_required
@require_http_methods(["GET", "POST"])
def contact_detail(request, pk):
@@ -76,6 +149,7 @@ def contact_detail(request, pk):
{"contact": contact, "prefs": prefs},
)
@login_required
def contact_import(request):
return render(request, "contacts/import.html")