From 58258f28752279e541384a6ec293741f19c77871 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 10 Aug 2026 05:15:49 -0500 Subject: [PATCH] Instrument webhooks, fix compose layout, and ship social connect. Add Grafana-friendly webhook request logging, Quill overflow fix, New Contact flow, in-place postcard campaign compose, and functional social account connect + composer. --- site/contacts/templates/contacts/create.html | 100 +++++ site/contacts/templates/contacts/list.html | 1 + site/contacts/urls.py | 1 + site/contacts/views.py | 74 ++++ .../templates/messaging/campaign_list.html | 98 ++++- site/messaging/views.py | 105 +++++- site/monica_site/static/css/portal.css | 23 +- .../social/templates/social/account_list.html | 90 ++++- site/social/templates/social/composer.html | 357 +++++------------- site/social/views.py | 240 ++++++++++-- 10 files changed, 778 insertions(+), 311 deletions(-) create mode 100644 site/contacts/templates/contacts/create.html diff --git a/site/contacts/templates/contacts/create.html b/site/contacts/templates/contacts/create.html new file mode 100644 index 0000000..9130255 --- /dev/null +++ b/site/contacts/templates/contacts/create.html @@ -0,0 +1,100 @@ +{% extends "portal_base.html" %} +{% load static %} +{% block title %}New contact · Portal{% endblock %} +{% block topbar_title %}New contact{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block portal_content %} +
+ {% csrf_token %} +
+
+

Profile

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ + Cancel +
+
+
+
+

Consent

+
+
+ +
+
+ +
+
+ +
+

+ Matches existing contacts by email, phone, or address when possible. + Postcard defaults on when an address is saved. +

