## Summary Closes #5. - Composer can **Send now** without a schedule time (Schedule and Save draft still available) - Account names wrap and show the platform id so a bad OAuth label is still readable - Social accounts page can rename the display name; re-auth keeps a custom name instead of overwriting it with a placeholder - Worker polls `dispatch_due` every 15s so scheduled campaigns and social posts fire without a manual command; local `docker compose up` now starts the worker ## Test plan - [ ] Open Social → Compose: confirm **Send now**, **Schedule**, and **Save draft** buttons (no combined Save / publish) - [ ] Send now with a connected account and caption → post queues immediately, no datetime required - [ ] Schedule with a datetime → post is scheduled; Schedule with a blank time → validation error - [ ] Composer account list shows full name plus external id, wrapping if the name is long - [ ] Social accounts → rename a messy label → new name shows in composer without reconnecting - [ ] Re-authorize an account that was renamed → custom name is kept - [ ] Rebuild and start compose (`docker compose up --build`) so the **worker** service is running - [ ] Schedule a social post ~2 minutes ahead → after due time, worker logs `Enqueued N due item(s).` and the post leaves Scheduled (published or failed) - [ ] `docker compose logs -f worker` shows `Polling due scheduled work every 15s.` Reviewed-on: #6
746 lines
30 KiB
Python
746 lines
30 KiB
Python
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 messaging.services import parse_scheduled_for
|
|
from messaging.models import StoredFile
|
|
from social.crypto import SocialCryptoError, encrypt_tokens
|
|
from social.media import (
|
|
ALLOWED_IMAGE_TYPES,
|
|
ALLOWED_VIDEO_TYPES,
|
|
MAX_IMAGE_BYTES,
|
|
MAX_VIDEO_BYTES,
|
|
media_item_from_stored,
|
|
parse_media_json,
|
|
)
|
|
from social.models import (
|
|
Platform,
|
|
SocialAccount,
|
|
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.ollama import OllamaError, generate_social_post
|
|
from social.tasks import publish_social_post
|
|
|
|
_META_DOCS = (
|
|
"https://developers.facebook.com/documentation/instagram-platform/"
|
|
"instagram-api-with-facebook-login/business-login-for-instagram"
|
|
)
|
|
|
|
CONNECT_INSTRUCTIONS = {
|
|
Platform.FACEBOOK: {
|
|
"title": "Connect Facebook Page",
|
|
"oauth": True,
|
|
"oauth_platform": "facebook",
|
|
"client_id_label": "Meta App ID",
|
|
"client_secret_label": "Meta App Secret",
|
|
"connect_label": "Connect with Facebook",
|
|
"save_action": "save_meta_app",
|
|
"callback_label": (
|
|
"Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings"
|
|
),
|
|
"steps": [
|
|
mark_safe(
|
|
'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 == "generate" and form["prompt"]:
|
|
try:
|
|
draft = generate_social_post(form["prompt"])
|
|
form["body"] = draft
|
|
except OllamaError as exc:
|
|
error = str(exc)
|
|
elif 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)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def api_generate(request):
|
|
"""
|
|
JSON endpoint for the portal to draft posts via Ollama.
|
|
|
|
POST JSON: {"prompt": "...", "platform": "facebook"|...}
|
|
"""
|
|
try:
|
|
payload = json.loads(request.body.decode() or "{}")
|
|
except json.JSONDecodeError:
|
|
payload = request.POST.dict()
|
|
prompt = (payload.get("prompt") or "").strip()
|
|
if not prompt:
|
|
return JsonResponse({"error": "prompt required"}, status=400)
|
|
try:
|
|
text = generate_social_post(
|
|
prompt, platform=(payload.get("platform") or "").strip()
|
|
)
|
|
except OllamaError as exc:
|
|
return JsonResponse({"error": str(exc)}, status=502)
|
|
return JsonResponse({"text": text})
|