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 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: app.encrypted_client_secret = encrypt_tokens(client_secret) 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 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") 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 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})