Add Grafana-friendly webhook request logging, Quill overflow fix, New Contact flow, in-place postcard campaign compose, and functional social account connect + composer.
280 lines
12 KiB
Python
280 lines
12 KiB
Python
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.utils import timezone
|
|
from django.views.decorators.http import require_http_methods, require_POST
|
|
|
|
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
|
|
def post_list(request):
|
|
posts = SocialPost.objects.all()[:100]
|
|
return render(request, "social/post_list.html", {"posts": posts})
|
|
|
|
|
|
@login_required
|
|
def post_detail(request, pk):
|
|
post = get_object_or_404(SocialPost.objects.prefetch_related("targets"), pk=pk)
|
|
return render(request, "social/post_detail.html", {"post": post})
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def account_list(request):
|
|
accounts = SocialAccount.objects.all()
|
|
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):
|
|
"""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 = ""
|
|
draft = ""
|
|
|
|
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["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(form["prompt"])
|
|
form["body"] = draft
|
|
except OllamaError as exc:
|
|
error = str(exc)
|
|
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",
|
|
{
|
|
"accounts": accounts,
|
|
"form": form,
|
|
"error": error,
|
|
"draft": draft or form["body"],
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def api_generate(request):
|
|
"""
|
|
JSON endpoint for the portal to draft posts via Ollama.
|
|
|
|
POST JSON: {"prompt": "...", "platform": "facebook"|...}
|
|
"""
|
|
try:
|
|
payload = json.loads(request.body.decode() or "{}")
|
|
except json.JSONDecodeError:
|
|
payload = request.POST.dict()
|
|
prompt = (payload.get("prompt") or "").strip()
|
|
if not prompt:
|
|
return JsonResponse({"error": "prompt required"}, status=400)
|
|
try:
|
|
text = generate_social_post(
|
|
prompt, platform=(payload.get("platform") or "").strip()
|
|
)
|
|
except OllamaError as exc:
|
|
return JsonResponse({"error": str(exc)}, status=502)
|
|
return JsonResponse({"text": text})
|