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 }}
+
+ {% 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 @@
-
+
+
+
@@ -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(
|