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 Meta developer registration '
"(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 '
'LinkedIn Developer Portal.'
),
"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})