Files
monica_site/site/social/views.py
T
westfarnandCursor 96741bc0c8
CI / test (pull_request) Successful in 17s
Let social composer send now and rename messy account names.
Closes #5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 09:42:53 -05:00

746 lines
30 KiB
Python

import json
import secrets
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.urls import reverse
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from messaging.services import parse_scheduled_for
from messaging.models import StoredFile
from social.crypto import SocialCryptoError, encrypt_tokens
from social.media import (
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
MAX_IMAGE_BYTES,
MAX_VIDEO_BYTES,
media_item_from_stored,
parse_media_json,
)
from social.models import (
Platform,
SocialAccount,
SocialAppCredentials,
SocialPost,
SocialPostTarget,
)
from social.oauth_linkedin import (
LinkedInOAuthError,
authorization_url as linkedin_authorization_url,
credentials_configured as linkedin_credentials_configured,
exchange_code as linkedin_exchange_code,
fetch_member_profile as linkedin_fetch_member_profile,
get_app_credentials as get_linkedin_app_credentials,
redirect_uri as linkedin_redirect_uri,
token_blob_from_oauth as linkedin_token_blob_from_oauth,
)
from social.oauth_meta import (
MetaOAuthError,
authorization_url as meta_authorization_url,
credentials_configured as meta_credentials_configured,
facebook_token_blob,
fetch_pages,
get_app_credentials as get_meta_app_credentials,
instagram_token_blob,
redirect_uri as meta_redirect_uri,
)
from social.ollama import OllamaError, generate_social_post
from social.tasks import publish_social_post
_META_DOCS = (
"https://developers.facebook.com/documentation/instagram-platform/"
"instagram-api-with-facebook-login/business-login-for-instagram"
)
CONNECT_INSTRUCTIONS = {
Platform.FACEBOOK: {
"title": "Connect Facebook Page",
"oauth": True,
"oauth_platform": "facebook",
"client_id_label": "Meta App ID",
"client_secret_label": "Meta App Secret",
"connect_label": "Connect with Facebook",
"save_action": "save_meta_app",
"callback_label": (
"Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings"
),
"steps": [
mark_safe(
'In <a href="https://developers.facebook.com/apps/" target="_blank" '
'rel="noopener">Meta for Developers</a>, use a Business-type app and add '
"<strong>Facebook Login for Business</strong> plus Instagram "
"(API setup with Facebook login)."
),
"Under Facebook Login for Business → Settings → Valid OAuth Redirect URIs, "
"paste the callback URL shown below (exact match required).",
"Copy Meta App ID and App Secret into the fields below, then Save app credentials.",
"Click Connect with Facebook — grant Page permissions; we store the Page "
"access token for posting.",
],
"docs_url": _META_DOCS,
"fields": [],
},
Platform.INSTAGRAM: {
"title": "Connect Instagram",
"oauth": True,
"oauth_platform": "instagram",
"client_id_label": "Meta App ID",
"client_secret_label": "Meta App Secret",
"connect_label": "Connect with Instagram",
"save_action": "save_meta_app",
"callback_label": (
"Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings"
),
"steps": [
mark_safe(
'Follow <a href="'
+ _META_DOCS
+ '" target="_blank" rel="noopener">Facebook Login for Business</a> '
"for Instagram — Professional IG account linked to a Facebook Page."
),
"Same Meta App ID / Secret as Facebook (shared). Save credentials once.",
"Add the redirect URI below to Valid OAuth Redirect URIs, then Connect with Instagram.",
"We request Page + Instagram publish scopes and save the linked Instagram "
"Business account using the Page access token.",
],
"docs_url": _META_DOCS,
"fields": [],
},
Platform.LINKEDIN: {
"title": "Connect LinkedIn",
"oauth": True,
"oauth_platform": "linkedin",
"client_id_label": "Client ID",
"client_secret_label": "Primary Client Secret",
"connect_label": "Connect with LinkedIn",
"save_action": "save_linkedin_app",
"callback_label": "Authorized redirect URL — paste into LinkedIn Auth tab",
"steps": [
mark_safe(
'Create a LinkedIn app in the '
'<a href="https://www.linkedin.com/developers/apps" target="_blank" '
'rel="noopener">LinkedIn Developer Portal</a>.'
),
"Products tab — request “Sign In with LinkedIn using OpenID Connect” "
"and “Share on LinkedIn”.",
"Auth tab — under Authorized redirect URLs, paste the callback URL "
"shown in the box below (exact match required).",
"Auth tab — copy Client ID and Primary Client Secret into the fields below, "
"then Save app credentials.",
"Click Connect with LinkedIn to authorize. Access tokens last ~2 months; "
"use Re-authorize when publishing fails with auth errors.",
],
"docs_url": "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow",
"fields": [],
},
}
@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 == "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":
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")
if action in {"save_linkedin_app", "save_meta_app", "save_instagram_app"}:
if action == "save_linkedin_app":
platform = Platform.LINKEDIN
connect_platform_name = "linkedin"
label = "LinkedIn"
else:
# Meta App ID/Secret shared by Facebook + Instagram (Facebook Login for Business).
platform = Platform.FACEBOOK
connect_return = (request.POST.get("connect_return") or "").strip()
if connect_return not in {Platform.FACEBOOK, Platform.INSTAGRAM}:
connect_return = Platform.FACEBOOK
connect_platform_name = connect_return
label = "Meta"
connect_q = f"?connect={connect_platform_name}"
client_id = (request.POST.get("client_id") or "").strip()
client_secret = (request.POST.get("client_secret") or "").strip()
if not client_id:
messages.error(request, "App ID / Client ID is required.")
return redirect(f"{request.path}{connect_q}")
app, _created = SocialAppCredentials.objects.get_or_create(platform=platform)
app.client_id = client_id
if client_secret:
try:
app.encrypted_client_secret = encrypt_tokens(client_secret)
except SocialCryptoError as exc:
messages.error(request, str(exc))
return redirect(f"{request.path}{connect_q}")
elif not app.encrypted_client_secret:
messages.error(
request,
"App Secret is required the first time you save credentials.",
)
return redirect(f"{request.path}{connect_q}")
app.save()
messages.success(
request,
f"{label} app credentials saved. You can Connect now.",
)
return redirect(f"{request.path}{connect_q}")
platform = (request.POST.get("platform") or "").strip()
if platform not in Platform.values:
messages.error(request, "Choose a platform.")
return redirect("social:account_list")
# Manual token paste remains as fallback for non-OAuth platforms only.
if platform in {Platform.FACEBOOK, Platform.INSTAGRAM, Platform.LINKEDIN}:
messages.error(
request,
"Use Connect with OAuth for this platform (Save app credentials first).",
)
return redirect(f"{request.path}?connect={platform}")
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()
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}
try:
encrypted = encrypt_tokens(json.dumps(token_blob))
except SocialCryptoError as exc:
messages.error(request, str(exc))
return redirect(f"{request.path}?connect={platform}")
account, created = SocialAccount.objects.update_or_create(
platform=platform,
external_id=external_id,
defaults={
"label": label,
"encrypted_tokens": encrypted,
"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")
linkedin_app = get_linkedin_app_credentials()
meta_app = get_meta_app_credentials()
linkedin_callback = ""
meta_callback = ""
try:
linkedin_callback = linkedin_redirect_uri()
except LinkedInOAuthError:
linkedin_callback = "(set PUBLIC_SITE_URL, then restart)"
try:
meta_callback = meta_redirect_uri()
except MetaOAuthError:
meta_callback = "(set PUBLIC_SITE_URL, then restart)"
meta_ready = meta_credentials_configured()
meta_client_id = meta_app.client_id if meta_app else ""
meta_secret_saved = bool(meta_app and meta_app.encrypted_client_secret)
oauth_ctx = {
"linkedin": {
"ready": linkedin_credentials_configured(),
"callback_url": linkedin_callback,
"client_id": (linkedin_app.client_id if linkedin_app else ""),
"secret_saved": bool(
linkedin_app and linkedin_app.encrypted_client_secret
),
"start_url_name": "social:linkedin_oauth_start",
},
"facebook": {
"ready": meta_ready,
"callback_url": meta_callback,
"client_id": meta_client_id,
"secret_saved": meta_secret_saved,
"start_url_name": "social:facebook_oauth_start",
},
"instagram": {
"ready": meta_ready,
"callback_url": meta_callback,
"client_id": meta_client_id,
"secret_saved": meta_secret_saved,
"start_url_name": "social:instagram_oauth_start",
},
}
oauth_state = oauth_ctx.get(connect_platform) or {}
return render(
request,
"social/account_list.html",
{
"accounts": accounts,
"connect_platform": connect_platform,
"connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
"platforms": Platform,
"oauth_ready": oauth_state.get("ready", False),
"oauth_callback_url": oauth_state.get("callback_url", ""),
"oauth_client_id": oauth_state.get("client_id", ""),
"oauth_secret_saved": oauth_state.get("secret_saved", False),
"oauth_start_url_name": oauth_state.get("start_url_name", ""),
},
)
@login_required
@require_GET
def linkedin_oauth_start(request):
"""Redirect browser to LinkedIn consent screen."""
if not linkedin_credentials_configured():
messages.error(
request,
"Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.",
)
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
state = secrets.token_urlsafe(24)
request.session["linkedin_oauth_state"] = state
try:
return redirect(linkedin_authorization_url(state=state))
except LinkedInOAuthError as exc:
messages.error(request, str(exc))
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
@login_required
@require_GET
def linkedin_oauth_callback(request):
"""Handle LinkedIn redirect: exchange code, store encrypted SocialAccount."""
error = (request.GET.get("error") or "").strip()
if error:
desc = (request.GET.get("error_description") or error).strip()
messages.error(request, f"LinkedIn authorization denied: {desc}")
return redirect("social:account_list")
state = (request.GET.get("state") or "").strip()
expected = request.session.pop("linkedin_oauth_state", None)
if not state or not expected or state != expected:
messages.error(request, "LinkedIn OAuth state mismatch — try Connect again.")
return redirect("social:account_list")
code = (request.GET.get("code") or "").strip()
if not code:
messages.error(request, "LinkedIn did not return an authorization code.")
return redirect("social:account_list")
try:
token_payload = linkedin_exchange_code(code)
profile = linkedin_fetch_member_profile(token_payload["access_token"])
author_urn = profile["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(
platform=Platform.LINKEDIN,
external_id=author_urn,
defaults={
"label": SocialAccount.resolve_label(existing, profile["label"]),
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True,
"owner": request.user,
},
)
except (LinkedInOAuthError, SocialCryptoError) as exc:
messages.error(request, str(exc))
return redirect("social:account_list")
except Exception:
messages.error(
request,
"Unexpected error saving LinkedIn account. Check app logs.",
)
raise
verb = "Connected" if created else "Re-authorized"
messages.success(
request,
f"{verb} LinkedIn · {account.label}. You can post from the composer.",
)
return redirect("social:account_list")
@login_required
@require_GET
def facebook_oauth_start(request):
"""Start Facebook Login for Business; save Facebook Page account on complete."""
return _meta_oauth_start(request, intent=Platform.FACEBOOK)
@login_required
@require_GET
def instagram_oauth_start(request):
"""Start Facebook Login for Business; save linked Instagram Business account."""
return _meta_oauth_start(request, intent=Platform.INSTAGRAM)
def _meta_oauth_start(request, *, intent: str):
connect_q = f"?connect={intent}"
if not meta_credentials_configured():
messages.error(
request,
"Save Meta App ID and App Secret on the Connect form first.",
)
return redirect(f"{reverse('social:account_list')}{connect_q}")
state = secrets.token_urlsafe(24)
request.session["meta_oauth_state"] = state
request.session["meta_oauth_intent"] = intent
try:
return redirect(meta_authorization_url(state=state))
except MetaOAuthError as exc:
messages.error(request, str(exc))
return redirect(f"{reverse('social:account_list')}{connect_q}")
@login_required
@require_GET
def meta_oauth_callback(request):
"""
Landing page after Facebook redirect.
Tokens arrive in the URL fragment (#) per Meta docs (response_type=token),
so JS reads them and POSTs to meta_oauth_complete.
"""
return render(request, "social/meta_oauth_callback.html")
@login_required
@require_POST
def meta_oauth_complete(request):
"""Persist Facebook Page and/or Instagram accounts from Facebook Login tokens."""
intent = request.session.pop("meta_oauth_intent", Platform.FACEBOOK)
connect_q = f"?connect={intent}"
error = (request.POST.get("error") or "").strip()
if error:
desc = (request.POST.get("error_description") or error).strip()
messages.error(request, f"Facebook authorization denied: {desc}")
return redirect(f"{reverse('social:account_list')}{connect_q}")
state = (request.POST.get("state") or "").strip()
expected = request.session.pop("meta_oauth_state", None)
if not state or not expected or state != expected:
messages.error(request, "Meta OAuth state mismatch — try Connect again.")
return redirect(f"{reverse('social:account_list')}{connect_q}")
user_token = (
(request.POST.get("long_lived_token") or "").strip()
or (request.POST.get("access_token") or "").strip()
)
if not user_token:
messages.error(request, "Facebook did not return an access token.")
return redirect(f"{reverse('social:account_list')}{connect_q}")
try:
pages = fetch_pages(user_token)
connected = []
if intent == Platform.INSTAGRAM:
ig_pages = [p for p in pages if (p.get("instagram_business_account") or {}).get("id")]
if not ig_pages:
raise MetaOAuthError(
"No Instagram Professional account linked to your Facebook Pages. "
"Link IG in Meta Business Suite, then Connect again."
)
for page in ig_pages:
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(
platform=Platform.INSTAGRAM,
external_id=blob["ig_user_id"],
defaults={
"label": SocialAccount.resolve_label(existing, blob["label"]),
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True,
"owner": request.user,
},
)
connected.append(("Connected" if created else "Re-authorized", account))
else:
for page in pages:
blob = facebook_token_blob(page, user_token=user_token)
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(
platform=Platform.FACEBOOK,
external_id=str(page["id"]),
defaults={
"label": SocialAccount.resolve_label(existing, incoming_label),
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
"is_active": True,
"owner": request.user,
},
)
connected.append(("Connected" if created else "Re-authorized", account))
except (MetaOAuthError, SocialCryptoError) as exc:
messages.error(request, str(exc))
return redirect(f"{reverse('social:account_list')}{connect_q}")
except Exception:
messages.error(
request,
"Unexpected error saving Meta account. Check app logs.",
)
raise
for verb, account in connected:
messages.success(
request,
f"{verb} {account.get_platform_display()} · {account.label}.",
)
return redirect("social:account_list")
@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": "now",
"scheduled_for": "",
"account_ids": [],
"media_json": "[]",
}
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 "now").strip()
form["scheduled_for"] = request.POST.get("scheduled_for") or ""
form["account_ids"] = request.POST.getlist("account_ids")
form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]"
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"]:
try:
draft = generate_social_post(form["prompt"])
form["body"] = draft
except OllamaError as exc:
error = str(exc)
elif action in {"save", "publish", "send_now", "schedule"}:
media_items: list = []
try:
media_items = parse_media_json(form["media_json"])
except ValueError as exc:
error = str(exc)
if not error and not form["body"] and not media_items:
error = "Add a caption and/or attach an image or video."
elif not error and 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 Send 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."
elif (
form["publish_mode"] != "draft"
and selected.filter(platform=Platform.INSTAGRAM).exists()
and not media_items
):
error = (
"Instagram requires an image or video. "
"Attach media before publishing to Instagram."
)
else:
post = SocialPost.objects.create(
body=form["body"],
media=media_items,
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 sent — 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"],
"media_upload_url": reverse("social:media_upload"),
},
)
@login_required
@require_POST
def media_upload(request):
"""Upload image/video for social compose; store bytes in StoredFile."""
upload = (
request.FILES.get("media")
or request.FILES.get("file")
or request.FILES.get("image")
or request.FILES.get("video")
)
if not upload:
return JsonResponse({"error": "No file uploaded."}, status=400)
content_type = (getattr(upload, "content_type", None) or "").lower()
if content_type in ALLOWED_IMAGE_TYPES:
kind = StoredFile.Kind.SOCIAL_IMAGE
max_bytes = MAX_IMAGE_BYTES
label = "Image"
elif content_type in ALLOWED_VIDEO_TYPES:
kind = StoredFile.Kind.SOCIAL_VIDEO
max_bytes = MAX_VIDEO_BYTES
label = "Video"
else:
return JsonResponse(
{
"error": "Use JPEG/PNG/GIF/WebP images or MP4/MOV/WebM video.",
},
status=400,
)
size = int(getattr(upload, "size", 0) or 0)
if size and size > max_bytes:
return JsonResponse(
{"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."},
status=400,
)
data = upload.read()
if len(data) > max_bytes:
return JsonResponse(
{"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."},
status=400,
)
original = (getattr(upload, "name", None) or "media")[:255]
stored = StoredFile.objects.create(
kind=kind,
filename=original,
content_type=content_type,
size=len(data),
data=data,
uploaded_by=request.user if request.user.is_authenticated else None,
)
item = media_item_from_stored(stored)
return JsonResponse(item)
@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})