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
-1
@@ -68,7 +68,7 @@ PCM_RETURN_ADDRESS=
|
|||||||
# PCM_RETURN_ZIP=
|
# PCM_RETURN_ZIP=
|
||||||
|
|
||||||
# Social
|
# Social
|
||||||
# LinkedIn Client ID / Secret are entered in Portal → Social accounts (not env).
|
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
META_APP_SECRET=
|
META_APP_SECRET=
|
||||||
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -122,6 +122,8 @@ class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
|
|
||||||
class Kind(models.TextChoices):
|
class Kind(models.TextChoices):
|
||||||
CAMPAIGN_IMAGE = "campaign_image", "Campaign image"
|
CAMPAIGN_IMAGE = "campaign_image", "Campaign image"
|
||||||
|
SOCIAL_IMAGE = "social_image", "Social image"
|
||||||
|
SOCIAL_VIDEO = "social_video", "Social video"
|
||||||
|
|
||||||
kind = models.CharField(
|
kind = models.CharField(
|
||||||
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
|
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"""LinkedIn API connector."""
|
"""LinkedIn API connector."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from messaging.models import StoredFile
|
||||||
from social.crypto import decrypt_tokens
|
from social.crypto import decrypt_tokens
|
||||||
|
from social.media import split_media
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,29 +24,140 @@ class LinkedInConnector:
|
|||||||
if not access_token or not author_urn:
|
if not access_token or not author_urn:
|
||||||
raise RuntimeError("LinkedIn account missing access_token/author_urn")
|
raise RuntimeError("LinkedIn account missing access_token/author_urn")
|
||||||
|
|
||||||
|
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 = {
|
payload = {
|
||||||
"author": author_urn,
|
"author": author_urn,
|
||||||
"lifecycleState": "PUBLISHED",
|
"lifecycleState": "PUBLISHED",
|
||||||
"specificContent": {
|
"specificContent": {
|
||||||
"com.linkedin.ugc.ShareContent": {
|
"com.linkedin.ugc.ShareContent": {
|
||||||
"shareCommentary": {"text": post.body},
|
"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",
|
"shareMediaCategory": "NONE",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
|
"visibility": {
|
||||||
|
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
"https://api.linkedin.com/v2/ugcPosts",
|
"https://api.linkedin.com/v2/ugcPosts",
|
||||||
json=payload,
|
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={
|
headers={
|
||||||
"Authorization": f"Bearer {access_token}",
|
"Authorization": f"Bearer {access_token}",
|
||||||
"X-Restli-Protocol-Version": "2.0.0",
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Restli-Protocol-Version": "2.0.0",
|
||||||
},
|
},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
if register.status_code >= 400:
|
||||||
return str(response.headers.get("x-restli-id") or response.json().get("id") or "")
|
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:
|
def refresh_token(self, account) -> None:
|
||||||
logger.info("LinkedIn token refresh stub for %s", account.pk)
|
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 logging
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from social.crypto import decrypt_tokens
|
from social.crypto import decrypt_tokens
|
||||||
import json
|
from social.media import split_media
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,26 +21,230 @@ class MetaConnector:
|
|||||||
def publish(self, post, target) -> str:
|
def publish(self, post, target) -> str:
|
||||||
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
|
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
|
||||||
access_token = tokens.get("access_token")
|
access_token = tokens.get("access_token")
|
||||||
page_id = target.account.external_id or tokens.get("page_id")
|
if not access_token:
|
||||||
if not access_token or not page_id:
|
raise RuntimeError("Meta/Instagram account missing access_token")
|
||||||
raise RuntimeError("Meta account missing access_token/page_id")
|
|
||||||
|
media = post.media if isinstance(post.media, list) else []
|
||||||
|
images, videos = split_media(media)
|
||||||
|
|
||||||
if target.platform == "instagram":
|
if target.platform == "instagram":
|
||||||
# IG content publishing is a multi-step Graph flow; stub container create.
|
ig_user_id = target.account.external_id or tokens.get("ig_user_id")
|
||||||
raise NotImplementedError(
|
if not ig_user_id:
|
||||||
"Instagram publish requires IG business account wiring — complete OAuth first"
|
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(
|
response = requests.post(
|
||||||
f"{self.GRAPH}/{page_id}/feed",
|
f"{self.GRAPH}/{page_id}/feed",
|
||||||
data={"message": post.body, "access_token": access_token},
|
data={"message": message, "access_token": access_token},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
self._raise_graph(response, "Facebook text feed")
|
||||||
return str(response.json().get("id") or "")
|
return str(response.json().get("id") or "")
|
||||||
|
|
||||||
def refresh_token(self, account) -> None:
|
def _publish_instagram(
|
||||||
# Long-lived token exchange when META_APP_ID/SECRET are set.
|
self,
|
||||||
if not settings.META_APP_ID or not settings.META_APP_SECRET:
|
*,
|
||||||
|
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
|
return
|
||||||
logger.info("Meta token refresh not yet implemented for account %s", account.pk)
|
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
|
||||||
|
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>
|
<span class="platform-icon meta">f</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Facebook Page</strong>
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a class="connect-card{% if connect_platform == 'instagram' %} is-active{% endif %}"
|
<a class="connect-card{% if connect_platform == 'instagram' %} is-active{% endif %}"
|
||||||
href="{% url 'social:account_list' %}?connect=instagram">
|
href="{% url 'social:account_list' %}?connect=instagram">
|
||||||
<span class="platform-icon ig">IG</span>
|
<span class="platform-icon ig">IG</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Instagram Business</strong>
|
<strong>Instagram</strong>
|
||||||
<p>Requires a Facebook Page linked to an IG business account.</p>
|
<p>Facebook Login for Business — Professional account linked to a Page.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a class="connect-card{% if connect_platform == 'linkedin' %} is-active{% endif %}"
|
<a class="connect-card{% if connect_platform == 'linkedin' %} is-active{% endif %}"
|
||||||
@@ -44,17 +44,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ol>
|
</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">
|
<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">
|
<label for="oauth-callback-url" style="display:block;margin-bottom:6px">
|
||||||
Authorized redirect URL — paste into LinkedIn Auth tab
|
{{ connect_meta.callback_label }}
|
||||||
</label>
|
</label>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:stretch">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:stretch">
|
||||||
<input id="linkedin-callback-url" type="text" readonly
|
<input id="oauth-callback-url" type="text" readonly
|
||||||
value="{{ linkedin_callback_url }}"
|
value="{{ oauth_callback_url }}"
|
||||||
onclick="this.select()"
|
onclick="this.select()"
|
||||||
style="flex:1;min-width:220px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px">
|
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>
|
</div>
|
||||||
<p class="muted" style="margin:8px 0 0;font-size:13px">
|
<p class="muted" style="margin:8px 0 0;font-size:13px">
|
||||||
Must match exactly (including http/https and trailing slash).
|
Must match exactly (including http/https and trailing slash).
|
||||||
@@ -63,30 +63,31 @@
|
|||||||
|
|
||||||
<form method="post" class="form-grid" style="margin-bottom:16px">
|
<form method="post" class="form-grid" style="margin-bottom:16px">
|
||||||
{% csrf_token %}
|
{% 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">
|
<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
|
<input id="id_client_id" name="client_id" required
|
||||||
value="{{ linkedin_client_id }}"
|
value="{{ oauth_client_id }}"
|
||||||
placeholder="From LinkedIn Auth tab"
|
placeholder="From the developer portal"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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"
|
<input id="id_client_secret" name="client_secret" type="password"
|
||||||
{% if not linkedin_secret_saved %}required{% endif %}
|
{% if not oauth_secret_saved %}required{% endif %}
|
||||||
placeholder="{% if linkedin_secret_saved %}Leave blank to keep saved secret{% else %}From LinkedIn Auth tab{% endif %}"
|
placeholder="{% if oauth_secret_saved %}Leave blank to keep saved secret{% else %}From the developer portal{% endif %}"
|
||||||
autocomplete="new-password">
|
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>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
<button class="btn btn-ghost" type="submit">Save app credentials</button>
|
<button class="btn btn-ghost" type="submit">Save app credentials</button>
|
||||||
{% if linkedin_oauth_ready %}
|
{% if oauth_ready and oauth_start_url_name %}
|
||||||
<a class="btn btn-primary" href="{% url 'social:linkedin_oauth_start' %}">Connect with LinkedIn</a>
|
<a class="btn btn-primary" href="{% url oauth_start_url_name %}">{{ connect_meta.connect_label }}</a>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
|
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,6 +156,10 @@
|
|||||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
{% if account.platform == 'linkedin' %}
|
{% if account.platform == 'linkedin' %}
|
||||||
<a class="btn btn-ghost btn-sm" href="{% url 'social:linkedin_oauth_start' %}">Re-authorize</a>
|
<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 %}
|
{% else %}
|
||||||
<a class="btn btn-ghost btn-sm" href="{% url 'social:account_list' %}?connect={{ account.platform }}">Update tokens</a>
|
<a class="btn btn-ghost btn-sm" href="{% url 'social:account_list' %}?connect={{ account.platform }}">Update tokens</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -189,7 +194,7 @@
|
|||||||
<div class="panel-h"><h2>When to re-auth</h2></div>
|
<div class="panel-h"><h2>When to re-auth</h2></div>
|
||||||
<div class="panel-b">
|
<div class="panel-b">
|
||||||
<ul class="plain-list">
|
<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>Meta password changes or app review updates</li>
|
||||||
<li>Publishing fails with an auth error</li>
|
<li>Publishing fails with an auth error</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -209,11 +214,11 @@
|
|||||||
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if connect_platform == 'linkedin' %}
|
{% if connect_meta and connect_meta.oauth %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const btn = document.getElementById('copy-linkedin-callback');
|
const btn = document.getElementById('copy-oauth-callback');
|
||||||
const input = document.getElementById('linkedin-callback-url');
|
const input = document.getElementById('oauth-callback-url');
|
||||||
if (!btn || !input) return;
|
if (!btn || !input) return;
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const value = input.value || '';
|
const value = input.value || '';
|
||||||
|
|||||||
@@ -1,6 +1,79 @@
|
|||||||
{% extends "portal_base.html" %}
|
{% extends "portal_base.html" %}
|
||||||
{% block title %}Compose · Social{% endblock %}
|
{% block title %}Compose · Social{% endblock %}
|
||||||
{% block topbar_title %}Compose & preview{% 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 %}
|
{% block portal_content %}
|
||||||
{% if not accounts %}
|
{% if not accounts %}
|
||||||
<div class="auth-alert">
|
<div class="auth-alert">
|
||||||
@@ -21,18 +94,32 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" class="form-grid" id="social-compose">
|
<form method="post" class="form-grid" id="social-compose">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="media_json" id="id_media_json" value="{{ form.media_json }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="id_body">Caption</label>
|
<label for="id_body">Caption</label>
|
||||||
<textarea id="id_body" name="body" style="min-height:140px"
|
<textarea id="id_body" name="body" style="min-height:140px"
|
||||||
placeholder="Open house this Saturday…"
|
placeholder="Open house this Saturday…">{{ form.body }}</textarea>
|
||||||
oninput="syncPreview()">{{ form.body }}</textarea>
|
|
||||||
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
|
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label>Accounts</label>
|
<label>Accounts</label>
|
||||||
{% for account in accounts %}
|
{% for account in accounts %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
<input type="checkbox" name="account_ids" value="{{ account.pk }}"
|
<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 %}>
|
{% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}>
|
||||||
{{ account.get_platform_display }} · {{ account.label }}
|
{{ account.get_platform_display }} · {{ account.label }}
|
||||||
</label>
|
</label>
|
||||||
@@ -70,6 +157,7 @@
|
|||||||
<input type="hidden" name="action" value="generate">
|
<input type="hidden" name="action" value="generate">
|
||||||
<input type="hidden" name="publish_mode" value="{{ form.publish_mode }}">
|
<input type="hidden" name="publish_mode" value="{{ form.publish_mode }}">
|
||||||
<input type="hidden" name="scheduled_for" value="{{ form.scheduled_for }}">
|
<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 %}
|
{% for id in form.account_ids %}
|
||||||
<input type="hidden" name="account_ids" value="{{ id }}">
|
<input type="hidden" name="account_ids" value="{{ id }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -89,8 +177,11 @@
|
|||||||
<div class="panel-h"><h2>Preview</h2></div>
|
<div class="panel-h"><h2>Preview</h2></div>
|
||||||
<div class="panel-b">
|
<div class="panel-b">
|
||||||
<div class="preview-pane">
|
<div class="preview-pane">
|
||||||
<div class="muted" id="preview-empty">Preview updates as you type.</div>
|
<div class="muted" id="preview-empty">Preview updates as you type or attach media.</div>
|
||||||
<div id="preview-content" hidden style="white-space:pre-wrap"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,29 +193,190 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function syncPreview() {
|
var bodyEl = document.getElementById('id_body');
|
||||||
var body = (document.getElementById('id_body') || {}).value || '';
|
var countEl = document.getElementById('char-count');
|
||||||
var count = document.getElementById('char-count');
|
|
||||||
if (count) count.textContent = String(body.length);
|
|
||||||
var empty = document.getElementById('preview-empty');
|
var empty = document.getElementById('preview-empty');
|
||||||
|
var previewBody = document.getElementById('preview-body');
|
||||||
var content = document.getElementById('preview-content');
|
var content = document.getElementById('preview-content');
|
||||||
if (!empty || !content) return;
|
var previewMedia = document.getElementById('preview-media');
|
||||||
if (!body) {
|
|
||||||
empty.hidden = false;
|
|
||||||
content.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
empty.hidden = true;
|
|
||||||
content.hidden = false;
|
|
||||||
content.textContent = body;
|
|
||||||
}
|
|
||||||
var mode = document.getElementById('id_publish_mode');
|
var mode = document.getElementById('id_publish_mode');
|
||||||
var when = document.getElementById('id_scheduled_for');
|
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() {
|
function syncWhen() {
|
||||||
if (!mode || !when) return;
|
if (!mode || !when) return;
|
||||||
when.disabled = mode.value !== 'schedule';
|
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);
|
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();
|
syncPreview();
|
||||||
syncWhen();
|
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">
|
||||||
<div class="panel-h"><h2>Caption</h2></div>
|
<div class="panel-h"><h2>Caption</h2></div>
|
||||||
<div class="panel-b">
|
<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>
|
<div class="preview-pane" style="white-space:pre-wrap">{{ post.body }}</div>
|
||||||
{% if post.scheduled_for %}
|
{% if post.scheduled_for %}
|
||||||
<p class="hint-block">Scheduled {{ post.scheduled_for|date:"M j, Y g:i A" }}</p>
|
<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,
|
views.linkedin_oauth_callback,
|
||||||
name="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("compose/", views.composer, name="composer"),
|
||||||
|
path("media/upload/", views.media_upload, name="media_upload"),
|
||||||
path("api/generate/", views.api_generate, name="api_generate"),
|
path("api/generate/", views.api_generate, name="api_generate"),
|
||||||
path("<uuid:pk>/", views.post_detail, name="post_detail"),
|
path("<uuid:pk>/", views.post_detail, name="post_detail"),
|
||||||
]
|
]
|
||||||
|
|||||||
+360
-68
@@ -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 django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
|
|
||||||
from messaging.services import parse_scheduled_for
|
from messaging.services import parse_scheduled_for
|
||||||
|
from messaging.models import StoredFile
|
||||||
from social.crypto import SocialCryptoError, encrypt_tokens
|
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 (
|
from social.models import (
|
||||||
Platform,
|
Platform,
|
||||||
SocialAccount,
|
SocialAccount,
|
||||||
@@ -21,59 +30,95 @@ from social.models import (
|
|||||||
)
|
)
|
||||||
from social.oauth_linkedin import (
|
from social.oauth_linkedin import (
|
||||||
LinkedInOAuthError,
|
LinkedInOAuthError,
|
||||||
authorization_url,
|
authorization_url as linkedin_authorization_url,
|
||||||
credentials_configured,
|
credentials_configured as linkedin_credentials_configured,
|
||||||
exchange_code,
|
exchange_code as linkedin_exchange_code,
|
||||||
fetch_member_profile,
|
fetch_member_profile as linkedin_fetch_member_profile,
|
||||||
get_app_credentials,
|
get_app_credentials as get_linkedin_app_credentials,
|
||||||
redirect_uri as linkedin_redirect_uri,
|
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.ollama import OllamaError, generate_social_post
|
||||||
from social.tasks import publish_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 = {
|
CONNECT_INSTRUCTIONS = {
|
||||||
Platform.FACEBOOK: {
|
Platform.FACEBOOK: {
|
||||||
"title": "Connect Facebook Page",
|
"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": [
|
"steps": [
|
||||||
mark_safe(
|
mark_safe(
|
||||||
'Go to <a href="https://developers.facebook.com/async/registration" '
|
'In <a href="https://developers.facebook.com/apps/" target="_blank" '
|
||||||
'target="_blank" rel="noopener">Meta developer registration</a> '
|
'rel="noopener">Meta for Developers</a>, use a Business-type app and add '
|
||||||
"(opens in a new tab) and fill out the information."
|
"<strong>Facebook Login for Business</strong> plus Instagram "
|
||||||
|
"(API setup with Facebook login)."
|
||||||
),
|
),
|
||||||
"Open Meta for Developers → your app → Tools → Graph API Explorer.",
|
"Under Facebook Login for Business → Settings → Valid OAuth Redirect URIs, "
|
||||||
"Select your app, then Get Page Access Token for the Page you manage.",
|
"paste the callback URL shown below (exact match required).",
|
||||||
"Grant pages_manage_posts and pages_read_engagement (and pages_show_list).",
|
"Copy Meta App ID and App Secret into the fields below, then Save app credentials.",
|
||||||
"Copy the Page access token and the numeric Page ID.",
|
"Click Connect with Facebook — grant Page permissions; we store the Page "
|
||||||
"Paste both below. Tokens are encrypted at rest.",
|
"access token for posting.",
|
||||||
],
|
|
||||||
"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", ""),
|
|
||||||
],
|
],
|
||||||
|
"docs_url": _META_DOCS,
|
||||||
|
"fields": [],
|
||||||
},
|
},
|
||||||
Platform.INSTAGRAM: {
|
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": [
|
"steps": [
|
||||||
"Instagram publishing uses a Facebook Page linked to an IG professional account.",
|
mark_safe(
|
||||||
"In Meta Business Suite, confirm the IG account is connected to your Page.",
|
'Follow <a href="'
|
||||||
"From Graph API Explorer, get a Page token that can manage the linked IG account.",
|
+ _META_DOCS
|
||||||
"Use the Instagram Business Account ID (not the username) as External ID.",
|
+ '" target="_blank" rel="noopener">Facebook Login for Business</a> '
|
||||||
"Paste Page access token + IG business account ID below.",
|
"for Instagram — Professional IG account linked to a Facebook Page."
|
||||||
],
|
),
|
||||||
"docs_url": "https://developers.facebook.com/docs/instagram-api/getting-started/",
|
"Same Meta App ID / Secret as Facebook (shared). Save credentials once.",
|
||||||
"fields": [
|
"Add the redirect URI below to Valid OAuth Redirect URIs, then Connect with Instagram.",
|
||||||
("label", "Display name", "@mkdrealtor"),
|
"We request Page + Instagram publish scopes and save the linked Instagram "
|
||||||
("external_id", "IG business account ID", ""),
|
"Business account using the Page access token.",
|
||||||
("access_token", "Page access token", ""),
|
|
||||||
("page_id", "Facebook Page ID (optional)", ""),
|
|
||||||
],
|
],
|
||||||
|
"docs_url": _META_DOCS,
|
||||||
|
"fields": [],
|
||||||
},
|
},
|
||||||
Platform.LINKEDIN: {
|
Platform.LINKEDIN: {
|
||||||
"title": "Connect LinkedIn",
|
"title": "Connect LinkedIn",
|
||||||
"oauth": True,
|
"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": [
|
"steps": [
|
||||||
mark_safe(
|
mark_safe(
|
||||||
'Create a LinkedIn app in the '
|
'Create a LinkedIn app in the '
|
||||||
@@ -132,56 +177,65 @@ def account_list(request):
|
|||||||
account.save(update_fields=["is_active", "updated_at"])
|
account.save(update_fields=["is_active", "updated_at"])
|
||||||
messages.success(request, f"Reactivated {account.label}.")
|
messages.success(request, f"Reactivated {account.label}.")
|
||||||
return redirect("social:account_list")
|
return redirect("social:account_list")
|
||||||
|
if action in {"save_linkedin_app", "save_meta_app", "save_instagram_app"}:
|
||||||
if action == "save_linkedin_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_id = (request.POST.get("client_id") or "").strip()
|
||||||
client_secret = (request.POST.get("client_secret") or "").strip()
|
client_secret = (request.POST.get("client_secret") or "").strip()
|
||||||
if not client_id:
|
if not client_id:
|
||||||
messages.error(request, "Client ID is required.")
|
messages.error(request, "App ID / Client ID is required.")
|
||||||
return redirect(f"{request.path}?connect=linkedin")
|
return redirect(f"{request.path}{connect_q}")
|
||||||
app, _created = SocialAppCredentials.objects.get_or_create(
|
app, _created = SocialAppCredentials.objects.get_or_create(platform=platform)
|
||||||
platform=Platform.LINKEDIN
|
|
||||||
)
|
|
||||||
app.client_id = client_id
|
app.client_id = client_id
|
||||||
if client_secret:
|
if client_secret:
|
||||||
try:
|
try:
|
||||||
app.encrypted_client_secret = encrypt_tokens(client_secret)
|
app.encrypted_client_secret = encrypt_tokens(client_secret)
|
||||||
except SocialCryptoError as exc:
|
except SocialCryptoError as exc:
|
||||||
messages.error(request, str(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:
|
elif not app.encrypted_client_secret:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
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()
|
app.save()
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
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()
|
platform = (request.POST.get("platform") or "").strip()
|
||||||
if platform not in Platform.values:
|
if platform not in Platform.values:
|
||||||
messages.error(request, "Choose a platform.")
|
messages.error(request, "Choose a platform.")
|
||||||
return redirect("social:account_list")
|
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()
|
label = (request.POST.get("label") or "").strip() or platform.title()
|
||||||
external_id = (request.POST.get("external_id") or "").strip()
|
external_id = (request.POST.get("external_id") or "").strip()
|
||||||
access_token = (request.POST.get("access_token") 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:
|
if not access_token or not external_id:
|
||||||
messages.error(request, "Access token and external ID are required.")
|
messages.error(request, "Access token and external ID are required.")
|
||||||
return redirect(f"{request.path}?connect={platform}")
|
return redirect(f"{request.path}?connect={platform}")
|
||||||
|
|
||||||
token_blob = {"access_token": access_token}
|
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:
|
try:
|
||||||
encrypted = encrypt_tokens(json.dumps(token_blob))
|
encrypted = encrypt_tokens(json.dumps(token_blob))
|
||||||
except SocialCryptoError as exc:
|
except SocialCryptoError as exc:
|
||||||
@@ -202,13 +256,48 @@ def account_list(request):
|
|||||||
messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.")
|
messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.")
|
||||||
return redirect("social:account_list")
|
return redirect("social:account_list")
|
||||||
|
|
||||||
linkedin_app = get_app_credentials()
|
linkedin_app = get_linkedin_app_credentials()
|
||||||
linkedin_oauth_ready = credentials_configured()
|
meta_app = get_meta_app_credentials()
|
||||||
linkedin_callback = ""
|
linkedin_callback = ""
|
||||||
|
meta_callback = ""
|
||||||
try:
|
try:
|
||||||
linkedin_callback = linkedin_redirect_uri()
|
linkedin_callback = linkedin_redirect_uri()
|
||||||
except LinkedInOAuthError:
|
except LinkedInOAuthError:
|
||||||
linkedin_callback = "(set PUBLIC_SITE_URL, then restart)"
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -218,12 +307,11 @@ def account_list(request):
|
|||||||
"connect_platform": connect_platform,
|
"connect_platform": connect_platform,
|
||||||
"connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
|
"connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
|
||||||
"platforms": Platform,
|
"platforms": Platform,
|
||||||
"linkedin_oauth_ready": linkedin_oauth_ready,
|
"oauth_ready": oauth_state.get("ready", False),
|
||||||
"linkedin_callback_url": linkedin_callback,
|
"oauth_callback_url": oauth_state.get("callback_url", ""),
|
||||||
"linkedin_client_id": (linkedin_app.client_id if linkedin_app else ""),
|
"oauth_client_id": oauth_state.get("client_id", ""),
|
||||||
"linkedin_secret_saved": bool(
|
"oauth_secret_saved": oauth_state.get("secret_saved", False),
|
||||||
linkedin_app and linkedin_app.encrypted_client_secret
|
"oauth_start_url_name": oauth_state.get("start_url_name", ""),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,7 +320,7 @@ def account_list(request):
|
|||||||
@require_GET
|
@require_GET
|
||||||
def linkedin_oauth_start(request):
|
def linkedin_oauth_start(request):
|
||||||
"""Redirect browser to LinkedIn consent screen."""
|
"""Redirect browser to LinkedIn consent screen."""
|
||||||
if not credentials_configured():
|
if not linkedin_credentials_configured():
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
"Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.",
|
"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)
|
state = secrets.token_urlsafe(24)
|
||||||
request.session["linkedin_oauth_state"] = state
|
request.session["linkedin_oauth_state"] = state
|
||||||
try:
|
try:
|
||||||
return redirect(authorization_url(state=state))
|
return redirect(linkedin_authorization_url(state=state))
|
||||||
except LinkedInOAuthError as exc:
|
except LinkedInOAuthError as exc:
|
||||||
messages.error(request, str(exc))
|
messages.error(request, str(exc))
|
||||||
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
||||||
@@ -269,10 +357,10 @@ def linkedin_oauth_callback(request):
|
|||||||
return redirect("social:account_list")
|
return redirect("social:account_list")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token_payload = exchange_code(code)
|
token_payload = linkedin_exchange_code(code)
|
||||||
profile = fetch_member_profile(token_payload["access_token"])
|
profile = linkedin_fetch_member_profile(token_payload["access_token"])
|
||||||
author_urn = profile["author_urn"]
|
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(
|
account, created = SocialAccount.objects.update_or_create(
|
||||||
platform=Platform.LINKEDIN,
|
platform=Platform.LINKEDIN,
|
||||||
external_id=author_urn,
|
external_id=author_urn,
|
||||||
@@ -301,6 +389,134 @@ def linkedin_oauth_callback(request):
|
|||||||
return redirect("social:account_list")
|
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
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def composer(request):
|
def composer(request):
|
||||||
@@ -312,6 +528,7 @@ def composer(request):
|
|||||||
"publish_mode": "schedule",
|
"publish_mode": "schedule",
|
||||||
"scheduled_for": "",
|
"scheduled_for": "",
|
||||||
"account_ids": [],
|
"account_ids": [],
|
||||||
|
"media_json": "[]",
|
||||||
}
|
}
|
||||||
error = ""
|
error = ""
|
||||||
draft = ""
|
draft = ""
|
||||||
@@ -322,6 +539,7 @@ def composer(request):
|
|||||||
form["publish_mode"] = (request.POST.get("publish_mode") or "schedule").strip()
|
form["publish_mode"] = (request.POST.get("publish_mode") or "schedule").strip()
|
||||||
form["scheduled_for"] = request.POST.get("scheduled_for") or ""
|
form["scheduled_for"] = request.POST.get("scheduled_for") or ""
|
||||||
form["account_ids"] = request.POST.getlist("account_ids")
|
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()
|
action = (request.POST.get("action") or "save").strip()
|
||||||
|
|
||||||
if action == "generate" and form["prompt"]:
|
if action == "generate" and form["prompt"]:
|
||||||
@@ -331,9 +549,15 @@ def composer(request):
|
|||||||
except OllamaError as exc:
|
except OllamaError as exc:
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
elif action in {"save", "publish"}:
|
elif action in {"save", "publish"}:
|
||||||
if not form["body"]:
|
media_items: list = []
|
||||||
error = "Caption / body is required."
|
try:
|
||||||
elif form["publish_mode"] != "draft" and not form["account_ids"]:
|
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."
|
error = "Select at least one connected account."
|
||||||
else:
|
else:
|
||||||
scheduled_for = None
|
scheduled_for = None
|
||||||
@@ -362,9 +586,19 @@ def composer(request):
|
|||||||
)
|
)
|
||||||
if form["publish_mode"] != "draft" and not selected.exists():
|
if form["publish_mode"] != "draft" and not selected.exists():
|
||||||
error = "No valid accounts selected."
|
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:
|
else:
|
||||||
post = SocialPost.objects.create(
|
post = SocialPost.objects.create(
|
||||||
body=form["body"],
|
body=form["body"],
|
||||||
|
media=media_items,
|
||||||
ollama_prompt=form["prompt"],
|
ollama_prompt=form["prompt"],
|
||||||
scheduled_for=scheduled_for,
|
scheduled_for=scheduled_for,
|
||||||
status=status,
|
status=status,
|
||||||
@@ -399,10 +633,68 @@ def composer(request):
|
|||||||
"form": form,
|
"form": form,
|
||||||
"error": error,
|
"error": error,
|
||||||
"draft": draft or form["body"],
|
"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
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def api_generate(request):
|
def api_generate(request):
|
||||||
|
|||||||
Reference in New Issue
Block a user