Template
Populate the client website template with catalog feature flags.
Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed. Refs #1 Refs #2 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,716 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
|
||||
from core.scheduling import parse_scheduled_for
|
||||
from core.models import StoredFile
|
||||
from social.crypto import SocialCryptoError, encrypt_tokens
|
||||
from social.media import (
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
MAX_IMAGE_BYTES,
|
||||
MAX_VIDEO_BYTES,
|
||||
media_item_from_stored,
|
||||
parse_media_json,
|
||||
)
|
||||
from social.models import (
|
||||
Platform,
|
||||
SocialAccount,
|
||||
SocialAppCredentials,
|
||||
SocialPost,
|
||||
SocialPostTarget,
|
||||
)
|
||||
from social.oauth_linkedin import (
|
||||
LinkedInOAuthError,
|
||||
authorization_url as linkedin_authorization_url,
|
||||
credentials_configured as linkedin_credentials_configured,
|
||||
exchange_code as linkedin_exchange_code,
|
||||
fetch_member_profile as linkedin_fetch_member_profile,
|
||||
get_app_credentials as get_linkedin_app_credentials,
|
||||
redirect_uri as linkedin_redirect_uri,
|
||||
token_blob_from_oauth as linkedin_token_blob_from_oauth,
|
||||
)
|
||||
from social.oauth_meta import (
|
||||
MetaOAuthError,
|
||||
authorization_url as meta_authorization_url,
|
||||
credentials_configured as meta_credentials_configured,
|
||||
facebook_token_blob,
|
||||
fetch_pages,
|
||||
get_app_credentials as get_meta_app_credentials,
|
||||
instagram_token_blob,
|
||||
redirect_uri as meta_redirect_uri,
|
||||
)
|
||||
from social.tasks import publish_social_post
|
||||
|
||||
_META_DOCS = (
|
||||
"https://developers.facebook.com/documentation/instagram-platform/"
|
||||
"instagram-api-with-facebook-login/business-login-for-instagram"
|
||||
)
|
||||
|
||||
CONNECT_INSTRUCTIONS = {
|
||||
Platform.FACEBOOK: {
|
||||
"title": "Connect Facebook Page",
|
||||
"oauth": True,
|
||||
"oauth_platform": "facebook",
|
||||
"client_id_label": "Meta App ID",
|
||||
"client_secret_label": "Meta App Secret",
|
||||
"connect_label": "Connect with Facebook",
|
||||
"save_action": "save_meta_app",
|
||||
"callback_label": (
|
||||
"Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings"
|
||||
),
|
||||
"steps": [
|
||||
mark_safe(
|
||||
'In <a href="https://developers.facebook.com/apps/" target="_blank" '
|
||||
'rel="noopener">Meta for Developers</a>, use a Business-type app and add '
|
||||
"<strong>Facebook Login for Business</strong> plus Instagram "
|
||||
"(API setup with Facebook login)."
|
||||
),
|
||||
"Under Facebook Login for Business → Settings → Valid OAuth Redirect URIs, "
|
||||
"paste the callback URL shown below (exact match required).",
|
||||
"Copy Meta App ID and App Secret into the fields below, then Save app credentials.",
|
||||
"Click Connect with Facebook — grant Page permissions; we store the Page "
|
||||
"access token for posting.",
|
||||
],
|
||||
"docs_url": _META_DOCS,
|
||||
"fields": [],
|
||||
},
|
||||
Platform.INSTAGRAM: {
|
||||
"title": "Connect Instagram",
|
||||
"oauth": True,
|
||||
"oauth_platform": "instagram",
|
||||
"client_id_label": "Meta App ID",
|
||||
"client_secret_label": "Meta App Secret",
|
||||
"connect_label": "Connect with Instagram",
|
||||
"save_action": "save_meta_app",
|
||||
"callback_label": (
|
||||
"Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings"
|
||||
),
|
||||
"steps": [
|
||||
mark_safe(
|
||||
'Follow <a href="'
|
||||
+ _META_DOCS
|
||||
+ '" target="_blank" rel="noopener">Facebook Login for Business</a> '
|
||||
"for Instagram — Professional IG account linked to a Facebook Page."
|
||||
),
|
||||
"Same Meta App ID / Secret as Facebook (shared). Save credentials once.",
|
||||
"Add the redirect URI below to Valid OAuth Redirect URIs, then Connect with Instagram.",
|
||||
"We request Page + Instagram publish scopes and save the linked Instagram "
|
||||
"Business account using the Page access token.",
|
||||
],
|
||||
"docs_url": _META_DOCS,
|
||||
"fields": [],
|
||||
},
|
||||
Platform.LINKEDIN: {
|
||||
"title": "Connect LinkedIn",
|
||||
"oauth": True,
|
||||
"oauth_platform": "linkedin",
|
||||
"client_id_label": "Client ID",
|
||||
"client_secret_label": "Primary Client Secret",
|
||||
"connect_label": "Connect with LinkedIn",
|
||||
"save_action": "save_linkedin_app",
|
||||
"callback_label": "Authorized redirect URL — paste into LinkedIn Auth tab",
|
||||
"steps": [
|
||||
mark_safe(
|
||||
'Create a LinkedIn app in the '
|
||||
'<a href="https://www.linkedin.com/developers/apps" target="_blank" '
|
||||
'rel="noopener">LinkedIn Developer Portal</a>.'
|
||||
),
|
||||
"Products tab — request “Sign In with LinkedIn using OpenID Connect” "
|
||||
"and “Share on LinkedIn”.",
|
||||
"Auth tab — under Authorized redirect URLs, paste the callback URL "
|
||||
"shown in the box below (exact match required).",
|
||||
"Auth tab — copy Client ID and Primary Client Secret into the fields below, "
|
||||
"then Save app credentials.",
|
||||
"Click Connect with LinkedIn to authorize. Access tokens last ~2 months; "
|
||||
"use Re-authorize when publishing fails with auth errors.",
|
||||
],
|
||||
"docs_url": "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow",
|
||||
"fields": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def post_list(request):
|
||||
posts = SocialPost.objects.all()[:100]
|
||||
return render(request, "social/post_list.html", {"posts": posts})
|
||||
|
||||
|
||||
@login_required
|
||||
def post_detail(request, pk):
|
||||
post = get_object_or_404(SocialPost.objects.prefetch_related("targets"), pk=pk)
|
||||
return render(request, "social/post_detail.html", {"post": post})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def account_list(request):
|
||||
accounts = SocialAccount.objects.all()
|
||||
connect_platform = (request.GET.get("connect") or "").strip()
|
||||
if connect_platform not in Platform.values:
|
||||
connect_platform = ""
|
||||
|
||||
if request.method == "POST":
|
||||
action = (request.POST.get("action") or "connect").strip()
|
||||
if action == "rename":
|
||||
pk = request.POST.get("account_id")
|
||||
account = get_object_or_404(SocialAccount, pk=pk)
|
||||
label = (request.POST.get("label") or "").strip()[:120]
|
||||
if not label:
|
||||
messages.error(request, "Enter a display name.")
|
||||
return redirect("social:account_list")
|
||||
account.label = label
|
||||
account.save(update_fields=["label", "updated_at"])
|
||||
messages.success(request, f"Renamed account to {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
if action == "disconnect":
|
||||
pk = request.POST.get("account_id")
|
||||
account = get_object_or_404(SocialAccount, pk=pk)
|
||||
account.is_active = False
|
||||
account.encrypted_tokens = ""
|
||||
account.save(update_fields=["is_active", "encrypted_tokens", "updated_at"])
|
||||
messages.success(request, f"Disconnected {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
if action == "reactivate":
|
||||
pk = request.POST.get("account_id")
|
||||
account = get_object_or_404(SocialAccount, pk=pk)
|
||||
account.is_active = True
|
||||
account.save(update_fields=["is_active", "updated_at"])
|
||||
messages.success(request, f"Reactivated {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
if action in {"save_linkedin_app", "save_meta_app", "save_instagram_app"}:
|
||||
if action == "save_linkedin_app":
|
||||
platform = Platform.LINKEDIN
|
||||
connect_platform_name = "linkedin"
|
||||
label = "LinkedIn"
|
||||
else:
|
||||
# Meta App ID/Secret shared by Facebook + Instagram (Facebook Login for Business).
|
||||
platform = Platform.FACEBOOK
|
||||
connect_return = (request.POST.get("connect_return") or "").strip()
|
||||
if connect_return not in {Platform.FACEBOOK, Platform.INSTAGRAM}:
|
||||
connect_return = Platform.FACEBOOK
|
||||
connect_platform_name = connect_return
|
||||
label = "Meta"
|
||||
connect_q = f"?connect={connect_platform_name}"
|
||||
client_id = (request.POST.get("client_id") or "").strip()
|
||||
client_secret = (request.POST.get("client_secret") or "").strip()
|
||||
if not client_id:
|
||||
messages.error(request, "App ID / Client ID is required.")
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
app, _created = SocialAppCredentials.objects.get_or_create(platform=platform)
|
||||
app.client_id = client_id
|
||||
if client_secret:
|
||||
try:
|
||||
app.encrypted_client_secret = encrypt_tokens(client_secret)
|
||||
except SocialCryptoError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
elif not app.encrypted_client_secret:
|
||||
messages.error(
|
||||
request,
|
||||
"App Secret is required the first time you save credentials.",
|
||||
)
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
app.save()
|
||||
messages.success(
|
||||
request,
|
||||
f"{label} app credentials saved. You can Connect now.",
|
||||
)
|
||||
return redirect(f"{request.path}{connect_q}")
|
||||
|
||||
platform = (request.POST.get("platform") or "").strip()
|
||||
if platform not in Platform.values:
|
||||
messages.error(request, "Choose a platform.")
|
||||
return redirect("social:account_list")
|
||||
# Manual token paste remains as fallback for non-OAuth platforms only.
|
||||
if platform in {Platform.FACEBOOK, Platform.INSTAGRAM, Platform.LINKEDIN}:
|
||||
messages.error(
|
||||
request,
|
||||
"Use Connect with OAuth for this platform (Save app credentials first).",
|
||||
)
|
||||
return redirect(f"{request.path}?connect={platform}")
|
||||
label = (request.POST.get("label") or "").strip() or platform.title()
|
||||
external_id = (request.POST.get("external_id") or "").strip()
|
||||
access_token = (request.POST.get("access_token") or "").strip()
|
||||
if not access_token or not external_id:
|
||||
messages.error(request, "Access token and external ID are required.")
|
||||
return redirect(f"{request.path}?connect={platform}")
|
||||
|
||||
token_blob = {"access_token": access_token}
|
||||
try:
|
||||
encrypted = encrypt_tokens(json.dumps(token_blob))
|
||||
except SocialCryptoError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{request.path}?connect={platform}")
|
||||
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=platform,
|
||||
external_id=external_id,
|
||||
defaults={
|
||||
"label": label,
|
||||
"encrypted_tokens": encrypted,
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
verb = "Connected" if created else "Updated"
|
||||
messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
linkedin_app = get_linkedin_app_credentials()
|
||||
meta_app = get_meta_app_credentials()
|
||||
linkedin_callback = ""
|
||||
meta_callback = ""
|
||||
try:
|
||||
linkedin_callback = linkedin_redirect_uri()
|
||||
except LinkedInOAuthError:
|
||||
linkedin_callback = "(set PUBLIC_SITE_URL, then restart)"
|
||||
try:
|
||||
meta_callback = meta_redirect_uri()
|
||||
except MetaOAuthError:
|
||||
meta_callback = "(set PUBLIC_SITE_URL, then restart)"
|
||||
|
||||
meta_ready = meta_credentials_configured()
|
||||
meta_client_id = meta_app.client_id if meta_app else ""
|
||||
meta_secret_saved = bool(meta_app and meta_app.encrypted_client_secret)
|
||||
oauth_ctx = {
|
||||
"linkedin": {
|
||||
"ready": linkedin_credentials_configured(),
|
||||
"callback_url": linkedin_callback,
|
||||
"client_id": (linkedin_app.client_id if linkedin_app else ""),
|
||||
"secret_saved": bool(
|
||||
linkedin_app and linkedin_app.encrypted_client_secret
|
||||
),
|
||||
"start_url_name": "social:linkedin_oauth_start",
|
||||
},
|
||||
"facebook": {
|
||||
"ready": meta_ready,
|
||||
"callback_url": meta_callback,
|
||||
"client_id": meta_client_id,
|
||||
"secret_saved": meta_secret_saved,
|
||||
"start_url_name": "social:facebook_oauth_start",
|
||||
},
|
||||
"instagram": {
|
||||
"ready": meta_ready,
|
||||
"callback_url": meta_callback,
|
||||
"client_id": meta_client_id,
|
||||
"secret_saved": meta_secret_saved,
|
||||
"start_url_name": "social:instagram_oauth_start",
|
||||
},
|
||||
}
|
||||
oauth_state = oauth_ctx.get(connect_platform) or {}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"social/account_list.html",
|
||||
{
|
||||
"accounts": accounts,
|
||||
"connect_platform": connect_platform,
|
||||
"connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
|
||||
"platforms": Platform,
|
||||
"oauth_ready": oauth_state.get("ready", False),
|
||||
"oauth_callback_url": oauth_state.get("callback_url", ""),
|
||||
"oauth_client_id": oauth_state.get("client_id", ""),
|
||||
"oauth_secret_saved": oauth_state.get("secret_saved", False),
|
||||
"oauth_start_url_name": oauth_state.get("start_url_name", ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def linkedin_oauth_start(request):
|
||||
"""Redirect browser to LinkedIn consent screen."""
|
||||
if not linkedin_credentials_configured():
|
||||
messages.error(
|
||||
request,
|
||||
"Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.",
|
||||
)
|
||||
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
||||
state = secrets.token_urlsafe(24)
|
||||
request.session["linkedin_oauth_state"] = state
|
||||
try:
|
||||
return redirect(linkedin_authorization_url(state=state))
|
||||
except LinkedInOAuthError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{reverse('social:account_list')}?connect=linkedin")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def linkedin_oauth_callback(request):
|
||||
"""Handle LinkedIn redirect: exchange code, store encrypted SocialAccount."""
|
||||
error = (request.GET.get("error") or "").strip()
|
||||
if error:
|
||||
desc = (request.GET.get("error_description") or error).strip()
|
||||
messages.error(request, f"LinkedIn authorization denied: {desc}")
|
||||
return redirect("social:account_list")
|
||||
|
||||
state = (request.GET.get("state") or "").strip()
|
||||
expected = request.session.pop("linkedin_oauth_state", None)
|
||||
if not state or not expected or state != expected:
|
||||
messages.error(request, "LinkedIn OAuth state mismatch — try Connect again.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
code = (request.GET.get("code") or "").strip()
|
||||
if not code:
|
||||
messages.error(request, "LinkedIn did not return an authorization code.")
|
||||
return redirect("social:account_list")
|
||||
|
||||
try:
|
||||
token_payload = linkedin_exchange_code(code)
|
||||
profile = linkedin_fetch_member_profile(token_payload["access_token"])
|
||||
author_urn = profile["author_urn"]
|
||||
blob = linkedin_token_blob_from_oauth(token_payload, author_urn=author_urn)
|
||||
existing = SocialAccount.objects.filter(
|
||||
platform=Platform.LINKEDIN, external_id=author_urn
|
||||
).first()
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=Platform.LINKEDIN,
|
||||
external_id=author_urn,
|
||||
defaults={
|
||||
"label": SocialAccount.resolve_label(existing, profile["label"]),
|
||||
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
except (LinkedInOAuthError, SocialCryptoError) as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect("social:account_list")
|
||||
except Exception:
|
||||
messages.error(
|
||||
request,
|
||||
"Unexpected error saving LinkedIn account. Check app logs.",
|
||||
)
|
||||
raise
|
||||
|
||||
verb = "Connected" if created else "Re-authorized"
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} LinkedIn · {account.label}. You can post from the composer.",
|
||||
)
|
||||
return redirect("social:account_list")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def facebook_oauth_start(request):
|
||||
"""Start Facebook Login for Business; save Facebook Page account on complete."""
|
||||
return _meta_oauth_start(request, intent=Platform.FACEBOOK)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def instagram_oauth_start(request):
|
||||
"""Start Facebook Login for Business; save linked Instagram Business account."""
|
||||
return _meta_oauth_start(request, intent=Platform.INSTAGRAM)
|
||||
|
||||
|
||||
def _meta_oauth_start(request, *, intent: str):
|
||||
connect_q = f"?connect={intent}"
|
||||
if not meta_credentials_configured():
|
||||
messages.error(
|
||||
request,
|
||||
"Save Meta App ID and App Secret on the Connect form first.",
|
||||
)
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
state = secrets.token_urlsafe(24)
|
||||
request.session["meta_oauth_state"] = state
|
||||
request.session["meta_oauth_intent"] = intent
|
||||
try:
|
||||
return redirect(meta_authorization_url(state=state))
|
||||
except MetaOAuthError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def meta_oauth_callback(request):
|
||||
"""
|
||||
Landing page after Facebook redirect.
|
||||
|
||||
Tokens arrive in the URL fragment (#) per Meta docs (response_type=token),
|
||||
so JS reads them and POSTs to meta_oauth_complete.
|
||||
"""
|
||||
return render(request, "social/meta_oauth_callback.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def meta_oauth_complete(request):
|
||||
"""Persist Facebook Page and/or Instagram accounts from Facebook Login tokens."""
|
||||
intent = request.session.pop("meta_oauth_intent", Platform.FACEBOOK)
|
||||
connect_q = f"?connect={intent}"
|
||||
|
||||
error = (request.POST.get("error") or "").strip()
|
||||
if error:
|
||||
desc = (request.POST.get("error_description") or error).strip()
|
||||
messages.error(request, f"Facebook authorization denied: {desc}")
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
state = (request.POST.get("state") or "").strip()
|
||||
expected = request.session.pop("meta_oauth_state", None)
|
||||
if not state or not expected or state != expected:
|
||||
messages.error(request, "Meta OAuth state mismatch — try Connect again.")
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
user_token = (
|
||||
(request.POST.get("long_lived_token") or "").strip()
|
||||
or (request.POST.get("access_token") or "").strip()
|
||||
)
|
||||
if not user_token:
|
||||
messages.error(request, "Facebook did not return an access token.")
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
|
||||
try:
|
||||
pages = fetch_pages(user_token)
|
||||
connected = []
|
||||
|
||||
if intent == Platform.INSTAGRAM:
|
||||
ig_pages = [p for p in pages if (p.get("instagram_business_account") or {}).get("id")]
|
||||
if not ig_pages:
|
||||
raise MetaOAuthError(
|
||||
"No Instagram Professional account linked to your Facebook Pages. "
|
||||
"Link IG in Meta Business Suite, then Connect again."
|
||||
)
|
||||
for page in ig_pages:
|
||||
blob = instagram_token_blob(page, user_token=user_token)
|
||||
existing = SocialAccount.objects.filter(
|
||||
platform=Platform.INSTAGRAM, external_id=blob["ig_user_id"]
|
||||
).first()
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=Platform.INSTAGRAM,
|
||||
external_id=blob["ig_user_id"],
|
||||
defaults={
|
||||
"label": SocialAccount.resolve_label(existing, blob["label"]),
|
||||
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
connected.append(("Connected" if created else "Re-authorized", account))
|
||||
else:
|
||||
for page in pages:
|
||||
blob = facebook_token_blob(page, user_token=user_token)
|
||||
incoming_label = (page.get("name") or "").strip() or f"Page {page['id']}"
|
||||
existing = SocialAccount.objects.filter(
|
||||
platform=Platform.FACEBOOK, external_id=str(page["id"])
|
||||
).first()
|
||||
account, created = SocialAccount.objects.update_or_create(
|
||||
platform=Platform.FACEBOOK,
|
||||
external_id=str(page["id"]),
|
||||
defaults={
|
||||
"label": SocialAccount.resolve_label(existing, incoming_label),
|
||||
"encrypted_tokens": encrypt_tokens(json.dumps(blob)),
|
||||
"is_active": True,
|
||||
"owner": request.user,
|
||||
},
|
||||
)
|
||||
connected.append(("Connected" if created else "Re-authorized", account))
|
||||
except (MetaOAuthError, SocialCryptoError) as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(f"{reverse('social:account_list')}{connect_q}")
|
||||
except Exception:
|
||||
messages.error(
|
||||
request,
|
||||
"Unexpected error saving Meta account. Check app logs.",
|
||||
)
|
||||
raise
|
||||
|
||||
for verb, account in connected:
|
||||
messages.success(
|
||||
request,
|
||||
f"{verb} {account.get_platform_display()} · {account.label}.",
|
||||
)
|
||||
return redirect("social:account_list")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def composer(request):
|
||||
"""Compose, schedule, or publish to connected social accounts."""
|
||||
accounts = list(SocialAccount.objects.filter(is_active=True))
|
||||
form = {
|
||||
"body": "",
|
||||
"prompt": "",
|
||||
"publish_mode": "now",
|
||||
"scheduled_for": "",
|
||||
"account_ids": [],
|
||||
"media_json": "[]",
|
||||
}
|
||||
error = ""
|
||||
draft = ""
|
||||
|
||||
if request.method == "POST":
|
||||
form["body"] = (request.POST.get("body") or "").strip()
|
||||
form["prompt"] = (request.POST.get("prompt") or "").strip()
|
||||
form["publish_mode"] = (request.POST.get("publish_mode") or "now").strip()
|
||||
form["scheduled_for"] = request.POST.get("scheduled_for") or ""
|
||||
form["account_ids"] = request.POST.getlist("account_ids")
|
||||
form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]"
|
||||
action = (request.POST.get("action") or "save").strip()
|
||||
if action == "send_now":
|
||||
form["publish_mode"] = "now"
|
||||
elif action == "schedule":
|
||||
form["publish_mode"] = "schedule"
|
||||
elif action == "save":
|
||||
form["publish_mode"] = "draft"
|
||||
|
||||
if action in {"save", "publish", "send_now", "schedule"}:
|
||||
media_items: list = []
|
||||
try:
|
||||
media_items = parse_media_json(form["media_json"])
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
|
||||
if not error and not form["body"] and not media_items:
|
||||
error = "Add a caption and/or attach an image or video."
|
||||
elif not error and form["publish_mode"] != "draft" and not form["account_ids"]:
|
||||
error = "Select at least one connected account."
|
||||
else:
|
||||
scheduled_for = None
|
||||
status = SocialPost.Status.DRAFT
|
||||
if form["publish_mode"] == "draft":
|
||||
status = SocialPost.Status.DRAFT
|
||||
elif form["publish_mode"] == "schedule":
|
||||
try:
|
||||
scheduled_for = parse_scheduled_for(form["scheduled_for"])
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
else:
|
||||
if not scheduled_for:
|
||||
error = "Pick a schedule date/time, or choose Send now."
|
||||
else:
|
||||
status = SocialPost.Status.SCHEDULED
|
||||
elif form["publish_mode"] == "now":
|
||||
status = SocialPost.Status.QUEUED
|
||||
scheduled_for = timezone.now()
|
||||
else:
|
||||
error = "Unknown publish mode."
|
||||
|
||||
if not error:
|
||||
selected = SocialAccount.objects.filter(
|
||||
pk__in=form["account_ids"], is_active=True
|
||||
)
|
||||
if form["publish_mode"] != "draft" and not selected.exists():
|
||||
error = "No valid accounts selected."
|
||||
elif (
|
||||
form["publish_mode"] != "draft"
|
||||
and selected.filter(platform=Platform.INSTAGRAM).exists()
|
||||
and not media_items
|
||||
):
|
||||
error = (
|
||||
"Instagram requires an image or video. "
|
||||
"Attach media before publishing to Instagram."
|
||||
)
|
||||
else:
|
||||
post = SocialPost.objects.create(
|
||||
body=form["body"],
|
||||
media=media_items,
|
||||
ollama_prompt=form["prompt"],
|
||||
scheduled_for=scheduled_for,
|
||||
status=status,
|
||||
created_by=request.user,
|
||||
)
|
||||
for account in selected:
|
||||
SocialPostTarget.objects.create(
|
||||
post=post,
|
||||
account=account,
|
||||
platform=account.platform,
|
||||
)
|
||||
if status == SocialPost.Status.QUEUED:
|
||||
publish_social_post.enqueue(post_id=str(post.pk))
|
||||
messages.success(
|
||||
request,
|
||||
"Post sent — queued for publishing to selected accounts.",
|
||||
)
|
||||
elif status == SocialPost.Status.SCHEDULED:
|
||||
messages.success(
|
||||
request,
|
||||
f"Post scheduled for {scheduled_for:%b %d, %I:%M %p}.",
|
||||
)
|
||||
else:
|
||||
messages.success(request, "Draft saved.")
|
||||
return redirect("social:post_detail", pk=post.pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"social/composer.html",
|
||||
{
|
||||
"accounts": accounts,
|
||||
"form": form,
|
||||
"error": error,
|
||||
"draft": draft or form["body"],
|
||||
"media_upload_url": reverse("social:media_upload"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def media_upload(request):
|
||||
"""Upload image/video for social compose; store bytes in StoredFile."""
|
||||
upload = (
|
||||
request.FILES.get("media")
|
||||
or request.FILES.get("file")
|
||||
or request.FILES.get("image")
|
||||
or request.FILES.get("video")
|
||||
)
|
||||
if not upload:
|
||||
return JsonResponse({"error": "No file uploaded."}, status=400)
|
||||
|
||||
content_type = (getattr(upload, "content_type", None) or "").lower()
|
||||
if content_type in ALLOWED_IMAGE_TYPES:
|
||||
kind = StoredFile.Kind.SOCIAL_IMAGE
|
||||
max_bytes = MAX_IMAGE_BYTES
|
||||
label = "Image"
|
||||
elif content_type in ALLOWED_VIDEO_TYPES:
|
||||
kind = StoredFile.Kind.SOCIAL_VIDEO
|
||||
max_bytes = MAX_VIDEO_BYTES
|
||||
label = "Video"
|
||||
else:
|
||||
return JsonResponse(
|
||||
{
|
||||
"error": "Use JPEG/PNG/GIF/WebP images or MP4/MOV/WebM video.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
size = int(getattr(upload, "size", 0) or 0)
|
||||
if size and size > max_bytes:
|
||||
return JsonResponse(
|
||||
{"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
data = upload.read()
|
||||
if len(data) > max_bytes:
|
||||
return JsonResponse(
|
||||
{"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
original = (getattr(upload, "name", None) or "media")[:255]
|
||||
stored = StoredFile.objects.create(
|
||||
kind=kind,
|
||||
filename=original,
|
||||
content_type=content_type,
|
||||
size=len(data),
|
||||
data=data,
|
||||
uploaded_by=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
item = media_item_from_stored(stored)
|
||||
return JsonResponse(item)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user