Compare commits
6
Commits
a817b9caf7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a867a57750 | ||
|
|
bc7c47a69c | ||
|
|
2b3f10e784 | ||
|
|
dc0f3b2908 | ||
|
|
e8611ef926 | ||
|
|
71de919c34 |
@@ -79,6 +79,14 @@ 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.
|
||||||
@@ -92,4 +100,7 @@ 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
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ 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
|
||||||
@@ -123,3 +130,5 @@ 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
|
||||||
|
|||||||
@@ -25,13 +25,14 @@ 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 (hot reload)
|
# optional: docker compose up # web + postgres + worker (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). Edit Python/templates → auto-reload; no image rebuild.
|
`DJANGO_ENV=dev` (the default). The **worker** service polls `dispatch_due` every 15s so
|
||||||
Rebuild only when Dockerfile / deps (`pyproject.toml`, `uv.lock`) change:
|
scheduled campaigns and social posts fire without a manual command. Edit Python/templates
|
||||||
|
→ auto-reload; no image rebuild.
|
||||||
|
Rebuild only when Dockerfile, entrypoint scripts, or deps (`pyproject.toml`, `uv.lock`) change:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
@@ -73,6 +74,17 @@ Templates: `.env.prod.example` (full var list). Validate with:
|
|||||||
./scripts/validate-env.sh ~/Documents/secrets/monica_site/monica_site_prod.env
|
./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>`.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# 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: .
|
||||||
|
|||||||
+6
-1
@@ -61,13 +61,14 @@ 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
|
||||||
@@ -77,6 +78,8 @@ 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:-}
|
||||||
@@ -101,6 +104,8 @@ 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
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -47,5 +47,24 @@ 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.
|
||||||
exec uv run python manage.py dj_queue
|
uv run python manage.py dj_queue
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -10,10 +12,37 @@ 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. "
|
||||||
"Optional when the task backend supports run_after defer; useful as a safety net."
|
"Pass --loop to poll until stopped (worker entrypoint)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--loop",
|
||||||
|
action="store_true",
|
||||||
|
help="Poll forever until SIGINT/SIGTERM.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=int,
|
||||||
|
default=15,
|
||||||
|
help="Seconds between polls when --loop is set (default 15).",
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
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
|
||||||
|
|
||||||
@@ -35,4 +64,7 @@ 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.")
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from io import StringIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
from django.test import Client, TestCase, override_settings
|
from django.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):
|
||||||
@@ -27,3 +35,43 @@ 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(), "")
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ webhook events match the correct recipient row. Invalid / missing header values
|
|||||||
no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -13,6 +16,7 @@ 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
|
||||||
@@ -33,6 +37,21 @@ _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,
|
||||||
@@ -66,6 +85,149 @@ 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
|
||||||
|
|
||||||
@@ -287,6 +449,17 @@ 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(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Server-to-server client for url_shortening_service (piha.lc).
|
||||||
|
|
||||||
|
Call SHORTENER_BASE_URL (https://piha.lc / https://beta.piha.li).
|
||||||
|
Auth: Authorization: Bearer <SHORTENER_API_TOKEN> (name:secret).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
base = (getattr(settings, "SHORTENER_BASE_URL", None) or "").strip()
|
||||||
|
token = (getattr(settings, "SHORTENER_API_TOKEN", None) or "").strip()
|
||||||
|
return bool(base and token)
|
||||||
|
|
||||||
|
|
||||||
|
def mint_short_url(
|
||||||
|
target_url: str,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
external_ref: str = "",
|
||||||
|
) -> str | None:
|
||||||
|
"""POST /api/links/. Return short_url, or None if unconfigured / failed.
|
||||||
|
|
||||||
|
Does not raise. Compose must still work when the shortener is down or unset.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
return None
|
||||||
|
target = (target_url or "").strip()
|
||||||
|
if not target.lower().startswith("https://"):
|
||||||
|
logger.info("shortener skip: target is not https")
|
||||||
|
return None
|
||||||
|
|
||||||
|
base = str(settings.SHORTENER_BASE_URL).rstrip("/")
|
||||||
|
token = str(settings.SHORTENER_API_TOKEN).strip()
|
||||||
|
timeout = int(getattr(settings, "SHORTENER_TIMEOUT_SECONDS", 10) or 10)
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{base}/api/links/",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"target_url": target,
|
||||||
|
"title": (title or "")[:200],
|
||||||
|
"external_ref": (external_ref or "")[:64],
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.exception("shortener request failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
logger.warning(
|
||||||
|
"shortener mint failed status=%s",
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("shortener mint returned non-json")
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
short = (data.get("short_url") or "").strip()
|
||||||
|
return short or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_display_url(
|
||||||
|
target_url: str,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
external_ref: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Short URL when mint succeeds, otherwise the original target."""
|
||||||
|
return mint_short_url(
|
||||||
|
target_url, title=title, external_ref=external_ref
|
||||||
|
) or target_url
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="utm-panel" id="utm-link-panel"
|
||||||
|
data-base-url="{{ utm_base_url }}"
|
||||||
|
data-link-label="{{ utm_link_label }}"
|
||||||
|
data-medium="{{ utm_medium|default:'' }}"
|
||||||
|
data-campaign-name="{{ utm_campaign_name|default:'' }}"
|
||||||
|
data-shorten-url="{{ utm_shorten_url|default:'' }}"
|
||||||
|
data-campaign-id="{% if campaign %}{{ campaign.pk }}{% endif %}"
|
||||||
|
data-csrf="{{ csrf_token }}"
|
||||||
|
data-live="{% if utm_live %}1{% else %}0{% endif %}">
|
||||||
|
<div class="field" style="margin:0">
|
||||||
|
<label>Tracked site link</label>
|
||||||
|
<p class="hint" data-utm-email-hint hidden>
|
||||||
|
Auto-added to the email as a styled link. Updates as you type the campaign name.
|
||||||
|
<code>utm_medium=email</code> · <code>utm_campaign</code> matches the name.
|
||||||
|
Recipients tap a short <code>piha.li</code> link that redirects to this URL.
|
||||||
|
QR below encodes the same short link.
|
||||||
|
</p>
|
||||||
|
<p class="hint" data-utm-sms-hint hidden>
|
||||||
|
SMS is plain text — phones cannot show HTML links. A short <code>piha.li</code>
|
||||||
|
URL is in the message and phones make it tappable. Preview below shows it as a link.
|
||||||
|
</p>
|
||||||
|
<p class="hint" data-utm-postcard-hint hidden>
|
||||||
|
Copy the QR image into the postcard designer. Recipients scan it to open the
|
||||||
|
short tracked link (<code>piha.li</code>).
|
||||||
|
</p>
|
||||||
|
<code class="utm-url" data-utm-url>{{ utm_url|default:"" }}</code>
|
||||||
|
<div class="utm-actions">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-url>Copy link</button>
|
||||||
|
</div>
|
||||||
|
<div class="utm-qr" data-utm-qr-wrap hidden>
|
||||||
|
<canvas data-utm-qr width="192" height="192" aria-label="Campaign QR code"></canvas>
|
||||||
|
<div class="utm-qr-actions">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-qr>Copy QR image</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" data-utm-download-qr>Download PNG</button>
|
||||||
|
<p class="hint" style="margin-top:8px" data-utm-qr-email-hint hidden>
|
||||||
|
Copy or download the PNG for print, signatures, or other materials.
|
||||||
|
</p>
|
||||||
|
<p class="hint" style="margin-top:8px" data-utm-qr-postcard-hint hidden>
|
||||||
|
Paste or upload the PNG in
|
||||||
|
<a href="{% url 'messaging:postcard_designer' %}">Postcard design</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" data-utm-status hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "portal_base.html" %}
|
{% 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 %}
|
||||||
@@ -51,6 +52,13 @@
|
|||||||
</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>
|
||||||
@@ -208,7 +216,10 @@
|
|||||||
</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,4 +1,5 @@
|
|||||||
{% 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 %}
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
<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>
|
||||||
@@ -84,7 +86,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></div>
|
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code>. Tracked site URL is added automatically (SMS cannot use HTML links).</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
@@ -128,6 +130,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
||||||
|
{% 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>
|
||||||
@@ -179,6 +182,8 @@
|
|||||||
{% 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 }}";
|
||||||
@@ -186,11 +191,37 @@
|
|||||||
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;
|
||||||
@@ -214,6 +245,7 @@
|
|||||||
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) {
|
||||||
@@ -244,6 +276,9 @@
|
|||||||
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;
|
||||||
@@ -283,6 +318,8 @@
|
|||||||
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();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,7 +378,10 @@
|
|||||||
} else if (initial) {
|
} else if (initial) {
|
||||||
quill.setText(initial);
|
quill.setText(initial);
|
||||||
}
|
}
|
||||||
quill.on('text-change', syncBodyFromQuill);
|
quill.on('text-change', function () {
|
||||||
|
if (utmLock) return;
|
||||||
|
syncBodyFromQuill();
|
||||||
|
});
|
||||||
document.getElementById('campaign-compose').addEventListener('submit', function () {
|
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();
|
||||||
@@ -370,6 +410,14 @@
|
|||||||
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>
|
||||||
|
|||||||
+258
-2
@@ -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
|
from django.test import Client, TestCase, override_settings
|
||||||
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,6 +1359,7 @@ 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(
|
||||||
@@ -1416,7 +1417,9 @@ 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.assertEqual(payload["content"], "Hello Rufus")
|
self.assertTrue(payload["content"].startswith("Hello Rufus"))
|
||||||
|
self.assertIn("utm_source=monica", payload["content"])
|
||||||
|
self.assertIn("utm_medium=sms", payload["content"])
|
||||||
self.assertNotIn("to", payload)
|
self.assertNotIn("to", payload)
|
||||||
self.assertNotIn("text", payload)
|
self.assertNotIn("text", payload)
|
||||||
|
|
||||||
@@ -1450,3 +1453,256 @@ 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"])
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ 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",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -26,12 +27,17 @@ 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 (
|
||||||
@@ -462,6 +468,16 @@ 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 (
|
||||||
@@ -506,10 +522,62 @@ 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)
|
||||||
@@ -518,6 +586,12 @@ 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",
|
||||||
@@ -529,6 +603,10 @@ 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,
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ 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"
|
||||||
|
|
||||||
@@ -330,6 +332,13 @@ 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(
|
||||||
|
|||||||
+1
-6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -362,6 +362,29 @@ 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;
|
||||||
@@ -667,3 +690,42 @@ 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,9 +20,55 @@
|
|||||||
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: var(--monica-muted);
|
color: #4b5563; /* was muted grey; needs ≥4.5:1 on white/surface */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contrast fixes (Lighthouse color-contrast) */
|
||||||
|
.page .bg-gray-100 .text-width-1,
|
||||||
|
.page section.bg-gray-100 p {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.page .footer-advanced-text,
|
||||||
|
.page .footer-advanced .footer-advanced-text {
|
||||||
|
color: #c5c6ca !important;
|
||||||
|
}
|
||||||
|
.page .footer-advanced .rights,
|
||||||
|
.page .footer-advanced .rights span {
|
||||||
|
color: #c5c6ca !important;
|
||||||
|
}
|
||||||
|
.page .form-label-outside {
|
||||||
|
color: #374151 !important;
|
||||||
|
}
|
||||||
|
.page .form-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.page .form-consent-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
margin: 12px 0 20px;
|
||||||
|
}
|
||||||
|
.page .contact-form-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip layout work for below-fold marketing sections */
|
||||||
|
.page > .section.section-lg,
|
||||||
|
.page > .section.section-sm,
|
||||||
|
.page > .section.bg-gray-100 {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page .button-primary,
|
.page .button-primary,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* Campaign UTM link helper (composer + report).
|
||||||
|
* utm_source=monica, utm_medium=email|sms|postcard, utm_campaign=slug(name).
|
||||||
|
* Short URLs come from the portal JSON endpoint (server mints via piha.li).
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var SOURCE = "monica";
|
||||||
|
var SLUG_MAX = 80;
|
||||||
|
var SHORTEN_WAIT_MS = 400;
|
||||||
|
var UTM_URL_RE = new RegExp(
|
||||||
|
"https?:\\/\\/[^\\s<>\"]+utm_source=" + SOURCE + "[^\\s<>\"]*",
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
var SHORT_URL_RE =
|
||||||
|
/https?:\/\/(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)\/[a-z0-9]{4,8}/i;
|
||||||
|
|
||||||
|
function slug(name) {
|
||||||
|
var s = String(name || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
return (s || "campaign").slice(0, SLUG_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(base, medium, name) {
|
||||||
|
var root = String(base || "").replace(/\/+$/, "") || "https://mkdrealtor.com";
|
||||||
|
var ch = String(medium || "email").toLowerCase();
|
||||||
|
if (ch !== "email" && ch !== "sms" && ch !== "postcard") ch = "email";
|
||||||
|
var q =
|
||||||
|
"utm_source=" +
|
||||||
|
encodeURIComponent(SOURCE) +
|
||||||
|
"&utm_medium=" +
|
||||||
|
encodeURIComponent(ch) +
|
||||||
|
"&utm_campaign=" +
|
||||||
|
encodeURIComponent(slug(name));
|
||||||
|
return root + "/?" + q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTextUrl(text, url) {
|
||||||
|
var raw = String(text || "");
|
||||||
|
if (UTM_URL_RE.test(raw)) {
|
||||||
|
return raw.replace(UTM_URL_RE, url);
|
||||||
|
}
|
||||||
|
if (SHORT_URL_RE.test(raw)) {
|
||||||
|
return raw.replace(SHORT_URL_RE, url);
|
||||||
|
}
|
||||||
|
var trimmed = raw.replace(/\s+$/, "");
|
||||||
|
return trimmed ? trimmed + "\n\n" + url : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkify(text) {
|
||||||
|
var esc = String(text || "").replace(/[&<>"']/g, function (c) {
|
||||||
|
return (
|
||||||
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||||
|
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);
|
||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -1151,10 +1151,15 @@
|
|||||||
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,6 +1199,14 @@
|
|||||||
|
|
||||||
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 () {
|
||||||
|
|||||||
@@ -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.jpg' %}">
|
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.webp' %}">
|
||||||
<meta property="og:image:alt" content="{{ SITE_NAME }} · EXIT Realty">
|
<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,11 +23,14 @@
|
|||||||
<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="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.googleapis.com">
|
||||||
<link rel="stylesheet" href="{% static 'css/bootstrap.css' %}">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="{% static 'css/fonts.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/style.css' %}" id="main-styles-link">
|
<link rel="stylesheet" href="{% static 'css/public-vendor.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>
|
||||||
@@ -49,13 +52,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" data-rd-navbar-toggle="#rd-navbar-nav-wrap-1"><span></span></button>
|
<button class="rd-navbar-toggle" type="button" aria-label="Open menu" data-rd-navbar-toggle="#rd-navbar-nav-wrap-1"><span></span></button>
|
||||||
<a class="rd-navbar-brand" href="{% url 'public:home' %}" data-tianji-event="nav_home">
|
<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" data-rd-navbar-toggle="#rd-navbar-collapse-content-1"><span></span></button>
|
<button class="rd-navbar-collapse-toggle rd-navbar-fixed-element-1" type="button" aria-label="Open contact details" data-rd-navbar-toggle="#rd-navbar-collapse-content-1"><span></span></button>
|
||||||
<div class="rd-navbar-collapse-content" id="rd-navbar-collapse-content-1">
|
<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">
|
||||||
@@ -111,16 +114,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">
|
<img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty" width="151" height="44">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</h5>
|
<p class="footer-heading font-weight-bold text-uppercase text-white" style="margin:0">{{ SITE_NAME }}</p>
|
||||||
<span style="color:#7dd3da;font-size:12px">{{ SITE_TAGLINE }}</span>
|
<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">
|
||||||
<h5 class="font-weight-bold text-uppercase text-white">Navigate</h5>
|
<p class="footer-heading font-weight-bold text-uppercase text-white">Navigate</p>
|
||||||
<ul class="list-marked list-marked-sm">
|
<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>
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
{% 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.jpg' %}">
|
<meta property="og:image" content="{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.webp' %}">
|
||||||
<meta property="og:image:alt" content="Professional headshot of {{ SITE_NAME }}, Real Estate Agent">
|
<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">
|
||||||
{
|
{
|
||||||
@@ -22,7 +25,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.jpg' %}",
|
"image": "{{ PUBLIC_SITE_URL }}{% static 'images/monica-dhillon-headshot.webp' %}",
|
||||||
"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 %}
|
||||||
@@ -36,7 +39,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.jpg' %});">
|
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});">
|
||||||
<div class="breadcrumbs-custom-inner">
|
<div class="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">
|
||||||
@@ -62,7 +65,10 @@
|
|||||||
<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">
|
||||||
<img class="img-responsive" src="{% static 'images/monica-dhillon-headshot.jpg' %}" alt="Professional headshot of Mandeep "Monica" Dhillon, Real Estate Agent, wearing a blue cardigan against a black background." width="816" height="1024"/>
|
<picture>
|
||||||
|
<source type="image/webp" srcset="{% static 'images/monica-dhillon-headshot-640.webp' %} 640w, {% static 'images/monica-dhillon-headshot.webp' %} 816w" sizes="(max-width: 991px) 90vw, 480px">
|
||||||
|
<img class="img-responsive" src="{% static 'images/monica-dhillon-headshot.jpg' %}" alt="Professional headshot of Mandeep "Monica" 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>
|
||||||
|
|||||||
@@ -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.jpg' %});">
|
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});">
|
||||||
<div class="breadcrumbs-custom-inner">
|
<div class="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">
|
||||||
<h3 class="text-uppercase">Send a message</h3>
|
<h2 class="text-uppercase contact-form-title">Send a message</h2>
|
||||||
<form class="rd-form contact-message-form" method="post" action="{% url 'public:contact' %}">
|
<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 style="font-weight:400;color:#6b7280">(optional — for postcards)</span></p>
|
<p class="form-label-outside" style="margin:8px 0 4px">Mailing address <span class="form-hint">(optional — for postcards)</span></p>
|
||||||
</div>
|
</div>
|
||||||
<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 style="font-size:13px;color:#6b7280;margin:12px 0 20px;">By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
|
<p class="form-consent-note">By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
|
||||||
<button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button>
|
<button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,6 +182,7 @@
|
|||||||
</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 %}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
{% block meta_description %}{{ SITE_NAME }}, EXIT Realty — buying and selling homes with clear advice and multi-channel marketing. {{ CONTACT_SERVICE_AREA }}.{% endblock %}
|
{% block 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">
|
||||||
{
|
{
|
||||||
@@ -13,7 +16,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.jpg' %}",
|
"image": "{{ PUBLIC_SITE_URL }}{% static 'images/slider-minimal-slide-1-1920x968.webp' %}",
|
||||||
"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 %}
|
||||||
@@ -27,32 +30,28 @@
|
|||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="swiper-container swiper-slider swiper-slider-minimal" data-loop="true" data-slide-effect="fade" data-autoplay="false" data-simulate-touch="true">
|
<div class="swiper-container swiper-slider swiper-slider-minimal" data-loop="false" data-slide-effect="fade" data-autoplay="false" data-simulate-touch="true">
|
||||||
<div class="swiper-wrapper">
|
<div class="swiper-wrapper">
|
||||||
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
|
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-1-1920x968.webp' %}">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="jumbotron-classic-content">
|
<div class="jumbotron-classic-content">
|
||||||
<div class="wow-outer">
|
<div class="title-docor-text font-weight-bold title-decorated text-uppercase text-white">{{ SITE_NAME }} · MKDRealtor.com</div>
|
||||||
<div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">{{ SITE_NAME }} · MKDRealtor.com</div>
|
<h1 class="text-uppercase text-white font-weight-bold">Your Home,<br>Guided Well</h1>
|
||||||
</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>
|
||||||
<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>
|
<div class="button-outer">
|
||||||
<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>
|
<a class="button button-md button-primary button-winona" href="{% url 'public:contact' %}" data-tianji-event="hero_cta" data-tianji-event-destination="contact">Start a conversation</a>
|
||||||
<div 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.jpg' %}">
|
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-2-1920x968.webp' %}">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="jumbotron-classic-content">
|
<div class="jumbotron-classic-content">
|
||||||
<div class="wow-outer">
|
<div class="title-docor-text font-weight-bold title-decorated text-uppercase text-white">Local Market Insight</div>
|
||||||
<div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">Local Market Insight</div>
|
<h2 class="text-uppercase text-white font-weight-bold">Sell With<br>Confidence</h2>
|
||||||
</div>
|
<p class="text-white">Pricing strategy, staging guidance, and multi-channel outreach so the right buyers see your listing.</p>
|
||||||
<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>
|
<div class="button-outer">
|
||||||
<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>
|
<a class="button button-md button-primary button-winona" href="{% url 'public:about' %}" data-tianji-event="hero_about">Meet Monica</a>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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.jpg' %});">
|
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.webp' %});">
|
||||||
<div class="breadcrumbs-custom-inner">
|
<div class="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">
|
||||||
|
|||||||
@@ -3,6 +3,29 @@ 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"
|
||||||
@@ -54,6 +77,22 @@ 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):
|
||||||
|
|||||||
@@ -137,8 +137,18 @@
|
|||||||
{% 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>
|
||||||
<strong>{{ account.label }}</strong><br>
|
<form method="post" class="account-rename">
|
||||||
<span class="muted">{{ account.external_id }}</span>
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="rename">
|
||||||
|
<input type="hidden" name="account_id" value="{{ account.pk }}">
|
||||||
|
<input id="id_label_{{ account.pk }}" name="label" type="text" maxlength="120"
|
||||||
|
value="{{ account.label }}" required
|
||||||
|
aria-label="Display name for {{ account.get_platform_display }}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Rename</button>
|
||||||
|
</form>
|
||||||
|
{% if account.external_id %}
|
||||||
|
<span class="muted" style="display:block;margin-top:6px;overflow-wrap:anywhere">{{ account.external_id }}</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<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 %}">
|
||||||
|
|||||||
@@ -116,34 +116,35 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Accounts</label>
|
<label>Accounts</label>
|
||||||
{% for account in accounts %}
|
{% for account in accounts %}
|
||||||
<label class="check-row">
|
<label class="check-row account-pick">
|
||||||
<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 %}>
|
||||||
{{ account.get_platform_display }} · {{ account.label }}
|
<span class="account-pick-copy">
|
||||||
|
<strong>{{ account.display_name }}</strong>
|
||||||
|
<span class="muted">{{ account.get_platform_display }}{% if account.external_id %} · {{ account.external_id }}{% endif %}</span>
|
||||||
|
</span>
|
||||||
</label>
|
</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_publish_mode">Publish</label>
|
<label for="id_scheduled_for">Schedule for <span class="muted">(optional)</span></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="publish">Save / publish</button>
|
<button class="btn btn-primary" type="submit" name="action" value="send_now">Send now</button>
|
||||||
|
<button class="btn btn-ghost" type="submit" name="action" value="schedule">Schedule</button>
|
||||||
|
<button class="btn btn-ghost" type="submit" name="action" value="save">Save draft</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,8 +200,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');
|
||||||
@@ -296,9 +297,14 @@
|
|||||||
previewBody.hidden = !hasContent;
|
previewBody.hidden = !hasContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncWhen() {
|
function requireScheduleTime(event) {
|
||||||
if (!mode || !when) return;
|
var submitter = event.submitter;
|
||||||
when.disabled = mode.value !== 'schedule';
|
var action = submitter && submitter.name === 'action' ? submitter.value : '';
|
||||||
|
if (action !== 'schedule') return;
|
||||||
|
if (when && when.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (when) when.focus();
|
||||||
|
window.alert('Pick a date and time to schedule, or choose Send now.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateBeforeUpload(file) {
|
function validateBeforeUpload(file) {
|
||||||
@@ -363,7 +369,7 @@
|
|||||||
bodyEl.addEventListener('keyup', syncPreview);
|
bodyEl.addEventListener('keyup', syncPreview);
|
||||||
bodyEl.addEventListener('change', syncPreview);
|
bodyEl.addEventListener('change', syncPreview);
|
||||||
}
|
}
|
||||||
if (mode) mode.addEventListener('change', syncWhen);
|
if (composeForm) composeForm.addEventListener('submit', requireScheduleTime);
|
||||||
|
|
||||||
var generateForm = document.querySelector('form input[name=action][value=generate]')?.form;
|
var generateForm = document.querySelector('form input[name=action][value=generate]')?.form;
|
||||||
if (generateForm) {
|
if (generateForm) {
|
||||||
@@ -378,7 +384,6 @@
|
|||||||
syncMediaField();
|
syncMediaField();
|
||||||
renderThumbs();
|
renderThumbs();
|
||||||
syncPreview();
|
syncPreview();
|
||||||
syncWhen();
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -38,7 +38,12 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for target in post.targets.all %}
|
{% for target in post.targets.all %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ target.account.label }}</td>
|
<td>
|
||||||
|
{{ target.account.display_name }}
|
||||||
|
{% if target.account.external_id %}
|
||||||
|
<br><span class="muted" style="overflow-wrap:anywhere">{{ target.account.external_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ target.get_platform_display }}</td>
|
<td>{{ 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>
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from social.models import (
|
||||||
|
Platform,
|
||||||
|
SocialAccount,
|
||||||
|
SocialPost,
|
||||||
|
is_generic_account_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountLabelTests(TestCase):
|
||||||
|
def test_generic_placeholders(self):
|
||||||
|
self.assertTrue(is_generic_account_label(""))
|
||||||
|
self.assertTrue(is_generic_account_label("LinkedIn member"))
|
||||||
|
self.assertTrue(is_generic_account_label("Instagram account"))
|
||||||
|
self.assertTrue(is_generic_account_label("Page 123456"))
|
||||||
|
self.assertTrue(is_generic_account_label("urn:li:person:abc"))
|
||||||
|
self.assertFalse(is_generic_account_label("Monica Dhillon"))
|
||||||
|
self.assertFalse(is_generic_account_label("@mkdrealtor"))
|
||||||
|
|
||||||
|
def test_resolve_keeps_custom_name(self):
|
||||||
|
existing = SocialAccount(
|
||||||
|
platform=Platform.LINKEDIN,
|
||||||
|
label="Monica (personal)",
|
||||||
|
external_id="urn:li:person:abc",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
SocialAccount.resolve_label(existing, "LinkedIn member"),
|
||||||
|
"Monica (personal)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_replaces_generic(self):
|
||||||
|
existing = SocialAccount(
|
||||||
|
platform=Platform.LINKEDIN,
|
||||||
|
label="LinkedIn member",
|
||||||
|
external_id="urn:li:person:abc",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
SocialAccount.resolve_label(existing, "Monica Dhillon"),
|
||||||
|
"Monica Dhillon",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialComposerTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="composer", password="test-pass-123"
|
||||||
|
)
|
||||||
|
self.client = Client()
|
||||||
|
self.client.login(username="composer", password="test-pass-123")
|
||||||
|
self.account = SocialAccount.objects.create(
|
||||||
|
platform=Platform.LINKEDIN,
|
||||||
|
label="LinkedIn member",
|
||||||
|
external_id="urn:li:person:abc123",
|
||||||
|
is_active=True,
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_composer_shows_send_now_and_full_name(self):
|
||||||
|
response = self.client.get(reverse("social:composer"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Send now")
|
||||||
|
self.assertContains(response, "Schedule")
|
||||||
|
self.assertContains(response, "LinkedIn member")
|
||||||
|
self.assertContains(response, "urn:li:person:abc123")
|
||||||
|
self.assertNotContains(response, "Save / publish")
|
||||||
|
|
||||||
|
def test_send_now_queues_without_schedule(self):
|
||||||
|
mock_task = patch("social.views.publish_social_post").start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("social:composer"),
|
||||||
|
{
|
||||||
|
"body": "Open house Saturday",
|
||||||
|
"account_ids": [str(self.account.pk)],
|
||||||
|
"action": "send_now",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
post = SocialPost.objects.get()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(
|
||||||
|
response.url, reverse("social:post_detail", kwargs={"pk": post.pk})
|
||||||
|
)
|
||||||
|
self.assertEqual(post.status, SocialPost.Status.QUEUED)
|
||||||
|
self.assertIsNotNone(post.scheduled_for)
|
||||||
|
self.assertEqual(post.targets.count(), 1)
|
||||||
|
mock_task.enqueue.assert_called_once_with(post_id=str(post.pk))
|
||||||
|
|
||||||
|
def test_schedule_requires_datetime(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("social:composer"),
|
||||||
|
{
|
||||||
|
"body": "Open house Saturday",
|
||||||
|
"account_ids": [str(self.account.pk)],
|
||||||
|
"action": "schedule",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Pick a schedule date/time, or choose Send now.")
|
||||||
|
self.assertFalse(SocialPost.objects.exists())
|
||||||
|
|
||||||
|
def test_schedule_with_datetime(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("social:composer"),
|
||||||
|
{
|
||||||
|
"body": "Open house Saturday",
|
||||||
|
"account_ids": [str(self.account.pk)],
|
||||||
|
"action": "schedule",
|
||||||
|
"scheduled_for": "2026-08-10T09:30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
post = SocialPost.objects.get()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(post.status, SocialPost.Status.SCHEDULED)
|
||||||
|
self.assertIsNotNone(post.scheduled_for)
|
||||||
|
|
||||||
|
def test_save_draft(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("social:composer"),
|
||||||
|
{
|
||||||
|
"body": "Draft caption",
|
||||||
|
"action": "save",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
post = SocialPost.objects.get()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(post.status, SocialPost.Status.DRAFT)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountRenameTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="renamer", password="test-pass-123"
|
||||||
|
)
|
||||||
|
self.client = Client()
|
||||||
|
self.client.login(username="renamer", password="test-pass-123")
|
||||||
|
self.account = SocialAccount.objects.create(
|
||||||
|
platform=Platform.FACEBOOK,
|
||||||
|
label="Page 999",
|
||||||
|
external_id="999",
|
||||||
|
is_active=True,
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_accounts_page_shows_rename_field(self):
|
||||||
|
response = self.client.get(reverse("social:account_list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'name="label"')
|
||||||
|
self.assertContains(response, "Page 999")
|
||||||
|
self.assertContains(response, "Rename")
|
||||||
|
|
||||||
|
def test_rename_updates_label(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("social:account_list"),
|
||||||
|
{
|
||||||
|
"action": "rename",
|
||||||
|
"account_id": str(self.account.pk),
|
||||||
|
"label": "Monica Dhillon Realty",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.account.refresh_from_db()
|
||||||
|
self.assertEqual(self.account.label, "Monica Dhillon Realty")
|
||||||
|
|
||||||
|
def test_rename_rejects_blank(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("social:account_list"),
|
||||||
|
{
|
||||||
|
"action": "rename",
|
||||||
|
"account_id": str(self.account.pk),
|
||||||
|
"label": " ",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.account.refresh_from_db()
|
||||||
|
self.assertEqual(self.account.label, "Page 999")
|
||||||
+35
-9
@@ -162,6 +162,17 @@ 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)
|
||||||
@@ -361,11 +372,14 @@ 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": profile["label"],
|
"label": SocialAccount.resolve_label(existing, 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,
|
||||||
@@ -473,11 +487,14 @@ 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": blob["label"],
|
"label": SocialAccount.resolve_label(existing, 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,
|
||||||
@@ -487,12 +504,15 @@ 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)
|
||||||
label = (page.get("name") or "").strip() or f"Page {page['id']}"
|
incoming_label = (page.get("name") or "").strip() or f"Page {page['id']}"
|
||||||
|
existing = SocialAccount.objects.filter(
|
||||||
|
platform=Platform.FACEBOOK, external_id=str(page["id"])
|
||||||
|
).first()
|
||||||
account, created = SocialAccount.objects.update_or_create(
|
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": label,
|
"label": SocialAccount.resolve_label(existing, incoming_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,
|
||||||
@@ -525,7 +545,7 @@ def composer(request):
|
|||||||
form = {
|
form = {
|
||||||
"body": "",
|
"body": "",
|
||||||
"prompt": "",
|
"prompt": "",
|
||||||
"publish_mode": "schedule",
|
"publish_mode": "now",
|
||||||
"scheduled_for": "",
|
"scheduled_for": "",
|
||||||
"account_ids": [],
|
"account_ids": [],
|
||||||
"media_json": "[]",
|
"media_json": "[]",
|
||||||
@@ -536,11 +556,17 @@ 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 "schedule").strip()
|
form["publish_mode"] = (request.POST.get("publish_mode") or "now").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:
|
||||||
@@ -548,7 +574,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"}:
|
elif action in {"save", "publish", "send_now", "schedule"}:
|
||||||
media_items: list = []
|
media_items: list = []
|
||||||
try:
|
try:
|
||||||
media_items = parse_media_json(form["media_json"])
|
media_items = parse_media_json(form["media_json"])
|
||||||
@@ -571,7 +597,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 Publish now."
|
error = "Pick a schedule date/time, or choose Send now."
|
||||||
else:
|
else:
|
||||||
status = SocialPost.Status.SCHEDULED
|
status = SocialPost.Status.SCHEDULED
|
||||||
elif form["publish_mode"] == "now":
|
elif form["publish_mode"] == "now":
|
||||||
@@ -614,7 +640,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 queued for publishing to selected accounts.",
|
"Post sent — queued for publishing to selected accounts.",
|
||||||
)
|
)
|
||||||
elif status == SocialPost.Status.SCHEDULED:
|
elif status == SocialPost.Status.SCHEDULED:
|
||||||
messages.success(
|
messages.success(
|
||||||
|
|||||||
Reference in New Issue
Block a user