Add Django site, Docker packaging, and beta/prod Gitea deploys.
Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.venv
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
db.sqlite3
|
||||
.env
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.log
|
||||
staticfiles/
|
||||
proposal/
|
||||
template/
|
||||
docs/
|
||||
*.pptx
|
||||
.~lock.*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
@@ -137,8 +137,8 @@ venv.bak/
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
# mkdocs documentation (do not use /site — that is the Django project tree)
|
||||
/mkdocs_site/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
@@ -167,3 +167,4 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
.ux-compare/
|
||||
|
||||
+29
@@ -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"]
|
||||
@@ -1,54 +1,92 @@
|
||||
# monica_site
|
||||
|
||||
Django 6.0 marketing platform for a single realtor client — a cheaper replacement for
|
||||
PostcardMania with added social-media automation.
|
||||
|
||||
- **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.
|
||||
Django 6 marketing platform for Monica Dhillon / MKDRealtor.com — public site, realtor
|
||||
portal, multi-channel outreach (email + SMS via SMTP2GO, postcards via PCM Integrations), and social
|
||||
scheduling (Facebook / Instagram / LinkedIn) with Ollama-assisted drafting.
|
||||
|
||||
## Stack
|
||||
|
||||
- Django 6.0, Python 3.12
|
||||
- Postgres (production), SQLite (local fallback)
|
||||
- **Django Tasks (`django.tasks`)** for background work (sends, scheduled posts) — Postgres
|
||||
queue store + a single worker process; no Redis/Celery
|
||||
- `uv` package manager
|
||||
- Docker; deployed via the `server-infra` Ansible pipeline onto active/active hosts behind
|
||||
Nginx Proxy Manager (same pattern as `company_site`)
|
||||
- Django 6 + `uv`
|
||||
- Postgres (prod/beta); SQLite locally when `DATABASE_URL` unset
|
||||
- **dj-queue** (Django Tasks backend, Postgres queue, no Redis/Celery)
|
||||
- Docker; deploy via `server-infra` Ansible pipeline
|
||||
|
||||
## 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
|
||||
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:
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
docker compose up # web + postgres
|
||||
cd site
|
||||
uv run python manage.py migrate
|
||||
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
|
||||
`server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`.
|
||||
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.
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -23,7 +23,7 @@ multiple active/active instances behind Nginx Proxy Manager (NPM).
|
||||
composer, social scheduler.
|
||||
- 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`).
|
||||
- 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.
|
||||
- Cheaper than PostcardMania + adds social automation.
|
||||
|
||||
@@ -74,7 +74,7 @@ flowchart TB
|
||||
|
||||
subgraph external [External APIs]
|
||||
SMTP2GO["SMTP2GO (email + SMS)"]
|
||||
Postcard["Postcard provider (Lob default)"]
|
||||
Postcard["Postcard provider (PCM Integrations)"]
|
||||
Meta["Meta Graph API (FB + IG)"]
|
||||
LinkedIn["LinkedIn API"]
|
||||
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
|
||||
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
|
||||
class PostcardProvider(Protocol):
|
||||
def send_postcard(self, *, to: PostalAddress, from_: PostalAddress,
|
||||
front: Asset, back: Asset, idempotency_key: str) -> ProviderResult: ...
|
||||
def get_status(self, provider_id: str) -> DeliveryStatus: ...
|
||||
def send_postcard(self, message) -> PostcardResult: ...
|
||||
def get_status(self, provider_id: str) -> str: ...
|
||||
```
|
||||
|
||||
Adapters live in `messaging/providers/postcard/`. Selected via `POSTCARD_PROVIDER` env var.
|
||||
|
||||
| Provider | Model | ~4x6 postcard | Best for | 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 |
|
||||
| Click2Mail | Pay-per-piece, no subscription | ~$0.35–0.70 | Low/occasional volume, no monthly fee | API less polished; great when volume is small |
|
||||
| PostGrid | API + dashboard, subscription | Contact sales (from ~$250/mo) | Compliance-heavy, templates | Overkill unless compliance-driven |
|
||||
| Stannp | Marketer-friendly, no minimums | Transparent per-piece | Non-dev fallback, EU | Good dashboard |
|
||||
| USPS EDDM | Postage-only saturation | Postage only | Whole-route blasts (no list) | No per-address API; manual/bulk option |
|
||||
| Provider | Model | Notes |
|
||||
|----------|-------|-------|
|
||||
| **PCM Integrations** (default) | DirectMail API v3 + embedded designer | Iframe editor (`POST /design/custom`, embed URL); orders with `designID` + recipients; inbound status webhooks |
|
||||
| Lob | API-first, tiered | Alternate behind same interface |
|
||||
| Click2Mail | Pay-per-piece | Low-volume alternate |
|
||||
| PostGrid | API + dashboard | Compliance-heavy alternate |
|
||||
|
||||
> Recommendation: start on **Lob Developer (free) tier** to validate, keep **Click2Mail** as the
|
||||
> low-volume cost option behind the same interface. Revisit once monthly volume is known — the
|
||||
> abstraction makes switching a config change.
|
||||
**Designer:** portal embeds PCM’s editor (no homemade layout tool). Saved designs become
|
||||
`MessageTemplate` rows with `postcard_front.design_id`.
|
||||
|
||||
**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)
|
||||
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)
|
||||
│ └── validate-env.sh
|
||||
├── .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
|
||||
│ └── 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
|
||||
├── monica_site/ # project package
|
||||
│ ├── __init__.py
|
||||
@@ -358,7 +362,7 @@ monica_site/ # repo root
|
||||
│ └── providers/
|
||||
│ ├── email/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
|
||||
├── models.py
|
||||
├── tasks.py # @task publish_social_target, etc.
|
||||
@@ -409,7 +413,7 @@ monica_site:
|
||||
|
||||
### 8.3 CI/CD flow
|
||||
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
|
||||
`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`,
|
||||
@@ -464,9 +468,11 @@ EMAIL_USE_TLS=true
|
||||
# SMTP2GO SMS
|
||||
SMTP2GO_SMS_API_KEY=...
|
||||
|
||||
# Postcards
|
||||
POSTCARD_PROVIDER=lob # lob|click2mail|postgrid
|
||||
LOB_API_KEY=...
|
||||
# Postcards (PCM Integrations)
|
||||
POSTCARD_PROVIDER=pcm # pcm|lob|click2mail|postgrid
|
||||
PCM_API_KEY=...
|
||||
PCM_WEBHOOK_SECRET=...
|
||||
PCM_RETURN_ADDRESS={...}
|
||||
|
||||
# Social (native)
|
||||
META_APP_ID=...
|
||||
@@ -512,11 +518,11 @@ TIANJI_WEBSITE_ID=...
|
||||
- **Exit:** realtor sends a real email + SMS campaign to consented contacts; opt-out works.
|
||||
|
||||
### Phase 3 — Postcards
|
||||
- `messaging/providers/postcard/` interface + Lob adapter (+ Click2Mail adapter as the
|
||||
low-volume option); `POSTCARD_PROVIDER` switch.
|
||||
- Postcard campaign flow: pick template/artwork, select recipients (address required + Lob
|
||||
address verification), enqueue batch as Django Tasks, poll delivery status.
|
||||
- Client prerequisites called out: Lob account, A2P 10DLC for SMS.
|
||||
- `messaging/providers/postcard/` + PCM Integrations adapter (Lob/Click2Mail kept as
|
||||
alternates); `POSTCARD_PROVIDER=pcm`.
|
||||
- PCM iframe designer; save `design_id` on `MessageTemplate`; postcard campaign audience;
|
||||
inbound PCM webhooks → `ProviderEvent`; campaign completion notify email.
|
||||
- Client prerequisites: PCM Integrations account + API key, A2P 10DLC for SMS.
|
||||
- **Exit:** realtor mails a postcard batch and sees delivery tracking.
|
||||
|
||||
### Phase 4 — Social automation
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
,westfarn,ryan-development-1,15.07.2026 14:50,file:///home/westfarn/.config/libreoffice/4;
|
||||
Binary file not shown.
@@ -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
|
||||
Executable
+56
@@ -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}"
|
||||
Executable
+60
@@ -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)."
|
||||
Executable
+46
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from accounts.models import RealtorProfile
|
||||
|
||||
|
||||
@admin.register(RealtorProfile)
|
||||
class RealtorProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "display_name", "phone")
|
||||
search_fields = ("user__username", "display_name")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RealtorProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('display_name', models.CharField(blank=True, max_length=120)),
|
||||
('phone', models.CharField(blank=True, max_length=32)),
|
||||
('title', models.CharField(blank=True, max_length=120)),
|
||||
('bio', models.TextField(blank=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='realtor_profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel
|
||||
|
||||
|
||||
class RealtorProfile(TimeStampedModel):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="realtor_profile",
|
||||
)
|
||||
display_name = models.CharField(max_length=120, blank=True)
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
title = models.CharField(max_length=120, blank=True)
|
||||
bio = models.TextField(blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.display_name or self.user.get_username()
|
||||
@@ -0,0 +1,42 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · Portal · MKDRealtor.com</title>
|
||||
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
|
||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Work+Sans:300,400,500,700%7CPoppins:400,600,700">
|
||||
<link rel="stylesheet" href="{% static 'css/portal.css' %}">
|
||||
</head>
|
||||
<body class="portal">
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<h1>MKD Portal</h1>
|
||||
<p class="sub">{{ SITE_TAGLINE }}</p>
|
||||
<p class="sub" style="margin-top:0;margin-bottom:16px;font-size:13px">Leads, campaigns, and social — one place.</p>
|
||||
{% if form.errors %}
|
||||
<ul class="portal-flash">
|
||||
<li class="error">Invalid email or password.</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form class="form-grid" method="post" action="{% url 'accounts:login' %}">
|
||||
{% csrf_token %}
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
<div class="field">
|
||||
<label for="id_username">Email</label>
|
||||
<input id="id_username" type="text" name="username" autocomplete="username" required value="{{ form.username.value|default:'' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_password">Password</label>
|
||||
<input id="id_password" type="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" data-tianji-event="login_submit">Sign in</button>
|
||||
</form>
|
||||
<p style="margin-top:16px;font-size:13px;color:#6b7280">
|
||||
<a href="{% url 'public:home' %}">← Back to site</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import path
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"login/",
|
||||
auth_views.LoginView.as_view(template_name="accounts/login.html"),
|
||||
name="login",
|
||||
),
|
||||
path(
|
||||
"logout/",
|
||||
auth_views.LogoutView.as_view(),
|
||||
name="logout",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from analytics.models import Attribution, UTMVisit
|
||||
|
||||
|
||||
@admin.register(UTMVisit)
|
||||
class UTMVisitAdmin(admin.ModelAdmin):
|
||||
list_display = ("utm_source", "utm_campaign", "path", "created_at")
|
||||
search_fields = ("utm_source", "utm_campaign", "correlation_id")
|
||||
|
||||
|
||||
@admin.register(Attribution)
|
||||
class AttributionAdmin(admin.ModelAdmin):
|
||||
list_display = ("lead", "utm_source", "utm_campaign", "created_at")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "analytics"
|
||||
@@ -0,0 +1,62 @@
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
from analytics.models import Attribution, UTMVisit
|
||||
|
||||
|
||||
CORRELATION_COOKIE = "ms_cid"
|
||||
|
||||
|
||||
class UTMTrackingMiddleware:
|
||||
"""Capture UTM params into UTMVisit and stash a correlation id cookie."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
cid = request.COOKIES.get(CORRELATION_COOKIE) or get_random_string(32)
|
||||
request.utm_correlation_id = cid
|
||||
|
||||
params = request.GET
|
||||
has_utm = any(params.get(k) for k in (
|
||||
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"
|
||||
))
|
||||
if has_utm or params.get("utm_source"):
|
||||
UTMVisit.objects.create(
|
||||
correlation_id=cid,
|
||||
path=request.path[:512],
|
||||
referrer=(request.META.get("HTTP_REFERER") or "")[:1024],
|
||||
utm_source=params.get("utm_source", "")[:128],
|
||||
utm_medium=params.get("utm_medium", "")[:128],
|
||||
utm_campaign=params.get("utm_campaign", "")[:128],
|
||||
utm_term=params.get("utm_term", "")[:128],
|
||||
utm_content=params.get("utm_content", "")[:128],
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:512],
|
||||
)
|
||||
|
||||
response = self.get_response(request)
|
||||
if CORRELATION_COOKIE not in request.COOKIES:
|
||||
response.set_cookie(
|
||||
CORRELATION_COOKIE,
|
||||
cid,
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
samesite="Lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def attribute_lead_from_request(request, lead) -> Attribution | None:
|
||||
cid = getattr(request, "utm_correlation_id", None) or request.COOKIES.get(
|
||||
CORRELATION_COOKIE
|
||||
)
|
||||
visit = None
|
||||
if cid:
|
||||
visit = (
|
||||
UTMVisit.objects.filter(correlation_id=cid).order_by("-created_at").first()
|
||||
)
|
||||
return Attribution.objects.create(
|
||||
lead=lead,
|
||||
visit=visit,
|
||||
utm_source=visit.utm_source if visit else "",
|
||||
utm_medium=visit.utm_medium if visit else "",
|
||||
utm_campaign=visit.utm_campaign if visit else "",
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('leads', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UTMVisit',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('correlation_id', models.CharField(db_index=True, max_length=64)),
|
||||
('path', models.CharField(blank=True, max_length=512)),
|
||||
('referrer', models.URLField(blank=True, max_length=1024)),
|
||||
('utm_source', models.CharField(blank=True, max_length=128)),
|
||||
('utm_medium', models.CharField(blank=True, max_length=128)),
|
||||
('utm_campaign', models.CharField(blank=True, max_length=128)),
|
||||
('utm_term', models.CharField(blank=True, max_length=128)),
|
||||
('utm_content', models.CharField(blank=True, max_length=128)),
|
||||
('user_agent', models.CharField(blank=True, max_length=512)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Attribution',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('utm_source', models.CharField(blank=True, max_length=128)),
|
||||
('utm_medium', models.CharField(blank=True, max_length=128)),
|
||||
('utm_campaign', models.CharField(blank=True, max_length=128)),
|
||||
('lead', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='attribution', to='leads.lead')),
|
||||
('visit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='analytics.utmvisit')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
from leads.models import Lead
|
||||
|
||||
|
||||
class UTMVisit(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
correlation_id = models.CharField(max_length=64, db_index=True)
|
||||
path = models.CharField(max_length=512, blank=True)
|
||||
referrer = models.URLField(blank=True, max_length=1024)
|
||||
utm_source = models.CharField(max_length=128, blank=True)
|
||||
utm_medium = models.CharField(max_length=128, blank=True)
|
||||
utm_campaign = models.CharField(max_length=128, blank=True)
|
||||
utm_term = models.CharField(max_length=128, blank=True)
|
||||
utm_content = models.CharField(max_length=128, blank=True)
|
||||
user_agent = models.CharField(max_length=512, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.utm_source or 'direct'} / {self.path}"
|
||||
|
||||
|
||||
class Attribution(TimeStampedModel):
|
||||
lead = models.OneToOneField(
|
||||
Lead, on_delete=models.CASCADE, related_name="attribution"
|
||||
)
|
||||
visit = models.ForeignKey(
|
||||
UTMVisit, null=True, blank=True, on_delete=models.SET_NULL
|
||||
)
|
||||
utm_source = models.CharField(max_length=128, blank=True)
|
||||
utm_medium = models.CharField(max_length=128, blank=True)
|
||||
utm_campaign = models.CharField(max_length=128, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"attr {self.lead_id} ← {self.utm_source or 'direct'}"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Analytics services."""
|
||||
|
||||
from analytics.middleware import attribute_lead_from_request # noqa: F401
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Analytics · Portal{% endblock %}
|
||||
{% block topbar_title %}Analytics{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">Views (last 30 days)</div>
|
||||
<div class="value">{{ visits_last_30_days }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">UTM landings</div>
|
||||
<div class="value">{{ total_visits }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Attributed leads</div>
|
||||
<div class="value">{{ total_attributed }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Top source</div>
|
||||
<div class="value" style="font-size:18px">{{ top_source|default:"—" }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Top campaign</div>
|
||||
<div class="value" style="font-size:18px">{{ top_campaign|default:"—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Landing volume by source</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="chart-placeholder">
|
||||
{% for row in visits_by_source|slice:":8" %}
|
||||
<div class="bar" style="height:{{ row.bar_pct }}%" title="{{ row.utm_source|default:'(direct)' }}: {{ row.count }}"></div>
|
||||
{% empty %}
|
||||
<div class="bar" style="height:12%"></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>UTM landings</h2><span class="muted" style="font-size:12px">From ?utm_* hits</span></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Medium</th>
|
||||
<th>Campaign</th>
|
||||
<th>Landings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in visits_by_combo %}
|
||||
<tr>
|
||||
<td>{{ row.utm_source|default:"(direct)" }}</td>
|
||||
<td>{{ row.utm_medium|default:"—" }}</td>
|
||||
<td>{{ row.utm_campaign|default:"—" }}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No UTM landings yet. Open a public URL with ?utm_source=…</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Recent landings</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Path</th>
|
||||
<th>Source</th>
|
||||
<th>Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for visit in recent_visits %}
|
||||
<tr>
|
||||
<td>{{ visit.created_at|date:"M j, g:i A" }}</td>
|
||||
<td>{{ visit.path }}</td>
|
||||
<td>{{ visit.utm_source|default:"—" }}</td>
|
||||
<td>{{ visit.utm_campaign|default:"—" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No visits recorded.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Lead attribution</h2><span class="muted" style="font-size:12px">After contact-form submit</span></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Medium</th>
|
||||
<th>Campaign</th>
|
||||
<th>Leads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in leads_by_combo %}
|
||||
<tr>
|
||||
<td>{{ row.utm_source|default:"(direct)" }}</td>
|
||||
<td>{{ row.utm_medium|default:"—" }}</td>
|
||||
<td>{{ row.utm_campaign|default:"—" }}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No attributed leads yet — submit the contact form after a UTM landing.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from analytics import views
|
||||
|
||||
app_name = "analytics"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.report, name="report"),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
|
||||
from analytics.models import Attribution, UTMVisit
|
||||
|
||||
|
||||
def _bar_pct(rows, key="count"):
|
||||
max_count = max((row[key] for row in rows), default=1) or 1
|
||||
for row in rows:
|
||||
row["bar_pct"] = max(12, int(100 * row[key] / max_count))
|
||||
return rows
|
||||
|
||||
|
||||
@login_required
|
||||
def report(request):
|
||||
since_30d = timezone.now() - timedelta(days=30)
|
||||
visits_last_30_days = UTMVisit.objects.filter(created_at__gte=since_30d).count()
|
||||
|
||||
visits_by_source = _bar_pct(
|
||||
list(
|
||||
UTMVisit.objects.values("utm_source")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:20]
|
||||
)
|
||||
)
|
||||
visits_by_combo = list(
|
||||
UTMVisit.objects.values("utm_source", "utm_medium", "utm_campaign")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:50]
|
||||
)
|
||||
top_visit = visits_by_source[0] if visits_by_source else None
|
||||
top_visit_campaign = (
|
||||
UTMVisit.objects.exclude(utm_campaign="")
|
||||
.values("utm_campaign")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
.first()
|
||||
)
|
||||
|
||||
leads_by_source = _bar_pct(
|
||||
list(
|
||||
Attribution.objects.values("utm_source")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:20]
|
||||
)
|
||||
)
|
||||
leads_by_combo = list(
|
||||
Attribution.objects.values("utm_source", "utm_medium", "utm_campaign")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:50]
|
||||
)
|
||||
|
||||
recent_visits = UTMVisit.objects.all()[:25]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"analytics/report.html",
|
||||
{
|
||||
"visits_last_30_days": visits_last_30_days,
|
||||
"total_visits": UTMVisit.objects.count(),
|
||||
"total_attributed": Attribution.objects.count(),
|
||||
"top_source": (top_visit or {}).get("utm_source") or "(direct)",
|
||||
"top_campaign": (top_visit_campaign or {}).get("utm_campaign") or "—",
|
||||
"visits_by_source": visits_by_source,
|
||||
"visits_by_combo": visits_by_combo,
|
||||
"leads_by_source": leads_by_source,
|
||||
"leads_by_combo": leads_by_combo,
|
||||
"recent_visits": recent_visits,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from contacts.models import ConsentRecord, Contact, Suppression
|
||||
|
||||
|
||||
class ConsentInline(admin.TabularInline):
|
||||
model = ConsentRecord
|
||||
extra = 0
|
||||
|
||||
|
||||
class SuppressionInline(admin.TabularInline):
|
||||
model = Suppression
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Contact)
|
||||
class ContactAdmin(admin.ModelAdmin):
|
||||
list_display = ("email", "first_name", "last_name", "phone", "source", "created_at")
|
||||
search_fields = ("email", "first_name", "last_name", "phone")
|
||||
list_filter = ("source",)
|
||||
fields = (
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"postal_address",
|
||||
"source",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
inlines = [ConsentInline, SuppressionInline]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContactsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "contacts"
|
||||
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Contact',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)),
|
||||
('phone', models.CharField(blank=True, max_length=32)),
|
||||
('first_name', models.CharField(blank=True, max_length=100)),
|
||||
('last_name', models.CharField(blank=True, max_length=100)),
|
||||
('postal_address', models.JSONField(blank=True, default=dict)),
|
||||
('source', models.CharField(choices=[('contact_form', 'Contact form'), ('import', 'Import'), ('manual', 'Manual'), ('notify_me', 'Notify me'), ('other', 'Other')], default='other', max_length=32)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConsentRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('opted_in', models.BooleanField(default=False)),
|
||||
('changed_at', models.DateTimeField(auto_now=True)),
|
||||
('reason', models.CharField(blank=True, max_length=255)),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='contacts.contact')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-changed_at'],
|
||||
'unique_together': {('contact', 'channel')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Suppression',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('reason', models.CharField(blank=True, max_length=255)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suppressions', to='contacts.contact')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('contact', 'channel')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Channel(models.TextChoices):
|
||||
EMAIL = "email", "Email"
|
||||
SMS = "sms", "SMS"
|
||||
POSTCARD = "postcard", "Postcard"
|
||||
|
||||
|
||||
class Contact(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Source(models.TextChoices):
|
||||
CONTACT_FORM = "contact_form", "Contact form"
|
||||
IMPORT = "import", "Import"
|
||||
MANUAL = "manual", "Manual"
|
||||
NOTIFY_ME = "notify_me", "Notify me"
|
||||
OTHER = "other", "Other"
|
||||
|
||||
email = models.EmailField(unique=True, blank=True, null=True)
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
first_name = models.CharField(max_length=100, blank=True)
|
||||
last_name = models.CharField(max_length=100, blank=True)
|
||||
postal_address = models.JSONField(default=dict, blank=True)
|
||||
source = models.CharField(
|
||||
max_length=32, choices=Source.choices, default=Source.OTHER
|
||||
)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
name = f"{self.first_name} {self.last_name}".strip()
|
||||
return name or self.email or self.phone or str(self.pk)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
|
||||
@staticmethod
|
||||
def make_postal_address(
|
||||
*,
|
||||
line1: str = "",
|
||||
line2: str = "",
|
||||
city: str = "",
|
||||
state: str = "",
|
||||
zip_code: str = "",
|
||||
country: str = "US",
|
||||
) -> dict:
|
||||
"""Normalize Lob-shaped postal address dict."""
|
||||
return {
|
||||
"line1": (line1 or "").strip(),
|
||||
"line2": (line2 or "").strip(),
|
||||
"city": (city or "").strip(),
|
||||
"state": (state or "").strip(),
|
||||
"zip": (zip_code or "").strip(),
|
||||
"country": ((country or "").strip() or "US"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def postal_address_has_content(addr: dict | None) -> bool:
|
||||
if not addr:
|
||||
return False
|
||||
return any(
|
||||
(addr.get(key) or "").strip()
|
||||
for key in ("line1", "line2", "city", "state", "zip")
|
||||
)
|
||||
|
||||
|
||||
class ConsentRecord(TimeStampedModel):
|
||||
contact = models.ForeignKey(
|
||||
Contact, on_delete=models.CASCADE, related_name="consents"
|
||||
)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
opted_in = models.BooleanField(default=False)
|
||||
changed_at = models.DateTimeField(auto_now=True)
|
||||
reason = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("contact", "channel")
|
||||
ordering = ["-changed_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
state = "in" if self.opted_in else "out"
|
||||
return f"{self.contact} {self.channel} opt-{state}"
|
||||
|
||||
|
||||
class Suppression(TimeStampedModel):
|
||||
contact = models.ForeignKey(
|
||||
Contact, on_delete=models.CASCADE, related_name="suppressions"
|
||||
)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
reason = models.CharField(max_length=255, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("contact", "channel")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"suppress {self.contact} {self.channel}"
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Nominatim client — server-side only; browsers never call Nominatim directly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ISO3166-2-lvl4 "US-OH" → "OH"; fall back to common full-name map.
|
||||
_US_STATE_ABBREV = {
|
||||
"alabama": "AL",
|
||||
"alaska": "AK",
|
||||
"arizona": "AZ",
|
||||
"arkansas": "AR",
|
||||
"california": "CA",
|
||||
"colorado": "CO",
|
||||
"connecticut": "CT",
|
||||
"delaware": "DE",
|
||||
"district of columbia": "DC",
|
||||
"florida": "FL",
|
||||
"georgia": "GA",
|
||||
"hawaii": "HI",
|
||||
"idaho": "ID",
|
||||
"illinois": "IL",
|
||||
"indiana": "IN",
|
||||
"iowa": "IA",
|
||||
"kansas": "KS",
|
||||
"kentucky": "KY",
|
||||
"louisiana": "LA",
|
||||
"maine": "ME",
|
||||
"maryland": "MD",
|
||||
"massachusetts": "MA",
|
||||
"michigan": "MI",
|
||||
"minnesota": "MN",
|
||||
"mississippi": "MS",
|
||||
"missouri": "MO",
|
||||
"montana": "MT",
|
||||
"nebraska": "NE",
|
||||
"nevada": "NV",
|
||||
"new hampshire": "NH",
|
||||
"new jersey": "NJ",
|
||||
"new mexico": "NM",
|
||||
"new york": "NY",
|
||||
"north carolina": "NC",
|
||||
"north dakota": "ND",
|
||||
"ohio": "OH",
|
||||
"oklahoma": "OK",
|
||||
"oregon": "OR",
|
||||
"pennsylvania": "PA",
|
||||
"rhode island": "RI",
|
||||
"south carolina": "SC",
|
||||
"south dakota": "SD",
|
||||
"tennessee": "TN",
|
||||
"texas": "TX",
|
||||
"utah": "UT",
|
||||
"vermont": "VT",
|
||||
"virginia": "VA",
|
||||
"washington": "WA",
|
||||
"west virginia": "WV",
|
||||
"wisconsin": "WI",
|
||||
"wyoming": "WY",
|
||||
}
|
||||
|
||||
|
||||
class NominatimError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# Leading house / unit number from user query (e.g. "1968", "12A", "100-102").
|
||||
_HOUSE_FROM_QUERY = re.compile(r"^(\d+[A-Za-z]?(?:-\d+[A-Za-z]?)?)\b")
|
||||
|
||||
|
||||
def _house_from_query(query: str) -> str:
|
||||
match = _HOUSE_FROM_QUERY.match((query or "").strip())
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def _state_code(addr: dict[str, Any]) -> str:
|
||||
iso = (addr.get("ISO3166-2-lvl4") or "").strip()
|
||||
if iso.startswith("US-") and len(iso) == 5:
|
||||
return iso[3:]
|
||||
raw = (addr.get("state") or "").strip()
|
||||
if len(raw) == 2:
|
||||
return raw.upper()
|
||||
return _US_STATE_ABBREV.get(raw.lower(), raw)
|
||||
|
||||
|
||||
def _city(addr: dict[str, Any]) -> str:
|
||||
for key in ("city", "town", "village", "hamlet", "municipality", "suburb"):
|
||||
val = (addr.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _line1(addr: dict[str, Any], display_name: str, *, query: str = "") -> str:
|
||||
house = (addr.get("house_number") or "").strip()
|
||||
road = (addr.get("road") or addr.get("pedestrian") or "").strip()
|
||||
# Nominatim often returns road-level hits with no house_number even when the
|
||||
# user typed one — keep that number so mailing street isn't incomplete.
|
||||
if not house:
|
||||
house = _house_from_query(query)
|
||||
if house and road:
|
||||
return f"{house} {road}"
|
||||
if road:
|
||||
return road
|
||||
# Place-level hits (city only) — leave street empty for the user to fill.
|
||||
if house or road:
|
||||
return " ".join(p for p in (house, road) if p)
|
||||
first = (display_name or "").split(",")[0].strip()
|
||||
# Avoid stuffing "Akron" into street when it's a city result.
|
||||
if first and first.lower() != _city(addr).lower():
|
||||
return first
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_hit(raw: dict[str, Any], *, query: str = "") -> dict[str, str]:
|
||||
addr = raw.get("address") or {}
|
||||
if not isinstance(addr, dict):
|
||||
addr = {}
|
||||
country_code = (addr.get("country_code") or "us").upper()
|
||||
if country_code == "US":
|
||||
country = "US"
|
||||
else:
|
||||
country = country_code[:2] or "US"
|
||||
display = (raw.get("display_name") or "").strip()
|
||||
line1 = _line1(addr, display, query=query)
|
||||
label = display
|
||||
# Surface recovered house number in the dropdown when OSM omitted it.
|
||||
house = (addr.get("house_number") or "").strip() or _house_from_query(query)
|
||||
if house and label and not re.match(rf"^{re.escape(house)}\b", label, re.I):
|
||||
label = f"{house} {label}"
|
||||
return {
|
||||
"label": label,
|
||||
"line1": line1,
|
||||
"line2": "",
|
||||
"city": _city(addr),
|
||||
"state": _state_code(addr),
|
||||
"zip": (addr.get("postcode") or "").strip().split(";")[0].strip(),
|
||||
"country": country,
|
||||
}
|
||||
|
||||
|
||||
def suggest_addresses(query: str, *, limit: int = 5) -> list[dict[str, str]]:
|
||||
"""
|
||||
Proxy Nominatim /search. Returns normalized address dicts for the UI.
|
||||
|
||||
Nominatim itself has no API-key auth — LAN firewall + this Django proxy
|
||||
gate access. Optional NOMINATIM_API_KEY is sent as X-API-Key if you put
|
||||
a gateway in front of Nominatim later.
|
||||
"""
|
||||
base = (settings.NOMINATIM_BASE_URL or "").rstrip("/")
|
||||
if not base:
|
||||
raise NominatimError("NOMINATIM_BASE_URL is not configured")
|
||||
|
||||
q = (query or "").strip()
|
||||
if len(q) < 3:
|
||||
return []
|
||||
|
||||
limit = max(1, min(int(limit or 5), 8))
|
||||
params: dict[str, str | int] = {
|
||||
"q": q,
|
||||
"format": "json",
|
||||
"addressdetails": 1,
|
||||
"limit": limit,
|
||||
}
|
||||
countrycodes = (settings.NOMINATIM_COUNTRY_CODES or "").strip()
|
||||
if countrycodes:
|
||||
params["countrycodes"] = countrycodes
|
||||
|
||||
headers = {
|
||||
"User-Agent": settings.NOMINATIM_USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
api_key = (settings.NOMINATIM_API_KEY or "").strip()
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
|
||||
url = f"{base}/search"
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=settings.NOMINATIM_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("Nominatim request failed")
|
||||
raise NominatimError(f"Nominatim unreachable at {url}: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise NominatimError("Nominatim returned invalid JSON") from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
|
||||
results: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized = normalize_hit(item, query=q)
|
||||
key = re.sub(r"\s+", " ", normalized["label"].lower())
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
results.append(normalized)
|
||||
return results
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}{{ contact }} · Contact{% endblock %}
|
||||
{% block topbar_title %}Contact · {{ contact }}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
|
||||
{% endblock %}
|
||||
{% block portal_content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Profile</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field"><label>First name</label><input value="{{ contact.first_name }}" readonly></div>
|
||||
<div class="field"><label>Last name</label><input value="{{ contact.last_name }}" readonly></div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field"><label>Email</label><input value="{{ contact.email }}" readonly></div>
|
||||
<div class="field"><label>Phone</label><input value="{{ contact.phone }}" readonly></div>
|
||||
</div>
|
||||
<div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div>
|
||||
|
||||
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
|
||||
<div class="field address-ac-wrap">
|
||||
<label>Street address</label>
|
||||
<input name="address_line1" data-ac="line1" value="{{ contact.postal_address.line1|default:'' }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Apt / suite</label>
|
||||
<input name="address_line2" data-ac="line2" value="{{ contact.postal_address.line2|default:'' }}" autocomplete="address-line2">
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label>City</label>
|
||||
<input name="address_city" data-ac="city" value="{{ contact.postal_address.city|default:'' }}" autocomplete="address-level2">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>State</label>
|
||||
<input name="address_state" data-ac="state" value="{{ contact.postal_address.state|default:'' }}" autocomplete="address-level1" maxlength="32">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label>ZIP</label>
|
||||
<input name="address_zip" data-ac="zip" value="{{ contact.postal_address.zip|default:'' }}" autocomplete="postal-code" maxlength="20">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Country</label>
|
||||
<input name="address_country" data-ac="country" value="{{ contact.postal_address.country|default:'US' }}" autocomplete="country" maxlength="2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field"><label>Notes</label>
|
||||
<textarea name="notes">{{ contact.notes }}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save</button>
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:list' %}">← Mailing list</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Consent</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="field">
|
||||
<label class="check-row"><input type="checkbox" name="consent_email" value="1" {% if prefs.email %}checked{% endif %}> Email marketing</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check-row"><input type="checkbox" name="consent_sms" value="1" {% if prefs.sms %}checked{% endif %}> SMS updates</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label>
|
||||
</div>
|
||||
<p class="hint-block" style="margin-top:16px">Postcard campaigns need a street address and postcard consent. Opt-outs also write a suppression so campaigns skip this contact.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/address-autocomplete.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Import contacts · Portal{% endblock %}
|
||||
{% block topbar_title %}Import contacts{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="steps">
|
||||
<div class="step active"><span>1</span> Upload</div>
|
||||
<div class="step"><span>2</span> Map columns</div>
|
||||
<div class="step"><span>3</span> Consent</div>
|
||||
<div class="step"><span>4</span> Import</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Upload</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="dropzone">
|
||||
<p style="margin:0 0 8px"><strong>Drop CSV or Excel here</strong></p>
|
||||
<p class="muted" style="margin:0">Import processing wires up next. Accepted: .csv, .xlsx</p>
|
||||
<p style="margin:16px 0 0"><button class="btn btn-ghost btn-sm" type="button" disabled>Choose file</button></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Column mapping</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead><tr><th>Your column</th><th>Maps to</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Email</td><td>email</td></tr>
|
||||
<tr><td>First</td><td>first_name</td></tr>
|
||||
<tr><td>Last</td><td>last_name</td></tr>
|
||||
<tr><td>Phone</td><td>phone</td></tr>
|
||||
<tr><td>Street</td><td>postal_address.line1</td></tr>
|
||||
<tr><td>City</td><td>postal_address.city</td></tr>
|
||||
<tr><td>State</td><td>postal_address.state</td></tr>
|
||||
<tr><td>ZIP</td><td>postal_address.zip</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Consent defaults</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
<label class="check-row"><input type="checkbox" checked disabled> Email marketing</label>
|
||||
<label class="check-row"><input type="checkbox" disabled> SMS</label>
|
||||
<label class="check-row"><input type="checkbox" disabled> Postcard</label>
|
||||
<div class="field"><label>Source</label><input value="Import" disabled></div>
|
||||
<div class="field"><label>Duplicates</label><select disabled><option>Update existing by email</option></select></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Preview</h2></div>
|
||||
<div class="panel-b">
|
||||
<p class="muted">Sample rows appear after upload.</p>
|
||||
<button class="btn btn-primary" type="button" disabled>Import contacts</button>
|
||||
<p class="hint-block"><a href="{% url 'contacts:list' %}">← Back to mailing list</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Mailing list · Portal{% endblock %}
|
||||
{% block topbar_title %}Mailing list{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<form class="toolbar-filters" method="get">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Search contacts">
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Search</button>
|
||||
</form>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Contact</th>
|
||||
<th>Address</th>
|
||||
<th>Consent</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in contacts %}
|
||||
<tr>
|
||||
<td><input type="checkbox" disabled></td>
|
||||
<td>
|
||||
<a href="{% url 'contacts:detail' contact.pk %}">{{ contact }}</a><br>
|
||||
<span class="muted">
|
||||
{% if contact.email %}{{ contact.email }}{% endif %}
|
||||
{% if contact.email and contact.phone %} · {% endif %}
|
||||
{% if contact.phone %}{{ contact.phone }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if contact.postal_address.line1 %}
|
||||
{{ contact.postal_address.line1 }}{% if contact.postal_address.city %}, {{ contact.postal_address.city }}{% endif %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% with c=contact.consent_flags %}
|
||||
<span class="badge {% if c.email %}badge-optin{% else %}badge-optout{% endif %}">E</span>
|
||||
<span class="badge {% if c.sms %}badge-optin{% else %}badge-optout{% endif %}">S</span>
|
||||
<span class="badge {% if c.postcard %}badge-optin{% else %}badge-optout{% endif %}">P</span>
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td>{{ contact.get_source_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No contacts yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:13px">
|
||||
E = email · S = SMS · P = postcard.
|
||||
<a href="{% url 'contacts:import' %}">Import contacts</a> for bulk CSV/Excel.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
from contacts.nominatim import normalize_hit
|
||||
|
||||
|
||||
def test_line1_keeps_house_number_from_query_when_nominatim_omits_it():
|
||||
raw = {
|
||||
"display_name": (
|
||||
"Greensboro Drive, Wheaton, DuPage County, Illinois, 60189, United States"
|
||||
),
|
||||
"address": {
|
||||
"road": "Greensboro Drive",
|
||||
"town": "Wheaton",
|
||||
"county": "DuPage County",
|
||||
"state": "Illinois",
|
||||
"postcode": "60189",
|
||||
"country_code": "us",
|
||||
"ISO3166-2-lvl4": "US-IL",
|
||||
},
|
||||
}
|
||||
hit = normalize_hit(raw, query="1968 Greensboro Drive, Wheaton")
|
||||
assert hit["line1"] == "1968 Greensboro Drive"
|
||||
assert hit["label"].startswith("1968 Greensboro Drive")
|
||||
assert hit["city"] == "Wheaton"
|
||||
assert hit["state"] == "IL"
|
||||
assert hit["zip"] == "60189"
|
||||
|
||||
|
||||
def test_line1_prefers_nominatim_house_number():
|
||||
raw = {
|
||||
"display_name": "1968 Greensboro Drive, Wheaton, Illinois, 60189, United States",
|
||||
"address": {
|
||||
"house_number": "1968",
|
||||
"road": "Greensboro Drive",
|
||||
"town": "Wheaton",
|
||||
"state": "Illinois",
|
||||
"postcode": "60189",
|
||||
"country_code": "us",
|
||||
"ISO3166-2-lvl4": "US-IL",
|
||||
},
|
||||
}
|
||||
hit = normalize_hit(raw, query="1968 Greensboro Drive")
|
||||
assert hit["line1"] == "1968 Greensboro Drive"
|
||||
assert hit["label"] == raw["display_name"]
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from contacts import views
|
||||
|
||||
app_name = "contacts"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.contact_list, name="list"),
|
||||
path("import/", views.contact_import, name="import"),
|
||||
path("<uuid:pk>/", views.contact_detail, name="detail"),
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_GET, require_http_methods
|
||||
|
||||
from contacts.models import Channel, ConsentRecord, Contact
|
||||
from contacts.nominatim import NominatimError, suggest_addresses
|
||||
from messaging.services import channel_preferences, set_channel_preferences
|
||||
|
||||
|
||||
def _consent_flags(contact: Contact) -> dict[str, bool]:
|
||||
return channel_preferences(contact)
|
||||
|
||||
|
||||
def _postal_from_post(post) -> dict:
|
||||
return Contact.make_postal_address(
|
||||
line1=post.get("address_line1", ""),
|
||||
line2=post.get("address_line2", ""),
|
||||
city=post.get("address_city", ""),
|
||||
state=post.get("address_state", ""),
|
||||
zip_code=post.get("address_zip", ""),
|
||||
country=post.get("address_country", "US"),
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def contact_list(request):
|
||||
contacts = Contact.objects.prefetch_related(
|
||||
Prefetch("consents", queryset=ConsentRecord.objects.all())
|
||||
).all()
|
||||
q = (request.GET.get("q") or "").strip()
|
||||
if q:
|
||||
contacts = contacts.filter(
|
||||
Q(first_name__icontains=q)
|
||||
| Q(last_name__icontains=q)
|
||||
| Q(email__icontains=q)
|
||||
| Q(phone__icontains=q)
|
||||
)
|
||||
rows = list(contacts[:200])
|
||||
for contact in rows:
|
||||
contact.consent_flags = _consent_flags(contact)
|
||||
return render(
|
||||
request,
|
||||
"contacts/list.html",
|
||||
{"contacts": rows, "q": q},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def contact_detail(request, pk):
|
||||
contact = get_object_or_404(
|
||||
Contact.objects.prefetch_related("consents"), pk=pk
|
||||
)
|
||||
if request.method == "POST":
|
||||
contact.postal_address = _postal_from_post(request.POST)
|
||||
contact.notes = (request.POST.get("notes") or "").strip()
|
||||
contact.save(update_fields=["postal_address", "notes", "updated_at"])
|
||||
set_channel_preferences(
|
||||
contact,
|
||||
{
|
||||
Channel.EMAIL: "consent_email" in request.POST,
|
||||
Channel.SMS: "consent_sms" in request.POST,
|
||||
Channel.POSTCARD: "consent_postcard" in request.POST,
|
||||
},
|
||||
reason="portal_manual",
|
||||
)
|
||||
messages.success(request, "Contact updated.")
|
||||
return redirect("contacts:detail", pk=contact.pk)
|
||||
prefs = _consent_flags(contact)
|
||||
return render(
|
||||
request,
|
||||
"contacts/detail.html",
|
||||
{"contact": contact, "prefs": prefs},
|
||||
)
|
||||
|
||||
@login_required
|
||||
def contact_import(request):
|
||||
return render(request, "contacts/import.html")
|
||||
|
||||
|
||||
@require_GET
|
||||
def address_suggest(request):
|
||||
"""
|
||||
Backend proxy for Nominatim search. Browser JS must call this URL only —
|
||||
never Nominatim directly.
|
||||
"""
|
||||
q = (request.GET.get("q") or "").strip()
|
||||
if len(q) < 3:
|
||||
return JsonResponse({"results": []})
|
||||
try:
|
||||
limit = int(request.GET.get("limit") or 5)
|
||||
except (TypeError, ValueError):
|
||||
limit = 5
|
||||
try:
|
||||
results = suggest_addresses(q, limit=limit)
|
||||
except NominatimError as exc:
|
||||
return JsonResponse({"error": str(exc), "results": []}, status=502)
|
||||
return JsonResponse({"results": results})
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
@@ -0,0 +1,38 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from messaging.models import Message
|
||||
from messaging.tasks import send_campaign_message
|
||||
from social.models import SocialPost
|
||||
from social.tasks import publish_social_post
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Enqueue due scheduled campaign messages and social posts. "
|
||||
"Optional when the task backend supports run_after defer; useful as a safety net."
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
now = timezone.now()
|
||||
enqueued = 0
|
||||
|
||||
for message in Message.objects.filter(
|
||||
status=Message.Status.SCHEDULED,
|
||||
scheduled_for__lte=now,
|
||||
).iterator():
|
||||
message.status = Message.Status.QUEUED
|
||||
message.save(update_fields=["status", "updated_at"])
|
||||
send_campaign_message.enqueue(message_id=str(message.pk))
|
||||
enqueued += 1
|
||||
|
||||
for post in SocialPost.objects.filter(
|
||||
status=SocialPost.Status.SCHEDULED,
|
||||
scheduled_for__lte=now,
|
||||
).iterator():
|
||||
post.status = SocialPost.Status.QUEUED
|
||||
post.save(update_fields=["status", "updated_at"])
|
||||
publish_social_post.enqueue(post_id=str(post.pk))
|
||||
enqueued += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s)."))
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
"""Abstract base with created/updated timestamps (mirrors company_site TimeInfoBase)."""
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class UUIDPrimaryKeyModel(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class HealthzTests(TestCase):
|
||||
def test_healthz_ok(self):
|
||||
response = Client().get("/healthz/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["status"], "ok")
|
||||
|
||||
|
||||
class UnderConstructionTests(TestCase):
|
||||
@override_settings(SITE_UNDER_CONSTRUCTION=True)
|
||||
def test_home_redirects_when_gated(self):
|
||||
response = Client().get("/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/under-construction", response["Location"])
|
||||
|
||||
@override_settings(SITE_UNDER_CONSTRUCTION=False)
|
||||
def test_home_ok_when_open(self):
|
||||
response = Client().get("/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class PublicSmokeTests(TestCase):
|
||||
def test_about_and_contact_get(self):
|
||||
client = Client()
|
||||
self.assertEqual(client.get(reverse("public:about")).status_code, 200)
|
||||
self.assertEqual(client.get(reverse("public:contact")).status_code, 200)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
def healthz(_request):
|
||||
"""Liveness probe for deploy / NPM health checks."""
|
||||
return JsonResponse({"status": "ok"})
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DashboardConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "dashboard"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Dashboard · Portal{% endblock %}
|
||||
{% block topbar_title %}Dashboard{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">New leads</div>
|
||||
<div class="value">{{ lead_count }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Open pipeline</div>
|
||||
<div class="value">{{ open_pipeline }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Mailing list</div>
|
||||
<div class="value">{{ contact_count }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Scheduled posts</div>
|
||||
<div class="value">{{ scheduled_posts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Recent leads</h2>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'leads:list' %}">View all</a>
|
||||
</div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Source</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lead in recent_leads %}
|
||||
<tr>
|
||||
<td><a href="{% url 'leads:detail' lead.pk %}">{{ lead.contact }}</a></td>
|
||||
<td>{% if lead.attribution %}{{ lead.attribution.utm_source|default:"direct" }}{% if lead.attribution.utm_campaign %} / {{ lead.attribution.utm_campaign }}{% endif %}{% else %}—{% endif %}</td>
|
||||
<td><span class="badge badge-{{ lead.status }}">{{ lead.get_status_display }}</span></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No leads yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Upcoming outreach</h2>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'messaging:campaign_list' %}">Compose</a>
|
||||
</div>
|
||||
<div class="panel-b">
|
||||
{% for campaign in upcoming_campaigns %}
|
||||
<p style="margin:0 0 12px;font-size:14px">
|
||||
<strong><a href="{% url 'messaging:campaign_detail' campaign.pk %}">{{ campaign.name }}</a></strong>
|
||||
— {{ campaign.get_channel_display }} · {{ campaign.get_status_display }}
|
||||
{% if campaign.scheduled_for %} · {{ campaign.scheduled_for }}{% endif %}
|
||||
</p>
|
||||
{% empty %}
|
||||
<p class="empty-state" style="margin:0">No campaigns yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from dashboard import views
|
||||
|
||||
app_name = "dashboard"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
|
||||
from contacts.models import Contact
|
||||
from leads.models import Lead
|
||||
from messaging.models import Campaign
|
||||
from social.models import SocialPost
|
||||
|
||||
|
||||
@login_required
|
||||
def home(request):
|
||||
upcoming = Campaign.objects.exclude(
|
||||
status__in=[Campaign.Status.COMPLETED, Campaign.Status.CANCELLED]
|
||||
).order_by("scheduled_for", "-created_at")[:5]
|
||||
context = {
|
||||
"lead_count": Lead.objects.filter(status=Lead.Status.NEW).count(),
|
||||
"open_pipeline": Lead.objects.filter(
|
||||
status__in=[Lead.Status.NEW, Lead.Status.CONTACTED]
|
||||
).count(),
|
||||
"contact_count": Contact.objects.count(),
|
||||
"scheduled_posts": SocialPost.objects.filter(
|
||||
status__in=[SocialPost.Status.SCHEDULED, SocialPost.Status.QUEUED]
|
||||
).count(),
|
||||
"recent_leads": Lead.objects.select_related("contact", "attribution").all()[:8],
|
||||
"upcoming_campaigns": upcoming,
|
||||
}
|
||||
return render(request, "dashboard/home.html", context)
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from leads.models import Lead, LeadNote
|
||||
|
||||
|
||||
class LeadNoteInline(admin.TabularInline):
|
||||
model = LeadNote
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Lead)
|
||||
class LeadAdmin(admin.ModelAdmin):
|
||||
list_display = ("contact", "status", "created_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("contact__email", "contact__first_name", "contact__last_name")
|
||||
inlines = [LeadNoteInline]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LeadsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "leads"
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Lead',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('message', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('won', 'Won'), ('lost', 'Lost')], default='new', max_length=16)),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leads', to='contacts.contact')),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeadNote',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('body', models.TextField()),
|
||||
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('lead', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='leads.lead')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from contacts.models import Contact
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Lead(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
NEW = "new", "New"
|
||||
CONTACTED = "contacted", "Contacted"
|
||||
WON = "won", "Won"
|
||||
LOST = "lost", "Lost"
|
||||
|
||||
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="leads")
|
||||
message = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.NEW
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="leads",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Lead {self.contact} ({self.status})"
|
||||
|
||||
|
||||
class LeadNote(TimeStampedModel):
|
||||
lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name="notes")
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
body = models.TextField()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ lead.contact }} · Lead{% endblock %}
|
||||
{% block topbar_title %}Lead · {{ lead.contact }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Contact</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input type="email" value="{{ lead.contact.email }}" readonly>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Phone</label>
|
||||
<input type="text" value="{{ lead.contact.phone }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Message</label>
|
||||
<textarea readonly style="min-height:120px">{{ lead.message }}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<input type="text" value="{{ lead.get_status_display }}" readonly>
|
||||
</div>
|
||||
<p class="hint-block">Status edits and note posting come next in functionality work.</p>
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'leads:list' %}">← Back to inbox</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Notes</h2></div>
|
||||
<div class="panel-b">
|
||||
{% for note in lead.notes.all %}
|
||||
<div style="font-size:13px;color:#6b7280;margin-bottom:12px">
|
||||
<strong style="color:#1a1f2c">{% if note.author %}{{ note.author }}{% else %}System{% endif %}</strong>
|
||||
· {{ note.created_at|date:"M j, g:i A" }} — {{ note.body }}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="empty-state" style="padding:0;margin:0 0 12px">No notes yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Attribution</h2></div>
|
||||
<div class="panel-b" style="font-size:14px">
|
||||
{% if lead.attribution %}
|
||||
<p>
|
||||
<strong>utm_source</strong> {{ lead.attribution.utm_source|default:"—" }}<br>
|
||||
<strong>utm_medium</strong> {{ lead.attribution.utm_medium|default:"—" }}<br>
|
||||
<strong>utm_campaign</strong> {{ lead.attribution.utm_campaign|default:"—" }}<br>
|
||||
{% if lead.attribution.visit %}
|
||||
<strong>Landing</strong> {{ lead.attribution.visit.path|default:"—" }}<br>
|
||||
<strong>First touch</strong> {{ lead.attribution.visit.created_at|date:"M j, g:i A" }}
|
||||
{% endif %}
|
||||
<br><strong>Converted</strong> {{ lead.created_at|date:"M j, g:i A" }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="muted">No UTM attribution on this lead.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Linked contact · consent</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="consent-pills">
|
||||
{% for channel, opted in consent_map.items %}
|
||||
<span class="badge {% if opted %}badge-optin{% else %}badge-optout{% endif %}">
|
||||
{{ channel|title }} {% if opted %}on{% else %}off{% endif %}
|
||||
</span>
|
||||
{% empty %}
|
||||
<span class="muted">No consent records.</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p style="font-size:13px;color:#6b7280;margin:12px 0 0">
|
||||
Form submit defaulted email opt-in with notice. Postcard requires address + separate consent.
|
||||
</p>
|
||||
<p style="margin-top:12px">
|
||||
<a href="{% url 'contacts:detail' lead.contact.pk %}">Open contact record →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Leads · Portal{% endblock %}
|
||||
{% block topbar_title %}Lead inbox{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<form class="toolbar-filters" method="get">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Search name or email">
|
||||
<select name="status">
|
||||
<option value="">All statuses</option>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if status_filter == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lead</th>
|
||||
<th>Interest</th>
|
||||
<th>UTM / source</th>
|
||||
<th>Status</th>
|
||||
<th>Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lead in leads %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'leads:detail' lead.pk %}">{{ lead.contact }}</a><br>
|
||||
<span class="muted">{{ lead.contact.email }}{% if lead.contact.phone %} · {{ lead.contact.phone }}{% endif %}</span>
|
||||
</td>
|
||||
<td class="muted">{{ lead.message|truncatechars:40|default:"—" }}</td>
|
||||
<td>
|
||||
{% if lead.attribution %}
|
||||
{{ lead.attribution.utm_source|default:"direct" }}
|
||||
{% if lead.attribution.utm_medium %}/ {{ lead.attribution.utm_medium }}{% endif %}
|
||||
{% if lead.attribution.utm_campaign %}/ {{ lead.attribution.utm_campaign }}{% endif %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-{{ lead.status }}">{{ lead.get_status_display }}</span></td>
|
||||
<td>{{ lead.created_at|date:"M j, g:i A" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No leads match.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from leads import views
|
||||
|
||||
app_name = "leads"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.lead_list, name="list"),
|
||||
path("<uuid:pk>/", views.lead_detail, name="detail"),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from contacts.models import Channel
|
||||
from leads.models import Lead
|
||||
|
||||
|
||||
@login_required
|
||||
def lead_list(request):
|
||||
leads = Lead.objects.select_related("contact", "attribution").all()
|
||||
q = (request.GET.get("q") or "").strip()
|
||||
status = (request.GET.get("status") or "").strip()
|
||||
if q:
|
||||
leads = leads.filter(
|
||||
Q(contact__first_name__icontains=q)
|
||||
| Q(contact__last_name__icontains=q)
|
||||
| Q(contact__email__icontains=q)
|
||||
| Q(contact__phone__icontains=q)
|
||||
| Q(message__icontains=q)
|
||||
)
|
||||
if status:
|
||||
leads = leads.filter(status=status)
|
||||
return render(
|
||||
request,
|
||||
"leads/list.html",
|
||||
{
|
||||
"leads": leads[:200],
|
||||
"status_choices": Lead.Status.choices,
|
||||
"q": q,
|
||||
"status_filter": status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def lead_detail(request, pk):
|
||||
lead = get_object_or_404(
|
||||
Lead.objects.select_related(
|
||||
"contact", "attribution", "attribution__visit"
|
||||
).prefetch_related("notes", "contact__consents"),
|
||||
pk=pk,
|
||||
)
|
||||
consent_map = {c.value: False for c in Channel}
|
||||
for record in lead.contact.consents.all():
|
||||
consent_map[record.channel] = record.opted_in
|
||||
return render(
|
||||
request,
|
||||
"leads/detail.html",
|
||||
{"lead": lead, "consent_map": consent_map},
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "monica_site.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
# Messaging
|
||||
|
||||
Campaign compose/send, SMTP2GO email + SMS, PCM Integrations postcards, and delivery webhooks.
|
||||
|
||||
## SMTP2GO webhook setup
|
||||
|
||||
Campaign report page polls provider events every 10s. Create **two** webhooks in
|
||||
SMTP2GO → **Settings → Webhooks** (email and SMS stay separate).
|
||||
|
||||
### Auth (`SMTP2GO_WEBHOOK_SECRET`)
|
||||
|
||||
1. Set `SMTP2GO_WEBHOOK_SECRET` in `.env` / prod env (long random string).
|
||||
2. In SMTP2GO, set **Authorization header** to **Bearer** and paste that same secret
|
||||
(do not leave it as “None”).
|
||||
3. Fallback: `?token=<SMTP2GO_WEBHOOK_SECRET>` on the webhook URL also works.
|
||||
|
||||
### Email webhook
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/email/` |
|
||||
| Authorization header | **Bearer** + `SMTP2GO_WEBHOOK_SECRET` |
|
||||
| Output type | JSON |
|
||||
| Email events | processed, bounced, rejected, spam, delivered, unsub/resub, opened, clicked |
|
||||
| Email headers | `X-Monica-Message-Id` |
|
||||
| SMS events | leave unchecked |
|
||||
|
||||
`X-Monica-Message-Id` is set on every campaign email send and is required so webhook
|
||||
events match the correct recipient row.
|
||||
|
||||
Beta / other hosts: swap the hostname, keep the path.
|
||||
|
||||
### SMS webhook (separate)
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/sms/` |
|
||||
| Authorization header | **Bearer** + same `SMTP2GO_WEBHOOK_SECRET` |
|
||||
| Output type | JSON |
|
||||
| Email events | leave unchecked |
|
||||
| SMS events | Submitted, Sending, Delivered, Failed, Rejected, Opt-out |
|
||||
|
||||
This endpoint also accepts inbound reply POSTs (`text=STOP`, `from=…`) and opts the
|
||||
contact out of SMS.
|
||||
|
||||
## PCM Integrations (postcards)
|
||||
|
||||
Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail API v3.
|
||||
|
||||
### Env
|
||||
|
||||
| Var | Purpose |
|
||||
|-----|---------|
|
||||
| `PCM_API_KEY` | Bearer token for `https://v3.pcmintegrations.com` |
|
||||
| `PCM_WEBHOOK_SECRET` | Auth for inbound status webhooks |
|
||||
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
|
||||
| `POSTCARD_PROVIDER` | `pcm` (default) |
|
||||
|
||||
### Designer
|
||||
|
||||
Portal → **Postcard design**: create/list designs via API, edit in iframe
|
||||
(`POST /design/custom`, `GET /design/{id}/edit?mode=embed`). Save as a
|
||||
`MessageTemplate` (stores `design_id`) then pick it when composing a postcard campaign.
|
||||
|
||||
### Postcard webhook
|
||||
|
||||
Create a webhook subscription in the PCM dashboard (Working with Webhooks):
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` |
|
||||
| Authorization | **Bearer** + `PCM_WEBHOOK_SECRET` |
|
||||
| Events | Order / recipient status (Pending, Processing, Processed, Delivered, Undeliverable, Canceled) |
|
||||
| Environments | Sandbox and/or Production as needed |
|
||||
|
||||
Fallback: `?token=<PCM_WEBHOOK_SECRET>` on the URL.
|
||||
|
||||
Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
|
||||
echo that (or `orderID`, matched to `Message.provider_message_id`).
|
||||
|
||||
### Campaign completion email
|
||||
|
||||
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
|
||||
goes to `campaign.created_by.email`, else `CONTACT_EMAIL`. Guarded by
|
||||
`Campaign.notify_sent_at` so it only sends once.
|
||||
|
||||
### Local development
|
||||
|
||||
SMTP2GO / PCM cannot reach `localhost`. Use a tunnel (Cloudflare Tunnel / ngrok) to `:8000`,
|
||||
or test webhooks against beta/prod.
|
||||
|
||||
For real SMTP delivery locally (not console logs):
|
||||
|
||||
```bash
|
||||
# in .env
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST_USER=…
|
||||
EMAIL_HOST_PASSWORD=…
|
||||
SMTP2GO_WEBHOOK_SECRET=…
|
||||
SMTP2GO_SMS_API_KEY=… # SMS sends only
|
||||
PCM_API_KEY=…
|
||||
PCM_WEBHOOK_SECRET=…
|
||||
PCM_RETURN_ADDRESS={…}
|
||||
```
|
||||
|
||||
### Endpoints (app)
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `POST /portal/messaging/webhooks/email/` | Email delivery / open / click / bounce / … |
|
||||
| `POST /portal/messaging/webhooks/sms/` | SMS delivery events + inbound STOP |
|
||||
| `POST /portal/messaging/webhooks/postcard/` | PCM order / mail tracking events |
|
||||
| `GET /portal/messaging/campaigns/<id>/status.json` | Live stats for the campaign report UI |
|
||||
| `GET /portal/messaging/postcard/` | PCM designer iframe |
|
||||
|
||||
Code: `webhooks.py`, `providers/postcard/pcm.py`, `views.py`.
|
||||
@@ -0,0 +1,40 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
|
||||
|
||||
|
||||
@admin.register(MessageTemplate)
|
||||
class MessageTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "channel", "created_at")
|
||||
list_filter = ("channel",)
|
||||
|
||||
|
||||
class MessageInline(admin.TabularInline):
|
||||
model = Message
|
||||
extra = 0
|
||||
readonly_fields = ("status", "provider", "provider_message_id", "sent_at")
|
||||
|
||||
|
||||
@admin.register(Campaign)
|
||||
class CampaignAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"channel",
|
||||
"audience",
|
||||
"status",
|
||||
"scheduled_for",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("channel", "audience", "status")
|
||||
inlines = [MessageInline]
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ("campaign", "contact", "channel", "status", "scheduled_for")
|
||||
list_filter = ("channel", "status")
|
||||
|
||||
|
||||
@admin.register(ProviderEvent)
|
||||
class ProviderEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("provider", "event_type", "created_at")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MessagingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "messaging"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Channel dispatch — email / SMS / postcard."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from contacts.models import Channel
|
||||
from messaging.models import Message
|
||||
from messaging.providers.email.smtp2go import send_email
|
||||
from messaging.providers.postcard import get_postcard_provider
|
||||
from messaging.providers.sms.smtp2go import send_sms
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
provider: str
|
||||
provider_id: str
|
||||
|
||||
|
||||
def dispatch_message(message: Message) -> ProviderResult:
|
||||
if message.channel == Channel.EMAIL:
|
||||
result = send_email(message)
|
||||
return ProviderResult(provider="smtp2go_email", provider_id=result)
|
||||
if message.channel == Channel.SMS:
|
||||
result = send_sms(message)
|
||||
return ProviderResult(provider="smtp2go_sms", provider_id=result)
|
||||
if message.channel == Channel.POSTCARD:
|
||||
provider = get_postcard_provider()
|
||||
result = provider.send_postcard(message)
|
||||
return ProviderResult(provider=provider.name, provider_id=result.provider_id)
|
||||
raise ValueError(f"Unsupported channel: {message.channel}")
|
||||
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MessageTemplate',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('subject', models.CharField(blank=True, max_length=255)),
|
||||
('body', models.TextField()),
|
||||
('postcard_front', models.JSONField(blank=True, default=dict)),
|
||||
('postcard_back', models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Campaign',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
|
||||
('scheduled_for', models.DateTimeField(blank=True, null=True)),
|
||||
('subject_override', models.CharField(blank=True, max_length=255)),
|
||||
('body_override', models.TextField(blank=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='campaigns', to='messaging.messagetemplate')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced'), ('suppressed', 'Suppressed')], default='draft', max_length=16)),
|
||||
('provider', models.CharField(blank=True, max_length=64)),
|
||||
('provider_message_id', models.CharField(blank=True, max_length=255)),
|
||||
('scheduled_for', models.DateTimeField(blank=True, null=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('body_snapshot', models.TextField(blank=True)),
|
||||
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='messaging.campaign')),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='contacts.contact')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProviderEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('provider', models.CharField(max_length=64)),
|
||||
('event_type', models.CharField(max_length=64)),
|
||||
('payload', models.JSONField(blank=True, default=dict)),
|
||||
('message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='messaging.message')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.1 on 2026-08-08 10:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='campaign',
|
||||
name='audience',
|
||||
field=models.CharField(blank=True, choices=[('email_opt_in', 'Mailing list · email opt-in'), ('sms_opt_in', 'Mailing list · SMS opt-in'), ('postcard_opt_in', 'Mailing list · postcard opt-in')], default='', max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated manually for Campaign.notify_sent_at
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("messaging", "0002_campaign_audience"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="campaign",
|
||||
name="notify_sent_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from contacts.models import Channel, Contact
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class MessageTemplate(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
name = models.CharField(max_length=120)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
subject = models.CharField(max_length=255, blank=True)
|
||||
body = models.TextField()
|
||||
postcard_front = models.JSONField(default=dict, blank=True)
|
||||
postcard_back = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.channel})"
|
||||
|
||||
|
||||
class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
SCHEDULED = "scheduled", "Scheduled"
|
||||
SENDING = "sending", "Sending"
|
||||
COMPLETED = "completed", "Completed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
class Audience(models.TextChoices):
|
||||
EMAIL_OPT_IN = "email_opt_in", "Mailing list · email opt-in"
|
||||
SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in"
|
||||
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
|
||||
|
||||
name = models.CharField(max_length=120)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
audience = models.CharField(
|
||||
max_length=32,
|
||||
choices=Audience.choices,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
template = models.ForeignKey(
|
||||
MessageTemplate,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="campaigns",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
scheduled_for = models.DateTimeField(null=True, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
subject_override = models.CharField(max_length=255, blank=True)
|
||||
body_override = models.TextField(blank=True)
|
||||
# Set when realtor summary email is sent (campaign COMPLETED).
|
||||
notify_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
SCHEDULED = "scheduled", "Scheduled"
|
||||
QUEUED = "queued", "Queued"
|
||||
SENT = "sent", "Sent"
|
||||
DELIVERED = "delivered", "Delivered"
|
||||
FAILED = "failed", "Failed"
|
||||
BOUNCED = "bounced", "Bounced"
|
||||
SUPPRESSED = "suppressed", "Suppressed"
|
||||
|
||||
campaign = models.ForeignKey(
|
||||
Campaign, on_delete=models.CASCADE, related_name="messages"
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
Contact, on_delete=models.CASCADE, related_name="messages"
|
||||
)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
provider = models.CharField(max_length=64, blank=True)
|
||||
provider_message_id = models.CharField(max_length=255, blank=True)
|
||||
scheduled_for = models.DateTimeField(null=True, blank=True)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
body_snapshot = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.channel} → {self.contact} ({self.status})"
|
||||
|
||||
|
||||
class ProviderEvent(TimeStampedModel):
|
||||
message = models.ForeignKey(
|
||||
Message,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="events",
|
||||
)
|
||||
provider = models.CharField(max_length=64)
|
||||
event_type = models.CharField(max_length=64)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""SMTP2GO email via Django's SMTP backend (mail.smtp2go.com)."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
from contacts.models import Channel
|
||||
from messaging.services import one_click_unsubscribe_url, preferences_url
|
||||
|
||||
# Reported back on SMTP2GO webhooks when this header is selected in webhook settings.
|
||||
MONICA_MESSAGE_HEADER = "X-Monica-Message-Id"
|
||||
|
||||
|
||||
def send_email(message) -> str:
|
||||
contact = message.contact
|
||||
if not contact.email:
|
||||
raise ValueError("Contact has no email address")
|
||||
|
||||
campaign = message.campaign
|
||||
subject = campaign.subject_override or (
|
||||
campaign.template.subject if campaign.template else "Message from Monica"
|
||||
)
|
||||
body = message.body_snapshot or campaign.body_override or (
|
||||
campaign.template.body if campaign.template else ""
|
||||
)
|
||||
|
||||
site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
|
||||
one_click_path = one_click_unsubscribe_url(str(contact.pk), Channel.EMAIL)
|
||||
prefs_url = f"{site}{prefs_path}" if site else prefs_path
|
||||
one_click_url = f"{site}{one_click_path}" if site else one_click_path
|
||||
body_with_unsub = (
|
||||
f"{body}\n\n---\n"
|
||||
f"Manage preferences: {prefs_url}\n"
|
||||
f"Unsubscribe from email: {one_click_url}"
|
||||
)
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body_with_unsub,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[contact.email],
|
||||
headers={
|
||||
"List-Unsubscribe": f"<{one_click_url}>",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
MONICA_MESSAGE_HEADER: str(message.pk),
|
||||
},
|
||||
)
|
||||
email.send(fail_silently=False)
|
||||
# Placeholder until SMTP2GO webhook supplies the real email_id.
|
||||
return f"smtp-{message.pk}"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Pluggable postcard providers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostcardResult:
|
||||
provider_id: str
|
||||
|
||||
|
||||
class PostcardProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult: ...
|
||||
|
||||
def get_status(self, provider_id: str) -> str: ...
|
||||
|
||||
|
||||
def get_postcard_provider() -> PostcardProvider:
|
||||
name = (settings.POSTCARD_PROVIDER or "pcm").lower()
|
||||
if name == "pcm":
|
||||
from messaging.providers.postcard.pcm import PcmProvider
|
||||
|
||||
return PcmProvider()
|
||||
if name == "click2mail":
|
||||
from messaging.providers.postcard.click2mail import Click2MailProvider
|
||||
|
||||
return Click2MailProvider()
|
||||
if name == "postgrid":
|
||||
from messaging.providers.postcard.postgrid import PostGridProvider
|
||||
|
||||
return PostGridProvider()
|
||||
if name == "lob":
|
||||
from messaging.providers.postcard.lob import LobProvider
|
||||
|
||||
return LobProvider()
|
||||
from messaging.providers.postcard.pcm import PcmProvider
|
||||
|
||||
return PcmProvider()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Click2Mail postcard adapter (low-volume pay-per-piece option)."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class Click2MailProvider:
|
||||
name: str = "click2mail"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
if not settings.CLICK2MAIL_API_KEY:
|
||||
raise RuntimeError("CLICK2MAIL_API_KEY is not configured")
|
||||
# Placeholder: wire full Click2Mail job API when account credentials are ready.
|
||||
raise NotImplementedError(
|
||||
"Click2Mail adapter stub — configure account then implement job submit"
|
||||
)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Lob postcard adapter (default)."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobProvider:
|
||||
name: str = "lob"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
api_key = settings.LOB_API_KEY
|
||||
if not api_key:
|
||||
raise RuntimeError("LOB_API_KEY is not configured")
|
||||
|
||||
contact = message.contact
|
||||
address = contact.postal_address or {}
|
||||
if not address.get("line1"):
|
||||
raise ValueError("Contact postal_address.line1 required for postcard")
|
||||
|
||||
# Minimal Lob create-postcard payload; artwork URLs come from template JSON.
|
||||
template = message.campaign.template
|
||||
front = (template.postcard_front if template else {}) or {}
|
||||
back = (template.postcard_back if template else {}) or {}
|
||||
|
||||
payload = {
|
||||
"description": f"campaign-{message.campaign_id}",
|
||||
"to": {
|
||||
"name": contact.full_name or contact.email or "Resident",
|
||||
"address_line1": address.get("line1", ""),
|
||||
"address_line2": address.get("line2", ""),
|
||||
"address_city": address.get("city", ""),
|
||||
"address_state": address.get("state", ""),
|
||||
"address_zip": address.get("zip", ""),
|
||||
"address_country": address.get("country", "US"),
|
||||
},
|
||||
"front": front.get("html") or front.get("url") or "<html></html>",
|
||||
"back": back.get("html") or back.get("url") or "<html></html>",
|
||||
}
|
||||
response = requests.post(
|
||||
"https://api.lob.com/v1/postcards",
|
||||
json=payload,
|
||||
auth=(api_key, ""),
|
||||
timeout=60,
|
||||
headers={"Idempotency-Key": str(message.pk)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return PostcardResult(provider_id=str(data.get("id") or message.pk))
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
api_key = settings.LOB_API_KEY
|
||||
if not api_key:
|
||||
return "unknown"
|
||||
response = requests.get(
|
||||
f"https://api.lob.com/v1/postcards/{provider_id}",
|
||||
auth=(api_key, ""),
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
return "unknown"
|
||||
return str(response.json().get("status") or "unknown")
|
||||
@@ -0,0 +1,260 @@
|
||||
"""PCM Integrations (DirectMail API v3) postcard adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PCM_API_BASE = "https://v3.pcmintegrations.com"
|
||||
|
||||
# PCM size codes for custom designer designs.
|
||||
PCM_SIZE_CHOICES = (
|
||||
("46", "4.25 × 6"),
|
||||
("68", "6 × 8.5"),
|
||||
("69", "6 × 9"),
|
||||
("611", "6 × 11"),
|
||||
("811", "8.5 × 11"),
|
||||
)
|
||||
|
||||
|
||||
class PcmApiError(RuntimeError):
|
||||
"""Raised when a PCM API call fails."""
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
return (settings.PCM_API_KEY or "").strip()
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
key = _api_key()
|
||||
if not key:
|
||||
raise PcmApiError("PCM_API_KEY is not configured")
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def pcm_request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
timeout: int = 60,
|
||||
) -> Any:
|
||||
"""Call PCM v3 API. ``path`` is absolute under the API host (e.g. ``/design``)."""
|
||||
url = f"{PCM_API_BASE}{path}"
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
headers=_headers(),
|
||||
params=params,
|
||||
json=json_body,
|
||||
timeout=timeout,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = (response.text or "")[:500]
|
||||
raise PcmApiError(
|
||||
f"PCM {method} {path} → {response.status_code}: {detail}"
|
||||
)
|
||||
if not response.content:
|
||||
return {}
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return {"raw": response.text}
|
||||
|
||||
|
||||
def return_address_from_settings() -> dict[str, str]:
|
||||
"""Build PCM returnAddress from PCM_RETURN_ADDRESS JSON or CONTACT_* vars."""
|
||||
raw = (settings.PCM_RETURN_ADDRESS or "").strip()
|
||||
if raw:
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object")
|
||||
return {
|
||||
"company": str(data.get("company") or ""),
|
||||
"firstName": str(data.get("firstName") or data.get("first_name") or ""),
|
||||
"lastName": str(data.get("lastName") or data.get("last_name") or ""),
|
||||
"address": str(data.get("address") or data.get("line1") or ""),
|
||||
"address2": str(data.get("address2") or data.get("line2") or ""),
|
||||
"city": str(data.get("city") or ""),
|
||||
"state": str(data.get("state") or ""),
|
||||
"zipCode": str(data.get("zipCode") or data.get("zip") or ""),
|
||||
}
|
||||
|
||||
name = (settings.SITE_NAME or "").strip()
|
||||
parts = name.split(None, 1)
|
||||
first = parts[0] if parts else "Monica"
|
||||
last = parts[1] if len(parts) > 1 else ""
|
||||
return {
|
||||
"company": "",
|
||||
"firstName": first,
|
||||
"lastName": last,
|
||||
"address": str(getattr(settings, "PCM_RETURN_LINE1", "") or ""),
|
||||
"address2": str(getattr(settings, "PCM_RETURN_LINE2", "") or ""),
|
||||
"city": str(getattr(settings, "PCM_RETURN_CITY", "") or ""),
|
||||
"state": str(getattr(settings, "PCM_RETURN_STATE", "") or ""),
|
||||
"zipCode": str(getattr(settings, "PCM_RETURN_ZIP", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def contact_to_pcm_recipient(contact, *, ext_ref: str) -> dict[str, str]:
|
||||
address = contact.postal_address or {}
|
||||
line1 = (address.get("line1") or "").strip()
|
||||
if not line1:
|
||||
raise ValueError("Contact postal_address.line1 required for postcard")
|
||||
|
||||
first = (contact.first_name or "").strip()
|
||||
last = (contact.last_name or "").strip()
|
||||
if not first and not last:
|
||||
# PCM requires name or company.
|
||||
first = (contact.full_name or contact.email or "Resident").strip()
|
||||
|
||||
return {
|
||||
"firstName": first,
|
||||
"lastName": last,
|
||||
"address": line1,
|
||||
"address2": (address.get("line2") or "").strip() or " ",
|
||||
"city": (address.get("city") or "").strip(),
|
||||
"state": (address.get("state") or "").strip(),
|
||||
"zipCode": (address.get("zip") or "").strip(),
|
||||
"extRefNbr": ext_ref,
|
||||
}
|
||||
|
||||
|
||||
def list_designs(*, product_type: str = "postcard", page: int = 1, per_page: int = 50) -> list[dict]:
|
||||
data = pcm_request(
|
||||
"GET",
|
||||
"/design",
|
||||
params={
|
||||
"productType": product_type,
|
||||
"page": page,
|
||||
"perPage": per_page,
|
||||
},
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
results = data.get("results") or data.get("designs") or []
|
||||
return results if isinstance(results, list) else []
|
||||
return []
|
||||
|
||||
|
||||
def create_custom_design(*, name: str, size: str) -> dict[str, Any]:
|
||||
"""POST /design/custom → designID + embed url."""
|
||||
return pcm_request(
|
||||
"POST",
|
||||
"/design/custom",
|
||||
json_body={"name": name, "size": size},
|
||||
)
|
||||
|
||||
|
||||
def get_design_embed_url(design_id: int | str, *, duplicate: bool = False) -> str:
|
||||
"""GET /design/{id}/edit?mode=embed → iframe URL."""
|
||||
params: dict[str, Any] = {"mode": "embed"}
|
||||
if duplicate:
|
||||
params["duplicate"] = "true"
|
||||
data = pcm_request("GET", f"/design/{design_id}/edit", params=params)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("Unexpected embed response from PCM")
|
||||
url = data.get("embed_url") or data.get("url") or ""
|
||||
if not url:
|
||||
raise PcmApiError("PCM did not return an embed URL")
|
||||
return str(url)
|
||||
|
||||
|
||||
def get_order(order_id: int | str) -> dict[str, Any]:
|
||||
data = pcm_request("GET", f"/order/{order_id}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def place_postcard_order(
|
||||
*,
|
||||
design_id: int,
|
||||
recipient: dict[str, str],
|
||||
ext_ref: str,
|
||||
mail_class: str = "FirstClass",
|
||||
) -> str:
|
||||
"""Place a one-recipient postcard order; return PCM orderID as string."""
|
||||
payload = {
|
||||
"designID": design_id,
|
||||
"mailClass": mail_class,
|
||||
"extRefNbr": ext_ref,
|
||||
"returnAddress": return_address_from_settings(),
|
||||
"recipients": [recipient],
|
||||
}
|
||||
data = pcm_request("POST", "/order", json_body=payload)
|
||||
if not isinstance(data, dict):
|
||||
raise PcmApiError("Unexpected order response from PCM")
|
||||
|
||||
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
|
||||
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
|
||||
order_id = data["results"][0].get("orderID")
|
||||
if order_id is None:
|
||||
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
|
||||
return str(order_id)
|
||||
|
||||
|
||||
def design_id_from_template(template) -> int | None:
|
||||
"""Read design_id from MessageTemplate.postcard_front JSON."""
|
||||
if not template:
|
||||
return None
|
||||
front = template.postcard_front or {}
|
||||
if not isinstance(front, dict):
|
||||
return None
|
||||
raw = front.get("design_id") or front.get("designID")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PcmProvider:
|
||||
name: str = "pcm"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
template = message.campaign.template if message.campaign_id else None
|
||||
design_id = design_id_from_template(template)
|
||||
if not design_id:
|
||||
raise ValueError(
|
||||
"Postcard campaign template missing PCM design_id "
|
||||
"(save a design from the postcard designer first)"
|
||||
)
|
||||
|
||||
recipient = contact_to_pcm_recipient(
|
||||
message.contact, ext_ref=str(message.pk)
|
||||
)
|
||||
mail_class = "FirstClass"
|
||||
if template and isinstance(template.postcard_front, dict):
|
||||
mail_class = (
|
||||
template.postcard_front.get("mail_class") or mail_class
|
||||
)
|
||||
|
||||
order_id = place_postcard_order(
|
||||
design_id=design_id,
|
||||
recipient=recipient,
|
||||
ext_ref=str(message.pk),
|
||||
mail_class=str(mail_class),
|
||||
)
|
||||
return PostcardResult(provider_id=order_id)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
try:
|
||||
data = get_order(provider_id)
|
||||
except PcmApiError:
|
||||
logger.exception("PCM get_status failed for %s", provider_id)
|
||||
return "unknown"
|
||||
return str(data.get("status") or "unknown")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""PostGrid postcard adapter."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from messaging.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostGridProvider:
|
||||
name: str = "postgrid"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
if not settings.POSTGRID_API_KEY:
|
||||
raise RuntimeError("POSTGRID_API_KEY is not configured")
|
||||
raise NotImplementedError(
|
||||
"PostGrid adapter stub — configure account then implement send"
|
||||
)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""SMTP2GO SMS REST API."""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_sms(message) -> str:
|
||||
contact = message.contact
|
||||
if not contact.phone:
|
||||
raise ValueError("Contact has no phone number")
|
||||
|
||||
api_key = settings.SMTP2GO_SMS_API_KEY
|
||||
if not api_key:
|
||||
raise RuntimeError("SMTP2GO_SMS_API_KEY is not configured")
|
||||
|
||||
campaign = message.campaign
|
||||
body = message.body_snapshot or campaign.body_override or (
|
||||
campaign.template.body if campaign.template else ""
|
||||
)
|
||||
|
||||
payload = {
|
||||
"api_key": api_key,
|
||||
"to": contact.phone,
|
||||
"text": body[:1600],
|
||||
}
|
||||
response = requests.post(
|
||||
settings.SMTP2GO_SMS_API_URL,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json() if response.content else {}
|
||||
# SMTP2GO returns varying shapes; store a useful id when present.
|
||||
return str(
|
||||
data.get("data", {}).get("sms_id")
|
||||
or data.get("request_id")
|
||||
or f"sms-{message.pk}"
|
||||
)
|
||||
@@ -0,0 +1,404 @@
|
||||
"""Consent checks and unsubscribe helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.core import signing
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
||||
from messaging.models import Campaign, Message, MessageTemplate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
|
||||
AUDIENCE_CHANNEL = {
|
||||
Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL,
|
||||
Campaign.Audience.SMS_OPT_IN: Channel.SMS,
|
||||
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
||||
}
|
||||
|
||||
UNSUB_SALT = "monica-site-unsubscribe"
|
||||
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
|
||||
|
||||
|
||||
def contact_may_receive(contact: Contact, channel: str) -> bool:
|
||||
if Suppression.objects.filter(
|
||||
contact=contact, channel=channel, active=True
|
||||
).exists():
|
||||
return False
|
||||
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
|
||||
return bool(consent and consent.opted_in)
|
||||
|
||||
|
||||
def channel_preferences(contact: Contact) -> dict[str, bool]:
|
||||
"""Current opt-in flags for every channel (missing record = False)."""
|
||||
flags = {c.value: False for c in Channel}
|
||||
for record in contact.consents.all():
|
||||
flags[record.channel] = record.opted_in
|
||||
return flags
|
||||
|
||||
|
||||
def set_channel_consent(
|
||||
contact: Contact,
|
||||
channel: str,
|
||||
*,
|
||||
opted_in: bool,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Write ConsentRecord + Suppression for one channel."""
|
||||
if channel not in Channel.values:
|
||||
raise ValueError(f"Unknown channel: {channel}")
|
||||
ConsentRecord.objects.update_or_create(
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
defaults={"opted_in": opted_in, "reason": reason},
|
||||
)
|
||||
Suppression.objects.update_or_create(
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
defaults={
|
||||
"active": not opted_in,
|
||||
"reason": reason if not opted_in else "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def set_channel_preferences(
|
||||
contact: Contact,
|
||||
preferences: dict[str, bool],
|
||||
*,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Update consent for each provided channel key."""
|
||||
for channel, opted_in in preferences.items():
|
||||
if channel not in Channel.values:
|
||||
continue
|
||||
set_channel_consent(
|
||||
contact, channel, opted_in=bool(opted_in), reason=reason
|
||||
)
|
||||
|
||||
|
||||
def unsubscribe_all(contact: Contact, *, reason: str = "unsubscribe_all") -> None:
|
||||
for channel in Channel:
|
||||
set_channel_consent(
|
||||
contact, channel.value, opted_in=False, reason=reason
|
||||
)
|
||||
|
||||
|
||||
def make_unsubscribe_token(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
||||
return signing.dumps({"c": str(contact_id), "ch": channel}, salt=UNSUB_SALT)
|
||||
|
||||
|
||||
def parse_unsubscribe_token(token: str) -> tuple[Contact | None, str]:
|
||||
"""Return (contact, channel) or (None, '') on bad/expired token."""
|
||||
try:
|
||||
data = signing.loads(token, salt=UNSUB_SALT, max_age=UNSUB_MAX_AGE)
|
||||
except signing.BadSignature:
|
||||
return None, ""
|
||||
contact = (
|
||||
Contact.objects.filter(pk=data.get("c"))
|
||||
.prefetch_related("consents")
|
||||
.first()
|
||||
)
|
||||
if not contact:
|
||||
return None, ""
|
||||
channel = data.get("ch") or Channel.EMAIL
|
||||
if channel not in Channel.values:
|
||||
channel = Channel.EMAIL
|
||||
return contact, channel
|
||||
|
||||
|
||||
def process_unsubscribe_token(token: str) -> bool:
|
||||
"""One-click opt-out for the channel encoded in the token."""
|
||||
contact, channel = parse_unsubscribe_token(token)
|
||||
if not contact:
|
||||
return False
|
||||
set_channel_consent(
|
||||
contact, channel, opted_in=False, reason="unsubscribe_link"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def preferences_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
||||
token = make_unsubscribe_token(contact_id, channel)
|
||||
return reverse("public:unsubscribe", kwargs={"token": token})
|
||||
|
||||
|
||||
def one_click_unsubscribe_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
||||
token = make_unsubscribe_token(contact_id, channel)
|
||||
return reverse("public:unsubscribe_one_click", kwargs={"token": token})
|
||||
|
||||
|
||||
def record_sms_stop(phone: str) -> bool:
|
||||
digits = "".join(ch for ch in (phone or "") if ch.isdigit())
|
||||
if len(digits) < 7:
|
||||
return False
|
||||
tail = digits[-10:]
|
||||
contact = None
|
||||
for row in Contact.objects.exclude(phone="").iterator():
|
||||
stored = "".join(ch for ch in row.phone if ch.isdigit())
|
||||
if stored.endswith(tail) or tail.endswith(stored[-10:]):
|
||||
contact = row
|
||||
break
|
||||
if not contact:
|
||||
return False
|
||||
set_channel_consent(
|
||||
contact, Channel.SMS, opted_in=False, reason="sms_stop"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def channel_for_audience(audience: str) -> str:
|
||||
try:
|
||||
return AUDIENCE_CHANNEL[audience]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown audience: {audience}") from exc
|
||||
|
||||
|
||||
def opted_in_contacts(channel: str) -> QuerySet[Contact]:
|
||||
"""Contacts opted in for channel and not actively suppressed."""
|
||||
suppressed = Suppression.objects.filter(
|
||||
channel=channel, active=True
|
||||
).values_list("contact_id", flat=True)
|
||||
qs = (
|
||||
Contact.objects.filter(
|
||||
consents__channel=channel,
|
||||
consents__opted_in=True,
|
||||
)
|
||||
.exclude(pk__in=suppressed)
|
||||
.distinct()
|
||||
.order_by("first_name", "last_name", "email")
|
||||
)
|
||||
if channel == Channel.POSTCARD:
|
||||
qs = qs.filter(postal_address__has_key="line1").exclude(
|
||||
postal_address__line1=""
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
def parse_scheduled_for(raw: str | None):
|
||||
"""Parse optional ``datetime-local`` value into an aware datetime."""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid schedule datetime.") from exc
|
||||
if timezone.is_naive(parsed):
|
||||
return timezone.make_aware(parsed, timezone.get_current_timezone())
|
||||
return parsed
|
||||
|
||||
|
||||
def create_campaign_draft(
|
||||
*,
|
||||
name: str,
|
||||
audience: str,
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
scheduled_for=None,
|
||||
created_by: AbstractBaseUser | None = None,
|
||||
template: MessageTemplate | None = None,
|
||||
) -> Campaign:
|
||||
"""Persist a draft campaign and per-recipient Message stubs."""
|
||||
channel = channel_for_audience(audience)
|
||||
campaign = Campaign.objects.create(
|
||||
name=name,
|
||||
channel=channel,
|
||||
audience=audience,
|
||||
status=Campaign.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
subject_override=subject,
|
||||
body_override=body,
|
||||
created_by=created_by,
|
||||
template=template,
|
||||
)
|
||||
contacts = list(opted_in_contacts(channel))
|
||||
Message.objects.bulk_create(
|
||||
[
|
||||
Message(
|
||||
campaign=campaign,
|
||||
contact=contact,
|
||||
channel=channel,
|
||||
status=Message.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
body_snapshot=body,
|
||||
)
|
||||
for contact in contacts
|
||||
]
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
def campaign_notify_recipient(campaign: Campaign) -> str:
|
||||
"""Email address for the realtor summary (created_by, else CONTACT_EMAIL)."""
|
||||
from django.conf import settings
|
||||
|
||||
user = campaign.created_by
|
||||
if user is not None:
|
||||
email = (getattr(user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
return (settings.CONTACT_EMAIL or "").strip()
|
||||
|
||||
|
||||
def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
||||
"""
|
||||
One-shot summary email when a campaign finishes sending.
|
||||
|
||||
Returns True if mail was sent (or already sent earlier).
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.db.models import Count, Q
|
||||
from django.urls import reverse
|
||||
|
||||
if campaign.notify_sent_at:
|
||||
return True
|
||||
if campaign.status != Campaign.Status.COMPLETED:
|
||||
return False
|
||||
|
||||
to_email = campaign_notify_recipient(campaign)
|
||||
if not to_email:
|
||||
return False
|
||||
|
||||
counts = campaign.messages.aggregate(
|
||||
sent=Count("id", filter=Q(status=Message.Status.SENT)),
|
||||
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
|
||||
failed=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
status__in=[
|
||||
Message.Status.FAILED,
|
||||
Message.Status.BOUNCED,
|
||||
]
|
||||
),
|
||||
),
|
||||
suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)),
|
||||
total=Count("id"),
|
||||
)
|
||||
report_path = reverse("messaging:campaign_detail", kwargs={"pk": campaign.pk})
|
||||
public = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
report_url = f"{public}{report_path}" if public else report_path
|
||||
|
||||
subject = f"Campaign sent: {campaign.name}"
|
||||
body = (
|
||||
f"Your {campaign.get_channel_display()} campaign “{campaign.name}” "
|
||||
f"has finished sending.\n\n"
|
||||
f"Recipients: {counts['total']}\n"
|
||||
f"Sent: {counts['sent']}\n"
|
||||
f"Delivered: {counts['delivered']}\n"
|
||||
f"Failed / bounced: {counts['failed']}\n"
|
||||
f"Suppressed: {counts['suppressed']}\n\n"
|
||||
f"Report: {report_url}\n"
|
||||
)
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
try:
|
||||
email.send(fail_silently=False)
|
||||
except Exception: # noqa: BLE001 — don't block completion on mail errors
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"Campaign completion notify failed for %s", campaign.pk
|
||||
)
|
||||
return False
|
||||
|
||||
campaign.notify_sent_at = timezone.now()
|
||||
campaign.save(update_fields=["notify_sent_at", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def refresh_campaign_status(campaign: Campaign) -> Campaign:
|
||||
"""Set campaign to completed when no messages remain pending."""
|
||||
pending = campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.QUEUED,
|
||||
]
|
||||
).exists()
|
||||
if pending:
|
||||
return campaign
|
||||
if campaign.status == Campaign.Status.SENDING:
|
||||
campaign.status = Campaign.Status.COMPLETED
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
send_campaign_completion_notify(campaign)
|
||||
return campaign
|
||||
|
||||
|
||||
def enqueue_campaign_send(campaign: Campaign) -> int:
|
||||
"""
|
||||
Queue draft/scheduled/failed messages for send.
|
||||
|
||||
Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console.
|
||||
"""
|
||||
from messaging.tasks import send_campaign_message
|
||||
|
||||
sendable = list(
|
||||
campaign.messages.filter(
|
||||
status__in=[
|
||||
Message.Status.DRAFT,
|
||||
Message.Status.SCHEDULED,
|
||||
Message.Status.FAILED,
|
||||
]
|
||||
)
|
||||
)
|
||||
if not sendable:
|
||||
return 0
|
||||
|
||||
campaign.status = Campaign.Status.SENDING
|
||||
campaign.save(update_fields=["status", "updated_at"])
|
||||
|
||||
enqueued = 0
|
||||
for message in sendable:
|
||||
message.status = Message.Status.QUEUED
|
||||
message.save(update_fields=["status", "updated_at"])
|
||||
try:
|
||||
send_campaign_message.enqueue(message_id=str(message.pk))
|
||||
except Exception: # noqa: BLE001 — task already persisted FAILED
|
||||
pass
|
||||
enqueued += 1
|
||||
|
||||
refresh_campaign_status(campaign)
|
||||
return enqueued
|
||||
|
||||
|
||||
def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
|
||||
"""Send one preview copy to ``to_email`` without touching recipient rows."""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
if campaign.channel != Channel.EMAIL:
|
||||
raise ValueError("Test send is only available for email campaigns.")
|
||||
subject = campaign.subject_override or (
|
||||
campaign.template.subject if campaign.template_id else "Message from Monica"
|
||||
)
|
||||
body = campaign.body_override or (
|
||||
campaign.template.body if campaign.template_id else ""
|
||||
)
|
||||
if not subject.strip():
|
||||
raise ValueError("Campaign has no subject.")
|
||||
if not body.strip():
|
||||
raise ValueError("Campaign has no body.")
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=f"[TEST] {subject}",
|
||||
body=(
|
||||
f"{body}\n\n---\n"
|
||||
"This is a test send from the Monica portal. "
|
||||
"Recipient list was not notified."
|
||||
),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
email.send(fail_silently=False)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user