Add Meta Facebook Login OAuth and social media attach/publish.
Deploy Beta / unit-tests (push) Successful in 14s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m32s

Connect Facebook and Instagram via shared portal Meta App credentials (Facebook Login for Business), and let compose attach DB-stored images/videos with live preview and platform publish.
This commit is contained in:
2026-08-11 14:45:23 -05:00
parent c8dacd0d94
commit 30ce58e610
13 changed files with 1412 additions and 142 deletions
@@ -0,0 +1,26 @@
# Generated manually for StoredFile social media kinds
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("messaging", "0005_message_opened_clicked_statuses"),
]
operations = [
migrations.AlterField(
model_name="storedfile",
name="kind",
field=models.CharField(
choices=[
("campaign_image", "Campaign image"),
("social_image", "Social image"),
("social_video", "Social video"),
],
default="campaign_image",
max_length=32,
),
),
]
+2
View File
@@ -122,6 +122,8 @@ class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel):
class Kind(models.TextChoices):
CAMPAIGN_IMAGE = "campaign_image", "Campaign image"
SOCIAL_IMAGE = "social_image", "Social image"
SOCIAL_VIDEO = "social_video", "Social video"
kind = models.CharField(
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
+128 -13
View File
@@ -1,11 +1,15 @@
"""LinkedIn API connector."""
from __future__ import annotations
import json
import logging
import requests
from messaging.models import StoredFile
from social.crypto import decrypt_tokens
from social.media import split_media
logger = logging.getLogger(__name__)
@@ -20,29 +24,140 @@ class LinkedInConnector:
if not access_token or not author_urn:
raise RuntimeError("LinkedIn account missing access_token/author_urn")
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post.body},
"shareMediaCategory": "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
media = post.media if isinstance(post.media, list) else []
images, videos = split_media(media)
headers = {
"Authorization": f"Bearer {access_token}",
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
}
if videos:
raise RuntimeError(
"LinkedIn video publish is not wired yet — attach images or post text-only."
)
if images:
# Share first image (multi-image LinkedIn needs multi-image recipe).
asset_urn = self._upload_image(
access_token=access_token,
author_urn=author_urn,
image=images[0],
)
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post.body or ""},
"shareMediaCategory": "IMAGE",
"media": [
{
"status": "READY",
"description": {"text": post.body or ""},
"media": asset_urn,
"title": {"text": images[0].get("filename") or "Image"},
}
],
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
},
}
else:
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post.body or ""},
"shareMediaCategory": "NONE",
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
},
}
response = requests.post(
"https://api.linkedin.com/v2/ugcPosts",
json=payload,
headers=headers,
timeout=60,
)
if response.status_code >= 400:
logger.warning(
"LinkedIn publish failed: %s %s",
response.status_code,
response.text[:500],
)
raise RuntimeError(
f"LinkedIn publish failed ({response.status_code}): {response.text[:400]}"
)
return str(response.headers.get("x-restli-id") or response.json().get("id") or "")
def _upload_image(
self, *, access_token: str, author_urn: str, image: dict
) -> str:
file_id = image.get("id")
if not file_id:
raise RuntimeError("LinkedIn image missing StoredFile id")
stored = StoredFile.objects.get(pk=file_id)
binary = bytes(stored.data)
register = requests.post(
"https://api.linkedin.com/v2/assets?action=registerUpload",
json={
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
"owner": author_urn,
"serviceRelationships": [
{
"relationshipType": "OWNER",
"identifier": "urn:li:userGeneratedContent",
}
],
}
},
headers={
"Authorization": f"Bearer {access_token}",
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
},
timeout=30,
)
response.raise_for_status()
return str(response.headers.get("x-restli-id") or response.json().get("id") or "")
if register.status_code >= 400:
raise RuntimeError(
f"LinkedIn image register failed ({register.status_code}): "
f"{register.text[:400]}"
)
value = register.json().get("value") or {}
asset = value.get("asset")
upload_mech = (
(value.get("uploadMechanism") or {}).get(
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
)
or {}
)
upload_url = upload_mech.get("uploadUrl")
upload_headers = upload_mech.get("headers") or {}
if not asset or not upload_url:
raise RuntimeError("LinkedIn registerUpload missing asset/uploadUrl")
put_headers = {"Authorization": f"Bearer {access_token}"}
put_headers.update(upload_headers)
put = requests.put(
upload_url,
data=binary,
headers=put_headers,
timeout=120,
)
if put.status_code >= 400:
raise RuntimeError(
f"LinkedIn image upload failed ({put.status_code}): {put.text[:400]}"
)
return str(asset)
def refresh_token(self, account) -> None:
logger.info("LinkedIn token refresh stub for %s", account.pk)
+222 -15
View File
@@ -1,12 +1,15 @@
"""Meta Graph API connector (Facebook Page + Instagram Business)."""
"""Meta Graph API connector (Facebook Page + Instagram via Facebook Login)."""
from __future__ import annotations
import json
import logging
import time
import requests
from django.conf import settings
from social.crypto import decrypt_tokens
import json
from social.media import split_media
logger = logging.getLogger(__name__)
@@ -18,26 +21,230 @@ class MetaConnector:
def publish(self, post, target) -> str:
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
access_token = tokens.get("access_token")
page_id = target.account.external_id or tokens.get("page_id")
if not access_token or not page_id:
raise RuntimeError("Meta account missing access_token/page_id")
if not access_token:
raise RuntimeError("Meta/Instagram account missing access_token")
media = post.media if isinstance(post.media, list) else []
images, videos = split_media(media)
if target.platform == "instagram":
# IG content publishing is a multi-step Graph flow; stub container create.
raise NotImplementedError(
"Instagram publish requires IG business account wiring — complete OAuth first"
ig_user_id = target.account.external_id or tokens.get("ig_user_id")
if not ig_user_id:
raise RuntimeError("Instagram account missing ig_user_id")
return self._publish_instagram(
ig_user_id=ig_user_id,
access_token=access_token,
caption=post.body or "",
images=images,
videos=videos,
)
page_id = target.account.external_id or tokens.get("page_id")
if not page_id:
raise RuntimeError("Meta account missing page_id")
return self._publish_facebook(
page_id=page_id,
access_token=access_token,
message=post.body or "",
images=images,
videos=videos,
)
def _publish_facebook(
self,
*,
page_id: str,
access_token: str,
message: str,
images: list[dict],
videos: list[dict],
) -> str:
if videos:
video = videos[0]
response = requests.post(
f"{self.GRAPH}/{page_id}/videos",
data={
"file_url": video["url"],
"description": message,
"access_token": access_token,
},
timeout=120,
)
self._raise_graph(response, "Facebook video publish")
return str(response.json().get("id") or "")
if len(images) == 1:
response = requests.post(
f"{self.GRAPH}/{page_id}/photos",
data={
"url": images[0]["url"],
"caption": message,
"access_token": access_token,
},
timeout=60,
)
self._raise_graph(response, "Facebook photo publish")
return str(response.json().get("id") or response.json().get("post_id") or "")
if len(images) > 1:
attached = []
for image in images:
resp = requests.post(
f"{self.GRAPH}/{page_id}/photos",
data={
"url": image["url"],
"published": "false",
"access_token": access_token,
},
timeout=60,
)
self._raise_graph(resp, "Facebook multi-photo upload")
photo_id = resp.json().get("id")
if photo_id:
attached.append({"media_fbid": photo_id})
data = {
"message": message,
"access_token": access_token,
}
for idx, item in enumerate(attached):
data[f"attached_media[{idx}]"] = json.dumps(item)
response = requests.post(
f"{self.GRAPH}/{page_id}/feed",
data=data,
timeout=60,
)
self._raise_graph(response, "Facebook multi-photo feed")
return str(response.json().get("id") or "")
response = requests.post(
f"{self.GRAPH}/{page_id}/feed",
data={"message": post.body, "access_token": access_token},
data={"message": message, "access_token": access_token},
timeout=30,
)
response.raise_for_status()
self._raise_graph(response, "Facebook text feed")
return str(response.json().get("id") or "")
def refresh_token(self, account) -> None:
# Long-lived token exchange when META_APP_ID/SECRET are set.
if not settings.META_APP_ID or not settings.META_APP_SECRET:
def _publish_instagram(
self,
*,
ig_user_id: str,
access_token: str,
caption: str,
images: list[dict],
videos: list[dict],
) -> str:
if not images and not videos:
raise RuntimeError(
"Instagram requires an image or video attachment (caption-only not allowed)."
)
if videos:
creation_id = self._ig_create_container(
ig_user_id,
access_token,
{
"media_type": "VIDEO",
"video_url": videos[0]["url"],
"caption": caption,
},
)
self._ig_wait_container(creation_id, access_token)
return self._ig_publish(ig_user_id, access_token, creation_id)
if len(images) == 1:
creation_id = self._ig_create_container(
ig_user_id,
access_token,
{"image_url": images[0]["url"], "caption": caption},
)
return self._ig_publish(ig_user_id, access_token, creation_id)
# Carousel: children first, then parent container.
children = []
for image in images[:10]:
child_id = self._ig_create_container(
ig_user_id,
access_token,
{"image_url": image["url"], "is_carousel_item": "true"},
)
children.append(child_id)
creation_id = self._ig_create_container(
ig_user_id,
access_token,
{
"media_type": "CAROUSEL",
"children": ",".join(children),
"caption": caption,
},
)
return self._ig_publish(ig_user_id, access_token, creation_id)
def _ig_create_container(
self, ig_user_id: str, access_token: str, fields: dict
) -> str:
data = {**fields, "access_token": access_token}
response = requests.post(
f"{self.GRAPH}/{ig_user_id}/media",
data=data,
timeout=60,
)
self._raise_graph(response, "Instagram media container")
creation_id = response.json().get("id")
if not creation_id:
raise RuntimeError("Instagram media container response missing id")
return str(creation_id)
def _ig_wait_container(
self, creation_id: str, access_token: str, *, attempts: int = 20
) -> None:
"""Poll video container until FINISHED (or fail)."""
for _ in range(attempts):
response = requests.get(
f"{self.GRAPH}/{creation_id}",
params={
"fields": "status_code,status",
"access_token": access_token,
},
timeout=30,
)
self._raise_graph(response, "Instagram container status")
status = (response.json().get("status_code") or "").upper()
if status == "FINISHED":
return
if status in {"ERROR", "EXPIRED"}:
raise RuntimeError(
f"Instagram video processing failed ({status}): "
f"{response.json().get('status') or ''}"
)
time.sleep(3)
raise RuntimeError("Instagram video still processing — try again shortly.")
def _ig_publish(
self, ig_user_id: str, access_token: str, creation_id: str
) -> str:
response = requests.post(
f"{self.GRAPH}/{ig_user_id}/media_publish",
data={"creation_id": creation_id, "access_token": access_token},
timeout=60,
)
self._raise_graph(response, "Instagram media_publish")
return str(response.json().get("id") or "")
@staticmethod
def _raise_graph(response: requests.Response, label: str) -> None:
if response.status_code < 400:
return
logger.info("Meta token refresh not yet implemented for account %s", account.pk)
detail = response.text[:500]
try:
err = response.json().get("error") or {}
detail = err.get("message") or detail
except Exception: # noqa: BLE001
pass
logger.warning("%s failed: %s %s", label, response.status_code, detail)
raise RuntimeError(f"{label} failed ({response.status_code}): {detail}")
def refresh_token(self, account) -> None:
logger.info(
"Meta/Instagram token refresh not yet implemented for account %s",
account.pk,
)
+91
View File
@@ -0,0 +1,91 @@
"""Helpers for social post media (StoredFile-backed)."""
from __future__ import annotations
import json
from typing import Any
from django.conf import settings
from django.urls import reverse
from messaging.models import StoredFile
ALLOWED_IMAGE_TYPES = frozenset(
{"image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"}
)
ALLOWED_VIDEO_TYPES = frozenset(
{"video/mp4", "video/quicktime", "video/webm"}
)
MAX_IMAGE_BYTES = 8 * 1024 * 1024 # 8 MB
MAX_VIDEO_BYTES = 100 * 1024 * 1024 # 100 MB
MAX_IMAGES = 10
def public_file_url(file_id: str) -> str:
"""Absolute URL Meta/LinkedIn can fetch (UUID = capability token)."""
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
path = reverse("messaging:stored_file", kwargs={"pk": file_id})
if not base:
return path
return f"{base}{path}"
def media_item_from_stored(stored: StoredFile) -> dict[str, Any]:
kind = stored.kind
media_type = "video" if kind == StoredFile.Kind.SOCIAL_VIDEO else "image"
return {
"id": str(stored.pk),
"type": media_type,
"url": public_file_url(str(stored.pk)),
"content_type": stored.content_type,
"filename": stored.filename or "",
"size": stored.size,
}
def parse_media_json(raw: str) -> list[dict[str, Any]]:
"""Parse composer hidden media JSON into a cleaned list."""
raw = (raw or "").strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("Invalid media payload.") from exc
if not isinstance(data, list):
raise ValueError("Media payload must be a list.")
items: list[dict[str, Any]] = []
for entry in data:
if not isinstance(entry, dict):
continue
file_id = str(entry.get("id") or "").strip()
if not file_id:
continue
try:
stored = StoredFile.objects.get(pk=file_id)
except (StoredFile.DoesNotExist, ValueError) as exc:
raise ValueError(f"Unknown media file: {file_id}") from exc
if stored.kind not in {
StoredFile.Kind.SOCIAL_IMAGE,
StoredFile.Kind.SOCIAL_VIDEO,
StoredFile.Kind.CAMPAIGN_IMAGE,
}:
raise ValueError("File is not a social media attachment.")
items.append(media_item_from_stored(stored))
videos = [m for m in items if m["type"] == "video"]
images = [m for m in items if m["type"] == "image"]
if len(videos) > 1:
raise ValueError("Attach at most one video per post.")
if videos and images:
raise ValueError("Use either images or one video — not both.")
if len(images) > MAX_IMAGES:
raise ValueError(f"Attach at most {MAX_IMAGES} images.")
return items
def split_media(media: list[dict[str, Any]]) -> tuple[list[dict], list[dict]]:
images = [m for m in media if m.get("type") == "image"]
videos = [m for m in media if m.get("type") == "video"]
return images, videos
+185
View File
@@ -0,0 +1,185 @@
"""Facebook Login for Business (Instagram API with Facebook Login).
Docs:
https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-facebook-login/business-login-for-instagram
Uses response_type=token (tokens arrive in the URL fragment). A thin callback
page reads the fragment and POSTs tokens to the server.
"""
from __future__ import annotations
import json
import logging
from datetime import timedelta
from urllib.parse import urlencode
import requests
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from social.crypto import decrypt_tokens
from social.models import Platform, SocialAppCredentials
logger = logging.getLogger(__name__)
GRAPH_VERSION = "v21.0"
GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"
AUTHORIZE_URL = f"https://www.facebook.com/{GRAPH_VERSION}/dialog/oauth"
# Permissions for Page posting + Instagram content publish via Facebook Login.
DEFAULT_SCOPES = (
"instagram_basic",
"instagram_content_publish",
"pages_show_list",
"pages_read_engagement",
"pages_manage_posts",
)
# Triggers Instagram Professional onboarding inside Facebook Login for Business.
IG_ONBOARDING_EXTRAS = {"setup": {"channel": "IG_API_ONBOARDING"}}
class MetaOAuthError(RuntimeError):
"""Raised when Facebook Login for Business or Page/IG lookup fails."""
def get_app_credentials() -> SocialAppCredentials | None:
"""Meta App ID/Secret — stored on the facebook credentials row (shared)."""
fb = SocialAppCredentials.objects.filter(platform=Platform.FACEBOOK).first()
if fb and fb.is_configured:
return fb
# Back-compat if credentials were saved from the Instagram form earlier.
return SocialAppCredentials.objects.filter(platform=Platform.INSTAGRAM).first()
def load_client_credentials() -> tuple[str, str]:
app = get_app_credentials()
if not app or not app.client_id or not app.encrypted_client_secret:
raise MetaOAuthError(
"Meta app credentials are not saved yet. "
"Enter Meta App ID and App Secret on the Connect Facebook or Instagram form."
)
try:
secret = decrypt_tokens(app.encrypted_client_secret)
except ValueError as exc:
raise MetaOAuthError(
"Could not decrypt Meta app secret. Re-enter the App Secret."
) from exc
if not secret:
raise MetaOAuthError("Meta app secret is empty — save it again.")
return app.client_id.strip(), secret.strip()
def credentials_configured() -> bool:
app = get_app_credentials()
return bool(app and app.is_configured)
def redirect_uri() -> str:
"""Must match Valid OAuth Redirect URIs (Facebook Login for Business settings)."""
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if not base:
raise MetaOAuthError(
"PUBLIC_SITE_URL is not set; cannot build Meta OAuth redirect URI."
)
return f"{base}{reverse('social:meta_oauth_callback')}"
def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str:
client_id, _secret = load_client_credentials()
params = {
"client_id": client_id,
"display": "page",
"extras": json.dumps(IG_ONBOARDING_EXTRAS, separators=(",", ":")),
"redirect_uri": redirect_uri(),
"response_type": "token",
"scope": ",".join(scopes),
"state": state,
}
return f"{AUTHORIZE_URL}?{urlencode(params)}"
def fetch_pages(user_access_token: str) -> list[dict]:
"""
GET /me/accounts — Pages the user can manage, with linked IG business accounts.
Each item: id, name, access_token (page), instagram_business_account (optional).
"""
response = requests.get(
f"{GRAPH}/me/accounts",
params={
"fields": "id,name,access_token,instagram_business_account{id,username,name}",
"access_token": user_access_token,
},
timeout=30,
)
if response.status_code >= 400:
logger.warning(
"Meta /me/accounts failed: %s %s",
response.status_code,
response.text[:500],
)
raise MetaOAuthError(
f"Could not list Facebook Pages ({response.status_code}). "
"Confirm pages_show_list was granted and the user manages a Page."
)
data = response.json().get("data") or []
if not data:
raise MetaOAuthError(
"No Facebook Pages found for this user. Create a Page and link a "
"Professional Instagram account, then try again."
)
return data
def facebook_token_blob(page: dict, *, user_token: str = "") -> dict:
page_token = page.get("access_token") or ""
if not page_token:
raise MetaOAuthError(f"Page {page.get('id')} missing access_token.")
blob: dict = {
"access_token": page_token,
"page_id": str(page["id"]),
"user_access_token": user_token,
"auth_type": "facebook_login_for_business",
}
ig = page.get("instagram_business_account") or {}
if ig.get("id"):
blob["ig_user_id"] = str(ig["id"])
return blob
def instagram_token_blob(page: dict, *, user_token: str = "") -> dict:
ig = page.get("instagram_business_account") or {}
ig_id = str(ig.get("id") or "").strip()
if not ig_id:
raise MetaOAuthError(
f"Page “{page.get('name') or page.get('id')}” has no linked "
"Instagram Professional account."
)
page_token = page.get("access_token") or ""
if not page_token:
raise MetaOAuthError("Page access token missing — required for Instagram Graph API.")
username = (ig.get("username") or "").strip()
name = (ig.get("name") or "").strip()
label = f"@{username}" if username else (name or "Instagram account")
return {
"access_token": page_token,
"page_id": str(page["id"]),
"ig_user_id": ig_id,
"username": username,
"label": label,
"user_access_token": user_token,
"auth_type": "facebook_login_for_business",
}
def expires_at_from_fragment(expires_in: str | int | None) -> str | None:
try:
seconds = int(expires_in or 0)
except (TypeError, ValueError):
return None
if seconds <= 0:
return None
return (timezone.now() + timedelta(seconds=seconds)).isoformat()
+29 -24
View File
@@ -11,15 +11,15 @@
<span class="platform-icon meta">f</span>
<div>
<strong>Facebook Page</strong>
<p>Connect a Page you manage with a Page access token.</p>
<p>Connect via Facebook Login for Business. Shared Meta App ID with Instagram.</p>
</div>
</a>
<a class="connect-card{% if connect_platform == 'instagram' %} is-active{% endif %}"
href="{% url 'social:account_list' %}?connect=instagram">
<span class="platform-icon ig">IG</span>
<div>
<strong>Instagram Business</strong>
<p>Requires a Facebook Page linked to an IG business account.</p>
<strong>Instagram</strong>
<p>Facebook Login for Business — Professional account linked to a Page.</p>
</div>
</a>
<a class="connect-card{% if connect_platform == 'linkedin' %} is-active{% endif %}"
@@ -44,17 +44,17 @@
{% endif %}
</ol>
{% if connect_meta.oauth and connect_platform == 'linkedin' %}
{% if connect_meta.oauth %}
<div class="field" style="margin:12px 0 16px;padding:12px;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc">
<label for="linkedin-callback-url" style="display:block;margin-bottom:6px">
Authorized redirect URL — paste into LinkedIn Auth tab
<label for="oauth-callback-url" style="display:block;margin-bottom:6px">
{{ connect_meta.callback_label }}
</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:stretch">
<input id="linkedin-callback-url" type="text" readonly
value="{{ linkedin_callback_url }}"
<input id="oauth-callback-url" type="text" readonly
value="{{ oauth_callback_url }}"
onclick="this.select()"
style="flex:1;min-width:220px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px">
<button type="button" class="btn btn-ghost btn-sm" id="copy-linkedin-callback">Copy</button>
<button type="button" class="btn btn-ghost btn-sm" id="copy-oauth-callback">Copy</button>
</div>
<p class="muted" style="margin:8px 0 0;font-size:13px">
Must match exactly (including http/https and trailing slash).
@@ -63,30 +63,31 @@
<form method="post" class="form-grid" style="margin-bottom:16px">
{% csrf_token %}
<input type="hidden" name="action" value="save_linkedin_app">
<input type="hidden" name="action" value="{{ connect_meta.save_action }}">
<input type="hidden" name="connect_return" value="{{ connect_platform }}">
<div class="field">
<label for="id_client_id">Client ID</label>
<label for="id_client_id">{{ connect_meta.client_id_label }}</label>
<input id="id_client_id" name="client_id" required
value="{{ linkedin_client_id }}"
placeholder="From LinkedIn Auth tab"
value="{{ oauth_client_id }}"
placeholder="From the developer portal"
autocomplete="off">
</div>
<div class="field">
<label for="id_client_secret">Primary Client Secret</label>
<label for="id_client_secret">{{ connect_meta.client_secret_label }}</label>
<input id="id_client_secret" name="client_secret" type="password"
{% if not linkedin_secret_saved %}required{% endif %}
placeholder="{% if linkedin_secret_saved %}Leave blank to keep saved secret{% else %}From LinkedIn Auth tab{% endif %}"
{% if not oauth_secret_saved %}required{% endif %}
placeholder="{% if oauth_secret_saved %}Leave blank to keep saved secret{% else %}From the developer portal{% endif %}"
autocomplete="new-password">
{% if linkedin_secret_saved %}
{% if oauth_secret_saved %}
<p class="muted" style="margin:6px 0 0;font-size:13px">Secret already saved (encrypted). Enter a new value only to replace it.</p>
{% endif %}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-ghost" type="submit">Save app credentials</button>
{% if linkedin_oauth_ready %}
<a class="btn btn-primary" href="{% url 'social:linkedin_oauth_start' %}">Connect with LinkedIn</a>
{% if oauth_ready and oauth_start_url_name %}
<a class="btn btn-primary" href="{% url oauth_start_url_name %}">{{ connect_meta.connect_label }}</a>
{% else %}
<button class="btn btn-primary" type="button" disabled title="Save Client ID and Client Secret first">Connect with LinkedIn</button>
<button class="btn btn-primary" type="button" disabled title="Save App ID and App Secret first">{{ connect_meta.connect_label }}</button>
{% endif %}
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
</div>
@@ -155,6 +156,10 @@
<div style="display:flex;gap:8px;flex-wrap:wrap">
{% if account.platform == 'linkedin' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:linkedin_oauth_start' %}">Re-authorize</a>
{% elif account.platform == 'instagram' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:instagram_oauth_start' %}">Re-authorize</a>
{% elif account.platform == 'facebook' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:facebook_oauth_start' %}">Re-authorize</a>
{% else %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:account_list' %}?connect={{ account.platform }}">Update tokens</a>
{% endif %}
@@ -189,7 +194,7 @@
<div class="panel-h"><h2>When to re-auth</h2></div>
<div class="panel-b">
<ul class="plain-list">
<li>LinkedIn access tokens expire on a short cycle</li>
<li>LinkedIn / Instagram access tokens expire on a short cycle</li>
<li>Meta password changes or app review updates</li>
<li>Publishing fails with an auth error</li>
</ul>
@@ -209,11 +214,11 @@
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
</script>
{% endif %}
{% if connect_platform == 'linkedin' %}
{% if connect_meta and connect_meta.oauth %}
<script>
(function () {
const btn = document.getElementById('copy-linkedin-callback');
const input = document.getElementById('linkedin-callback-url');
const btn = document.getElementById('copy-oauth-callback');
const input = document.getElementById('oauth-callback-url');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const value = input.value || '';
+272 -20
View File
@@ -1,6 +1,79 @@
{% extends "portal_base.html" %}
{% block title %}Compose · Social{% endblock %}
{% block topbar_title %}Compose & preview{% endblock %}
{% block extra_head %}
<style>
.media-drop {
border: 1px dashed var(--monica-border, #cbd5e1);
border-radius: 8px;
padding: 14px;
background: #f8fafc;
}
.media-thumbs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.media-thumb {
position: relative;
width: 96px;
height: 96px;
border-radius: 8px;
overflow: hidden;
background: #e2e8f0;
border: 1px solid var(--monica-border, #cbd5e1);
}
.media-thumb img,
.media-thumb video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-thumb .remove-media {
position: absolute;
top: 4px;
right: 4px;
border: 0;
border-radius: 999px;
width: 22px;
height: 22px;
line-height: 22px;
padding: 0;
background: rgba(15, 23, 42, 0.75);
color: #fff;
cursor: pointer;
font-size: 14px;
}
.media-thumb .media-label {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.65);
color: #fff;
font-size: 10px;
padding: 2px 4px;
text-align: center;
}
#preview-media {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
#preview-media img,
#preview-media video {
max-width: 100%;
max-height: 280px;
border-radius: 8px;
background: #0f172a;
}
#preview-media img { object-fit: contain; }
#media-upload-status { margin-top: 8px; font-size: 13px; }
</style>
{% endblock %}
{% block portal_content %}
{% if not accounts %}
<div class="auth-alert">
@@ -21,18 +94,32 @@
{% endif %}
<form method="post" class="form-grid" id="social-compose">
{% csrf_token %}
<input type="hidden" name="media_json" id="id_media_json" value="{{ form.media_json }}">
<div class="field">
<label for="id_body">Caption</label>
<textarea id="id_body" name="body" style="min-height:140px"
placeholder="Open house this Saturday…"
oninput="syncPreview()">{{ form.body }}</textarea>
placeholder="Open house this Saturday…">{{ form.body }}</textarea>
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
</div>
<div class="field">
<label for="id_media">Media</label>
<div class="media-drop">
<input id="id_media" type="file" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm" multiple>
<div class="hint" style="margin-top:8px">
Images (JPEG/PNG/GIF/WebP, ≤8&nbsp;MB) or one video (MP4/MOV/WebM, ≤100&nbsp;MB).
Instagram requires media. Dont mix images + video on one post.
</div>
<div id="media-upload-status" class="muted" hidden></div>
<div class="media-thumbs" id="media-thumbs"></div>
</div>
</div>
<div class="field">
<label>Accounts</label>
{% for account in accounts %}
<label class="check-row">
<input type="checkbox" name="account_ids" value="{{ account.pk }}"
class="account-check"
data-platform="{{ account.platform }}"
{% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}>
{{ account.get_platform_display }} · {{ account.label }}
</label>
@@ -70,6 +157,7 @@
<input type="hidden" name="action" value="generate">
<input type="hidden" name="publish_mode" value="{{ form.publish_mode }}">
<input type="hidden" name="scheduled_for" value="{{ form.scheduled_for }}">
<input type="hidden" name="media_json" value="{{ form.media_json }}">
{% for id in form.account_ids %}
<input type="hidden" name="account_ids" value="{{ id }}">
{% endfor %}
@@ -89,8 +177,11 @@
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b">
<div class="preview-pane">
<div class="muted" id="preview-empty">Preview updates as you type.</div>
<div id="preview-content" hidden style="white-space:pre-wrap"></div>
<div class="muted" id="preview-empty">Preview updates as you type or attach media.</div>
<div id="preview-body" hidden>
<div id="preview-media"></div>
<div id="preview-content" style="white-space:pre-wrap"></div>
</div>
</div>
</div>
</div>
@@ -102,29 +193,190 @@
{% block extra_js %}
<script>
(function () {
function syncPreview() {
var body = (document.getElementById('id_body') || {}).value || '';
var count = document.getElementById('char-count');
if (count) count.textContent = String(body.length);
var empty = document.getElementById('preview-empty');
var content = document.getElementById('preview-content');
if (!empty || !content) return;
if (!body) {
empty.hidden = false;
content.hidden = true;
return;
}
empty.hidden = true;
content.hidden = false;
content.textContent = body;
}
var bodyEl = document.getElementById('id_body');
var countEl = document.getElementById('char-count');
var empty = document.getElementById('preview-empty');
var previewBody = document.getElementById('preview-body');
var content = document.getElementById('preview-content');
var previewMedia = document.getElementById('preview-media');
var mode = document.getElementById('id_publish_mode');
var when = document.getElementById('id_scheduled_for');
var mediaInput = document.getElementById('id_media');
var mediaJson = document.getElementById('id_media_json');
var thumbs = document.getElementById('media-thumbs');
var statusEl = document.getElementById('media-upload-status');
var uploadUrl = "{{ media_upload_url|escapejs }}";
var csrf = (document.querySelector('#social-compose input[name=csrfmiddlewaretoken]') || {}).value || '';
var mediaItems = [];
try {
mediaItems = JSON.parse((mediaJson && mediaJson.value) || '[]') || [];
if (!Array.isArray(mediaItems)) mediaItems = [];
} catch (_err) {
mediaItems = [];
}
function setStatus(msg, isError) {
if (!statusEl) return;
if (!msg) {
statusEl.hidden = true;
statusEl.textContent = '';
return;
}
statusEl.hidden = false;
statusEl.textContent = msg;
statusEl.style.color = isError ? '#b91c1c' : '';
}
function syncMediaField() {
if (mediaJson) mediaJson.value = JSON.stringify(mediaItems);
}
function renderThumbs() {
if (!thumbs) return;
thumbs.innerHTML = '';
mediaItems.forEach(function (item, idx) {
var wrap = document.createElement('div');
wrap.className = 'media-thumb';
if (item.type === 'video') {
var vid = document.createElement('video');
vid.src = item.url;
vid.muted = true;
wrap.appendChild(vid);
var label = document.createElement('div');
label.className = 'media-label';
label.textContent = 'Video';
wrap.appendChild(label);
} else {
var img = document.createElement('img');
img.src = item.url;
img.alt = item.filename || 'Image';
wrap.appendChild(img);
}
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'remove-media';
btn.setAttribute('aria-label', 'Remove');
btn.textContent = '×';
btn.addEventListener('click', function () {
mediaItems.splice(idx, 1);
syncMediaField();
renderThumbs();
syncPreview();
});
wrap.appendChild(btn);
thumbs.appendChild(wrap);
});
}
function syncPreview() {
var body = bodyEl ? bodyEl.value : '';
if (countEl) countEl.textContent = String(body.length);
if (!empty || !previewBody || !content || !previewMedia) return;
previewMedia.innerHTML = '';
mediaItems.forEach(function (item) {
if (item.type === 'video') {
var vid = document.createElement('video');
vid.src = item.url;
vid.controls = true;
vid.playsInline = true;
previewMedia.appendChild(vid);
} else {
var img = document.createElement('img');
img.src = item.url;
img.alt = item.filename || 'Image';
previewMedia.appendChild(img);
}
});
content.textContent = body;
var hasContent = !!(body || mediaItems.length);
empty.hidden = hasContent;
previewBody.hidden = !hasContent;
}
function syncWhen() {
if (!mode || !when) return;
when.disabled = mode.value !== 'schedule';
}
function validateBeforeUpload(file) {
var hasVideo = mediaItems.some(function (m) { return m.type === 'video'; });
var hasImage = mediaItems.some(function (m) { return m.type === 'image'; });
var isVideo = (file.type || '').indexOf('video/') === 0;
var isImage = (file.type || '').indexOf('image/') === 0;
if (!isVideo && !isImage) {
return 'Use an image or video file.';
}
if (isVideo && hasVideo) return 'Only one video per post.';
if (isVideo && hasImage) return 'Remove images before attaching a video.';
if (isImage && hasVideo) return 'Remove the video before attaching images.';
if (isImage && mediaItems.filter(function (m) { return m.type === 'image'; }).length >= 10) {
return 'At most 10 images.';
}
return '';
}
async function uploadFile(file) {
var err = validateBeforeUpload(file);
if (err) {
setStatus(err, true);
return;
}
setStatus('Uploading ' + (file.name || 'file') + '…');
var fd = new FormData();
fd.append('media', file);
try {
var res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-CSRFToken': csrf },
body: fd,
credentials: 'same-origin'
});
var data = await res.json().catch(function () { return {}; });
if (!res.ok) {
throw new Error(data.error || ('Upload failed (' + res.status + ')'));
}
mediaItems.push(data);
syncMediaField();
renderThumbs();
syncPreview();
setStatus('Attached ' + (data.filename || data.type) + '.');
} catch (e) {
setStatus(e.message || 'Upload failed', true);
}
}
if (mediaInput) {
mediaInput.addEventListener('change', async function () {
var files = Array.prototype.slice.call(mediaInput.files || []);
mediaInput.value = '';
for (var i = 0; i < files.length; i++) {
await uploadFile(files[i]);
}
});
}
if (bodyEl) {
bodyEl.addEventListener('input', syncPreview);
bodyEl.addEventListener('keyup', syncPreview);
bodyEl.addEventListener('change', syncPreview);
}
if (mode) mode.addEventListener('change', syncWhen);
var generateForm = document.querySelector('form input[name=action][value=generate]')?.form;
if (generateForm) {
generateForm.addEventListener('submit', function () {
var genMedia = generateForm.querySelector('input[name=media_json]');
if (genMedia && mediaJson) genMedia.value = mediaJson.value;
var genBody = generateForm.querySelector('textarea[name=body]');
// keep account/mode already mirrored server-side on last render
});
}
syncMediaField();
renderThumbs();
syncPreview();
syncWhen();
})();
@@ -0,0 +1,63 @@
{% extends "portal_base.html" %}
{% block title %}Connecting Meta…{% endblock %}
{% block topbar_title %}Connecting…{% endblock %}
{% block portal_content %}
<div class="panel">
<div class="panel-h"><h2>Finishing Facebook Login</h2></div>
<div class="panel-b">
<p id="meta-oauth-status">Capturing access token from Facebook…</p>
<p class="muted" id="meta-oauth-error" style="display:none;color:#b91c1c"></p>
</div>
</div>
<form id="meta-oauth-complete" method="post" action="{% url 'social:meta_oauth_complete' %}" style="display:none">
{% csrf_token %}
<input type="hidden" name="access_token" id="meta-access-token" value="">
<input type="hidden" name="long_lived_token" id="meta-long-lived-token" value="">
<input type="hidden" name="expires_in" id="meta-expires-in" value="">
<input type="hidden" name="state" id="meta-state" value="">
<input type="hidden" name="error" id="meta-error" value="">
<input type="hidden" name="error_description" id="meta-error-description" value="">
</form>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const status = document.getElementById('meta-oauth-status');
const errEl = document.getElementById('meta-oauth-error');
const form = document.getElementById('meta-oauth-complete');
const hash = (window.location.hash || '').replace(/^#/, '');
const params = new URLSearchParams(hash);
// Some Meta flows also put error on the query string.
const query = new URLSearchParams(window.location.search || '');
function fail(msg) {
status.textContent = 'Could not complete Facebook Login.';
errEl.style.display = 'block';
errEl.textContent = msg;
}
const error = params.get('error_reason') || params.get('error') || query.get('error') || '';
if (error) {
document.getElementById('meta-error').value = error;
document.getElementById('meta-error-description').value =
params.get('error_description') || query.get('error_description') || error;
form.submit();
return;
}
const accessToken = params.get('access_token') || '';
const longLived = params.get('long_lived_token') || '';
if (!accessToken && !longLived) {
fail('No access token in the redirect. Confirm the Valid OAuth Redirect URI matches exactly, then try Connect again.');
return;
}
document.getElementById('meta-access-token').value = accessToken;
document.getElementById('meta-long-lived-token').value = longLived;
document.getElementById('meta-expires-in').value = params.get('expires_in') || '';
document.getElementById('meta-state').value = params.get('state') || query.get('state') || '';
status.textContent = 'Token received — saving account…';
form.submit();
})();
</script>
{% endblock %}
@@ -10,6 +10,17 @@
<div class="panel">
<div class="panel-h"><h2>Caption</h2></div>
<div class="panel-b">
{% if post.media %}
<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">
{% for item in post.media %}
{% if item.type == "video" %}
<video src="{{ item.url }}" controls playsinline style="max-width:100%;max-height:280px;border-radius:8px"></video>
{% else %}
<img src="{{ item.url }}" alt="{{ item.filename|default:'Image' }}" style="max-width:100%;max-height:280px;border-radius:8px;object-fit:contain">
{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="preview-pane" style="white-space:pre-wrap">{{ post.body }}</div>
{% if post.scheduled_for %}
<p class="hint-block">Scheduled {{ post.scheduled_for|date:"M j, Y g:i A" }}</p>
+21
View File
@@ -17,7 +17,28 @@ urlpatterns = [
views.linkedin_oauth_callback,
name="linkedin_oauth_callback",
),
path(
"accounts/facebook/start/",
views.facebook_oauth_start,
name="facebook_oauth_start",
),
path(
"accounts/instagram/start/",
views.instagram_oauth_start,
name="instagram_oauth_start",
),
path(
"accounts/meta/callback/",
views.meta_oauth_callback,
name="meta_oauth_callback",
),
path(
"accounts/meta/callback/complete/",
views.meta_oauth_complete,
name="meta_oauth_complete",
),
path("compose/", views.composer, name="composer"),
path("media/upload/", views.media_upload, name="media_upload"),
path("api/generate/", views.api_generate, name="api_generate"),
path("<uuid:pk>/", views.post_detail, name="post_detail"),
]
+361 -69
View File
@@ -11,7 +11,16 @@ 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,
@@ -21,59 +30,95 @@ from social.models import (
)
from social.oauth_linkedin import (
LinkedInOAuthError,
authorization_url,
credentials_configured,
exchange_code,
fetch_member_profile,
get_app_credentials,
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,
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(
'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."
'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)."
),
"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", ""),
"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 Business",
"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": [
"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)", ""),
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 '
@@ -132,56 +177,65 @@ def account_list(request):
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":
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, "Client ID is required.")
return redirect(f"{request.path}?connect=linkedin")
app, _created = SocialAppCredentials.objects.get_or_create(
platform=Platform.LINKEDIN
)
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=linkedin")
return redirect(f"{request.path}{connect_q}")
elif not app.encrypted_client_secret:
messages.error(
request,
"Client Secret is required the first time you save LinkedIn app credentials.",
"App Secret is required the first time you save credentials.",
)
return redirect(f"{request.path}?connect=linkedin")
return redirect(f"{request.path}{connect_q}")
app.save()
messages.success(
request,
"LinkedIn app credentials saved. You can Connect with LinkedIn now.",
f"{label} app credentials saved. You can Connect now.",
)
return redirect(f"{request.path}?connect=linkedin")
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()
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:
@@ -202,13 +256,48 @@ def account_list(request):
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_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,
@@ -218,12 +307,11 @@ def account_list(request):
"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
),
"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", ""),
},
)
@@ -232,7 +320,7 @@ def account_list(request):
@require_GET
def linkedin_oauth_start(request):
"""Redirect browser to LinkedIn consent screen."""
if not credentials_configured():
if not linkedin_credentials_configured():
messages.error(
request,
"Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.",
@@ -241,7 +329,7 @@ def linkedin_oauth_start(request):
state = secrets.token_urlsafe(24)
request.session["linkedin_oauth_state"] = state
try:
return redirect(authorization_url(state=state))
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")
@@ -269,10 +357,10 @@ def linkedin_oauth_callback(request):
return redirect("social:account_list")
try:
token_payload = exchange_code(code)
profile = fetch_member_profile(token_payload["access_token"])
token_payload = linkedin_exchange_code(code)
profile = linkedin_fetch_member_profile(token_payload["access_token"])
author_urn = profile["author_urn"]
blob = token_blob_from_oauth(token_payload, author_urn=author_urn)
blob = linkedin_token_blob_from_oauth(token_payload, author_urn=author_urn)
account, created = SocialAccount.objects.update_or_create(
platform=Platform.LINKEDIN,
external_id=author_urn,
@@ -301,6 +389,134 @@ def linkedin_oauth_callback(request):
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)
account, created = SocialAccount.objects.update_or_create(
platform=Platform.INSTAGRAM,
external_id=blob["ig_user_id"],
defaults={
"label": 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)
label = (page.get("name") or "").strip() or f"Page {page['id']}"
account, created = SocialAccount.objects.update_or_create(
platform=Platform.FACEBOOK,
external_id=str(page["id"]),
defaults={
"label": 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):
@@ -312,6 +528,7 @@ def composer(request):
"publish_mode": "schedule",
"scheduled_for": "",
"account_ids": [],
"media_json": "[]",
}
error = ""
draft = ""
@@ -322,6 +539,7 @@ def composer(request):
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")
form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]"
action = (request.POST.get("action") or "save").strip()
if action == "generate" and form["prompt"]:
@@ -331,9 +549,15 @@ def composer(request):
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"]:
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
@@ -362,9 +586,19 @@ def composer(request):
)
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,
@@ -399,10 +633,68 @@ def composer(request):
"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):