Instrument webhooks, fix compose layout, and ship social connect.
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / docker (push) Successful in 15s
Deploy Beta / deploy-beta (push) Successful in 1m40s

Add Grafana-friendly webhook request logging, Quill overflow fix, New Contact flow, in-place postcard campaign compose, and functional social account connect + composer.
This commit is contained in:
2026-08-10 05:15:49 -05:00
parent b3a6ee0cd0
commit 58258f2875
10 changed files with 778 additions and 311 deletions
+217 -23
View File
@@ -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"],
},
)