Compare commits

..
1 Commits
Author SHA1 Message Date
westfarn a817b9caf7 Update public About bio, Wheaton address, and headshot.
CI / test (pull_request) Successful in 13s
Closes #1 — refresh About copy and photo, surface the Butterfield Rd office on Home/Contact/nav, and align service-area defaults with Chicago's western suburbs.
2026-08-20 03:35:12 -07:00
44 changed files with 349 additions and 1652 deletions
-11
View File
@@ -79,14 +79,6 @@ SOCIAL_TOKEN_ENCRYPTION_KEY=
OLLAMA_BASE_URL=http://10.0.0.128:11434 OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2 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 (LAN) — address autocomplete via Django /api/address-suggest/
# Nominatim has no built-in API keys; optional NOMINATIM_API_KEY only if you # Nominatim has no built-in API keys; optional NOMINATIM_API_KEY only if you
# put a gateway in front that checks X-API-Key. # put a gateway in front that checks X-API-Key.
@@ -100,7 +92,4 @@ TIANJI_ENABLED=false
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=cmshzhxdf6gee10qzkzrfn9iw TIANJI_WEBSITE_ID=cmshzhxdf6gee10qzkzrfn9iw
# Worker poll interval for due scheduled campaigns / social posts (seconds).
# DISPATCH_DUE_INTERVAL_SECONDS=15
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
-9
View File
@@ -87,13 +87,6 @@ OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2 OLLAMA_MODEL=llama3.2
OLLAMA_TIMEOUT_SECONDS=120 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 address suggest (server-side proxy only; not called from browser)
NOMINATIM_BASE_URL=http://10.0.0.128:8089 NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8 NOMINATIM_TIMEOUT_SECONDS=8
@@ -130,5 +123,3 @@ GUNICORN_BIND=0.0.0.0:8000
# OLLAMA_BASE_URL=http://10.0.0.128:11434 # OLLAMA_BASE_URL=http://10.0.0.128:11434
# NOMINATIM_BASE_URL=http://10.0.0.128:8089 # NOMINATIM_BASE_URL=http://10.0.0.128:8089
# NOMINATIM_COUNTRY_CODES=us # NOMINATIM_COUNTRY_CODES=us
# SHORTENER_BASE_URL=https://beta.piha.li
# SHORTENER_API_TOKEN=monica:replace-with-a-different-token
+4 -16
View File
@@ -25,14 +25,13 @@ uv sync
cd site cd site
uv run python manage.py migrate uv run python manage.py migrate
uv run python manage.py runserver uv run python manage.py runserver
# optional: docker compose up # web + postgres + worker (hot reload) # optional: docker compose up # web + postgres (hot reload)
# optional worker: docker compose --profile worker up worker
``` ```
Docker compose mounts `./site` into the container and runs Django `runserver` when Docker compose mounts `./site` into the container and runs Django `runserver` when
`DJANGO_ENV=dev` (the default). The **worker** service polls `dispatch_due` every 15s so `DJANGO_ENV=dev` (the default). Edit Python/templates → auto-reload; no image rebuild.
scheduled campaigns and social posts fire without a manual command. Edit Python/templates Rebuild only when Dockerfile / deps (`pyproject.toml`, `uv.lock`) change:
→ auto-reload; no image rebuild.
Rebuild only when Dockerfile, entrypoint scripts, or deps (`pyproject.toml`, `uv.lock`) change:
```bash ```bash
docker compose up --build docker compose up --build
@@ -74,17 +73,6 @@ Templates: `.env.prod.example` (full var list). Validate with:
./scripts/validate-env.sh ~/Documents/secrets/monica_site/monica_site_prod.env ./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 ## Deploy
CI on merge to `main` → tests → `server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`. CI on merge to `main` → tests → `server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`.
-1
View File
@@ -4,7 +4,6 @@
# Web runs on every app host (active/active). Start the dj-queue worker on # Web runs on every app host (active/active). Start the dj-queue worker on
# exactly ONE host via: # exactly ONE host via:
# docker compose -f docker-compose.prod.yml --profile worker up -d # docker compose -f docker-compose.prod.yml --profile worker up -d
# Worker also polls dispatch_due so scheduled campaigns/social posts fire.
services: services:
web: web:
build: . build: .
+1 -6
View File
@@ -61,14 +61,13 @@ services:
META_APP_ID: ${META_APP_ID:-} META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
worker: worker:
build: . build: .
profiles: ["worker"]
entrypoint: ["/worker-entrypoint.sh"] entrypoint: ["/worker-entrypoint.sh"]
volumes: volumes:
- ./site:/app/site - ./site:/app/site
@@ -78,8 +77,6 @@ services:
DJANGO_DEBUG: ${DJANGO_DEBUG:-true} DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0} 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} 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: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-} EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-} EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
@@ -104,8 +101,6 @@ services:
META_APP_ID: ${META_APP_ID:-} META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
-2
View File
@@ -28,8 +28,6 @@ if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
RECAPTCHA_PRIVATE_KEY RECAPTCHA_PRIVATE_KEY
EMAIL_HOST_USER EMAIL_HOST_USER
EMAIL_HOST_PASSWORD EMAIL_HOST_PASSWORD
SHORTENER_BASE_URL
SHORTENER_API_TOKEN
) )
fi fi
+1 -20
View File
@@ -47,24 +47,5 @@ uv run python manage.py migrate --noinput
# worker must too or hashed lookups raise ValueError. # worker must too or hashed lookups raise ValueError.
uv run python manage.py collectstatic --noinput 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. # dj-queue supervisor (workers + dispatcher + scheduler). Run on ONE host only.
uv run python manage.py dj_queue exec uv run python manage.py dj_queue
+1 -33
View File
@@ -1,5 +1,3 @@
import time
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
@@ -12,37 +10,10 @@ from social.tasks import publish_social_post
class Command(BaseCommand): class Command(BaseCommand):
help = ( help = (
"Enqueue due scheduled campaign messages and social posts. " "Enqueue due scheduled campaign messages and social posts. "
"Pass --loop to poll until stopped (worker entrypoint)." "Optional when the task backend supports run_after defer; useful as a safety net."
)
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): 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() now = timezone.now()
enqueued = 0 enqueued = 0
@@ -64,7 +35,4 @@ class Command(BaseCommand):
publish_social_post.enqueue(post_id=str(post.pk)) publish_social_post.enqueue(post_id=str(post.pk))
enqueued += 1 enqueued += 1
if enqueued:
self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s).")) 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,13 +1,5 @@
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.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from social.models import SocialPost
class HealthzTests(TestCase): class HealthzTests(TestCase):
@@ -35,43 +27,3 @@ class PublicSmokeTests(TestCase):
client = Client() client = Client()
self.assertEqual(client.get(reverse("public:about")).status_code, 200) self.assertEqual(client.get(reverse("public:about")).status_code, 200)
self.assertEqual(client.get(reverse("public:contact")).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,23 +35,6 @@ webhook events match the correct recipient row. Invalid / missing header values
no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample
non-UUID). 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 SMS correlation uses `message_id` (SMS id), then `destination_number` phone
fallback. Do **not** treat webhook `id` as the SMS id. fallback. Do **not** treat webhook `id` as the SMS id.
-173
View File
@@ -2,13 +2,10 @@
from __future__ import annotations from __future__ import annotations
import html
import re import re
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlencode
from django.conf import settings
from django.core import signing from django.core import signing
from django.db.models import QuerySet from django.db.models import QuerySet
from django.urls import reverse from django.urls import reverse
@@ -16,7 +13,6 @@ from django.utils import timezone
from contacts.models import Channel, ConsentRecord, Contact, Suppression from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate from messaging.models import Campaign, Message, MessageTemplate
from messaging.shortener import resolve_display_url
if TYPE_CHECKING: if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser from django.contrib.auth.models import AbstractBaseUser
@@ -37,21 +33,6 @@ _MERGE_TAG_RE = re.compile(
re.IGNORECASE, 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( REMOVABLE_MESSAGE_STATUSES = frozenset(
{ {
Message.Status.DRAFT, Message.Status.DRAFT,
@@ -85,149 +66,6 @@ def render_merge_tags(text: str, contact: Contact | None) -> str:
return _MERGE_TAG_RE.sub(_replace, text) 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: def message_is_removable(message: Message) -> bool:
return message.status in REMOVABLE_MESSAGE_STATUSES return message.status in REMOVABLE_MESSAGE_STATUSES
@@ -449,17 +287,6 @@ def create_campaign_draft(
created_by=created_by, created_by=created_by,
template=template, 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)) contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create( Message.objects.bulk_create(
[ [
-87
View File
@@ -1,87 +0,0 @@
"""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
@@ -1,46 +0,0 @@
<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,5 +1,4 @@
{% extends "portal_base.html" %} {% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ campaign.name }} · Campaign{% endblock %} {% block title %}{{ campaign.name }} · Campaign{% endblock %}
{% block topbar_title %}{{ campaign.name }}{% endblock %} {% block topbar_title %}{{ campaign.name }}{% endblock %}
{% block portal_content %} {% block portal_content %}
@@ -52,13 +51,6 @@
</div> </div>
{% endif %} {% 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-row" id="campaign-stats">
<div class="stat-card"> <div class="stat-card">
<div class="label">Messages</div> <div class="label">Messages</div>
@@ -216,10 +208,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% 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> <script>
window.MonicaUtm.bindPanel(document.getElementById("utm-link-panel"));
(function () { (function () {
var panel = document.getElementById("recipients-panel"); var panel = document.getElementById("recipients-panel");
var page = (panel && panel.getAttribute("data-page")) || "1"; var page = (panel && panel.getAttribute("data-page")) || "1";
@@ -1,5 +1,4 @@
{% extends "portal_base.html" %} {% extends "portal_base.html" %}
{% load static %}
{% block title %}Campaigns · Portal{% endblock %} {% block title %}Campaigns · Portal{% endblock %}
{% block topbar_title %}Campaign composer{% endblock %} {% block topbar_title %}Campaign composer{% endblock %}
{% block extra_head %} {% block extra_head %}
@@ -65,7 +64,6 @@
<label for="id_name">Campaign name</label> <label for="id_name">Campaign name</label>
<input id="id_name" name="name" type="text" required <input id="id_name" name="name" type="text" required
placeholder="Spring seller tips" value="{{ form_data.name }}"> 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>
<div class="field" id="subject-field"> <div class="field" id="subject-field">
<label for="id_subject">Subject</label> <label for="id_subject">Subject</label>
@@ -86,7 +84,7 @@
<textarea id="id_body_sms" style="min-height:140px" <textarea id="id_body_sms" style="min-height:140px"
placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …" placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
oninput="syncSmsBody()">{{ form_data.body }}</textarea> 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>. Tracked site URL is added automatically (SMS cannot use HTML links).</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></div>
</div> </div>
<div class="field" id="postcard-body-hint" hidden> <div class="field" id="postcard-body-hint" hidden>
<p class="hint-block" style="margin:0"> <p class="hint-block" style="margin:0">
@@ -130,7 +128,6 @@
</div> </div>
</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> <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> <button class="btn btn-primary" type="submit">Save draft</button>
</form> </form>
</div> </div>
@@ -182,8 +179,6 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% 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/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> <script>
(function () { (function () {
var uploadUrl = "{{ image_upload_url|escapejs }}"; var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -191,37 +186,11 @@
var bodyField = document.getElementById('id_body'); var bodyField = document.getElementById('id_body');
var smsField = document.getElementById('id_body_sms'); var smsField = document.getElementById('id_body_sms');
var quill = null; var quill = null;
var utmLock = false;
var utmLinkLabel = "{{ utm_link_label|escapejs }}";
function csrfHeader() { function csrfHeader() {
return { 'X-CSRFToken': csrfToken }; 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() { function syncBodyFromQuill() {
if (!quill || !bodyField) return; if (!quill || !bodyField) return;
var html = quill.root.innerHTML; var html = quill.root.innerHTML;
@@ -245,7 +214,6 @@
var subject = (document.getElementById('id_subject') || {}).value || ''; var subject = (document.getElementById('id_subject') || {}).value || '';
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in'; var isEmail = audience === 'email_opt_in';
var isSms = audience === 'sms_opt_in';
var isPostcard = audience === 'postcard_opt_in'; var isPostcard = audience === 'postcard_opt_in';
var body = ''; var body = '';
if (isEmail && quill) { if (isEmail && quill) {
@@ -276,9 +244,6 @@
if (isEmail) { if (isEmail) {
bodyEl.style.whiteSpace = 'normal'; bodyEl.style.whiteSpace = 'normal';
bodyEl.innerHTML = body; bodyEl.innerHTML = body;
} else if (isSms) {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.innerHTML = window.MonicaUtm.linkify(body);
} else { } else {
bodyEl.style.whiteSpace = 'pre-wrap'; bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.textContent = body; bodyEl.textContent = body;
@@ -318,8 +283,6 @@
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard); var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
a.classList.toggle('active', active); a.classList.toggle('active', active);
}); });
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
syncCampaignPreview(); syncCampaignPreview();
}; };
@@ -378,10 +341,7 @@
} else if (initial) { } else if (initial) {
quill.setText(initial); quill.setText(initial);
} }
quill.on('text-change', function () { quill.on('text-change', syncBodyFromQuill);
if (utmLock) return;
syncBodyFromQuill();
});
document.getElementById('campaign-compose').addEventListener('submit', function () { document.getElementById('campaign-compose').addEventListener('submit', function () {
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill(); if (audience === 'email_opt_in') syncBodyFromQuill();
@@ -410,14 +370,6 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview); if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill(); initQuill();
window.MonicaUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';
},
getMedium: audienceChannel,
csrfToken: csrfToken,
onUrlChange: applyUtmToBodies
});
syncComposeChannel(); syncComposeChannel();
})(); })();
</script> </script>
+2 -258
View File
@@ -1,5 +1,5 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression from contacts.models import Channel, ConsentRecord, Contact, Suppression
@@ -1359,7 +1359,6 @@ class CampaignRecipientTableTests(TestCase):
self.assertNotContains(response, "Recent SMTP2GO events") self.assertNotContains(response, "Recent SMTP2GO events")
@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="")
class Smtp2goSmsSendTests(TestCase): class Smtp2goSmsSendTests(TestCase):
def setUp(self): def setUp(self):
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
@@ -1417,9 +1416,7 @@ class Smtp2goSmsSendTests(TestCase):
payload = post.call_args.kwargs["json"] payload = post.call_args.kwargs["json"]
self.assertEqual(payload["api_key"], "api-test-key") self.assertEqual(payload["api_key"], "api-test-key")
self.assertEqual(payload["destination"], ["+13304022675"]) self.assertEqual(payload["destination"], ["+13304022675"])
self.assertTrue(payload["content"].startswith("Hello Rufus")) self.assertEqual(payload["content"], "Hello Rufus")
self.assertIn("utm_source=monica", payload["content"])
self.assertIn("utm_medium=sms", payload["content"])
self.assertNotIn("to", payload) self.assertNotIn("to", payload)
self.assertNotIn("text", payload) self.assertNotIn("text", payload)
@@ -1453,256 +1450,3 @@ class Smtp2goSmsSendTests(TestCase):
self.assertIn("Missing required field", str(ctx.exception)) self.assertIn("Missing required field", str(ctx.exception))
self.assertIn("INVALID_REQUEST", 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,11 +6,6 @@ app_name = "messaging"
urlpatterns = [ urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"), 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>/", views.campaign_detail, name="campaign_detail"),
path( path(
"campaigns/<uuid:pk>/status.json", "campaigns/<uuid:pk>/status.json",
-78
View File
@@ -1,7 +1,6 @@
import hashlib import hashlib
import hmac import hmac
import io import io
import json
import logging import logging
from django.conf import settings from django.conf import settings
@@ -27,17 +26,12 @@ from messaging.providers.postcard.pcm import (
list_designs, list_designs,
) )
from messaging.services import ( from messaging.services import (
channel_for_audience,
create_campaign_draft, create_campaign_draft,
enqueue_campaign_send, enqueue_campaign_send,
ensure_campaign_utm_link,
message_is_removable, message_is_removable,
opted_in_contacts, opted_in_contacts,
parse_scheduled_for, parse_scheduled_for,
public_site_base_url,
public_site_link_label,
record_sms_stop, record_sms_stop,
resolve_campaign_tracked_url,
send_campaign_test_email, send_campaign_test_email,
) )
from messaging.webhooks import ( from messaging.webhooks import (
@@ -468,16 +462,6 @@ def campaign_list(request):
if not body: if not body:
body = "Postcard mailing" body = "Postcard mailing"
else: 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: if not body:
form_errors.append("Body is required.") form_errors.append("Body is required.")
if ( if (
@@ -522,62 +506,10 @@ def campaign_list(request):
"form_data": form_data, "form_data": form_data,
"form_errors": form_errors, "form_errors": form_errors,
"image_upload_url": reverse("messaging:campaign_image_upload"), "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 @login_required
def campaign_detail(request, pk): def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk) campaign = get_object_or_404(Campaign, pk=pk)
@@ -586,12 +518,6 @@ def campaign_detail(request, pk):
except (TypeError, ValueError): except (TypeError, ValueError):
page = 1 page = 1
ctx = _campaign_report(campaign, page=page) 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( return render(
request, request,
"messaging/campaign_detail.html", "messaging/campaign_detail.html",
@@ -603,10 +529,6 @@ def campaign_detail(request, pk):
"recent_events": ctx["recent_events"], "recent_events": ctx["recent_events"],
"events_title": ctx["events_title"], "events_title": ctx["events_title"],
"events_empty": ctx["events_empty"], "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 "can_send": campaign.status
in { in {
Campaign.Status.DRAFT, Campaign.Status.DRAFT,
-9
View File
@@ -197,8 +197,6 @@ STORAGES = {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "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" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -332,13 +330,6 @@ NOMINATIM_USER_AGENT = env(
) )
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "") 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 analytics (pageviews + events; notice banner, not opt-in) ---
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG) TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
TIANJI_TRACKER_URL = env( 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,29 +362,6 @@ body.portal {
.plain-list { margin: 0 0 12px; padding-left: 18px; } .plain-list { margin: 0 0 12px; padding-left: 18px; }
.plain-list li { margin-bottom: 6px; } .plain-list li { margin-bottom: 6px; }
.check-row { display: flex; align-items: center; gap: 8px; font-size: 14px; } .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; } .row-warn td { background: #fffbeb; }
.auth-alert { .auth-alert {
background: #fff7ed; background: #fff7ed;
@@ -690,42 +667,3 @@ body.portal {
padding: 8px 10px; border: 1px solid var(--monica-border); font: inherit; font-size: 13px; background: #fff; 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; } .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,55 +20,9 @@
width: auto; 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 { .about-subtitle {
font-size: 18px; font-size: 18px;
color: #4b5563; /* was muted grey; needs ≥4.5:1 on white/surface */ color: var(--monica-muted);
}
/* 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, .page .button-primary,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

-296
View File
@@ -1,296 +0,0 @@
/**
* 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
+1 -14
View File
@@ -1151,15 +1151,10 @@
url; url;
if (url = $this.attr("data-slide-bg")) { if (url = $this.attr("data-slide-bg")) {
// Eager-load first slide only; defer others until slide change.
if (j === 0) {
$this.css({ $this.css({
"background-image": "url(" + url + ")", "background-image": "url(" + url + ")",
"background-size": "cover" "background-size": "cover"
}); })
} else {
$this.attr("data-slide-bg-lazy", url).removeAttr("data-slide-bg");
}
} }
} }
@@ -1199,14 +1194,6 @@
transitionStart: function () { transitionStart: function () {
toggleSwiperInnerVideos(this); 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 // TODO code review
// transitionEnd: function () { // transitionEnd: function () {
+10 -13
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: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 %}"> <meta property="og:url" content="{% block og_url %}{{ PUBLIC_SITE_URL }}{{ request.path }}{% endblock %}">
{% block og_image %} {% block og_image %}
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.webp' %}"> <meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
<meta property="og:image:alt" content="{{ SITE_NAME }} · EXIT Realty"> <meta property="og:image:alt" content="{{ SITE_NAME }} · EXIT Realty">
{% endblock %} {% endblock %}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
@@ -23,14 +23,11 @@
<meta name="twitter:description" content="{% block twitter_description %}{{ SITE_NAME }} — {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %}"> <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="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
<link rel="apple-touch-icon" href="{% static 'brand/apple-touch-icon.png' %}"> <link rel="apple-touch-icon" href="{% static 'brand/apple-touch-icon.png' %}">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Work+Sans:300,400,500,700,800%7CPoppins:300,400,700">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="{% static 'css/bootstrap.css' %}">
<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/fonts.css' %}">
<link rel="stylesheet" href="{% static 'css/public-vendor.css' %}" id="main-styles-link"> <link rel="stylesheet" href="{% static 'css/style.css' %}" id="main-styles-link">
<link rel="stylesheet" href="{% static 'css/theme-overrides.css' %}"> <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 structured_data %}{% endblock %}
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
@@ -52,13 +49,13 @@
<div class="rd-navbar-aside-outer"> <div class="rd-navbar-aside-outer">
<div class="rd-navbar-aside"> <div class="rd-navbar-aside">
<div class="rd-navbar-panel"> <div class="rd-navbar-panel">
<button class="rd-navbar-toggle" type="button" aria-label="Open menu" data-rd-navbar-toggle="#rd-navbar-nav-wrap-1"><span></span></button> <button class="rd-navbar-toggle" 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"> <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"/> <img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty · {{ SITE_NAME }}" width="151" height="44"/>
</a> </a>
</div> </div>
<div class="rd-navbar-collapse"> <div class="rd-navbar-collapse">
<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> <button class="rd-navbar-collapse-toggle rd-navbar-fixed-element-1" data-rd-navbar-toggle="#rd-navbar-collapse-content-1"><span></span></button>
<div class="rd-navbar-collapse-content" id="rd-navbar-collapse-content-1"> <div class="rd-navbar-collapse-content" id="rd-navbar-collapse-content-1">
{% if CONTACT_PHONE %} {% if CONTACT_PHONE %}
<article class="unit align-items-center"> <article class="unit align-items-center">
@@ -114,16 +111,16 @@
<div class="row row-50"> <div class="row row-50">
<div class="col-lg-6"> <div class="col-lg-6">
<div class="footer-brand-row"> <div class="footer-brand-row">
<img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty" width="151" height="44"> <img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty">
<div> <div>
<p class="footer-heading font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</p> <h5 class="font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</h5>
<span style="color:#7dd3da;font-size:12px">{{ SITE_TAGLINE }}</span> <span style="color:#7dd3da;font-size:12px">{{ SITE_TAGLINE }}</span>
</div> </div>
</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.{% if CONTACT_ADDRESS %} {{ CONTACT_ADDRESS }}.{% endif %}</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>
<div class="col-lg-6"> <div class="col-lg-6">
<p class="footer-heading font-weight-bold text-uppercase text-white">Navigate</p> <h5 class="font-weight-bold text-uppercase text-white">Navigate</h5>
<ul class="list-marked list-marked-sm"> <ul class="list-marked list-marked-sm">
<li><a href="{% url 'public:home' %}">Home</a></li> <li><a href="{% url 'public:home' %}">Home</a></li>
<li><a href="{% url 'public:about' %}">About</a></li> <li><a href="{% url 'public:about' %}">About</a></li>
+4 -10
View File
@@ -7,12 +7,9 @@
{% 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 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 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 %} {% block og_image %}
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.webp' %}"> <meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.jpg' %}">
<meta property="og:image:alt" content="Professional headshot of {{ SITE_NAME }}, Real Estate Agent"> <meta property="og:image:alt" content="Professional headshot of {{ SITE_NAME }}, Real Estate Agent">
{% endblock %} {% 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 %} {% block structured_data %}
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@@ -25,7 +22,7 @@
"@type": "RealEstateAgent", "@type": "RealEstateAgent",
"name": "{{ SITE_NAME|escapejs }}", "name": "{{ SITE_NAME|escapejs }}",
"url": "{{ PUBLIC_SITE_URL }}/", "url": "{{ PUBLIC_SITE_URL }}/",
"image": "{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.webp' %}", "image": "{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.jpg' %}",
"telephone": "{{ CONTACT_PHONE|escapejs }}", "telephone": "{{ CONTACT_PHONE|escapejs }}",
"email": "{{ CONTACT_EMAIL|escapejs }}", "email": "{{ CONTACT_EMAIL|escapejs }}",
{% if CONTACT_ADDRESS %}"address": "{{ CONTACT_ADDRESS|escapejs }}",{% endif %} {% if CONTACT_ADDRESS %}"address": "{{ CONTACT_ADDRESS|escapejs }}",{% endif %}
@@ -39,7 +36,7 @@
</script> </script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});"> <section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
<div class="breadcrumbs-custom-inner"> <div class="breadcrumbs-custom-inner">
<div class="container breadcrumbs-custom-container"> <div class="container breadcrumbs-custom-container">
<div class="breadcrumbs-custom-main"> <div class="breadcrumbs-custom-main">
@@ -65,10 +62,7 @@
<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> <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>
<div class="col-md-10 col-lg-6 col-xl-6"> <div class="col-md-10 col-lg-6 col-xl-6">
<picture> <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"/>
<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> </div>
</div> </div>
+4 -5
View File
@@ -35,7 +35,7 @@
</script> </script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});"> <section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
<div class="breadcrumbs-custom-inner"> <div class="breadcrumbs-custom-inner">
<div class="container breadcrumbs-custom-container"> <div class="container breadcrumbs-custom-container">
<div class="breadcrumbs-custom-main"> <div class="breadcrumbs-custom-main">
@@ -83,7 +83,7 @@
<div class="container section-lg"> <div class="container section-lg">
<div class="row"> <div class="row">
<div class="col-lg-8"> <div class="col-lg-8">
<h2 class="text-uppercase contact-form-title">Send a message</h2> <h3 class="text-uppercase">Send a message</h3>
<form class="rd-form contact-message-form" method="post" action="{% url 'public:contact' %}"> <form class="rd-form contact-message-form" method="post" action="{% url 'public:contact' %}">
{% csrf_token %} {% csrf_token %}
<div class="row row-10"> <div class="row row-10">
@@ -116,7 +116,7 @@
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
<p class="form-label-outside" style="margin:8px 0 4px">Mailing address <span class="form-hint">(optional — for postcards)</span></p> <p class="form-label-outside" style="margin:8px 0 4px">Mailing address <span style="font-weight:400;color:#6b7280">(optional — for postcards)</span></p>
</div> </div>
<div class="col-12" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}"> <div class="col-12" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
<div class="form-wrap address-ac-wrap"> <div class="form-wrap address-ac-wrap">
@@ -173,7 +173,7 @@
<div class="col-12">{{ form.captcha }}{{ form.captcha.errors }}</div> <div class="col-12">{{ form.captcha }}{{ form.captcha.errors }}</div>
{% endif %} {% endif %}
</div> </div>
<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> <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>
<button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button> <button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button>
</form> </form>
</div> </div>
@@ -182,7 +182,6 @@
</section> </section>
{% endblock %} {% endblock %}
{% block extra_head %} {% 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' %}"> <link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
{% endblock %} {% endblock %}
{% block tracking_events %} {% block tracking_events %}
+18 -17
View File
@@ -6,9 +6,6 @@
{% block meta_description %}{{ SITE_NAME }}, EXIT Realty — buying and selling homes with clear advice and multi-channel marketing. {{ CONTACT_SERVICE_AREA }}.{% endblock %} {% 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 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 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 %} {% block structured_data %}
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@@ -16,7 +13,7 @@
"@type": "RealEstateAgent", "@type": "RealEstateAgent",
"name": "{{ SITE_NAME|escapejs }}", "name": "{{ SITE_NAME|escapejs }}",
"url": "{{ PUBLIC_SITE_URL }}/", "url": "{{ PUBLIC_SITE_URL }}/",
"image": "{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.webp' %}", "image": "{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}",
"telephone": "{{ CONTACT_PHONE|escapejs }}", "telephone": "{{ CONTACT_PHONE|escapejs }}",
"email": "{{ CONTACT_EMAIL|escapejs }}", "email": "{{ CONTACT_EMAIL|escapejs }}",
{% if CONTACT_ADDRESS %}"address": "{{ CONTACT_ADDRESS|escapejs }}",{% endif %} {% if CONTACT_ADDRESS %}"address": "{{ CONTACT_ADDRESS|escapejs }}",{% endif %}
@@ -30,28 +27,32 @@
</script> </script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<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-container swiper-slider swiper-slider-minimal" data-loop="true" data-slide-effect="fade" data-autoplay="false" data-simulate-touch="true">
<div class="swiper-wrapper"> <div class="swiper-wrapper">
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-1-1920x968.webp' %}"> <div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
<div class="container"> <div class="container">
<div class="jumbotron-classic-content"> <div class="jumbotron-classic-content">
<div class="title-docor-text font-weight-bold title-decorated text-uppercase text-white">{{ SITE_NAME }} · MKDRealtor.com</div> <div class="wow-outer">
<h1 class="text-uppercase text-white font-weight-bold">Your Home,<br>Guided Well</h1> <div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">{{ SITE_NAME }} · MKDRealtor.com</div>
<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>
<div class="button-outer"> <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>
<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> <p class="text-white wow-outer"><span class="wow slideInDown" data-wow-delay=".35s">EXIT Realty Redefined — buying or selling in Chicago's western suburbs 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> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-2-1920x968.webp' %}"> <div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-2-1920x968.jpg' %}">
<div class="container"> <div class="container">
<div class="jumbotron-classic-content"> <div class="jumbotron-classic-content">
<div class="title-docor-text font-weight-bold title-decorated text-uppercase text-white">Local Market Insight</div> <div class="wow-outer">
<h2 class="text-uppercase text-white font-weight-bold">Sell With<br>Confidence</h2> <div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">Local Market Insight</div>
<p class="text-white">Pricing strategy, staging guidance, and multi-channel outreach so the right buyers see your listing.</p> </div>
<div class="button-outer"> <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>
<a class="button button-md button-primary button-winona" href="{% url 'public:about' %}" data-tianji-event="hero_about">Meet Monica</a> <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> </div>
</div> </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 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 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 %} {% block content %}
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});"> <section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
<div class="breadcrumbs-custom-inner"> <div class="breadcrumbs-custom-inner">
<div class="container breadcrumbs-custom-container"> <div class="container breadcrumbs-custom-container">
<div class="breadcrumbs-custom-main"> <div class="breadcrumbs-custom-main">
-39
View File
@@ -3,29 +3,6 @@ from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel 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): class Platform(models.TextChoices):
FACEBOOK = "facebook", "Facebook" FACEBOOK = "facebook", "Facebook"
@@ -77,22 +54,6 @@ class SocialAccount(UUIDPrimaryKeyModel, TimeStampedModel):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.platform}: {self.label}" 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 SocialPost(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices): class Status(models.TextChoices):
+2 -12
View File
@@ -137,18 +137,8 @@
{% for account in accounts %} {% for account in accounts %}
<tr {% if not account.is_active %}class="row-warn"{% endif %}> <tr {% if not account.is_active %}class="row-warn"{% endif %}>
<td> <td>
<form method="post" class="account-rename"> <strong>{{ account.label }}</strong><br>
{% csrf_token %} <span class="muted">{{ account.external_id }}</span>
<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>
<td> <td>
<span class="platform-pill {% if account.platform == 'facebook' %}meta{% elif account.platform == 'instagram' %}ig{% else %}li{% endif %}"> <span class="platform-pill {% if account.platform == 'facebook' %}meta{% elif account.platform == 'instagram' %}ig{% else %}li{% endif %}">
+18 -23
View File
@@ -116,35 +116,34 @@
<div class="field"> <div class="field">
<label>Accounts</label> <label>Accounts</label>
{% for account in accounts %} {% for account in accounts %}
<label class="check-row account-pick"> <label class="check-row">
<input type="checkbox" name="account_ids" value="{{ account.pk }}" <input type="checkbox" name="account_ids" value="{{ account.pk }}"
class="account-check" class="account-check"
data-platform="{{ account.platform }}" data-platform="{{ account.platform }}"
{% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}> {% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}>
<span class="account-pick-copy"> {{ account.get_platform_display }} · {{ account.label }}
<strong>{{ account.display_name }}</strong>
<span class="muted">{{ account.get_platform_display }}{% if account.external_id %} · {{ account.external_id }}{% endif %}</span>
</span>
</label> </label>
{% empty %} {% empty %}
<p class="muted">No active accounts.</p> <p class="muted">No active accounts.</p>
{% endfor %} {% 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>
<div class="form-grid cols-2"> <div class="form-grid cols-2">
<div class="field"> <div class="field">
<label for="id_scheduled_for">Schedule for <span class="muted">(optional)</span></label> <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>
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60" <input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
value="{{ form.scheduled_for }}"> value="{{ form.scheduled_for }}">
<div class="hint">Needed only if you click Schedule. Leave blank to Send now.</div>
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap"> <div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary" type="submit" name="action" value="send_now">Send now</button> <button class="btn btn-primary" type="submit" name="action" value="publish">Save / publish</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> </div>
</form> </form>
</div> </div>
@@ -200,8 +199,8 @@
var previewBody = document.getElementById('preview-body'); var previewBody = document.getElementById('preview-body');
var content = document.getElementById('preview-content'); var content = document.getElementById('preview-content');
var previewMedia = document.getElementById('preview-media'); var previewMedia = document.getElementById('preview-media');
var mode = document.getElementById('id_publish_mode');
var when = document.getElementById('id_scheduled_for'); var when = document.getElementById('id_scheduled_for');
var composeForm = document.getElementById('social-compose');
var mediaInput = document.getElementById('id_media'); var mediaInput = document.getElementById('id_media');
var mediaJson = document.getElementById('id_media_json'); var mediaJson = document.getElementById('id_media_json');
var thumbs = document.getElementById('media-thumbs'); var thumbs = document.getElementById('media-thumbs');
@@ -297,14 +296,9 @@
previewBody.hidden = !hasContent; previewBody.hidden = !hasContent;
} }
function requireScheduleTime(event) { function syncWhen() {
var submitter = event.submitter; if (!mode || !when) return;
var action = submitter && submitter.name === 'action' ? submitter.value : ''; when.disabled = mode.value !== 'schedule';
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) { function validateBeforeUpload(file) {
@@ -369,7 +363,7 @@
bodyEl.addEventListener('keyup', syncPreview); bodyEl.addEventListener('keyup', syncPreview);
bodyEl.addEventListener('change', syncPreview); bodyEl.addEventListener('change', syncPreview);
} }
if (composeForm) composeForm.addEventListener('submit', requireScheduleTime); if (mode) mode.addEventListener('change', syncWhen);
var generateForm = document.querySelector('form input[name=action][value=generate]')?.form; var generateForm = document.querySelector('form input[name=action][value=generate]')?.form;
if (generateForm) { if (generateForm) {
@@ -384,6 +378,7 @@
syncMediaField(); syncMediaField();
renderThumbs(); renderThumbs();
syncPreview(); syncPreview();
syncWhen();
})(); })();
</script> </script>
{% endblock %} {% endblock %}
@@ -38,12 +38,7 @@
<tbody> <tbody>
{% for target in post.targets.all %} {% for target in post.targets.all %}
<tr> <tr>
<td> <td>{{ target.account.label }}</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>{{ target.get_platform_display }}</td>
<td><span class="badge badge-{{ target.status }}">{{ target.get_status_display }}</span></td> <td><span class="badge badge-{{ target.status }}">{{ target.get_status_display }}</span></td>
</tr> </tr>
-182
View File
@@ -1,182 +0,0 @@
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")
+9 -35
View File
@@ -162,17 +162,6 @@ def account_list(request):
if request.method == "POST": if request.method == "POST":
action = (request.POST.get("action") or "connect").strip() 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": if action == "disconnect":
pk = request.POST.get("account_id") pk = request.POST.get("account_id")
account = get_object_or_404(SocialAccount, pk=pk) account = get_object_or_404(SocialAccount, pk=pk)
@@ -372,14 +361,11 @@ def linkedin_oauth_callback(request):
profile = linkedin_fetch_member_profile(token_payload["access_token"]) profile = linkedin_fetch_member_profile(token_payload["access_token"])
author_urn = profile["author_urn"] author_urn = profile["author_urn"]
blob = linkedin_token_blob_from_oauth(token_payload, author_urn=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( account, created = SocialAccount.objects.update_or_create(
platform=Platform.LINKEDIN, platform=Platform.LINKEDIN,
external_id=author_urn, external_id=author_urn,
defaults={ defaults={
"label": SocialAccount.resolve_label(existing, profile["label"]), "label": profile["label"],
"encrypted_tokens": encrypt_tokens(json.dumps(blob)), "encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True, "is_active": True,
"owner": request.user, "owner": request.user,
@@ -487,14 +473,11 @@ def meta_oauth_complete(request):
) )
for page in ig_pages: for page in ig_pages:
blob = instagram_token_blob(page, user_token=user_token) 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( account, created = SocialAccount.objects.update_or_create(
platform=Platform.INSTAGRAM, platform=Platform.INSTAGRAM,
external_id=blob["ig_user_id"], external_id=blob["ig_user_id"],
defaults={ defaults={
"label": SocialAccount.resolve_label(existing, blob["label"]), "label": blob["label"],
"encrypted_tokens": encrypt_tokens(json.dumps(blob)), "encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True, "is_active": True,
"owner": request.user, "owner": request.user,
@@ -504,15 +487,12 @@ def meta_oauth_complete(request):
else: else:
for page in pages: for page in pages:
blob = facebook_token_blob(page, user_token=user_token) blob = facebook_token_blob(page, user_token=user_token)
incoming_label = (page.get("name") or "").strip() or f"Page {page['id']}" 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( account, created = SocialAccount.objects.update_or_create(
platform=Platform.FACEBOOK, platform=Platform.FACEBOOK,
external_id=str(page["id"]), external_id=str(page["id"]),
defaults={ defaults={
"label": SocialAccount.resolve_label(existing, incoming_label), "label": label,
"encrypted_tokens": encrypt_tokens(json.dumps(blob)), "encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True, "is_active": True,
"owner": request.user, "owner": request.user,
@@ -545,7 +525,7 @@ def composer(request):
form = { form = {
"body": "", "body": "",
"prompt": "", "prompt": "",
"publish_mode": "now", "publish_mode": "schedule",
"scheduled_for": "", "scheduled_for": "",
"account_ids": [], "account_ids": [],
"media_json": "[]", "media_json": "[]",
@@ -556,17 +536,11 @@ def composer(request):
if request.method == "POST": if request.method == "POST":
form["body"] = (request.POST.get("body") or "").strip() form["body"] = (request.POST.get("body") or "").strip()
form["prompt"] = (request.POST.get("prompt") or "").strip() form["prompt"] = (request.POST.get("prompt") or "").strip()
form["publish_mode"] = (request.POST.get("publish_mode") or "now").strip() form["publish_mode"] = (request.POST.get("publish_mode") or "schedule").strip()
form["scheduled_for"] = request.POST.get("scheduled_for") or "" form["scheduled_for"] = request.POST.get("scheduled_for") or ""
form["account_ids"] = request.POST.getlist("account_ids") form["account_ids"] = request.POST.getlist("account_ids")
form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]" form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]"
action = (request.POST.get("action") or "save").strip() 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"]: if action == "generate" and form["prompt"]:
try: try:
@@ -574,7 +548,7 @@ def composer(request):
form["body"] = draft form["body"] = draft
except OllamaError as exc: except OllamaError as exc:
error = str(exc) error = str(exc)
elif action in {"save", "publish", "send_now", "schedule"}: elif action in {"save", "publish"}:
media_items: list = [] media_items: list = []
try: try:
media_items = parse_media_json(form["media_json"]) media_items = parse_media_json(form["media_json"])
@@ -597,7 +571,7 @@ def composer(request):
error = str(exc) error = str(exc)
else: else:
if not scheduled_for: if not scheduled_for:
error = "Pick a schedule date/time, or choose Send now." error = "Pick a schedule date/time, or choose Publish now."
else: else:
status = SocialPost.Status.SCHEDULED status = SocialPost.Status.SCHEDULED
elif form["publish_mode"] == "now": elif form["publish_mode"] == "now":
@@ -640,7 +614,7 @@ def composer(request):
publish_social_post.enqueue(post_id=str(post.pk)) publish_social_post.enqueue(post_id=str(post.pk))
messages.success( messages.success(
request, request,
"Post sent — queued for publishing to selected accounts.", "Post queued for publishing to selected accounts.",
) )
elif status == SocialPost.Status.SCHEDULED: elif status == SocialPost.Status.SCHEDULED:
messages.success( messages.success(