Instrument webhooks, fix compose layout, and ship social connect.
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:
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user