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

Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
2026-08-08 07:32:55 -05:00
parent 7dca98bbf6
commit 1f7d78de64
204 changed files with 21662 additions and 70 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.venv
**/__pycache__/
*.py[cod]
db.sqlite3
.env
htmlcov/
.pytest_cache/
.mypy_cache/
*.log
staticfiles/
proposal/
template/
docs/
*.pptx
.~lock.*
+81
View File
@@ -0,0 +1,81 @@
# Local development defaults. Copy to `.env` (gitignored) — never commit secrets.
# docker compose auto-loads `.env` for ${VAR} substitution into the web container.
DJANGO_ENV=dev
DJANGO_DEBUG=true
DJANGO_SECRET_KEY=dev-only-change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Leave empty for SQLite, or point at local compose Postgres:
# DATABASE_URL=postgres://monica_site:monica_site@127.0.0.1:5432/monica_site
SITE_UNDER_CONSTRUCTION=false
SITE_NAME=Monica Dhillon
SITE_TAGLINE=MKDRealtor.com · EXIT Realty
PUBLIC_SITE_URL=http://127.0.0.1:8000
CONTACT_PHONE=(630) 452-4443
CONTACT_EMAIL=moni.dhill@gmail.com
CONTACT_SERVICE_AREA=Serving Chicagoland
CREDIT_NAME=AI ML Operations, LLC
CREDIT_URL=https://aimloperations.com
# reCAPTCHA (optional locally — form skips captcha when empty)
RECAPTCHA_PUBLIC_KEY=
RECAPTCHA_PRIVATE_KEY=
# SMTP2GO — fill in `.env` (not this file). Dev defaults to console backend
# until you set EMAIL_BACKEND to the SMTP backend below.
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=2525
EMAIL_USE_TLS=true
DEFAULT_FROM_EMAIL=noreply@mkdrealtor.com
# Console (default in DJANGO_ENV=dev) prints mail to container logs.
# For real SMTP2GO delivery locally, uncomment:
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
SMTP2GO_SMS_API_KEY=
# Shared secret for email + SMS webhooks.
# In SMTP2GO: Authorization header = Bearer, value = this secret.
SMTP2GO_WEBHOOK_SECRET=
# Postcards — PCM Integrations (DirectMail API v3)
POSTCARD_PROVIDER=pcm
PCM_API_KEY=
# Inbound webhook auth (Bearer / ?token=) — set a long random string
PCM_WEBHOOK_SECRET=
# Return address on every postcard order (JSON). Example:
# PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"}
PCM_RETURN_ADDRESS=
# Or set fields individually if JSON is empty:
# PCM_RETURN_LINE1=
# PCM_RETURN_CITY=
# PCM_RETURN_STATE=
# PCM_RETURN_ZIP=
# Social
META_APP_ID=
META_APP_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
SOCIAL_TOKEN_ENCRYPTION_KEY=
# Ollama (LAN) — standard Ollama HTTP port
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
# Nominatim (LAN) — address autocomplete via Django /api/address-suggest/
# Nominatim has no built-in API keys; optional NOMINATIM_API_KEY only if you
# put a gateway in front that checks X-API-Key.
NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8
NOMINATIM_COUNTRY_CODES=us
# NOMINATIM_API_KEY=
# Tianji analytics (off in local/dev by default)
TIANJI_ENABLED=false
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=cmshzhxdf6gee10qzkzrfn9iw
GUNICORN_WORKERS=2
+113
View File
@@ -0,0 +1,113 @@
# Secret env files for server-infra deploy.
# Copy to the control node (never commit):
# ~/Documents/secrets/monica_site/monica_site_prod.env
# ~/Documents/secrets/monica_site/monica_site_beta.env
#
# server-infra pushes these to /opt/apps/env/monica_site_<env>.env on each host.
#
# Docker Compose: if a secret contains $ (e.g. in DATABASE_URL password), escape each
# $ as $$ or compose will treat $word as a variable.
# =============================================================================
# PROD — mkdrealtor.com (holding page until launch)
# =============================================================================
DJANGO_ENV=prod
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=mkdrealtor.com,www.mkdrealtor.com
# Optional override; when unset, https:// origins are derived from DJANGO_ALLOWED_HOSTS.
# DJANGO_CSRF_TRUSTED_ORIGINS=https://mkdrealtor.com,https://www.mkdrealtor.com
# Holding page gate — keep true on prod until go-live; beta should be false.
SITE_UNDER_CONSTRUCTION=true
SITE_NAME=Monica Dhillon
SITE_TAGLINE=MKDRealtor.com · EXIT Realty Redefined
PUBLIC_SITE_URL=https://mkdrealtor.com
DEFAULT_FROM_EMAIL=noreply@mkdrealtor.com
CONTACT_PHONE=
CONTACT_EMAIL=
CONTACT_SERVICE_AREA=Serving Greater Metro Area
CREDIT_NAME=AI ML Operations, LLC
CREDIT_URL=https://aimloperations.com
# Shared external Postgres (10.0.0.230)
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/monica_site
# Host port on adama/roslin/ai-server-4080 (must match server-infra host_apps)
WEB_PORT=8004
# reCAPTCHA v3
RECAPTCHA_PUBLIC_KEY=replace-me
RECAPTCHA_PRIVATE_KEY=replace-me
# SMTP2GO email
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=replace-me
EMAIL_HOST_PASSWORD=replace-me
EMAIL_PORT=2525
EMAIL_USE_TLS=true
# SMTP2GO SMS
SMTP2GO_SMS_API_KEY=replace-me
# Email + SMS event webhook auth. SMTP2GO Authorization header = Bearer + this value.
SMTP2GO_WEBHOOK_SECRET=replace-me
# Postcards — PCM Integrations
POSTCARD_PROVIDER=pcm
PCM_API_KEY=replace-me
PCM_WEBHOOK_SECRET=replace-me
# JSON return address for postcard orders
PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"}
# LOB_API_KEY= # only if POSTCARD_PROVIDER=lob
# CLICK2MAIL_API_KEY=
# POSTGRID_API_KEY=
# Social (native)
META_APP_ID=
META_APP_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key
# Ollama for social drafting (reachable from app hosts)
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
OLLAMA_TIMEOUT_SECONDS=120
# Nominatim address suggest (server-side proxy only; not called from browser)
NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8
NOMINATIM_COUNTRY_CODES=us
# NOMINATIM_API_KEY=
# Tianji analytics (pageviews + events)
TIANJI_ENABLED=true
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=cmshzhxdf6gee10qzkzrfn9iw
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
# =============================================================================
# BETA overrides — monica-preview.aimloperations.com (full app)
# File: monica_site_beta.env
# =============================================================================
# DJANGO_ENV=beta
# DJANGO_DEBUG=false
# DJANGO_SECRET_KEY=replace-with-a-different-beta-secret
# DJANGO_ALLOWED_HOSTS=monica-preview.aimloperations.com
# SITE_UNDER_CONSTRUCTION=false
# PUBLIC_SITE_URL=https://monica-preview.aimloperations.com
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/monica_site_beta
# WEB_PORT=8014
# RECAPTCHA_PUBLIC_KEY=replace-me
# RECAPTCHA_PRIVATE_KEY=replace-me
# EMAIL_HOST_USER=replace-me
# EMAIL_HOST_PASSWORD=replace-me
# SMTP2GO_SMS_API_KEY=replace-me
# LOB_API_KEY=replace-me
# SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key
# OLLAMA_BASE_URL=http://10.0.0.128:11434
# NOMINATIM_BASE_URL=http://10.0.0.128:8089
# NOMINATIM_COUNTRY_CODES=us
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
run: |
cd site
uv run python manage.py test
+74
View File
@@ -0,0 +1,74 @@
name: Deploy Beta
on:
push:
branches:
- main
jobs:
unit-tests:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
run: |
cd site
uv run python manage.py test
docker:
needs: unit-tests
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker compose build
- name: Run containerized tests
run: |
set -euo pipefail
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true
PROJECT="monica-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://monica_site:monica_site@db:5432/monica_site \
-e SITE_UNDER_CONSTRUCTION=false \
web uv run python manage.py test
deploy-beta:
needs: docker
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy monica_site beta to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app monica_site \
--env beta \
--ref "${{ gitea.sha }}"
+72
View File
@@ -0,0 +1,72 @@
name: Deploy Prod
on:
workflow_dispatch: {}
jobs:
unit-tests:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
run: |
cd site
uv run python manage.py test
docker:
needs: unit-tests
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker compose build
- name: Run containerized tests
run: |
set -euo pipefail
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true
PROJECT="monica-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://monica_site:monica_site@db:5432/monica_site \
-e SITE_UNDER_CONSTRUCTION=false \
web uv run python manage.py test
deploy-prod:
needs: docker
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy monica_site prod to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app monica_site \
--env prod \
--ref "${{ gitea.sha }}"
+3 -2
View File
@@ -137,8 +137,8 @@ venv.bak/
# Rope project settings # Rope project settings
.ropeproject .ropeproject
# mkdocs documentation # mkdocs documentation (do not use /site — that is the Django project tree)
/site /mkdocs_site/
# mypy # mypy
.mypy_cache/ .mypy_cache/
@@ -167,3 +167,4 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
.ux-compare/
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY site ./site
COPY scripts/docker-entrypoint.sh /entrypoint.sh
COPY scripts/worker-entrypoint.sh /worker-entrypoint.sh
RUN chmod +x /entrypoint.sh /worker-entrypoint.sh
WORKDIR /app/site
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+76 -38
View File
@@ -1,54 +1,92 @@
# monica_site # monica_site
Django 6.0 marketing platform for a single realtor client — a cheaper replacement for Django 6 marketing platform for Monica Dhillon / MKDRealtor.com — public site, realtor
PostcardMania with added social-media automation. portal, multi-channel outreach (email + SMS via SMTP2GO, postcards via PCM Integrations), and social
scheduling (Facebook / Instagram / LinkedIn) with Ollama-assisted drafting.
- **Public site** with a reCAPTCHA-protected contact form.
- **Private portal** for the realtor: lead inbox + UTM/campaign analytics.
- **Mailing list** with per-channel consent and easy opt-out; outreach over
**email + SMS (SMTP2GO)** and **postcard** (pluggable provider, default Lob).
- **Social automation** — schedule/post to Facebook, Instagram, and LinkedIn via native APIs.
See [`docs/monica-site-design.md`](docs/monica-site-design.md) for the full architecture,
data model, folder layout, deployment, and phased implementation plan.
## Stack ## Stack
- Django 6.0, Python 3.12 - Django 6 + `uv`
- Postgres (production), SQLite (local fallback) - Postgres (prod/beta); SQLite locally when `DATABASE_URL` unset
- **Django Tasks (`django.tasks`)** for background work (sends, scheduled posts) — Postgres - **dj-queue** (Django Tasks backend, Postgres queue, no Redis/Celery)
queue store + a single worker process; no Redis/Celery - Docker; deploy via `server-infra` Ansible pipeline
- `uv` package manager
- Docker; deployed via the `server-infra` Ansible pipeline onto active/active hosts behind
Nginx Proxy Manager (same pattern as `company_site`)
## Status ## Environments
Design/planning phase. Implementation follows the phases in the design doc: | Env | Domain | Port (host) | Gate |
|-----|--------|-------------|------|
| prod | `mkdrealtor.com` | 8004 | `SITE_UNDER_CONSTRUCTION=true` (holding page) |
| beta | `monica-preview.aimloperations.com` | 8014 | full app (`SITE_UNDER_CONSTRUCTION=false`) |
0. Scaffolding & deploy skeleton ## Local development
1. Public site + contact form + leads + UTM
2. Mailing list + email/SMS outreach + opt-out
3. Postcards
4. Social automation
5. Hardening & polish
## Local development (planned)
Once scaffolded:
```bash ```bash
uv sync uv sync
docker compose up # web + postgres cd site
uv run python manage.py migrate uv run python manage.py migrate
uv run python manage.py runserver uv run python manage.py runserver
# Dev uses ImmediateBackend — tasks run inline. For prod-like workers, run worker-entrypoint. # optional: docker compose up # web + postgres (hot reload)
# optional worker: docker compose --profile worker up worker
``` ```
## Deployment Docker compose mounts `./site` into the container and runs Django `runserver` when
`DJANGO_ENV=dev` (the default). Edit Python/templates → auto-reload; no image rebuild.
Rebuild only when Dockerfile / deps (`pyproject.toml`, `uv.lock`) change:
CI on merge to `main` runs tests, then calls ```bash
`server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`. docker compose up --build
Requires a `monica_site` entry in the `server-infra` `app_catalog`, a reserved host port pair, ```
a shared-Postgres database, a singleton django.tasks worker on one host, and a control-node
secret env file. Details in the design doc. ### Local secrets (SMTP2GO, etc.)
Copy `.env.example``.env` (gitignored). Compose reads `.env` for email vars.
```bash
cp .env.example .env
# fill EMAIL_HOST_USER / EMAIL_HOST_PASSWORD
# for real delivery (not console logs):
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
docker compose up
```
Without `EMAIL_BACKEND=…smtp…`, mail prints to the web container logs (console backend).
### SMTP2GO delivery webhooks
See [`site/messaging/README.md`](site/messaging/README.md) for SMTP2GO email/SMS webhooks
and PCM Integrations postcard designer + webhook setup (Bearer auth, events).
Prod compose (`docker-compose.prod.yml`) still uses gunicorn with baked-in image code.
## Secret env files (control node)
Create on the deploy host:
```text
~/Documents/secrets/monica_site/monica_site_prod.env
~/Documents/secrets/monica_site/monica_site_beta.env
```
Templates: `.env.prod.example` (full var list). Validate with:
```bash
./scripts/validate-env.sh ~/Documents/secrets/monica_site/monica_site_prod.env
```
## Deploy
CI on merge to `main` → tests → `server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`.
Start the **dj-queue worker on exactly one host**:
```bash
docker compose -f docker-compose.prod.yml --profile worker up -d
```
## Apps
`public` · `accounts` · `dashboard` · `leads` · `contacts` · `analytics` · `messaging` · `social`
Social AI drafts: `POST /portal/social/api/generate/` → Ollama at `OLLAMA_BASE_URL` (default `http://10.0.0.128:11434`).
See [`docs/monica-site-design.md`](docs/monica-site-design.md) for architecture.
+22
View File
@@ -0,0 +1,22 @@
# Production compose for server-infra deploy. No bundled Postgres — use shared
# external DB via DATABASE_URL in .env (see .env.prod.example).
#
# Web runs on every app host (active/active). Start the dj-queue worker on
# exactly ONE host via:
# docker compose -f docker-compose.prod.yml --profile worker up -d
services:
web:
build: .
restart: unless-stopped
ports:
- "${WEB_PORT:-8004}:8000"
env_file:
- .env
worker:
build: .
restart: unless-stopped
profiles: ["worker"]
entrypoint: ["/worker-entrypoint.sh"]
env_file:
- .env
+78
View File
@@ -0,0 +1,78 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: monica_site
POSTGRES_USER: monica_site
POSTGRES_PASSWORD: monica_site
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U monica_site -d monica_site"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
web:
build: .
ports:
- "8000:8000"
# Bind-mount source so edit → runserver auto-reload (no rebuild).
volumes:
- ./site:/app/site
# No required env_file — CI has no .env. Defaults below; for local secrets:
# docker compose --env-file .env up
#
# Do NOT interpolate ${DATABASE_URL} here. On the Act runner / control node that
# var often points at shared prod/beta Postgres; compose would bake it into
# containerized tests. Use COMPOSE_DATABASE_URL only if you need to override.
environment:
DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://monica_site:monica_site@db:5432/monica_site}
SITE_UNDER_CONSTRUCTION: ${SITE_UNDER_CONSTRUCTION:-false}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_PORT: ${EMAIL_PORT:-2525}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@mkdrealtor.com}
# Dev default = console. Set smtp backend in `.env` for real SMTP2GO sends.
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
depends_on:
db:
condition: service_healthy
worker:
build: .
profiles: ["worker"]
entrypoint: ["/worker-entrypoint.sh"]
volumes:
- ./site:/app/site
environment:
DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://monica_site:monica_site@db:5432/monica_site}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_PORT: ${EMAIL_PORT:-2525}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@mkdrealtor.com}
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
+35 -29
View File
@@ -23,7 +23,7 @@ multiple active/active instances behind Nginx Proxy Manager (NPM).
composer, social scheduler. composer, social scheduler.
- One mailing list of contacts. Form submissions auto-create/append contacts. - One mailing list of contacts. Form submissions auto-create/append contacts.
- Per-channel, per-contact consent with easy opt-out (email unsubscribe link, SMS `STOP`). - Per-channel, per-contact consent with easy opt-out (email unsubscribe link, SMS `STOP`).
- Outreach over Email + SMS (SMTP2GO) and Postcard (pluggable provider, default Lob). - Outreach over Email + SMS (SMTP2GO) and Postcard (PCM Integrations, pluggable).
- Social posting/scheduling to Facebook, Instagram, LinkedIn via native APIs. - Social posting/scheduling to Facebook, Instagram, LinkedIn via native APIs.
- Cheaper than PostcardMania + adds social automation. - Cheaper than PostcardMania + adds social automation.
@@ -74,7 +74,7 @@ flowchart TB
subgraph external [External APIs] subgraph external [External APIs]
SMTP2GO["SMTP2GO (email + SMS)"] SMTP2GO["SMTP2GO (email + SMS)"]
Postcard["Postcard provider (Lob default)"] Postcard["Postcard provider (PCM Integrations)"]
Meta["Meta Graph API (FB + IG)"] Meta["Meta Graph API (FB + IG)"]
LinkedIn["LinkedIn API"] LinkedIn["LinkedIn API"]
Recaptcha["Google reCAPTCHA"] Recaptcha["Google reCAPTCHA"]
@@ -230,30 +230,34 @@ a contact opt out of SMS while keeping email.
writes a `Suppression`. A2P 10DLC registration is a client onboarding prerequisite (call out writes a `Suppression`. A2P 10DLC registration is a client onboarding prerequisite (call out
in Phase 3). in Phase 3).
### 6.2 Postcards — pluggable provider (default Lob) ### 6.2 Postcards — PCM Integrations (pluggable)
A small provider interface so the realtor isn't locked in and we can shop on price: Default provider is **PCM Integrations** (DirectMail API v3). A small provider interface
keeps Lob / Click2Mail / PostGrid available behind `POSTCARD_PROVIDER` if needed later.
```python ```python
class PostcardProvider(Protocol): class PostcardProvider(Protocol):
def send_postcard(self, *, to: PostalAddress, from_: PostalAddress, def send_postcard(self, message) -> PostcardResult: ...
front: Asset, back: Asset, idempotency_key: str) -> ProviderResult: ... def get_status(self, provider_id: str) -> str: ...
def get_status(self, provider_id: str) -> DeliveryStatus: ...
``` ```
Adapters live in `messaging/providers/postcard/`. Selected via `POSTCARD_PROVIDER` env var. Adapters live in `messaging/providers/postcard/`. Selected via `POSTCARD_PROVIDER` env var.
| Provider | Model | ~4x6 postcard | Best for | Notes | | Provider | Model | Notes |
|----------|-------|---------------|----------|-------| |----------|-------|-------|
| **Lob** (default) | API-first, tiered | Free dev tier ~$0.77; ~$0.51 on $260/mo Startup | Clean API, address verification, in-transit tracking | Best DX; monthly fee only worth it at volume | | **PCM Integrations** (default) | DirectMail API v3 + embedded designer | Iframe editor (`POST /design/custom`, embed URL); orders with `designID` + recipients; inbound status webhooks |
| Click2Mail | Pay-per-piece, no subscription | ~$0.350.70 | Low/occasional volume, no monthly fee | API less polished; great when volume is small | | Lob | API-first, tiered | Alternate behind same interface |
| PostGrid | API + dashboard, subscription | Contact sales (from ~$250/mo) | Compliance-heavy, templates | Overkill unless compliance-driven | | Click2Mail | Pay-per-piece | Low-volume alternate |
| Stannp | Marketer-friendly, no minimums | Transparent per-piece | Non-dev fallback, EU | Good dashboard | | PostGrid | API + dashboard | Compliance-heavy alternate |
| USPS EDDM | Postage-only saturation | Postage only | Whole-route blasts (no list) | No per-address API; manual/bulk option |
> Recommendation: start on **Lob Developer (free) tier** to validate, keep **Click2Mail** as the **Designer:** portal embeds PCMs editor (no homemade layout tool). Saved designs become
> low-volume cost option behind the same interface. Revisit once monthly volume is known — the `MessageTemplate` rows with `postcard_front.design_id`.
> abstraction makes switching a config change.
**Webhooks:** `POST /portal/messaging/webhooks/postcard/` with Bearer `PCM_WEBHOOK_SECRET`.
Correlate via `extRefNbr` (= `Message.pk`) or `orderID` (= `provider_message_id`).
**Campaign notify:** when any channel campaign reaches `completed`, one summary email goes
to `created_by.email` or `CONTACT_EMAIL` (`Campaign.notify_sent_at` guard).
### 6.3 Social — native APIs (Facebook, Instagram, LinkedIn) ### 6.3 Social — native APIs (Facebook, Instagram, LinkedIn)
Chosen over an aggregator (Ayrshare) to avoid per-profile monthly fees, since this is one Chosen over an aggregator (Ayrshare) to avoid per-profile monthly fees, since this is one
@@ -324,10 +328,10 @@ monica_site/ # repo root
│ ├── worker-entrypoint.sh # django.tasks backend worker (+ optional dispatch_due loop) │ ├── worker-entrypoint.sh # django.tasks backend worker (+ optional dispatch_due loop)
│ └── validate-env.sh │ └── validate-env.sh
├── .gitea/workflows/ ├── .gitea/workflows/
│ ├── ci.yml # PR: uv sync + manage.py test │ ├── ci.yml # PR: uv sync + cd site && manage.py test
│ ├── unittests.yml # master: tests gate deploy │ ├── unittests.yml # master: tests gate deploy
│ └── deploy.yml # calls server-infra/scripts/deploy.sh --app monica_site │ └── deploy.yml # calls server-infra/scripts/deploy.sh --app monica_site
└── monica_site/ # Django project dir (manage.py lives here) └── site/ # Django project dir (manage.py lives here)
├── manage.py ├── manage.py
├── monica_site/ # project package ├── monica_site/ # project package
│ ├── __init__.py │ ├── __init__.py
@@ -358,7 +362,7 @@ monica_site/ # repo root
│ └── providers/ │ └── providers/
│ ├── email/smtp2go.py │ ├── email/smtp2go.py
│ ├── sms/smtp2go.py │ ├── sms/smtp2go.py
│ └── postcard/{base.py,lob.py,click2mail.py,postgrid.py} │ └── postcard/{__init__.py,pcm.py,lob.py,click2mail.py,postgrid.py}
└── social/ # accounts, posts, scheduling └── social/ # accounts, posts, scheduling
├── models.py ├── models.py
├── tasks.py # @task publish_social_target, etc. ├── tasks.py # @task publish_social_target, etc.
@@ -409,7 +413,7 @@ monica_site:
### 8.3 CI/CD flow ### 8.3 CI/CD flow
Same as `company_site`: Same as `company_site`:
1. PR → `ci.yml` runs `uv sync` + `manage.py test`. 1. PR → `ci.yml` runs `uv sync` + `cd site && manage.py test`.
2. Merge to `main` → `unittests.yml` (containerized tests) → on green, `deploy.yml` calls 2. Merge to `main` → `unittests.yml` (containerized tests) → on green, `deploy.yml` calls
`server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`. `server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`.
3. `deploy-apps.yml` checks out the ref on each app host, injects `.env`, `docker compose build`, 3. `deploy-apps.yml` checks out the ref on each app host, injects `.env`, `docker compose build`,
@@ -464,9 +468,11 @@ EMAIL_USE_TLS=true
# SMTP2GO SMS # SMTP2GO SMS
SMTP2GO_SMS_API_KEY=... SMTP2GO_SMS_API_KEY=...
# Postcards # Postcards (PCM Integrations)
POSTCARD_PROVIDER=lob # lob|click2mail|postgrid POSTCARD_PROVIDER=pcm # pcm|lob|click2mail|postgrid
LOB_API_KEY=... PCM_API_KEY=...
PCM_WEBHOOK_SECRET=...
PCM_RETURN_ADDRESS={...}
# Social (native) # Social (native)
META_APP_ID=... META_APP_ID=...
@@ -512,11 +518,11 @@ TIANJI_WEBSITE_ID=...
- **Exit:** realtor sends a real email + SMS campaign to consented contacts; opt-out works. - **Exit:** realtor sends a real email + SMS campaign to consented contacts; opt-out works.
### Phase 3 — Postcards ### Phase 3 — Postcards
- `messaging/providers/postcard/` interface + Lob adapter (+ Click2Mail adapter as the - `messaging/providers/postcard/` + PCM Integrations adapter (Lob/Click2Mail kept as
low-volume option); `POSTCARD_PROVIDER` switch. alternates); `POSTCARD_PROVIDER=pcm`.
- Postcard campaign flow: pick template/artwork, select recipients (address required + Lob - PCM iframe designer; save `design_id` on `MessageTemplate`; postcard campaign audience;
address verification), enqueue batch as Django Tasks, poll delivery status. inbound PCM webhooks → `ProviderEvent`; campaign completion notify email.
- Client prerequisites called out: Lob account, A2P 10DLC for SMS. - Client prerequisites: PCM Integrations account + API key, A2P 10DLC for SMS.
- **Exit:** realtor mails a postcard batch and sees delivery tracking. - **Exit:** realtor mails a postcard batch and sees delivery tracking.
### Phase 4 — Social automation ### Phase 4 — Social automation
@@ -1 +0,0 @@
,westfarn,ryan-development-1,15.07.2026 14:50,file:///home/westfarn/.config/libreoffice/4;
+26
View File
@@ -0,0 +1,26 @@
[project]
name = "monica-site"
version = "0.1.0"
description = "Django marketing platform for MKDRealtor (Monica Dhillon)"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"django>=6.0,<7",
"dj-queue>=0.13.0",
"django-phonenumber-field>=8.1.0",
"django-recaptcha>=4.1.0",
"cryptography>=44.0.0",
"gunicorn>=23.0.0",
"phonenumbers>=9.0.0",
"psycopg2-binary>=2.9.10",
"requests>=2.32.0",
"whitenoise>=6.9.0",
]
[dependency-groups]
dev = [
"pre-commit>=4.1.0",
]
[tool.uv]
package = false
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app/site
wait_for_database() {
if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then
return 0
fi
echo "Waiting for database..."
for _ in $(seq 1 30); do
if uv run python - <<'PY'
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "monica_site.settings")
import django
from django.db import connections
from django.db.utils import OperationalError
django.setup()
try:
connections["default"].ensure_connection()
except OperationalError:
sys.exit(1)
PY
then
echo "Database is ready."
return 0
fi
sleep 2
done
echo "Database did not become ready in time." >&2
exit 1
}
wait_for_database
uv run python manage.py migrate --noinput
# Local compose defaults to DJANGO_ENV=dev: runserver + source bind-mount → hot reload.
# Prod/beta images keep gunicorn (no file watch).
if [[ "${DJANGO_ENV:-}" == "dev" || "${DJANGO_USE_RUNSERVER:-}" == "true" ]]; then
echo "Starting Django runserver (auto-reload on)."
exec uv run python manage.py runserver "${GUNICORN_BIND:-0.0.0.0:8000}"
fi
uv run python manage.py collectstatic --noinput
exec uv run gunicorn monica_site.wsgi:application \
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
--workers "${GUNICORN_WORKERS:-2}"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:?Usage: $0 <env-file>}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file not found: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
DJANGO_ENV="${DJANGO_ENV:-prod}"
required_vars=(
DJANGO_SECRET_KEY
DJANGO_ALLOWED_HOSTS
DATABASE_URL
WEB_PORT
)
if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
required_vars+=(
RECAPTCHA_PUBLIC_KEY
RECAPTCHA_PRIVATE_KEY
EMAIL_HOST_USER
EMAIL_HOST_PASSWORD
)
fi
if [[ "$DJANGO_ENV" == "prod" ]]; then
required_vars+=(
SITE_UNDER_CONSTRUCTION
TIANJI_WEBSITE_ID
)
fi
missing=()
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
missing+=("$var")
fi
done
if ((${#missing[@]} > 0)); then
echo "Missing required environment variables in $ENV_FILE:" >&2
printf ' - %s\n' "${missing[@]}" >&2
echo "Copy .env.prod.example to ~/Documents/secrets/monica_site/monica_site_${DJANGO_ENV}.env and set values." >&2
exit 1
fi
if [[ "$DJANGO_ENV" == "prod" && "$DJANGO_SECRET_KEY" == change-me* ]]; then
echo "DJANGO_SECRET_KEY must be changed from the example value for production." >&2
exit 1
fi
echo "Environment validation passed (DJANGO_ENV=$DJANGO_ENV)."
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app/site
wait_for_database() {
if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then
return 0
fi
echo "Waiting for database..."
for _ in $(seq 1 30); do
if uv run python - <<'PY'
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "monica_site.settings")
import django
from django.db import connections
from django.db.utils import OperationalError
django.setup()
try:
connections["default"].ensure_connection()
except OperationalError:
sys.exit(1)
PY
then
echo "Database is ready."
return 0
fi
sleep 2
done
echo "Database did not become ready in time." >&2
exit 1
}
wait_for_database
uv run python manage.py migrate --noinput
# dj-queue supervisor (workers + dispatcher + scheduler). Run on ONE host only.
exec uv run python manage.py dj_queue
View File
+9
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
+33
View File
@@ -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,
},
),
]
+19
View File
@@ -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>
+17
View File
@@ -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",
),
]
View File
+14
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "analytics"
+62
View File
@@ -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 "",
)
+53
View File
@@ -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,
},
),
]
+37
View File
@@ -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'}"
+3
View File
@@ -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 %}
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from analytics import views
app_name = "analytics"
urlpatterns = [
path("", views.report, name="report"),
]
+74
View File
@@ -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,
},
)
View File
+33
View File
@@ -0,0 +1,33 @@
from django.contrib import admin
from contacts.models import ConsentRecord, Contact, Suppression
class ConsentInline(admin.TabularInline):
model = ConsentRecord
extra = 0
class SuppressionInline(admin.TabularInline):
model = Suppression
extra = 0
@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
list_display = ("email", "first_name", "last_name", "phone", "source", "created_at")
search_fields = ("email", "first_name", "last_name", "phone")
list_filter = ("source",)
fields = (
"email",
"first_name",
"last_name",
"phone",
"postal_address",
"source",
"notes",
"created_at",
"updated_at",
)
readonly_fields = ("created_at", "updated_at")
inlines = [ConsentInline, SuppressionInline]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "contacts"
+66
View File
@@ -0,0 +1,66 @@
# Generated by Django 6.1 on 2026-08-06 18:01
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Contact',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)),
('phone', models.CharField(blank=True, max_length=32)),
('first_name', models.CharField(blank=True, max_length=100)),
('last_name', models.CharField(blank=True, max_length=100)),
('postal_address', models.JSONField(blank=True, default=dict)),
('source', models.CharField(choices=[('contact_form', 'Contact form'), ('import', 'Import'), ('manual', 'Manual'), ('notify_me', 'Notify me'), ('other', 'Other')], default='other', max_length=32)),
('notes', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ConsentRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
('opted_in', models.BooleanField(default=False)),
('changed_at', models.DateTimeField(auto_now=True)),
('reason', models.CharField(blank=True, max_length=255)),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='contacts.contact')),
],
options={
'ordering': ['-changed_at'],
'unique_together': {('contact', 'channel')},
},
),
migrations.CreateModel(
name='Suppression',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
('reason', models.CharField(blank=True, max_length=255)),
('active', models.BooleanField(default=True)),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suppressions', to='contacts.contact')),
],
options={
'unique_together': {('contact', 'channel')},
},
),
]
+101
View File
@@ -0,0 +1,101 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Channel(models.TextChoices):
EMAIL = "email", "Email"
SMS = "sms", "SMS"
POSTCARD = "postcard", "Postcard"
class Contact(UUIDPrimaryKeyModel, TimeStampedModel):
class Source(models.TextChoices):
CONTACT_FORM = "contact_form", "Contact form"
IMPORT = "import", "Import"
MANUAL = "manual", "Manual"
NOTIFY_ME = "notify_me", "Notify me"
OTHER = "other", "Other"
email = models.EmailField(unique=True, blank=True, null=True)
phone = models.CharField(max_length=32, blank=True)
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
postal_address = models.JSONField(default=dict, blank=True)
source = models.CharField(
max_length=32, choices=Source.choices, default=Source.OTHER
)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
name = f"{self.first_name} {self.last_name}".strip()
return name or self.email or self.phone or str(self.pk)
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()
@staticmethod
def make_postal_address(
*,
line1: str = "",
line2: str = "",
city: str = "",
state: str = "",
zip_code: str = "",
country: str = "US",
) -> dict:
"""Normalize Lob-shaped postal address dict."""
return {
"line1": (line1 or "").strip(),
"line2": (line2 or "").strip(),
"city": (city or "").strip(),
"state": (state or "").strip(),
"zip": (zip_code or "").strip(),
"country": ((country or "").strip() or "US"),
}
@staticmethod
def postal_address_has_content(addr: dict | None) -> bool:
if not addr:
return False
return any(
(addr.get(key) or "").strip()
for key in ("line1", "line2", "city", "state", "zip")
)
class ConsentRecord(TimeStampedModel):
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="consents"
)
channel = models.CharField(max_length=16, choices=Channel.choices)
opted_in = models.BooleanField(default=False)
changed_at = models.DateTimeField(auto_now=True)
reason = models.CharField(max_length=255, blank=True)
class Meta:
unique_together = ("contact", "channel")
ordering = ["-changed_at"]
def __str__(self) -> str:
state = "in" if self.opted_in else "out"
return f"{self.contact} {self.channel} opt-{state}"
class Suppression(TimeStampedModel):
contact = models.ForeignKey(
Contact, on_delete=models.CASCADE, related_name="suppressions"
)
channel = models.CharField(max_length=16, choices=Channel.choices)
reason = models.CharField(max_length=255, blank=True)
active = models.BooleanField(default=True)
class Meta:
unique_together = ("contact", "channel")
def __str__(self) -> str:
return f"suppress {self.contact} {self.channel}"
+214
View File
@@ -0,0 +1,214 @@
"""Nominatim client — server-side only; browsers never call Nominatim directly."""
from __future__ import annotations
import logging
import re
from typing import Any
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
# ISO3166-2-lvl4 "US-OH" → "OH"; fall back to common full-name map.
_US_STATE_ABBREV = {
"alabama": "AL",
"alaska": "AK",
"arizona": "AZ",
"arkansas": "AR",
"california": "CA",
"colorado": "CO",
"connecticut": "CT",
"delaware": "DE",
"district of columbia": "DC",
"florida": "FL",
"georgia": "GA",
"hawaii": "HI",
"idaho": "ID",
"illinois": "IL",
"indiana": "IN",
"iowa": "IA",
"kansas": "KS",
"kentucky": "KY",
"louisiana": "LA",
"maine": "ME",
"maryland": "MD",
"massachusetts": "MA",
"michigan": "MI",
"minnesota": "MN",
"mississippi": "MS",
"missouri": "MO",
"montana": "MT",
"nebraska": "NE",
"nevada": "NV",
"new hampshire": "NH",
"new jersey": "NJ",
"new mexico": "NM",
"new york": "NY",
"north carolina": "NC",
"north dakota": "ND",
"ohio": "OH",
"oklahoma": "OK",
"oregon": "OR",
"pennsylvania": "PA",
"rhode island": "RI",
"south carolina": "SC",
"south dakota": "SD",
"tennessee": "TN",
"texas": "TX",
"utah": "UT",
"vermont": "VT",
"virginia": "VA",
"washington": "WA",
"west virginia": "WV",
"wisconsin": "WI",
"wyoming": "WY",
}
class NominatimError(RuntimeError):
pass
# Leading house / unit number from user query (e.g. "1968", "12A", "100-102").
_HOUSE_FROM_QUERY = re.compile(r"^(\d+[A-Za-z]?(?:-\d+[A-Za-z]?)?)\b")
def _house_from_query(query: str) -> str:
match = _HOUSE_FROM_QUERY.match((query or "").strip())
return match.group(1) if match else ""
def _state_code(addr: dict[str, Any]) -> str:
iso = (addr.get("ISO3166-2-lvl4") or "").strip()
if iso.startswith("US-") and len(iso) == 5:
return iso[3:]
raw = (addr.get("state") or "").strip()
if len(raw) == 2:
return raw.upper()
return _US_STATE_ABBREV.get(raw.lower(), raw)
def _city(addr: dict[str, Any]) -> str:
for key in ("city", "town", "village", "hamlet", "municipality", "suburb"):
val = (addr.get(key) or "").strip()
if val:
return val
return ""
def _line1(addr: dict[str, Any], display_name: str, *, query: str = "") -> str:
house = (addr.get("house_number") or "").strip()
road = (addr.get("road") or addr.get("pedestrian") or "").strip()
# Nominatim often returns road-level hits with no house_number even when the
# user typed one — keep that number so mailing street isn't incomplete.
if not house:
house = _house_from_query(query)
if house and road:
return f"{house} {road}"
if road:
return road
# Place-level hits (city only) — leave street empty for the user to fill.
if house or road:
return " ".join(p for p in (house, road) if p)
first = (display_name or "").split(",")[0].strip()
# Avoid stuffing "Akron" into street when it's a city result.
if first and first.lower() != _city(addr).lower():
return first
return ""
def normalize_hit(raw: dict[str, Any], *, query: str = "") -> dict[str, str]:
addr = raw.get("address") or {}
if not isinstance(addr, dict):
addr = {}
country_code = (addr.get("country_code") or "us").upper()
if country_code == "US":
country = "US"
else:
country = country_code[:2] or "US"
display = (raw.get("display_name") or "").strip()
line1 = _line1(addr, display, query=query)
label = display
# Surface recovered house number in the dropdown when OSM omitted it.
house = (addr.get("house_number") or "").strip() or _house_from_query(query)
if house and label and not re.match(rf"^{re.escape(house)}\b", label, re.I):
label = f"{house} {label}"
return {
"label": label,
"line1": line1,
"line2": "",
"city": _city(addr),
"state": _state_code(addr),
"zip": (addr.get("postcode") or "").strip().split(";")[0].strip(),
"country": country,
}
def suggest_addresses(query: str, *, limit: int = 5) -> list[dict[str, str]]:
"""
Proxy Nominatim /search. Returns normalized address dicts for the UI.
Nominatim itself has no API-key auth — LAN firewall + this Django proxy
gate access. Optional NOMINATIM_API_KEY is sent as X-API-Key if you put
a gateway in front of Nominatim later.
"""
base = (settings.NOMINATIM_BASE_URL or "").rstrip("/")
if not base:
raise NominatimError("NOMINATIM_BASE_URL is not configured")
q = (query or "").strip()
if len(q) < 3:
return []
limit = max(1, min(int(limit or 5), 8))
params: dict[str, str | int] = {
"q": q,
"format": "json",
"addressdetails": 1,
"limit": limit,
}
countrycodes = (settings.NOMINATIM_COUNTRY_CODES or "").strip()
if countrycodes:
params["countrycodes"] = countrycodes
headers = {
"User-Agent": settings.NOMINATIM_USER_AGENT,
"Accept": "application/json",
}
api_key = (settings.NOMINATIM_API_KEY or "").strip()
if api_key:
headers["X-API-Key"] = api_key
url = f"{base}/search"
try:
response = requests.get(
url,
params=params,
headers=headers,
timeout=settings.NOMINATIM_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
except requests.RequestException as exc:
logger.exception("Nominatim request failed")
raise NominatimError(f"Nominatim unreachable at {url}: {exc}") from exc
except ValueError as exc:
raise NominatimError("Nominatim returned invalid JSON") from exc
if not isinstance(payload, list):
return []
results: list[dict[str, str]] = []
seen: set[str] = set()
for item in payload:
if not isinstance(item, dict):
continue
normalized = normalize_hit(item, query=q)
key = re.sub(r"\s+", " ", normalized["label"].lower())
if not key or key in seen:
continue
seen.add(key)
results.append(normalized)
return results
@@ -0,0 +1,85 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ contact }} · Contact{% endblock %}
{% block topbar_title %}Contact · {{ contact }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block portal_content %}
<form method="post">
{% csrf_token %}
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Profile</h2></div>
<div class="panel-b form-grid">
<div class="form-grid cols-2">
<div class="field"><label>First name</label><input value="{{ contact.first_name }}" readonly></div>
<div class="field"><label>Last name</label><input value="{{ contact.last_name }}" readonly></div>
</div>
<div class="form-grid cols-2">
<div class="field"><label>Email</label><input value="{{ contact.email }}" readonly></div>
<div class="field"><label>Phone</label><input value="{{ contact.phone }}" readonly></div>
</div>
<div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div>
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="field address-ac-wrap">
<label>Street address</label>
<input name="address_line1" data-ac="line1" value="{{ contact.postal_address.line1|default:'' }}" autocomplete="off">
</div>
<div class="field">
<label>Apt / suite</label>
<input name="address_line2" data-ac="line2" value="{{ contact.postal_address.line2|default:'' }}" autocomplete="address-line2">
</div>
<div class="form-grid cols-2">
<div class="field">
<label>City</label>
<input name="address_city" data-ac="city" value="{{ contact.postal_address.city|default:'' }}" autocomplete="address-level2">
</div>
<div class="field">
<label>State</label>
<input name="address_state" data-ac="state" value="{{ contact.postal_address.state|default:'' }}" autocomplete="address-level1" maxlength="32">
</div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label>ZIP</label>
<input name="address_zip" data-ac="zip" value="{{ contact.postal_address.zip|default:'' }}" autocomplete="postal-code" maxlength="20">
</div>
<div class="field">
<label>Country</label>
<input name="address_country" data-ac="country" value="{{ contact.postal_address.country|default:'US' }}" autocomplete="country" maxlength="2">
</div>
</div>
</div>
<div class="field"><label>Notes</label>
<textarea name="notes">{{ contact.notes }}</textarea>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:list' %}">← Mailing list</a>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Consent</h2></div>
<div class="panel-b">
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_email" value="1" {% if prefs.email %}checked{% endif %}> Email marketing</label>
</div>
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_sms" value="1" {% if prefs.sms %}checked{% endif %}> SMS updates</label>
</div>
<div class="field">
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label>
</div>
<p class="hint-block" style="margin-top:16px">Postcard campaigns need a street address and postcard consent. Opt-outs also write a suppression so campaigns skip this contact.</p>
</div>
</div>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "portal_base.html" %}
{% block title %}Import contacts · Portal{% endblock %}
{% block topbar_title %}Import contacts{% endblock %}
{% block portal_content %}
<div class="steps">
<div class="step active"><span>1</span> Upload</div>
<div class="step"><span>2</span> Map columns</div>
<div class="step"><span>3</span> Consent</div>
<div class="step"><span>4</span> Import</div>
</div>
<div class="split">
<div>
<div class="panel">
<div class="panel-h"><h2>Upload</h2></div>
<div class="panel-b">
<div class="dropzone">
<p style="margin:0 0 8px"><strong>Drop CSV or Excel here</strong></p>
<p class="muted" style="margin:0">Import processing wires up next. Accepted: .csv, .xlsx</p>
<p style="margin:16px 0 0"><button class="btn btn-ghost btn-sm" type="button" disabled>Choose file</button></p>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Column mapping</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>Your column</th><th>Maps to</th></tr></thead>
<tbody>
<tr><td>Email</td><td>email</td></tr>
<tr><td>First</td><td>first_name</td></tr>
<tr><td>Last</td><td>last_name</td></tr>
<tr><td>Phone</td><td>phone</td></tr>
<tr><td>Street</td><td>postal_address.line1</td></tr>
<tr><td>City</td><td>postal_address.city</td></tr>
<tr><td>State</td><td>postal_address.state</td></tr>
<tr><td>ZIP</td><td>postal_address.zip</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div>
<div class="panel">
<div class="panel-h"><h2>Consent defaults</h2></div>
<div class="panel-b form-grid">
<label class="check-row"><input type="checkbox" checked disabled> Email marketing</label>
<label class="check-row"><input type="checkbox" disabled> SMS</label>
<label class="check-row"><input type="checkbox" disabled> Postcard</label>
<div class="field"><label>Source</label><input value="Import" disabled></div>
<div class="field"><label>Duplicates</label><select disabled><option>Update existing by email</option></select></div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b">
<p class="muted">Sample rows appear after upload.</p>
<button class="btn btn-primary" type="button" disabled>Import contacts</button>
<p class="hint-block"><a href="{% url 'contacts:list' %}">← Back to mailing list</a></p>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,67 @@
{% extends "portal_base.html" %}
{% block title %}Mailing list · Portal{% endblock %}
{% block topbar_title %}Mailing list{% endblock %}
{% block portal_content %}
<div class="toolbar">
<form class="toolbar-filters" method="get">
<input type="search" name="q" value="{{ q }}" placeholder="Search contacts">
<button class="btn btn-sm btn-ghost" type="submit">Search</button>
</form>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
</div>
</div>
<div class="panel">
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th></th>
<th>Contact</th>
<th>Address</th>
<th>Consent</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr>
<td><input type="checkbox" disabled></td>
<td>
<a href="{% url 'contacts:detail' contact.pk %}">{{ contact }}</a><br>
<span class="muted">
{% if contact.email %}{{ contact.email }}{% endif %}
{% if contact.email and contact.phone %} · {% endif %}
{% if contact.phone %}{{ contact.phone }}{% endif %}
</span>
</td>
<td>
{% if contact.postal_address.line1 %}
{{ contact.postal_address.line1 }}{% if contact.postal_address.city %}, {{ contact.postal_address.city }}{% endif %}
{% else %}
{% endif %}
</td>
<td>
{% with c=contact.consent_flags %}
<span class="badge {% if c.email %}badge-optin{% else %}badge-optout{% endif %}">E</span>
<span class="badge {% if c.sms %}badge-optin{% else %}badge-optout{% endif %}">S</span>
<span class="badge {% if c.postcard %}badge-optin{% else %}badge-optout{% endif %}">P</span>
{% endwith %}
</td>
<td>{{ contact.get_source_display }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="empty-state">No contacts yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="muted" style="font-size:13px">
E = email · S = SMS · P = postcard.
<a href="{% url 'contacts:import' %}">Import contacts</a> for bulk CSV/Excel.
</p>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
from contacts.nominatim import normalize_hit
def test_line1_keeps_house_number_from_query_when_nominatim_omits_it():
raw = {
"display_name": (
"Greensboro Drive, Wheaton, DuPage County, Illinois, 60189, United States"
),
"address": {
"road": "Greensboro Drive",
"town": "Wheaton",
"county": "DuPage County",
"state": "Illinois",
"postcode": "60189",
"country_code": "us",
"ISO3166-2-lvl4": "US-IL",
},
}
hit = normalize_hit(raw, query="1968 Greensboro Drive, Wheaton")
assert hit["line1"] == "1968 Greensboro Drive"
assert hit["label"].startswith("1968 Greensboro Drive")
assert hit["city"] == "Wheaton"
assert hit["state"] == "IL"
assert hit["zip"] == "60189"
def test_line1_prefers_nominatim_house_number():
raw = {
"display_name": "1968 Greensboro Drive, Wheaton, Illinois, 60189, United States",
"address": {
"house_number": "1968",
"road": "Greensboro Drive",
"town": "Wheaton",
"state": "Illinois",
"postcode": "60189",
"country_code": "us",
"ISO3166-2-lvl4": "US-IL",
},
}
hit = normalize_hit(raw, query="1968 Greensboro Drive")
assert hit["line1"] == "1968 Greensboro Drive"
assert hit["label"] == raw["display_name"]
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from contacts import views
app_name = "contacts"
urlpatterns = [
path("", views.contact_list, name="list"),
path("import/", views.contact_import, name="import"),
path("<uuid:pk>/", views.contact_detail, name="detail"),
]
+101
View File
@@ -0,0 +1,101 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Prefetch, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_GET, require_http_methods
from contacts.models import Channel, ConsentRecord, Contact
from contacts.nominatim import NominatimError, suggest_addresses
from messaging.services import channel_preferences, set_channel_preferences
def _consent_flags(contact: Contact) -> dict[str, bool]:
return channel_preferences(contact)
def _postal_from_post(post) -> dict:
return Contact.make_postal_address(
line1=post.get("address_line1", ""),
line2=post.get("address_line2", ""),
city=post.get("address_city", ""),
state=post.get("address_state", ""),
zip_code=post.get("address_zip", ""),
country=post.get("address_country", "US"),
)
@login_required
def contact_list(request):
contacts = Contact.objects.prefetch_related(
Prefetch("consents", queryset=ConsentRecord.objects.all())
).all()
q = (request.GET.get("q") or "").strip()
if q:
contacts = contacts.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(phone__icontains=q)
)
rows = list(contacts[:200])
for contact in rows:
contact.consent_flags = _consent_flags(contact)
return render(
request,
"contacts/list.html",
{"contacts": rows, "q": q},
)
@login_required
@require_http_methods(["GET", "POST"])
def contact_detail(request, pk):
contact = get_object_or_404(
Contact.objects.prefetch_related("consents"), pk=pk
)
if request.method == "POST":
contact.postal_address = _postal_from_post(request.POST)
contact.notes = (request.POST.get("notes") or "").strip()
contact.save(update_fields=["postal_address", "notes", "updated_at"])
set_channel_preferences(
contact,
{
Channel.EMAIL: "consent_email" in request.POST,
Channel.SMS: "consent_sms" in request.POST,
Channel.POSTCARD: "consent_postcard" in request.POST,
},
reason="portal_manual",
)
messages.success(request, "Contact updated.")
return redirect("contacts:detail", pk=contact.pk)
prefs = _consent_flags(contact)
return render(
request,
"contacts/detail.html",
{"contact": contact, "prefs": prefs},
)
@login_required
def contact_import(request):
return render(request, "contacts/import.html")
@require_GET
def address_suggest(request):
"""
Backend proxy for Nominatim search. Browser JS must call this URL only —
never Nominatim directly.
"""
q = (request.GET.get("q") or "").strip()
if len(q) < 3:
return JsonResponse({"results": []})
try:
limit = int(request.GET.get("limit") or 5)
except (TypeError, ValueError):
limit = 5
try:
results = suggest_addresses(q, limit=limit)
except NominatimError as exc:
return JsonResponse({"error": str(exc), "results": []}, status=502)
return JsonResponse({"results": results})
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"
View File
@@ -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)."))
View File
+20
View File
@@ -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
+29
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
from django.http import JsonResponse
def healthz(_request):
"""Liveness probe for deploy / NPM health checks."""
return JsonResponse({"status": "ok"})
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DashboardConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dashboard"
+1
View File
@@ -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 %}
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from dashboard import views
app_name = "dashboard"
urlpatterns = [
path("", views.home, name="home"),
]
+27
View File
@@ -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)
View File
+16
View File
@@ -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]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LeadsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "leads"
+48
View File
@@ -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'],
},
),
]
View File
+46
View File
@@ -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"]
+88
View File
@@ -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 %}
+57
View File
@@ -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 %}
+10
View File
@@ -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"),
]
+51
View File
@@ -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},
)
+23
View File
@@ -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()
+116
View File
@@ -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 PCMs 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`.
View File
+40
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MessagingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "messaging"
+29
View File
@@ -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}")
+91
View File
@@ -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),
),
]
+115
View File
@@ -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)
+50
View File
@@ -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"
+66
View File
@@ -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")
+260
View File
@@ -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"
+42
View File
@@ -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}"
)
+404
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More