Placeholder Fernet values like replace-with-fernet-key now raise a clear SocialCryptoError in the portal, and prod env examples no longer ship a fake key.
428 lines
17 KiB
Python
428 lines
17 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 social.crypto import SocialCryptoError, encrypt_tokens
|
|
from social.models import (
|
|
Platform,
|
|
SocialAccount,
|
|
SocialAppCredentials,
|
|
SocialPost,
|
|
SocialPostTarget,
|
|
)
|
|
from social.oauth_linkedin import (
|
|
LinkedInOAuthError,
|
|
authorization_url,
|
|
credentials_configured,
|
|
exchange_code,
|
|
fetch_member_profile,
|
|
get_app_credentials,
|
|
redirect_uri as linkedin_redirect_uri,
|
|
token_blob_from_oauth,
|
|
)
|
|
from social.ollama import OllamaError, generate_social_post
|
|
from social.tasks import publish_social_post
|
|
|
|
CONNECT_INSTRUCTIONS = {
|
|
Platform.FACEBOOK: {
|
|
"title": "Connect Facebook Page",
|
|
"steps": [
|
|
mark_safe(
|
|
'Go to <a href="https://developers.facebook.com/async/registration" '
|
|
'target="_blank" rel="noopener">Meta developer registration</a> '
|
|
"(opens in a new tab) and fill out the information."
|
|
),
|
|
"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",
|
|
"oauth": True,
|
|
"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 == "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 == "save_linkedin_app":
|
|
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, "Client ID is required.")
|
|
return redirect(f"{request.path}?connect=linkedin")
|
|
app, _created = SocialAppCredentials.objects.get_or_create(
|
|
platform=Platform.LINKEDIN
|
|
)
|
|
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=linkedin")
|
|
elif not app.encrypted_client_secret:
|
|
messages.error(
|
|
request,
|
|
"Client Secret is required the first time you save LinkedIn app credentials.",
|
|
)
|
|
return redirect(f"{request.path}?connect=linkedin")
|
|
app.save()
|
|
messages.success(
|
|
request,
|
|
"LinkedIn app credentials saved. You can Connect with LinkedIn now.",
|
|
)
|
|
return redirect(f"{request.path}?connect=linkedin")
|
|
|
|
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
|
|
|
|
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_app_credentials()
|
|
linkedin_oauth_ready = credentials_configured()
|
|
linkedin_callback = ""
|
|
try:
|
|
linkedin_callback = linkedin_redirect_uri()
|
|
except LinkedInOAuthError:
|
|
linkedin_callback = "(set PUBLIC_SITE_URL, then restart)"
|
|
|
|
return render(
|
|
request,
|
|
"social/account_list.html",
|
|
{
|
|
"accounts": accounts,
|
|
"connect_platform": connect_platform,
|
|
"connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
|
|
"platforms": Platform,
|
|
"linkedin_oauth_ready": linkedin_oauth_ready,
|
|
"linkedin_callback_url": linkedin_callback,
|
|
"linkedin_client_id": (linkedin_app.client_id if linkedin_app else ""),
|
|
"linkedin_secret_saved": bool(
|
|
linkedin_app and linkedin_app.encrypted_client_secret
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_GET
|
|
def linkedin_oauth_start(request):
|
|
"""Redirect browser to LinkedIn consent screen."""
|
|
if not 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(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 = exchange_code(code)
|
|
profile = fetch_member_profile(token_payload["access_token"])
|
|
author_urn = profile["author_urn"]
|
|
blob = token_blob_from_oauth(token_payload, author_urn=author_urn)
|
|
account, created = SocialAccount.objects.update_or_create(
|
|
platform=Platform.LINKEDIN,
|
|
external_id=author_urn,
|
|
defaults={
|
|
"label": 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_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})
|