- {% if error %}
{% endif %}
- {% if saved %}
{% endif %}
-
+
+
Preview
+
+
+
Preview updates as you type.
+
+
+
+
+ View recent posts
+
{% endblock %}
{% block extra_js %}
{% endblock %}
diff --git a/site/social/views.py b/site/social/views.py
index 1aee107..2760b0c 100644
--- a/site/social/views.py
+++ b/site/social/views.py
@@ -1,12 +1,71 @@
import json
+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.shortcuts import get_object_or_404, render
+from django.utils import timezone
from django.views.decorators.http import require_http_methods, require_POST
-from social.models import SocialAccount, SocialPost
+from messaging.services import parse_scheduled_for
+from social.crypto import encrypt_tokens
+from social.models import Platform, SocialAccount, SocialPost, SocialPostTarget
from social.ollama import OllamaError, generate_social_post
+from social.tasks import publish_social_post
+
+CONNECT_INSTRUCTIONS = {
+ Platform.FACEBOOK: {
+ "title": "Connect Facebook Page",
+ "steps": [
+ "Open Meta for Developers → your app → Tools → Graph API Explorer.",
+ "Select your app, then Get Page Access Token for the Page you manage.",
+ "Grant pages_manage_posts and pages_read_engagement (and pages_show_list).",
+ "Copy the Page access token and the numeric Page ID.",
+ "Paste both below. Tokens are encrypted at rest.",
+ ],
+ "docs_url": "https://developers.facebook.com/docs/pages/access-tokens/",
+ "fields": [
+ ("label", "Display name", "Monica Dhillon · EXIT"),
+ ("external_id", "Page ID", "1029384756"),
+ ("access_token", "Page access token", ""),
+ ],
+ },
+ Platform.INSTAGRAM: {
+ "title": "Connect Instagram Business",
+ "steps": [
+ "Instagram publishing uses a Facebook Page linked to an IG professional account.",
+ "In Meta Business Suite, confirm the IG account is connected to your Page.",
+ "From Graph API Explorer, get a Page token that can manage the linked IG account.",
+ "Use the Instagram Business Account ID (not the username) as External ID.",
+ "Paste Page access token + IG business account ID below.",
+ ],
+ "docs_url": "https://developers.facebook.com/docs/instagram-api/getting-started/",
+ "fields": [
+ ("label", "Display name", "@mkdrealtor"),
+ ("external_id", "IG business account ID", ""),
+ ("access_token", "Page access token", ""),
+ ("page_id", "Facebook Page ID (optional)", ""),
+ ],
+ },
+ Platform.LINKEDIN: {
+ "title": "Connect LinkedIn",
+ "steps": [
+ "Create a LinkedIn Developer app and add the Share on LinkedIn / Marketing products.",
+ "Generate a member or organization access token with w_member_social "
+ "(or w_organization_social for company pages).",
+ "Find your author URN: person URN looks like urn:li:person:XXXX; "
+ "organization URN like urn:li:organization:XXXX.",
+ "Paste the access token and author URN below. LinkedIn tokens expire — "
+ "reconnect when publishing fails with auth errors.",
+ ],
+ "docs_url": "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/ugc-post-api",
+ "fields": [
+ ("label", "Display name", "Monica Dhillon"),
+ ("external_id", "Author URN", "urn:li:person:…"),
+ ("access_token", "Access token", ""),
+ ],
+ },
+}
@login_required
@@ -22,42 +81,177 @@ def post_detail(request, pk):
@login_required
+@require_http_methods(["GET", "POST"])
def account_list(request):
accounts = SocialAccount.objects.all()
- return render(request, "social/account_list.html", {"accounts": accounts})
+ 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 == "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")
+
+ platform = (request.POST.get("platform") or "").strip()
+ if platform not in Platform.values:
+ messages.error(request, "Choose a platform.")
+ return redirect("social:account_list")
+ 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()
+ page_id = (request.POST.get("page_id") 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}
+ if platform == Platform.FACEBOOK:
+ token_blob["page_id"] = external_id
+ elif platform == Platform.INSTAGRAM:
+ if page_id:
+ token_blob["page_id"] = page_id
+ elif platform == Platform.LINKEDIN:
+ token_blob["author_urn"] = external_id
+
+ account, created = SocialAccount.objects.update_or_create(
+ platform=platform,
+ external_id=external_id,
+ defaults={
+ "label": label,
+ "encrypted_tokens": encrypt_tokens(json.dumps(token_blob)),
+ "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")
+
+ return render(
+ request,
+ "social/account_list.html",
+ {
+ "accounts": accounts,
+ "connect_platform": connect_platform,
+ "connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform),
+ "platforms": Platform,
+ },
+ )
@login_required
@require_http_methods(["GET", "POST"])
def composer(request):
- """Portal composer — optional Ollama draft assist."""
- draft = ""
+ """Compose, schedule, or publish to connected social accounts."""
+ accounts = list(SocialAccount.objects.filter(is_active=True))
+ form = {
+ "body": "",
+ "prompt": "",
+ "publish_mode": "schedule",
+ "scheduled_for": "",
+ "account_ids": [],
+ }
error = ""
- prompt = ""
+ draft = ""
+
if request.method == "POST":
- prompt = (request.POST.get("prompt") or "").strip()
- platform = (request.POST.get("platform") or "").strip()
- if prompt:
+ 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 "schedule").strip()
+ form["scheduled_for"] = request.POST.get("scheduled_for") or ""
+ form["account_ids"] = request.POST.getlist("account_ids")
+ action = (request.POST.get("action") or "save").strip()
+
+ if action == "generate" and form["prompt"]:
try:
- draft = generate_social_post(prompt, platform=platform)
+ draft = generate_social_post(form["prompt"])
+ form["body"] = draft
except OllamaError as exc:
error = str(exc)
- body = (request.POST.get("body") or draft).strip()
- if request.POST.get("action") == "save" and body:
- post = SocialPost.objects.create(
- body=body,
- ollama_prompt=prompt,
- created_by=request.user,
- )
- return render(
- request,
- "social/composer.html",
- {"saved": post, "draft": body, "prompt": prompt},
- )
+ elif action in {"save", "publish"}:
+ if not form["body"]:
+ error = "Caption / body is required."
+ elif 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 Publish 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."
+ else:
+ post = SocialPost.objects.create(
+ body=form["body"],
+ 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 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",
- {"draft": draft, "prompt": prompt, "error": error},
+ {
+ "accounts": accounts,
+ "form": form,
+ "error": error,
+ "draft": draft or form["body"],
+ },
)