Social composer: send now and rename messy account names (#6)
## Summary Closes #5. - Composer can **Send now** without a schedule time (Schedule and Save draft still available) - Account names wrap and show the platform id so a bad OAuth label is still readable - Social accounts page can rename the display name; re-auth keeps a custom name instead of overwriting it with a placeholder - Worker polls `dispatch_due` every 15s so scheduled campaigns and social posts fire without a manual command; local `docker compose up` now starts the worker ## Test plan - [ ] Open Social → Compose: confirm **Send now**, **Schedule**, and **Save draft** buttons (no combined Save / publish) - [ ] Send now with a connected account and caption → post queues immediately, no datetime required - [ ] Schedule with a datetime → post is scheduled; Schedule with a blank time → validation error - [ ] Composer account list shows full name plus external id, wrapping if the name is long - [ ] Social accounts → rename a messy label → new name shows in composer without reconnecting - [ ] Re-authorize an account that was renamed → custom name is kept - [ ] Rebuild and start compose (`docker compose up --build`) so the **worker** service is running - [ ] Schedule a social post ~2 minutes ahead → after due time, worker logs `Enqueued N due item(s).` and the post leaves Scheduled (published or failed) - [ ] `docker compose logs -f worker` shows `Polling due scheduled work every 15s.` Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -92,4 +92,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: .
|
||||||
|
|||||||
+2
-1
@@ -67,7 +67,6 @@ services:
|
|||||||
|
|
||||||
worker:
|
worker:
|
||||||
build: .
|
build: .
|
||||||
profiles: ["worker"]
|
|
||||||
entrypoint: ["/worker-entrypoint.sh"]
|
entrypoint: ["/worker-entrypoint.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./site:/app/site
|
- ./site:/app/site
|
||||||
@@ -77,6 +76,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:-}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
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.")
|
||||||
|
|||||||
@@ -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(), "")
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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