Compare commits

...
6 Commits
Author SHA1 Message Date
westfarn a867a57750 Show campaign QR codes for email as well as postcard (#14)
Deploy Beta / unit-tests (push) Successful in 21s
Deploy Beta / docker (push) Successful in 29s
Deploy Beta / deploy-beta (push) Successful in 6m33s
## Summary
Closes #13.
- Draw the tracked-link QR for email campaigns as well as postcard (copy / download PNG).
- Keep the QR hidden for SMS.
- Fix `.utm-qr { display: flex }` overriding the HTML `hidden` attribute so SMS does not leak an empty canvas.

## Test plan
- [ ] Open campaign composer on Email — QR renders for the short `piha.li` URL
- [ ] Copy QR image / Download PNG work
- [ ] Switch to SMS — QR box is gone
- [ ] Switch to Postcard — QR still renders; postcard-designer hint still shows
- [ ] Email campaign report page also shows a filled QR

Reviewed-on: #14
2026-09-01 11:51:57 -07:00
westfarn bc7c47a69c Mint short links on piha.lc / beta.piha.li (#12)
Deploy Beta / unit-tests (push) Successful in 21s
Deploy Beta / docker (push) Successful in 24s
Deploy Beta / deploy-beta (push) Successful in 2m4s
## Summary
- Point `SHORTENER_BASE_URL` at `https://piha.lc` (prod) and `https://beta.piha.li` (beta).
- Stop documenting `shortener.aimloperations.com` as the mint host.

Closes #11.

Depends on url_shortening_service serving `/api/` on the short host.

## Test plan
- [ ] Set `SHORTENER_BASE_URL=https://beta.piha.li` and matching token in beta env
- [ ] Compose a campaign SMS and confirm mint hits beta.piha.li (not shortener-beta)
- [ ] Body contains `https://beta.piha.li/<code>`

Reviewed-on: #12
2026-08-30 17:27:51 -07:00
westfarn 2b3f10e784 Mint campaign UTM links through the piha.li shortener (#10)
Deploy Beta / unit-tests (push) Successful in 21s
Deploy Beta / docker (push) Successful in 26s
Deploy Beta / deploy-beta (push) Successful in 5m20s
## Summary
Closes #9.

- Campaign composer auto-inserts a tracked homepage link (`utm_source=monica`, `utm_medium` = channel, `utm_campaign` = slug of the name) for email, SMS, and postcard QR — no manual UTM paste.
- When `SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` are set, the app mints that long HTTPS URL via `POST /api/links/` on the shortener **API host** and puts the returned `piha.li` / `beta.piha.li` short URL in SMS, email hrefs, and QR codes. Empty env (local) falls back to the long UTM URL.
- Live composer resolves shorts through a portal JSON endpoint (login + CSRF). Browser never calls the shortener.

## Secrets (control node, not git)

**monica_site** (`~/Documents/secrets/monica_site/`):
```
# prod
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:<secret>

# beta
SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
SHORTENER_API_TOKEN=monica:<beta-secret>
```

**url_shortening_service** (same secret, named token):
```
SHORTENER_API_TOKENS=monica:<secret>
SHORT_ALLOWED_HOSTS=mkdrealtor.com,aimloperations.com
```
Prod public short host: `piha.li`. Beta: `beta.piha.li`. Generate with `python -c "import secrets; print(secrets.token_urlsafe(32))"`.

Template port: westfarn/web_django_template#3

## Test plan
- [ ] `cd site && uv run python manage.py test messaging.tests.CampaignUtmLinkTests`
- [ ] Composer: type a campaign name — email gets an HTML link, SMS gets a URL, postcard shows a QR
- [ ] With shortener env set: SMS/QR show `piha.li` (or `beta.piha.li`); without it, long UTM URL still works
- [ ] Copy/download QR into postcard designer
- [ ] Secret files have `SHORTENER_*` on both caller and operator sides before beta/prod deploy

Reviewed-on: #10
2026-08-30 13:24:40 -07:00
westfarn dc0f3b2908 Improve public Lighthouse scores (#7) (#8)
Deploy Beta / unit-tests (push) Successful in 16s
Deploy Beta / docker (push) Successful in 22s
Deploy Beta / deploy-beta (push) Successful in 5m20s
## Summary
Closes #7.

Raise public marketing page Lighthouse scores (Performance / Accessibility / Best Practices / SEO) on `/`, `/about/`, and `/contact/`.

### Baseline (preview, mobile)
| Page | Perf | A11y | BP | SEO |
|------|------|------|----|-----|
| `/` | 70 | 80 | 82 | 100 |
| `/about/` | 71 | 81 | 82 | 100 |
| `/contact/` | 74 | 90 | 82 | 100 |

### Changes
- **Accessibility:** contrast overrides, nav `aria-label`s, footer/contact heading order (no skipped levels).
- **Best practices:** replace theme `unload` listeners with `pagehide` (also helps bfcache); `WHITENOISE_MAX_AGE` for hashed static.
- **Performance:** WebP heroes/headshot/breadcrumbs + preload; Google Fonts `display=swap` + fewer weights; async icon `fonts.css`; bundled/minified vendor CSS; lazy second swiper slide; remove above-fold WOW delay on home hero.
- **SEO:** already 100; kept structured data / meta.

### Local verify (dev server — no gzip, so Perf is not representative)
A11y / Best Practices / SEO all **100** on the three pages after fixes.

Perf will look better on beta/preview (WhiteNoise compression + CDN/proxy) than on `runserver`. Remaining Perf headroom is mostly the theme’s large `core.min.js` / unused CSS and infra HTTP/2.

## Test plan
- [ ] Deploy/preview branch and re-run Lighthouse on `/`, `/about/`, `/contact/`
- [ ] Spot-check home hero, about headshot, contact form + mobile nav toggles
- [ ] Confirm icons still appear after async `fonts.css` load
- [ ] Confirm swiper slide 2 background appears when navigating slides

Reviewed-on: #8
2026-08-27 10:49:10 -07:00
westfarn e8611ef926 Social composer: send now and rename messy account names (#6)
Deploy Beta / unit-tests (push) Successful in 16s
Deploy Beta / docker (push) Successful in 20s
Deploy Beta / deploy-beta (push) Successful in 3m11s
## Summary
Closes #5.
- Composer can **Send now** without a schedule time (Schedule and Save draft still available)
- Account names wrap and show the platform id so a bad OAuth label is still readable
- Social accounts page can rename the display name; re-auth keeps a custom name instead of overwriting it with a placeholder
- Worker polls `dispatch_due` every 15s so scheduled campaigns and social posts fire without a manual command; local `docker compose up` now starts the worker

## Test plan
- [ ] Open Social → Compose: confirm **Send now**, **Schedule**, and **Save draft** buttons (no combined Save / publish)
- [ ] Send now with a connected account and caption → post queues immediately, no datetime required
- [ ] Schedule with a datetime → post is scheduled; Schedule with a blank time → validation error
- [ ] Composer account list shows full name plus external id, wrapping if the name is long
- [ ] Social accounts → rename a messy label → new name shows in composer without reconnecting
- [ ] Re-authorize an account that was renamed → custom name is kept
- [ ] Rebuild and start compose (`docker compose up --build`) so the **worker** service is running
- [ ] Schedule a social post ~2 minutes ahead → after due time, worker logs `Enqueued N due item(s).` and the post leaves Scheduled (published or failed)
- [ ] `docker compose logs -f worker` shows `Polling due scheduled work every 15s.`

Reviewed-on: #6
2026-08-24 09:47:58 -07:00
westfarn 71de919c34 Update public About bio, address, and headshot (#2)
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m57s
## Summary
Closes #1.

- Replace About page bio with the western-suburbs / Exit Realty Redefined copy and Monica's headshot
- Add `CONTACT_ADDRESS` (`1245 Butterfield Rd. #100, Wheaton, IL 60189`) and show it on Contact, nav, footer, and structured data
- Align Home hero + service-area defaults with Chicago's western suburbs

## Test plan
- [ ] `/about/` shows new bio and headshot
- [ ] `/contact/` map block shows Butterfield Rd address
- [ ] Header / footer on Home show the address
- [ ] Set `CONTACT_ADDRESS` in deploy `.env` if not relying on defaults
- [ ] Confirm OG image for About uses the new headshot

Reviewed-on: #2
2026-08-20 03:35:33 -07:00
46 changed files with 1683 additions and 363 deletions
+14 -2
View File
@@ -15,7 +15,8 @@ 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
CONTACT_ADDRESS=1245 Butterfield Rd. #100, Wheaton, IL 60189
CONTACT_SERVICE_AREA=Serving Chicago's western suburbs
CREDIT_NAME=AI ML Operations, LLC
CREDIT_URL=https://aimloperations.com
@@ -59,7 +60,7 @@ PCM_WEBHOOK_SECRETS=
# PCM_WEBHOOK_SECRET=
# Required return address on every postcard order.
# Quote the JSON (compose/shell break on bare {…, …}).
# PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"}'
# PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"1245 Butterfield Rd.","address2":"#100","city":"Wheaton","state":"IL","zipCode":"60189"}'
PCM_RETURN_ADDRESS=
# Or set fields individually if JSON is empty:
# PCM_RETURN_LINE1=
@@ -78,6 +79,14 @@ SOCIAL_TOKEN_ENCRYPTION_KEY=
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
# URL shortener (piha.lc). Empty locally = long UTM URLs in campaigns.
# Call the short host: POST /api/links/ (Bearer) and recipients GET /<code>.
SHORTENER_BASE_URL=
SHORTENER_API_TOKEN=
# Local shortener compose:
# SHORTENER_BASE_URL=http://127.0.0.1:8005
# SHORTENER_API_TOKEN=monica:dev-only-token
# 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.
@@ -91,4 +100,7 @@ TIANJI_ENABLED=false
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=cmshzhxdf6gee10qzkzrfn9iw
# Worker poll interval for due scheduled campaigns / social posts (seconds).
# DISPATCH_DUE_INTERVAL_SECONDS=15
GUNICORN_WORKERS=2
+9
View File
@@ -87,6 +87,13 @@ OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
OLLAMA_TIMEOUT_SECONDS=120
# URL shortener — mint short links for campaign SMS / email / postcard QR.
# POST /api/links/ on the same host phones hit. Recipients use https://piha.lc/<code>.
# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret).
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
SHORTENER_BASE_URL=https://piha.lc
SHORTENER_API_TOKEN=monica:replace-me
# Nominatim address suggest (server-side proxy only; not called from browser)
NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8
@@ -123,3 +130,5 @@ GUNICORN_BIND=0.0.0.0:8000
# OLLAMA_BASE_URL=http://10.0.0.128:11434
# NOMINATIM_BASE_URL=http://10.0.0.128:8089
# NOMINATIM_COUNTRY_CODES=us
# SHORTENER_BASE_URL=https://beta.piha.li
# SHORTENER_API_TOKEN=monica:replace-with-a-different-token
+16 -4
View File
@@ -25,13 +25,14 @@ uv sync
cd site
uv run python manage.py migrate
uv run python manage.py runserver
# optional: docker compose up # web + postgres (hot reload)
# optional worker: docker compose --profile worker up worker
# optional: docker compose up # web + postgres + worker (hot reload)
```
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:
`DJANGO_ENV=dev` (the default). The **worker** service polls `dispatch_due` every 15s so
scheduled campaigns and social posts fire without a manual command. Edit Python/templates
→ auto-reload; no image rebuild.
Rebuild only when Dockerfile, entrypoint scripts, or deps (`pyproject.toml`, `uv.lock`) change:
```bash
docker compose up --build
@@ -73,6 +74,17 @@ Templates: `.env.prod.example` (full var list). Validate with:
./scripts/validate-env.sh ~/Documents/secrets/monica_site/monica_site_prod.env
```
Campaign UTM short links (piha.li) need **both** sides:
| File | Vars |
|------|------|
| `monica_site_prod.env` | `SHORTENER_BASE_URL=https://piha.lc` · `SHORTENER_API_TOKEN=monica:<secret>` |
| `monica_site_beta.env` | `SHORTENER_BASE_URL=https://beta.piha.li` · distinct `SHORTENER_API_TOKEN` |
| `url_shortening_service_prod.env` | `SHORTENER_API_TOKENS=monica:<same-secret>` · `SHORT_ALLOWED_HOSTS` includes `mkdrealtor.com` · `PUBLIC_SHORT_URL=https://piha.lc` |
| `url_shortening_service_beta.env` | matching beta token · `PUBLIC_SHORT_URL=https://beta.piha.li` |
Generate the secret with `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Do not put it in git. Mint and redirects share the short host; Bearer is required on `/api/`.
## Deploy
CI on merge to `main` → tests → `server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`.
+1
View File
@@ -4,6 +4,7 @@
# 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
# Worker also polls dispatch_due so scheduled campaigns/social posts fire.
services:
web:
build: .
+6 -1
View File
@@ -61,13 +61,14 @@ services:
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on:
db:
condition: service_healthy
worker:
build: .
profiles: ["worker"]
entrypoint: ["/worker-entrypoint.sh"]
volumes:
- ./site:/app/site
@@ -77,6 +78,8 @@ services:
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}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
DISPATCH_DUE_INTERVAL_SECONDS: ${DISPATCH_DUE_INTERVAL_SECONDS:-15}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
@@ -101,6 +104,8 @@ services:
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on:
db:
condition: service_healthy
+2
View File
@@ -28,6 +28,8 @@ if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
RECAPTCHA_PRIVATE_KEY
EMAIL_HOST_USER
EMAIL_HOST_PASSWORD
SHORTENER_BASE_URL
SHORTENER_API_TOKEN
)
fi
+20 -1
View File
@@ -47,5 +47,24 @@ uv run python manage.py migrate --noinput
# worker must too or hashed lookups raise ValueError.
uv run python manage.py collectstatic --noinput
interval="${DISPATCH_DUE_INTERVAL_SECONDS:-15}"
echo "Starting dispatch_due poller (every ${interval}s)."
uv run python manage.py dispatch_due --loop --interval "${interval}" &
dispatch_pid=$!
cleanup() {
echo "Stopping dispatch_due poller..."
kill "${dispatch_pid}" 2>/dev/null || true
wait "${dispatch_pid}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Dev uses ImmediateBackend: enqueue runs inline in dispatch_due. No dj-queue.
if [[ "${DJANGO_ENV:-}" == "dev" ]]; then
echo "Dev worker: dispatch_due loop publishes scheduled work."
wait "${dispatch_pid}"
exit $?
fi
# dj-queue supervisor (workers + dispatcher + scheduler). Run on ONE host only.
exec uv run python manage.py dj_queue
uv run python manage.py dj_queue
+33 -1
View File
@@ -1,3 +1,5 @@
import time
from django.core.management.base import BaseCommand
from django.utils import timezone
@@ -10,10 +12,37 @@ 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."
"Pass --loop to poll until stopped (worker entrypoint)."
)
def add_arguments(self, parser):
parser.add_argument(
"--loop",
action="store_true",
help="Poll forever until SIGINT/SIGTERM.",
)
parser.add_argument(
"--interval",
type=int,
default=15,
help="Seconds between polls when --loop is set (default 15).",
)
def handle(self, *args, **options):
self.verbosity = int(options.get("verbosity", 1))
if options["loop"]:
interval = max(1, int(options["interval"] or 15))
self.stdout.write(f"Polling due scheduled work every {interval}s.")
try:
while True:
self._dispatch_once()
time.sleep(interval)
except KeyboardInterrupt:
self.stdout.write("dispatch_due loop stopped.")
return
self._dispatch_once()
def _dispatch_once(self):
now = timezone.now()
enqueued = 0
@@ -35,4 +64,7 @@ class Command(BaseCommand):
publish_social_post.enqueue(post_id=str(post.pk))
enqueued += 1
if enqueued:
self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s)."))
elif self.verbosity >= 2:
self.stdout.write("No due items.")
+48
View File
@@ -1,5 +1,13 @@
from datetime import timedelta
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from social.models import SocialPost
class HealthzTests(TestCase):
@@ -27,3 +35,43 @@ class PublicSmokeTests(TestCase):
client = Client()
self.assertEqual(client.get(reverse("public:about")).status_code, 200)
self.assertEqual(client.get(reverse("public:contact")).status_code, 200)
class DispatchDueTests(TestCase):
def test_enqueues_past_scheduled_social_post(self):
post = SocialPost.objects.create(
body="Open house",
status=SocialPost.Status.SCHEDULED,
scheduled_for=timezone.now() - timedelta(minutes=5),
)
mock_task = patch(
"core.management.commands.dispatch_due.publish_social_post"
).start()
self.addCleanup(patch.stopall)
out = StringIO()
call_command("dispatch_due", stdout=out)
post.refresh_from_db()
self.assertEqual(post.status, SocialPost.Status.QUEUED)
mock_task.enqueue.assert_called_once_with(post_id=str(post.pk))
self.assertIn("Enqueued 1 due item(s).", out.getvalue())
def test_skips_future_scheduled_social_post(self):
post = SocialPost.objects.create(
body="Later",
status=SocialPost.Status.SCHEDULED,
scheduled_for=timezone.now() + timedelta(hours=1),
)
mock_task = patch(
"core.management.commands.dispatch_due.publish_social_post"
).start()
self.addCleanup(patch.stopall)
call_command("dispatch_due", stdout=StringIO())
post.refresh_from_db()
self.assertEqual(post.status, SocialPost.Status.SCHEDULED)
mock_task.enqueue.assert_not_called()
def test_quiet_when_nothing_due(self):
out = StringIO()
call_command("dispatch_due", stdout=out)
self.assertEqual(out.getvalue(), "")
+17
View File
@@ -35,6 +35,23 @@ webhook events match the correct recipient row. Invalid / missing header values
no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample
non-UUID).
## Campaign tracked links (UTM + piha.li)
Composer auto-inserts a homepage link:
| Param | Value |
|-------|--------|
| `utm_source` | `monica` |
| `utm_medium` | `email` / `sms` / `postcard` |
| `utm_campaign` | hyphenated campaign name |
When `SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` are set, the app POSTs that
long HTTPS URL to `url_shortening_service` (`POST /api/links/`, Bearer
`monica:<secret>`) and puts `short_url` (`https://piha.li/<code>`) in SMS,
email hrefs, and postcard QR codes. Empty env → long UTM URL (local default).
Mint against the short host (`https://piha.lc` / `https://beta.piha.li`). See `.env.example` / `.env.prod.example`.
SMS correlation uses `message_id` (SMS id), then `destination_number` phone
fallback. Do **not** treat webhook `id` as the SMS id.
+173
View File
@@ -2,10 +2,13 @@
from __future__ import annotations
import html
import re
from datetime import datetime
from typing import TYPE_CHECKING
from urllib.parse import urlencode
from django.conf import settings
from django.core import signing
from django.db.models import QuerySet
from django.urls import reverse
@@ -13,6 +16,7 @@ from django.utils import timezone
from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate
from messaging.shortener import resolve_display_url
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser
@@ -33,6 +37,21 @@ _MERGE_TAG_RE = re.compile(
re.IGNORECASE,
)
# Campaign tracked links: utm_source is always the brand; medium = channel.
UTM_SOURCE = "monica"
UTM_CAMPAIGN_SLUG_MAX = 80
_UTM_ANCHOR_RE = re.compile(r"(<a\b[^>]*>)(.*?)(</a>)", re.IGNORECASE | re.DOTALL)
_UTM_TEXT_URL_RE = re.compile(
r"https?://[^\s<>\"]+utm_source=" + re.escape(UTM_SOURCE) + r"[^\s<>\"]*",
re.IGNORECASE,
)
# Minted short links in SMS (piha.li prod, beta.piha.li, local compose).
_SHORT_TEXT_URL_RE = re.compile(
r"https?://(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)"
r"/[a-z0-9]{4,8}",
re.IGNORECASE,
)
REMOVABLE_MESSAGE_STATUSES = frozenset(
{
Message.Status.DRAFT,
@@ -66,6 +85,149 @@ def render_merge_tags(text: str, contact: Contact | None) -> str:
return _MERGE_TAG_RE.sub(_replace, text)
def public_site_base_url() -> str:
"""Public homepage used as the UTM landing URL."""
base = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/")
if not base:
base = "https://mkdrealtor.com"
return base
def public_site_link_label() -> str:
"""Visible email-link text (host), with a stable label in local/dev."""
host = (
public_site_base_url()
.replace("https://", "")
.replace("http://", "")
.split("/")[0]
)
if host.startswith("127.") or host.startswith("localhost") or host.startswith("0.0.0.0"):
return "MKDRealtor.com"
return host or "MKDRealtor.com"
def campaign_utm_slug(name: str) -> str:
"""Lowercase hyphenated utm_campaign from the campaign name."""
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower())
slug = re.sub(r"-{2,}", "-", slug).strip("-")
return (slug or "campaign")[:UTM_CAMPAIGN_SLUG_MAX]
def build_campaign_utm_url(
*,
name: str,
medium: str,
base_url: str = "",
) -> str:
"""Homepage URL with utm_source / utm_medium / utm_campaign."""
base = (base_url or public_site_base_url()).rstrip("/")
channel = (medium or "").strip().lower()
if channel not in Channel.values:
channel = Channel.EMAIL
query = urlencode(
{
"utm_source": UTM_SOURCE,
"utm_medium": channel,
"utm_campaign": campaign_utm_slug(name),
}
)
return f"{base}/?{query}"
def campaign_shortener_ref(*, campaign_id=None, user_id=None, medium: str = "", name: str = "") -> str:
"""external_ref for the shortener (max 64). Prefer campaign UUID."""
if campaign_id:
return str(campaign_id)[:64]
slug = campaign_utm_slug(name)[:40]
ch = (medium or "email").strip().lower()[:8]
uid = "" if user_id is None else str(user_id)
return f"p{uid}-{ch}-{slug}"[:64]
def resolve_campaign_tracked_url(
*,
name: str,
medium: str,
campaign_id=None,
user_id=None,
shorten: bool = True,
) -> tuple[str, str]:
"""Return (long UTM URL, display URL). Display is short when mint works."""
target = build_campaign_utm_url(name=name, medium=medium)
if not shorten:
return target, target
display = resolve_display_url(
target,
title=f"{(name or 'campaign').strip()} ({medium})"[:200],
external_ref=campaign_shortener_ref(
campaign_id=campaign_id, user_id=user_id, medium=medium, name=name
),
)
return target, display
def _is_our_utm_anchor(attrs: str) -> bool:
lower = attrs.lower()
return "data-monica-utm" in lower or f"utm_source={UTM_SOURCE}" in lower
def ensure_campaign_utm_in_html(body: str, url: str, label: str) -> str:
"""Insert or refresh the tracked <a> in an HTML (email) body."""
href = html.escape(url, quote=True)
label_html = html.escape(label)
replaced = False
def _repl(match: re.Match[str]) -> str:
nonlocal replaced
if replaced or not _is_our_utm_anchor(match.group(1)):
return match.group(0)
replaced = True
inner = match.group(2) if (match.group(2) or "").strip() else label_html
return f'<a href="{href}" data-monica-utm="1">{inner}</a>'
out = _UTM_ANCHOR_RE.sub(_repl, body or "")
if replaced:
return out
anchor = f'<a href="{href}" data-monica-utm="1">{label_html}</a>'
text = (body or "").rstrip()
if text:
return f"{text}\n<p>{anchor}</p>"
return f"<p>{anchor}</p>"
def ensure_campaign_utm_in_text(body: str, url: str) -> str:
"""Insert or refresh the tracked URL in a plain-text (SMS) body."""
raw = body or ""
if _UTM_TEXT_URL_RE.search(raw):
return _UTM_TEXT_URL_RE.sub(url, raw, count=1)
if _SHORT_TEXT_URL_RE.search(raw):
return _SHORT_TEXT_URL_RE.sub(url, raw, count=1)
text = raw.rstrip()
return f"{text}\n\n{url}" if text else url
def ensure_campaign_utm_link(
body: str,
*,
name: str,
medium: str,
html: bool,
campaign_id=None,
shorten: bool | None = None,
) -> str:
"""Keep a tracked site link in the body, matching campaign name + channel."""
do_shorten = bool(campaign_id) if shorten is None else shorten
_target, url = resolve_campaign_tracked_url(
name=name,
medium=medium,
campaign_id=campaign_id,
shorten=do_shorten,
)
if html:
return ensure_campaign_utm_in_html(body, url, public_site_link_label())
return ensure_campaign_utm_in_text(body, url)
def message_is_removable(message: Message) -> bool:
return message.status in REMOVABLE_MESSAGE_STATUSES
@@ -287,6 +449,17 @@ def create_campaign_draft(
created_by=created_by,
template=template,
)
if channel in (Channel.EMAIL, Channel.SMS):
body = ensure_campaign_utm_link(
body,
name=name,
medium=channel,
html=(channel == Channel.EMAIL),
campaign_id=campaign.pk,
)
if body != campaign.body_override:
campaign.body_override = body
campaign.save(update_fields=["body_override", "updated_at"])
contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create(
[
+87
View File
@@ -0,0 +1,87 @@
"""Server-to-server client for url_shortening_service (piha.lc).
Call SHORTENER_BASE_URL (https://piha.lc / https://beta.piha.li).
Auth: Authorization: Bearer <SHORTENER_API_TOKEN> (name:secret).
"""
from __future__ import annotations
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
def is_configured() -> bool:
base = (getattr(settings, "SHORTENER_BASE_URL", None) or "").strip()
token = (getattr(settings, "SHORTENER_API_TOKEN", None) or "").strip()
return bool(base and token)
def mint_short_url(
target_url: str,
*,
title: str = "",
external_ref: str = "",
) -> str | None:
"""POST /api/links/. Return short_url, or None if unconfigured / failed.
Does not raise. Compose must still work when the shortener is down or unset.
"""
if not is_configured():
return None
target = (target_url or "").strip()
if not target.lower().startswith("https://"):
logger.info("shortener skip: target is not https")
return None
base = str(settings.SHORTENER_BASE_URL).rstrip("/")
token = str(settings.SHORTENER_API_TOKEN).strip()
timeout = int(getattr(settings, "SHORTENER_TIMEOUT_SECONDS", 10) or 10)
try:
response = requests.post(
f"{base}/api/links/",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={
"target_url": target,
"title": (title or "")[:200],
"external_ref": (external_ref or "")[:64],
},
timeout=timeout,
)
except requests.RequestException:
logger.exception("shortener request failed")
return None
if response.status_code not in (200, 201):
logger.warning(
"shortener mint failed status=%s",
response.status_code,
)
return None
try:
data = response.json()
except ValueError:
logger.warning("shortener mint returned non-json")
return None
if not isinstance(data, dict):
return None
short = (data.get("short_url") or "").strip()
return short or None
def resolve_display_url(
target_url: str,
*,
title: str = "",
external_ref: str = "",
) -> str:
"""Short URL when mint succeeds, otherwise the original target."""
return mint_short_url(
target_url, title=title, external_ref=external_ref
) or target_url
@@ -0,0 +1,46 @@
<div class="utm-panel" id="utm-link-panel"
data-base-url="{{ utm_base_url }}"
data-link-label="{{ utm_link_label }}"
data-medium="{{ utm_medium|default:'' }}"
data-campaign-name="{{ utm_campaign_name|default:'' }}"
data-shorten-url="{{ utm_shorten_url|default:'' }}"
data-campaign-id="{% if campaign %}{{ campaign.pk }}{% endif %}"
data-csrf="{{ csrf_token }}"
data-live="{% if utm_live %}1{% else %}0{% endif %}">
<div class="field" style="margin:0">
<label>Tracked site link</label>
<p class="hint" data-utm-email-hint hidden>
Auto-added to the email as a styled link. Updates as you type the campaign name.
<code>utm_medium=email</code> · <code>utm_campaign</code> matches the name.
Recipients tap a short <code>piha.li</code> link that redirects to this URL.
QR below encodes the same short link.
</p>
<p class="hint" data-utm-sms-hint hidden>
SMS is plain text — phones cannot show HTML links. A short <code>piha.li</code>
URL is in the message and phones make it tappable. Preview below shows it as a link.
</p>
<p class="hint" data-utm-postcard-hint hidden>
Copy the QR image into the postcard designer. Recipients scan it to open the
short tracked link (<code>piha.li</code>).
</p>
<code class="utm-url" data-utm-url>{{ utm_url|default:"" }}</code>
<div class="utm-actions">
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-url>Copy link</button>
</div>
<div class="utm-qr" data-utm-qr-wrap hidden>
<canvas data-utm-qr width="192" height="192" aria-label="Campaign QR code"></canvas>
<div class="utm-qr-actions">
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-qr>Copy QR image</button>
<button type="button" class="btn btn-ghost btn-sm" data-utm-download-qr>Download PNG</button>
<p class="hint" style="margin-top:8px" data-utm-qr-email-hint hidden>
Copy or download the PNG for print, signatures, or other materials.
</p>
<p class="hint" style="margin-top:8px" data-utm-qr-postcard-hint hidden>
Paste or upload the PNG in
<a href="{% url 'messaging:postcard_designer' %}">Postcard design</a>.
</p>
</div>
</div>
<p class="hint" data-utm-status hidden></p>
</div>
</div>
@@ -1,4 +1,5 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ campaign.name }} · Campaign{% endblock %}
{% block topbar_title %}{{ campaign.name }}{% endblock %}
{% block portal_content %}
@@ -51,6 +52,13 @@
</div>
{% endif %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b">
{% include "messaging/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %}
</div>
</div>
<div class="stat-row" id="campaign-stats">
<div class="stat-card">
<div class="label">Messages</div>
@@ -208,7 +216,10 @@
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script src="{% static 'js/campaign-utm.js' %}"></script>
<script>
window.MonicaUtm.bindPanel(document.getElementById("utm-link-panel"));
(function () {
var panel = document.getElementById("recipients-panel");
var page = (panel && panel.getAttribute("data-page")) || "1";
@@ -1,4 +1,5 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}Campaigns · Portal{% endblock %}
{% block topbar_title %}Campaign composer{% endblock %}
{% block extra_head %}
@@ -64,6 +65,7 @@
<label for="id_name">Campaign name</label>
<input id="id_name" name="name" type="text" required
placeholder="Spring seller tips" value="{{ form_data.name }}">
<div class="hint">Used as <code>utm_campaign</code> on the tracked site link.</div>
</div>
<div class="field" id="subject-field">
<label for="id_subject">Subject</label>
@@ -84,7 +86,7 @@
<textarea id="id_body_sms" style="min-height:140px"
placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code>. Tracked site URL is added automatically (SMS cannot use HTML links).</div>
</div>
<div class="field" id="postcard-body-hint" hidden>
<p class="hint-block" style="margin:0">
@@ -128,6 +130,7 @@
</div>
</div>
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. Youll get an email when the send finishes.</p>
{% include "messaging/_utm_link_panel.html" with utm_live=True %}
<button class="btn btn-primary" type="submit">Save draft</button>
</form>
</div>
@@ -179,6 +182,8 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script src="{% static 'js/campaign-utm.js' %}"></script>
<script>
(function () {
var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -186,11 +191,37 @@
var bodyField = document.getElementById('id_body');
var smsField = document.getElementById('id_body_sms');
var quill = null;
var utmLock = false;
var utmLinkLabel = "{{ utm_link_label|escapejs }}";
function csrfHeader() {
return { 'X-CSRFToken': csrfToken };
}
function audienceChannel() {
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'sms_opt_in') return 'sms';
if (audience === 'postcard_opt_in') return 'postcard';
return 'email';
}
function applyUtmToBodies(url, channel, label) {
if (utmLock) return;
utmLock = true;
try {
if (channel === 'email' && quill) {
window.MonicaUtm.ensureQuillLink(quill, url, label || utmLinkLabel);
syncBodyFromQuill();
} else if (channel === 'sms' && smsField) {
smsField.value = window.MonicaUtm.replaceTextUrl(smsField.value, url);
if (bodyField) bodyField.value = smsField.value;
syncCampaignPreview();
}
} finally {
utmLock = false;
}
}
function syncBodyFromQuill() {
if (!quill || !bodyField) return;
var html = quill.root.innerHTML;
@@ -214,6 +245,7 @@
var subject = (document.getElementById('id_subject') || {}).value || '';
var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in';
var isSms = audience === 'sms_opt_in';
var isPostcard = audience === 'postcard_opt_in';
var body = '';
if (isEmail && quill) {
@@ -244,6 +276,9 @@
if (isEmail) {
bodyEl.style.whiteSpace = 'normal';
bodyEl.innerHTML = body;
} else if (isSms) {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.innerHTML = window.MonicaUtm.linkify(body);
} else {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.textContent = body;
@@ -283,6 +318,8 @@
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
a.classList.toggle('active', active);
});
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
syncCampaignPreview();
};
@@ -341,7 +378,10 @@
} else if (initial) {
quill.setText(initial);
}
quill.on('text-change', syncBodyFromQuill);
quill.on('text-change', function () {
if (utmLock) return;
syncBodyFromQuill();
});
document.getElementById('campaign-compose').addEventListener('submit', function () {
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill();
@@ -370,6 +410,14 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill();
window.MonicaUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';
},
getMedium: audienceChannel,
csrfToken: csrfToken,
onUrlChange: applyUtmToBodies
});
syncComposeChannel();
})();
</script>
+258 -2
View File
@@ -1,5 +1,5 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression
@@ -1359,6 +1359,7 @@ class CampaignRecipientTableTests(TestCase):
self.assertNotContains(response, "Recent SMTP2GO events")
@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="")
class Smtp2goSmsSendTests(TestCase):
def setUp(self):
self.contact = Contact.objects.create(
@@ -1416,7 +1417,9 @@ class Smtp2goSmsSendTests(TestCase):
payload = post.call_args.kwargs["json"]
self.assertEqual(payload["api_key"], "api-test-key")
self.assertEqual(payload["destination"], ["+13304022675"])
self.assertEqual(payload["content"], "Hello Rufus")
self.assertTrue(payload["content"].startswith("Hello Rufus"))
self.assertIn("utm_source=monica", payload["content"])
self.assertIn("utm_medium=sms", payload["content"])
self.assertNotIn("to", payload)
self.assertNotIn("text", payload)
@@ -1450,3 +1453,256 @@ class Smtp2goSmsSendTests(TestCase):
self.assertIn("Missing required field", str(ctx.exception))
self.assertIn("INVALID_REQUEST", str(ctx.exception))
@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="")
class CampaignUtmLinkTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="utm-composer", password="test-pass-123"
)
self.client = Client()
self.client.login(username="utm-composer", password="test-pass-123")
self.contact = Contact.objects.create(
email="pat@example.com",
phone="5550100199",
first_name="Pat",
)
set_channel_consent(
self.contact, Channel.EMAIL, opted_in=True, reason="test"
)
set_channel_consent(
self.contact, Channel.SMS, opted_in=True, reason="test"
)
def test_slug_and_url_shape(self):
from messaging.services import (
build_campaign_utm_url,
campaign_utm_slug,
)
self.assertEqual(campaign_utm_slug("Spring seller tips"), "spring-seller-tips")
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
url = build_campaign_utm_url(
name="Spring seller tips", medium="email"
)
self.assertEqual(
url,
"https://mkdrealtor.com/?utm_source=monica"
"&utm_medium=email&utm_campaign=spring-seller-tips",
)
def test_email_draft_inserts_html_link(self):
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
campaign = create_campaign_draft(
name="Spring seller tips",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hello",
body="<p>Hi there</p>",
created_by=self.user,
)
self.assertIn('data-monica-utm="1"', campaign.body_override)
self.assertIn("utm_medium=email", campaign.body_override)
self.assertIn("utm_campaign=spring-seller-tips", campaign.body_override)
self.assertIn("Hi there", campaign.body_override)
def test_sms_draft_inserts_plain_url(self):
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
campaign = create_campaign_draft(
name="Open house",
audience=Campaign.Audience.SMS_OPT_IN,
body="See you Saturday",
created_by=self.user,
)
self.assertIn("See you Saturday", campaign.body_override)
self.assertIn(
"https://mkdrealtor.com/?utm_source=monica&utm_medium=sms"
"&utm_campaign=open-house",
campaign.body_override,
)
self.assertNotIn("<a ", campaign.body_override)
def test_sms_replaces_existing_tracked_url(self):
from messaging.services import ensure_campaign_utm_link
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
old = (
"Hi\n\nhttps://mkdrealtor.com/?utm_source=monica"
"&utm_medium=sms&utm_campaign=old-name"
)
updated = ensure_campaign_utm_link(
old, name="New name", medium="sms", html=False
)
self.assertIn("utm_campaign=new-name", updated)
self.assertNotIn("old-name", updated)
self.assertEqual(updated.count("https://"), 1)
def test_postcard_does_not_inject_into_body(self):
campaign = create_campaign_draft(
name="March mailer",
audience=Campaign.Audience.POSTCARD_OPT_IN,
body="Internal note only",
created_by=self.user,
)
self.assertEqual(campaign.body_override, "Internal note only")
self.assertNotIn("utm_source", campaign.body_override)
def test_composer_shows_tracked_link_panel(self):
url = reverse("messaging:campaign_list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Tracked site link")
self.assertContains(response, "campaign-utm.js")
self.assertContains(response, "qrcode.min.js")
self.assertContains(response, "data-utm-qr")
self.assertContains(response, "utm_campaign")
self.assertContains(response, reverse("messaging:campaign_short_link"))
def test_detail_shows_tracked_url(self):
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
campaign = create_campaign_draft(
name="Spring seller tips",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hello",
body="Hi",
created_by=self.user,
)
url = reverse(
"messaging:campaign_detail", kwargs={"pk": campaign.pk}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "utm_campaign=spring-seller-tips")
self.assertContains(response, "utm_medium=email")
self.assertContains(response, "qrcode.min.js")
self.assertContains(response, "data-utm-qr")
self.assertContains(response, "QR below encodes the same short link")
def test_sms_replaces_existing_short_url(self):
from messaging.services import ensure_campaign_utm_in_text
updated = ensure_campaign_utm_in_text(
"Hi\n\nhttps://piha.li/a3k9xm",
"https://piha.li/zzzzzz",
)
self.assertEqual(updated, "Hi\n\nhttps://piha.li/zzzzzz")
def test_shortener_mints_into_sms_body(self):
from unittest.mock import MagicMock, patch
mock = MagicMock()
mock.status_code = 201
mock.json.return_value = {
"code": "a3k9xm",
"short_url": "https://piha.li/a3k9xm",
"target_url": (
"https://mkdrealtor.com/?utm_source=monica"
"&utm_medium=sms&utm_campaign=open-house"
),
}
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="https://piha.lc",
SHORTENER_API_TOKEN="monica:test-token",
):
with patch(
"messaging.shortener.requests.post", return_value=mock
) as post:
campaign = create_campaign_draft(
name="Open house",
audience=Campaign.Audience.SMS_OPT_IN,
body="See you Saturday",
created_by=self.user,
)
self.assertIn("https://piha.li/a3k9xm", campaign.body_override)
self.assertIn("See you Saturday", campaign.body_override)
self.assertNotIn("utm_medium=sms", campaign.body_override)
post.assert_called_once()
headers = post.call_args.kwargs["headers"]
self.assertEqual(headers["Authorization"], "Bearer monica:test-token")
payload = post.call_args.kwargs["json"]
self.assertIn("utm_medium=sms", payload["target_url"])
self.assertIn("utm_campaign=open-house", payload["target_url"])
self.assertEqual(payload["external_ref"], str(campaign.pk))
self.assertEqual(campaign.messages.get().body_snapshot, campaign.body_override)
def test_shortener_failure_falls_back_to_long_url(self):
from unittest.mock import MagicMock, patch
mock = MagicMock()
mock.status_code = 401
mock.text = '{"detail":"Unauthorized"}'
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="https://piha.lc",
SHORTENER_API_TOKEN="monica:bad",
):
with patch("messaging.shortener.requests.post", return_value=mock):
campaign = create_campaign_draft(
name="Open house",
audience=Campaign.Audience.SMS_OPT_IN,
body="See you Saturday",
created_by=self.user,
)
self.assertIn(
"https://mkdrealtor.com/?utm_source=monica&utm_medium=sms"
"&utm_campaign=open-house",
campaign.body_override,
)
def test_short_link_endpoint_requires_login(self):
client = Client()
url = reverse("messaging:campaign_short_link")
response = client.post(
url,
data='{"name":"Open house","medium":"sms"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_short_link_endpoint_unconfigured_returns_long_url(self):
url = reverse("messaging:campaign_short_link")
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="",
SHORTENER_API_TOKEN="",
):
response = self.client.post(
url,
data='{"name":"Open house","medium":"sms"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertFalse(data["shortened"])
self.assertIn("utm_medium=sms", data["display_url"])
self.assertIn("utm_campaign=open-house", data["display_url"])
def test_short_link_endpoint_mints(self):
from unittest.mock import MagicMock, patch
mock = MagicMock()
mock.status_code = 201
mock.json.return_value = {
"code": "a3k9xm",
"short_url": "https://piha.li/a3k9xm",
"target_url": "https://mkdrealtor.com/?utm_source=monica",
}
url = reverse("messaging:campaign_short_link")
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="https://piha.lc",
SHORTENER_API_TOKEN="monica:test-token",
):
with patch("messaging.shortener.requests.post", return_value=mock):
response = self.client.post(
url,
data='{"name":"Open house","medium":"sms"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["shortened"])
self.assertEqual(data["display_url"], "https://piha.li/a3k9xm")
self.assertIn("utm_medium=sms", data["target_url"])
+5
View File
@@ -6,6 +6,11 @@ app_name = "messaging"
urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"),
path(
"campaigns/short-link/",
views.campaign_short_link,
name="campaign_short_link",
),
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
path(
"campaigns/<uuid:pk>/status.json",
+78
View File
@@ -1,6 +1,7 @@
import hashlib
import hmac
import io
import json
import logging
from django.conf import settings
@@ -26,12 +27,17 @@ from messaging.providers.postcard.pcm import (
list_designs,
)
from messaging.services import (
channel_for_audience,
create_campaign_draft,
enqueue_campaign_send,
ensure_campaign_utm_link,
message_is_removable,
opted_in_contacts,
parse_scheduled_for,
public_site_base_url,
public_site_link_label,
record_sms_stop,
resolve_campaign_tracked_url,
send_campaign_test_email,
)
from messaging.webhooks import (
@@ -462,6 +468,16 @@ def campaign_list(request):
if not body:
body = "Postcard mailing"
else:
if audience in Campaign.Audience.values:
channel = channel_for_audience(audience)
if channel in (Channel.EMAIL, Channel.SMS):
body = ensure_campaign_utm_link(
body,
name=name or "campaign",
medium=channel,
html=(channel == Channel.EMAIL),
)
form_data["body"] = body
if not body:
form_errors.append("Body is required.")
if (
@@ -506,10 +522,62 @@ def campaign_list(request):
"form_data": form_data,
"form_errors": form_errors,
"image_upload_url": reverse("messaging:campaign_image_upload"),
"utm_base_url": public_site_base_url(),
"utm_link_label": public_site_link_label(),
"utm_shorten_url": reverse("messaging:campaign_short_link"),
"utm_url": "",
},
)
@login_required
@require_POST
def campaign_short_link(request):
"""Mint (or reuse) a short URL for the campaign tracked landing link.
Browser talks to this portal endpoint only. The shortener is server-to-server.
"""
try:
payload = json.loads(request.body.decode() or "{}")
except (json.JSONDecodeError, UnicodeDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
name = (payload.get("name") or request.POST.get("name") or "").strip()
medium = (payload.get("medium") or request.POST.get("medium") or "").strip()
campaign_id = (
payload.get("campaign_id") or request.POST.get("campaign_id") or ""
)
campaign_id = str(campaign_id).strip()
if campaign_id:
try:
campaign = Campaign.objects.filter(pk=campaign_id).first()
except (ValidationError, ValueError):
campaign = None
if campaign is None:
return JsonResponse({"detail": "Campaign not found."}, status=404)
name = name or campaign.name
medium = medium or campaign.channel
campaign_id = str(campaign.pk)
else:
campaign_id = None
target, display = resolve_campaign_tracked_url(
name=name or "campaign",
medium=medium,
campaign_id=campaign_id,
user_id=getattr(request.user, "pk", None),
shorten=True,
)
return JsonResponse(
{
"target_url": target,
"short_url": display if display != target else "",
"display_url": display,
"shortened": display != target,
}
)
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
@@ -518,6 +586,12 @@ def campaign_detail(request, pk):
except (TypeError, ValueError):
page = 1
ctx = _campaign_report(campaign, page=page)
_target, display_url = resolve_campaign_tracked_url(
name=campaign.name,
medium=campaign.channel,
campaign_id=campaign.pk,
shorten=True,
)
return render(
request,
"messaging/campaign_detail.html",
@@ -529,6 +603,10 @@ def campaign_detail(request, pk):
"recent_events": ctx["recent_events"],
"events_title": ctx["events_title"],
"events_empty": ctx["events_empty"],
"utm_url": display_url,
"utm_base_url": public_site_base_url(),
"utm_link_label": public_site_link_label(),
"utm_shorten_url": reverse("messaging:campaign_short_link"),
"can_send": campaign.status
in {
Campaign.Status.DRAFT,
+15 -1
View File
@@ -197,6 +197,8 @@ STORAGES = {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Hashed static filenames are immutable; long cache helps Lighthouse "efficient cache".
WHITENOISE_MAX_AGE = 31536000
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -216,7 +218,12 @@ SITE_TAGLINE = env("SITE_TAGLINE", "MKDRealtor.com · EXIT Realty Redefined")
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "")
CONTACT_PHONE = env("CONTACT_PHONE", "(555) 123-4567")
CONTACT_EMAIL = env("CONTACT_EMAIL", "monica@example.com")
CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "Serving Greater Metro Area")
CONTACT_ADDRESS = env(
"CONTACT_ADDRESS", "1245 Butterfield Rd. #100, Wheaton, IL 60189"
)
CONTACT_SERVICE_AREA = env(
"CONTACT_SERVICE_AREA", "Serving Chicago's western suburbs"
)
CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations, LLC")
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com")
@@ -325,6 +332,13 @@ NOMINATIM_USER_AGENT = env(
)
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "")
# --- URL shortener (piha.lc) ---
# Same host as SMS links. Empty = skip minting (composer falls back to the
# long UTM URL). Token format: name:secret.
SHORTENER_BASE_URL = env("SHORTENER_BASE_URL", "")
SHORTENER_API_TOKEN = env("SHORTENER_API_TOKEN", "")
SHORTENER_TIMEOUT_SECONDS = int(env("SHORTENER_TIMEOUT_SECONDS", "10") or "10")
# --- Tianji analytics (pageviews + events; notice banner, not opt-in) ---
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
TIANJI_TRACKER_URL = env(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+62
View File
@@ -362,6 +362,29 @@ body.portal {
.plain-list { margin: 0 0 12px; padding-left: 18px; }
.plain-list li { margin-bottom: 6px; }
.check-row { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.check-row.account-pick { align-items: flex-start; }
.account-pick-copy {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.account-pick-copy strong { display: block; font-weight: 600; }
.account-rename {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.account-rename input[type="text"] {
flex: 1;
min-width: 160px;
max-width: 100%;
padding: 8px 10px;
border: 1px solid var(--monica-border);
font: inherit;
background: #fff;
box-sizing: border-box;
}
.row-warn td { background: #fffbeb; }
.auth-alert {
background: #fff7ed;
@@ -667,3 +690,42 @@ body.portal {
padding: 8px 10px; border: 1px solid var(--monica-border); font: inherit; font-size: 13px; background: #fff;
}
.empty-state { padding: 24px; color: var(--monica-muted); font-size: 14px; }
.utm-panel {
margin-top: 4px;
padding-top: 4px;
}
.utm-url {
display: block;
font-size: 12px;
line-height: 1.45;
word-break: break-all;
background: #f8fafc;
border: 1px solid var(--monica-border);
padding: 8px 10px;
color: #0f172a;
}
.utm-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.utm-qr {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 12px;
}
.utm-qr[hidden] {
display: none !important;
}
.utm-qr canvas {
display: block;
border: 1px solid var(--monica-border);
background: #fff;
}
.utm-qr-actions { min-width: 160px; }
.utm-qr-actions .btn { margin: 0 8px 8px 0; }
#preview-body a { color: var(--monica-primary); }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -20,9 +20,55 @@
width: auto;
}
/* Footer section titles — keep h5 visual weight without skipping heading levels */
.footer-heading {
font-size: 18px;
line-height: 1.4;
letter-spacing: 0.06em;
margin: 0 0 0.75rem;
}
.about-subtitle {
font-size: 18px;
color: var(--monica-muted);
color: #4b5563; /* was muted grey; needs ≥4.5:1 on white/surface */
}
/* Contrast fixes (Lighthouse color-contrast) */
.page .bg-gray-100 .text-width-1,
.page section.bg-gray-100 p {
color: #4b5563;
}
.page .footer-advanced-text,
.page .footer-advanced .footer-advanced-text {
color: #c5c6ca !important;
}
.page .footer-advanced .rights,
.page .footer-advanced .rights span {
color: #c5c6ca !important;
}
.page .form-label-outside {
color: #374151 !important;
}
.page .form-hint {
font-weight: 400;
color: #4b5563;
}
.page .form-consent-note {
font-size: 13px;
color: #4b5563;
margin: 12px 0 20px;
}
.page .contact-form-title {
font-size: 1.75rem;
margin-bottom: 1rem;
}
/* Skip layout work for below-fold marketing sections */
.page > .section.section-lg,
.page > .section.section-sm,
.page > .section.bg-gray-100 {
content-visibility: auto;
contain-intrinsic-size: 800px;
}
.page .button-primary,
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+296
View File
@@ -0,0 +1,296 @@
/**
* Campaign UTM link helper (composer + report).
* utm_source=monica, utm_medium=email|sms|postcard, utm_campaign=slug(name).
* Short URLs come from the portal JSON endpoint (server mints via piha.li).
*/
(function (global) {
"use strict";
var SOURCE = "monica";
var SLUG_MAX = 80;
var SHORTEN_WAIT_MS = 400;
var UTM_URL_RE = new RegExp(
"https?:\\/\\/[^\\s<>\"]+utm_source=" + SOURCE + "[^\\s<>\"]*",
"i"
);
var SHORT_URL_RE =
/https?:\/\/(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)\/[a-z0-9]{4,8}/i;
function slug(name) {
var s = String(name || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return (s || "campaign").slice(0, SLUG_MAX);
}
function buildUrl(base, medium, name) {
var root = String(base || "").replace(/\/+$/, "") || "https://mkdrealtor.com";
var ch = String(medium || "email").toLowerCase();
if (ch !== "email" && ch !== "sms" && ch !== "postcard") ch = "email";
var q =
"utm_source=" +
encodeURIComponent(SOURCE) +
"&utm_medium=" +
encodeURIComponent(ch) +
"&utm_campaign=" +
encodeURIComponent(slug(name));
return root + "/?" + q;
}
function replaceTextUrl(text, url) {
var raw = String(text || "");
if (UTM_URL_RE.test(raw)) {
return raw.replace(UTM_URL_RE, url);
}
if (SHORT_URL_RE.test(raw)) {
return raw.replace(SHORT_URL_RE, url);
}
var trimmed = raw.replace(/\s+$/, "");
return trimmed ? trimmed + "\n\n" + url : url;
}
function linkify(text) {
var esc = String(text || "").replace(/[&<>"']/g, function (c) {
return (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c
);
});
return esc.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" rel="noopener noreferrer">$1</a>'
);
}
function ensureQuillLink(quill, url, label) {
if (!quill || !quill.root) return;
var existing = quill.root.querySelectorAll(
'a[data-monica-utm], a[href*="utm_source=' + SOURCE + '"], a[href*="piha.li/"]'
);
if (existing.length) {
existing.forEach(function (a) {
a.setAttribute("href", url);
a.setAttribute("data-monica-utm", "1");
});
return;
}
var labelText = label || "MKDRealtor.com";
var start = Math.max(0, quill.getLength() - 1);
quill.insertText(start, "\n" + labelText, "silent");
quill.formatText(start + 1, labelText.length, "link", url, "silent");
var links = quill.root.querySelectorAll("a");
var last = links[links.length - 1];
if (last) last.setAttribute("data-monica-utm", "1");
}
function renderQr(canvas, url) {
if (!canvas || !url || typeof QRCode === "undefined") return Promise.resolve();
return QRCode.toCanvas(canvas, url, {
width: 192,
margin: 1,
color: { dark: "#00626c", light: "#ffffff" },
});
}
function flash(el, okText, errText, ok) {
if (!el) return;
el.textContent = ok ? okText : errText;
el.hidden = false;
window.setTimeout(function () {
el.hidden = true;
}, 2200);
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return Promise.reject(new Error("clipboard unavailable"));
}
function copyCanvas(canvas) {
if (!canvas || !canvas.toBlob) {
return Promise.reject(new Error("canvas unavailable"));
}
return new Promise(function (resolve, reject) {
canvas.toBlob(function (blob) {
if (!blob) {
reject(new Error("qr blob failed"));
return;
}
if (!navigator.clipboard || !navigator.clipboard.write || typeof ClipboardItem === "undefined") {
reject(new Error("image clipboard unavailable"));
return;
}
navigator.clipboard
.write([new ClipboardItem({ "image/png": blob })])
.then(resolve, reject);
}, "image/png");
});
}
function downloadCanvas(canvas, filename) {
if (!canvas) return;
var a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = filename || "campaign-qr.png";
document.body.appendChild(a);
a.click();
a.remove();
}
function bindPanel(panel, opts) {
if (!panel) return;
opts = opts || {};
var urlEl = panel.querySelector("[data-utm-url]");
var canvas = panel.querySelector("[data-utm-qr]");
var qrWrap = panel.querySelector("[data-utm-qr-wrap]");
var smsHint = panel.querySelector("[data-utm-sms-hint]");
var emailHint = panel.querySelector("[data-utm-email-hint]");
var postcardHint = panel.querySelector("[data-utm-postcard-hint]");
var statusEl = panel.querySelector("[data-utm-status]");
var base = panel.getAttribute("data-base-url") || "";
var label = panel.getAttribute("data-link-label") || "MKDRealtor.com";
var live = panel.getAttribute("data-live") === "1";
var shortenUrl = panel.getAttribute("data-shorten-url") || "";
var csrf =
(opts.csrfToken || panel.getAttribute("data-csrf") || "").trim();
var shortenTimer = null;
var shortenSeq = 0;
function medium() {
if (typeof opts.getMedium === "function") return opts.getMedium();
return panel.getAttribute("data-medium") || "email";
}
function name() {
if (typeof opts.getName === "function") return opts.getName();
return panel.getAttribute("data-campaign-name") || "";
}
function campaignId() {
return (
(typeof opts.getCampaignId === "function" && opts.getCampaignId()) ||
panel.getAttribute("data-campaign-id") ||
""
);
}
function paint(url, inject) {
var ch = medium();
var showQr = ch === "email" || ch === "postcard";
if (urlEl) urlEl.textContent = url;
if (qrWrap) qrWrap.hidden = !showQr;
if (smsHint) smsHint.hidden = ch !== "sms";
if (emailHint) emailHint.hidden = ch !== "email";
if (postcardHint) postcardHint.hidden = ch !== "postcard";
panel.querySelectorAll("[data-utm-qr-email-hint]").forEach(function (el) {
el.hidden = ch !== "email";
});
panel.querySelectorAll("[data-utm-qr-postcard-hint]").forEach(function (el) {
el.hidden = ch !== "postcard";
});
if (showQr && canvas) {
renderQr(canvas, url).catch(function () {});
}
if (inject && typeof opts.onUrlChange === "function") {
opts.onUrlChange(url, ch, label);
}
}
function apply() {
var ch = medium();
var longUrl = buildUrl(base, ch, name());
if (!shortenUrl) {
paint(longUrl, true);
return longUrl;
}
paint(longUrl, false);
var seq = ++shortenSeq;
window.clearTimeout(shortenTimer);
shortenTimer = window.setTimeout(function () {
fetch(shortenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-CSRFToken": csrf,
},
credentials: "same-origin",
body: JSON.stringify({
name: name(),
medium: ch,
campaign_id: campaignId(),
}),
})
.then(function (r) {
return r.ok ? r.json() : Promise.reject();
})
.then(function (data) {
if (seq !== shortenSeq) return;
paint((data && data.display_url) || longUrl, true);
})
.catch(function () {
if (seq !== shortenSeq) return;
paint(longUrl, true);
});
}, SHORTEN_WAIT_MS);
return longUrl;
}
panel.querySelectorAll("[data-utm-copy-url]").forEach(function (btn) {
btn.addEventListener("click", function () {
var url = (urlEl && urlEl.textContent) || "";
copyText(url).then(
function () {
flash(statusEl, "Link copied.", "Could not copy.", true);
},
function () {
flash(statusEl, "", "Could not copy link.", false);
}
);
});
});
panel.querySelectorAll("[data-utm-copy-qr]").forEach(function (btn) {
btn.addEventListener("click", function () {
copyCanvas(canvas).then(
function () {
flash(statusEl, "QR image copied.", "Could not copy QR.", true);
},
function () {
flash(statusEl, "", "Copy failed — use Download PNG.", false);
}
);
});
});
panel.querySelectorAll("[data-utm-download-qr]").forEach(function (btn) {
btn.addEventListener("click", function () {
var slugName = slug(name());
downloadCanvas(canvas, "qr-" + slugName + ".png");
flash(statusEl, "QR downloaded.", "", true);
});
});
if (live) {
var nameField = document.getElementById("id_name");
if (nameField) nameField.addEventListener("input", apply);
}
panel._utmApply = apply;
apply();
return { apply: apply, buildUrl: buildUrl };
}
global.MonicaUtm = {
SOURCE: SOURCE,
slug: slug,
buildUrl: buildUrl,
replaceTextUrl: replaceTextUrl,
linkify: linkify,
ensureQuillLink: ensureQuillLink,
bindPanel: bindPanel,
};
})(window);
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -1151,10 +1151,15 @@
url;
if (url = $this.attr("data-slide-bg")) {
// Eager-load first slide only; defer others until slide change.
if (j === 0) {
$this.css({
"background-image": "url(" + url + ")",
"background-size": "cover"
})
});
} else {
$this.attr("data-slide-bg-lazy", url).removeAttr("data-slide-bg");
}
}
}
@@ -1194,6 +1199,14 @@
transitionStart: function () {
toggleSwiperInnerVideos(this);
var active = $(this.slides[this.activeIndex]);
var lazyUrl = active.attr("data-slide-bg-lazy");
if (lazyUrl) {
active.css({
"background-image": "url(" + lazyUrl + ")",
"background-size": "cover"
}).removeAttr("data-slide-bg-lazy");
}
}
// TODO code review
// transitionEnd: function () {
+15 -12
View File
@@ -15,7 +15,7 @@
<meta property="og:description" content="{% block og_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
<meta property="og:url" content="{% block og_url %}{{ PUBLIC_SITE_URL }}{{ request.path }}{% endblock %}">
{% block og_image %}
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.webp' %}">
<meta property="og:image:alt" content="{{ SITE_NAME }} · EXIT Realty">
{% endblock %}
<meta name="twitter:card" content="summary_large_image">
@@ -23,11 +23,14 @@
<meta name="twitter:description" content="{% block twitter_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}">
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
<link rel="apple-touch-icon" href="{% static 'brand/apple-touch-icon.png' %}">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Work+Sans:300,400,500,700,800%7CPoppins:300,400,700">
<link rel="stylesheet" href="{% static 'css/bootstrap.css' %}">
<link rel="stylesheet" href="{% static 'css/fonts.css' %}">
<link rel="stylesheet" href="{% static 'css/style.css' %}" id="main-styles-link">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;700&family=Poppins:wght@400;700&display=swap">
<link rel="stylesheet" href="{% static 'css/public-vendor.css' %}" id="main-styles-link">
<link rel="stylesheet" href="{% static 'css/theme-overrides.css' %}">
{# Icon font pack is large and non-critical for first paint #}
<link rel="stylesheet" href="{% static 'css/fonts.css' %}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="{% static 'css/fonts.css' %}"></noscript>
{% block structured_data %}{% endblock %}
{% block extra_head %}{% endblock %}
</head>
@@ -49,13 +52,13 @@
<div class="rd-navbar-aside-outer">
<div class="rd-navbar-aside">
<div class="rd-navbar-panel">
<button class="rd-navbar-toggle" data-rd-navbar-toggle="#rd-navbar-nav-wrap-1"><span></span></button>
<button class="rd-navbar-toggle" type="button" aria-label="Open menu" data-rd-navbar-toggle="#rd-navbar-nav-wrap-1"><span></span></button>
<a class="rd-navbar-brand" href="{% url 'public:home' %}" data-tianji-event="nav_home">
<img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty · {{ SITE_NAME }}" width="151" height="44"/>
</a>
</div>
<div class="rd-navbar-collapse">
<button class="rd-navbar-collapse-toggle rd-navbar-fixed-element-1" data-rd-navbar-toggle="#rd-navbar-collapse-content-1"><span></span></button>
<button class="rd-navbar-collapse-toggle rd-navbar-fixed-element-1" type="button" aria-label="Open contact details" data-rd-navbar-toggle="#rd-navbar-collapse-content-1"><span></span></button>
<div class="rd-navbar-collapse-content" id="rd-navbar-collapse-content-1">
{% if CONTACT_PHONE %}
<article class="unit align-items-center">
@@ -65,7 +68,7 @@
{% endif %}
<article class="unit align-items-center">
<div class="unit-left"><span class="icon icon-md icon-modern mdi mdi-map-marker"></span></div>
<div class="unit-body"><span class="link-default">{{ CONTACT_SERVICE_AREA }}</span></div>
<div class="unit-body"><span class="link-default">{% if CONTACT_ADDRESS %}{{ CONTACT_ADDRESS }}{% else %}{{ CONTACT_SERVICE_AREA }}{% endif %}</span></div>
</article>
<a class="button button-gray-bordered button-winona" href="{% url 'public:contact' %}" data-tianji-event="nav_get_in_touch">Get in touch</a>
</div>
@@ -111,16 +114,16 @@
<div class="row row-50">
<div class="col-lg-6">
<div class="footer-brand-row">
<img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty">
<img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty" width="151" height="44">
<div>
<h5 class="font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</h5>
<p class="footer-heading font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</p>
<span style="color:#7dd3da;font-size:12px">{{ SITE_TAGLINE }}</span>
</div>
</div>
<p class="footer-advanced-text">Local expertise, clear communication, and a marketing platform built to bring serious buyers and sellers to you — not to a generic campaign factory.</p>
<p class="footer-advanced-text">Local expertise, clear communication, and a marketing platform built to bring serious buyers and sellers to you — not to a generic campaign factory.{% if CONTACT_ADDRESS %} {{ CONTACT_ADDRESS }}.{% endif %}</p>
</div>
<div class="col-lg-6">
<h5 class="font-weight-bold text-uppercase text-white">Navigate</h5>
<p class="footer-heading font-weight-bold text-uppercase text-white">Navigate</p>
<ul class="list-marked list-marked-sm">
<li><a href="{% url 'public:home' %}">Home</a></li>
<li><a href="{% url 'public:about' %}">About</a></li>
+4 -1
View File
@@ -20,8 +20,11 @@ def site_branding(request):
"CONTACT_PHONE": phone,
"CONTACT_PHONE_TEL": _phone_tel(phone),
"CONTACT_EMAIL": settings.CONTACT_EMAIL or "",
"CONTACT_ADDRESS": getattr(settings, "CONTACT_ADDRESS", "") or "",
"CONTACT_SERVICE_AREA": getattr(
settings, "CONTACT_SERVICE_AREA", "Serving Greater Metro Area"
settings,
"CONTACT_SERVICE_AREA",
"Serving Chicago's western suburbs",
),
"CREDIT_NAME": getattr(settings, "CREDIT_NAME", "AI ML Operations, LLC"),
"CREDIT_URL": getattr(
+21 -11
View File
@@ -3,12 +3,15 @@
{% block title %}About {{ SITE_NAME }} · EXIT Realty Realtor{% endblock %}
{% block og_title %}About {{ SITE_NAME }} · EXIT Realty Realtor{% endblock %}
{% block twitter_title %}About {{ SITE_NAME }} · EXIT Realty Realtor{% endblock %}
{% block meta_description %}Meet {{ SITE_NAME }}, a local EXIT Realty realtor helping families buy and sell homes with clear communication and marketing that reaches people. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block og_description %}Meet {{ SITE_NAME }}, a local EXIT Realty realtor helping families buy and sell homes with clear communication and marketing that reaches people. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block twitter_description %}Meet {{ SITE_NAME }}, a local EXIT Realty realtor helping families buy and sell homes with clear communication and marketing that reaches people. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block meta_description %}Meet {{ SITE_NAME }}, your real estate partner in Chicago's western suburbs with EXIT Realty Redefined — buyers, sellers, and first-time homebuyers welcome. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block og_description %}Meet {{ SITE_NAME }}, your real estate partner in Chicago's western suburbs with EXIT Realty Redefined — buyers, sellers, and first-time homebuyers welcome. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block twitter_description %}Meet {{ SITE_NAME }}, your real estate partner in Chicago's western suburbs with EXIT Realty Redefined — buyers, sellers, and first-time homebuyers welcome. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block og_image %}
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/careers-1-570x388.jpg' %}">
<meta property="og:image:alt" content="{{ SITE_NAME }}">
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.webp' %}">
<meta property="og:image:alt" content="Professional headshot of {{ SITE_NAME }}, Real Estate Agent">
{% endblock %}
{% block extra_head %}
<link rel="preload" as="image" type="image/webp" href="{% static 'images/monica-dhillon-headshot-640.webp' %}" imagesrcset="{% static 'images/monica-dhillon-headshot-640.webp' %} 640w, {% static 'images/monica-dhillon-headshot.webp' %} 816w" imagesizes="(max-width: 991px) 90vw, 480px" fetchpriority="high">
{% endblock %}
{% block structured_data %}
<script type="application/ld+json">
@@ -17,13 +20,15 @@
"@type": "AboutPage",
"name": "About {{ SITE_NAME|escapejs }}",
"url": "{{ PUBLIC_SITE_URL }}{% url 'public:about' %}",
"description": "Meet {{ SITE_NAME|escapejs }}, a local EXIT Realty realtor helping families buy and sell homes with clear communication and marketing that reaches people.",
"description": "Meet {{ SITE_NAME|escapejs }}, your real estate partner in Chicago's western suburbs with EXIT Realty Redefined.",
"mainEntity": {
"@type": "RealEstateAgent",
"name": "{{ SITE_NAME|escapejs }}",
"url": "{{ PUBLIC_SITE_URL }}/",
"image": "{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.webp' %}",
"telephone": "{{ CONTACT_PHONE|escapejs }}",
"email": "{{ CONTACT_EMAIL|escapejs }}",
{% if CONTACT_ADDRESS %}"address": "{{ CONTACT_ADDRESS|escapejs }}",{% endif %}
"areaServed": "{{ CONTACT_SERVICE_AREA|escapejs }}",
"worksFor": {
"@type": "Organization",
@@ -34,7 +39,7 @@
</script>
{% endblock %}
{% block content %}
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});">
<div class="breadcrumbs-custom-inner">
<div class="container breadcrumbs-custom-container">
<div class="breadcrumbs-custom-main">
@@ -52,13 +57,18 @@
<div class="container">
<div class="row row-50 justify-content-center justify-content-lg-between flex-lg-row-reverse">
<div class="col-md-10 col-lg-6 col-xl-5">
<h2 class="text-uppercase">Local realtor,<br>personal service</h2>
<p class="about-subtitle">I help families buy and sell homes with clear communication and marketing that actually reaches people — not just another postcard blast.</p>
<p>This site replaces a costly third-party campaign platform. Visitors reach me through a protected contact form; every inquiry lands in my portal with source tracking so I know which campaigns work.</p>
<h2>Meet Monica Dhillon — Your Real Estate Partner in the Western Suburbs</h2>
<p class="about-subtitle">Mandeep "Monica" Dhillon is a real estate agent serving Chicago's western suburbs, just forty minutes from downtown. At Exit Realty Redefined, Monica and her team bring years of hands-on experience helping buyers and sellers reach their goals — with a special passion for guiding first-time homebuyers through a process that can feel overwhelming, from mortgages to market values to all the fine print in between.</p>
<p>Family values aren't just a tagline here — they're how we treat every client who walks through our door.</p>
<p>Monica has been through the homebuying process herself, more than once, so she gets the nerves and the "wait, is this normal?" moments. Her experience spans both sides of the table — buyers, sellers, investors — and properties ranging from $200K starter homes to multi-million dollar deals.</p>
<p>Whatever your real estate goals, Monica and the Exit Realty team are ready to help you navigate the market's ups and downs. Questions? Don't be shy — she's always just a call or message away.</p>
<a class="button button-lg button-primary button-winona" href="{% url 'public:contact' %}" data-tianji-event="about_cta" data-tianji-event-destination="contact">Work with me</a>
</div>
<div class="col-md-10 col-lg-6 col-xl-6">
<img class="img-responsive" src="{% static 'images/careers-1-570x388.jpg' %}" alt="{{ SITE_NAME }}, EXIT Realty realtor" width="570" height="388"/>
<picture>
<source type="image/webp" srcset="{% static 'images/monica-dhillon-headshot-640.webp' %} 640w, {% static 'images/monica-dhillon-headshot.webp' %} 816w" sizes="(max-width: 991px) 90vw, 480px">
<img class="img-responsive" src="{% static 'images/monica-dhillon-headshot.jpg' %}" alt="Professional headshot of Mandeep &quot;Monica&quot; Dhillon, Real Estate Agent, wearing a blue cardigan against a black background." width="816" height="1024" decoding="async" fetchpriority="high"/>
</picture>
</div>
</div>
</div>
+9 -5
View File
@@ -20,6 +20,7 @@
"url": "{{ PUBLIC_SITE_URL }}/",
"telephone": "{{ CONTACT_PHONE|escapejs }}",
"email": "{{ CONTACT_EMAIL|escapejs }}",
{% if CONTACT_ADDRESS %}"address": "{{ CONTACT_ADDRESS|escapejs }}",{% endif %}
"areaServed": "{{ CONTACT_SERVICE_AREA|escapejs }}",
"contactPoint": {
"@type": "ContactPoint",
@@ -34,7 +35,7 @@
</script>
{% endblock %}
{% block content %}
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});">
<div class="breadcrumbs-custom-inner">
<div class="container breadcrumbs-custom-container">
<div class="breadcrumbs-custom-main">
@@ -67,12 +68,14 @@
</div>
</div>
{% endif %}
{% if CONTACT_ADDRESS or CONTACT_SERVICE_AREA %}
<div class="layout-bordered-item wow-outer">
<div class="layout-bordered-item-inner wow slideInUp">
<div class="icon icon-lg mdi mdi-map-marker text-primary"></div>
<span class="link-default">{{ CONTACT_SERVICE_AREA }}</span>
<span class="link-default">{% if CONTACT_ADDRESS %}{{ CONTACT_ADDRESS }}{% else %}{{ CONTACT_SERVICE_AREA }}{% endif %}</span>
</div>
</div>
{% endif %}
</div>
</div>
</section>
@@ -80,7 +83,7 @@
<div class="container section-lg">
<div class="row">
<div class="col-lg-8">
<h3 class="text-uppercase">Send a message</h3>
<h2 class="text-uppercase contact-form-title">Send a message</h2>
<form class="rd-form contact-message-form" method="post" action="{% url 'public:contact' %}">
{% csrf_token %}
<div class="row row-10">
@@ -113,7 +116,7 @@
</div>
</div>
<div class="col-12">
<p class="form-label-outside" style="margin:8px 0 4px">Mailing address <span style="font-weight:400;color:#6b7280">(optional — for postcards)</span></p>
<p class="form-label-outside" style="margin:8px 0 4px">Mailing address <span class="form-hint">(optional — for postcards)</span></p>
</div>
<div class="col-12" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="form-wrap address-ac-wrap">
@@ -170,7 +173,7 @@
<div class="col-12">{{ form.captcha }}{{ form.captcha.errors }}</div>
{% endif %}
</div>
<p style="font-size:13px;color:#6b7280;margin:12px 0 20px;">By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
<p class="form-consent-note">By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
<button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button>
</form>
</div>
@@ -179,6 +182,7 @@
</section>
{% endblock %}
{% block extra_head %}
<link rel="preload" as="image" type="image/webp" href="{% static 'images/breadcrumbs-image-1.webp' %}" fetchpriority="high">
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %}
{% block tracking_events %}
+18 -18
View File
@@ -6,6 +6,9 @@
{% block meta_description %}{{ SITE_NAME }}, EXIT Realty — buying and selling homes with clear advice and multi-channel marketing. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block og_description %}{{ SITE_NAME }}, EXIT Realty — buying and selling homes with clear advice and multi-channel marketing. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block twitter_description %}{{ SITE_NAME }}, EXIT Realty — buying and selling homes with clear advice and multi-channel marketing. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
{% block extra_head %}
<link rel="preload" as="image" type="image/webp" href="{% static 'images/slider-minimal-slide-1-1920x968.webp' %}" fetchpriority="high">
{% endblock %}
{% block structured_data %}
<script type="application/ld+json">
{
@@ -13,9 +16,10 @@
"@type": "RealEstateAgent",
"name": "{{ SITE_NAME|escapejs }}",
"url": "{{ PUBLIC_SITE_URL }}/",
"image": "{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}",
"image": "{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.webp' %}",
"telephone": "{{ CONTACT_PHONE|escapejs }}",
"email": "{{ CONTACT_EMAIL|escapejs }}",
{% if CONTACT_ADDRESS %}"address": "{{ CONTACT_ADDRESS|escapejs }}",{% endif %}
"description": "{{ SITE_NAME|escapejs }}, EXIT Realty — buying and selling homes with clear advice and multi-channel marketing. {{ CONTACT_SERVICE_AREA|escapejs }}.",
"areaServed": "{{ CONTACT_SERVICE_AREA|escapejs }}",
"worksFor": {
@@ -26,32 +30,28 @@
</script>
{% endblock %}
{% block content %}
<div class="swiper-container swiper-slider swiper-slider-minimal" data-loop="true" data-slide-effect="fade" data-autoplay="false" data-simulate-touch="true">
<div class="swiper-container swiper-slider swiper-slider-minimal" data-loop="false" data-slide-effect="fade" data-autoplay="false" data-simulate-touch="true">
<div class="swiper-wrapper">
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-1-1920x968.webp' %}">
<div class="container">
<div class="jumbotron-classic-content">
<div class="wow-outer">
<div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">{{ SITE_NAME }} · MKDRealtor.com</div>
</div>
<h1 class="text-uppercase text-white font-weight-bold wow-outer"><span class="wow slideInDown" data-wow-delay=".2s">Your Home,<br>Guided Well</span></h1>
<p class="text-white wow-outer"><span class="wow slideInDown" data-wow-delay=".35s">EXIT Realty Redefined — buying or selling in the metro area with clear advice, strong marketing, and a team that answers the phone.</span></p>
<div class="wow-outer button-outer">
<a class="button button-md button-primary button-winona wow slideInDown" href="{% url 'public:contact' %}" data-wow-delay=".4s" data-tianji-event="hero_cta" data-tianji-event-destination="contact">Start a conversation</a>
<div class="title-docor-text font-weight-bold title-decorated text-uppercase text-white">{{ SITE_NAME }} · MKDRealtor.com</div>
<h1 class="text-uppercase text-white font-weight-bold">Your Home,<br>Guided Well</h1>
<p class="text-white">EXIT Realty Redefined — buying or selling in Chicago's western suburbs with clear advice, strong marketing, and a team that answers the phone.</p>
<div class="button-outer">
<a class="button button-md button-primary button-winona" href="{% url 'public:contact' %}" data-tianji-event="hero_cta" data-tianji-event-destination="contact">Start a conversation</a>
</div>
</div>
</div>
</div>
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-2-1920x968.jpg' %}">
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-2-1920x968.webp' %}">
<div class="container">
<div class="jumbotron-classic-content">
<div class="wow-outer">
<div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">Local Market Insight</div>
</div>
<h2 class="text-uppercase text-white font-weight-bold wow-outer"><span class="wow slideInDown" data-wow-delay=".2s">Sell With<br>Confidence</span></h2>
<p class="text-white wow-outer"><span class="wow slideInDown" data-wow-delay=".35s">Pricing strategy, staging guidance, and multi-channel outreach so the right buyers see your listing.</span></p>
<div class="wow-outer button-outer">
<a class="button button-md button-primary button-winona wow slideInDown" href="{% url 'public:about' %}" data-wow-delay=".4s" data-tianji-event="hero_about">Meet Monica</a>
<div class="title-docor-text font-weight-bold title-decorated text-uppercase text-white">Local Market Insight</div>
<h2 class="text-uppercase text-white font-weight-bold">Sell With<br>Confidence</h2>
<p class="text-white">Pricing strategy, staging guidance, and multi-channel outreach so the right buyers see your listing.</p>
<div class="button-outer">
<a class="button button-md button-primary button-winona" href="{% url 'public:about' %}" data-tianji-event="hero_about">Meet Monica</a>
</div>
</div>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@
{% block og_description %}Terms of Service for {{ SITE_NAME }} / MKDRealtor.com — how we use analytics, protect your information, and never sell or share personal data with third parties for marketing.{% endblock %}
{% block twitter_description %}Terms of Service for {{ SITE_NAME }} / MKDRealtor.com — how we use analytics, protect your information, and never sell or share personal data with third parties for marketing.{% endblock %}
{% block content %}
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});">
<div class="breadcrumbs-custom-inner">
<div class="container breadcrumbs-custom-container">
<div class="breadcrumbs-custom-main">
+39
View File
@@ -3,6 +3,29 @@ from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
_GENERIC_ACCOUNT_LABELS = frozenset(
{
"linkedin member",
"instagram account",
"facebook page",
}
)
def is_generic_account_label(label: str) -> bool:
"""True when OAuth left a placeholder or unreadable name."""
text = (label or "").strip()
if not text:
return True
lowered = text.lower()
if lowered in _GENERIC_ACCOUNT_LABELS:
return True
if lowered.startswith("page ") and text.split()[-1].isdigit():
return True
if text.startswith("urn:"):
return True
return False
class Platform(models.TextChoices):
FACEBOOK = "facebook", "Facebook"
@@ -54,6 +77,22 @@ class SocialAccount(UUIDPrimaryKeyModel, TimeStampedModel):
def __str__(self) -> str:
return f"{self.platform}: {self.label}"
@property
def display_name(self) -> str:
return (self.label or "").strip() or self.get_platform_display()
@staticmethod
def resolve_label(existing: "SocialAccount | None", incoming: str) -> str:
"""Keep a custom rename; otherwise take the OAuth name."""
incoming = (incoming or "").strip()[:120]
if existing is not None and not is_generic_account_label(existing.label):
return existing.label
if incoming:
return incoming
if existing and existing.label:
return existing.label
return "Account"
class SocialPost(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
+12 -2
View File
@@ -137,8 +137,18 @@
{% for account in accounts %}
<tr {% if not account.is_active %}class="row-warn"{% endif %}>
<td>
<strong>{{ account.label }}</strong><br>
<span class="muted">{{ account.external_id }}</span>
<form method="post" class="account-rename">
{% csrf_token %}
<input type="hidden" name="action" value="rename">
<input type="hidden" name="account_id" value="{{ account.pk }}">
<input id="id_label_{{ account.pk }}" name="label" type="text" maxlength="120"
value="{{ account.label }}" required
aria-label="Display name for {{ account.get_platform_display }}">
<button class="btn btn-ghost btn-sm" type="submit">Rename</button>
</form>
{% if account.external_id %}
<span class="muted" style="display:block;margin-top:6px;overflow-wrap:anywhere">{{ account.external_id }}</span>
{% endif %}
</td>
<td>
<span class="platform-pill {% if account.platform == 'facebook' %}meta{% elif account.platform == 'instagram' %}ig{% else %}li{% endif %}">
+23 -18
View File
@@ -116,34 +116,35 @@
<div class="field">
<label>Accounts</label>
{% for account in accounts %}
<label class="check-row">
<label class="check-row account-pick">
<input type="checkbox" name="account_ids" value="{{ account.pk }}"
class="account-check"
data-platform="{{ account.platform }}"
{% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}>
{{ account.get_platform_display }} · {{ account.label }}
<span class="account-pick-copy">
<strong>{{ account.display_name }}</strong>
<span class="muted">{{ account.get_platform_display }}{% if account.external_id %} · {{ account.external_id }}{% endif %}</span>
</span>
</label>
{% empty %}
<p class="muted">No active accounts.</p>
{% endfor %}
{% if accounts %}
<div class="hint">Name look wrong? <a href="{% url 'social:account_list' %}">Rename it on Social accounts</a>.</div>
{% endif %}
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="id_publish_mode">Publish</label>
<select id="id_publish_mode" name="publish_mode">
<option value="schedule"{% if form.publish_mode == "schedule" %} selected{% endif %}>Schedule</option>
<option value="now"{% if form.publish_mode == "now" %} selected{% endif %}>Publish now</option>
<option value="draft"{% if form.publish_mode == "draft" %} selected{% endif %}>Save draft only</option>
</select>
</div>
<div class="field">
<label for="id_scheduled_for">When</label>
<label for="id_scheduled_for">Schedule for <span class="muted">(optional)</span></label>
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
value="{{ form.scheduled_for }}">
<div class="hint">Needed only if you click Schedule. Leave blank to Send now.</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary" type="submit" name="action" value="publish">Save / publish</button>
<button class="btn btn-primary" type="submit" name="action" value="send_now">Send now</button>
<button class="btn btn-ghost" type="submit" name="action" value="schedule">Schedule</button>
<button class="btn btn-ghost" type="submit" name="action" value="save">Save draft</button>
</div>
</form>
</div>
@@ -199,8 +200,8 @@
var previewBody = document.getElementById('preview-body');
var content = document.getElementById('preview-content');
var previewMedia = document.getElementById('preview-media');
var mode = document.getElementById('id_publish_mode');
var when = document.getElementById('id_scheduled_for');
var composeForm = document.getElementById('social-compose');
var mediaInput = document.getElementById('id_media');
var mediaJson = document.getElementById('id_media_json');
var thumbs = document.getElementById('media-thumbs');
@@ -296,9 +297,14 @@
previewBody.hidden = !hasContent;
}
function syncWhen() {
if (!mode || !when) return;
when.disabled = mode.value !== 'schedule';
function requireScheduleTime(event) {
var submitter = event.submitter;
var action = submitter && submitter.name === 'action' ? submitter.value : '';
if (action !== 'schedule') return;
if (when && when.value) return;
event.preventDefault();
if (when) when.focus();
window.alert('Pick a date and time to schedule, or choose Send now.');
}
function validateBeforeUpload(file) {
@@ -363,7 +369,7 @@
bodyEl.addEventListener('keyup', syncPreview);
bodyEl.addEventListener('change', syncPreview);
}
if (mode) mode.addEventListener('change', syncWhen);
if (composeForm) composeForm.addEventListener('submit', requireScheduleTime);
var generateForm = document.querySelector('form input[name=action][value=generate]')?.form;
if (generateForm) {
@@ -378,7 +384,6 @@
syncMediaField();
renderThumbs();
syncPreview();
syncWhen();
})();
</script>
{% endblock %}
@@ -38,7 +38,12 @@
<tbody>
{% for target in post.targets.all %}
<tr>
<td>{{ target.account.label }}</td>
<td>
{{ target.account.display_name }}
{% if target.account.external_id %}
<br><span class="muted" style="overflow-wrap:anywhere">{{ target.account.external_id }}</span>
{% endif %}
</td>
<td>{{ target.get_platform_display }}</td>
<td><span class="badge badge-{{ target.status }}">{{ target.get_status_display }}</span></td>
</tr>
+182
View File
@@ -0,0 +1,182 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from social.models import (
Platform,
SocialAccount,
SocialPost,
is_generic_account_label,
)
class AccountLabelTests(TestCase):
def test_generic_placeholders(self):
self.assertTrue(is_generic_account_label(""))
self.assertTrue(is_generic_account_label("LinkedIn member"))
self.assertTrue(is_generic_account_label("Instagram account"))
self.assertTrue(is_generic_account_label("Page 123456"))
self.assertTrue(is_generic_account_label("urn:li:person:abc"))
self.assertFalse(is_generic_account_label("Monica Dhillon"))
self.assertFalse(is_generic_account_label("@mkdrealtor"))
def test_resolve_keeps_custom_name(self):
existing = SocialAccount(
platform=Platform.LINKEDIN,
label="Monica (personal)",
external_id="urn:li:person:abc",
)
self.assertEqual(
SocialAccount.resolve_label(existing, "LinkedIn member"),
"Monica (personal)",
)
def test_resolve_replaces_generic(self):
existing = SocialAccount(
platform=Platform.LINKEDIN,
label="LinkedIn member",
external_id="urn:li:person:abc",
)
self.assertEqual(
SocialAccount.resolve_label(existing, "Monica Dhillon"),
"Monica Dhillon",
)
class SocialComposerTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="composer", password="test-pass-123"
)
self.client = Client()
self.client.login(username="composer", password="test-pass-123")
self.account = SocialAccount.objects.create(
platform=Platform.LINKEDIN,
label="LinkedIn member",
external_id="urn:li:person:abc123",
is_active=True,
owner=self.user,
)
def test_composer_shows_send_now_and_full_name(self):
response = self.client.get(reverse("social:composer"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Send now")
self.assertContains(response, "Schedule")
self.assertContains(response, "LinkedIn member")
self.assertContains(response, "urn:li:person:abc123")
self.assertNotContains(response, "Save / publish")
def test_send_now_queues_without_schedule(self):
mock_task = patch("social.views.publish_social_post").start()
self.addCleanup(patch.stopall)
response = self.client.post(
reverse("social:composer"),
{
"body": "Open house Saturday",
"account_ids": [str(self.account.pk)],
"action": "send_now",
},
)
post = SocialPost.objects.get()
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url, reverse("social:post_detail", kwargs={"pk": post.pk})
)
self.assertEqual(post.status, SocialPost.Status.QUEUED)
self.assertIsNotNone(post.scheduled_for)
self.assertEqual(post.targets.count(), 1)
mock_task.enqueue.assert_called_once_with(post_id=str(post.pk))
def test_schedule_requires_datetime(self):
response = self.client.post(
reverse("social:composer"),
{
"body": "Open house Saturday",
"account_ids": [str(self.account.pk)],
"action": "schedule",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Pick a schedule date/time, or choose Send now.")
self.assertFalse(SocialPost.objects.exists())
def test_schedule_with_datetime(self):
response = self.client.post(
reverse("social:composer"),
{
"body": "Open house Saturday",
"account_ids": [str(self.account.pk)],
"action": "schedule",
"scheduled_for": "2026-08-10T09:30",
},
)
post = SocialPost.objects.get()
self.assertEqual(response.status_code, 302)
self.assertEqual(post.status, SocialPost.Status.SCHEDULED)
self.assertIsNotNone(post.scheduled_for)
def test_save_draft(self):
response = self.client.post(
reverse("social:composer"),
{
"body": "Draft caption",
"action": "save",
},
)
post = SocialPost.objects.get()
self.assertEqual(response.status_code, 302)
self.assertEqual(post.status, SocialPost.Status.DRAFT)
class SocialAccountRenameTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="renamer", password="test-pass-123"
)
self.client = Client()
self.client.login(username="renamer", password="test-pass-123")
self.account = SocialAccount.objects.create(
platform=Platform.FACEBOOK,
label="Page 999",
external_id="999",
is_active=True,
owner=self.user,
)
def test_accounts_page_shows_rename_field(self):
response = self.client.get(reverse("social:account_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'name="label"')
self.assertContains(response, "Page 999")
self.assertContains(response, "Rename")
def test_rename_updates_label(self):
response = self.client.post(
reverse("social:account_list"),
{
"action": "rename",
"account_id": str(self.account.pk),
"label": "Monica Dhillon Realty",
},
)
self.assertEqual(response.status_code, 302)
self.account.refresh_from_db()
self.assertEqual(self.account.label, "Monica Dhillon Realty")
def test_rename_rejects_blank(self):
response = self.client.post(
reverse("social:account_list"),
{
"action": "rename",
"account_id": str(self.account.pk),
"label": " ",
},
)
self.assertEqual(response.status_code, 302)
self.account.refresh_from_db()
self.assertEqual(self.account.label, "Page 999")
+35 -9
View File
@@ -162,6 +162,17 @@ def account_list(request):
if request.method == "POST":
action = (request.POST.get("action") or "connect").strip()
if action == "rename":
pk = request.POST.get("account_id")
account = get_object_or_404(SocialAccount, pk=pk)
label = (request.POST.get("label") or "").strip()[:120]
if not label:
messages.error(request, "Enter a display name.")
return redirect("social:account_list")
account.label = label
account.save(update_fields=["label", "updated_at"])
messages.success(request, f"Renamed account to {account.label}.")
return redirect("social:account_list")
if action == "disconnect":
pk = request.POST.get("account_id")
account = get_object_or_404(SocialAccount, pk=pk)
@@ -361,11 +372,14 @@ def linkedin_oauth_callback(request):
profile = linkedin_fetch_member_profile(token_payload["access_token"])
author_urn = profile["author_urn"]
blob = linkedin_token_blob_from_oauth(token_payload, author_urn=author_urn)
existing = SocialAccount.objects.filter(
platform=Platform.LINKEDIN, external_id=author_urn
).first()
account, created = SocialAccount.objects.update_or_create(
platform=Platform.LINKEDIN,
external_id=author_urn,
defaults={
"label": profile["label"],
"label": SocialAccount.resolve_label(existing, profile["label"]),
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True,
"owner": request.user,
@@ -473,11 +487,14 @@ def meta_oauth_complete(request):
)
for page in ig_pages:
blob = instagram_token_blob(page, user_token=user_token)
existing = SocialAccount.objects.filter(
platform=Platform.INSTAGRAM, external_id=blob["ig_user_id"]
).first()
account, created = SocialAccount.objects.update_or_create(
platform=Platform.INSTAGRAM,
external_id=blob["ig_user_id"],
defaults={
"label": blob["label"],
"label": SocialAccount.resolve_label(existing, blob["label"]),
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True,
"owner": request.user,
@@ -487,12 +504,15 @@ def meta_oauth_complete(request):
else:
for page in pages:
blob = facebook_token_blob(page, user_token=user_token)
label = (page.get("name") or "").strip() or f"Page {page['id']}"
incoming_label = (page.get("name") or "").strip() or f"Page {page['id']}"
existing = SocialAccount.objects.filter(
platform=Platform.FACEBOOK, external_id=str(page["id"])
).first()
account, created = SocialAccount.objects.update_or_create(
platform=Platform.FACEBOOK,
external_id=str(page["id"]),
defaults={
"label": label,
"label": SocialAccount.resolve_label(existing, incoming_label),
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True,
"owner": request.user,
@@ -525,7 +545,7 @@ def composer(request):
form = {
"body": "",
"prompt": "",
"publish_mode": "schedule",
"publish_mode": "now",
"scheduled_for": "",
"account_ids": [],
"media_json": "[]",
@@ -536,11 +556,17 @@ def composer(request):
if request.method == "POST":
form["body"] = (request.POST.get("body") or "").strip()
form["prompt"] = (request.POST.get("prompt") or "").strip()
form["publish_mode"] = (request.POST.get("publish_mode") or "schedule").strip()
form["publish_mode"] = (request.POST.get("publish_mode") or "now").strip()
form["scheduled_for"] = request.POST.get("scheduled_for") or ""
form["account_ids"] = request.POST.getlist("account_ids")
form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]"
action = (request.POST.get("action") or "save").strip()
if action == "send_now":
form["publish_mode"] = "now"
elif action == "schedule":
form["publish_mode"] = "schedule"
elif action == "save":
form["publish_mode"] = "draft"
if action == "generate" and form["prompt"]:
try:
@@ -548,7 +574,7 @@ def composer(request):
form["body"] = draft
except OllamaError as exc:
error = str(exc)
elif action in {"save", "publish"}:
elif action in {"save", "publish", "send_now", "schedule"}:
media_items: list = []
try:
media_items = parse_media_json(form["media_json"])
@@ -571,7 +597,7 @@ def composer(request):
error = str(exc)
else:
if not scheduled_for:
error = "Pick a schedule date/time, or choose Publish now."
error = "Pick a schedule date/time, or choose Send now."
else:
status = SocialPost.Status.SCHEDULED
elif form["publish_mode"] == "now":
@@ -614,7 +640,7 @@ def composer(request):
publish_social_post.enqueue(post_id=str(post.pk))
messages.success(
request,
"Post queued for publishing to selected accounts.",
"Post sent — queued for publishing to selected accounts.",
)
elif status == SocialPost.Status.SCHEDULED:
messages.success(