diff --git a/.env.example b/.env.example index d0a98a6..edfb813 100644 --- a/.env.example +++ b/.env.example @@ -92,4 +92,7 @@ TIANJI_ENABLED=false TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js TIANJI_WEBSITE_ID=cmshzhxdf6gee10qzkzrfn9iw +# Worker poll interval for due scheduled campaigns / social posts (seconds). +# DISPATCH_DUE_INTERVAL_SECONDS=15 + GUNICORN_WORKERS=2 diff --git a/README.md b/README.md index 1fa75bd..67470e0 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,14 @@ uv sync cd site uv run python manage.py migrate uv run python manage.py runserver -# optional: docker compose up # web + postgres (hot reload) -# optional worker: docker compose --profile worker up worker +# optional: docker compose up # web + postgres + worker (hot reload) ``` Docker compose mounts `./site` into the container and runs Django `runserver` when -`DJANGO_ENV=dev` (the default). Edit Python/templates → auto-reload; no image rebuild. -Rebuild only when Dockerfile / deps (`pyproject.toml`, `uv.lock`) change: +`DJANGO_ENV=dev` (the default). The **worker** service polls `dispatch_due` every 15s so +scheduled campaigns and social posts fire without a manual command. Edit Python/templates +→ auto-reload; no image rebuild. +Rebuild only when Dockerfile, entrypoint scripts, or deps (`pyproject.toml`, `uv.lock`) change: ```bash docker compose up --build diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d636ff3..2edbeda 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -4,6 +4,7 @@ # Web runs on every app host (active/active). Start the dj-queue worker on # exactly ONE host via: # docker compose -f docker-compose.prod.yml --profile worker up -d +# Worker also polls dispatch_due so scheduled campaigns/social posts fire. services: web: build: . diff --git a/docker-compose.yml b/docker-compose.yml index 3565892..97b3dc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,7 +67,6 @@ services: worker: build: . - profiles: ["worker"] entrypoint: ["/worker-entrypoint.sh"] volumes: - ./site:/app/site @@ -77,6 +76,8 @@ services: DJANGO_DEBUG: ${DJANGO_DEBUG:-true} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0} DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://monica_site:monica_site@db:5432/monica_site} + PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000} + DISPATCH_DUE_INTERVAL_SECONDS: ${DISPATCH_DUE_INTERVAL_SECONDS:-15} EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com} EMAIL_HOST_USER: ${EMAIL_HOST_USER:-} EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-} diff --git a/scripts/worker-entrypoint.sh b/scripts/worker-entrypoint.sh index 5f78b44..b22c97f 100755 --- a/scripts/worker-entrypoint.sh +++ b/scripts/worker-entrypoint.sh @@ -47,5 +47,24 @@ uv run python manage.py migrate --noinput # worker must too or hashed lookups raise ValueError. uv run python manage.py collectstatic --noinput +interval="${DISPATCH_DUE_INTERVAL_SECONDS:-15}" +echo "Starting dispatch_due poller (every ${interval}s)." +uv run python manage.py dispatch_due --loop --interval "${interval}" & +dispatch_pid=$! + +cleanup() { + echo "Stopping dispatch_due poller..." + kill "${dispatch_pid}" 2>/dev/null || true + wait "${dispatch_pid}" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# Dev uses ImmediateBackend: enqueue runs inline in dispatch_due. No dj-queue. +if [[ "${DJANGO_ENV:-}" == "dev" ]]; then + echo "Dev worker: dispatch_due loop publishes scheduled work." + wait "${dispatch_pid}" + exit $? +fi + # dj-queue supervisor (workers + dispatcher + scheduler). Run on ONE host only. -exec uv run python manage.py dj_queue +uv run python manage.py dj_queue diff --git a/site/core/management/commands/dispatch_due.py b/site/core/management/commands/dispatch_due.py index 61a3e6b..33cc3e3 100644 --- a/site/core/management/commands/dispatch_due.py +++ b/site/core/management/commands/dispatch_due.py @@ -1,3 +1,5 @@ +import time + from django.core.management.base import BaseCommand from django.utils import timezone @@ -10,10 +12,37 @@ from social.tasks import publish_social_post class Command(BaseCommand): help = ( "Enqueue due scheduled campaign messages and social posts. " - "Optional when the task backend supports run_after defer; useful as a safety net." + "Pass --loop to poll until stopped (worker entrypoint)." ) + def add_arguments(self, parser): + parser.add_argument( + "--loop", + action="store_true", + help="Poll forever until SIGINT/SIGTERM.", + ) + parser.add_argument( + "--interval", + type=int, + default=15, + help="Seconds between polls when --loop is set (default 15).", + ) + def handle(self, *args, **options): + self.verbosity = int(options.get("verbosity", 1)) + if options["loop"]: + interval = max(1, int(options["interval"] or 15)) + self.stdout.write(f"Polling due scheduled work every {interval}s.") + try: + while True: + self._dispatch_once() + time.sleep(interval) + except KeyboardInterrupt: + self.stdout.write("dispatch_due loop stopped.") + return + self._dispatch_once() + + def _dispatch_once(self): now = timezone.now() enqueued = 0 @@ -35,4 +64,7 @@ class Command(BaseCommand): publish_social_post.enqueue(post_id=str(post.pk)) enqueued += 1 - self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s).")) + if enqueued: + self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s).")) + elif self.verbosity >= 2: + self.stdout.write("No due items.") diff --git a/site/core/tests.py b/site/core/tests.py index 8d2967c..e606058 100644 --- a/site/core/tests.py +++ b/site/core/tests.py @@ -1,5 +1,13 @@ +from datetime import timedelta +from io import StringIO +from unittest.mock import patch + +from django.core.management import call_command from django.test import Client, TestCase, override_settings from django.urls import reverse +from django.utils import timezone + +from social.models import SocialPost class HealthzTests(TestCase): @@ -27,3 +35,43 @@ class PublicSmokeTests(TestCase): client = Client() self.assertEqual(client.get(reverse("public:about")).status_code, 200) self.assertEqual(client.get(reverse("public:contact")).status_code, 200) + + +class DispatchDueTests(TestCase): + def test_enqueues_past_scheduled_social_post(self): + post = SocialPost.objects.create( + body="Open house", + status=SocialPost.Status.SCHEDULED, + scheduled_for=timezone.now() - timedelta(minutes=5), + ) + mock_task = patch( + "core.management.commands.dispatch_due.publish_social_post" + ).start() + self.addCleanup(patch.stopall) + out = StringIO() + call_command("dispatch_due", stdout=out) + post.refresh_from_db() + self.assertEqual(post.status, SocialPost.Status.QUEUED) + mock_task.enqueue.assert_called_once_with(post_id=str(post.pk)) + self.assertIn("Enqueued 1 due item(s).", out.getvalue()) + + def test_skips_future_scheduled_social_post(self): + post = SocialPost.objects.create( + body="Later", + status=SocialPost.Status.SCHEDULED, + scheduled_for=timezone.now() + timedelta(hours=1), + ) + mock_task = patch( + "core.management.commands.dispatch_due.publish_social_post" + ).start() + self.addCleanup(patch.stopall) + call_command("dispatch_due", stdout=StringIO()) + post.refresh_from_db() + self.assertEqual(post.status, SocialPost.Status.SCHEDULED) + mock_task.enqueue.assert_not_called() + + def test_quiet_when_nothing_due(self): + out = StringIO() + call_command("dispatch_due", stdout=out) + self.assertEqual(out.getvalue(), "") + diff --git a/site/monica_site/static/css/portal.css b/site/monica_site/static/css/portal.css index 949ff9d..10834da 100644 --- a/site/monica_site/static/css/portal.css +++ b/site/monica_site/static/css/portal.css @@ -362,6 +362,29 @@ body.portal { .plain-list { margin: 0 0 12px; padding-left: 18px; } .plain-list li { margin-bottom: 6px; } .check-row { display: flex; align-items: center; gap: 8px; font-size: 14px; } +.check-row.account-pick { align-items: flex-start; } +.account-pick-copy { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} +.account-pick-copy strong { display: block; font-weight: 600; } +.account-rename { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} +.account-rename input[type="text"] { + flex: 1; + min-width: 160px; + max-width: 100%; + padding: 8px 10px; + border: 1px solid var(--monica-border); + font: inherit; + background: #fff; + box-sizing: border-box; +} .row-warn td { background: #fffbeb; } .auth-alert { background: #fff7ed; diff --git a/site/social/models.py b/site/social/models.py index 2b62649..6b7c77c 100644 --- a/site/social/models.py +++ b/site/social/models.py @@ -3,6 +3,29 @@ from django.db import models from core.models import TimeStampedModel, UUIDPrimaryKeyModel +_GENERIC_ACCOUNT_LABELS = frozenset( + { + "linkedin member", + "instagram account", + "facebook page", + } +) + + +def is_generic_account_label(label: str) -> bool: + """True when OAuth left a placeholder or unreadable name.""" + text = (label or "").strip() + if not text: + return True + lowered = text.lower() + if lowered in _GENERIC_ACCOUNT_LABELS: + return True + if lowered.startswith("page ") and text.split()[-1].isdigit(): + return True + if text.startswith("urn:"): + return True + return False + class Platform(models.TextChoices): FACEBOOK = "facebook", "Facebook" @@ -54,6 +77,22 @@ class SocialAccount(UUIDPrimaryKeyModel, TimeStampedModel): def __str__(self) -> str: return f"{self.platform}: {self.label}" + @property + def display_name(self) -> str: + return (self.label or "").strip() or self.get_platform_display() + + @staticmethod + def resolve_label(existing: "SocialAccount | None", incoming: str) -> str: + """Keep a custom rename; otherwise take the OAuth name.""" + incoming = (incoming or "").strip()[:120] + if existing is not None and not is_generic_account_label(existing.label): + return existing.label + if incoming: + return incoming + if existing and existing.label: + return existing.label + return "Account" + class SocialPost(UUIDPrimaryKeyModel, TimeStampedModel): class Status(models.TextChoices): diff --git a/site/social/templates/social/account_list.html b/site/social/templates/social/account_list.html index 94b143e..14085fe 100644 --- a/site/social/templates/social/account_list.html +++ b/site/social/templates/social/account_list.html @@ -137,8 +137,18 @@ {% for account in accounts %} - {{ account.label }}
- {{ account.external_id }} +
+ {% csrf_token %} + + + + +
+ {% if account.external_id %} + {{ account.external_id }} + {% endif %} diff --git a/site/social/templates/social/composer.html b/site/social/templates/social/composer.html index 38127c9..ab2d09f 100644 --- a/site/social/templates/social/composer.html +++ b/site/social/templates/social/composer.html @@ -116,34 +116,35 @@
{% for account in accounts %} -
- - -
-
- + +
Needed only if you click Schedule. Leave blank to Send now.
- + + +
@@ -199,8 +200,8 @@ var previewBody = document.getElementById('preview-body'); var content = document.getElementById('preview-content'); var previewMedia = document.getElementById('preview-media'); - var mode = document.getElementById('id_publish_mode'); var when = document.getElementById('id_scheduled_for'); + var composeForm = document.getElementById('social-compose'); var mediaInput = document.getElementById('id_media'); var mediaJson = document.getElementById('id_media_json'); var thumbs = document.getElementById('media-thumbs'); @@ -296,9 +297,14 @@ previewBody.hidden = !hasContent; } - function syncWhen() { - if (!mode || !when) return; - when.disabled = mode.value !== 'schedule'; + function requireScheduleTime(event) { + var submitter = event.submitter; + var action = submitter && submitter.name === 'action' ? submitter.value : ''; + if (action !== 'schedule') return; + if (when && when.value) return; + event.preventDefault(); + if (when) when.focus(); + window.alert('Pick a date and time to schedule, or choose Send now.'); } function validateBeforeUpload(file) { @@ -363,7 +369,7 @@ bodyEl.addEventListener('keyup', syncPreview); bodyEl.addEventListener('change', syncPreview); } - if (mode) mode.addEventListener('change', syncWhen); + if (composeForm) composeForm.addEventListener('submit', requireScheduleTime); var generateForm = document.querySelector('form input[name=action][value=generate]')?.form; if (generateForm) { @@ -378,7 +384,6 @@ syncMediaField(); renderThumbs(); syncPreview(); - syncWhen(); })(); {% endblock %} diff --git a/site/social/templates/social/post_detail.html b/site/social/templates/social/post_detail.html index 620e21f..d3040eb 100644 --- a/site/social/templates/social/post_detail.html +++ b/site/social/templates/social/post_detail.html @@ -38,7 +38,12 @@ {% for target in post.targets.all %} - {{ target.account.label }} + + {{ target.account.display_name }} + {% if target.account.external_id %} +
{{ target.account.external_id }} + {% endif %} + {{ target.get_platform_display }} {{ target.get_status_display }} diff --git a/site/social/tests.py b/site/social/tests.py new file mode 100644 index 0000000..01e6db4 --- /dev/null +++ b/site/social/tests.py @@ -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") diff --git a/site/social/views.py b/site/social/views.py index bdbf83e..2be24b3 100644 --- a/site/social/views.py +++ b/site/social/views.py @@ -162,6 +162,17 @@ def account_list(request): if request.method == "POST": action = (request.POST.get("action") or "connect").strip() + if action == "rename": + pk = request.POST.get("account_id") + account = get_object_or_404(SocialAccount, pk=pk) + label = (request.POST.get("label") or "").strip()[:120] + if not label: + messages.error(request, "Enter a display name.") + return redirect("social:account_list") + account.label = label + account.save(update_fields=["label", "updated_at"]) + messages.success(request, f"Renamed account to {account.label}.") + return redirect("social:account_list") if action == "disconnect": pk = request.POST.get("account_id") account = get_object_or_404(SocialAccount, pk=pk) @@ -361,11 +372,14 @@ def linkedin_oauth_callback(request): profile = linkedin_fetch_member_profile(token_payload["access_token"]) author_urn = profile["author_urn"] blob = linkedin_token_blob_from_oauth(token_payload, author_urn=author_urn) + existing = SocialAccount.objects.filter( + platform=Platform.LINKEDIN, external_id=author_urn + ).first() account, created = SocialAccount.objects.update_or_create( platform=Platform.LINKEDIN, external_id=author_urn, defaults={ - "label": profile["label"], + "label": SocialAccount.resolve_label(existing, profile["label"]), "encrypted_tokens": encrypt_tokens(json.dumps(blob)), "is_active": True, "owner": request.user, @@ -473,11 +487,14 @@ def meta_oauth_complete(request): ) for page in ig_pages: blob = instagram_token_blob(page, user_token=user_token) + existing = SocialAccount.objects.filter( + platform=Platform.INSTAGRAM, external_id=blob["ig_user_id"] + ).first() account, created = SocialAccount.objects.update_or_create( platform=Platform.INSTAGRAM, external_id=blob["ig_user_id"], defaults={ - "label": blob["label"], + "label": SocialAccount.resolve_label(existing, blob["label"]), "encrypted_tokens": encrypt_tokens(json.dumps(blob)), "is_active": True, "owner": request.user, @@ -487,12 +504,15 @@ def meta_oauth_complete(request): else: for page in pages: blob = facebook_token_blob(page, user_token=user_token) - label = (page.get("name") or "").strip() or f"Page {page['id']}" + incoming_label = (page.get("name") or "").strip() or f"Page {page['id']}" + existing = SocialAccount.objects.filter( + platform=Platform.FACEBOOK, external_id=str(page["id"]) + ).first() account, created = SocialAccount.objects.update_or_create( platform=Platform.FACEBOOK, external_id=str(page["id"]), defaults={ - "label": label, + "label": SocialAccount.resolve_label(existing, incoming_label), "encrypted_tokens": encrypt_tokens(json.dumps(blob)), "is_active": True, "owner": request.user, @@ -525,7 +545,7 @@ def composer(request): form = { "body": "", "prompt": "", - "publish_mode": "schedule", + "publish_mode": "now", "scheduled_for": "", "account_ids": [], "media_json": "[]", @@ -536,11 +556,17 @@ def composer(request): if request.method == "POST": form["body"] = (request.POST.get("body") or "").strip() form["prompt"] = (request.POST.get("prompt") or "").strip() - form["publish_mode"] = (request.POST.get("publish_mode") or "schedule").strip() + form["publish_mode"] = (request.POST.get("publish_mode") or "now").strip() form["scheduled_for"] = request.POST.get("scheduled_for") or "" form["account_ids"] = request.POST.getlist("account_ids") form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]" action = (request.POST.get("action") or "save").strip() + if action == "send_now": + form["publish_mode"] = "now" + elif action == "schedule": + form["publish_mode"] = "schedule" + elif action == "save": + form["publish_mode"] = "draft" if action == "generate" and form["prompt"]: try: @@ -548,7 +574,7 @@ def composer(request): form["body"] = draft except OllamaError as exc: error = str(exc) - elif action in {"save", "publish"}: + elif action in {"save", "publish", "send_now", "schedule"}: media_items: list = [] try: media_items = parse_media_json(form["media_json"]) @@ -571,7 +597,7 @@ def composer(request): error = str(exc) else: if not scheduled_for: - error = "Pick a schedule date/time, or choose Publish now." + error = "Pick a schedule date/time, or choose Send now." else: status = SocialPost.Status.SCHEDULED elif form["publish_mode"] == "now": @@ -614,7 +640,7 @@ def composer(request): publish_social_post.enqueue(post_id=str(post.pk)) messages.success( request, - "Post queued for publishing to selected accounts.", + "Post sent — queued for publishing to selected accounts.", ) elif status == SocialPost.Status.SCHEDULED: messages.success(