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.
This commit is contained in:
+217
-23
@@ -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"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user