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:
2026-08-26 07:55:26 -05:00
co-authored by Cursor
parent 45d0888d33
commit 787f0e48fb
297 changed files with 32534 additions and 3 deletions
View File
+27
View File
@@ -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]
+11
View File
@@ -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()
View File
+10
View File
@@ -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: ...
+163
View File
@@ -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)
+250
View File
@@ -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,
)
+67
View File
@@ -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
+57
View File
@@ -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
View File
+91
View File
@@ -0,0 +1,91 @@
"""Helpers for social post media (StoredFile-backed)."""
from __future__ import annotations
import json
from typing import Any
from django.conf import settings
from django.urls import reverse
from 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
+85
View File
@@ -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')},
},
),
]
View File
+149
View File
@@ -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")
+188
View File
@@ -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
+185
View File
@@ -0,0 +1,185 @@
"""Facebook Login for Business (Instagram API with Facebook Login).
Docs:
https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-facebook-login/business-login-for-instagram
Uses response_type=token (tokens arrive in the URL fragment). A thin callback
page reads the fragment and POSTs tokens to the server.
"""
from __future__ import annotations
import json
import logging
from datetime import timedelta
from urllib.parse import urlencode
import requests
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from social.crypto import decrypt_tokens
from social.models import Platform, SocialAppCredentials
logger = logging.getLogger(__name__)
GRAPH_VERSION = "v21.0"
GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"
AUTHORIZE_URL = f"https://www.facebook.com/{GRAPH_VERSION}/dialog/oauth"
# Permissions for Page posting + Instagram content publish via Facebook Login.
DEFAULT_SCOPES = (
"instagram_basic",
"instagram_content_publish",
"pages_show_list",
"pages_read_engagement",
"pages_manage_posts",
)
# Triggers Instagram Professional onboarding inside Facebook Login for Business.
IG_ONBOARDING_EXTRAS = {"setup": {"channel": "IG_API_ONBOARDING"}}
class MetaOAuthError(RuntimeError):
"""Raised when Facebook Login for Business or Page/IG lookup fails."""
def get_app_credentials() -> SocialAppCredentials | None:
"""Meta App ID/Secret — stored on the facebook credentials row (shared)."""
fb = SocialAppCredentials.objects.filter(platform=Platform.FACEBOOK).first()
if fb and fb.is_configured:
return fb
# Back-compat if credentials were saved from the Instagram form earlier.
return SocialAppCredentials.objects.filter(platform=Platform.INSTAGRAM).first()
def load_client_credentials() -> tuple[str, str]:
app = get_app_credentials()
if not app or not app.client_id or not app.encrypted_client_secret:
raise MetaOAuthError(
"Meta app credentials are not saved yet. "
"Enter Meta App ID and App Secret on the Connect Facebook or Instagram form."
)
try:
secret = decrypt_tokens(app.encrypted_client_secret)
except ValueError as exc:
raise MetaOAuthError(
"Could not decrypt Meta app secret. Re-enter the App Secret."
) from exc
if not secret:
raise MetaOAuthError("Meta app secret is empty — save it again.")
return app.client_id.strip(), secret.strip()
def credentials_configured() -> bool:
app = get_app_credentials()
return bool(app and app.is_configured)
def redirect_uri() -> str:
"""Must match Valid OAuth Redirect URIs (Facebook Login for Business settings)."""
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if not base:
raise MetaOAuthError(
"PUBLIC_SITE_URL is not set; cannot build Meta OAuth redirect URI."
)
return f"{base}{reverse('social:meta_oauth_callback')}"
def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str:
client_id, _secret = load_client_credentials()
params = {
"client_id": client_id,
"display": "page",
"extras": json.dumps(IG_ONBOARDING_EXTRAS, separators=(",", ":")),
"redirect_uri": redirect_uri(),
"response_type": "token",
"scope": ",".join(scopes),
"state": state,
}
return f"{AUTHORIZE_URL}?{urlencode(params)}"
def fetch_pages(user_access_token: str) -> list[dict]:
"""
GET /me/accounts — Pages the user can manage, with linked IG business accounts.
Each item: id, name, access_token (page), instagram_business_account (optional).
"""
response = requests.get(
f"{GRAPH}/me/accounts",
params={
"fields": "id,name,access_token,instagram_business_account{id,username,name}",
"access_token": user_access_token,
},
timeout=30,
)
if response.status_code >= 400:
logger.warning(
"Meta /me/accounts failed: %s %s",
response.status_code,
response.text[:500],
)
raise MetaOAuthError(
f"Could not list Facebook Pages ({response.status_code}). "
"Confirm pages_show_list was granted and the user manages a Page."
)
data = response.json().get("data") or []
if not data:
raise MetaOAuthError(
"No Facebook Pages found for this user. Create a Page and link a "
"Professional Instagram account, then try again."
)
return data
def facebook_token_blob(page: dict, *, user_token: str = "") -> dict:
page_token = page.get("access_token") or ""
if not page_token:
raise MetaOAuthError(f"Page {page.get('id')} missing access_token.")
blob: dict = {
"access_token": page_token,
"page_id": str(page["id"]),
"user_access_token": user_token,
"auth_type": "facebook_login_for_business",
}
ig = page.get("instagram_business_account") or {}
if ig.get("id"):
blob["ig_user_id"] = str(ig["id"])
return blob
def instagram_token_blob(page: dict, *, user_token: str = "") -> dict:
ig = page.get("instagram_business_account") or {}
ig_id = str(ig.get("id") or "").strip()
if not ig_id:
raise MetaOAuthError(
f"Page “{page.get('name') or page.get('id')}” has no linked "
"Instagram Professional account."
)
page_token = page.get("access_token") or ""
if not page_token:
raise MetaOAuthError("Page access token missing — required for Instagram Graph API.")
username = (ig.get("username") or "").strip()
name = (ig.get("name") or "").strip()
label = f"@{username}" if username else (name or "Instagram account")
return {
"access_token": page_token,
"page_id": str(page["id"]),
"ig_user_id": ig_id,
"username": username,
"label": label,
"user_access_token": user_token,
"auth_type": "facebook_login_for_business",
}
def expires_at_from_fragment(expires_in: str | int | None) -> str | None:
try:
seconds = int(expires_in or 0)
except (TypeError, ValueError):
return None
if seconds <= 0:
return None
return (timezone.now() + timedelta(seconds=seconds)).isoformat()
+59
View File
@@ -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 %}
+414
View File
@@ -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&nbsp;MB) or one video (MP4/MOV/WebM, ≤100&nbsp;MB).
Instagram requires media. Dont mix images + video on one post.
</div>
<div id="media-upload-status" class="muted" hidden></div>
<div class="media-thumbs" id="media-thumbs"></div>
</div>
</div>
<div class="field">
<label>Accounts</label>
{% for account in accounts %}
<label class="check-row 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 weeks 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 &amp; 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 %}
+207
View File
@@ -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())
+43
View File
@@ -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"),
]
+716
View File
@@ -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)