Template
Populate the client website template with catalog feature flags.
Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed. Refs #1 Refs #2 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from social.models import SocialAccount, SocialAppCredentials, SocialPost, SocialPostTarget
|
||||
|
||||
|
||||
class SocialPostTargetInline(admin.TabularInline):
|
||||
model = SocialPostTarget
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(SocialAppCredentials)
|
||||
class SocialAppCredentialsAdmin(admin.ModelAdmin):
|
||||
list_display = ("platform", "client_id", "updated_at")
|
||||
readonly_fields = ("encrypted_client_secret", "created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(SocialAccount)
|
||||
class SocialAccountAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "platform", "is_active", "external_id")
|
||||
list_filter = ("platform", "is_active")
|
||||
|
||||
|
||||
@admin.register(SocialPost)
|
||||
class SocialPostAdmin(admin.ModelAdmin):
|
||||
list_display = ("pk", "status", "scheduled_for", "created_at")
|
||||
list_filter = ("status",)
|
||||
inlines = [SocialPostTargetInline]
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SocialConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "social"
|
||||
|
||||
def ready(self):
|
||||
from social import hooks
|
||||
|
||||
hooks.register()
|
||||
@@ -0,0 +1,10 @@
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SocialConnector(Protocol):
|
||||
platform: str
|
||||
|
||||
def publish(self, post, target) -> str:
|
||||
"""Publish and return remote post id."""
|
||||
|
||||
def refresh_token(self, account) -> None: ...
|
||||
@@ -0,0 +1,163 @@
|
||||
"""LinkedIn API connector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from core.models import StoredFile
|
||||
from social.crypto import decrypt_tokens
|
||||
from social.media import split_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LinkedInConnector:
|
||||
platform = "linkedin"
|
||||
|
||||
def publish(self, post, target) -> str:
|
||||
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
|
||||
access_token = tokens.get("access_token")
|
||||
author_urn = target.account.external_id or tokens.get("author_urn")
|
||||
if not access_token or not 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 = {
|
||||
"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}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Restli-Protocol-Version": "2.0.0",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
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)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Meta Graph API connector (Facebook Page + Instagram via Facebook Login)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from social.crypto import decrypt_tokens
|
||||
from social.media import split_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetaConnector:
|
||||
platform = "meta"
|
||||
GRAPH = "https://graph.facebook.com/v21.0"
|
||||
|
||||
def publish(self, post, target) -> str:
|
||||
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
|
||||
access_token = tokens.get("access_token")
|
||||
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_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": message, "access_token": access_token},
|
||||
timeout=30,
|
||||
)
|
||||
self._raise_graph(response, "Facebook text feed")
|
||||
return str(response.json().get("id") or "")
|
||||
|
||||
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
|
||||
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,67 @@
|
||||
"""Token encryption helpers for SocialAccount / SocialAppCredentials."""
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from django.conf import settings
|
||||
|
||||
# Copied from .env.prod.example / deploy templates — not valid Fernet material.
|
||||
_PLACEHOLDER_KEYS = frozenset(
|
||||
{
|
||||
"replace-with-fernet-key",
|
||||
"changeme",
|
||||
"change-me",
|
||||
"todo",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SocialCryptoError(ValueError):
|
||||
"""Raised when SOCIAL_TOKEN_ENCRYPTION_KEY is missing or not a Fernet key."""
|
||||
|
||||
|
||||
def _normalize_key(raw: str | bytes | None) -> bytes | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bytes):
|
||||
text = raw.decode("utf-8", errors="ignore").strip()
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text or text.lower() in _PLACEHOLDER_KEYS:
|
||||
return None
|
||||
return text.encode()
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = _normalize_key(settings.SOCIAL_TOKEN_ENCRYPTION_KEY)
|
||||
if key is None:
|
||||
if settings.DEBUG:
|
||||
# Dev-only unstable fallback — set SOCIAL_TOKEN_ENCRYPTION_KEY for real use.
|
||||
return Fernet(Fernet.generate_key())
|
||||
raise SocialCryptoError(
|
||||
"SOCIAL_TOKEN_ENCRYPTION_KEY is not set. Generate one with: "
|
||||
'python -c "from cryptography.fernet import Fernet; '
|
||||
'print(Fernet.generate_key().decode())" '
|
||||
"and add it to the beta/prod app env, then redeploy/restart."
|
||||
)
|
||||
try:
|
||||
return Fernet(key)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise SocialCryptoError(
|
||||
"SOCIAL_TOKEN_ENCRYPTION_KEY is invalid (must be 32 url-safe "
|
||||
"base64-encoded bytes from Fernet.generate_key()). "
|
||||
"Replace the placeholder in the beta/prod env and restart."
|
||||
) from exc
|
||||
|
||||
|
||||
def encrypt_tokens(plaintext: str) -> str:
|
||||
return _fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_tokens(ciphertext: str) -> str:
|
||||
if not ciphertext:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise ValueError("Unable to decrypt social tokens") from exc
|
||||
except SocialCryptoError:
|
||||
raise
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Register portal nav, dashboard widgets, and due-work dispatch."""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from core.registry import (
|
||||
register_dashboard_collector,
|
||||
register_dispatcher,
|
||||
register_feature,
|
||||
register_portal_nav,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
register_feature("social")
|
||||
register_portal_nav(
|
||||
section="social",
|
||||
label="Compose & preview",
|
||||
url_name="social:composer",
|
||||
group="Social",
|
||||
order=10,
|
||||
)
|
||||
register_portal_nav(
|
||||
section="social_accounts",
|
||||
label="Accounts",
|
||||
url_name="social:account_list",
|
||||
group="Social",
|
||||
order=20,
|
||||
)
|
||||
register_dashboard_collector(_dashboard)
|
||||
register_dispatcher(_dispatch_due)
|
||||
|
||||
|
||||
def _dashboard(request) -> dict:
|
||||
from social.models import SocialPost
|
||||
|
||||
return {
|
||||
"scheduled_posts": SocialPost.objects.filter(
|
||||
status__in=[SocialPost.Status.SCHEDULED, SocialPost.Status.QUEUED]
|
||||
).count()
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_due() -> int:
|
||||
from social.models import SocialPost
|
||||
from social.tasks import publish_social_post
|
||||
|
||||
now = timezone.now()
|
||||
enqueued = 0
|
||||
for post in SocialPost.objects.filter(
|
||||
status=SocialPost.Status.SCHEDULED,
|
||||
scheduled_for__lte=now,
|
||||
).iterator():
|
||||
post.status = SocialPost.Status.QUEUED
|
||||
post.save(update_fields=["status", "updated_at"])
|
||||
publish_social_post.enqueue(post_id=str(post.pk))
|
||||
enqueued += 1
|
||||
return enqueued
|
||||
@@ -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 core.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("core: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,85 @@
|
||||
# Generated by Django 6.1 on 2026-08-26 11:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SocialAppCredentials',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16, unique=True)),
|
||||
('client_id', models.CharField(blank=True, max_length=255)),
|
||||
('encrypted_client_secret', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'social app credentials',
|
||||
'ordering': ['platform'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SocialAccount',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16)),
|
||||
('label', models.CharField(max_length=120)),
|
||||
('external_id', models.CharField(blank=True, max_length=255)),
|
||||
('encrypted_tokens', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='social_accounts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['platform', 'label'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SocialPost',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('body', models.TextField()),
|
||||
('media', models.JSONField(blank=True, default=list)),
|
||||
('scheduled_for', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('publishing', 'Publishing'), ('published', 'Published'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
|
||||
('ollama_prompt', models.TextField(blank=True)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SocialPostTarget',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('published', 'Published'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||
('remote_id', models.CharField(blank=True, max_length=255)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='social.socialaccount')),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='social.socialpost')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('post', 'account')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
_GENERIC_ACCOUNT_LABELS = frozenset(
|
||||
{
|
||||
"linkedin member",
|
||||
"instagram account",
|
||||
"facebook page",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_generic_account_label(label: str) -> bool:
|
||||
"""True when OAuth left a placeholder or unreadable name."""
|
||||
text = (label or "").strip()
|
||||
if not text:
|
||||
return True
|
||||
lowered = text.lower()
|
||||
if lowered in _GENERIC_ACCOUNT_LABELS:
|
||||
return True
|
||||
if lowered.startswith("page ") and text.split()[-1].isdigit():
|
||||
return True
|
||||
if text.startswith("urn:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Platform(models.TextChoices):
|
||||
FACEBOOK = "facebook", "Facebook"
|
||||
INSTAGRAM = "instagram", "Instagram"
|
||||
LINKEDIN = "linkedin", "LinkedIn"
|
||||
|
||||
|
||||
class SocialAppCredentials(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
"""
|
||||
Developer-app OAuth credentials entered in the portal (not env).
|
||||
|
||||
client_secret is Fernet-encrypted. One row per platform.
|
||||
"""
|
||||
|
||||
platform = models.CharField(max_length=16, choices=Platform.choices, unique=True)
|
||||
client_id = models.CharField(max_length=255, blank=True)
|
||||
encrypted_client_secret = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "social app credentials"
|
||||
ordering = ["platform"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.platform} app credentials"
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.client_id and self.encrypted_client_secret)
|
||||
|
||||
|
||||
class SocialAccount(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
platform = models.CharField(max_length=16, choices=Platform.choices)
|
||||
label = models.CharField(max_length=120)
|
||||
external_id = models.CharField(max_length=255, blank=True)
|
||||
# Fernet-encrypted JSON blob of OAuth tokens
|
||||
encrypted_tokens = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="social_accounts",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["platform", "label"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.platform}: {self.label}"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return (self.label or "").strip() or self.get_platform_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_label(existing: "SocialAccount | None", incoming: str) -> str:
|
||||
"""Keep a custom rename; otherwise take the OAuth name."""
|
||||
incoming = (incoming or "").strip()[:120]
|
||||
if existing is not None and not is_generic_account_label(existing.label):
|
||||
return existing.label
|
||||
if incoming:
|
||||
return incoming
|
||||
if existing and existing.label:
|
||||
return existing.label
|
||||
return "Account"
|
||||
|
||||
|
||||
class SocialPost(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
SCHEDULED = "scheduled", "Scheduled"
|
||||
QUEUED = "queued", "Queued"
|
||||
PUBLISHING = "publishing", "Publishing"
|
||||
PUBLISHED = "published", "Published"
|
||||
FAILED = "failed", "Failed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
body = models.TextField()
|
||||
media = models.JSONField(default=list, blank=True)
|
||||
scheduled_for = models.DateTimeField(null=True, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
ollama_prompt = models.TextField(blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Post {self.pk} ({self.status})"
|
||||
|
||||
|
||||
class SocialPostTarget(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
PUBLISHED = "published", "Published"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
post = models.ForeignKey(
|
||||
SocialPost, on_delete=models.CASCADE, related_name="targets"
|
||||
)
|
||||
account = models.ForeignKey(
|
||||
SocialAccount, on_delete=models.CASCADE, related_name="targets"
|
||||
)
|
||||
platform = models.CharField(max_length=16, choices=Platform.choices)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.PENDING
|
||||
)
|
||||
remote_id = models.CharField(max_length=255, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("post", "account")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""LinkedIn 3-legged OAuth helpers (Authorization Code flow)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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__)
|
||||
|
||||
AUTHORIZE_URL = "https://www.linkedin.com/oauth/v2/authorization"
|
||||
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||
USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
|
||||
ME_URL = "https://api.linkedin.com/v2/me"
|
||||
|
||||
# openid/profile/email: Sign In with LinkedIn (OpenID Connect)
|
||||
# w_member_social: Share on LinkedIn product (required to post as the member)
|
||||
DEFAULT_SCOPES = ("openid", "profile", "email", "w_member_social")
|
||||
|
||||
|
||||
class LinkedInOAuthError(RuntimeError):
|
||||
"""Raised when LinkedIn OAuth or profile lookup fails."""
|
||||
|
||||
|
||||
def get_app_credentials() -> SocialAppCredentials | None:
|
||||
return SocialAppCredentials.objects.filter(platform=Platform.LINKEDIN).first()
|
||||
|
||||
|
||||
def load_client_credentials() -> tuple[str, str]:
|
||||
"""Return (client_id, client_secret) from portal-saved credentials."""
|
||||
app = get_app_credentials()
|
||||
if not app or not app.client_id or not app.encrypted_client_secret:
|
||||
raise LinkedInOAuthError(
|
||||
"LinkedIn app credentials are not saved yet. "
|
||||
"Enter Client ID and Client Secret on the Connect LinkedIn form."
|
||||
)
|
||||
try:
|
||||
secret = decrypt_tokens(app.encrypted_client_secret)
|
||||
except ValueError as exc:
|
||||
raise LinkedInOAuthError(
|
||||
"Could not decrypt LinkedIn client secret. "
|
||||
"Re-enter the Client Secret on the Connect LinkedIn form."
|
||||
) from exc
|
||||
if not secret:
|
||||
raise LinkedInOAuthError("LinkedIn client 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:
|
||||
"""Absolute callback URL — must match Authorized redirect URLs in LinkedIn Auth tab."""
|
||||
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
if not base:
|
||||
raise LinkedInOAuthError(
|
||||
"PUBLIC_SITE_URL is not set; cannot build LinkedIn OAuth redirect URI."
|
||||
)
|
||||
return f"{base}{reverse('social:linkedin_oauth_callback')}"
|
||||
|
||||
|
||||
def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str:
|
||||
client_id, _secret = load_client_credentials()
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri(),
|
||||
"state": state,
|
||||
"scope": " ".join(scopes),
|
||||
}
|
||||
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
def exchange_code(code: str) -> dict:
|
||||
"""Exchange authorization code for access token payload."""
|
||||
client_id, client_secret = load_client_credentials()
|
||||
response = requests.post(
|
||||
TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri(),
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
logger.warning(
|
||||
"LinkedIn token exchange failed: %s %s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
raise LinkedInOAuthError(
|
||||
f"LinkedIn token exchange failed ({response.status_code}). "
|
||||
"Check client ID/secret, redirect URI, and that Share on LinkedIn is enabled."
|
||||
)
|
||||
payload = response.json()
|
||||
if not payload.get("access_token"):
|
||||
raise LinkedInOAuthError("LinkedIn token response missing access_token.")
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_member_profile(access_token: str) -> dict:
|
||||
"""
|
||||
Resolve member display name + person URN.
|
||||
|
||||
Prefers OpenID userinfo (`sub`); falls back to legacy /v2/me.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
userinfo = requests.get(USERINFO_URL, headers=headers, timeout=30)
|
||||
if userinfo.status_code == 200:
|
||||
data = userinfo.json()
|
||||
sub = (data.get("sub") or "").strip()
|
||||
if sub:
|
||||
name = (
|
||||
(data.get("name") or "").strip()
|
||||
or " ".join(
|
||||
p
|
||||
for p in (
|
||||
(data.get("given_name") or "").strip(),
|
||||
(data.get("family_name") or "").strip(),
|
||||
)
|
||||
if p
|
||||
)
|
||||
or "LinkedIn member"
|
||||
)
|
||||
return {
|
||||
"author_urn": f"urn:li:person:{sub}",
|
||||
"label": name,
|
||||
"email": (data.get("email") or "").strip(),
|
||||
}
|
||||
|
||||
me = requests.get(
|
||||
ME_URL,
|
||||
headers={
|
||||
**headers,
|
||||
"X-Restli-Protocol-Version": "2.0.0",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if me.status_code >= 400:
|
||||
logger.warning(
|
||||
"LinkedIn profile lookup failed: userinfo=%s me=%s %s",
|
||||
userinfo.status_code,
|
||||
me.status_code,
|
||||
me.text[:500],
|
||||
)
|
||||
raise LinkedInOAuthError(
|
||||
"Could not load LinkedIn profile. Ensure Sign In with LinkedIn "
|
||||
"(openid/profile) is approved on the app."
|
||||
)
|
||||
data = me.json()
|
||||
person_id = (data.get("id") or "").strip()
|
||||
if not person_id:
|
||||
raise LinkedInOAuthError("LinkedIn /v2/me response missing id.")
|
||||
localized = data.get("localizedFirstName") or ""
|
||||
last = data.get("localizedLastName") or ""
|
||||
label = f"{localized} {last}".strip() or "LinkedIn member"
|
||||
return {"author_urn": f"urn:li:person:{person_id}", "label": label, "email": ""}
|
||||
|
||||
|
||||
def token_blob_from_oauth(token_payload: dict, *, author_urn: str) -> dict:
|
||||
expires_in = int(token_payload.get("expires_in") or 0)
|
||||
blob: dict = {
|
||||
"access_token": token_payload["access_token"],
|
||||
"author_urn": author_urn,
|
||||
"token_type": token_payload.get("token_type") or "Bearer",
|
||||
"scope": token_payload.get("scope") or "",
|
||||
}
|
||||
if token_payload.get("refresh_token"):
|
||||
blob["refresh_token"] = token_payload["refresh_token"]
|
||||
if expires_in:
|
||||
blob["expires_at"] = (
|
||||
timezone.now() + timedelta(seconds=expires_in)
|
||||
).isoformat()
|
||||
return blob
|
||||
@@ -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()
|
||||
@@ -0,0 +1,59 @@
|
||||
from django.tasks import task
|
||||
|
||||
from social.connectors.linkedin import LinkedInConnector
|
||||
from social.connectors.meta import MetaConnector
|
||||
from social.models import Platform, SocialPost, SocialPostTarget
|
||||
|
||||
|
||||
def _connector_for(platform: str):
|
||||
if platform in {Platform.FACEBOOK, Platform.INSTAGRAM}:
|
||||
return MetaConnector()
|
||||
if platform == Platform.LINKEDIN:
|
||||
return LinkedInConnector()
|
||||
raise ValueError(f"Unknown platform: {platform}")
|
||||
|
||||
|
||||
@task
|
||||
def publish_social_post(post_id: str) -> None:
|
||||
try:
|
||||
post = SocialPost.objects.prefetch_related("targets__account").get(pk=post_id)
|
||||
except SocialPost.DoesNotExist:
|
||||
return
|
||||
|
||||
post.status = SocialPost.Status.PUBLISHING
|
||||
post.save(update_fields=["status", "updated_at"])
|
||||
|
||||
any_ok = False
|
||||
for target in post.targets.all():
|
||||
try:
|
||||
remote_id = _connector_for(target.platform).publish(post, target)
|
||||
target.status = SocialPostTarget.Status.PUBLISHED
|
||||
target.remote_id = remote_id
|
||||
target.error = ""
|
||||
target.save(update_fields=["status", "remote_id", "error", "updated_at"])
|
||||
any_ok = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
target.status = SocialPostTarget.Status.FAILED
|
||||
target.error = str(exc)[:2000]
|
||||
target.save(update_fields=["status", "error", "updated_at"])
|
||||
|
||||
post.status = (
|
||||
SocialPost.Status.PUBLISHED if any_ok else SocialPost.Status.FAILED
|
||||
)
|
||||
if not any_ok:
|
||||
post.error = "All targets failed"
|
||||
post.save(update_fields=["status", "error", "updated_at"])
|
||||
|
||||
|
||||
@task
|
||||
def publish_social_target(target_id: str) -> None:
|
||||
try:
|
||||
target = SocialPostTarget.objects.select_related("post", "account").get(
|
||||
pk=target_id
|
||||
)
|
||||
except SocialPostTarget.DoesNotExist:
|
||||
return
|
||||
remote_id = _connector_for(target.platform).publish(target.post, target)
|
||||
target.status = SocialPostTarget.Status.PUBLISHED
|
||||
target.remote_id = remote_id
|
||||
target.save(update_fields=["status", "remote_id", "updated_at"])
|
||||
@@ -0,0 +1,248 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Social accounts · Portal{% endblock %}
|
||||
{% block topbar_title %}Social accounts{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Add account</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="connect-grid">
|
||||
<a class="connect-card{% if connect_platform == 'facebook' %} is-active{% endif %}"
|
||||
href="{% url 'social:account_list' %}?connect=facebook">
|
||||
<span class="platform-icon meta">f</span>
|
||||
<div>
|
||||
<strong>Facebook Page</strong>
|
||||
<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</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 %}"
|
||||
href="{% url 'social:account_list' %}?connect=linkedin">
|
||||
<span class="platform-icon li">in</span>
|
||||
<div>
|
||||
<strong>LinkedIn</strong>
|
||||
<p>Connect via OAuth. Tokens expire (~2 months) — re-authorize when prompted.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if connect_meta %}
|
||||
<div class="connect-form-panel" id="connect-form">
|
||||
<h3 style="margin:0 0 8px;font-size:16px">{{ connect_meta.title }}</h3>
|
||||
<ol class="connect-steps">
|
||||
{% for step in connect_meta.steps %}
|
||||
<li>{{ step }}</li>
|
||||
{% endfor %}
|
||||
{% if connect_meta.docs_url %}
|
||||
<li>Reference: <a href="{{ connect_meta.docs_url }}" target="_blank" rel="noopener">platform docs</a></li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
|
||||
{% 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="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="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-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).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" class="form-grid" style="margin-bottom:16px">
|
||||
{% csrf_token %}
|
||||
<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">{{ connect_meta.client_id_label }}</label>
|
||||
<input id="id_client_id" name="client_id" required
|
||||
value="{{ oauth_client_id }}"
|
||||
placeholder="From the developer portal"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_client_secret">{{ connect_meta.client_secret_label }}</label>
|
||||
<input id="id_client_secret" name="client_secret" type="password"
|
||||
{% 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 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 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 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>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="connect">
|
||||
<input type="hidden" name="platform" value="{{ connect_platform }}">
|
||||
{% for name, label, placeholder in connect_meta.fields %}
|
||||
<div class="field">
|
||||
<label for="id_{{ name }}">{{ label }}</label>
|
||||
{% if name == 'access_token' %}
|
||||
<textarea id="id_{{ name }}" name="{{ name }}" required
|
||||
style="min-height:88px" placeholder="{{ placeholder }}"></textarea>
|
||||
{% else %}
|
||||
<input id="id_{{ name }}" name="{{ name }}"
|
||||
{% if name != 'page_id' and name != 'label' %}required{% endif %}
|
||||
placeholder="{{ placeholder }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" type="submit">Save account</button>
|
||||
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Connected accounts</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Platform</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account in accounts %}
|
||||
<tr {% if not account.is_active %}class="row-warn"{% endif %}>
|
||||
<td>
|
||||
<form method="post" class="account-rename">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="rename">
|
||||
<input type="hidden" name="account_id" value="{{ account.pk }}">
|
||||
<input id="id_label_{{ account.pk }}" name="label" type="text" maxlength="120"
|
||||
value="{{ account.label }}" required
|
||||
aria-label="Display name for {{ account.get_platform_display }}">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Rename</button>
|
||||
</form>
|
||||
{% if account.external_id %}
|
||||
<span class="muted" style="display:block;margin-top:6px;overflow-wrap:anywhere">{{ account.external_id }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="platform-pill {% if account.platform == 'facebook' %}meta{% elif account.platform == 'instagram' %}ig{% else %}li{% endif %}">
|
||||
{{ account.get_platform_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if account.is_active %}
|
||||
<span class="badge badge-delivered">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-failed">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<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 %}
|
||||
{% if account.is_active %}
|
||||
<form method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="disconnect">
|
||||
<input type="hidden" name="account_id" value="{{ account.pk }}">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Disconnect</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="reactivate">
|
||||
<input type="hidden" name="account_id" value="{{ account.pk }}">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Mark active</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No accounts connected yet — pick a platform above.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>When to re-auth</h2></div>
|
||||
<div class="panel-b">
|
||||
<ul class="plain-list">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Next</h2></div>
|
||||
<div class="panel-b">
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'social:composer' %}">Open composer →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
{% if connect_platform %}
|
||||
<script>
|
||||
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if connect_meta and connect_meta.oauth %}
|
||||
<script>
|
||||
(function () {
|
||||
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 || '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(() => { btn.textContent = prev; }, 1500);
|
||||
} catch (_err) {
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,414 @@
|
||||
{% 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">
|
||||
<strong>No social accounts connected.</strong>
|
||||
<a href="{% url 'social:account_list' %}">Connect Facebook, Instagram, or LinkedIn</a> first.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>Compose</h2>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'social:account_list' %}">Manage accounts</a>
|
||||
</div>
|
||||
<div class="panel-b">
|
||||
{% if error %}
|
||||
<ul class="portal-flash" style="margin:0 0 12px"><li class="error">{{ error }}</li></ul>
|
||||
{% 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…">{{ 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 account-pick">
|
||||
<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 %}>
|
||||
<span class="account-pick-copy">
|
||||
<strong>{{ account.display_name }}</strong>
|
||||
<span class="muted">{{ account.get_platform_display }}{% if account.external_id %} · {{ account.external_id }}{% endif %}</span>
|
||||
</span>
|
||||
</label>
|
||||
{% empty %}
|
||||
<p class="muted">No active accounts.</p>
|
||||
{% endfor %}
|
||||
{% if accounts %}
|
||||
<div class="hint">Name look wrong? <a href="{% url 'social:account_list' %}">Rename it on Social accounts</a>.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label for="id_scheduled_for">Schedule for <span class="muted">(optional)</span></label>
|
||||
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
|
||||
value="{{ form.scheduled_for }}">
|
||||
<div class="hint">Needed only if you click Schedule. Leave blank to Send now.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" type="submit" name="action" value="send_now">Send now</button>
|
||||
<button class="btn btn-ghost" type="submit" name="action" value="schedule">Schedule</button>
|
||||
<button class="btn btn-ghost" type="submit" name="action" value="save">Save draft</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if 'social_ai' in enabled_features %}
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>AI assist</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="id_prompt">Prompt</label>
|
||||
<textarea id="id_prompt" name="prompt" style="min-height:88px"
|
||||
placeholder="Warm post about this week’s offer…">{{ form.prompt }}</textarea>
|
||||
</div>
|
||||
<button class="btn btn-ghost" type="button" id="ai-generate" data-url="{% url 'social_ai:generate' %}">Generate draft (Ollama)</button>
|
||||
<p class="hint-block" id="ai-status">Generated text fills the caption field. Review before publishing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<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 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>
|
||||
|
||||
<p class="muted" style="font-size:13px;margin-top:12px">
|
||||
<a href="{% url 'social:post_list' %}">View recent posts</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
|
||||
{% if 'social_ai' in enabled_features %}
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('ai-generate');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var prompt = (document.getElementById('id_prompt') || {}).value || '';
|
||||
var status = document.getElementById('ai-status');
|
||||
var body = document.querySelector('textarea[name=body]');
|
||||
if (!prompt.trim()) { if (status) status.textContent = 'Enter a prompt first.'; return; }
|
||||
btn.disabled = true;
|
||||
fetch(btn.getAttribute('data-url'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||
},
|
||||
body: JSON.stringify({prompt: prompt})
|
||||
}).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok && res.d.text && body) { body.value = res.d.text; if (status) status.textContent = 'Draft inserted. Review before publishing.'; }
|
||||
else if (status) status.textContent = res.d.error || 'Generate failed.';
|
||||
})
|
||||
.catch(function () { if (status) status.textContent = 'Generate failed.'; })
|
||||
.finally(function () { btn.disabled = false; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
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 when = document.getElementById('id_scheduled_for');
|
||||
var composeForm = document.getElementById('social-compose');
|
||||
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 requireScheduleTime(event) {
|
||||
var submitter = event.submitter;
|
||||
var action = submitter && submitter.name === 'action' ? submitter.value : '';
|
||||
if (action !== 'schedule') return;
|
||||
if (when && when.value) return;
|
||||
event.preventDefault();
|
||||
if (when) when.focus();
|
||||
window.alert('Pick a date and time to schedule, or choose Send now.');
|
||||
}
|
||||
|
||||
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 (composeForm) composeForm.addEventListener('submit', requireScheduleTime);
|
||||
|
||||
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();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Post · Portal{% endblock %}
|
||||
{% block topbar_title %}Social post{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<span class="badge badge-{{ post.status }}">{{ post.get_status_display }}</span>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'social:post_list' %}">← All posts</a>
|
||||
</div>
|
||||
<div class="split">
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if post.ollama_prompt %}
|
||||
<p class="hint-block">AI prompt: {{ post.ollama_prompt }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Targets</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead><tr><th>Account</th><th>Platform</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for target in post.targets.all %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ target.account.display_name }}
|
||||
{% if target.account.external_id %}
|
||||
<br><span class="muted" style="overflow-wrap:anywhere">{{ target.account.external_id }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ target.get_platform_display }}</td>
|
||||
<td><span class="badge badge-{{ target.status }}">{{ target.get_status_display }}</span></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No targets attached.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Social posts · Portal{% endblock %}
|
||||
{% block topbar_title %}Scheduled & drafts{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<p class="muted" style="margin:0">Posts saved from the composer.</p>
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'social:composer' %}">Compose</a>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Preview</th><th>Status</th><th>Scheduled</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td>{{ post.body|truncatechars:80 }}</td>
|
||||
<td><span class="badge badge-{{ post.status }}">{{ post.get_status_display }}</span></td>
|
||||
<td>{% if post.scheduled_for %}{{ post.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}</td>
|
||||
<td><a href="{% url 'social:post_detail' post.pk %}">Open</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No posts yet. <a href="{% url 'social:composer' %}">Compose one</a>.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,207 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from social.models import (
|
||||
Platform,
|
||||
SocialAccount,
|
||||
SocialPost,
|
||||
is_generic_account_label,
|
||||
)
|
||||
|
||||
|
||||
class AccountLabelTests(TestCase):
|
||||
def test_generic_placeholders(self):
|
||||
self.assertTrue(is_generic_account_label(""))
|
||||
self.assertTrue(is_generic_account_label("LinkedIn member"))
|
||||
self.assertTrue(is_generic_account_label("Instagram account"))
|
||||
self.assertTrue(is_generic_account_label("Page 123456"))
|
||||
self.assertTrue(is_generic_account_label("urn:li:person:abc"))
|
||||
self.assertFalse(is_generic_account_label("Monica Dhillon"))
|
||||
self.assertFalse(is_generic_account_label("@mkdrealtor"))
|
||||
|
||||
def test_resolve_keeps_custom_name(self):
|
||||
existing = SocialAccount(
|
||||
platform=Platform.LINKEDIN,
|
||||
label="Monica (personal)",
|
||||
external_id="urn:li:person:abc",
|
||||
)
|
||||
self.assertEqual(
|
||||
SocialAccount.resolve_label(existing, "LinkedIn member"),
|
||||
"Monica (personal)",
|
||||
)
|
||||
|
||||
def test_resolve_replaces_generic(self):
|
||||
existing = SocialAccount(
|
||||
platform=Platform.LINKEDIN,
|
||||
label="LinkedIn member",
|
||||
external_id="urn:li:person:abc",
|
||||
)
|
||||
self.assertEqual(
|
||||
SocialAccount.resolve_label(existing, "Monica Dhillon"),
|
||||
"Monica Dhillon",
|
||||
)
|
||||
|
||||
|
||||
class SocialComposerTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="composer", password="test-pass-123"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="composer", password="test-pass-123")
|
||||
self.account = SocialAccount.objects.create(
|
||||
platform=Platform.LINKEDIN,
|
||||
label="LinkedIn member",
|
||||
external_id="urn:li:person:abc123",
|
||||
is_active=True,
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
def test_composer_shows_send_now_and_full_name(self):
|
||||
response = self.client.get(reverse("social:composer"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Send now")
|
||||
self.assertContains(response, "Schedule")
|
||||
self.assertContains(response, "LinkedIn member")
|
||||
self.assertContains(response, "urn:li:person:abc123")
|
||||
self.assertNotContains(response, "Save / publish")
|
||||
|
||||
def test_send_now_queues_without_schedule(self):
|
||||
mock_task = patch("social.views.publish_social_post").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
response = self.client.post(
|
||||
reverse("social:composer"),
|
||||
{
|
||||
"body": "Open house Saturday",
|
||||
"account_ids": [str(self.account.pk)],
|
||||
"action": "send_now",
|
||||
},
|
||||
)
|
||||
post = SocialPost.objects.get()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("social:post_detail", kwargs={"pk": post.pk})
|
||||
)
|
||||
self.assertEqual(post.status, SocialPost.Status.QUEUED)
|
||||
self.assertIsNotNone(post.scheduled_for)
|
||||
self.assertEqual(post.targets.count(), 1)
|
||||
mock_task.enqueue.assert_called_once_with(post_id=str(post.pk))
|
||||
|
||||
def test_schedule_requires_datetime(self):
|
||||
response = self.client.post(
|
||||
reverse("social:composer"),
|
||||
{
|
||||
"body": "Open house Saturday",
|
||||
"account_ids": [str(self.account.pk)],
|
||||
"action": "schedule",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Pick a schedule date/time, or choose Send now.")
|
||||
self.assertFalse(SocialPost.objects.exists())
|
||||
|
||||
def test_schedule_with_datetime(self):
|
||||
response = self.client.post(
|
||||
reverse("social:composer"),
|
||||
{
|
||||
"body": "Open house Saturday",
|
||||
"account_ids": [str(self.account.pk)],
|
||||
"action": "schedule",
|
||||
"scheduled_for": "2026-08-10T09:30",
|
||||
},
|
||||
)
|
||||
post = SocialPost.objects.get()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(post.status, SocialPost.Status.SCHEDULED)
|
||||
self.assertIsNotNone(post.scheduled_for)
|
||||
|
||||
def test_save_draft(self):
|
||||
response = self.client.post(
|
||||
reverse("social:composer"),
|
||||
{
|
||||
"body": "Draft caption",
|
||||
"action": "save",
|
||||
},
|
||||
)
|
||||
post = SocialPost.objects.get()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(post.status, SocialPost.Status.DRAFT)
|
||||
|
||||
|
||||
class SocialAccountRenameTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="renamer", password="test-pass-123"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="renamer", password="test-pass-123")
|
||||
self.account = SocialAccount.objects.create(
|
||||
platform=Platform.FACEBOOK,
|
||||
label="Page 999",
|
||||
external_id="999",
|
||||
is_active=True,
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
def test_accounts_page_shows_rename_field(self):
|
||||
response = self.client.get(reverse("social:account_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'name="label"')
|
||||
self.assertContains(response, "Page 999")
|
||||
self.assertContains(response, "Rename")
|
||||
|
||||
def test_rename_updates_label(self):
|
||||
response = self.client.post(
|
||||
reverse("social:account_list"),
|
||||
{
|
||||
"action": "rename",
|
||||
"account_id": str(self.account.pk),
|
||||
"label": "Monica Dhillon Realty",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.label, "Monica Dhillon Realty")
|
||||
|
||||
def test_rename_rejects_blank(self):
|
||||
response = self.client.post(
|
||||
reverse("social:account_list"),
|
||||
{
|
||||
"action": "rename",
|
||||
"account_id": str(self.account.pk),
|
||||
"label": " ",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.label, "Page 999")
|
||||
|
||||
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class DispatchDueSocialTests(TestCase):
|
||||
def test_enqueues_past_scheduled_social_post(self):
|
||||
post = SocialPost.objects.create(
|
||||
body="Open house",
|
||||
status=SocialPost.Status.SCHEDULED,
|
||||
scheduled_for=timezone.now() - timedelta(minutes=5),
|
||||
)
|
||||
mock_task = _patch("social.tasks.publish_social_post").start()
|
||||
self.addCleanup(_patch.stopall)
|
||||
out = StringIO()
|
||||
call_command("dispatch_due", stdout=out)
|
||||
post.refresh_from_db()
|
||||
self.assertEqual(post.status, SocialPost.Status.QUEUED)
|
||||
mock_task.enqueue.assert_called_once_with(post_id=str(post.pk))
|
||||
self.assertIn("Enqueued 1 due item(s).", out.getvalue())
|
||||
@@ -0,0 +1,43 @@
|
||||
from django.urls import path
|
||||
|
||||
from social import views
|
||||
|
||||
app_name = "social"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.post_list, name="post_list"),
|
||||
path("accounts/", views.account_list, name="account_list"),
|
||||
path(
|
||||
"accounts/linkedin/start/",
|
||||
views.linkedin_oauth_start,
|
||||
name="linkedin_oauth_start",
|
||||
),
|
||||
path(
|
||||
"accounts/linkedin/callback/",
|
||||
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("<uuid:pk>/", views.post_detail, name="post_detail"),
|
||||
]
|
||||
@@ -0,0 +1,716 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
|
||||
from core.scheduling import parse_scheduled_for
|
||||
from core.models import StoredFile
|
||||
from social.crypto import SocialCryptoError, encrypt_tokens
|
||||
from social.media import (
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
MAX_IMAGE_BYTES,
|
||||
MAX_VIDEO_BYTES,
|
||||
media_item_from_stored,
|
||||
parse_media_json,
|
||||
)
|
||||
from social.models import (
|
||||
Platform,
|
||||
SocialAccount,
|
||||
SocialAppCredentials,
|
||||
SocialPost,
|
||||
SocialPostTarget,
|
||||
)
|
||||
from social.oauth_linkedin import (
|
||||
LinkedInOAuthError,
|
||||
authorization_url as linkedin_authorization_url,
|
||||
credentials_configured as linkedin_credentials_configured,
|
||||
exchange_code as linkedin_exchange_code,
|
||||
fetch_member_profile as linkedin_fetch_member_profile,
|
||||
get_app_credentials as get_linkedin_app_credentials,
|
||||
redirect_uri as linkedin_redirect_uri,
|
||||
token_blob_from_oauth as linkedin_token_blob_from_oauth,
|
||||
)
|
||||
from social.oauth_meta import (
|
||||
MetaOAuthError,
|
||||
authorization_url as meta_authorization_url,
|
||||
credentials_configured as meta_credentials_configured,
|
||||
facebook_token_blob,
|
||||
fetch_pages,
|
||||
get_app_credentials as get_meta_app_credentials,
|
||||
instagram_token_blob,
|
||||
redirect_uri as meta_redirect_uri,
|
||||
)
|
||||
from social.tasks import publish_social_post
|
||||
|
||||
_META_DOCS = (
|
||||
"https://developers.facebook.com/documentation/instagram-platform/"
|
||||
"instagram-api-with-facebook-login/business-login-for-instagram"
|
||||
)
|
||||
|
||||
CONNECT_INSTRUCTIONS = {
|
||||
Platform.FACEBOOK: {
|
||||
"title": "Connect Facebook Page",
|
||||
"oauth": True,
|
||||
"oauth_platform": "facebook",
|
||||
"client_id_label": "Meta App ID",
|
||||
"client_secret_label": "Meta App Secret",
|
||||
"connect_label": "Connect with Facebook",
|
||||
"save_action": "save_meta_app",
|
||||
"callback_label": (
|
||||
"Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings"
|
||||
),
|
||||
"steps": [
|
||||
mark_safe(
|
||||
'In <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)."
|
||||
),
|
||||
"Under Facebook Login for Business → Settings → Valid OAuth Redirect URIs, "
|
||||
"paste the callback URL shown below (exact match required).",
|
||||
"Copy Meta App ID and App Secret into the fields below, then Save app credentials.",
|
||||
"Click Connect with Facebook — grant Page permissions; we store the Page "
|
||||
"access token for posting.",
|
||||
],
|
||||
"docs_url": _META_DOCS,
|
||||
"fields": [],
|
||||
},
|
||||
Platform.INSTAGRAM: {
|
||||
"title": "Connect Instagram",
|
||||
"oauth": True,
|
||||
"oauth_platform": "instagram",
|
||||
"client_id_label": "Meta App ID",
|
||||
"client_secret_label": "Meta App Secret",
|
||||
"connect_label": "Connect with Instagram",
|
||||
"save_action": "save_meta_app",
|
||||
"callback_label": (
|
||||
"Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings"
|
||||
),
|
||||
"steps": [
|
||||
mark_safe(
|
||||
'Follow <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 '
|
||||
'<a href="https://www.linkedin.com/developers/apps" target="_blank" '
|
||||
'rel="noopener">LinkedIn Developer Portal</a>.'
|
||||
),
|
||||
"Products tab — request “Sign In with LinkedIn using OpenID Connect” "
|
||||
"and “Share on LinkedIn”.",
|
||||
"Auth tab — under Authorized redirect URLs, paste the callback URL "
|
||||
"shown in the box below (exact match required).",
|
||||
"Auth tab — copy Client ID and Primary Client Secret into the fields below, "
|
||||
"then Save app credentials.",
|
||||
"Click Connect with LinkedIn to authorize. Access tokens last ~2 months; "
|
||||
"use Re-authorize when publishing fails with auth errors.",
|
||||
],
|
||||
"docs_url": "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow",
|
||||
"fields": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def post_list(request):
|
||||
posts = SocialPost.objects.all()[:100]
|
||||
return render(request, "social/post_list.html", {"posts": posts})
|
||||
|
||||
|
||||
@login_required
|
||||
def post_detail(request, pk):
|
||||
post = get_object_or_404(SocialPost.objects.prefetch_related("targets"), pk=pk)
|
||||
return render(request, "social/post_detail.html", {"post": post})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def account_list(request):
|
||||
accounts = SocialAccount.objects.all()
|
||||
connect_platform = (request.GET.get("connect") or "").strip()
|
||||
if connect_platform not in Platform.values:
|
||||
connect_platform = ""
|
||||
|
||||
if request.method == "POST":
|
||||
action = (request.POST.get("action") or "connect").strip()
|
||||
if action == "rename":
|
||||
pk = request.POST.get("account_id")
|
||||
account = get_object_or_404(SocialAccount, pk=pk)
|
||||
label = (request.POST.get("label") or "").strip()[:120]
|
||||
if not label:
|
||||
messages.error(request, "Enter a display name.")
|
||||
return redirect("social:account_list")
|
||||
account.label = label
|
||||
account.save(update_fields=["label", "updated_at"])
|
||||
messages.success(request, f"Renamed account to {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
if action == "disconnect":
|
||||
pk = request.POST.get("account_id")
|
||||
account = get_object_or_404(SocialAccount, pk=pk)
|
||||
account.is_active = False
|
||||
account.encrypted_tokens = ""
|
||||
account.save(update_fields=["is_active", "encrypted_tokens", "updated_at"])
|
||||
messages.success(request, f"Disconnected {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
if action == "reactivate":
|
||||
pk = request.POST.get("account_id")
|
||||
account = get_object_or_404(SocialAccount, pk=pk)
|
||||
account.is_active = True
|
||||
account.save(update_fields=["is_active", "updated_at"])
|
||||
messages.success(request, f"Reactivated {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
if action in {"save_linkedin_app", "save_meta_app", "save_instagram_app"}:
|
||||
if action == "save_linkedin_app":
|
||||
platform = Platform.LINKEDIN
|
||||
connect_platform_name = "linkedin"
|
||||
label = "LinkedIn"
|
||||
else:
|
||||
# Meta App ID/Secret shared by Facebook + Instagram (Facebook Login for Business).
|
||||
platform = Platform.FACEBOOK
|
||||
connect_return = (request.POST.get("connect_return") or "").strip()
|
||||
if connect_return not in {Platform.FACEBOOK, Platform.INSTAGRAM}:
|
||||
connect_return = Platform.FACEBOOK
|
||||
connect_platform_name = connect_return
|
||||
label = "Meta"
|
||||
connect_q = f"?connect={connect_platform_name}"
|
||||
client_id = (request.POST.get("client_id") or "").strip()
|
||||
client_secret = (request.POST.get("client_secret") or "").strip()
|
||||
if not client_id:
|
||||
messages.error(request, "App ID / Client ID is required.")
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
app, _created = SocialAppCredentials.objects.get_or_create(platform=platform)
|
||||
app.client_id = client_id
|
||||
if client_secret:
|
||||
try:
|
||||
app.encrypted_client_secret = encrypt_tokens(client_secret)
|
||||
except SocialCryptoError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
elif not app.encrypted_client_secret:
|
||||
messages.error(
|
||||
request,
|
||||
"App Secret is required the first time you save credentials.",
|
||||
)
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
app.save()
|
||||
messages.success(
|
||||
request,
|
||||
f"{label} app credentials saved. You can Connect now.",
|
||||
)
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
|
||||
platform = (request.POST.get("platform") or "").strip()
|
||||
if platform not in Platform.values:
|
||||
messages.error(request, "Choose a platform.")
|
||||
return redirect("social:account_list")
|
||||
# Manual token paste remains as fallback for non-OAuth platforms only.
|
||||
if platform in {Platform.FACEBOOK, Platform.INSTAGRAM, Platform.LINKEDIN}:
|
||||
messages.error(
|
||||
request,
|
||||
"Use Connect with OAuth for this platform (Save app credentials first).",
|
||||
)
|
||||
return redirect(f"{request.path}?connect={platform}")
|
||||
label = (request.POST.get("label") or "").strip() or platform.title()
|
||||
external_id = (request.POST.get("external_id") or "").strip()
|
||||
access_token = (request.POST.get("access_token") or "").strip()
|
||||
if not access_token or not external_id:
|
||||
messages.error(request, "Access token and external ID are required.")
|
||||
return redirect(f"{request.path}?connect={platform}")
|
||||
|
||||
token_blob = {"access_token": access_token}
|
||||
try:
|
||||
encrypted = encrypt_tokens(json.dumps(token_blob))
|
||||
except SocialCryptoError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{request.path}?connect={platform}")
|
||||
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=platform,
|
||||
external_id=external_id,
|
||||
defaults={
|
||||
"label": label,
|
||||
"encrypted_tokens": encrypted,
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
verb = "Connected" if created else "Updated"
|
||||
messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
linkedin_app = get_linkedin_app_credentials()
|
||||
meta_app = get_meta_app_credentials()
|
||||
linkedin_callback = ""
|
||||
meta_callback = ""
|
||||
try:
|
||||
linkedin_callback = linkedin_redirect_uri()
|
||||
except LinkedInOAuthError:
|
||||
linkedin_callback = "(set PUBLIC_SITE_URL, then restart)"
|
||||
try:
|
||||
meta_callback = meta_redirect_uri()
|
||||
except MetaOAuthError:
|
||||
meta_callback = "(set PUBLIC_SITE_URL, then restart)"
|
||||
|
||||
meta_ready = meta_credentials_configured()
|
||||
meta_client_id = meta_app.client_id if meta_app else ""
|
||||
meta_secret_saved = bool(meta_app and meta_app.encrypted_client_secret)
|
||||
oauth_ctx = {
|
||||
"linkedin": {
|
||||
"ready": linkedin_credentials_configured(),
|
||||
"callback_url": linkedin_callback,
|
||||
"client_id": (linkedin_app.client_id if linkedin_app else ""),
|
||||
"secret_saved": bool(
|
||||
linkedin_app and linkedin_app.encrypted_client_secret
|
||||
),
|
||||
"start_url_name": "social:linkedin_oauth_start",
|
||||
},
|
||||
"facebook": {
|
||||
"ready": meta_ready,
|
||||
"callback_url": meta_callback,
|
||||
"client_id": meta_client_id,
|
||||
"secret_saved": meta_secret_saved,
|
||||
"start_url_name": "social:facebook_oauth_start",
|
||||
},
|
||||
"instagram": {
|
||||
"ready": meta_ready,
|
||||
"callback_url": meta_callback,
|
||||
"client_id": meta_client_id,
|
||||
"secret_saved": meta_secret_saved,
|
||||
"start_url_name": "social:instagram_oauth_start",
|
||||
},
|
||||
}
|
||||
oauth_state = oauth_ctx.get(connect_platform) or {}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"social/account_list.html",
|
||||
{
|
||||
"accounts": accounts,
|
||||
"connect_platform": connect_platform,
|
||||
"connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
|
||||
"platforms": Platform,
|
||||
"oauth_ready": oauth_state.get("ready", False),
|
||||
"oauth_callback_url": oauth_state.get("callback_url", ""),
|
||||
"oauth_client_id": oauth_state.get("client_id", ""),
|
||||
"oauth_secret_saved": oauth_state.get("secret_saved", False),
|
||||
"oauth_start_url_name": oauth_state.get("start_url_name", ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def linkedin_oauth_start(request):
|
||||
"""Redirect browser to LinkedIn consent screen."""
|
||||
if not linkedin_credentials_configured():
|
||||
messages.error(
|
||||
request,
|
||||
"Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.",
|
||||
)
|
||||
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
||||
state = secrets.token_urlsafe(24)
|
||||
request.session["linkedin_oauth_state"] = state
|
||||
try:
|
||||
return redirect(linkedin_authorization_url(state=state))
|
||||
except LinkedInOAuthError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def linkedin_oauth_callback(request):
|
||||
"""Handle LinkedIn redirect: exchange code, store encrypted SocialAccount."""
|
||||
error = (request.GET.get("error") or "").strip()
|
||||
if error:
|
||||
desc = (request.GET.get("error_description") or error).strip()
|
||||
messages.error(request, f"LinkedIn authorization denied: {desc}")
|
||||
return redirect("social:account_list")
|
||||
|
||||
state = (request.GET.get("state") or "").strip()
|
||||
expected = request.session.pop("linkedin_oauth_state", None)
|
||||
if not state or not expected or state != expected:
|
||||
messages.error(request, "LinkedIn OAuth state mismatch — try Connect again.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
code = (request.GET.get("code") or "").strip()
|
||||
if not code:
|
||||
messages.error(request, "LinkedIn did not return an authorization code.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
try:
|
||||
token_payload = linkedin_exchange_code(code)
|
||||
profile = linkedin_fetch_member_profile(token_payload["access_token"])
|
||||
author_urn = profile["author_urn"]
|
||||
blob = linkedin_token_blob_from_oauth(token_payload, author_urn=author_urn)
|
||||
existing = SocialAccount.objects.filter(
|
||||
platform=Platform.LINKEDIN, external_id=author_urn
|
||||
).first()
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=Platform.LINKEDIN,
|
||||
external_id=author_urn,
|
||||
defaults={
|
||||
"label": SocialAccount.resolve_label(existing, profile["label"]),
|
||||
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
except (LinkedInOAuthError, SocialCryptoError) as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect("social:account_list")
|
||||
except Exception:
|
||||
messages.error(
|
||||
request,
|
||||
"Unexpected error saving LinkedIn account. Check app logs.",
|
||||
)
|
||||
raise
|
||||
|
||||
verb = "Connected" if created else "Re-authorized"
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} LinkedIn · {account.label}. You can post from the composer.",
|
||||
)
|
||||
return redirect("social:account_list")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def facebook_oauth_start(request):
|
||||
"""Start Facebook Login for Business; save Facebook Page account on complete."""
|
||||
return _meta_oauth_start(request, intent=Platform.FACEBOOK)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def instagram_oauth_start(request):
|
||||
"""Start Facebook Login for Business; save linked Instagram Business account."""
|
||||
return _meta_oauth_start(request, intent=Platform.INSTAGRAM)
|
||||
|
||||
|
||||
def _meta_oauth_start(request, *, intent: str):
|
||||
connect_q = f"?connect={intent}"
|
||||
if not meta_credentials_configured():
|
||||
messages.error(
|
||||
request,
|
||||
"Save Meta App ID and App Secret on the Connect form first.",
|
||||
)
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
state = secrets.token_urlsafe(24)
|
||||
request.session["meta_oauth_state"] = state
|
||||
request.session["meta_oauth_intent"] = intent
|
||||
try:
|
||||
return redirect(meta_authorization_url(state=state))
|
||||
except MetaOAuthError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def meta_oauth_callback(request):
|
||||
"""
|
||||
Landing page after Facebook redirect.
|
||||
|
||||
Tokens arrive in the URL fragment (#) per Meta docs (response_type=token),
|
||||
so JS reads them and POSTs to meta_oauth_complete.
|
||||
"""
|
||||
return render(request, "social/meta_oauth_callback.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def meta_oauth_complete(request):
|
||||
"""Persist Facebook Page and/or Instagram accounts from Facebook Login tokens."""
|
||||
intent = request.session.pop("meta_oauth_intent", Platform.FACEBOOK)
|
||||
connect_q = f"?connect={intent}"
|
||||
|
||||
error = (request.POST.get("error") or "").strip()
|
||||
if error:
|
||||
desc = (request.POST.get("error_description") or error).strip()
|
||||
messages.error(request, f"Facebook authorization denied: {desc}")
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
state = (request.POST.get("state") or "").strip()
|
||||
expected = request.session.pop("meta_oauth_state", None)
|
||||
if not state or not expected or state != expected:
|
||||
messages.error(request, "Meta OAuth state mismatch — try Connect again.")
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
user_token = (
|
||||
(request.POST.get("long_lived_token") or "").strip()
|
||||
or (request.POST.get("access_token") or "").strip()
|
||||
)
|
||||
if not user_token:
|
||||
messages.error(request, "Facebook did not return an access token.")
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
try:
|
||||
pages = fetch_pages(user_token)
|
||||
connected = []
|
||||
|
||||
if intent == Platform.INSTAGRAM:
|
||||
ig_pages = [p for p in pages if (p.get("instagram_business_account") or {}).get("id")]
|
||||
if not ig_pages:
|
||||
raise MetaOAuthError(
|
||||
"No Instagram Professional account linked to your Facebook Pages. "
|
||||
"Link IG in Meta Business Suite, then Connect again."
|
||||
)
|
||||
for page in ig_pages:
|
||||
blob = instagram_token_blob(page, user_token=user_token)
|
||||
existing = SocialAccount.objects.filter(
|
||||
platform=Platform.INSTAGRAM, external_id=blob["ig_user_id"]
|
||||
).first()
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=Platform.INSTAGRAM,
|
||||
external_id=blob["ig_user_id"],
|
||||
defaults={
|
||||
"label": SocialAccount.resolve_label(existing, blob["label"]),
|
||||
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
connected.append(("Connected" if created else "Re-authorized", account))
|
||||
else:
|
||||
for page in pages:
|
||||
blob = facebook_token_blob(page, user_token=user_token)
|
||||
incoming_label = (page.get("name") or "").strip() or f"Page {page['id']}"
|
||||
existing = SocialAccount.objects.filter(
|
||||
platform=Platform.FACEBOOK, external_id=str(page["id"])
|
||||
).first()
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=Platform.FACEBOOK,
|
||||
external_id=str(page["id"]),
|
||||
defaults={
|
||||
"label": SocialAccount.resolve_label(existing, incoming_label),
|
||||
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
connected.append(("Connected" if created else "Re-authorized", account))
|
||||
except (MetaOAuthError, SocialCryptoError) as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
except Exception:
|
||||
messages.error(
|
||||
request,
|
||||
"Unexpected error saving Meta account. Check app logs.",
|
||||
)
|
||||
raise
|
||||
|
||||
for verb, account in connected:
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} {account.get_platform_display()} · {account.label}.",
|
||||
)
|
||||
return redirect("social:account_list")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def composer(request):
|
||||
"""Compose, schedule, or publish to connected social accounts."""
|
||||
accounts = list(SocialAccount.objects.filter(is_active=True))
|
||||
form = {
|
||||
"body": "",
|
||||
"prompt": "",
|
||||
"publish_mode": "now",
|
||||
"scheduled_for": "",
|
||||
"account_ids": [],
|
||||
"media_json": "[]",
|
||||
}
|
||||
error = ""
|
||||
draft = ""
|
||||
|
||||
if request.method == "POST":
|
||||
form["body"] = (request.POST.get("body") or "").strip()
|
||||
form["prompt"] = (request.POST.get("prompt") or "").strip()
|
||||
form["publish_mode"] = (request.POST.get("publish_mode") or "now").strip()
|
||||
form["scheduled_for"] = request.POST.get("scheduled_for") or ""
|
||||
form["account_ids"] = request.POST.getlist("account_ids")
|
||||
form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]"
|
||||
action = (request.POST.get("action") or "save").strip()
|
||||
if action == "send_now":
|
||||
form["publish_mode"] = "now"
|
||||
elif action == "schedule":
|
||||
form["publish_mode"] = "schedule"
|
||||
elif action == "save":
|
||||
form["publish_mode"] = "draft"
|
||||
|
||||
if action in {"save", "publish", "send_now", "schedule"}:
|
||||
media_items: list = []
|
||||
try:
|
||||
media_items = parse_media_json(form["media_json"])
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
|
||||
if not error and not form["body"] and not media_items:
|
||||
error = "Add a caption and/or attach an image or video."
|
||||
elif not error and form["publish_mode"] != "draft" and not form["account_ids"]:
|
||||
error = "Select at least one connected account."
|
||||
else:
|
||||
scheduled_for = None
|
||||
status = SocialPost.Status.DRAFT
|
||||
if form["publish_mode"] == "draft":
|
||||
status = SocialPost.Status.DRAFT
|
||||
elif form["publish_mode"] == "schedule":
|
||||
try:
|
||||
scheduled_for = parse_scheduled_for(form["scheduled_for"])
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
else:
|
||||
if not scheduled_for:
|
||||
error = "Pick a schedule date/time, or choose Send now."
|
||||
else:
|
||||
status = SocialPost.Status.SCHEDULED
|
||||
elif form["publish_mode"] == "now":
|
||||
status = SocialPost.Status.QUEUED
|
||||
scheduled_for = timezone.now()
|
||||
else:
|
||||
error = "Unknown publish mode."
|
||||
|
||||
if not error:
|
||||
selected = SocialAccount.objects.filter(
|
||||
pk__in=form["account_ids"], is_active=True
|
||||
)
|
||||
if form["publish_mode"] != "draft" and not selected.exists():
|
||||
error = "No valid accounts selected."
|
||||
elif (
|
||||
form["publish_mode"] != "draft"
|
||||
and selected.filter(platform=Platform.INSTAGRAM).exists()
|
||||
and not media_items
|
||||
):
|
||||
error = (
|
||||
"Instagram requires an image or video. "
|
||||
"Attach media before publishing to Instagram."
|
||||
)
|
||||
else:
|
||||
post = SocialPost.objects.create(
|
||||
body=form["body"],
|
||||
media=media_items,
|
||||
ollama_prompt=form["prompt"],
|
||||
scheduled_for=scheduled_for,
|
||||
status=status,
|
||||
created_by=request.user,
|
||||
)
|
||||
for account in selected:
|
||||
SocialPostTarget.objects.create(
|
||||
post=post,
|
||||
account=account,
|
||||
platform=account.platform,
|
||||
)
|
||||
if status == SocialPost.Status.QUEUED:
|
||||
publish_social_post.enqueue(post_id=str(post.pk))
|
||||
messages.success(
|
||||
request,
|
||||
"Post sent — queued for publishing to selected accounts.",
|
||||
)
|
||||
elif status == SocialPost.Status.SCHEDULED:
|
||||
messages.success(
|
||||
request,
|
||||
f"Post scheduled for {scheduled_for:%b %d, %I:%M %p}.",
|
||||
)
|
||||
else:
|
||||
messages.success(request, "Draft saved.")
|
||||
return redirect("social:post_detail", pk=post.pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"social/composer.html",
|
||||
{
|
||||
"accounts": accounts,
|
||||
"form": form,
|
||||
"error": error,
|
||||
"draft": draft or form["body"],
|
||||
"media_upload_url": reverse("social:media_upload"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def media_upload(request):
|
||||
"""Upload image/video for social compose; store bytes in StoredFile."""
|
||||
upload = (
|
||||
request.FILES.get("media")
|
||||
or request.FILES.get("file")
|
||||
or request.FILES.get("image")
|
||||
or request.FILES.get("video")
|
||||
)
|
||||
if not upload:
|
||||
return JsonResponse({"error": "No file uploaded."}, status=400)
|
||||
|
||||
content_type = (getattr(upload, "content_type", None) or "").lower()
|
||||
if content_type in ALLOWED_IMAGE_TYPES:
|
||||
kind = StoredFile.Kind.SOCIAL_IMAGE
|
||||
max_bytes = MAX_IMAGE_BYTES
|
||||
label = "Image"
|
||||
elif content_type in ALLOWED_VIDEO_TYPES:
|
||||
kind = StoredFile.Kind.SOCIAL_VIDEO
|
||||
max_bytes = MAX_VIDEO_BYTES
|
||||
label = "Video"
|
||||
else:
|
||||
return JsonResponse(
|
||||
{
|
||||
"error": "Use JPEG/PNG/GIF/WebP images or MP4/MOV/WebM video.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
size = int(getattr(upload, "size", 0) or 0)
|
||||
if size and size > max_bytes:
|
||||
return JsonResponse(
|
||||
{"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
data = upload.read()
|
||||
if len(data) > max_bytes:
|
||||
return JsonResponse(
|
||||
{"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
original = (getattr(upload, "name", None) or "media")[:255]
|
||||
stored = StoredFile.objects.create(
|
||||
kind=kind,
|
||||
filename=original,
|
||||
content_type=content_type,
|
||||
size=len(data),
|
||||
data=data,
|
||||
uploaded_by=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
item = media_item_from_stored(stored)
|
||||
return JsonResponse(item)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user