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,9 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from accounts.models import RealtorProfile
|
||||
|
||||
|
||||
@admin.register(RealtorProfile)
|
||||
class RealtorProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "display_name", "phone")
|
||||
search_fields = ("user__username", "display_name")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RealtorProfile',
|
||||
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)),
|
||||
('display_name', models.CharField(blank=True, max_length=120)),
|
||||
('phone', models.CharField(blank=True, max_length=32)),
|
||||
('title', models.CharField(blank=True, max_length=120)),
|
||||
('bio', models.TextField(blank=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='realtor_profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel
|
||||
|
||||
|
||||
class RealtorProfile(TimeStampedModel):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="realtor_profile",
|
||||
)
|
||||
display_name = models.CharField(max_length=120, blank=True)
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
title = models.CharField(max_length=120, blank=True)
|
||||
bio = models.TextField(blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.display_name or self.user.get_username()
|
||||
@@ -0,0 +1,42 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · Portal · MKDRealtor.com</title>
|
||||
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
|
||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Work+Sans:300,400,500,700%7CPoppins:400,600,700">
|
||||
<link rel="stylesheet" href="{% static 'css/portal.css' %}">
|
||||
</head>
|
||||
<body class="portal">
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<h1>MKD Portal</h1>
|
||||
<p class="sub">{{ SITE_TAGLINE }}</p>
|
||||
<p class="sub" style="margin-top:0;margin-bottom:16px;font-size:13px">Leads, campaigns, and social — one place.</p>
|
||||
{% if form.errors %}
|
||||
<ul class="portal-flash">
|
||||
<li class="error">Invalid email or password.</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form class="form-grid" method="post" action="{% url 'accounts:login' %}">
|
||||
{% csrf_token %}
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
<div class="field">
|
||||
<label for="id_username">Email</label>
|
||||
<input id="id_username" type="text" name="username" autocomplete="username" required value="{{ form.username.value|default:'' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_password">Password</label>
|
||||
<input id="id_password" type="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" data-tianji-event="login_submit">Sign in</button>
|
||||
</form>
|
||||
<p style="margin-top:16px;font-size:13px;color:#6b7280">
|
||||
<a href="{% url 'public:home' %}">← Back to site</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import path
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"login/",
|
||||
auth_views.LoginView.as_view(template_name="accounts/login.html"),
|
||||
name="login",
|
||||
),
|
||||
path(
|
||||
"logout/",
|
||||
auth_views.LogoutView.as_view(),
|
||||
name="logout",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from analytics.models import Attribution, UTMVisit
|
||||
|
||||
|
||||
@admin.register(UTMVisit)
|
||||
class UTMVisitAdmin(admin.ModelAdmin):
|
||||
list_display = ("utm_source", "utm_campaign", "path", "created_at")
|
||||
search_fields = ("utm_source", "utm_campaign", "correlation_id")
|
||||
|
||||
|
||||
@admin.register(Attribution)
|
||||
class AttributionAdmin(admin.ModelAdmin):
|
||||
list_display = ("lead", "utm_source", "utm_campaign", "created_at")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "analytics"
|
||||
@@ -0,0 +1,62 @@
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
from analytics.models import Attribution, UTMVisit
|
||||
|
||||
|
||||
CORRELATION_COOKIE = "ms_cid"
|
||||
|
||||
|
||||
class UTMTrackingMiddleware:
|
||||
"""Capture UTM params into UTMVisit and stash a correlation id cookie."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
cid = request.COOKIES.get(CORRELATION_COOKIE) or get_random_string(32)
|
||||
request.utm_correlation_id = cid
|
||||
|
||||
params = request.GET
|
||||
has_utm = any(params.get(k) for k in (
|
||||
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"
|
||||
))
|
||||
if has_utm or params.get("utm_source"):
|
||||
UTMVisit.objects.create(
|
||||
correlation_id=cid,
|
||||
path=request.path[:512],
|
||||
referrer=(request.META.get("HTTP_REFERER") or "")[:1024],
|
||||
utm_source=params.get("utm_source", "")[:128],
|
||||
utm_medium=params.get("utm_medium", "")[:128],
|
||||
utm_campaign=params.get("utm_campaign", "")[:128],
|
||||
utm_term=params.get("utm_term", "")[:128],
|
||||
utm_content=params.get("utm_content", "")[:128],
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:512],
|
||||
)
|
||||
|
||||
response = self.get_response(request)
|
||||
if CORRELATION_COOKIE not in request.COOKIES:
|
||||
response.set_cookie(
|
||||
CORRELATION_COOKIE,
|
||||
cid,
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
samesite="Lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def attribute_lead_from_request(request, lead) -> Attribution | None:
|
||||
cid = getattr(request, "utm_correlation_id", None) or request.COOKIES.get(
|
||||
CORRELATION_COOKIE
|
||||
)
|
||||
visit = None
|
||||
if cid:
|
||||
visit = (
|
||||
UTMVisit.objects.filter(correlation_id=cid).order_by("-created_at").first()
|
||||
)
|
||||
return Attribution.objects.create(
|
||||
lead=lead,
|
||||
visit=visit,
|
||||
utm_source=visit.utm_source if visit else "",
|
||||
utm_medium=visit.utm_medium if visit else "",
|
||||
utm_campaign=visit.utm_campaign if visit else "",
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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 = [
|
||||
('leads', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UTMVisit',
|
||||
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)),
|
||||
('correlation_id', models.CharField(db_index=True, max_length=64)),
|
||||
('path', models.CharField(blank=True, max_length=512)),
|
||||
('referrer', models.URLField(blank=True, max_length=1024)),
|
||||
('utm_source', models.CharField(blank=True, max_length=128)),
|
||||
('utm_medium', models.CharField(blank=True, max_length=128)),
|
||||
('utm_campaign', models.CharField(blank=True, max_length=128)),
|
||||
('utm_term', models.CharField(blank=True, max_length=128)),
|
||||
('utm_content', models.CharField(blank=True, max_length=128)),
|
||||
('user_agent', models.CharField(blank=True, max_length=512)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Attribution',
|
||||
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)),
|
||||
('utm_source', models.CharField(blank=True, max_length=128)),
|
||||
('utm_medium', models.CharField(blank=True, max_length=128)),
|
||||
('utm_campaign', models.CharField(blank=True, max_length=128)),
|
||||
('lead', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='attribution', to='leads.lead')),
|
||||
('visit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='analytics.utmvisit')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
from leads.models import Lead
|
||||
|
||||
|
||||
class UTMVisit(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
correlation_id = models.CharField(max_length=64, db_index=True)
|
||||
path = models.CharField(max_length=512, blank=True)
|
||||
referrer = models.URLField(blank=True, max_length=1024)
|
||||
utm_source = models.CharField(max_length=128, blank=True)
|
||||
utm_medium = models.CharField(max_length=128, blank=True)
|
||||
utm_campaign = models.CharField(max_length=128, blank=True)
|
||||
utm_term = models.CharField(max_length=128, blank=True)
|
||||
utm_content = models.CharField(max_length=128, blank=True)
|
||||
user_agent = models.CharField(max_length=512, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.utm_source or 'direct'} / {self.path}"
|
||||
|
||||
|
||||
class Attribution(TimeStampedModel):
|
||||
lead = models.OneToOneField(
|
||||
Lead, on_delete=models.CASCADE, related_name="attribution"
|
||||
)
|
||||
visit = models.ForeignKey(
|
||||
UTMVisit, null=True, blank=True, on_delete=models.SET_NULL
|
||||
)
|
||||
utm_source = models.CharField(max_length=128, blank=True)
|
||||
utm_medium = models.CharField(max_length=128, blank=True)
|
||||
utm_campaign = models.CharField(max_length=128, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"attr {self.lead_id} ← {self.utm_source or 'direct'}"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Analytics services."""
|
||||
|
||||
from analytics.middleware import attribute_lead_from_request # noqa: F401
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Analytics · Portal{% endblock %}
|
||||
{% block topbar_title %}Analytics{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">Views (last 30 days)</div>
|
||||
<div class="value">{{ visits_last_30_days }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">UTM landings</div>
|
||||
<div class="value">{{ total_visits }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Attributed leads</div>
|
||||
<div class="value">{{ total_attributed }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Top source</div>
|
||||
<div class="value" style="font-size:18px">{{ top_source|default:"—" }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Top campaign</div>
|
||||
<div class="value" style="font-size:18px">{{ top_campaign|default:"—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Landing volume by source</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="chart-placeholder">
|
||||
{% for row in visits_by_source|slice:":8" %}
|
||||
<div class="bar" style="height:{{ row.bar_pct }}%" title="{{ row.utm_source|default:'(direct)' }}: {{ row.count }}"></div>
|
||||
{% empty %}
|
||||
<div class="bar" style="height:12%"></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>UTM landings</h2><span class="muted" style="font-size:12px">From ?utm_* hits</span></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Medium</th>
|
||||
<th>Campaign</th>
|
||||
<th>Landings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in visits_by_combo %}
|
||||
<tr>
|
||||
<td>{{ row.utm_source|default:"(direct)" }}</td>
|
||||
<td>{{ row.utm_medium|default:"—" }}</td>
|
||||
<td>{{ row.utm_campaign|default:"—" }}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No UTM landings yet. Open a public URL with ?utm_source=…</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Recent landings</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Path</th>
|
||||
<th>Source</th>
|
||||
<th>Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for visit in recent_visits %}
|
||||
<tr>
|
||||
<td>{{ visit.created_at|date:"M j, g:i A" }}</td>
|
||||
<td>{{ visit.path }}</td>
|
||||
<td>{{ visit.utm_source|default:"—" }}</td>
|
||||
<td>{{ visit.utm_campaign|default:"—" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No visits recorded.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Lead attribution</h2><span class="muted" style="font-size:12px">After contact-form submit</span></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Medium</th>
|
||||
<th>Campaign</th>
|
||||
<th>Leads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in leads_by_combo %}
|
||||
<tr>
|
||||
<td>{{ row.utm_source|default:"(direct)" }}</td>
|
||||
<td>{{ row.utm_medium|default:"—" }}</td>
|
||||
<td>{{ row.utm_campaign|default:"—" }}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No attributed leads yet — submit the contact form after a UTM landing.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from analytics import views
|
||||
|
||||
app_name = "analytics"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.report, name="report"),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
|
||||
from analytics.models import Attribution, UTMVisit
|
||||
|
||||
|
||||
def _bar_pct(rows, key="count"):
|
||||
max_count = max((row[key] for row in rows), default=1) or 1
|
||||
for row in rows:
|
||||
row["bar_pct"] = max(12, int(100 * row[key] / max_count))
|
||||
return rows
|
||||
|
||||
|
||||
@login_required
|
||||
def report(request):
|
||||
since_30d = timezone.now() - timedelta(days=30)
|
||||
visits_last_30_days = UTMVisit.objects.filter(created_at__gte=since_30d).count()
|
||||
|
||||
visits_by_source = _bar_pct(
|
||||
list(
|
||||
UTMVisit.objects.values("utm_source")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:20]
|
||||
)
|
||||
)
|
||||
visits_by_combo = list(
|
||||
UTMVisit.objects.values("utm_source", "utm_medium", "utm_campaign")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:50]
|
||||
)
|
||||
top_visit = visits_by_source[0] if visits_by_source else None
|
||||
top_visit_campaign = (
|
||||
UTMVisit.objects.exclude(utm_campaign="")
|
||||
.values("utm_campaign")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
.first()
|
||||
)
|
||||
|
||||
leads_by_source = _bar_pct(
|
||||
list(
|
||||
Attribution.objects.values("utm_source")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:20]
|
||||
)
|
||||
)
|
||||
leads_by_combo = list(
|
||||
Attribution.objects.values("utm_source", "utm_medium", "utm_campaign")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:50]
|
||||
)
|
||||
|
||||
recent_visits = UTMVisit.objects.all()[:25]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"analytics/report.html",
|
||||
{
|
||||
"visits_last_30_days": visits_last_30_days,
|
||||
"total_visits": UTMVisit.objects.count(),
|
||||
"total_attributed": Attribution.objects.count(),
|
||||
"top_source": (top_visit or {}).get("utm_source") or "(direct)",
|
||||
"top_campaign": (top_visit_campaign or {}).get("utm_campaign") or "—",
|
||||
"visits_by_source": visits_by_source,
|
||||
"visits_by_combo": visits_by_combo,
|
||||
"leads_by_source": leads_by_source,
|
||||
"leads_by_combo": leads_by_combo,
|
||||
"recent_visits": recent_visits,
|
||||
},
|
||||
)
|
||||
@@ -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})
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
@@ -0,0 +1,38 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from messaging.models import Message
|
||||
from messaging.tasks import send_campaign_message
|
||||
from social.models import SocialPost
|
||||
from social.tasks import publish_social_post
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Enqueue due scheduled campaign messages and social posts. "
|
||||
"Optional when the task backend supports run_after defer; useful as a safety net."
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
now = timezone.now()
|
||||
enqueued = 0
|
||||
|
||||
for message in Message.objects.filter(
|
||||
status=Message.Status.SCHEDULED,
|
||||
scheduled_for__lte=now,
|
||||
).iterator():
|
||||
message.status = Message.Status.QUEUED
|
||||
message.save(update_fields=["status", "updated_at"])
|
||||
send_campaign_message.enqueue(message_id=str(message.pk))
|
||||
enqueued += 1
|
||||
|
||||
for post in SocialPost.objects.filter(
|
||||
status=SocialPost.Status.SCHEDULED,
|
||||
scheduled_for__lte=now,
|
||||
).iterator():
|
||||
post.status = SocialPost.Status.QUEUED
|
||||
post.save(update_fields=["status", "updated_at"])
|
||||
publish_social_post.enqueue(post_id=str(post.pk))
|
||||
enqueued += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s)."))
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
"""Abstract base with created/updated timestamps (mirrors company_site TimeInfoBase)."""
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class UUIDPrimaryKeyModel(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class HealthzTests(TestCase):
|
||||
def test_healthz_ok(self):
|
||||
response = Client().get("/healthz/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["status"], "ok")
|
||||
|
||||
|
||||
class UnderConstructionTests(TestCase):
|
||||
@override_settings(SITE_UNDER_CONSTRUCTION=True)
|
||||
def test_home_redirects_when_gated(self):
|
||||
response = Client().get("/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/under-construction", response["Location"])
|
||||
|
||||
@override_settings(SITE_UNDER_CONSTRUCTION=False)
|
||||
def test_home_ok_when_open(self):
|
||||
response = Client().get("/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class PublicSmokeTests(TestCase):
|
||||
def test_about_and_contact_get(self):
|
||||
client = Client()
|
||||
self.assertEqual(client.get(reverse("public:about")).status_code, 200)
|
||||
self.assertEqual(client.get(reverse("public:contact")).status_code, 200)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
def healthz(_request):
|
||||
"""Liveness probe for deploy / NPM health checks."""
|
||||
return JsonResponse({"status": "ok"})
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DashboardConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "dashboard"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Dashboard · Portal{% endblock %}
|
||||
{% block topbar_title %}Dashboard{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">New leads</div>
|
||||
<div class="value">{{ lead_count }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Open pipeline</div>
|
||||
<div class="value">{{ open_pipeline }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Mailing list</div>
|
||||
<div class="value">{{ contact_count }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Scheduled posts</div>
|
||||
<div class="value">{{ scheduled_posts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Recent leads</h2>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'leads:list' %}">View all</a>
|
||||
</div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Source</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lead in recent_leads %}
|
||||
<tr>
|
||||
<td><a href="{% url 'leads:detail' lead.pk %}">{{ lead.contact }}</a></td>
|
||||
<td>{% if lead.attribution %}{{ lead.attribution.utm_source|default:"direct" }}{% if lead.attribution.utm_campaign %} / {{ lead.attribution.utm_campaign }}{% endif %}{% else %}—{% endif %}</td>
|
||||
<td><span class="badge badge-{{ lead.status }}">{{ lead.get_status_display }}</span></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No leads yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Upcoming outreach</h2>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'messaging:campaign_list' %}">Compose</a>
|
||||
</div>
|
||||
<div class="panel-b">
|
||||
{% for campaign in upcoming_campaigns %}
|
||||
<p style="margin:0 0 12px;font-size:14px">
|
||||
<strong><a href="{% url 'messaging:campaign_detail' campaign.pk %}">{{ campaign.name }}</a></strong>
|
||||
— {{ campaign.get_channel_display }} · {{ campaign.get_status_display }}
|
||||
{% if campaign.scheduled_for %} · {{ campaign.scheduled_for }}{% endif %}
|
||||
</p>
|
||||
{% empty %}
|
||||
<p class="empty-state" style="margin:0">No campaigns yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from dashboard import views
|
||||
|
||||
app_name = "dashboard"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
|
||||
from contacts.models import Contact
|
||||
from leads.models import Lead
|
||||
from messaging.models import Campaign
|
||||
from social.models import SocialPost
|
||||
|
||||
|
||||
@login_required
|
||||
def home(request):
|
||||
upcoming = Campaign.objects.exclude(
|
||||
status__in=[Campaign.Status.COMPLETED, Campaign.Status.CANCELLED]
|
||||
).order_by("scheduled_for", "-created_at")[:5]
|
||||
context = {
|
||||
"lead_count": Lead.objects.filter(status=Lead.Status.NEW).count(),
|
||||
"open_pipeline": Lead.objects.filter(
|
||||
status__in=[Lead.Status.NEW, Lead.Status.CONTACTED]
|
||||
).count(),
|
||||
"contact_count": Contact.objects.count(),
|
||||
"scheduled_posts": SocialPost.objects.filter(
|
||||
status__in=[SocialPost.Status.SCHEDULED, SocialPost.Status.QUEUED]
|
||||
).count(),
|
||||
"recent_leads": Lead.objects.select_related("contact", "attribution").all()[:8],
|
||||
"upcoming_campaigns": upcoming,
|
||||
}
|
||||
return render(request, "dashboard/home.html", context)
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from leads.models import Lead, LeadNote
|
||||
|
||||
|
||||
class LeadNoteInline(admin.TabularInline):
|
||||
model = LeadNote
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Lead)
|
||||
class LeadAdmin(admin.ModelAdmin):
|
||||
list_display = ("contact", "status", "created_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("contact__email", "contact__first_name", "contact__last_name")
|
||||
inlines = [LeadNoteInline]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LeadsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "leads"
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Lead',
|
||||
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)),
|
||||
('message', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('won', 'Won'), ('lost', 'Lost')], default='new', max_length=16)),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leads', to='contacts.contact')),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeadNote',
|
||||
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)),
|
||||
('body', models.TextField()),
|
||||
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('lead', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='leads.lead')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from contacts.models import Contact
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Lead(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
NEW = "new", "New"
|
||||
CONTACTED = "contacted", "Contacted"
|
||||
WON = "won", "Won"
|
||||
LOST = "lost", "Lost"
|
||||
|
||||
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="leads")
|
||||
message = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.NEW
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="leads",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Lead {self.contact} ({self.status})"
|
||||
|
||||
|
||||
class LeadNote(TimeStampedModel):
|
||||
lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name="notes")
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
body = models.TextField()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ lead.contact }} · Lead{% endblock %}
|
||||
{% block topbar_title %}Lead · {{ lead.contact }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Contact</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input type="email" value="{{ lead.contact.email }}" readonly>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Phone</label>
|
||||
<input type="text" value="{{ lead.contact.phone }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Message</label>
|
||||
<textarea readonly style="min-height:120px">{{ lead.message }}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<input type="text" value="{{ lead.get_status_display }}" readonly>
|
||||
</div>
|
||||
<p class="hint-block">Status edits and note posting come next in functionality work.</p>
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'leads:list' %}">← Back to inbox</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Notes</h2></div>
|
||||
<div class="panel-b">
|
||||
{% for note in lead.notes.all %}
|
||||
<div style="font-size:13px;color:#6b7280;margin-bottom:12px">
|
||||
<strong style="color:#1a1f2c">{% if note.author %}{{ note.author }}{% else %}System{% endif %}</strong>
|
||||
· {{ note.created_at|date:"M j, g:i A" }} — {{ note.body }}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="empty-state" style="padding:0;margin:0 0 12px">No notes yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Attribution</h2></div>
|
||||
<div class="panel-b" style="font-size:14px">
|
||||
{% if lead.attribution %}
|
||||
<p>
|
||||
<strong>utm_source</strong> {{ lead.attribution.utm_source|default:"—" }}<br>
|
||||
<strong>utm_medium</strong> {{ lead.attribution.utm_medium|default:"—" }}<br>
|
||||
<strong>utm_campaign</strong> {{ lead.attribution.utm_campaign|default:"—" }}<br>
|
||||
{% if lead.attribution.visit %}
|
||||
<strong>Landing</strong> {{ lead.attribution.visit.path|default:"—" }}<br>
|
||||
<strong>First touch</strong> {{ lead.attribution.visit.created_at|date:"M j, g:i A" }}
|
||||
{% endif %}
|
||||
<br><strong>Converted</strong> {{ lead.created_at|date:"M j, g:i A" }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="muted">No UTM attribution on this lead.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Linked contact · consent</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="consent-pills">
|
||||
{% for channel, opted in consent_map.items %}
|
||||
<span class="badge {% if opted %}badge-optin{% else %}badge-optout{% endif %}">
|
||||
{{ channel|title }} {% if opted %}on{% else %}off{% endif %}
|
||||
</span>
|
||||
{% empty %}
|
||||
<span class="muted">No consent records.</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p style="font-size:13px;color:#6b7280;margin:12px 0 0">
|
||||
Form submit defaulted email opt-in with notice. Postcard requires address + separate consent.
|
||||
</p>
|
||||
<p style="margin-top:12px">
|
||||
<a href="{% url 'contacts:detail' lead.contact.pk %}">Open contact record →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Leads · Portal{% endblock %}
|
||||
{% block topbar_title %}Lead inbox{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<form class="toolbar-filters" method="get">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Search name or email">
|
||||
<select name="status">
|
||||
<option value="">All statuses</option>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if status_filter == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lead</th>
|
||||
<th>Interest</th>
|
||||
<th>UTM / source</th>
|
||||
<th>Status</th>
|
||||
<th>Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lead in leads %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'leads:detail' lead.pk %}">{{ lead.contact }}</a><br>
|
||||
<span class="muted">{{ lead.contact.email }}{% if lead.contact.phone %} · {{ lead.contact.phone }}{% endif %}</span>
|
||||
</td>
|
||||
<td class="muted">{{ lead.message|truncatechars:40|default:"—" }}</td>
|
||||
<td>
|
||||
{% if lead.attribution %}
|
||||
{{ lead.attribution.utm_source|default:"direct" }}
|
||||
{% if lead.attribution.utm_medium %}/ {{ lead.attribution.utm_medium }}{% endif %}
|
||||
{% if lead.attribution.utm_campaign %}/ {{ lead.attribution.utm_campaign }}{% endif %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-{{ lead.status }}">{{ lead.get_status_display }}</span></td>
|
||||
<td>{{ lead.created_at|date:"M j, g:i A" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No leads match.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from leads import views
|
||||
|
||||
app_name = "leads"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.lead_list, name="list"),
|
||||
path("<uuid:pk>/", views.lead_detail, name="detail"),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from contacts.models import Channel
|
||||
from leads.models import Lead
|
||||
|
||||
|
||||
@login_required
|
||||
def lead_list(request):
|
||||
leads = Lead.objects.select_related("contact", "attribution").all()
|
||||
q = (request.GET.get("q") or "").strip()
|
||||
status = (request.GET.get("status") or "").strip()
|
||||
if q:
|
||||
leads = leads.filter(
|
||||
Q(contact__first_name__icontains=q)
|
||||
| Q(contact__last_name__icontains=q)
|
||||
| Q(contact__email__icontains=q)
|
||||
| Q(contact__phone__icontains=q)
|
||||
| Q(message__icontains=q)
|
||||
)
|
||||
if status:
|
||||
leads = leads.filter(status=status)
|
||||
return render(
|
||||
request,
|
||||
"leads/list.html",
|
||||
{
|
||||
"leads": leads[:200],
|
||||
"status_choices": Lead.Status.choices,
|
||||
"q": q,
|
||||
"status_filter": status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def lead_detail(request, pk):
|
||||
lead = get_object_or_404(
|
||||
Lead.objects.select_related(
|
||||
"contact", "attribution", "attribution__visit"
|
||||
).prefetch_related("notes", "contact__consents"),
|
||||
pk=pk,
|
||||
)
|
||||
consent_map = {c.value: False for c in Channel}
|
||||
for record in lead.contact.consents.all():
|
||||
consent_map[record.channel] = record.opted_in
|
||||
return render(
|
||||
request,
|
||||
"leads/detail.html",
|
||||
{"lead": lead, "consent_map": consent_map},
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "monica_site.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
# Messaging
|
||||
|
||||
Campaign compose/send, SMTP2GO email + SMS, PCM Integrations postcards, and delivery webhooks.
|
||||
|
||||
## SMTP2GO webhook setup
|
||||
|
||||
Campaign report page polls provider events every 10s. Create **two** webhooks in
|
||||
SMTP2GO → **Settings → Webhooks** (email and SMS stay separate).
|
||||
|
||||
### Auth (`SMTP2GO_WEBHOOK_SECRET`)
|
||||
|
||||
1. Set `SMTP2GO_WEBHOOK_SECRET` in `.env` / prod env (long random string).
|
||||
2. In SMTP2GO, set **Authorization header** to **Bearer** and paste that same secret
|
||||
(do not leave it as “None”).
|
||||
3. Fallback: `?token=<SMTP2GO_WEBHOOK_SECRET>` on the webhook URL also works.
|
||||
|
||||
### Email webhook
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/email/` |
|
||||
| Authorization header | **Bearer** + `SMTP2GO_WEBHOOK_SECRET` |
|
||||
| Output type | JSON |
|
||||
| Email events | processed, bounced, rejected, spam, delivered, unsub/resub, opened, clicked |
|
||||
| Email headers | `X-Monica-Message-Id` |
|
||||
| SMS events | leave unchecked |
|
||||
|
||||
`X-Monica-Message-Id` is set on every campaign email send and is required so webhook
|
||||
events match the correct recipient row.
|
||||
|
||||
Beta / other hosts: swap the hostname, keep the path.
|
||||
|
||||
### SMS webhook (separate)
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/sms/` |
|
||||
| Authorization header | **Bearer** + same `SMTP2GO_WEBHOOK_SECRET` |
|
||||
| Output type | JSON |
|
||||
| Email events | leave unchecked |
|
||||
| SMS events | Submitted, Sending, Delivered, Failed, Rejected, Opt-out |
|
||||
|
||||
This endpoint also accepts inbound reply POSTs (`text=STOP`, `from=…`) and opts the
|
||||
contact out of SMS.
|
||||
|
||||
## PCM Integrations (postcards)
|
||||
|
||||
Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail API v3.
|
||||
|
||||
### Env
|
||||
|
||||
| Var | Purpose |
|
||||
|-----|---------|
|
||||
| `PCM_API_KEY` | Bearer token for `https://v3.pcmintegrations.com` |
|
||||
| `PCM_WEBHOOK_SECRET` | Auth for inbound status webhooks |
|
||||
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
|
||||
| `POSTCARD_PROVIDER` | `pcm` (default) |
|
||||
|
||||
### Designer
|
||||
|
||||
Portal → **Postcard design**: create/list designs via API, edit in iframe
|
||||
(`POST /design/custom`, `GET /design/{id}/edit?mode=embed`). Save as a
|
||||
`MessageTemplate` (stores `design_id`) then pick it when composing a postcard campaign.
|
||||
|
||||
### Postcard webhook
|
||||
|
||||
Create a webhook subscription in the PCM dashboard (Working with Webhooks):
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` |
|
||||
| Authorization | **Bearer** + `PCM_WEBHOOK_SECRET` |
|
||||
| Events | Order / recipient status (Pending, Processing, Processed, Delivered, Undeliverable, Canceled) |
|
||||
| Environments | Sandbox and/or Production as needed |
|
||||
|
||||
Fallback: `?token=<PCM_WEBHOOK_SECRET>` on the URL.
|
||||
|
||||
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
|
||||
echo that (or `orderID`, matched to `Message.provider_message_id`).
|
||||
|
||||
### Campaign completion email
|
||||
|
||||
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
|
||||
goes to `campaign.created_by.email`, else `CONTACT_EMAIL`. Guarded by
|
||||
`Campaign.notify_sent_at` so it only sends once.
|
||||
|
||||
### Local development
|
||||
|
||||
SMTP2GO / PCM cannot reach `localhost`. Use a tunnel (Cloudflare Tunnel / ngrok) to `:8000`,
|
||||
or test webhooks against beta/prod.
|
||||
|
||||
For real SMTP delivery locally (not console logs):
|
||||
|
||||
```bash
|
||||
# in .env
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST_USER=…
|
||||
EMAIL_HOST_PASSWORD=…
|
||||
SMTP2GO_WEBHOOK_SECRET=…
|
||||
SMTP2GO_SMS_API_KEY=… # SMS sends only
|
||||
PCM_API_KEY=…
|
||||
PCM_WEBHOOK_SECRET=…
|
||||
PCM_RETURN_ADDRESS={…}
|
||||
```
|
||||
|
||||
### Endpoints (app)
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `POST /portal/messaging/webhooks/email/` | Email delivery / open / click / bounce / … |
|
||||
| `POST /portal/messaging/webhooks/sms/` | SMS delivery events + inbound STOP |
|
||||
| `POST /portal/messaging/webhooks/postcard/` | PCM order / mail tracking events |
|
||||
| `GET /portal/messaging/campaigns/<id>/status.json` | Live stats for the campaign report UI |
|
||||
| `GET /portal/messaging/postcard/` | PCM designer iframe |
|
||||
|
||||
Code: `webhooks.py`, `providers/postcard/pcm.py`, `views.py`.
|
||||
@@ -0,0 +1,40 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
|
||||
|
||||
|
||||
@admin.register(MessageTemplate)
|
||||
class MessageTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "channel", "created_at")
|
||||
list_filter = ("channel",)
|
||||
|
||||
|
||||
class MessageInline(admin.TabularInline):
|
||||
model = Message
|
||||
extra = 0
|
||||
readonly_fields = ("status", "provider", "provider_message_id", "sent_at")
|
||||
|
||||
|
||||
@admin.register(Campaign)
|
||||
class CampaignAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"channel",
|
||||
"audience",
|
||||
"status",
|
||||
"scheduled_for",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("channel", "audience", "status")
|
||||
inlines = [MessageInline]
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ("campaign", "contact", "channel", "status", "scheduled_for")
|
||||
list_filter = ("channel", "status")
|
||||
|
||||
|
||||
@admin.register(ProviderEvent)
|
||||
class ProviderEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("provider", "event_type", "created_at")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MessagingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "messaging"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Channel dispatch — email / SMS / postcard."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from contacts.models import Channel
|
||||
from messaging.models import Message
|
||||
from messaging.providers.email.smtp2go import send_email
|
||||
from messaging.providers.postcard import get_postcard_provider
|
||||
from messaging.providers.sms.smtp2go import send_sms
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
provider: str
|
||||
provider_id: str
|
||||
|
||||
|
||||
def dispatch_message(message: Message) -> ProviderResult:
|
||||
if message.channel == Channel.EMAIL:
|
||||
result = send_email(message)
|
||||
return ProviderResult(provider="smtp2go_email", provider_id=result)
|
||||
if message.channel == Channel.SMS:
|
||||
result = send_sms(message)
|
||||
return ProviderResult(provider="smtp2go_sms", provider_id=result)
|
||||
if message.channel == Channel.POSTCARD:
|
||||
provider = get_postcard_provider()
|
||||
result = provider.send_postcard(message)
|
||||
return ProviderResult(provider=provider.name, provider_id=result.provider_id)
|
||||
raise ValueError(f"Unsupported channel: {message.channel}")
|
||||
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MessageTemplate',
|
||||
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)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('subject', models.CharField(blank=True, max_length=255)),
|
||||
('body', models.TextField()),
|
||||
('postcard_front', models.JSONField(blank=True, default=dict)),
|
||||
('postcard_back', models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Campaign',
|
||||
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)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
|
||||
('scheduled_for', models.DateTimeField(blank=True, null=True)),
|
||||
('subject_override', models.CharField(blank=True, max_length=255)),
|
||||
('body_override', models.TextField(blank=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='campaigns', to='messaging.messagetemplate')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
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)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced'), ('suppressed', 'Suppressed')], default='draft', max_length=16)),
|
||||
('provider', models.CharField(blank=True, max_length=64)),
|
||||
('provider_message_id', models.CharField(blank=True, max_length=255)),
|
||||
('scheduled_for', models.DateTimeField(blank=True, null=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('body_snapshot', models.TextField(blank=True)),
|
||||
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='messaging.campaign')),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='contacts.contact')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProviderEvent',
|
||||
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)),
|
||||
('provider', models.CharField(max_length=64)),
|
||||
('event_type', models.CharField(max_length=64)),
|
||||
('payload', models.JSONField(blank=True, default=dict)),
|
||||
('message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='messaging.message')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.1 on 2026-08-08 10:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='campaign',
|
||||
name='audience',
|
||||
field=models.CharField(blank=True, choices=[('email_opt_in', 'Mailing list · email opt-in'), ('sms_opt_in', 'Mailing list · SMS opt-in'), ('postcard_opt_in', 'Mailing list · postcard opt-in')], default='', max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated manually for Campaign.notify_sent_at
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("messaging", "0002_campaign_audience"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="campaign",
|
||||
name="notify_sent_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from contacts.models import Channel, Contact
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class MessageTemplate(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
name = models.CharField(max_length=120)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
subject = models.CharField(max_length=255, blank=True)
|
||||
body = models.TextField()
|
||||
postcard_front = models.JSONField(default=dict, blank=True)
|
||||
postcard_back = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.channel})"
|
||||
|
||||
|
||||
class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
SCHEDULED = "scheduled", "Scheduled"
|
||||
SENDING = "sending", "Sending"
|
||||
COMPLETED = "completed", "Completed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
class Audience(models.TextChoices):
|
||||
EMAIL_OPT_IN = "email_opt_in", "Mailing list · email opt-in"
|
||||
SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in"
|
||||
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
|
||||
|
||||
name = models.CharField(max_length=120)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
audience = models.CharField(
|
||||
max_length=32,
|
||||
choices=Audience.choices,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
template = models.ForeignKey(
|
||||
MessageTemplate,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="campaigns",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
scheduled_for = models.DateTimeField(null=True, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
subject_override = models.CharField(max_length=255, blank=True)
|
||||
body_override = models.TextField(blank=True)
|
||||
# Set when realtor summary email is sent (campaign COMPLETED).
|
||||
notify_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
SCHEDULED = "scheduled", "Scheduled"
|
||||
QUEUED = "queued", "Queued"
|
||||
SENT = "sent", "Sent"
|
||||
DELIVERED = "delivered", "Delivered"
|
||||
FAILED = "failed", "Failed"
|
||||
BOUNCED = "bounced", "Bounced"
|
||||
SUPPRESSED = "suppressed", "Suppressed"
|
||||
|
||||
campaign = models.ForeignKey(
|
||||
Campaign, on_delete=models.CASCADE, related_name="messages"
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
Contact, on_delete=models.CASCADE, related_name="messages"
|
||||
)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
provider = models.CharField(max_length=64, blank=True)
|
||||
provider_message_id = models.CharField(max_length=255, blank=True)
|
||||
scheduled_for = models.DateTimeField(null=True, blank=True)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
body_snapshot = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.channel} → {self.contact} ({self.status})"
|
||||
|
||||
|
||||
class ProviderEvent(TimeStampedModel):
|
||||
message = models.ForeignKey(
|
||||
Message,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="events",
|
||||
)
|
||||
provider = models.CharField(max_length=64)
|
||||
event_type = models.CharField(max_length=64)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""SMTP2GO email via Django's SMTP backend (mail.smtp2go.com)."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
from contacts.models import Channel
|
||||
from messaging.services import one_click_unsubscribe_url, preferences_url
|
||||
|
||||
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
|
||||
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
|
||||
|
||||
|
||||
def send_email(message) -> str:
|
||||
contact = message.contact
|
||||
if not contact.email:
|
||||
raise ValueError("Contact has no email address")
|
||||
|
||||
campaign = message.campaign
|
||||
subject = campaign.subject_override or (
|
||||
campaign.template.subject if campaign.template else "Message from Monica"
|
||||
)
|
||||
body = message.body_snapshot or campaign.body_override or (
|
||||
campaign.template.body if campaign.template else ""
|
||||
)
|
||||
|
||||
site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
|
||||
one_click_path = one_click_unsubscribe_url(str(contact.pk), Channel.EMAIL)
|
||||
prefs_url = f"{site}{prefs_path}" if site else prefs_path
|
||||
one_click_url = f"{site}{one_click_path}" if site else one_click_path
|
||||
body_with_unsub = (
|
||||
f"{body}\n\n---\n"
|
||||
f"Manage preferences: {prefs_url}\n"
|
||||
f"Unsubscribe from email: {one_click_url}"
|
||||
)
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body_with_unsub,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[contact.email],
|
||||
headers={
|
||||
"List-Unsubscribe": f"<{one_click_url}>",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
MONICA_MESSAGE_HEADER: str(message.pk),
|
||||
},
|
||||
)
|
||||
email.send(fail_silently=False)
|
||||
# Placeholder until SMTP2GO webhook supplies the real email_id.
|
||||
return f"smtp-{message.pk}"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Pluggable postcard providers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostcardResult:
|
||||
provider_id: str
|
||||
|
||||
|
||||
class PostcardProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult: ...
|
||||
|
||||
def get_status(self, provider_id: str) -> str: ...
|
||||
|
||||
|
||||
def get_postcard_provider() -> PostcardProvider:
|
||||
name = (settings.POSTCARD_PROVIDER or "pcm").lower()
|
||||
if name == "pcm":
|
||||
from messaging.providers.postcard.pcm import PcmProvider
|
||||
|
||||
return PcmProvider()
|
||||
if name == "click2mail":
|
||||
from messaging.providers.postcard.click2mail import Click2MailProvider
|
||||
|
||||
return Click2MailProvider()
|
||||
if name == "postgrid":
|
||||
from messaging.providers.postcard.postgrid import PostGridProvider
|
||||
|
||||
return PostGridProvider()
|
||||
if name == "lob":
|
||||
from messaging.providers.postcard.lob import LobProvider
|
||||
|
||||
return LobProvider()
|
||||
from messaging.providers.postcard.pcm import PcmProvider
|
||||
|
||||
return PcmProvider()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Click2Mail postcard adapter (low-volume pay-per-piece option)."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class Click2MailProvider:
|
||||
name: str = "click2mail"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
if not settings.CLICK2MAIL_API_KEY:
|
||||
raise RuntimeError("CLICK2MAIL_API_KEY is not configured")
|
||||
# Placeholder: wire full Click2Mail job API when account credentials are ready.
|
||||
raise NotImplementedError(
|
||||
"Click2Mail adapter stub — configure account then implement job submit"
|
||||
)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Lob postcard adapter (default)."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobProvider:
|
||||
name: str = "lob"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
api_key = settings.LOB_API_KEY
|
||||
if not api_key:
|
||||
raise RuntimeError("LOB_API_KEY is not configured")
|
||||
|
||||
contact = message.contact
|
||||
address = contact.postal_address or {}
|
||||
if not address.get("line1"):
|
||||
raise ValueError("Contact postal_address.line1 required for postcard")
|
||||
|
||||
# Minimal Lob create-postcard payload; artwork URLs come from template JSON.
|
||||
template = message.campaign.template
|
||||
front = (template.postcard_front if template else {}) or {}
|
||||
back = (template.postcard_back if template else {}) or {}
|
||||
|
||||
payload = {
|
||||
"description": f"campaign-{message.campaign_id}",
|
||||
"to": {
|
||||
"name": contact.full_name or contact.email or "Resident",
|
||||
"address_line1": address.get("line1", ""),
|
||||
"address_line2": address.get("line2", ""),
|
||||
"address_city": address.get("city", ""),
|
||||
"address_state": address.get("state", ""),
|
||||
"address_zip": address.get("zip", ""),
|
||||
"address_country": address.get("country", "US"),
|
||||
},
|
||||
"front": front.get("html") or front.get("url") or "<html></html>",
|
||||
"back": back.get("html") or back.get("url") or "<html></html>",
|
||||
}
|
||||
response = requests.post(
|
||||
"https://api.lob.com/v1/postcards",
|
||||
json=payload,
|
||||
auth=(api_key, ""),
|
||||
timeout=60,
|
||||
headers={"Idempotency-Key": str(message.pk)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return PostcardResult(provider_id=str(data.get("id") or message.pk))
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
api_key = settings.LOB_API_KEY
|
||||
if not api_key:
|
||||
return "unknown"
|
||||
response = requests.get(
|
||||
f"https://api.lob.com/v1/postcards/{provider_id}",
|
||||
auth=(api_key, ""),
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
return "unknown"
|
||||
return str(response.json().get("status") or "unknown")
|
||||
@@ -0,0 +1,260 @@
|
||||
"""PCM Integrations (DirectMail API v3) postcard adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PCM_API_BASE = "https://v3.pcmintegrations.com"
|
||||
|
||||
# PCM size codes for custom designer designs.
|
||||
PCM_SIZE_CHOICES = (
|
||||
("46", "4.25 × 6"),
|
||||
("68", "6 × 8.5"),
|
||||
("69", "6 × 9"),
|
||||
("611", "6 × 11"),
|
||||
("811", "8.5 × 11"),
|
||||
)
|
||||
|
||||
|
||||
class PcmApiError(RuntimeError):
|
||||
"""Raised when a PCM API call fails."""
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
return (settings.PCM_API_KEY or "").strip()
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
key = _api_key()
|
||||
if not key:
|
||||
raise PcmApiError("PCM_API_KEY is not configured")
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def pcm_request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
timeout: int = 60,
|
||||
) -> Any:
|
||||
"""Call PCM v3 API. ``path`` is absolute under the API host (e.g. ``/design``)."""
|
||||
url = f"{PCM_API_BASE}{path}"
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
headers=_headers(),
|
||||
params=params,
|
||||
json=json_body,
|
||||
timeout=timeout,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = (response.text or "")[:500]
|
||||
raise PcmApiError(
|
||||
f"PCM {method} {path} → {response.status_code}: {detail}"
|
||||
)
|
||||
if not response.content:
|
||||
return {}
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return {"raw": response.text}
|
||||
|
||||
|
||||
def return_address_from_settings() -> dict[str, str]:
|
||||
"""Build PCM returnAddress from PCM_RETURN_ADDRESS JSON or CONTACT_* vars."""
|
||||
raw = (settings.PCM_RETURN_ADDRESS or "").strip()
|
||||
if raw:
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object")
|
||||
return {
|
||||
"company": str(data.get("company") or ""),
|
||||
"firstName": str(data.get("firstName") or data.get("first_name") or ""),
|
||||
"lastName": str(data.get("lastName") or data.get("last_name") or ""),
|
||||
"address": str(data.get("address") or data.get("line1") or ""),
|
||||
"address2": str(data.get("address2") or data.get("line2") or ""),
|
||||
"city": str(data.get("city") or ""),
|
||||
"state": str(data.get("state") or ""),
|
||||
"zipCode": str(data.get("zipCode") or data.get("zip") or ""),
|
||||
}
|
||||
|
||||
name = (settings.SITE_NAME or "").strip()
|
||||
parts = name.split(None, 1)
|
||||
first = parts[0] if parts else "Monica"
|
||||
last = parts[1] if len(parts) > 1 else ""
|
||||
return {
|
||||
"company": "",
|
||||
"firstName": first,
|
||||
"lastName": last,
|
||||
"address": str(getattr(settings, "PCM_RETURN_LINE1", "") or ""),
|
||||
"address2": str(getattr(settings, "PCM_RETURN_LINE2", "") or ""),
|
||||
"city": str(getattr(settings, "PCM_RETURN_CITY", "") or ""),
|
||||
"state": str(getattr(settings, "PCM_RETURN_STATE", "") or ""),
|
||||
"zipCode": str(getattr(settings, "PCM_RETURN_ZIP", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def contact_to_pcm_recipient(contact, *, ext_ref: str) -> dict[str, str]:
|
||||
address = contact.postal_address or {}
|
||||
line1 = (address.get("line1") or "").strip()
|
||||
if not line1:
|
||||
raise ValueError("Contact postal_address.line1 required for postcard")
|
||||
|
||||
first = (contact.first_name or "").strip()
|
||||
last = (contact.last_name or "").strip()
|
||||
if not first and not last:
|
||||
# PCM requires name or company.
|
||||
first = (contact.full_name or contact.email or "Resident").strip()
|
||||
|
||||
return {
|
||||
"firstName": first,
|
||||
"lastName": last,
|
||||
"address": line1,
|
||||
"address2": (address.get("line2") or "").strip() or " ",
|
||||
"city": (address.get("city") or "").strip(),
|
||||
"state": (address.get("state") or "").strip(),
|
||||
"zipCode": (address.get("zip") or "").strip(),
|
||||
"extRefNbr": ext_ref,
|
||||
}
|
||||
|
||||
|
||||
def list_designs(*, product_type: str = "postcard", page: int = 1, per_page: int = 50) -> list[dict]:
|
||||
data = pcm_request(
|
||||
"GET",
|
||||
"/design",
|
||||
params={
|
||||
"productType": product_type,
|
||||
"page": page,
|
||||
"perPage": per_page,
|
||||
},
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
results = data.get("results") or data.get("designs") or []
|
||||
return results if isinstance(results, list) else []
|
||||
return []
|
||||
|
||||
|
||||
def create_custom_design(*, name: str, size: str) -> dict[str, Any]:
|
||||
"""POST /design/custom → designID + embed url."""
|
||||
return pcm_request(
|
||||
"POST",
|
||||
"/design/custom",
|
||||
json_body={"name": name, "size": size},
|
||||
)
|
||||
|
||||
|
||||
def get_design_embed_url(design_id: int | str, *, duplicate: bool = False) -> str:
|
||||
"""GET /design/{id}/edit?mode=embed → iframe URL."""
|
||||
params: dict[str, Any] = {"mode": "embed"}
|
||||
if duplicate:
|
||||
params["duplicate"] = "true"
|
||||
data = pcm_request("GET", f"/design/{design_id}/edit", params=params)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("Unexpected embed response from PCM")
|
||||
url = data.get("embed_url") or data.get("url") or ""
|
||||
if not url:
|
||||
raise PcmApiError("PCM did not return an embed URL")
|
||||
return str(url)
|
||||
|
||||
|
||||
def get_order(order_id: int | str) -> dict[str, Any]:
|
||||
data = pcm_request("GET", f"/order/{order_id}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def place_postcard_order(
|
||||
*,
|
||||
design_id: int,
|
||||
recipient: dict[str, str],
|
||||
ext_ref: str,
|
||||
mail_class: str = "FirstClass",
|
||||
) -> str:
|
||||
"""Place a one-recipient postcard order; return PCM orderID as string."""
|
||||
payload = {
|
||||
"designID": design_id,
|
||||
"mailClass": mail_class,
|
||||
"extRefNbr": ext_ref,
|
||||
"returnAddress": return_address_from_settings(),
|
||||
"recipients": [recipient],
|
||||
}
|
||||
data = pcm_request("POST", "/order", json_body=payload)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("Unexpected order response from PCM")
|
||||
|
||||
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
|
||||
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
|
||||
order_id = data["results"][0].get("orderID")
|
||||
if order_id is None:
|
||||
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
|
||||
return str(order_id)
|
||||
|
||||
|
||||
def design_id_from_template(template) -> int | None:
|
||||
"""Read design_id from MessageTemplate.postcard_front JSON."""
|
||||
if not template:
|
||||
return None
|
||||
front = template.postcard_front or {}
|
||||
if not isinstance(front, dict):
|
||||
return None
|
||||
raw = front.get("design_id") or front.get("designID")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PcmProvider:
|
||||
name: str = "pcm"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
template = message.campaign.template if message.campaign_id else None
|
||||
design_id = design_id_from_template(template)
|
||||
if not design_id:
|
||||
raise ValueError(
|
||||
"Postcard campaign template missing PCM design_id "
|
||||
"(save a design from the postcard designer first)"
|
||||
)
|
||||
|
||||
recipient = contact_to_pcm_recipient(
|
||||
message.contact, ext_ref=str(message.pk)
|
||||
)
|
||||
mail_class = "FirstClass"
|
||||
if template and isinstance(template.postcard_front, dict):
|
||||
mail_class = (
|
||||
template.postcard_front.get("mail_class") or mail_class
|
||||
)
|
||||
|
||||
order_id = place_postcard_order(
|
||||
design_id=design_id,
|
||||
recipient=recipient,
|
||||
ext_ref=str(message.pk),
|
||||
mail_class=str(mail_class),
|
||||
)
|
||||
return PostcardResult(provider_id=order_id)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
try:
|
||||
data = get_order(provider_id)
|
||||
except PcmApiError:
|
||||
logger.exception("PCM get_status failed for %s", provider_id)
|
||||
return "unknown"
|
||||
return str(data.get("status") or "unknown")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""PostGrid postcard adapter."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostGridProvider:
|
||||
name: str = "postgrid"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
if not settings.POSTGRID_API_KEY:
|
||||
raise RuntimeError("POSTGRID_API_KEY is not configured")
|
||||
raise NotImplementedError(
|
||||
"PostGrid adapter stub — configure account then implement send"
|
||||
)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""SMTP2GO SMS REST API."""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_sms(message) -> str:
|
||||
contact = message.contact
|
||||
if not contact.phone:
|
||||
raise ValueError("Contact has no phone number")
|
||||
|
||||
api_key = settings.SMTP2GO_SMS_API_KEY
|
||||
if not api_key:
|
||||
raise RuntimeError("SMTP2GO_SMS_API_KEY is not configured")
|
||||
|
||||
campaign = message.campaign
|
||||
body = message.body_snapshot or campaign.body_override or (
|
||||
campaign.template.body if campaign.template else ""
|
||||
)
|
||||
|
||||
payload = {
|
||||
"api_key": api_key,
|
||||
"to": contact.phone,
|
||||
"text": body[:1600],
|
||||
}
|
||||
response = requests.post(
|
||||
settings.SMTP2GO_SMS_API_URL,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json() if response.content else {}
|
||||
# SMTP2GO returns varying shapes; store a useful id when present.
|
||||
return str(
|
||||
data.get("data", {}).get("sms_id")
|
||||
or data.get("request_id")
|
||||
or f"sms-{message.pk}"
|
||||
)
|
||||
@@ -0,0 +1,404 @@
|
||||
"""Consent checks and unsubscribe helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.core import signing
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
||||
from messaging.models import Campaign, Message, MessageTemplate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
|
||||
AUDIENCE_CHANNEL = {
|
||||
Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL,
|
||||
Campaign.Audience.SMS_OPT_IN: Channel.SMS,
|
||||
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
||||
}
|
||||
|
||||
UNSUB_SALT = "monica-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()
|
||||
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)."""
|
||||
flags = {c.value: False for c in Channel}
|
||||
for record in contact.consents.all():
|
||||
flags[record.channel] = record.opted_in
|
||||
return flags
|
||||
|
||||
|
||||
def set_channel_consent(
|
||||
contact: Contact,
|
||||
channel: str,
|
||||
*,
|
||||
opted_in: bool,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Write ConsentRecord + Suppression for one channel."""
|
||||
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:
|
||||
"""Update consent for each provided channel key."""
|
||||
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]:
|
||||
"""Return (contact, channel) or (None, '') on bad/expired token."""
|
||||
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:
|
||||
"""One-click opt-out for the channel encoded in the token."""
|
||||
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 channel_for_audience(audience: str) -> str:
|
||||
try:
|
||||
return AUDIENCE_CHANNEL[audience]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown audience: {audience}") from exc
|
||||
|
||||
|
||||
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)
|
||||
qs = (
|
||||
Contact.objects.filter(
|
||||
consents__channel=channel,
|
||||
consents__opted_in=True,
|
||||
)
|
||||
.exclude(pk__in=suppressed)
|
||||
.distinct()
|
||||
.order_by("first_name", "last_name", "email")
|
||||
)
|
||||
if channel == Channel.POSTCARD:
|
||||
qs = qs.filter(postal_address__has_key="line1").exclude(
|
||||
postal_address__line1=""
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
def parse_scheduled_for(raw: str | None):
|
||||
"""Parse optional ``datetime-local`` value into an aware datetime."""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid schedule datetime.") from exc
|
||||
if timezone.is_naive(parsed):
|
||||
return timezone.make_aware(parsed, timezone.get_current_timezone())
|
||||
return parsed
|
||||
|
||||
|
||||
def create_campaign_draft(
|
||||
*,
|
||||
name: str,
|
||||
audience: str,
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
scheduled_for=None,
|
||||
created_by: AbstractBaseUser | None = None,
|
||||
template: MessageTemplate | None = None,
|
||||
) -> Campaign:
|
||||
"""Persist a draft campaign and per-recipient Message stubs."""
|
||||
channel = channel_for_audience(audience)
|
||||
campaign = Campaign.objects.create(
|
||||
name=name,
|
||||
channel=channel,
|
||||
audience=audience,
|
||||
status=Campaign.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
subject_override=subject,
|
||||
body_override=body,
|
||||
created_by=created_by,
|
||||
template=template,
|
||||
)
|
||||
contacts = list(opted_in_contacts(channel))
|
||||
Message.objects.bulk_create(
|
||||
[
|
||||
Message(
|
||||
campaign=campaign,
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
status=Message.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
body_snapshot=body,
|
||||
)
|
||||
for contact in contacts
|
||||
]
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
def campaign_notify_recipient(campaign: Campaign) -> str:
|
||||
"""Email address for the realtor summary (created_by, else CONTACT_EMAIL)."""
|
||||
from django.conf import settings
|
||||
|
||||
user = campaign.created_by
|
||||
if user is not None:
|
||||
email = (getattr(user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
return (settings.CONTACT_EMAIL or "").strip()
|
||||
|
||||
|
||||
def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
||||
"""
|
||||
One-shot summary email when a campaign finishes sending.
|
||||
|
||||
Returns True if mail was sent (or already sent earlier).
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.db.models import Count, Q
|
||||
from django.urls import reverse
|
||||
|
||||
if campaign.notify_sent_at:
|
||||
return True
|
||||
if campaign.status != Campaign.Status.COMPLETED:
|
||||
return False
|
||||
|
||||
to_email = campaign_notify_recipient(campaign)
|
||||
if not to_email:
|
||||
return False
|
||||
|
||||
counts = campaign.messages.aggregate(
|
||||
sent=Count("id", filter=Q(status=Message.Status.SENT)),
|
||||
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
|
||||
failed=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
status__in=[
|
||||
Message.Status.FAILED,
|
||||
Message.Status.BOUNCED,
|
||||
]
|
||||
),
|
||||
),
|
||||
suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)),
|
||||
total=Count("id"),
|
||||
)
|
||||
report_path = reverse("messaging:campaign_detail", kwargs={"pk": campaign.pk})
|
||||
public = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
report_url = f"{public}{report_path}" if public else report_path
|
||||
|
||||
subject = f"Campaign sent: {campaign.name}"
|
||||
body = (
|
||||
f"Your {campaign.get_channel_display()} campaign “{campaign.name}” "
|
||||
f"has finished sending.\n\n"
|
||||
f"Recipients: {counts['total']}\n"
|
||||
f"Sent: {counts['sent']}\n"
|
||||
f"Delivered: {counts['delivered']}\n"
|
||||
f"Failed / bounced: {counts['failed']}\n"
|
||||
f"Suppressed: {counts['suppressed']}\n\n"
|
||||
f"Report: {report_url}\n"
|
||||
)
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
try:
|
||||
email.send(fail_silently=False)
|
||||
except Exception: # noqa: BLE001 — don't block completion on mail errors
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"Campaign completion notify failed for %s", campaign.pk
|
||||
)
|
||||
return False
|
||||
|
||||
campaign.notify_sent_at = timezone.now()
|
||||
campaign.save(update_fields=["notify_sent_at", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def refresh_campaign_status(campaign: Campaign) -> Campaign:
|
||||
"""Set campaign to completed when no messages remain pending."""
|
||||
pending = campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.QUEUED,
|
||||
]
|
||||
).exists()
|
||||
if pending:
|
||||
return campaign
|
||||
if campaign.status == Campaign.Status.SENDING:
|
||||
campaign.status = Campaign.Status.COMPLETED
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
send_campaign_completion_notify(campaign)
|
||||
return campaign
|
||||
|
||||
|
||||
def enqueue_campaign_send(campaign: Campaign) -> int:
|
||||
"""
|
||||
Queue draft/scheduled/failed messages for send.
|
||||
|
||||
Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console.
|
||||
"""
|
||||
from messaging.tasks import send_campaign_message
|
||||
|
||||
sendable = list(
|
||||
campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.FAILED,
|
||||
]
|
||||
)
|
||||
)
|
||||
if not sendable:
|
||||
return 0
|
||||
|
||||
campaign.status = Campaign.Status.SENDING
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
|
||||
enqueued = 0
|
||||
for message in sendable:
|
||||
message.status = Message.Status.QUEUED
|
||||
message.save(update_fields=["status", "updated_at"])
|
||||
try:
|
||||
send_campaign_message.enqueue(message_id=str(message.pk))
|
||||
except Exception: # noqa: BLE001 — task already persisted FAILED
|
||||
pass
|
||||
enqueued += 1
|
||||
|
||||
refresh_campaign_status(campaign)
|
||||
return enqueued
|
||||
|
||||
|
||||
def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
|
||||
"""Send one preview copy to ``to_email`` without touching recipient rows."""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
if campaign.channel != Channel.EMAIL:
|
||||
raise ValueError("Test send is only available for email campaigns.")
|
||||
subject = campaign.subject_override or (
|
||||
campaign.template.subject if campaign.template_id else "Message from Monica"
|
||||
)
|
||||
body = campaign.body_override or (
|
||||
campaign.template.body if campaign.template_id else ""
|
||||
)
|
||||
if not subject.strip():
|
||||
raise ValueError("Campaign has no subject.")
|
||||
if not body.strip():
|
||||
raise ValueError("Campaign has no body.")
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=f"[TEST] {subject}",
|
||||
body=(
|
||||
f"{body}\n\n---\n"
|
||||
"This is a test send from the Monica portal. "
|
||||
"Recipient list was not notified."
|
||||
),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
email.send(fail_silently=False)
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.tasks import task
|
||||
from django.utils import timezone
|
||||
|
||||
from messaging.channels import dispatch_message
|
||||
from messaging.models import Message
|
||||
from messaging.services import contact_may_receive
|
||||
|
||||
|
||||
@task
|
||||
def send_campaign_message(message_id: str) -> None:
|
||||
try:
|
||||
message = Message.objects.select_related("contact", "campaign").get(
|
||||
pk=message_id
|
||||
)
|
||||
except Message.DoesNotExist:
|
||||
return
|
||||
|
||||
if not contact_may_receive(message.contact, message.channel):
|
||||
message.status = Message.Status.SUPPRESSED
|
||||
message.error = "Contact opted out or suppressed"
|
||||
message.save(update_fields=["status", "error", "updated_at"])
|
||||
return
|
||||
|
||||
try:
|
||||
result = dispatch_message(message)
|
||||
message.status = Message.Status.SENT
|
||||
message.provider = result.provider
|
||||
message.provider_message_id = result.provider_id
|
||||
message.sent_at = timezone.now()
|
||||
message.error = ""
|
||||
message.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"provider",
|
||||
"provider_message_id",
|
||||
"sent_at",
|
||||
"error",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — persist provider failures
|
||||
message.status = Message.Status.FAILED
|
||||
message.error = str(exc)[:2000]
|
||||
message.save(update_fields=["status", "error", "updated_at"])
|
||||
raise
|
||||
@@ -0,0 +1,199 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ campaign.name }} · Campaign{% endblock %}
|
||||
{% block topbar_title %}{{ campaign.name }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<span class="badge badge-{{ campaign.status }}" id="campaign-status-badge">{{ campaign.get_status_display }}</span>
|
||||
<span class="muted" style="margin-left:8px">{{ campaign.get_channel_display }}</span>
|
||||
{% if campaign.scheduled_for %}
|
||||
<span class="muted" style="margin-left:8px">Scheduled {{ campaign.scheduled_for|date:"M j, g:i A" }}</span>
|
||||
{% endif %}
|
||||
<span class="muted" style="margin-left:8px" id="live-hint">Live · updates every 10s</span>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'messaging:campaign_list' %}">← Campaigns</a>
|
||||
</div>
|
||||
|
||||
{% if campaign.channel == "email" or can_send %}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-h"><h2>Send</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
{% if campaign.channel == "email" %}
|
||||
<form method="post" action="{% url 'messaging:campaign_test_send' campaign.pk %}" class="form-grid cols-2" style="align-items:end">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_test_email">Test send (your inbox)</label>
|
||||
<input id="id_test_email" name="test_email" type="email" required
|
||||
placeholder="you@example.com"
|
||||
value="{{ request.user.email }}">
|
||||
<div class="hint">Sends one [TEST] copy. Does not notify the recipient list.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="btn btn-ghost" type="submit">Send test email</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_send %}
|
||||
<form method="post" action="{% url 'messaging:campaign_send' campaign.pk %}"
|
||||
onsubmit="return confirm('Send this campaign to all remaining recipients now?');">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-primary" type="submit">Send now to recipients</button>
|
||||
<p class="hint-block" style="margin-top:8px">
|
||||
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="stat-row" id="campaign-stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Messages</div>
|
||||
<div class="value" data-stat="total">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Sent</div>
|
||||
<div class="value" data-stat="sent">{{ stats.sent }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Delivered</div>
|
||||
<div class="value" data-stat="delivered">{{ stats.delivered }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Opens</div>
|
||||
<div class="value" data-stat="opens">{{ stats.opens }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Clicks</div>
|
||||
<div class="value" data-stat="clicks">{{ stats.clicks }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Bounced / failed</div>
|
||||
<div class="value" data-stat="failed">{{ stats.failed }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Suppressed</div>
|
||||
<div class="value" data-stat="suppressed">{{ stats.suppressed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Engagement</h2></div>
|
||||
<div class="panel-b">
|
||||
<p class="hint-block" style="margin-top:0">
|
||||
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
|
||||
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
|
||||
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO).
|
||||
</p>
|
||||
<div class="chart-placeholder" aria-hidden="true">
|
||||
<div class="bar" style="height:55%"></div>
|
||||
<div class="bar" style="height:70%"></div>
|
||||
<div class="bar" style="height:40%"></div>
|
||||
<div class="bar" style="height:30%"></div>
|
||||
<div class="bar" style="height:20%"></div>
|
||||
</div> </div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Recent SMTP2GO events</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Event</th><th>Contact</th></tr></thead>
|
||||
<tbody id="events-body">
|
||||
{% for event in recent_events %}
|
||||
<tr>
|
||||
<td class="muted">{{ event.created_at|date:"M j, g:i A" }}</td>
|
||||
<td><span class="badge">{{ event.event_type }}</span></td>
|
||||
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No provider events yet. Configure the SMTP2GO webhook after first send.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Recipients</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Contact</th><th>Status</th><th>Provider id</th><th>Error</th></tr>
|
||||
</thead>
|
||||
<tbody id="recipients-body">
|
||||
{% for message in messages %}
|
||||
<tr data-message-id="{{ message.pk }}">
|
||||
<td>{{ message.contact }}</td>
|
||||
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
|
||||
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
|
||||
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var url = "{% url 'messaging:campaign_status_json' campaign.pk %}";
|
||||
function esc(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, function (c) {
|
||||
return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c];
|
||||
});
|
||||
}
|
||||
function apply(data) {
|
||||
var badge = document.getElementById("campaign-status-badge");
|
||||
if (badge) {
|
||||
badge.textContent = data.status_display;
|
||||
badge.className = "badge badge-" + data.status;
|
||||
}
|
||||
Object.keys(data.stats || {}).forEach(function (key) {
|
||||
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
|
||||
el.textContent = data.stats[key];
|
||||
});
|
||||
});
|
||||
var body = document.getElementById("recipients-body");
|
||||
if (body && data.messages) {
|
||||
if (!data.messages.length) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
|
||||
} else {
|
||||
body.innerHTML = data.messages.map(function (m) {
|
||||
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
|
||||
"<td>" + esc(m.contact) + "</td>" +
|
||||
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
|
||||
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
|
||||
"<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>";
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
var eventsBody = document.getElementById("events-body");
|
||||
if (eventsBody && data.events) {
|
||||
if (!data.events.length) {
|
||||
eventsBody.innerHTML = '<tr><td colspan="3" class="empty-state">No provider events yet.</td></tr>';
|
||||
} else {
|
||||
eventsBody.innerHTML = data.events.map(function (e) {
|
||||
var when = e.created_at ? new Date(e.created_at).toLocaleString() : "—";
|
||||
return "<tr><td class=\"muted\">" + esc(when) + "</td>" +
|
||||
"<td><span class=\"badge\">" + esc(e.event_type) + "</span></td>" +
|
||||
"<td>" + esc(e.contact) + "</td></tr>";
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
function tick() {
|
||||
fetch(url, { headers: { "Accept": "application/json" }, credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(apply)
|
||||
.catch(function () {});
|
||||
}
|
||||
setInterval(tick, 10000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,158 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Campaigns · Portal{% endblock %}
|
||||
{% block topbar_title %}Campaign composer{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="channel-tabs">
|
||||
<a class="active" href="#compose-email">Email</a>
|
||||
<a href="#compose-sms">SMS</a>
|
||||
<a href="{% url 'messaging:postcard_designer' %}">Postcard</a>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Compose</h2></div>
|
||||
<div class="panel-b">
|
||||
{% if form_errors %}
|
||||
<ul class="portal-flash" style="margin:0 0 8px">
|
||||
{% for err in form_errors %}
|
||||
<li class="error">{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'messaging:campaign_list' %}" class="form-grid" id="campaign-compose">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_name">Campaign name</label>
|
||||
<input id="id_name" name="name" type="text" required
|
||||
placeholder="Spring seller tips" value="{{ form_data.name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_subject">Subject</label>
|
||||
<input id="id_subject" name="subject" type="text"
|
||||
placeholder="A quick tip for sellers this week"
|
||||
value="{{ form_data.subject }}"
|
||||
oninput="syncCampaignPreview()">
|
||||
<div class="hint">Email only — ignored for SMS / postcard</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_body">Body</label>
|
||||
<textarea id="id_body" name="body" style="min-height:140px"
|
||||
placeholder="Hi {first_name}, …"
|
||||
oninput="syncCampaignPreview()">{{ form_data.body }}</textarea>
|
||||
<div class="hint">Merge tags: first_name, last_name, unsubscribe_url · optional for postcard</div>
|
||||
</div>
|
||||
<div class="field" id="postcard-template-field">
|
||||
<label for="id_template_id">Postcard template</label>
|
||||
<select id="id_template_id" name="template_id">
|
||||
<option value="">— Select saved design —</option>
|
||||
{% for t in postcard_templates %}
|
||||
<option value="{{ t.pk }}"{% if form_data.template_id == t.pk|stringformat:"s" %} selected{% endif %}>
|
||||
{{ t.name }} (design {{ t.postcard_front.design_id }})
|
||||
</option>
|
||||
{% empty %}
|
||||
<option value="" disabled>No templates yet — use Postcard designer</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a></div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label for="id_audience">Recipients</label>
|
||||
<select id="id_audience" name="audience" required onchange="syncComposeChannel()">
|
||||
{% for value, label in audience_choices %}
|
||||
<option value="{{ value }}"{% if form_data.audience == value %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_scheduled_for">Schedule <span class="muted">(optional)</span></label>
|
||||
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
|
||||
value="{{ form_data.scheduled_for }}">
|
||||
<div class="hint">Date & time · leave blank to keep as unscheduled draft</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
||||
<button class="btn btn-primary" type="submit">Save draft</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Preview</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="preview-pane" id="campaign-preview">
|
||||
<div class="muted" id="preview-empty">Preview updates as you type.</div>
|
||||
<div id="preview-content" hidden>
|
||||
<div class="hint" id="preview-subject"></div>
|
||||
<div id="preview-body" style="white-space:pre-wrap;margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-block">After send → open the campaign report for delivery & engagement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Recent campaigns</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Channel</th>
|
||||
<th>Status</th>
|
||||
<th>Scheduled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for campaign in campaigns %}
|
||||
<tr>
|
||||
<td><a href="{% url 'messaging:campaign_detail' campaign.pk %}">{{ campaign.name }}</a></td>
|
||||
<td>{{ campaign.get_channel_display }}</td>
|
||||
<td><span class="badge badge-{{ campaign.status }}">{{ campaign.get_status_display }}</span></td>
|
||||
<td>{% if campaign.scheduled_for %}{{ campaign.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}</td>
|
||||
<td><a href="{% url 'messaging:campaign_detail' campaign.pk %}">Report</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No campaigns yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function syncCampaignPreview() {
|
||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||
var body = (document.getElementById('id_body') || {}).value || '';
|
||||
var empty = document.getElementById('preview-empty');
|
||||
var content = document.getElementById('preview-content');
|
||||
var subEl = document.getElementById('preview-subject');
|
||||
var bodyEl = document.getElementById('preview-body');
|
||||
if (!empty || !content) return;
|
||||
if (!subject && !body) {
|
||||
empty.hidden = false;
|
||||
content.hidden = true;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
content.hidden = false;
|
||||
subEl.textContent = subject ? ('Subject: ' + subject) : '';
|
||||
bodyEl.textContent = body;
|
||||
}
|
||||
function syncComposeChannel() {
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
var isPostcard = audience === 'postcard_opt_in';
|
||||
var tmplField = document.getElementById('postcard-template-field');
|
||||
var body = document.getElementById('id_body');
|
||||
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
||||
if (body) {
|
||||
if (isPostcard) body.removeAttribute('required');
|
||||
else body.setAttribute('required', 'required');
|
||||
}
|
||||
}
|
||||
syncCampaignPreview();
|
||||
syncComposeChannel();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,134 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Postcard designer · Portal{% endblock %}
|
||||
{% block topbar_title %}Postcard designer{% endblock %}
|
||||
{% block portal_content %}
|
||||
{% if api_error %}
|
||||
<ul class="portal-flash" style="margin:0 0 16px">
|
||||
<li class="error">{{ api_error }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div class="designer-layout pcm-designer">
|
||||
<div class="designer-controls">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>New design</h2></div>
|
||||
<div class="panel-b">
|
||||
<form method="post" action="{% url 'messaging:postcard_design_create' %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_design_name">Name</label>
|
||||
<input id="id_design_name" name="name" type="text" required
|
||||
placeholder="March just-listed" value="{{ new_name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_design_size">Size</label>
|
||||
<select id="id_design_size" name="size" required>
|
||||
{% for code, label in size_choices %}
|
||||
<option value="{{ code }}"{% if code == new_size %} selected{% endif %}>{{ label }} ({{ code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Create in PCM designer</button>
|
||||
</form>
|
||||
<p class="library-hint" style="margin-top:12px">
|
||||
Opens PCM Integrations editor in the frame. Artwork stays on their side;
|
||||
we store the design id for campaigns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Your designs</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in designs %}
|
||||
<tr{% if d.design_id == active_design_id %} class="is-active"{% endif %}>
|
||||
<td>{{ d.name }}</td>
|
||||
<td class="muted">{{ d.design_id }}</td>
|
||||
<td>
|
||||
<a href="{% url 'messaging:postcard_designer' %}?design_id={{ d.design_id }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No designs yet — create one above.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if active_design_id %}
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Save for campaigns</h2></div>
|
||||
<div class="panel-b">
|
||||
<form method="post" action="{% url 'messaging:postcard_design_save' %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="design_id" value="{{ active_design_id }}">
|
||||
<input type="hidden" name="size" value="{{ active_size }}">
|
||||
<div class="field">
|
||||
<label for="id_template_name">Template name</label>
|
||||
<input id="id_template_name" name="template_name" type="text" required
|
||||
value="{{ active_name }}">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Save as postcard template</button>
|
||||
</form>
|
||||
<p class="library-hint" style="margin-top:12px">
|
||||
Saved templates appear when composing a postcard campaign.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if saved_templates %}
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Saved templates</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Design</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in saved_templates %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td class="muted">{{ t.postcard_front.design_id }}</td>
|
||||
<td>
|
||||
<a href="{% url 'messaging:postcard_designer' %}?design_id={{ t.postcard_front.design_id }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="designer-preview-col panel">
|
||||
<div class="panel-h">
|
||||
<h2>{% if active_design_id %}PCM editor · design {{ active_design_id }}{% else %}Editor{% endif %}</h2>
|
||||
</div>
|
||||
<div class="panel-b pcm-iframe-wrap">
|
||||
{% if embed_url %}
|
||||
<iframe
|
||||
title="PCM postcard designer"
|
||||
src="{{ embed_url }}"
|
||||
allow="clipboard-write"
|
||||
></iframe>
|
||||
{% else %}
|
||||
<div class="empty-state" style="padding:48px 24px;text-align:center">
|
||||
Create a design or pick one from the list to open the PCM editor here.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,629 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
||||
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
|
||||
from messaging.services import (
|
||||
contact_may_receive,
|
||||
create_campaign_draft,
|
||||
make_unsubscribe_token,
|
||||
set_channel_consent,
|
||||
)
|
||||
|
||||
|
||||
class PreferenceCenterTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.contact = Contact.objects.create(
|
||||
email="jordan.lee@example.com",
|
||||
phone="5550184420",
|
||||
first_name="Jordan",
|
||||
last_name="Lee",
|
||||
)
|
||||
for channel in Channel:
|
||||
set_channel_consent(
|
||||
self.contact, channel.value, opted_in=True, reason="test_seed"
|
||||
)
|
||||
self.token = make_unsubscribe_token(str(self.contact.pk), Channel.EMAIL)
|
||||
|
||||
def test_preferences_get_shows_form(self):
|
||||
url = reverse("public:unsubscribe", kwargs={"token": self.token})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Email marketing")
|
||||
self.assertContains(response, "SMS updates")
|
||||
self.assertContains(response, "Postcard mailings")
|
||||
self.assertTrue(contact_may_receive(self.contact, Channel.EMAIL))
|
||||
|
||||
def test_invalid_token(self):
|
||||
url = reverse("public:unsubscribe", kwargs={"token": "not-a-valid-token"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "invalid or has expired")
|
||||
|
||||
def test_one_click_query_opts_out_email_only(self):
|
||||
url = reverse("public:unsubscribe", kwargs={"token": self.token})
|
||||
response = self.client.get(f"{url}?one_click=1")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.contact.refresh_from_db()
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
|
||||
self.assertTrue(contact_may_receive(self.contact, Channel.SMS))
|
||||
self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD))
|
||||
|
||||
def test_one_click_endpoint(self):
|
||||
url = reverse(
|
||||
"public:unsubscribe_one_click", kwargs={"token": self.token}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
|
||||
|
||||
def test_one_click_post_rfc8058(self):
|
||||
url = reverse(
|
||||
"public:unsubscribe_one_click", kwargs={"token": self.token}
|
||||
)
|
||||
response = self.client.post(
|
||||
url, {"List-Unsubscribe": "One-Click"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
|
||||
|
||||
def test_save_preferences_partial_opt_out(self):
|
||||
url = reverse("public:unsubscribe", kwargs={"token": self.token})
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"action": "save",
|
||||
"consent_email": "1",
|
||||
# SMS unchecked
|
||||
"consent_postcard": "1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(contact_may_receive(self.contact, Channel.EMAIL))
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.SMS))
|
||||
self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD))
|
||||
sms_sup = Suppression.objects.get(
|
||||
contact=self.contact, channel=Channel.SMS
|
||||
)
|
||||
self.assertTrue(sms_sup.active)
|
||||
|
||||
def test_unsubscribe_all(self):
|
||||
url = reverse("public:unsubscribe", kwargs={"token": self.token})
|
||||
response = self.client.post(url, {"action": "unsubscribe_all"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
for channel in Channel:
|
||||
self.assertFalse(contact_may_receive(self.contact, channel.value))
|
||||
|
||||
|
||||
class SmsStopWebhookTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.contact = Contact.objects.create(
|
||||
email="avery@example.com",
|
||||
phone="5550142291",
|
||||
)
|
||||
set_channel_consent(
|
||||
self.contact, Channel.SMS, opted_in=True, reason="test"
|
||||
)
|
||||
|
||||
def test_stop_webhook(self):
|
||||
url = reverse("messaging:sms_webhook")
|
||||
response = self.client.post(
|
||||
url, {"from": "5550142291", "text": "STOP"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.SMS))
|
||||
|
||||
|
||||
class PortalConsentToggleTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="monica", password="test-pass-123"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="monica", password="test-pass-123")
|
||||
self.contact = Contact.objects.create(
|
||||
email="sam@example.com",
|
||||
first_name="Sam",
|
||||
)
|
||||
set_channel_consent(
|
||||
self.contact, Channel.EMAIL, opted_in=True, reason="test"
|
||||
)
|
||||
|
||||
def test_portal_can_toggle_consent(self):
|
||||
url = reverse("contacts:detail", kwargs={"pk": self.contact.pk})
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"notes": "updated",
|
||||
"consent_sms": "1",
|
||||
"consent_postcard": "1",
|
||||
# email unchecked → opt out
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
|
||||
self.assertTrue(contact_may_receive(self.contact, Channel.SMS))
|
||||
self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD))
|
||||
email_consent = ConsentRecord.objects.get(
|
||||
contact=self.contact, channel=Channel.EMAIL
|
||||
)
|
||||
self.assertEqual(email_consent.reason, "portal_manual")
|
||||
|
||||
|
||||
class CampaignDraftSaveTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="composer", password="test-pass-123"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="composer", password="test-pass-123")
|
||||
self.contact = Contact.objects.create(
|
||||
email="pat@example.com",
|
||||
first_name="Pat",
|
||||
)
|
||||
set_channel_consent(
|
||||
self.contact, Channel.EMAIL, opted_in=True, reason="test"
|
||||
)
|
||||
|
||||
def test_save_draft_creates_campaign_and_messages(self):
|
||||
url = reverse("messaging:campaign_list")
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"name": "Spring tips",
|
||||
"subject": "Hello sellers",
|
||||
"body": "Hi {first_name}",
|
||||
"audience": Campaign.Audience.EMAIL_OPT_IN,
|
||||
"scheduled_for": "2026-08-10T09:30",
|
||||
},
|
||||
)
|
||||
campaign = Campaign.objects.get(name="Spring tips")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
reverse("messaging:campaign_detail", kwargs={"pk": campaign.pk}),
|
||||
)
|
||||
self.assertEqual(campaign.status, Campaign.Status.DRAFT)
|
||||
self.assertEqual(campaign.channel, Channel.EMAIL)
|
||||
self.assertEqual(campaign.audience, Campaign.Audience.EMAIL_OPT_IN)
|
||||
self.assertIsNotNone(campaign.scheduled_for)
|
||||
self.assertEqual(campaign.messages.count(), 1)
|
||||
msg = campaign.messages.get()
|
||||
self.assertEqual(msg.contact, self.contact)
|
||||
self.assertEqual(msg.status, Message.Status.DRAFT)
|
||||
|
||||
def test_save_draft_requires_subject_for_email(self):
|
||||
url = reverse("messaging:campaign_list")
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"name": "No subject",
|
||||
"subject": "",
|
||||
"body": "Body only",
|
||||
"audience": Campaign.Audience.EMAIL_OPT_IN,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Subject is required")
|
||||
self.assertFalse(Campaign.objects.filter(name="No subject").exists())
|
||||
|
||||
def test_create_campaign_draft_helper(self):
|
||||
campaign = create_campaign_draft(
|
||||
name="Helper draft",
|
||||
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||
subject="Subj",
|
||||
body="Body",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.assertEqual(campaign.messages.count(), 1)
|
||||
self.assertEqual(campaign.created_by, self.user)
|
||||
|
||||
|
||||
class CampaignSendTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="sender", password="test-pass-123", email="sender@example.com"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="sender", password="test-pass-123")
|
||||
self.contact = Contact.objects.create(
|
||||
email="pat@example.com",
|
||||
first_name="Pat",
|
||||
)
|
||||
set_channel_consent(
|
||||
self.contact, Channel.EMAIL, opted_in=True, reason="test"
|
||||
)
|
||||
self.campaign = create_campaign_draft(
|
||||
name="Send me",
|
||||
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||
subject="Hello",
|
||||
body="Body text",
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
def test_detail_shows_send_controls(self):
|
||||
url = reverse(
|
||||
"messaging:campaign_detail", kwargs={"pk": self.campaign.pk}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Send test email")
|
||||
self.assertContains(response, "Send now to recipients")
|
||||
|
||||
def test_test_send_uses_locmem(self):
|
||||
from django.core import mail
|
||||
|
||||
url = reverse(
|
||||
"messaging:campaign_test_send", kwargs={"pk": self.campaign.pk}
|
||||
)
|
||||
with self.settings(
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
DEFAULT_FROM_EMAIL="noreply@example.com",
|
||||
):
|
||||
response = self.client.post(url, {"test_email": "me@example.com"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].to, ["me@example.com"])
|
||||
self.assertTrue(mail.outbox[0].subject.startswith("[TEST]"))
|
||||
# Recipients untouched
|
||||
self.assertEqual(
|
||||
self.campaign.messages.filter(status=Message.Status.DRAFT).count(), 1
|
||||
)
|
||||
|
||||
def test_send_now_marks_messages_sent(self):
|
||||
from django.core import mail
|
||||
|
||||
url = reverse(
|
||||
"messaging:campaign_send", kwargs={"pk": self.campaign.pk}
|
||||
)
|
||||
with self.settings(
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
DEFAULT_FROM_EMAIL="noreply@example.com",
|
||||
PUBLIC_SITE_URL="http://testserver",
|
||||
):
|
||||
response = self.client.post(url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.campaign.refresh_from_db()
|
||||
self.assertEqual(self.campaign.status, Campaign.Status.COMPLETED)
|
||||
self.assertEqual(
|
||||
self.campaign.messages.filter(status=Message.Status.SENT).count(), 1
|
||||
)
|
||||
# Recipient campaign email + one realtor completion summary.
|
||||
self.assertEqual(len(mail.outbox), 2)
|
||||
self.assertIsNotNone(self.campaign.notify_sent_at)
|
||||
summary = mail.outbox[1]
|
||||
self.assertIn("Campaign sent:", summary.subject)
|
||||
self.assertEqual(summary.to, [self.user.email])
|
||||
|
||||
|
||||
class Smtp2goEmailWebhookTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.contact = Contact.objects.create(
|
||||
email="pat@example.com",
|
||||
first_name="Pat",
|
||||
)
|
||||
set_channel_consent(
|
||||
self.contact, Channel.EMAIL, opted_in=True, reason="test"
|
||||
)
|
||||
self.campaign = create_campaign_draft(
|
||||
name="Webhook campaign",
|
||||
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||
subject="Hello",
|
||||
body="Body",
|
||||
)
|
||||
self.message = self.campaign.messages.get()
|
||||
self.message.status = Message.Status.SENT
|
||||
self.message.provider_message_id = f"smtp-{self.message.pk}"
|
||||
self.message.save()
|
||||
|
||||
def test_delivered_updates_status_via_monica_header(self):
|
||||
url = reverse("messaging:email_webhook")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
"event": "delivered",
|
||||
"rcpt": "pat@example.com",
|
||||
"email_id": "smtp2go-abc-123",
|
||||
"X-Monica-Message-Id": str(self.message.pk),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.message.refresh_from_db()
|
||||
self.assertEqual(self.message.status, Message.Status.DELIVERED)
|
||||
self.assertEqual(self.message.provider_message_id, "smtp2go-abc-123")
|
||||
self.assertTrue(
|
||||
ProviderEvent.objects.filter(
|
||||
message=self.message, event_type="delivered"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_open_records_event_keeps_delivered(self):
|
||||
self.message.status = Message.Status.DELIVERED
|
||||
self.message.save(update_fields=["status"])
|
||||
url = reverse("messaging:email_webhook")
|
||||
self.client.post(
|
||||
url,
|
||||
data={
|
||||
"event": "open",
|
||||
"rcpt": "pat@example.com",
|
||||
"X-Monica-Message-Id": str(self.message.pk),
|
||||
},
|
||||
)
|
||||
self.message.refresh_from_db()
|
||||
self.assertEqual(self.message.status, Message.Status.DELIVERED)
|
||||
self.assertTrue(
|
||||
ProviderEvent.objects.filter(
|
||||
message=self.message, event_type="open"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_hard_bounce_suppresses_contact(self):
|
||||
url = reverse("messaging:email_webhook")
|
||||
self.client.post(
|
||||
url,
|
||||
data={
|
||||
"event": "bounce",
|
||||
"bounce": "hard",
|
||||
"rcpt": "pat@example.com",
|
||||
"message": "550 user unknown",
|
||||
"X-Monica-Message-Id": str(self.message.pk),
|
||||
},
|
||||
)
|
||||
self.message.refresh_from_db()
|
||||
self.assertEqual(self.message.status, Message.Status.BOUNCED)
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL))
|
||||
|
||||
def test_webhook_secret_required_when_configured(self):
|
||||
url = reverse("messaging:email_webhook")
|
||||
with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"):
|
||||
denied = self.client.post(url, data={"event": "delivered"})
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
ok = self.client.post(
|
||||
f"{url}?token=s3cret",
|
||||
data={
|
||||
"event": "delivered",
|
||||
"X-Monica-Message-Id": str(self.message.pk),
|
||||
},
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
|
||||
def test_status_json_includes_opens(self):
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user(username="viewer", password="test-pass-123")
|
||||
self.client.login(username="viewer", password="test-pass-123")
|
||||
ProviderEvent.objects.create(
|
||||
message=self.message,
|
||||
provider="smtp2go_email",
|
||||
event_type="open",
|
||||
payload={"event": "open"},
|
||||
)
|
||||
url = reverse(
|
||||
"messaging:campaign_status_json", kwargs={"pk": self.campaign.pk}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["stats"]["opens"], 1)
|
||||
|
||||
def test_bearer_authorization_header(self):
|
||||
import json
|
||||
|
||||
url = reverse("messaging:email_webhook")
|
||||
with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"):
|
||||
denied = self.client.post(
|
||||
url,
|
||||
data=json.dumps(
|
||||
{
|
||||
"event": "delivered",
|
||||
"X-Monica-Message-Id": str(self.message.pk),
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer wrong",
|
||||
)
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
ok = self.client.post(
|
||||
url,
|
||||
data=json.dumps(
|
||||
{
|
||||
"event": "delivered",
|
||||
"X-Monica-Message-Id": str(self.message.pk),
|
||||
"email_id": "e-1",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer s3cret",
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
|
||||
|
||||
class Smtp2goSmsWebhookTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.contact = Contact.objects.create(
|
||||
email="pat@example.com",
|
||||
phone="+15550142291",
|
||||
first_name="Pat",
|
||||
)
|
||||
set_channel_consent(
|
||||
self.contact, Channel.SMS, opted_in=True, reason="test"
|
||||
)
|
||||
self.campaign = create_campaign_draft(
|
||||
name="SMS blast",
|
||||
audience=Campaign.Audience.SMS_OPT_IN,
|
||||
subject="",
|
||||
body="Hi there",
|
||||
)
|
||||
self.message = self.campaign.messages.get()
|
||||
self.message.status = Message.Status.SENT
|
||||
self.message.provider_message_id = "sms-provider-99"
|
||||
self.message.save()
|
||||
|
||||
def test_sms_delivered_by_message_id(self):
|
||||
url = reverse("messaging:sms_webhook")
|
||||
import json
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps(
|
||||
{
|
||||
"event": "sms_delivered",
|
||||
"message_id": "sms-provider-99",
|
||||
"destination_number": "5550142291",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.message.refresh_from_db()
|
||||
self.assertEqual(self.message.status, Message.Status.DELIVERED)
|
||||
self.assertTrue(
|
||||
ProviderEvent.objects.filter(
|
||||
message=self.message, event_type="sms_delivered"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_sms_failed_by_phone_fallback(self):
|
||||
url = reverse("messaging:sms_webhook")
|
||||
import json
|
||||
|
||||
self.message.provider_message_id = "other-id"
|
||||
self.message.save(update_fields=["provider_message_id"])
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps(
|
||||
{
|
||||
"event": "sms_failed",
|
||||
"message_id": "unknown",
|
||||
"destination_number": "+1 (555) 014-2291",
|
||||
"status_code": "undeliverable",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.message.refresh_from_db()
|
||||
self.assertEqual(self.message.status, Message.Status.FAILED)
|
||||
|
||||
def test_inbound_stop_still_works(self):
|
||||
url = reverse("messaging:sms_webhook")
|
||||
response = self.client.post(
|
||||
url, {"from": "5550142291", "text": "STOP"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(contact_may_receive(self.contact, Channel.SMS))
|
||||
|
||||
def test_sms_webhook_requires_bearer_when_secret_set(self):
|
||||
url = reverse("messaging:sms_webhook")
|
||||
import json
|
||||
|
||||
with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"):
|
||||
denied = self.client.post(
|
||||
url,
|
||||
data=json.dumps({"event": "sms_delivered", "message_id": "x"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
ok = self.client.post(
|
||||
url,
|
||||
data=json.dumps(
|
||||
{
|
||||
"event": "sms_delivered",
|
||||
"message_id": "sms-provider-99",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer s3cret",
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
|
||||
|
||||
class PcmPostcardWebhookTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.contact = Contact.objects.create(
|
||||
email="mail@example.com",
|
||||
first_name="Pat",
|
||||
last_name="Lee",
|
||||
postal_address=Contact.make_postal_address(
|
||||
line1="123 Main St",
|
||||
city="Naperville",
|
||||
state="IL",
|
||||
zip_code="60540",
|
||||
),
|
||||
)
|
||||
set_channel_consent(
|
||||
self.contact, Channel.POSTCARD, opted_in=True, reason="test"
|
||||
)
|
||||
template = MessageTemplate.objects.create(
|
||||
name="PCM test design",
|
||||
channel=Channel.POSTCARD,
|
||||
body="Postcard",
|
||||
postcard_front={"design_id": 99, "size": "46", "provider": "pcm"},
|
||||
)
|
||||
self.campaign = create_campaign_draft(
|
||||
name="March mailer",
|
||||
audience=Campaign.Audience.POSTCARD_OPT_IN,
|
||||
body="Postcard mailing",
|
||||
template=template,
|
||||
)
|
||||
self.message = self.campaign.messages.get()
|
||||
self.message.status = Message.Status.SENT
|
||||
self.message.provider = "pcm"
|
||||
self.message.provider_message_id = "order-555"
|
||||
self.message.save()
|
||||
|
||||
def test_delivered_by_ext_ref(self):
|
||||
import json
|
||||
|
||||
url = reverse("messaging:postcard_webhook")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps(
|
||||
{
|
||||
"status": "Delivered",
|
||||
"extRefNbr": str(self.message.pk),
|
||||
"orderID": 555,
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.message.refresh_from_db()
|
||||
self.assertEqual(self.message.status, Message.Status.DELIVERED)
|
||||
self.assertTrue(
|
||||
ProviderEvent.objects.filter(
|
||||
message=self.message, provider="pcm", event_type="Delivered"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_requires_bearer_when_secret_set(self):
|
||||
import json
|
||||
|
||||
url = reverse("messaging:postcard_webhook")
|
||||
with self.settings(PCM_WEBHOOK_SECRET="pcm-secret"):
|
||||
denied = self.client.post(
|
||||
url,
|
||||
data=json.dumps({"status": "Delivered", "orderID": 555}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
ok = self.client.post(
|
||||
url,
|
||||
data=json.dumps(
|
||||
{
|
||||
"status": "Delivered",
|
||||
"orderID": "order-555",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer pcm-secret",
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.urls import path
|
||||
|
||||
from messaging import views
|
||||
|
||||
app_name = "messaging"
|
||||
|
||||
urlpatterns = [
|
||||
path("campaigns/", views.campaign_list, name="campaign_list"),
|
||||
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
|
||||
path(
|
||||
"campaigns/<uuid:pk>/status.json",
|
||||
views.campaign_status_json,
|
||||
name="campaign_status_json",
|
||||
),
|
||||
path("campaigns/<uuid:pk>/send/", views.campaign_send, name="campaign_send"),
|
||||
path(
|
||||
"campaigns/<uuid:pk>/test-send/",
|
||||
views.campaign_test_send,
|
||||
name="campaign_test_send",
|
||||
),
|
||||
path("postcard/", views.postcard_designer, name="postcard_designer"),
|
||||
path(
|
||||
"postcard/create/",
|
||||
views.postcard_design_create,
|
||||
name="postcard_design_create",
|
||||
),
|
||||
path(
|
||||
"postcard/save/",
|
||||
views.postcard_design_save,
|
||||
name="postcard_design_save",
|
||||
),
|
||||
path("webhooks/sms/", views.sms_webhook, name="sms_webhook"),
|
||||
path("webhooks/email/", views.email_webhook, name="email_webhook"),
|
||||
path(
|
||||
"webhooks/postcard/",
|
||||
views.postcard_webhook,
|
||||
name="postcard_webhook",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,549 @@
|
||||
from django.conf import settings
|
||||
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.http import HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
|
||||
from contacts.models import Channel
|
||||
from messaging.models import Campaign, MessageTemplate, ProviderEvent
|
||||
from messaging.providers.postcard.pcm import (
|
||||
PCM_SIZE_CHOICES,
|
||||
PcmApiError,
|
||||
create_custom_design,
|
||||
get_design_embed_url,
|
||||
list_designs,
|
||||
)
|
||||
from messaging.services import (
|
||||
create_campaign_draft,
|
||||
enqueue_campaign_send,
|
||||
opted_in_contacts,
|
||||
parse_scheduled_for,
|
||||
record_sms_stop,
|
||||
send_campaign_test_email,
|
||||
)
|
||||
from messaging.webhooks import (
|
||||
campaign_engagement_stats,
|
||||
is_inbound_sms_stop,
|
||||
parse_webhook_payload,
|
||||
process_pcm_postcard_webhook,
|
||||
process_smtp2go_email_webhook,
|
||||
process_smtp2go_sms_webhook,
|
||||
)
|
||||
|
||||
|
||||
def _audience_choices() -> list[tuple[str, str]]:
|
||||
"""Labeled audience options with live opted-in counts."""
|
||||
rows = [
|
||||
(Campaign.Audience.EMAIL_OPT_IN, Channel.EMAIL, "email"),
|
||||
(Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"),
|
||||
(Campaign.Audience.POSTCARD_OPT_IN, Channel.POSTCARD, "postcard"),
|
||||
]
|
||||
choices = []
|
||||
for value, channel, label in rows:
|
||||
count = opted_in_contacts(channel).count()
|
||||
noun = "contact" if count == 1 else "contacts"
|
||||
choices.append(
|
||||
(value, f"Mailing list · {label} opt-in ({count} {noun})")
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
def _postcard_templates():
|
||||
return MessageTemplate.objects.filter(channel=Channel.POSTCARD).order_by(
|
||||
"-updated_at"
|
||||
)[:50]
|
||||
|
||||
|
||||
def _campaign_report(campaign: Campaign) -> dict:
|
||||
messages_qs = list(campaign.messages.select_related("contact").all()[:200])
|
||||
stats = campaign_engagement_stats(campaign)
|
||||
recent_events = (
|
||||
ProviderEvent.objects.filter(message__campaign=campaign)
|
||||
.select_related("message", "message__contact")
|
||||
.order_by("-created_at")[:25]
|
||||
)
|
||||
return {
|
||||
"messages": messages_qs,
|
||||
"stats": stats,
|
||||
"recent_events": recent_events,
|
||||
}
|
||||
|
||||
|
||||
def _webhook_authorized(request, *, secret: str) -> bool:
|
||||
secret = (secret or "").strip()
|
||||
if not secret:
|
||||
return True
|
||||
token = (request.GET.get("token") or "").strip()
|
||||
auth = (request.headers.get("Authorization") or "").strip()
|
||||
if token and token == secret:
|
||||
return True
|
||||
if auth.lower().startswith("bearer ") and auth[7:].strip() == secret:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def campaign_list(request):
|
||||
form_errors: list[str] = []
|
||||
form_data = {
|
||||
"name": "",
|
||||
"subject": "",
|
||||
"body": "",
|
||||
"audience": Campaign.Audience.EMAIL_OPT_IN,
|
||||
"scheduled_for": "",
|
||||
"template_id": "",
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
name = (request.POST.get("name") or "").strip()
|
||||
subject = (request.POST.get("subject") or "").strip()
|
||||
body = (request.POST.get("body") or "").strip()
|
||||
audience = (request.POST.get("audience") or "").strip()
|
||||
scheduled_raw = request.POST.get("scheduled_for") or ""
|
||||
template_id = (request.POST.get("template_id") or "").strip()
|
||||
|
||||
form_data.update(
|
||||
{
|
||||
"name": name,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"audience": audience,
|
||||
"scheduled_for": scheduled_raw,
|
||||
"template_id": template_id,
|
||||
}
|
||||
)
|
||||
|
||||
template = None
|
||||
if template_id:
|
||||
template = MessageTemplate.objects.filter(pk=template_id).first()
|
||||
|
||||
if not name:
|
||||
form_errors.append("Campaign name is required.")
|
||||
if audience not in Campaign.Audience.values:
|
||||
form_errors.append("Choose a recipient list.")
|
||||
if audience == Campaign.Audience.POSTCARD_OPT_IN:
|
||||
if not template or template.channel != Channel.POSTCARD:
|
||||
form_errors.append(
|
||||
"Choose a saved postcard template (design it under Postcard first)."
|
||||
)
|
||||
if not body:
|
||||
body = "Postcard mailing"
|
||||
else:
|
||||
if not body:
|
||||
form_errors.append("Body is required.")
|
||||
if (
|
||||
audience == Campaign.Audience.EMAIL_OPT_IN
|
||||
and not subject
|
||||
):
|
||||
form_errors.append("Subject is required for email campaigns.")
|
||||
|
||||
scheduled_for = None
|
||||
try:
|
||||
scheduled_for = parse_scheduled_for(scheduled_raw)
|
||||
except ValueError as exc:
|
||||
form_errors.append(str(exc))
|
||||
|
||||
if not form_errors:
|
||||
campaign = create_campaign_draft(
|
||||
name=name,
|
||||
audience=audience,
|
||||
subject=subject,
|
||||
body=body,
|
||||
scheduled_for=scheduled_for,
|
||||
created_by=request.user,
|
||||
template=template,
|
||||
)
|
||||
recipient_count = campaign.messages.count()
|
||||
messages.success(
|
||||
request,
|
||||
f'Draft “{campaign.name}” saved '
|
||||
f"({recipient_count} recipient"
|
||||
f"{'' if recipient_count == 1 else 's'}).",
|
||||
)
|
||||
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||
|
||||
campaigns = Campaign.objects.all()[:100]
|
||||
return render(
|
||||
request,
|
||||
"messaging/campaign_list.html",
|
||||
{
|
||||
"campaigns": campaigns,
|
||||
"audience_choices": _audience_choices(),
|
||||
"postcard_templates": _postcard_templates(),
|
||||
"form_data": form_data,
|
||||
"form_errors": form_errors,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def campaign_detail(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
ctx = _campaign_report(campaign)
|
||||
return render(
|
||||
request,
|
||||
"messaging/campaign_detail.html",
|
||||
{
|
||||
"campaign": campaign,
|
||||
"messages": ctx["messages"],
|
||||
"stats": ctx["stats"],
|
||||
"recent_events": ctx["recent_events"],
|
||||
"can_send": campaign.status
|
||||
in {
|
||||
Campaign.Status.DRAFT,
|
||||
Campaign.Status.SCHEDULED,
|
||||
Campaign.Status.SENDING,
|
||||
}
|
||||
and campaign.messages.exclude(
|
||||
status__in={"sent", "delivered", "suppressed"}
|
||||
).exists(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def campaign_status_json(request, pk):
|
||||
"""JSON snapshot for live-updating the campaign report page."""
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
ctx = _campaign_report(campaign)
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": campaign.status,
|
||||
"status_display": campaign.get_status_display(),
|
||||
"stats": ctx["stats"],
|
||||
"messages": [
|
||||
{
|
||||
"id": str(m.pk),
|
||||
"contact": str(m.contact),
|
||||
"status": m.status,
|
||||
"status_display": m.get_status_display(),
|
||||
"provider_message_id": m.provider_message_id or "",
|
||||
"error": (m.error or "")[:120],
|
||||
}
|
||||
for m in ctx["messages"]
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"event_type": e.event_type,
|
||||
"contact": str(e.message.contact) if e.message_id else "—",
|
||||
"created_at": e.created_at.isoformat(),
|
||||
}
|
||||
for e in ctx["recent_events"]
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def campaign_send(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
if campaign.status == Campaign.Status.CANCELLED:
|
||||
messages.error(request, "Cancelled campaigns cannot be sent.")
|
||||
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||
|
||||
count = enqueue_campaign_send(campaign)
|
||||
campaign.refresh_from_db()
|
||||
if count == 0:
|
||||
messages.warning(request, "No draft/scheduled/failed messages to send.")
|
||||
else:
|
||||
sent = campaign.messages.filter(status="sent").count()
|
||||
failed = campaign.messages.filter(status="failed").count()
|
||||
messages.success(
|
||||
request,
|
||||
f"Send finished for {count} message(s): {sent} sent, {failed} failed.",
|
||||
)
|
||||
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def campaign_test_send(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
to_email = (request.POST.get("test_email") or "").strip()
|
||||
if not to_email:
|
||||
messages.error(request, "Enter an email address for the test send.")
|
||||
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||
try:
|
||||
validate_email(to_email)
|
||||
except ValidationError:
|
||||
messages.error(request, "That test email address is not valid.")
|
||||
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||
|
||||
try:
|
||||
send_campaign_test_email(campaign, to_email)
|
||||
except ValueError as exc:
|
||||
messages.error(request, str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — surface SMTP misconfig to portal
|
||||
messages.error(request, f"Test send failed: {exc}")
|
||||
else:
|
||||
messages.success(request, f"Test email sent to {to_email}.")
|
||||
return redirect("messaging:campaign_detail", pk=campaign.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def postcard_designer(request):
|
||||
"""PCM Integrations designer — list designs + embed iframe."""
|
||||
api_error = ""
|
||||
designs: list[dict] = []
|
||||
embed_url = ""
|
||||
active_design_id = (request.GET.get("design_id") or "").strip()
|
||||
active_name = ""
|
||||
active_size = "46"
|
||||
|
||||
try:
|
||||
remote = list_designs(product_type="postcard")
|
||||
for item in remote:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
did = item.get("designID") or item.get("design_id") or item.get("id")
|
||||
if did is None:
|
||||
continue
|
||||
size_info = item.get("size") or {}
|
||||
size_key = (
|
||||
size_info.get("key")
|
||||
if isinstance(size_info, dict)
|
||||
else size_info
|
||||
) or ""
|
||||
designs.append(
|
||||
{
|
||||
"design_id": str(did),
|
||||
"name": item.get("friendlyName")
|
||||
or item.get("name")
|
||||
or f"Design {did}",
|
||||
"size": str(size_key),
|
||||
}
|
||||
)
|
||||
except PcmApiError as exc:
|
||||
api_error = str(exc)
|
||||
|
||||
# Merge saved local templates that may not appear in the remote page yet.
|
||||
seen = {d["design_id"] for d in designs}
|
||||
for tmpl in _postcard_templates():
|
||||
front = tmpl.postcard_front or {}
|
||||
did = front.get("design_id")
|
||||
if did is None:
|
||||
continue
|
||||
did_s = str(did)
|
||||
if did_s in seen:
|
||||
continue
|
||||
designs.insert(
|
||||
0,
|
||||
{
|
||||
"design_id": did_s,
|
||||
"name": tmpl.name,
|
||||
"size": str(front.get("size") or ""),
|
||||
},
|
||||
)
|
||||
seen.add(did_s)
|
||||
|
||||
if active_design_id:
|
||||
match = next(
|
||||
(d for d in designs if d["design_id"] == active_design_id), None
|
||||
)
|
||||
if match:
|
||||
active_name = match["name"]
|
||||
active_size = match.get("size") or "46"
|
||||
else:
|
||||
active_name = f"Design {active_design_id}"
|
||||
try:
|
||||
embed_url = get_design_embed_url(active_design_id)
|
||||
except PcmApiError as exc:
|
||||
api_error = api_error or str(exc)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"messaging/postcard_designer.html",
|
||||
{
|
||||
"api_error": api_error,
|
||||
"designs": designs,
|
||||
"embed_url": embed_url,
|
||||
"active_design_id": active_design_id,
|
||||
"active_name": active_name,
|
||||
"active_size": active_size,
|
||||
"size_choices": PCM_SIZE_CHOICES,
|
||||
"new_name": "",
|
||||
"new_size": "46",
|
||||
"saved_templates": _postcard_templates(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def postcard_design_create(request):
|
||||
name = (request.POST.get("name") or "").strip() or "Untitled postcard"
|
||||
size = (request.POST.get("size") or "46").strip()
|
||||
allowed = {code for code, _ in PCM_SIZE_CHOICES}
|
||||
if size not in allowed:
|
||||
messages.error(request, "Invalid postcard size.")
|
||||
return redirect("messaging:postcard_designer")
|
||||
try:
|
||||
data = create_custom_design(name=name, size=size)
|
||||
except PcmApiError as exc:
|
||||
messages.error(request, f"PCM create failed: {exc}")
|
||||
return redirect("messaging:postcard_designer")
|
||||
|
||||
design_id = data.get("designID") or data.get("design_id")
|
||||
if design_id is None:
|
||||
messages.error(request, "PCM did not return a design ID.")
|
||||
return redirect("messaging:postcard_designer")
|
||||
messages.success(request, f"Design {design_id} created — edit below.")
|
||||
return redirect(
|
||||
f"{reverse('messaging:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def postcard_design_save(request):
|
||||
design_id = (request.POST.get("design_id") or "").strip()
|
||||
template_name = (request.POST.get("template_name") or "").strip()
|
||||
size = (request.POST.get("size") or "").strip()
|
||||
if not design_id:
|
||||
messages.error(request, "Missing design id.")
|
||||
return redirect("messaging:postcard_designer")
|
||||
if not template_name:
|
||||
messages.error(request, "Template name is required.")
|
||||
return redirect(
|
||||
f"{reverse('messaging:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
try:
|
||||
design_id_int = int(design_id)
|
||||
except ValueError:
|
||||
messages.error(request, "Invalid design id.")
|
||||
return redirect("messaging:postcard_designer")
|
||||
|
||||
front = {
|
||||
"design_id": design_id_int,
|
||||
"size": size,
|
||||
"name": template_name,
|
||||
"provider": "pcm",
|
||||
}
|
||||
tmpl, created = MessageTemplate.objects.update_or_create(
|
||||
channel=Channel.POSTCARD,
|
||||
name=template_name,
|
||||
defaults={
|
||||
"subject": "",
|
||||
"body": f"PCM design {design_id_int}",
|
||||
"postcard_front": front,
|
||||
"postcard_back": {},
|
||||
},
|
||||
)
|
||||
verb = "Created" if created else "Updated"
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} postcard template “{tmpl.name}” (design {design_id_int}).",
|
||||
)
|
||||
return redirect(
|
||||
f"{reverse('messaging:postcard_designer')}?design_id={design_id}"
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def postcard_webhook(request):
|
||||
"""
|
||||
PCM Integrations order / mail-tracking webhook.
|
||||
|
||||
Configure in PCM → Webhooks:
|
||||
URL: https://<host>/portal/messaging/webhooks/postcard/
|
||||
Authorization: Bearer + PCM_WEBHOOK_SECRET
|
||||
Events: order / recipient status updates (Delivered, Undeliverable, …)
|
||||
"""
|
||||
if not _webhook_authorized(
|
||||
request, secret=settings.PCM_WEBHOOK_SECRET or ""
|
||||
):
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
payload = parse_webhook_payload(request)
|
||||
if not payload:
|
||||
payload = request.POST.dict() or {}
|
||||
event = process_pcm_postcard_webhook(payload)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"matched": bool(event and event.message_id),
|
||||
"event_id": event.pk if event else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def sms_webhook(request):
|
||||
"""
|
||||
SMTP2GO SMS webhook — delivery status events + inbound STOP replies.
|
||||
|
||||
Configure a *separate* webhook in SMTP2GO → Settings → Webhooks:
|
||||
URL: https://<host>/portal/messaging/webhooks/sms/
|
||||
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
|
||||
Output type: JSON
|
||||
SMS events: Submitted, Sending, Delivered, Failed, Rejected, Opt-out
|
||||
(leave Email events unchecked on this webhook)
|
||||
|
||||
Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out.
|
||||
"""
|
||||
if not _webhook_authorized(
|
||||
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
||||
):
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
|
||||
payload = parse_webhook_payload(request)
|
||||
if not payload:
|
||||
payload = request.POST.dict() or {}
|
||||
|
||||
# Inbound reply (STOP) — different payload shape than delivery events.
|
||||
if is_inbound_sms_stop(payload):
|
||||
phone = (
|
||||
payload.get("from")
|
||||
or payload.get("phone")
|
||||
or payload.get("source_number")
|
||||
or payload.get("destination_number")
|
||||
or ""
|
||||
)
|
||||
stopped = bool(phone) and record_sms_stop(str(phone))
|
||||
return JsonResponse({"ok": True, "opt_out": stopped})
|
||||
|
||||
event = process_smtp2go_sms_webhook(payload)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"matched": bool(event and event.message_id),
|
||||
"event_id": event.pk if event else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def email_webhook(request):
|
||||
"""
|
||||
SMTP2GO email event webhook (delivered / open / click / bounce / …).
|
||||
|
||||
Configure in SMTP2GO → Settings → Webhooks:
|
||||
URL: https://<host>/portal/messaging/webhooks/email/
|
||||
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
|
||||
Output type: JSON
|
||||
Email events: all delivery/engagement boxes
|
||||
Email headers: X-Monica-Message-Id
|
||||
"""
|
||||
if not _webhook_authorized(
|
||||
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
||||
):
|
||||
return HttpResponseForbidden("invalid webhook token")
|
||||
payload = parse_webhook_payload(request)
|
||||
event = process_smtp2go_email_webhook(payload)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"matched": bool(event and event.message_id),
|
||||
"event_id": event.pk if event else None,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,575 @@
|
||||
"""SMTP2GO email/SMS event webhooks → Message + ProviderEvent updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from contacts.models import Channel, Contact
|
||||
from messaging.models import Message, ProviderEvent
|
||||
from messaging.services import set_channel_consent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_EMAIL = "smtp2go_email"
|
||||
PROVIDER_SMS = "smtp2go_sms"
|
||||
PROVIDER_PCM = "pcm"
|
||||
PROVIDER = PROVIDER_EMAIL # backward-compatible alias
|
||||
|
||||
# Do not move a message backward to a weaker delivery state.
|
||||
_STATUS_RANK = {
|
||||
Message.Status.DRAFT: 0,
|
||||
Message.Status.SCHEDULED: 1,
|
||||
Message.Status.QUEUED: 2,
|
||||
Message.Status.SENT: 3,
|
||||
Message.Status.FAILED: 3,
|
||||
Message.Status.DELIVERED: 4,
|
||||
Message.Status.BOUNCED: 5,
|
||||
Message.Status.SUPPRESSED: 5,
|
||||
}
|
||||
|
||||
_MONICA_HEADER_KEYS = (
|
||||
"X-Monica-Message-Id",
|
||||
"x-monica-message-id",
|
||||
"X_Monica_Message_Id",
|
||||
"monica-message-id",
|
||||
)
|
||||
|
||||
|
||||
def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
|
||||
"""Accept JSON or form-encoded SMTP2GO webhook bodies."""
|
||||
content_type = (request.content_type or "").lower()
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
data = json.loads(request.body.decode() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
# Form-encoded (SMTP2GO default)
|
||||
return {key: request.POST.get(key) for key in request.POST.keys()}
|
||||
|
||||
|
||||
def extract_monica_message_id(payload: dict[str, Any]) -> str:
|
||||
"""Pull our correlation id from flat keys or a nested headers object."""
|
||||
for key in _MONICA_HEADER_KEYS:
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
return str(value).strip()
|
||||
|
||||
headers = payload.get("headers") or payload.get("email_headers") or {}
|
||||
if isinstance(headers, dict):
|
||||
for key in _MONICA_HEADER_KEYS:
|
||||
value = headers.get(key)
|
||||
if value:
|
||||
return str(value).strip()
|
||||
# Case-insensitive scan
|
||||
lower_map = {str(k).lower(): v for k, v in headers.items()}
|
||||
for key in _MONICA_HEADER_KEYS:
|
||||
value = lower_map.get(key.lower())
|
||||
if value:
|
||||
return str(value).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
|
||||
monica_id = extract_monica_message_id(payload)
|
||||
if monica_id:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(pk=monica_id)
|
||||
.first()
|
||||
)
|
||||
if message:
|
||||
return message
|
||||
|
||||
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip()
|
||||
if email_id:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(provider_message_id=email_id)
|
||||
.first()
|
||||
)
|
||||
if message:
|
||||
return message
|
||||
|
||||
rcpt = (payload.get("rcpt") or "").strip().lower()
|
||||
if not rcpt:
|
||||
recipients = payload.get("recipients")
|
||||
if isinstance(recipients, str) and recipients.strip():
|
||||
rcpt = recipients.split(",")[0].strip().lower()
|
||||
elif isinstance(recipients, list) and recipients:
|
||||
rcpt = str(recipients[0]).strip().lower()
|
||||
|
||||
if not rcpt:
|
||||
return None
|
||||
|
||||
contact = Contact.objects.filter(email__iexact=rcpt).first()
|
||||
if not contact:
|
||||
return None
|
||||
|
||||
return (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(
|
||||
contact=contact,
|
||||
channel=Channel.EMAIL,
|
||||
status__in=[
|
||||
Message.Status.QUEUED,
|
||||
Message.Status.SENT,
|
||||
Message.Status.DELIVERED,
|
||||
Message.Status.FAILED,
|
||||
Message.Status.BOUNCED,
|
||||
],
|
||||
)
|
||||
.order_by("-sent_at", "-updated_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "") -> None:
|
||||
current_rank = _STATUS_RANK.get(message.status, 0)
|
||||
new_rank = _STATUS_RANK.get(new_status, 0)
|
||||
# Always allow bounce/suppress to overwrite delivered; allow delivered over sent.
|
||||
if new_rank < current_rank and new_status not in {
|
||||
Message.Status.BOUNCED,
|
||||
Message.Status.SUPPRESSED,
|
||||
Message.Status.FAILED,
|
||||
}:
|
||||
return
|
||||
if (
|
||||
message.status
|
||||
in {Message.Status.BOUNCED, Message.Status.SUPPRESSED}
|
||||
and new_status == Message.Status.DELIVERED
|
||||
):
|
||||
return
|
||||
|
||||
fields = ["status", "updated_at"]
|
||||
message.status = new_status
|
||||
if error:
|
||||
message.error = error[:2000]
|
||||
fields.append("error")
|
||||
elif new_status == Message.Status.DELIVERED:
|
||||
message.error = ""
|
||||
fields.append("error")
|
||||
message.save(update_fields=fields)
|
||||
|
||||
|
||||
def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None:
|
||||
event = (event or "").strip().lower()
|
||||
bounce_kind = (payload.get("bounce") or "").strip().lower()
|
||||
err = (payload.get("message") or payload.get("context") or "").strip()
|
||||
|
||||
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip()
|
||||
if email_id and message.provider_message_id != email_id:
|
||||
message.provider_message_id = email_id
|
||||
message.provider = PROVIDER_EMAIL
|
||||
message.save(
|
||||
update_fields=["provider_message_id", "provider", "updated_at"]
|
||||
)
|
||||
|
||||
if event == "processed":
|
||||
if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}:
|
||||
_maybe_upgrade_status(message, Message.Status.SENT)
|
||||
return
|
||||
|
||||
if event == "delivered":
|
||||
_maybe_upgrade_status(message, Message.Status.DELIVERED)
|
||||
return
|
||||
|
||||
if event == "bounce":
|
||||
status = Message.Status.BOUNCED
|
||||
_maybe_upgrade_status(
|
||||
message,
|
||||
status,
|
||||
error=err or f"{bounce_kind or 'unknown'} bounce",
|
||||
)
|
||||
if bounce_kind == "hard":
|
||||
set_channel_consent(
|
||||
message.contact,
|
||||
Channel.EMAIL,
|
||||
opted_in=False,
|
||||
reason="smtp2go_hard_bounce",
|
||||
)
|
||||
return
|
||||
|
||||
if event == "reject":
|
||||
_maybe_upgrade_status(
|
||||
message, Message.Status.FAILED, error=err or "rejected by provider"
|
||||
)
|
||||
return
|
||||
|
||||
if event == "spam":
|
||||
_maybe_upgrade_status(
|
||||
message, Message.Status.SUPPRESSED, error=err or "spam complaint"
|
||||
)
|
||||
set_channel_consent(
|
||||
message.contact,
|
||||
Channel.EMAIL,
|
||||
opted_in=False,
|
||||
reason="smtp2go_spam",
|
||||
)
|
||||
return
|
||||
|
||||
if event == "unsubscribe":
|
||||
_maybe_upgrade_status(
|
||||
message, Message.Status.SUPPRESSED, error="provider unsubscribe"
|
||||
)
|
||||
set_channel_consent(
|
||||
message.contact,
|
||||
Channel.EMAIL,
|
||||
opted_in=False,
|
||||
reason="smtp2go_unsubscribe",
|
||||
)
|
||||
return
|
||||
|
||||
# open / click / resubscribe — event row only (status unchanged)
|
||||
|
||||
|
||||
def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
|
||||
"""
|
||||
Persist ProviderEvent and update Message delivery status when possible.
|
||||
|
||||
Returns the stored event (even if message could not be matched).
|
||||
"""
|
||||
event = (payload.get("event") or "").strip().lower()
|
||||
if not event:
|
||||
logger.warning("SMTP2GO webhook missing event: %s", payload)
|
||||
return None
|
||||
|
||||
message = find_message_for_email_event(payload)
|
||||
if message:
|
||||
_apply_email_event(message, event, payload)
|
||||
message.refresh_from_db()
|
||||
else:
|
||||
logger.info(
|
||||
"SMTP2GO webhook unmatched event=%s rcpt=%s email_id=%s",
|
||||
event,
|
||||
payload.get("rcpt"),
|
||||
payload.get("email_id"),
|
||||
)
|
||||
|
||||
return ProviderEvent.objects.create(
|
||||
message=message,
|
||||
provider=PROVIDER_EMAIL,
|
||||
event_type=event,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def normalize_phone(value: str) -> str:
|
||||
return "".join(ch for ch in (value or "") if ch.isdigit())
|
||||
|
||||
|
||||
def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None:
|
||||
provider_id = (
|
||||
payload.get("message_id")
|
||||
or payload.get("sms_id")
|
||||
or payload.get("id")
|
||||
or ""
|
||||
)
|
||||
provider_id = str(provider_id).strip()
|
||||
if provider_id:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(channel=Channel.SMS, provider_message_id=provider_id)
|
||||
.first()
|
||||
)
|
||||
if message:
|
||||
return message
|
||||
|
||||
raw_phone = (
|
||||
payload.get("destination_number")
|
||||
or payload.get("to")
|
||||
or payload.get("phone")
|
||||
or payload.get("from")
|
||||
or ""
|
||||
)
|
||||
digits = normalize_phone(str(raw_phone))
|
||||
if len(digits) < 7:
|
||||
return None
|
||||
|
||||
# Match last 10 digits so +1 / formatting differences still hit.
|
||||
tail = digits[-10:]
|
||||
contacts = Contact.objects.exclude(phone="").only("id", "phone")
|
||||
contact = None
|
||||
for row in contacts.iterator():
|
||||
if normalize_phone(row.phone).endswith(tail):
|
||||
contact = row
|
||||
break
|
||||
if not contact:
|
||||
return None
|
||||
|
||||
return (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(
|
||||
contact=contact,
|
||||
channel=Channel.SMS,
|
||||
status__in=[
|
||||
Message.Status.QUEUED,
|
||||
Message.Status.SENT,
|
||||
Message.Status.DELIVERED,
|
||||
Message.Status.FAILED,
|
||||
],
|
||||
)
|
||||
.order_by("-sent_at", "-updated_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> None:
|
||||
event = (event or "").strip().lower().replace("-", "_")
|
||||
err = (
|
||||
payload.get("message")
|
||||
or payload.get("status_code")
|
||||
or payload.get("context")
|
||||
or ""
|
||||
)
|
||||
err = str(err).strip()
|
||||
|
||||
provider_id = (
|
||||
payload.get("message_id") or payload.get("sms_id") or ""
|
||||
)
|
||||
provider_id = str(provider_id).strip()
|
||||
if provider_id and message.provider_message_id != provider_id:
|
||||
message.provider_message_id = provider_id
|
||||
message.provider = PROVIDER_SMS
|
||||
message.save(
|
||||
update_fields=["provider_message_id", "provider", "updated_at"]
|
||||
)
|
||||
|
||||
if event in {"sms_sending", "sending", "sms_submitted", "submitted"}:
|
||||
if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}:
|
||||
_maybe_upgrade_status(message, Message.Status.SENT)
|
||||
return
|
||||
|
||||
if event in {"sms_delivered", "delivered"}:
|
||||
_maybe_upgrade_status(message, Message.Status.DELIVERED)
|
||||
return
|
||||
|
||||
if event in {"sms_failed", "failed", "sms_rejected", "rejected"}:
|
||||
_maybe_upgrade_status(
|
||||
message,
|
||||
Message.Status.FAILED,
|
||||
error=err or event,
|
||||
)
|
||||
return
|
||||
|
||||
if event in {"sms_opt_out", "opt_out", "optout"}:
|
||||
_maybe_upgrade_status(
|
||||
message, Message.Status.SUPPRESSED, error="sms opt-out"
|
||||
)
|
||||
set_channel_consent(
|
||||
message.contact,
|
||||
Channel.SMS,
|
||||
opted_in=False,
|
||||
reason="smtp2go_sms_opt_out",
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
|
||||
"""Persist SMS delivery/opt-out ProviderEvent and update Message when matched."""
|
||||
event = (payload.get("event") or "").strip().lower()
|
||||
if not event:
|
||||
logger.warning("SMTP2GO SMS webhook missing event: %s", payload)
|
||||
return None
|
||||
|
||||
message = find_message_for_sms_event(payload)
|
||||
if message:
|
||||
_apply_sms_event(message, event, payload)
|
||||
message.refresh_from_db()
|
||||
else:
|
||||
# Opt-out with no matched campaign message still suppresses by phone.
|
||||
if event.replace("-", "_") in {"sms_opt_out", "opt_out", "optout"}:
|
||||
phone = (
|
||||
payload.get("destination_number")
|
||||
or payload.get("from")
|
||||
or payload.get("source_number")
|
||||
or ""
|
||||
)
|
||||
if phone:
|
||||
from messaging.services import record_sms_stop
|
||||
|
||||
record_sms_stop(str(phone))
|
||||
logger.info(
|
||||
"SMTP2GO SMS webhook unmatched event=%s phone=%s message_id=%s",
|
||||
event,
|
||||
payload.get("destination_number"),
|
||||
payload.get("message_id"),
|
||||
)
|
||||
|
||||
return ProviderEvent.objects.create(
|
||||
message=message,
|
||||
provider=PROVIDER_SMS,
|
||||
event_type=event,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def is_inbound_sms_stop(payload: dict[str, Any]) -> bool:
|
||||
"""True for gateway-style inbound reply payloads (STOP / UNSUBSCRIBE)."""
|
||||
if payload.get("event"):
|
||||
return False
|
||||
text = (
|
||||
payload.get("text")
|
||||
or payload.get("message")
|
||||
or payload.get("message_content")
|
||||
or ""
|
||||
)
|
||||
text = str(text).strip().upper()
|
||||
return text in {"STOP", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"}
|
||||
|
||||
|
||||
def _pcm_event_type(payload: dict[str, Any]) -> str:
|
||||
for key in ("event", "eventType", "event_type", "type", "status"):
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
return str(value).strip()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def find_message_for_pcm_event(payload: dict[str, Any]) -> Message | None:
|
||||
"""Correlate PCM webhook to Message via extRefNbr or orderID."""
|
||||
ext = (
|
||||
payload.get("extRefNbr")
|
||||
or payload.get("ext_ref_nbr")
|
||||
or payload.get("externalReference")
|
||||
or ""
|
||||
)
|
||||
if not ext and isinstance(payload.get("recipient"), dict):
|
||||
ext = payload["recipient"].get("extRefNbr") or ""
|
||||
ext = str(ext).strip()
|
||||
if ext:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(pk=ext)
|
||||
.first()
|
||||
)
|
||||
if message:
|
||||
return message
|
||||
|
||||
order_id = (
|
||||
payload.get("orderID")
|
||||
or payload.get("orderId")
|
||||
or payload.get("order_id")
|
||||
or ""
|
||||
)
|
||||
order_id = str(order_id).strip()
|
||||
if order_id:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
.filter(provider_message_id=order_id, channel=Channel.POSTCARD)
|
||||
.first()
|
||||
)
|
||||
if message:
|
||||
return message
|
||||
return None
|
||||
|
||||
|
||||
def _apply_pcm_status(message: Message, status: str, payload: dict[str, Any]) -> None:
|
||||
status_norm = (status or "").strip().lower()
|
||||
err = (
|
||||
payload.get("message")
|
||||
or payload.get("error")
|
||||
or payload.get("reason")
|
||||
or ""
|
||||
)
|
||||
err = str(err).strip()
|
||||
|
||||
order_id = (
|
||||
payload.get("orderID")
|
||||
or payload.get("orderId")
|
||||
or payload.get("order_id")
|
||||
or ""
|
||||
)
|
||||
if order_id and message.provider_message_id != str(order_id):
|
||||
message.provider_message_id = str(order_id)
|
||||
message.provider = PROVIDER_PCM
|
||||
message.save(
|
||||
update_fields=["provider_message_id", "provider", "updated_at"]
|
||||
)
|
||||
|
||||
if status_norm in {"delivered"}:
|
||||
_maybe_upgrade_status(message, Message.Status.DELIVERED)
|
||||
return
|
||||
if status_norm in {"undeliverable", "returned"}:
|
||||
_maybe_upgrade_status(
|
||||
message,
|
||||
Message.Status.BOUNCED,
|
||||
error=err or "undeliverable",
|
||||
)
|
||||
return
|
||||
if status_norm in {"canceled", "cancelled"}:
|
||||
_maybe_upgrade_status(
|
||||
message, Message.Status.FAILED, error=err or "canceled"
|
||||
)
|
||||
return
|
||||
if status_norm in {"pending", "processing", "processed", "mailed", "intransit", "in_transit"}:
|
||||
if message.status in {
|
||||
Message.Status.QUEUED,
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
}:
|
||||
_maybe_upgrade_status(message, Message.Status.SENT)
|
||||
return
|
||||
|
||||
|
||||
def process_pcm_postcard_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
|
||||
"""Record a PCM Integrations postcard event and advance Message status."""
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
# Nested data wrappers some webhook UIs use.
|
||||
if "data" in payload and isinstance(payload["data"], dict):
|
||||
inner = dict(payload["data"])
|
||||
for key in ("event", "eventType", "type"):
|
||||
if key in payload and key not in inner:
|
||||
inner[key] = payload[key]
|
||||
payload = inner
|
||||
|
||||
event_type = _pcm_event_type(payload)
|
||||
message = find_message_for_pcm_event(payload)
|
||||
if message:
|
||||
status_for_apply = (
|
||||
payload.get("status")
|
||||
or payload.get("orderStatus")
|
||||
or event_type
|
||||
)
|
||||
_apply_pcm_status(message, str(status_for_apply), payload)
|
||||
|
||||
return ProviderEvent.objects.create(
|
||||
message=message,
|
||||
provider=PROVIDER_PCM,
|
||||
event_type=event_type[:64],
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def campaign_engagement_stats(campaign) -> dict[str, int]:
|
||||
"""Aggregate delivery + open/click counts for the campaign report."""
|
||||
messages_qs = campaign.messages.all()
|
||||
statuses = list(messages_qs.values_list("status", flat=True))
|
||||
message_ids = list(messages_qs.values_list("pk", flat=True))
|
||||
|
||||
events = ProviderEvent.objects.filter(message_id__in=message_ids)
|
||||
open_message_ids = set(
|
||||
events.filter(event_type__iexact="open").values_list("message_id", flat=True)
|
||||
)
|
||||
click_message_ids = set(
|
||||
events.filter(event_type__iexact="click").values_list("message_id", flat=True)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": len(statuses),
|
||||
"sent": sum(1 for s in statuses if s in {"sent", "delivered"}),
|
||||
"delivered": sum(1 for s in statuses if s == "delivered"),
|
||||
"failed": sum(1 for s in statuses if s in {"failed", "bounced"}),
|
||||
"bounced": sum(1 for s in statuses if s == "bounced"),
|
||||
"suppressed": sum(1 for s in statuses if s == "suppressed"),
|
||||
"opens": len(open_message_ids),
|
||||
"clicks": len(click_message_ids),
|
||||
"open_events": events.filter(event_type__iexact="open").count(),
|
||||
"click_events": events.filter(event_type__iexact="click").count(),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""ASGI config for monica_site."""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "monica_site.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Load environment-specific Django settings based on DJANGO_ENV."""
|
||||
|
||||
import os
|
||||
|
||||
_environment = os.environ.get("DJANGO_ENV", "dev").lower()
|
||||
|
||||
if _environment == "prod":
|
||||
from .prod import * # noqa: F403
|
||||
elif _environment == "beta":
|
||||
from .beta import * # noqa: F403
|
||||
else:
|
||||
from .dev import * # noqa: F403
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Shared Django settings for all environments."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def env(key: str, default: str | None = None) -> str | None:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def env_bool(key: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def env_list(key: str, default: str = "") -> list[str]:
|
||||
value = os.environ.get(key, default)
|
||||
if not value:
|
||||
return []
|
||||
value = value.strip()
|
||||
if value.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def database_config() -> dict:
|
||||
database_url = env("DATABASE_URL")
|
||||
if database_url:
|
||||
parsed = urlparse(database_url)
|
||||
return {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": parsed.path.lstrip("/"),
|
||||
"USER": parsed.username or "",
|
||||
"PASSWORD": parsed.password or "",
|
||||
"HOST": parsed.hostname or "",
|
||||
"PORT": str(parsed.port or 5432),
|
||||
}
|
||||
}
|
||||
|
||||
if env("DB_HOST"):
|
||||
return {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": env("DB_NAME", "monica_site"),
|
||||
"USER": env("DB_USER", "monica_site"),
|
||||
"PASSWORD": env("DB_PASSWORD", ""),
|
||||
"HOST": env("DB_HOST"),
|
||||
"PORT": env("DB_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def build_csrf_trusted_origins(
|
||||
allowed_hosts: list[str], explicit: list[str] | None = None
|
||||
) -> list[str]:
|
||||
"""Build CSRF_TRUSTED_ORIGINS for Django 4+ Origin checks on HTTPS POSTs."""
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"}
|
||||
origins: list[str] = []
|
||||
for host in allowed_hosts:
|
||||
if not host or host == "*" or host.startswith("."):
|
||||
continue
|
||||
hostname = host.split(":")[0]
|
||||
scheme = "http" if hostname in local_hosts else "https"
|
||||
origins.append(f"{scheme}://{host}")
|
||||
return origins
|
||||
|
||||
|
||||
SECRET_KEY = env(
|
||||
"DJANGO_SECRET_KEY",
|
||||
"django-insecure-dev-only-change-me-before-production",
|
||||
)
|
||||
|
||||
DEBUG = env_bool("DJANGO_DEBUG", False)
|
||||
|
||||
allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*")
|
||||
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins(
|
||||
ALLOWED_HOSTS,
|
||||
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
|
||||
)
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"core.apps.CoreConfig",
|
||||
"public.apps.PublicConfig",
|
||||
"accounts.apps.AccountsConfig",
|
||||
"dashboard.apps.DashboardConfig",
|
||||
"leads.apps.LeadsConfig",
|
||||
"contacts.apps.ContactsConfig",
|
||||
"analytics.apps.AnalyticsConfig",
|
||||
"messaging.apps.MessagingConfig",
|
||||
"social.apps.SocialConfig",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
"phonenumber_field",
|
||||
"django_recaptcha",
|
||||
"dj_queue",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"analytics.middleware.UTMTrackingMiddleware",
|
||||
"public.middleware.UnderConstructionMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "monica_site.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "monica_site" / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"public.context_processors.site_branding",
|
||||
"public.context_processors.tianji_tracking",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "monica_site.wsgi.application"
|
||||
|
||||
DATABASES = database_config()
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "America/Chicago"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "monica_site" / "static",
|
||||
]
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGIN_URL = "accounts:login"
|
||||
LOGIN_REDIRECT_URL = "dashboard:home"
|
||||
LOGOUT_REDIRECT_URL = "accounts:login"
|
||||
|
||||
# --- Feature gates ---
|
||||
# When true, public visitors only see the under-construction page.
|
||||
# Prod launch holding page: SITE_UNDER_CONSTRUCTION=true
|
||||
# Beta (full app): SITE_UNDER_CONSTRUCTION=false
|
||||
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False)
|
||||
|
||||
# --- Branding / public site ---
|
||||
SITE_NAME = env("SITE_NAME", "Monica Dhillon")
|
||||
SITE_TAGLINE = env("SITE_TAGLINE", "MKDRealtor.com · EXIT Realty Redefined")
|
||||
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "")
|
||||
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", "noreply@mkdrealtor.com")
|
||||
CONTACT_PHONE = env("CONTACT_PHONE", "(555) 123-4567")
|
||||
CONTACT_EMAIL = env("CONTACT_EMAIL", "monica@example.com")
|
||||
CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "Serving Greater Metro Area")
|
||||
CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations, LLC")
|
||||
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com")
|
||||
|
||||
# --- reCAPTCHA v3 ---
|
||||
RECAPTCHA_PUBLIC_KEY = env("RECAPTCHA_PUBLIC_KEY", "")
|
||||
RECAPTCHA_PRIVATE_KEY = env("RECAPTCHA_PRIVATE_KEY", "")
|
||||
|
||||
# --- Email (SMTP2GO) ---
|
||||
EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com")
|
||||
EMAIL_HOST_USER = env("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "")
|
||||
EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525")
|
||||
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
|
||||
EMAIL_BACKEND = env(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend",
|
||||
)
|
||||
|
||||
# --- SMTP2GO SMS ---
|
||||
SMTP2GO_SMS_API_KEY = env("SMTP2GO_SMS_API_KEY", "")
|
||||
SMTP2GO_SMS_API_URL = env(
|
||||
"SMTP2GO_SMS_API_URL",
|
||||
"https://api.smtp2go.com/v3/sms/send",
|
||||
)
|
||||
# Optional shared secret: ?token=… or Authorization: Bearer …
|
||||
SMTP2GO_WEBHOOK_SECRET = env("SMTP2GO_WEBHOOK_SECRET", "")
|
||||
|
||||
# --- Postcards (PCM Integrations default) ---
|
||||
POSTCARD_PROVIDER = env("POSTCARD_PROVIDER", "pcm")
|
||||
PCM_API_KEY = env("PCM_API_KEY", "")
|
||||
# Shared secret for inbound PCM event webhooks (Bearer / ?token=).
|
||||
PCM_WEBHOOK_SECRET = env("PCM_WEBHOOK_SECRET", "")
|
||||
# JSON object: company, firstName, lastName, address, address2, city, state, zipCode
|
||||
PCM_RETURN_ADDRESS = env("PCM_RETURN_ADDRESS", "")
|
||||
PCM_RETURN_LINE1 = env("PCM_RETURN_LINE1", "")
|
||||
PCM_RETURN_LINE2 = env("PCM_RETURN_LINE2", "")
|
||||
PCM_RETURN_CITY = env("PCM_RETURN_CITY", "")
|
||||
PCM_RETURN_STATE = env("PCM_RETURN_STATE", "")
|
||||
PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "")
|
||||
# Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid)
|
||||
LOB_API_KEY = env("LOB_API_KEY", "")
|
||||
CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "")
|
||||
POSTGRID_API_KEY = env("POSTGRID_API_KEY", "")
|
||||
|
||||
# --- Social (native APIs) ---
|
||||
META_APP_ID = env("META_APP_ID", "")
|
||||
META_APP_SECRET = env("META_APP_SECRET", "")
|
||||
LINKEDIN_CLIENT_ID = env("LINKEDIN_CLIENT_ID", "")
|
||||
LINKEDIN_CLIENT_SECRET = env("LINKEDIN_CLIENT_SECRET", "")
|
||||
SOCIAL_TOKEN_ENCRYPTION_KEY = env("SOCIAL_TOKEN_ENCRYPTION_KEY", "")
|
||||
|
||||
# --- Ollama (social post drafting) ---
|
||||
# Host-network reachable from app hosts; default matches internal LAN Ollama.
|
||||
OLLAMA_BASE_URL = env("OLLAMA_BASE_URL", "http://10.0.0.128:11434")
|
||||
OLLAMA_MODEL = env("OLLAMA_MODEL", "llama3.2")
|
||||
OLLAMA_TIMEOUT_SECONDS = int(env("OLLAMA_TIMEOUT_SECONDS", "120") or "120")
|
||||
|
||||
# --- Nominatim (address suggest; server-side proxy only) ---
|
||||
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
|
||||
# LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as
|
||||
# X-API-Key if you later put auth in front of Nominatim.
|
||||
NOMINATIM_BASE_URL = env("NOMINATIM_BASE_URL", "http://10.0.0.128:8089")
|
||||
NOMINATIM_TIMEOUT_SECONDS = int(env("NOMINATIM_TIMEOUT_SECONDS", "8") or "8")
|
||||
NOMINATIM_COUNTRY_CODES = env("NOMINATIM_COUNTRY_CODES", "us")
|
||||
NOMINATIM_USER_AGENT = env(
|
||||
"NOMINATIM_USER_AGENT",
|
||||
"monica_site/1.0 (address-suggest; contact admin)",
|
||||
)
|
||||
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "")
|
||||
|
||||
# --- Tianji analytics (pageviews + events; notice banner, not opt-in) ---
|
||||
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
|
||||
TIANJI_TRACKER_URL = env(
|
||||
"TIANJI_TRACKER_URL",
|
||||
"https://tianji.aimloperations.com/tracker.js",
|
||||
)
|
||||
TIANJI_WEBSITE_ID = env("TIANJI_WEBSITE_ID", "cmshzhxdf6gee10qzkzrfn9iw")
|
||||
|
||||
# --- Django Tasks ---
|
||||
# Dev: ImmediateBackend (settings/dev.py). Beta/prod: dj-queue (settings/beta.py|prod.py).
|
||||
TASKS = {
|
||||
"default": {
|
||||
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Beta/staging settings — full functionality for preview."""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .logging import build_logging_config, logging_level_for_env
|
||||
|
||||
DEBUG = env_bool("DJANGO_DEBUG", False) # noqa: F405
|
||||
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
|
||||
|
||||
if DEBUG:
|
||||
import warnings
|
||||
|
||||
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1)
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
|
||||
# Beta always runs the full app unless explicitly overridden.
|
||||
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False) # noqa: F405
|
||||
|
||||
DATABASE_ROUTERS = ["dj_queue.routers.DjQueueRouter"]
|
||||
TASKS = {
|
||||
"default": {
|
||||
"BACKEND": "dj_queue.backend.DjQueueBackend",
|
||||
"QUEUES": [],
|
||||
"OPTIONS": {},
|
||||
},
|
||||
}
|
||||
|
||||
LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Development settings."""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .logging import build_logging_config, logging_level_for_env
|
||||
|
||||
DEBUG = True
|
||||
|
||||
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False) # noqa: F405
|
||||
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405
|
||||
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
EMAIL_BACKEND = env( # noqa: F405
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.console.EmailBackend",
|
||||
)
|
||||
|
||||
# ALLOWED_HOSTS default "*" → empty CSRF_TRUSTED_ORIGINS, which breaks browser
|
||||
# Origin checks on POST. Always trust local runserver origins in dev.
|
||||
_local_csrf = [
|
||||
"http://127.0.0.1:8000",
|
||||
"http://localhost:8000",
|
||||
"http://0.0.0.0:8000",
|
||||
"http://127.0.0.1:8001",
|
||||
"http://localhost:8001",
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = list(
|
||||
dict.fromkeys([*_local_csrf, *CSRF_TRUSTED_ORIGINS]) # noqa: F405
|
||||
)
|
||||
|
||||
LOGGING = build_logging_config(logging_level_for_env("dev"), "dev")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Environment-specific logging configuration."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def build_logging_config(level: str, environment: str) -> dict:
|
||||
"""Return a Django LOGGING dict for the given level and environment name."""
|
||||
return {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": (
|
||||
f"{{levelname}} {{asctime}} {{name}} {{process:d}} {{thread:d}} "
|
||||
f"[env={environment}] {{message}}"
|
||||
),
|
||||
"style": "{",
|
||||
},
|
||||
"simple": {
|
||||
"format": f"{{levelname}} [env={environment}] {{message}}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose" if environment == "dev" else "simple",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": level,
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["console"],
|
||||
"level": level,
|
||||
"propagate": False,
|
||||
},
|
||||
"django.request": {
|
||||
"handlers": ["console"],
|
||||
"level": "ERROR" if environment == "prod" else level,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def logging_level_for_env(environment: str) -> str:
|
||||
override = os.environ.get("DJANGO_LOG_LEVEL")
|
||||
if override:
|
||||
return override.upper()
|
||||
|
||||
if environment == "dev":
|
||||
return "DEBUG"
|
||||
if environment == "beta":
|
||||
return "INFO"
|
||||
return "WARNING"
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Production settings."""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .logging import build_logging_config, logging_level_for_env
|
||||
|
||||
DEBUG = False
|
||||
|
||||
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
|
||||
|
||||
if not env("DJANGO_SECRET_KEY"): # noqa: F405
|
||||
raise ValueError("DJANGO_SECRET_KEY must be set in production.")
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
# Prod holds visitors on under-construction until launch (set false when ready).
|
||||
if env("SITE_UNDER_CONSTRUCTION") is None: # noqa: F405
|
||||
SITE_UNDER_CONSTRUCTION = True # noqa: F405
|
||||
|
||||
DATABASE_ROUTERS = ["dj_queue.routers.DjQueueRouter"]
|
||||
TASKS = {
|
||||
"default": {
|
||||
"BACKEND": "dj_queue.backend.DjQueueBackend",
|
||||
"QUEUES": [],
|
||||
"OPTIONS": {},
|
||||
},
|
||||
}
|
||||
|
||||
LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user