Add Meta Facebook Login OAuth and social media attach/publish.
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:
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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 MB) or one video (MP4/MOV/WebM, ≤100 MB).
|
||||
Instagram requires media. Don’t 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>
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user