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 Meta for Developers, use a Business-type app and add ' "Facebook Login for Business 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 Facebook Login for Business ' "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 ' '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 == "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})