+
+
+
+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/contacts/templates/contacts/list.html b/site/contacts/templates/contacts/list.html index 4088493..a40eb65 100644 --- a/site/contacts/templates/contacts/list.html +++ b/site/contacts/templates/contacts/list.html @@ -9,6 +9,7 @@
Import CSV / Excel + New contact New campaign
diff --git a/site/contacts/urls.py b/site/contacts/urls.py index cca07a7..e48b34c 100644 --- a/site/contacts/urls.py +++ b/site/contacts/urls.py @@ -6,6 +6,7 @@ app_name = "contacts" urlpatterns = [ path("", views.contact_list, name="list"), + path("new/", views.contact_create, name="create"), path("import/", views.contact_import, name="import"), path("/", views.contact_detail, name="detail"), ] diff --git a/site/contacts/views.py b/site/contacts/views.py index d36e70c..9dfd83a 100644 --- a/site/contacts/views.py +++ b/site/contacts/views.py @@ -1,5 +1,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.core.validators import validate_email from django.db.models import Prefetch, Q from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -7,6 +9,7 @@ from django.views.decorators.http import require_GET, require_http_methods from contacts.models import Channel, ConsentRecord, Contact from contacts.nominatim import NominatimError, suggest_addresses +from contacts.services import upsert_contact from messaging.services import channel_preferences, set_channel_preferences @@ -48,6 +51,76 @@ def contact_list(request): ) +@login_required +@require_http_methods(["GET", "POST"]) +def contact_create(request): + form = { + "first_name": "", + "last_name": "", + "email": "", + "phone": "", + "address_line1": "", + "address_line2": "", + "address_city": "", + "address_state": "", + "address_zip": "", + "address_country": "US", + "notes": "", + "consent_email": True, + "consent_sms": False, + "consent_postcard": True, + } + if request.method == "POST": + for key in list(form.keys()): + if key.startswith("consent_"): + form[key] = key in request.POST + else: + form[key] = (request.POST.get(key) or "").strip() + email = form["email"].lower() + errors: list[str] = [] + if not form["first_name"]: + errors.append("First name is required.") + if not email: + errors.append("Email is required.") + else: + try: + validate_email(email) + except ValidationError: + errors.append("Enter a valid email address.") + postal = _postal_from_post(request.POST) + has_postal = Contact.postal_address_has_content(postal) + if form["consent_sms"] and not form["phone"]: + errors.append("Phone is required for SMS consent.") + if form["consent_postcard"] and not has_postal: + # Soft: allow save but clear postcard consent if no address + form["consent_postcard"] = False + if not errors: + contact, created, reason = upsert_contact( + email=email, + first_name=form["first_name"], + last_name=form["last_name"], + phone=form["phone"], + postal_address=postal if has_postal else None, + source=Contact.Source.MANUAL, + notes_append=form["notes"], + ) + set_channel_preferences( + contact, + { + Channel.EMAIL: form["consent_email"], + Channel.SMS: form["consent_sms"], + Channel.POSTCARD: form["consent_postcard"], + }, + reason="portal_manual", + ) + verb = "Added" if created else f"Updated (matched by {reason or 'email'})" + messages.success(request, f"{verb} {contact}.") + return redirect("contacts:detail", pk=contact.pk) + for err in errors: + messages.error(request, err) + return render(request, "contacts/create.html", {"form": form}) + + @login_required @require_http_methods(["GET", "POST"]) def contact_detail(request, pk): @@ -76,6 +149,7 @@ def contact_detail(request, pk): {"contact": contact, "prefs": prefs}, ) + @login_required def contact_import(request): return render(request, "contacts/import.html") diff --git a/site/messaging/templates/messaging/campaign_list.html b/site/messaging/templates/messaging/campaign_list.html index 3a327d9..3e8e4a4 100644 --- a/site/messaging/templates/messaging/campaign_list.html +++ b/site/messaging/templates/messaging/campaign_list.html @@ -4,19 +4,47 @@ {% block extra_head %} {% endblock %} {% block portal_content %}
@@ -58,6 +86,15 @@ oninput="syncSmsBody()">{{ form_data.body }}
Plain text for SMS · keep it short
+
-
Open postcard designer to create or edit designs
+
@@ -165,14 +204,27 @@ syncCampaignPreview(); }; + window.syncPostcardBody = function () { + var pc = document.getElementById('id_body_pc'); + if (pc && bodyField) bodyField.value = pc.value; + syncCampaignPreview(); + }; + window.syncCampaignPreview = function () { var subject = (document.getElementById('id_subject') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || ''; var isEmail = audience === 'email_opt_in'; + var isPostcard = audience === 'postcard_opt_in'; var body = ''; if (isEmail && quill) { body = quill.root.innerHTML; if (body === '


' || body === '

') body = ''; + } else if (isPostcard) { + var tmpl = document.getElementById('id_template_id'); + var label = tmpl && tmpl.selectedIndex >= 0 ? tmpl.options[tmpl.selectedIndex].text : ''; + body = label && tmpl.value ? ('Postcard design: ' + label) : ''; + var note = (document.getElementById('id_body_pc') || {}).value || ''; + if (note) body = (body ? body + '\n\n' : '') + note; } else { body = (bodyField && bodyField.value) || ''; } @@ -188,7 +240,7 @@ } empty.hidden = true; content.hidden = false; - subEl.textContent = subject ? ('Subject: ' + subject) : ''; + subEl.textContent = subject ? ('Subject: ' + subject) : (isPostcard ? 'Postcard mailing' : ''); if (isEmail) { bodyEl.style.whiteSpace = 'normal'; bodyEl.innerHTML = body; @@ -206,19 +258,24 @@ var tmplField = document.getElementById('postcard-template-field'); var emailWrap = document.getElementById('email-editor-wrap'); var smsWrap = document.getElementById('sms-body-wrap'); + var pcHint = document.getElementById('postcard-body-hint'); var subjectField = document.getElementById('subject-field'); if (tmplField) tmplField.style.display = isPostcard ? '' : 'none'; if (subjectField) subjectField.style.display = isEmail ? '' : 'none'; if (emailWrap) emailWrap.hidden = !isEmail; - if (smsWrap) smsWrap.hidden = isEmail; + if (smsWrap) smsWrap.hidden = !isSms; + if (pcHint) pcHint.hidden = !isPostcard; if (isEmail && quill) { syncBodyFromQuill(); - } else if (smsField && bodyField) { - // Keep SMS/postcard plain body in hidden field - if (!isEmail && smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) { + } else if (isSms && smsField && bodyField) { + if (smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) { smsField.value = bodyField.value; } - bodyField.value = smsField ? smsField.value : bodyField.value; + bodyField.value = smsField.value; + bodyField.removeAttribute('required'); + } else if (isPostcard && bodyField) { + var pc = document.getElementById('id_body_pc'); + bodyField.value = pc ? pc.value : ''; bodyField.removeAttribute('required'); } document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) { @@ -288,21 +345,30 @@ document.getElementById('campaign-compose').addEventListener('submit', function () { var audience = (document.getElementById('id_audience') || {}).value || ''; if (audience === 'email_opt_in') syncBodyFromQuill(); - else if (smsField) bodyField.value = smsField.value; + else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value; + else if (audience === 'postcard_opt_in') { + var pc = document.getElementById('id_body_pc'); + bodyField.value = pc ? pc.value : ''; + } }); } - document.querySelectorAll('#compose-channel-tabs a[data-channel="email"], #compose-channel-tabs a[data-channel="sms"]').forEach(function (a) { + document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) { a.addEventListener('click', function (e) { e.preventDefault(); var ch = a.getAttribute('data-channel'); var audience = document.getElementById('id_audience'); if (!audience) return; - audience.value = ch === 'sms' ? 'sms_opt_in' : 'email_opt_in'; + if (ch === 'sms') audience.value = 'sms_opt_in'; + else if (ch === 'postcard') audience.value = 'postcard_opt_in'; + else audience.value = 'email_opt_in'; syncComposeChannel(); }); }); + var tmplSelect = document.getElementById('id_template_id'); + if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview); + initQuill(); syncComposeChannel(); })(); diff --git a/site/messaging/views.py b/site/messaging/views.py index 8fc16b5..ccce528 100644 --- a/site/messaging/views.py +++ b/site/messaging/views.py @@ -286,6 +286,76 @@ def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None return False +def _log_webhook_request(request, *, channel: str) -> None: + """Full request dump for Grafana / log aggregation.""" + try: + headers = {str(k): str(v) for k, v in request.headers.items()} + except Exception: # noqa: BLE001 + headers = {"_error": "unable to serialize headers"} + try: + body_text = (request.body or b"").decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + body_text = repr(request.body) + if len(body_text) > 12000: + body_text = body_text[:12000] + "…[truncated]" + logger.info( + "webhook_received channel=%s path=%s method=%s query=%s", + channel, + request.path, + request.method, + request.META.get("QUERY_STRING", ""), + ) + logger.info("webhook_headers channel=%s headers=%s", channel, headers) + logger.info("webhook_body channel=%s body=%s", channel, body_text) + + +def _log_webhook_auth_failed(request, *, channel: str) -> None: + logger.warning( + "webhook_auth_failed channel=%s path=%s " + "missing_or_invalid_authorization_or_token", + channel, + request.path, + ) + + +def _log_webhook_result( + *, + channel: str, + event=None, + error: str = "", + extra: str = "", +) -> None: + if error: + logger.error( + "webhook_error channel=%s error=%s %s", + channel, + error, + extra, + ) + return + if not event: + logger.warning( + "webhook_unmatched channel=%s no_provider_event_created %s", + channel, + extra, + ) + return + message = getattr(event, "message", None) + campaign = getattr(message, "campaign", None) if message else None + logger.info( + "webhook_processed channel=%s event_type=%s event_id=%s " + "matched=%s message_id=%s campaign_id=%s campaign_name=%s %s", + channel, + getattr(event, "event_type", ""), + getattr(event, "pk", None), + bool(message), + getattr(message, "pk", None), + getattr(campaign, "pk", None), + getattr(campaign, "name", "") or "", + extra, + ) + + def _pcm_webhook_secrets() -> list[str]: """All PCM subscription signature secrets from env.""" raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip() @@ -675,12 +745,21 @@ def postcard_webhook(request): URL: https:///portal/messaging/webhooks/postcard/ Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS """ + channel = "postcard" + _log_webhook_request(request, channel=channel) if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()): + _log_webhook_auth_failed(request, channel=channel) return HttpResponseForbidden("invalid webhook token") payload = parse_webhook_payload(request) if not payload: payload = request.POST.dict() or {} - event = process_pcm_postcard_webhook(payload) + try: + event = process_pcm_postcard_webhook(payload) + except Exception as exc: # noqa: BLE001 + logger.exception("PCM postcard webhook processing failed") + _log_webhook_result(channel=channel, error=str(exc)) + return JsonResponse({"ok": False, "error": "processing_failed"}, status=200) + _log_webhook_result(channel=channel, event=event) return JsonResponse( { "ok": True, @@ -705,9 +784,12 @@ def sms_webhook(request): Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out. """ + channel = "sms" + _log_webhook_request(request, channel=channel) if not _webhook_authorized( request, secret=settings.SMTP2GO_WEBHOOK_SECRET or "" ): + _log_webhook_auth_failed(request, channel=channel) return HttpResponseForbidden("invalid webhook token") payload = parse_webhook_payload(request) @@ -724,9 +806,21 @@ def sms_webhook(request): or "" ) stopped = bool(phone) and record_sms_stop(str(phone)) + logger.info( + "webhook_processed channel=sms event_type=inbound_stop " + "opt_out=%s phone=%s", + stopped, + phone, + ) return JsonResponse({"ok": True, "opt_out": stopped}) - event = process_smtp2go_sms_webhook(payload) + try: + event = process_smtp2go_sms_webhook(payload) + except Exception as exc: # noqa: BLE001 + logger.exception("SMTP2GO SMS webhook processing failed") + _log_webhook_result(channel=channel, error=str(exc)) + return JsonResponse({"ok": False, "error": "processing_failed"}, status=200) + _log_webhook_result(channel=channel, event=event) return JsonResponse( { "ok": True, @@ -749,16 +843,21 @@ def email_webhook(request): Email events: all delivery/engagement boxes Email headers: X-Monica-Message-Id """ + channel = "email" + _log_webhook_request(request, channel=channel) if not _webhook_authorized( request, secret=settings.SMTP2GO_WEBHOOK_SECRET or "" ): + _log_webhook_auth_failed(request, channel=channel) return HttpResponseForbidden("invalid webhook token") payload = parse_webhook_payload(request) try: event = process_smtp2go_email_webhook(payload) - except Exception: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h) + except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h) logger.exception("SMTP2GO email webhook processing failed") + _log_webhook_result(channel=channel, error=str(exc)) return JsonResponse({"ok": False, "error": "processing_failed"}, status=200) + _log_webhook_result(channel=channel, event=event) return JsonResponse( { "ok": True, diff --git a/site/monica_site/static/css/portal.css b/site/monica_site/static/css/portal.css index 0cbd17b..34bb955 100644 --- a/site/monica_site/static/css/portal.css +++ b/site/monica_site/static/css/portal.css @@ -319,14 +319,35 @@ body.portal { .auth-alert a { color: #9a3412; font-weight: 600; } /* Social accounts */ -.connect-grid { display: grid; gap: 12px; } +.connect-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +@media (max-width: 900px) { + .connect-grid { grid-template-columns: 1fr; } +} .connect-card { display: flex; gap: 14px; align-items: flex-start; text-align: left; padding: 14px; border: 1px solid var(--monica-border); background: #fff; cursor: pointer; font: inherit; width: 100%; } .connect-card:hover { border-color: var(--monica-primary); } +.connect-card.is-active { border-color: var(--monica-primary); box-shadow: inset 0 0 0 1px var(--monica-primary); } .connect-card p { margin: 4px 0 0; font-size: 13px; color: var(--monica-muted); } +.connect-form-panel { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--monica-border); +} +.connect-steps { + margin: 0 0 16px; + padding-left: 18px; + font-size: 13px; + color: var(--monica-muted); +} +.connect-steps li { margin-bottom: 8px; } +.connect-steps a { color: var(--monica-primary); } .platform-icon { width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; flex-shrink: 0; font-size: 14px; diff --git a/site/social/templates/social/account_list.html b/site/social/templates/social/account_list.html index 14ea0c0..6a9ebbe 100644 --- a/site/social/templates/social/account_list.html +++ b/site/social/templates/social/account_list.html @@ -6,28 +6,67 @@

Add account

+ + {% if connect_meta %} +
+

{{ connect_meta.title }}

+
    + {% for step in connect_meta.steps %} +
  1. {{ step }}
  2. + {% endfor %} + {% if connect_meta.docs_url %} +
  3. Reference: platform docs
  4. + {% endif %} +
+
+ {% csrf_token %} + + + {% for name, label, placeholder in connect_meta.fields %} +
+ + {% if name == 'access_token' %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ + Cancel +
+
+
+ {% endif %}
@@ -62,10 +101,29 @@ Inactive {% endif %} - Connect / disconnect in functionality pass + +
+ Update tokens + {% if account.is_active %} +
+ {% csrf_token %} + + + +
+ {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
+ {% empty %} - No accounts connected yet. + No accounts connected yet — pick a platform above. {% endfor %} @@ -84,11 +142,17 @@
-

Health checks

+

Next

-

Token test buttons wire up with the connectors next.

- Open composer → + Open composer →
{% endblock %} +{% block extra_js %} +{% if connect_platform %} + +{% endif %} +{% endblock %} diff --git a/site/social/templates/social/composer.html b/site/social/templates/social/composer.html index 2bd8b6c..b2e4df5 100644 --- a/site/social/templates/social/composer.html +++ b/site/social/templates/social/composer.html @@ -1,285 +1,132 @@ {% extends "portal_base.html" %} -{% load static %} {% block title %}Compose · Social{% endblock %} {% block topbar_title %}Compose & preview{% endblock %} {% block portal_content %} -
- LinkedIn needs re-auth. Token expired — - Re-authorize in Accounts before scheduling to LinkedIn. +{% if not accounts %} +
+ No social accounts connected. + Connect Facebook, Instagram, or LinkedIn first.
+{% endif %} -
-
-
- -
-
- +
+
+
+

Compose

+ Manage accounts +
+
+ {% if error %} +
  • {{ error }}
+ {% endif %} +
+ {% csrf_token %} +
+ +
0 characters · IG soft limit ~2,200
-
- -
-
-
- - - -
+
+ + {% for account in accounts %} + + {% empty %} +

No active accounts.

+ {% endfor %}
-
- +
+ +
-
-
-
-
- Facebook - Instagram - LinkedIn +
+ +
- -
+
+ +
+
-
-
-
-

AI post assistant

- Beta mockup -
-
-
-
Assistant
-
- I can draft or refine captions for Facebook, Instagram, and LinkedIn. - Tell me the listing, tone (warm / bold / luxury), and any must-include details — - or tap a quick prompt below. -
+
+

AI assist

+
+
+ {% csrf_token %} + + + + {% for id in form.account_ids %} + + {% endfor %} +
+ +
-
-
- - - - - -
-
- - -
-
-
- -
-
-

Live post preview

Facebook
-
- - - -
-
-
-

Scheduled

-
- - - - - - -
PostPlatformsStatus
Open house Willow LaneFB · IGScheduled
Just sold — Birch AveFB · IG · LIPublished
-
+ + +

Generated text fills the caption field. Review before publishing.

-
-

Save draft (server)

-
- {% if error %}
  • {{ error }}
{% endif %} - {% if saved %}{% endif %} -
- {% csrf_token %} -
-
- -
-
-
- - - Scheduled / drafts -
-
+
+

Preview

+
+
+
Preview updates as you type.
+ +
+ +

+ View recent posts +

{% endblock %} {% block extra_js %} {% endblock %} diff --git a/site/social/views.py b/site/social/views.py index 1aee107..2760b0c 100644 --- a/site/social/views.py +++ b/site/social/views.py @@ -1,12 +1,71 @@ import json +from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, redirect, render from django.http import JsonResponse -from django.shortcuts import get_object_or_404, render +from django.utils import timezone from django.views.decorators.http import require_http_methods, require_POST -from social.models import SocialAccount, SocialPost +from messaging.services import parse_scheduled_for +from social.crypto import encrypt_tokens +from social.models import Platform, SocialAccount, SocialPost, SocialPostTarget from social.ollama import OllamaError, generate_social_post +from social.tasks import publish_social_post + +CONNECT_INSTRUCTIONS = { + Platform.FACEBOOK: { + "title": "Connect Facebook Page", + "steps": [ + "Open Meta for Developers → your app → Tools → Graph API Explorer.", + "Select your app, then Get Page Access Token for the Page you manage.", + "Grant pages_manage_posts and pages_read_engagement (and pages_show_list).", + "Copy the Page access token and the numeric Page ID.", + "Paste both below. Tokens are encrypted at rest.", + ], + "docs_url": "https://developers.facebook.com/docs/pages/access-tokens/", + "fields": [ + ("label", "Display name", "Monica Dhillon · EXIT"), + ("external_id", "Page ID", "1029384756"), + ("access_token", "Page access token", ""), + ], + }, + Platform.INSTAGRAM: { + "title": "Connect Instagram Business", + "steps": [ + "Instagram publishing uses a Facebook Page linked to an IG professional account.", + "In Meta Business Suite, confirm the IG account is connected to your Page.", + "From Graph API Explorer, get a Page token that can manage the linked IG account.", + "Use the Instagram Business Account ID (not the username) as External ID.", + "Paste Page access token + IG business account ID below.", + ], + "docs_url": "https://developers.facebook.com/docs/instagram-api/getting-started/", + "fields": [ + ("label", "Display name", "@mkdrealtor"), + ("external_id", "IG business account ID", ""), + ("access_token", "Page access token", ""), + ("page_id", "Facebook Page ID (optional)", ""), + ], + }, + Platform.LINKEDIN: { + "title": "Connect LinkedIn", + "steps": [ + "Create a LinkedIn Developer app and add the Share on LinkedIn / Marketing products.", + "Generate a member or organization access token with w_member_social " + "(or w_organization_social for company pages).", + "Find your author URN: person URN looks like urn:li:person:XXXX; " + "organization URN like urn:li:organization:XXXX.", + "Paste the access token and author URN below. LinkedIn tokens expire — " + "reconnect when publishing fails with auth errors.", + ], + "docs_url": "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/ugc-post-api", + "fields": [ + ("label", "Display name", "Monica Dhillon"), + ("external_id", "Author URN", "urn:li:person:…"), + ("access_token", "Access token", ""), + ], + }, +} @login_required @@ -22,42 +81,177 @@ def post_detail(request, pk): @login_required +@require_http_methods(["GET", "POST"]) def account_list(request): accounts = SocialAccount.objects.all() - return render(request, "social/account_list.html", {"accounts": accounts}) + connect_platform = (request.GET.get("connect") or "").strip() + if connect_platform not in Platform.values: + connect_platform = "" + + if request.method == "POST": + action = (request.POST.get("action") or "connect").strip() + if action == "disconnect": + pk = request.POST.get("account_id") + account = get_object_or_404(SocialAccount, pk=pk) + account.is_active = False + account.encrypted_tokens = "" + account.save(update_fields=["is_active", "encrypted_tokens", "updated_at"]) + messages.success(request, f"Disconnected {account.label}.") + return redirect("social:account_list") + if action == "reactivate": + pk = request.POST.get("account_id") + account = get_object_or_404(SocialAccount, pk=pk) + account.is_active = True + account.save(update_fields=["is_active", "updated_at"]) + messages.success(request, f"Reactivated {account.label}.") + return redirect("social:account_list") + + platform = (request.POST.get("platform") or "").strip() + if platform not in Platform.values: + messages.error(request, "Choose a platform.") + return redirect("social:account_list") + label = (request.POST.get("label") or "").strip() or platform.title() + external_id = (request.POST.get("external_id") or "").strip() + access_token = (request.POST.get("access_token") or "").strip() + page_id = (request.POST.get("page_id") or "").strip() + if not access_token or not external_id: + messages.error(request, "Access token and external ID are required.") + return redirect(f"{request.path}?connect={platform}") + + token_blob = {"access_token": access_token} + if platform == Platform.FACEBOOK: + token_blob["page_id"] = external_id + elif platform == Platform.INSTAGRAM: + if page_id: + token_blob["page_id"] = page_id + elif platform == Platform.LINKEDIN: + token_blob["author_urn"] = external_id + + account, created = SocialAccount.objects.update_or_create( + platform=platform, + external_id=external_id, + defaults={ + "label": label, + "encrypted_tokens": encrypt_tokens(json.dumps(token_blob)), + "is_active": True, + "owner": request.user, + }, + ) + verb = "Connected" if created else "Updated" + messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.") + return redirect("social:account_list") + + return render( + request, + "social/account_list.html", + { + "accounts": accounts, + "connect_platform": connect_platform, + "connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform), + "platforms": Platform, + }, + ) @login_required @require_http_methods(["GET", "POST"]) def composer(request): - """Portal composer — optional Ollama draft assist.""" - draft = "" + """Compose, schedule, or publish to connected social accounts.""" + accounts = list(SocialAccount.objects.filter(is_active=True)) + form = { + "body": "", + "prompt": "", + "publish_mode": "schedule", + "scheduled_for": "", + "account_ids": [], + } error = "" - prompt = "" + draft = "" + if request.method == "POST": - prompt = (request.POST.get("prompt") or "").strip() - platform = (request.POST.get("platform") or "").strip() - if prompt: + 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["scheduled_for"] = request.POST.get("scheduled_for") or "" + form["account_ids"] = request.POST.getlist("account_ids") + action = (request.POST.get("action") or "save").strip() + + if action == "generate" and form["prompt"]: try: - draft = generate_social_post(prompt, platform=platform) + draft = generate_social_post(form["prompt"]) + form["body"] = draft except OllamaError as exc: error = str(exc) - body = (request.POST.get("body") or draft).strip() - if request.POST.get("action") == "save" and body: - post = SocialPost.objects.create( - body=body, - ollama_prompt=prompt, - created_by=request.user, - ) - return render( - request, - "social/composer.html", - {"saved": post, "draft": body, "prompt": prompt}, - ) + elif action in {"save", "publish"}: + if not form["body"]: + error = "Caption / body is required." + elif form["publish_mode"] != "draft" and not form["account_ids"]: + error = "Select at least one connected account." + else: + scheduled_for = None + status = SocialPost.Status.DRAFT + if form["publish_mode"] == "draft": + status = SocialPost.Status.DRAFT + elif form["publish_mode"] == "schedule": + try: + scheduled_for = parse_scheduled_for(form["scheduled_for"]) + except ValueError as exc: + error = str(exc) + else: + if not scheduled_for: + error = "Pick a schedule date/time, or choose Publish now." + else: + status = SocialPost.Status.SCHEDULED + elif form["publish_mode"] == "now": + status = SocialPost.Status.QUEUED + scheduled_for = timezone.now() + else: + error = "Unknown publish mode." + + if not error: + selected = SocialAccount.objects.filter( + pk__in=form["account_ids"], is_active=True + ) + if form["publish_mode"] != "draft" and not selected.exists(): + error = "No valid accounts selected." + else: + post = SocialPost.objects.create( + body=form["body"], + ollama_prompt=form["prompt"], + scheduled_for=scheduled_for, + status=status, + created_by=request.user, + ) + for account in selected: + SocialPostTarget.objects.create( + post=post, + account=account, + platform=account.platform, + ) + if status == SocialPost.Status.QUEUED: + publish_social_post.enqueue(post_id=str(post.pk)) + messages.success( + request, + "Post queued for publishing to selected accounts.", + ) + elif status == SocialPost.Status.SCHEDULED: + messages.success( + request, + f"Post scheduled for {scheduled_for:%b %d, %I:%M %p}.", + ) + else: + messages.success(request, "Draft saved.") + return redirect("social:post_detail", pk=post.pk) + return render( request, "social/composer.html", - {"draft": draft, "prompt": prompt, "error": error}, + { + "accounts": accounts, + "form": form, + "error": error, + "draft": draft or form["body"], + }, )