Add Django site, Docker packaging, and beta/prod Gitea deploys.
Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from social.models import SocialAccount, SocialPost, SocialPostTarget
|
||||
|
||||
|
||||
class SocialPostTargetInline(admin.TabularInline):
|
||||
model = SocialPostTarget
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(SocialAccount)
|
||||
class SocialAccountAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "platform", "is_active", "external_id")
|
||||
list_filter = ("platform", "is_active")
|
||||
|
||||
|
||||
@admin.register(SocialPost)
|
||||
class SocialPostAdmin(admin.ModelAdmin):
|
||||
list_display = ("pk", "status", "scheduled_for", "created_at")
|
||||
list_filter = ("status",)
|
||||
inlines = [SocialPostTargetInline]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SocialConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "social"
|
||||
@@ -0,0 +1,10 @@
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SocialConnector(Protocol):
|
||||
platform: str
|
||||
|
||||
def publish(self, post, target) -> str:
|
||||
"""Publish and return remote post id."""
|
||||
|
||||
def refresh_token(self, account) -> None: ...
|
||||
@@ -0,0 +1,48 @@
|
||||
"""LinkedIn API connector."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from social.crypto import decrypt_tokens
|
||||
|
||||
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")
|
||||
|
||||
payload = {
|
||||
"author": author_urn,
|
||||
"lifecycleState": "PUBLISHED",
|
||||
"specificContent": {
|
||||
"com.linkedin.ugc.ShareContent": {
|
||||
"shareCommentary": {"text": post.body},
|
||||
"shareMediaCategory": "NONE",
|
||||
}
|
||||
},
|
||||
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
|
||||
}
|
||||
response = requests.post(
|
||||
"https://api.linkedin.com/v2/ugcPosts",
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"X-Restli-Protocol-Version": "2.0.0",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return str(response.headers.get("x-restli-id") or response.json().get("id") or "")
|
||||
|
||||
def refresh_token(self, account) -> None:
|
||||
logger.info("LinkedIn token refresh stub for %s", account.pk)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Meta Graph API connector (Facebook Page + Instagram Business)."""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from social.crypto import decrypt_tokens
|
||||
import json
|
||||
|
||||
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")
|
||||
page_id = target.account.external_id or tokens.get("page_id")
|
||||
if not access_token or not page_id:
|
||||
raise RuntimeError("Meta account missing access_token/page_id")
|
||||
|
||||
if target.platform == "instagram":
|
||||
# IG content publishing is a multi-step Graph flow; stub container create.
|
||||
raise NotImplementedError(
|
||||
"Instagram publish requires IG business account wiring — complete OAuth first"
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
f"{self.GRAPH}/{page_id}/feed",
|
||||
data={"message": post.body, "access_token": access_token},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return str(response.json().get("id") or "")
|
||||
|
||||
def refresh_token(self, account) -> None:
|
||||
# Long-lived token exchange when META_APP_ID/SECRET are set.
|
||||
if not settings.META_APP_ID or not settings.META_APP_SECRET:
|
||||
return
|
||||
logger.info("Meta token refresh not yet implemented for account %s", account.pk)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Token encryption helpers for SocialAccount."""
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = settings.SOCIAL_TOKEN_ENCRYPTION_KEY
|
||||
if not key:
|
||||
# Dev-only fallback — generate is not stable across restarts; set the env var.
|
||||
key = Fernet.generate_key().decode()
|
||||
if isinstance(key, str):
|
||||
key = key.encode()
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 6.1 on 2026-08-06 18:01
|
||||
|
||||
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='SocialAccount',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16)),
|
||||
('label', models.CharField(max_length=120)),
|
||||
('external_id', models.CharField(blank=True, max_length=255)),
|
||||
('encrypted_tokens', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='social_accounts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['platform', 'label'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SocialPost',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('body', models.TextField()),
|
||||
('media', models.JSONField(blank=True, default=list)),
|
||||
('scheduled_for', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('publishing', 'Publishing'), ('published', 'Published'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
|
||||
('ollama_prompt', models.TextField(blank=True)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SocialPostTarget',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('published', 'Published'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||
('remote_id', models.CharField(blank=True, max_length=255)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='social.socialaccount')),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='social.socialpost')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('post', 'account')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Platform(models.TextChoices):
|
||||
FACEBOOK = "facebook", "Facebook"
|
||||
INSTAGRAM = "instagram", "Instagram"
|
||||
LINKEDIN = "linkedin", "LinkedIn"
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
class SocialPost(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
SCHEDULED = "scheduled", "Scheduled"
|
||||
QUEUED = "queued", "Queued"
|
||||
PUBLISHING = "publishing", "Publishing"
|
||||
PUBLISHED = "published", "Published"
|
||||
FAILED = "failed", "Failed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
body = models.TextField()
|
||||
media = models.JSONField(default=list, blank=True)
|
||||
scheduled_for = models.DateTimeField(null=True, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
ollama_prompt = models.TextField(blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Post {self.pk} ({self.status})"
|
||||
|
||||
|
||||
class SocialPostTarget(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
PUBLISHED = "published", "Published"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
post = models.ForeignKey(
|
||||
SocialPost, on_delete=models.CASCADE, related_name="targets"
|
||||
)
|
||||
account = models.ForeignKey(
|
||||
SocialAccount, on_delete=models.CASCADE, related_name="targets"
|
||||
)
|
||||
platform = models.CharField(max_length=16, choices=Platform.choices)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.PENDING
|
||||
)
|
||||
remote_id = models.CharField(max_length=255, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("post", "account")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Ollama client for drafting social posts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def generate_social_post(
|
||||
prompt: str,
|
||||
*,
|
||||
platform: str = "",
|
||||
tone: str = "professional, warm, local realtor",
|
||||
) -> str:
|
||||
"""
|
||||
Call the LAN Ollama endpoint (default http://10.0.0.128:11434) to draft copy.
|
||||
|
||||
Uses the standard Ollama /api/generate HTTP API.
|
||||
"""
|
||||
base = (settings.OLLAMA_BASE_URL or "").rstrip("/")
|
||||
if not base:
|
||||
raise OllamaError("OLLAMA_BASE_URL is not configured")
|
||||
|
||||
system = (
|
||||
"You write short social media posts for a residential realtor. "
|
||||
f"Tone: {tone}. "
|
||||
"Return only the post text, no preamble."
|
||||
)
|
||||
if platform:
|
||||
system += f" Optimize for {platform}."
|
||||
|
||||
full_prompt = f"{system}\n\nUser request:\n{prompt}"
|
||||
|
||||
url = f"{base}/api/generate"
|
||||
payload = {
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"prompt": full_prompt,
|
||||
"stream": False,
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
timeout=settings.OLLAMA_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("Ollama request failed")
|
||||
raise OllamaError(f"Ollama unreachable at {url}: {exc}") from exc
|
||||
|
||||
data = response.json()
|
||||
text = (data.get("response") or "").strip()
|
||||
if not text:
|
||||
raise OllamaError("Ollama returned empty response")
|
||||
return text
|
||||
@@ -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,94 @@
|
||||
{% 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">
|
||||
<button type="button" class="connect-card" disabled>
|
||||
<span class="platform-icon meta">f</span>
|
||||
<div>
|
||||
<strong>Facebook Page</strong>
|
||||
<p>Connect a Page you manage. OAuth wiring comes next.</p>
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" class="connect-card" disabled>
|
||||
<span class="platform-icon ig">IG</span>
|
||||
<div>
|
||||
<strong>Instagram Business</strong>
|
||||
<p>Requires a Facebook Page linked to an IG business account.</p>
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" class="connect-card" disabled>
|
||||
<span class="platform-icon li">in</span>
|
||||
<div>
|
||||
<strong>LinkedIn</strong>
|
||||
<p>Personal or organization page. Tokens expire — re-auth when prompted.</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<strong>{{ account.label }}</strong><br>
|
||||
<span class="muted">{{ account.external_id }}</span>
|
||||
</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 class="muted">Connect / disconnect in functionality pass</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No accounts connected yet.</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 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>Health checks</h2></div>
|
||||
<div class="panel-b">
|
||||
<p class="muted">Token test buttons wire up with the connectors next.</p>
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'social:composer' %}">Open composer →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,285 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Compose · Social{% endblock %}
|
||||
{% block topbar_title %}Compose & preview{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="auth-alert" id="li-warn">
|
||||
<strong>LinkedIn needs re-auth.</strong> Token expired —
|
||||
<a href="{% url 'social:account_list' %}">Re-authorize in Accounts</a> before scheduling to LinkedIn.
|
||||
</div>
|
||||
|
||||
<div class="designer-layout with-ai">
|
||||
<div class="designer-controls">
|
||||
<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 form-grid">
|
||||
<div class="field"><label>Caption</label>
|
||||
<textarea id="soc-caption" style="min-height:130px" oninput="syncSocial()">Open house this Saturday 11–1 at 412 Willow Lane. Quiet street, updated kitchen, walkable to Oakridge. DM or text me for details.
|
||||
|
||||
#JustListed #OpenHouse</textarea>
|
||||
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
|
||||
</div>
|
||||
<div class="field"><label>Media</label>
|
||||
<select id="soc-media" onchange="syncSocial()">
|
||||
<option value="{% static "images/grid-layout-1-370x256.jpg" %}">Willow Lane exterior</option>
|
||||
<option value="{% static "images/grid-layout-2-370x256.jpg" %}">Kitchen</option>
|
||||
<option value="{% static "images/grid-layout-3-370x256.jpg" %}">Living room</option>
|
||||
<option value="">Text only (FB / LI)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Platforms</label>
|
||||
<div class="platform-toggles">
|
||||
<label class="toggle-pill"><input type="checkbox" id="plat-fb" checked onchange="syncSocial()"> Facebook</label>
|
||||
<label class="toggle-pill"><input type="checkbox" id="plat-ig" checked onchange="syncSocial()"> Instagram</label>
|
||||
<label class="toggle-pill warn"><input type="checkbox" id="plat-li" onchange="syncSocial()"> LinkedIn <span class="muted">(re-auth)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field"><label>Publish</label>
|
||||
<select id="soc-when-mode"><option>Schedule</option><option>Publish now</option></select>
|
||||
</div>
|
||||
<div class="field"><label>When</label><input type="datetime-local" value="2026-07-16T09:00"></div>
|
||||
</div>
|
||||
<div class="field"><label>Preview as</label>
|
||||
<div class="channel-tabs" style="margin:0">
|
||||
<a href="#" class="active" id="prev-fb" onclick="setPreview('fb');return false">Facebook</a>
|
||||
<a href="#" id="prev-ig" onclick="setPreview('ig');return false">Instagram</a>
|
||||
<a href="#" id="prev-li" onclick="setPreview('li');return false">LinkedIn</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" onclick="alert('Mockup: SocialPost + SocialPostTargets enqueued')">Schedule to selected platforms</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-assist-col">
|
||||
<div class="ai-chat">
|
||||
<div class="ai-chat-h">
|
||||
<h2>AI post assistant</h2>
|
||||
<span class="ai-pill">Beta mockup</span>
|
||||
</div>
|
||||
<div class="ai-chat-messages" id="ai-messages">
|
||||
<div class="ai-msg bot">
|
||||
<div class="who">Assistant</div>
|
||||
<div class="bubble">
|
||||
I can draft or refine captions for Facebook, Instagram, and LinkedIn.
|
||||
Tell me the listing, tone (warm / bold / luxury), and any must-include details —
|
||||
or tap a quick prompt below.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-chat-prompts">
|
||||
<button type="button" class="chip" onclick="sendPrompt('Write an open-house post for 412 Willow Lane')">Open house draft</button>
|
||||
<button type="button" class="chip" onclick="sendPrompt('Make my caption shorter for Instagram')">Shorter for IG</button>
|
||||
<button type="button" class="chip" onclick="sendPrompt('Rewrite in a warmer, neighborly tone')">Warmer tone</button>
|
||||
<button type="button" class="chip" onclick="sendPrompt('Add 4 relevant hashtags')">Add hashtags</button>
|
||||
<button type="button" class="chip" onclick="sendPrompt('Make a LinkedIn-friendly professional version')">LinkedIn version</button>
|
||||
</div>
|
||||
<div class="ai-chat-input">
|
||||
<textarea id="ai-input" placeholder="Ask for a draft, rewrite, or hashtags…" rows="2"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat();}"></textarea>
|
||||
<button class="btn btn-primary" type="button" onclick="sendChat()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="designer-preview-col">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Live post preview</h2><span class="muted" style="font-size:12px" id="prev-label">Facebook</span></div>
|
||||
<div class="panel-b" style="background:#e8edf3;display:flex;justify-content:center;padding:24px">
|
||||
<div class="social-phone" id="preview-fb">
|
||||
<div class="soc-header">
|
||||
<div class="soc-avatar">MR</div>
|
||||
<div>
|
||||
<div class="soc-name">Monica Dhillon · MKDRealtor.com</div>
|
||||
<div class="soc-meta">Sponsored · Just now · 🌐</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="soc-caption" id="fb-caption"></div>
|
||||
<div class="soc-image" id="fb-image"></div>
|
||||
<div class="soc-actions">Like · Comment · Share</div>
|
||||
</div>
|
||||
<div class="social-phone ig" id="preview-ig" style="display:none">
|
||||
<div class="soc-header">
|
||||
<div class="soc-avatar round">MR</div>
|
||||
<div>
|
||||
<div class="soc-name">monicadhillonhomes</div>
|
||||
<div class="soc-meta">Metro Area</div>
|
||||
</div>
|
||||
<div class="soc-more">•••</div>
|
||||
</div>
|
||||
<div class="soc-image square" id="ig-image"></div>
|
||||
<div class="soc-actions ig-act">♥ 💬 ✉</div>
|
||||
<div class="soc-caption"><strong>monicadhillonhomes</strong> <span id="ig-caption"></span></div>
|
||||
</div>
|
||||
<div class="social-phone li" id="preview-li" style="display:none">
|
||||
<div class="soc-header">
|
||||
<div class="soc-avatar sq">MR</div>
|
||||
<div>
|
||||
<div class="soc-name">Monica Dhillon</div>
|
||||
<div class="soc-meta">Realtor · 1st · Just now</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="soc-caption" id="li-caption"></div>
|
||||
<div class="soc-image" id="li-image"></div>
|
||||
<div class="soc-actions">Like · Comment · Repost · Send</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Scheduled</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead><tr><th>Post</th><th>Platforms</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Open house Willow Lane</td><td>FB · IG</td><td><span class="badge badge-scheduled">Scheduled</span></td></tr>
|
||||
<tr><td>Just sold — Birch Ave</td><td>FB · IG · LI</td><td><span class="badge badge-delivered">Published</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:20px">
|
||||
<div class="panel-h"><h2>Save draft (server)</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
{% if error %}<ul class="portal-flash"><li class="error">{{ error }}</li></ul>{% endif %}
|
||||
{% if saved %}<ul class="portal-flash"><li>Saved draft <a href="{% url 'social:post_detail' saved.pk %}">{{ saved.pk }}</a></li></ul>{% endif %}
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<div class="field"><label>AI prompt (optional)</label><textarea name="prompt">{{ prompt }}</textarea></div>
|
||||
<div class="field"><label>Platform hint</label>
|
||||
<select name="platform">
|
||||
<option value="">Any</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
<option value="instagram">Instagram</option>
|
||||
<option value="linkedin">LinkedIn</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Body</label><textarea name="body" style="min-height:100px">{{ draft }}</textarea></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-ghost" type="submit" name="action" value="generate">Generate draft</button>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save">Save draft</button>
|
||||
<a class="btn btn-ghost" href="{% url 'social:post_list' %}">Scheduled / drafts</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
|
||||
let previewPlat = 'fb';
|
||||
let lastDraft = '';
|
||||
|
||||
function setPreview(p) {
|
||||
previewPlat = p;
|
||||
['fb','ig','li'].forEach(x => {
|
||||
document.getElementById('preview-' + x).style.display = x === p ? 'block' : 'none';
|
||||
document.getElementById('prev-' + x).classList.toggle('active', x === p);
|
||||
});
|
||||
document.getElementById('prev-label').textContent = ({fb:'Facebook',ig:'Instagram',li:'LinkedIn'})[p];
|
||||
}
|
||||
|
||||
function syncSocial() {
|
||||
const cap = document.getElementById('soc-caption').value;
|
||||
const media = document.getElementById('soc-media').value;
|
||||
document.getElementById('char-count').textContent = cap.length;
|
||||
document.getElementById('fb-caption').textContent = cap;
|
||||
document.getElementById('ig-caption').textContent = cap;
|
||||
document.getElementById('li-caption').textContent = cap;
|
||||
['fb','ig','li'].forEach(x => {
|
||||
const el = document.getElementById(x + '-image');
|
||||
if (!media) { el.style.display = 'none'; }
|
||||
else { el.style.display = 'block'; el.style.backgroundImage = 'url(' + media + ')'; }
|
||||
});
|
||||
document.getElementById('li-warn').style.display = document.getElementById('plat-li').checked ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function appendMsg(role, html) {
|
||||
const root = document.getElementById('ai-messages');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ai-msg ' + role;
|
||||
div.innerHTML =
|
||||
'<div class="who">' + (role === 'user' ? 'You' : 'Assistant') + '</div>' +
|
||||
'<div class="bubble">' + html + '</div>';
|
||||
root.appendChild(div);
|
||||
root.scrollTop = root.scrollHeight;
|
||||
}
|
||||
|
||||
function draftActions(draft) {
|
||||
lastDraft = draft;
|
||||
const id = 'draft-' + Date.now();
|
||||
return (
|
||||
'<div class="ai-draft" id="' + id + '">' + escapeHtml(draft) + '</div>' +
|
||||
'<div class="ai-msg-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" onclick="insertDraft(false)">Replace caption</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-ghost" onclick="insertDraft(true)">Append</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function insertDraft(append) {
|
||||
const ta = document.getElementById('soc-caption');
|
||||
if (append && ta.value.trim()) ta.value = ta.value.replace(/\s*$/, '') + '\n\n' + lastDraft;
|
||||
else ta.value = lastDraft;
|
||||
syncSocial();
|
||||
}
|
||||
|
||||
function inventReply(prompt, current) {
|
||||
const p = prompt.toLowerCase();
|
||||
if (p.includes('shorter') || p.includes('instagram')) {
|
||||
return 'Open house Sat 11–1 · 412 Willow Lane\nUpdated kitchen, quiet street, walk to Oakridge.\nDM for details 🏡\n\n#JustListed #OpenHouse #WillowLane';
|
||||
}
|
||||
if (p.includes('warm') || p.includes('neighbor')) {
|
||||
return 'I\'d love to show you around this Saturday.\n\n412 Willow Lane is open 11–1 — quiet street, bright kitchen, and an easy walk to Oakridge schools. Come say hi, no pressure.\n\nText me anytime if you want a private look first.';
|
||||
}
|
||||
if (p.includes('hashtag')) {
|
||||
const base = current.trim() || 'Just listed — 412 Willow Lane. Open house Saturday 11–1.';
|
||||
return base.replace(/\s*#\S+/g, '').trim() + '\n\n#JustListed #OpenHouse #RealEstate #HomeTour';
|
||||
}
|
||||
if (p.includes('linkedin') || p.includes('professional')) {
|
||||
return 'Hosting an open house this Saturday, 11–1, at 412 Willow Lane.\n\nUpdated kitchen, walkable to Oakridge schools, asking $449,000. Happy to arrange a private showing for clients who can\'t make the window.\n\n#RealEstate #OpenHouse';
|
||||
}
|
||||
if (p.includes('open house') || p.includes('willow') || p.includes('draft')) {
|
||||
return 'Open house this Saturday 11–1 at 412 Willow Lane.\n\nQuiet street · updated kitchen · walkable to Oakridge.\nDM or text me for details — hope to see you there!\n\n#JustListed #OpenHouse';
|
||||
}
|
||||
if (p.includes('rewrite') || p.includes('improve') || current.trim()) {
|
||||
return 'Just listed: 412 Willow Lane.\n\nOpen Saturday 11–1. Updated kitchen, quiet street, walkable to Oakridge schools. Asking $449,000.\n\nMessage me for a private showing.\n\n#JustListed #OpenHouse';
|
||||
}
|
||||
return 'Tell me the address, price band, and vibe (warm / bold / luxury) and I\'ll draft a caption you can drop straight into the composer.';
|
||||
}
|
||||
|
||||
function respond(prompt) {
|
||||
const current = document.getElementById('soc-caption').value;
|
||||
const draft = inventReply(prompt, current);
|
||||
const needsDraft = !/tell me the address/i.test(draft);
|
||||
const body = needsDraft
|
||||
? 'Here\'s a draft you can use:' + draftActions(draft)
|
||||
: escapeHtml(draft);
|
||||
appendMsg('bot', body);
|
||||
}
|
||||
|
||||
function sendPrompt(text) {
|
||||
document.getElementById('ai-input').value = text;
|
||||
sendChat();
|
||||
}
|
||||
|
||||
function sendChat() {
|
||||
const input = document.getElementById('ai-input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
appendMsg('user', escapeHtml(text));
|
||||
input.value = '';
|
||||
setTimeout(() => respond(text), 350);
|
||||
}
|
||||
|
||||
syncSocial();
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% 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">
|
||||
<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.label }}</td>
|
||||
<td>{{ target.get_platform_display }}</td>
|
||||
<td><span class="badge badge-{{ target.status }}">{{ target.get_status_display }}</span></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No targets attached.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Social posts · Portal{% endblock %}
|
||||
{% block topbar_title %}Scheduled & drafts{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<p class="muted" style="margin:0">Posts saved from the composer.</p>
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'social:composer' %}">Compose</a>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Preview</th><th>Status</th><th>Scheduled</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td>{{ post.body|truncatechars:80 }}</td>
|
||||
<td><span class="badge badge-{{ post.status }}">{{ post.get_status_display }}</span></td>
|
||||
<td>{% if post.scheduled_for %}{{ post.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}</td>
|
||||
<td><a href="{% url 'social:post_detail' post.pk %}">Open</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No posts yet. <a href="{% url 'social:composer' %}">Compose one</a>.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
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("compose/", views.composer, name="composer"),
|
||||
path("api/generate/", views.api_generate, name="api_generate"),
|
||||
path("<uuid:pk>/", views.post_detail, name="post_detail"),
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
|
||||
from social.models import SocialAccount, SocialPost
|
||||
from social.ollama import OllamaError, generate_social_post
|
||||
|
||||
|
||||
@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
|
||||
def account_list(request):
|
||||
accounts = SocialAccount.objects.all()
|
||||
return render(request, "social/account_list.html", {"accounts": accounts})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def composer(request):
|
||||
"""Portal composer — optional Ollama draft assist."""
|
||||
draft = ""
|
||||
error = ""
|
||||
prompt = ""
|
||||
if request.method == "POST":
|
||||
prompt = (request.POST.get("prompt") or "").strip()
|
||||
platform = (request.POST.get("platform") or "").strip()
|
||||
if prompt:
|
||||
try:
|
||||
draft = generate_social_post(prompt, platform=platform)
|
||||
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},
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"social/composer.html",
|
||||
{"draft": draft, "prompt": prompt, "error": error},
|
||||
)
|
||||
|
||||
|
||||
@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})
|
||||
Reference in New Issue
Block a user