Instrument webhooks, fix compose layout, and ship social connect.
Add Grafana-friendly webhook request logging, Quill overflow fix, New Contact flow, in-place postcard campaign compose, and functional social account connect + composer.
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
{% extends "portal_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}New contact · Portal{% endblock %}
|
||||||
|
{% block topbar_title %}New contact{% endblock %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block portal_content %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="split">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h"><h2>Profile</h2></div>
|
||||||
|
<div class="panel-b form-grid">
|
||||||
|
<div class="form-grid cols-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_first_name">First name</label>
|
||||||
|
<input id="id_first_name" name="first_name" required value="{{ form.first_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_last_name">Last name</label>
|
||||||
|
<input id="id_last_name" name="last_name" value="{{ form.last_name }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid cols-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_email">Email</label>
|
||||||
|
<input id="id_email" name="email" type="email" required value="{{ form.email }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_phone">Phone</label>
|
||||||
|
<input id="id_phone" name="phone" value="{{ form.phone }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
|
||||||
|
<div class="field address-ac-wrap">
|
||||||
|
<label>Street address</label>
|
||||||
|
<input name="address_line1" data-ac="line1" value="{{ form.address_line1 }}" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Apt / suite</label>
|
||||||
|
<input name="address_line2" data-ac="line2" value="{{ form.address_line2 }}" autocomplete="address-line2">
|
||||||
|
</div>
|
||||||
|
<div class="form-grid cols-2">
|
||||||
|
<div class="field">
|
||||||
|
<label>City</label>
|
||||||
|
<input name="address_city" data-ac="city" value="{{ form.address_city }}" autocomplete="address-level2">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>State</label>
|
||||||
|
<input name="address_state" data-ac="state" value="{{ form.address_state }}" autocomplete="address-level1" maxlength="32">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid cols-2">
|
||||||
|
<div class="field">
|
||||||
|
<label>ZIP</label>
|
||||||
|
<input name="address_zip" data-ac="zip" value="{{ form.address_zip }}" autocomplete="postal-code" maxlength="20">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Country</label>
|
||||||
|
<input name="address_country" data-ac="country" value="{{ form.address_country|default:'US' }}" autocomplete="country" maxlength="2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_notes">Notes</label>
|
||||||
|
<textarea id="id_notes" name="notes">{{ form.notes }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">Add to mailing list</button>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:list' %}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h"><h2>Consent</h2></div>
|
||||||
|
<div class="panel-b">
|
||||||
|
<div class="field">
|
||||||
|
<label class="check-row"><input type="checkbox" name="consent_email" value="1" {% if form.consent_email %}checked{% endif %}> Email marketing</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="check-row"><input type="checkbox" name="consent_sms" value="1" {% if form.consent_sms %}checked{% endif %}> SMS updates</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if form.consent_postcard %}checked{% endif %}> Postcard mailings</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint-block" style="margin-top:16px">
|
||||||
|
Matches existing contacts by email, phone, or address when possible.
|
||||||
|
Postcard defaults on when an address is saved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'js/address-autocomplete.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
|
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:create' %}">New contact</a>
|
||||||
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
|
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ app_name = "contacts"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.contact_list, name="list"),
|
path("", views.contact_list, name="list"),
|
||||||
|
path("new/", views.contact_create, name="create"),
|
||||||
path("import/", views.contact_import, name="import"),
|
path("import/", views.contact_import, name="import"),
|
||||||
path("<uuid:pk>/", views.contact_detail, name="detail"),
|
path("<uuid:pk>/", views.contact_detail, name="detail"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import validate_email
|
||||||
from django.db.models import Prefetch, Q
|
from django.db.models import Prefetch, Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@@ -7,6 +9,7 @@ from django.views.decorators.http import require_GET, require_http_methods
|
|||||||
|
|
||||||
from contacts.models import Channel, ConsentRecord, Contact
|
from contacts.models import Channel, ConsentRecord, Contact
|
||||||
from contacts.nominatim import NominatimError, suggest_addresses
|
from contacts.nominatim import NominatimError, suggest_addresses
|
||||||
|
from contacts.services import upsert_contact
|
||||||
from messaging.services import channel_preferences, set_channel_preferences
|
from messaging.services import channel_preferences, set_channel_preferences
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +51,76 @@ def contact_list(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def contact_create(request):
|
||||||
|
form = {
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": "",
|
||||||
|
"email": "",
|
||||||
|
"phone": "",
|
||||||
|
"address_line1": "",
|
||||||
|
"address_line2": "",
|
||||||
|
"address_city": "",
|
||||||
|
"address_state": "",
|
||||||
|
"address_zip": "",
|
||||||
|
"address_country": "US",
|
||||||
|
"notes": "",
|
||||||
|
"consent_email": True,
|
||||||
|
"consent_sms": False,
|
||||||
|
"consent_postcard": True,
|
||||||
|
}
|
||||||
|
if request.method == "POST":
|
||||||
|
for key in list(form.keys()):
|
||||||
|
if key.startswith("consent_"):
|
||||||
|
form[key] = key in request.POST
|
||||||
|
else:
|
||||||
|
form[key] = (request.POST.get(key) or "").strip()
|
||||||
|
email = form["email"].lower()
|
||||||
|
errors: list[str] = []
|
||||||
|
if not form["first_name"]:
|
||||||
|
errors.append("First name is required.")
|
||||||
|
if not email:
|
||||||
|
errors.append("Email is required.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
validate_email(email)
|
||||||
|
except ValidationError:
|
||||||
|
errors.append("Enter a valid email address.")
|
||||||
|
postal = _postal_from_post(request.POST)
|
||||||
|
has_postal = Contact.postal_address_has_content(postal)
|
||||||
|
if form["consent_sms"] and not form["phone"]:
|
||||||
|
errors.append("Phone is required for SMS consent.")
|
||||||
|
if form["consent_postcard"] and not has_postal:
|
||||||
|
# Soft: allow save but clear postcard consent if no address
|
||||||
|
form["consent_postcard"] = False
|
||||||
|
if not errors:
|
||||||
|
contact, created, reason = upsert_contact(
|
||||||
|
email=email,
|
||||||
|
first_name=form["first_name"],
|
||||||
|
last_name=form["last_name"],
|
||||||
|
phone=form["phone"],
|
||||||
|
postal_address=postal if has_postal else None,
|
||||||
|
source=Contact.Source.MANUAL,
|
||||||
|
notes_append=form["notes"],
|
||||||
|
)
|
||||||
|
set_channel_preferences(
|
||||||
|
contact,
|
||||||
|
{
|
||||||
|
Channel.EMAIL: form["consent_email"],
|
||||||
|
Channel.SMS: form["consent_sms"],
|
||||||
|
Channel.POSTCARD: form["consent_postcard"],
|
||||||
|
},
|
||||||
|
reason="portal_manual",
|
||||||
|
)
|
||||||
|
verb = "Added" if created else f"Updated (matched by {reason or 'email'})"
|
||||||
|
messages.success(request, f"{verb} {contact}.")
|
||||||
|
return redirect("contacts:detail", pk=contact.pk)
|
||||||
|
for err in errors:
|
||||||
|
messages.error(request, err)
|
||||||
|
return render(request, "contacts/create.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def contact_detail(request, pk):
|
def contact_detail(request, pk):
|
||||||
@@ -76,6 +149,7 @@ def contact_detail(request, pk):
|
|||||||
{"contact": contact, "prefs": prefs},
|
{"contact": contact, "prefs": prefs},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def contact_import(request):
|
def contact_import(request):
|
||||||
return render(request, "contacts/import.html")
|
return render(request, "contacts/import.html")
|
||||||
|
|||||||
@@ -4,19 +4,47 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.ql-editor { min-height: 160px; font-family: Georgia, "Times New Roman", serif; font-size: 15px; }
|
#email-editor-wrap {
|
||||||
.ql-toolbar.ql-snow { border-color: var(--monica-border); border-radius: 4px 4px 0 0; }
|
position: relative;
|
||||||
.ql-container.ql-snow { border-color: var(--monica-border); border-radius: 0 0 4px 4px; background: #fff; }
|
overflow: visible;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
#email-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.ql-toolbar.ql-snow {
|
||||||
|
border-color: var(--monica-border);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ql-container.ql-snow {
|
||||||
|
border-color: var(--monica-border);
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
background: #fff;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 160px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.ql-editor {
|
||||||
|
min-height: 160px;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
#preview-body img { max-width: 100%; height: auto; }
|
#preview-body img { max-width: 100%; height: auto; }
|
||||||
#preview-body { line-height: 1.55; color: #212121; }
|
#preview-body { line-height: 1.55; color: #212121; }
|
||||||
#email-editor-wrap[hidden], #sms-body-wrap[hidden] { display: none !important; }
|
#email-editor-wrap[hidden],
|
||||||
|
#sms-body-wrap[hidden],
|
||||||
|
#postcard-body-hint[hidden] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block portal_content %}
|
{% block portal_content %}
|
||||||
<div class="channel-tabs" id="compose-channel-tabs">
|
<div class="channel-tabs" id="compose-channel-tabs">
|
||||||
<a class="active" href="#compose-email" data-channel="email">Email</a>
|
<a class="active" href="#compose-email" data-channel="email">Email</a>
|
||||||
<a href="#compose-sms" data-channel="sms">SMS</a>
|
<a href="#compose-sms" data-channel="sms">SMS</a>
|
||||||
<a href="{% url 'messaging:postcard_designer' %}" data-channel="postcard">Postcard</a>
|
<a href="#compose-postcard" data-channel="postcard">Postcard</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
@@ -58,6 +86,15 @@
|
|||||||
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
||||||
<div class="hint">Plain text for SMS · keep it short</div>
|
<div class="hint">Plain text for SMS · keep it short</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" id="postcard-body-hint" hidden>
|
||||||
|
<p class="hint-block" style="margin:0">
|
||||||
|
Postcard campaigns use the selected PCM design. Optional note below is stored on the draft only.
|
||||||
|
</p>
|
||||||
|
<label for="id_body_pc" style="margin-top:8px">Internal note <span class="muted">(optional)</span></label>
|
||||||
|
<textarea id="id_body_pc" style="min-height:72px"
|
||||||
|
placeholder="Optional internal note…"
|
||||||
|
oninput="syncPostcardBody()">{{ form_data.body }}</textarea>
|
||||||
|
</div>
|
||||||
<div class="field" id="postcard-template-field">
|
<div class="field" id="postcard-template-field">
|
||||||
<label for="id_template_id">Postcard design</label>
|
<label for="id_template_id">Postcard design</label>
|
||||||
<select id="id_template_id" name="template_id">
|
<select id="id_template_id" name="template_id">
|
||||||
@@ -67,10 +104,12 @@
|
|||||||
{{ d.label }}
|
{{ d.label }}
|
||||||
</option>
|
</option>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<option value="" disabled>No designs yet — open Postcard design</option>
|
<option value="" disabled>No designs yet — create one first</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a> to create or edit designs</div>
|
<div class="hint" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{% url 'messaging:postcard_designer' %}">Create / update postcard design</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid cols-2">
|
<div class="form-grid cols-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -165,14 +204,27 @@
|
|||||||
syncCampaignPreview();
|
syncCampaignPreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.syncPostcardBody = function () {
|
||||||
|
var pc = document.getElementById('id_body_pc');
|
||||||
|
if (pc && bodyField) bodyField.value = pc.value;
|
||||||
|
syncCampaignPreview();
|
||||||
|
};
|
||||||
|
|
||||||
window.syncCampaignPreview = function () {
|
window.syncCampaignPreview = function () {
|
||||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
var isEmail = audience === 'email_opt_in';
|
var isEmail = audience === 'email_opt_in';
|
||||||
|
var isPostcard = audience === 'postcard_opt_in';
|
||||||
var body = '';
|
var body = '';
|
||||||
if (isEmail && quill) {
|
if (isEmail && quill) {
|
||||||
body = quill.root.innerHTML;
|
body = quill.root.innerHTML;
|
||||||
if (body === '<p><br></p>' || body === '<p></p>') body = '';
|
if (body === '<p><br></p>' || body === '<p></p>') body = '';
|
||||||
|
} else if (isPostcard) {
|
||||||
|
var tmpl = document.getElementById('id_template_id');
|
||||||
|
var label = tmpl && tmpl.selectedIndex >= 0 ? tmpl.options[tmpl.selectedIndex].text : '';
|
||||||
|
body = label && tmpl.value ? ('Postcard design: ' + label) : '';
|
||||||
|
var note = (document.getElementById('id_body_pc') || {}).value || '';
|
||||||
|
if (note) body = (body ? body + '\n\n' : '') + note;
|
||||||
} else {
|
} else {
|
||||||
body = (bodyField && bodyField.value) || '';
|
body = (bodyField && bodyField.value) || '';
|
||||||
}
|
}
|
||||||
@@ -188,7 +240,7 @@
|
|||||||
}
|
}
|
||||||
empty.hidden = true;
|
empty.hidden = true;
|
||||||
content.hidden = false;
|
content.hidden = false;
|
||||||
subEl.textContent = subject ? ('Subject: ' + subject) : '';
|
subEl.textContent = subject ? ('Subject: ' + subject) : (isPostcard ? 'Postcard mailing' : '');
|
||||||
if (isEmail) {
|
if (isEmail) {
|
||||||
bodyEl.style.whiteSpace = 'normal';
|
bodyEl.style.whiteSpace = 'normal';
|
||||||
bodyEl.innerHTML = body;
|
bodyEl.innerHTML = body;
|
||||||
@@ -206,19 +258,24 @@
|
|||||||
var tmplField = document.getElementById('postcard-template-field');
|
var tmplField = document.getElementById('postcard-template-field');
|
||||||
var emailWrap = document.getElementById('email-editor-wrap');
|
var emailWrap = document.getElementById('email-editor-wrap');
|
||||||
var smsWrap = document.getElementById('sms-body-wrap');
|
var smsWrap = document.getElementById('sms-body-wrap');
|
||||||
|
var pcHint = document.getElementById('postcard-body-hint');
|
||||||
var subjectField = document.getElementById('subject-field');
|
var subjectField = document.getElementById('subject-field');
|
||||||
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
||||||
if (subjectField) subjectField.style.display = isEmail ? '' : 'none';
|
if (subjectField) subjectField.style.display = isEmail ? '' : 'none';
|
||||||
if (emailWrap) emailWrap.hidden = !isEmail;
|
if (emailWrap) emailWrap.hidden = !isEmail;
|
||||||
if (smsWrap) smsWrap.hidden = isEmail;
|
if (smsWrap) smsWrap.hidden = !isSms;
|
||||||
|
if (pcHint) pcHint.hidden = !isPostcard;
|
||||||
if (isEmail && quill) {
|
if (isEmail && quill) {
|
||||||
syncBodyFromQuill();
|
syncBodyFromQuill();
|
||||||
} else if (smsField && bodyField) {
|
} else if (isSms && smsField && bodyField) {
|
||||||
// Keep SMS/postcard plain body in hidden field
|
if (smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
|
||||||
if (!isEmail && smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
|
|
||||||
smsField.value = bodyField.value;
|
smsField.value = bodyField.value;
|
||||||
}
|
}
|
||||||
bodyField.value = smsField ? smsField.value : bodyField.value;
|
bodyField.value = smsField.value;
|
||||||
|
bodyField.removeAttribute('required');
|
||||||
|
} else if (isPostcard && bodyField) {
|
||||||
|
var pc = document.getElementById('id_body_pc');
|
||||||
|
bodyField.value = pc ? pc.value : '';
|
||||||
bodyField.removeAttribute('required');
|
bodyField.removeAttribute('required');
|
||||||
}
|
}
|
||||||
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
|
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
|
||||||
@@ -288,21 +345,30 @@
|
|||||||
document.getElementById('campaign-compose').addEventListener('submit', function () {
|
document.getElementById('campaign-compose').addEventListener('submit', function () {
|
||||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||||
if (audience === 'email_opt_in') syncBodyFromQuill();
|
if (audience === 'email_opt_in') syncBodyFromQuill();
|
||||||
else if (smsField) bodyField.value = smsField.value;
|
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
|
||||||
|
else if (audience === 'postcard_opt_in') {
|
||||||
|
var pc = document.getElementById('id_body_pc');
|
||||||
|
bodyField.value = pc ? pc.value : '';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('#compose-channel-tabs a[data-channel="email"], #compose-channel-tabs a[data-channel="sms"]').forEach(function (a) {
|
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
|
||||||
a.addEventListener('click', function (e) {
|
a.addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var ch = a.getAttribute('data-channel');
|
var ch = a.getAttribute('data-channel');
|
||||||
var audience = document.getElementById('id_audience');
|
var audience = document.getElementById('id_audience');
|
||||||
if (!audience) return;
|
if (!audience) return;
|
||||||
audience.value = ch === 'sms' ? 'sms_opt_in' : 'email_opt_in';
|
if (ch === 'sms') audience.value = 'sms_opt_in';
|
||||||
|
else if (ch === 'postcard') audience.value = 'postcard_opt_in';
|
||||||
|
else audience.value = 'email_opt_in';
|
||||||
syncComposeChannel();
|
syncComposeChannel();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var tmplSelect = document.getElementById('id_template_id');
|
||||||
|
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
||||||
|
|
||||||
initQuill();
|
initQuill();
|
||||||
syncComposeChannel();
|
syncComposeChannel();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+100
-1
@@ -286,6 +286,76 @@ def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _log_webhook_request(request, *, channel: str) -> None:
|
||||||
|
"""Full request dump for Grafana / log aggregation."""
|
||||||
|
try:
|
||||||
|
headers = {str(k): str(v) for k, v in request.headers.items()}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
headers = {"_error": "unable to serialize headers"}
|
||||||
|
try:
|
||||||
|
body_text = (request.body or b"").decode("utf-8", errors="replace")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
body_text = repr(request.body)
|
||||||
|
if len(body_text) > 12000:
|
||||||
|
body_text = body_text[:12000] + "…[truncated]"
|
||||||
|
logger.info(
|
||||||
|
"webhook_received channel=%s path=%s method=%s query=%s",
|
||||||
|
channel,
|
||||||
|
request.path,
|
||||||
|
request.method,
|
||||||
|
request.META.get("QUERY_STRING", ""),
|
||||||
|
)
|
||||||
|
logger.info("webhook_headers channel=%s headers=%s", channel, headers)
|
||||||
|
logger.info("webhook_body channel=%s body=%s", channel, body_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_webhook_auth_failed(request, *, channel: str) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"webhook_auth_failed channel=%s path=%s "
|
||||||
|
"missing_or_invalid_authorization_or_token",
|
||||||
|
channel,
|
||||||
|
request.path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_webhook_result(
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
event=None,
|
||||||
|
error: str = "",
|
||||||
|
extra: str = "",
|
||||||
|
) -> None:
|
||||||
|
if error:
|
||||||
|
logger.error(
|
||||||
|
"webhook_error channel=%s error=%s %s",
|
||||||
|
channel,
|
||||||
|
error,
|
||||||
|
extra,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not event:
|
||||||
|
logger.warning(
|
||||||
|
"webhook_unmatched channel=%s no_provider_event_created %s",
|
||||||
|
channel,
|
||||||
|
extra,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
message = getattr(event, "message", None)
|
||||||
|
campaign = getattr(message, "campaign", None) if message else None
|
||||||
|
logger.info(
|
||||||
|
"webhook_processed channel=%s event_type=%s event_id=%s "
|
||||||
|
"matched=%s message_id=%s campaign_id=%s campaign_name=%s %s",
|
||||||
|
channel,
|
||||||
|
getattr(event, "event_type", ""),
|
||||||
|
getattr(event, "pk", None),
|
||||||
|
bool(message),
|
||||||
|
getattr(message, "pk", None),
|
||||||
|
getattr(campaign, "pk", None),
|
||||||
|
getattr(campaign, "name", "") or "",
|
||||||
|
extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pcm_webhook_secrets() -> list[str]:
|
def _pcm_webhook_secrets() -> list[str]:
|
||||||
"""All PCM subscription signature secrets from env."""
|
"""All PCM subscription signature secrets from env."""
|
||||||
raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip()
|
raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip()
|
||||||
@@ -675,12 +745,21 @@ def postcard_webhook(request):
|
|||||||
URL: https://<host>/portal/messaging/webhooks/postcard/
|
URL: https://<host>/portal/messaging/webhooks/postcard/
|
||||||
Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS
|
Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS
|
||||||
"""
|
"""
|
||||||
|
channel = "postcard"
|
||||||
|
_log_webhook_request(request, channel=channel)
|
||||||
if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()):
|
if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()):
|
||||||
|
_log_webhook_auth_failed(request, channel=channel)
|
||||||
return HttpResponseForbidden("invalid webhook token")
|
return HttpResponseForbidden("invalid webhook token")
|
||||||
payload = parse_webhook_payload(request)
|
payload = parse_webhook_payload(request)
|
||||||
if not payload:
|
if not payload:
|
||||||
payload = request.POST.dict() or {}
|
payload = request.POST.dict() or {}
|
||||||
|
try:
|
||||||
event = process_pcm_postcard_webhook(payload)
|
event = process_pcm_postcard_webhook(payload)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("PCM postcard webhook processing failed")
|
||||||
|
_log_webhook_result(channel=channel, error=str(exc))
|
||||||
|
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||||
|
_log_webhook_result(channel=channel, event=event)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -705,9 +784,12 @@ def sms_webhook(request):
|
|||||||
|
|
||||||
Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out.
|
Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out.
|
||||||
"""
|
"""
|
||||||
|
channel = "sms"
|
||||||
|
_log_webhook_request(request, channel=channel)
|
||||||
if not _webhook_authorized(
|
if not _webhook_authorized(
|
||||||
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
||||||
):
|
):
|
||||||
|
_log_webhook_auth_failed(request, channel=channel)
|
||||||
return HttpResponseForbidden("invalid webhook token")
|
return HttpResponseForbidden("invalid webhook token")
|
||||||
|
|
||||||
payload = parse_webhook_payload(request)
|
payload = parse_webhook_payload(request)
|
||||||
@@ -724,9 +806,21 @@ def sms_webhook(request):
|
|||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
stopped = bool(phone) and record_sms_stop(str(phone))
|
stopped = bool(phone) and record_sms_stop(str(phone))
|
||||||
|
logger.info(
|
||||||
|
"webhook_processed channel=sms event_type=inbound_stop "
|
||||||
|
"opt_out=%s phone=%s",
|
||||||
|
stopped,
|
||||||
|
phone,
|
||||||
|
)
|
||||||
return JsonResponse({"ok": True, "opt_out": stopped})
|
return JsonResponse({"ok": True, "opt_out": stopped})
|
||||||
|
|
||||||
|
try:
|
||||||
event = process_smtp2go_sms_webhook(payload)
|
event = process_smtp2go_sms_webhook(payload)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("SMTP2GO SMS webhook processing failed")
|
||||||
|
_log_webhook_result(channel=channel, error=str(exc))
|
||||||
|
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||||
|
_log_webhook_result(channel=channel, event=event)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -749,16 +843,21 @@ def email_webhook(request):
|
|||||||
Email events: all delivery/engagement boxes
|
Email events: all delivery/engagement boxes
|
||||||
Email headers: X-Monica-Message-Id
|
Email headers: X-Monica-Message-Id
|
||||||
"""
|
"""
|
||||||
|
channel = "email"
|
||||||
|
_log_webhook_request(request, channel=channel)
|
||||||
if not _webhook_authorized(
|
if not _webhook_authorized(
|
||||||
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
|
||||||
):
|
):
|
||||||
|
_log_webhook_auth_failed(request, channel=channel)
|
||||||
return HttpResponseForbidden("invalid webhook token")
|
return HttpResponseForbidden("invalid webhook token")
|
||||||
payload = parse_webhook_payload(request)
|
payload = parse_webhook_payload(request)
|
||||||
try:
|
try:
|
||||||
event = process_smtp2go_email_webhook(payload)
|
event = process_smtp2go_email_webhook(payload)
|
||||||
except Exception: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
|
except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
|
||||||
logger.exception("SMTP2GO email webhook processing failed")
|
logger.exception("SMTP2GO email webhook processing failed")
|
||||||
|
_log_webhook_result(channel=channel, error=str(exc))
|
||||||
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
|
||||||
|
_log_webhook_result(channel=channel, event=event)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|||||||
@@ -319,14 +319,35 @@ body.portal {
|
|||||||
.auth-alert a { color: #9a3412; font-weight: 600; }
|
.auth-alert a { color: #9a3412; font-weight: 600; }
|
||||||
|
|
||||||
/* Social accounts */
|
/* Social accounts */
|
||||||
.connect-grid { display: grid; gap: 12px; }
|
.connect-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.connect-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
.connect-card {
|
.connect-card {
|
||||||
display: flex; gap: 14px; align-items: flex-start; text-align: left;
|
display: flex; gap: 14px; align-items: flex-start; text-align: left;
|
||||||
padding: 14px; border: 1px solid var(--monica-border); background: #fff;
|
padding: 14px; border: 1px solid var(--monica-border); background: #fff;
|
||||||
cursor: pointer; font: inherit; width: 100%;
|
cursor: pointer; font: inherit; width: 100%;
|
||||||
}
|
}
|
||||||
.connect-card:hover { border-color: var(--monica-primary); }
|
.connect-card:hover { border-color: var(--monica-primary); }
|
||||||
|
.connect-card.is-active { border-color: var(--monica-primary); box-shadow: inset 0 0 0 1px var(--monica-primary); }
|
||||||
.connect-card p { margin: 4px 0 0; font-size: 13px; color: var(--monica-muted); }
|
.connect-card p { margin: 4px 0 0; font-size: 13px; color: var(--monica-muted); }
|
||||||
|
.connect-form-panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--monica-border);
|
||||||
|
}
|
||||||
|
.connect-steps {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--monica-muted);
|
||||||
|
}
|
||||||
|
.connect-steps li { margin-bottom: 8px; }
|
||||||
|
.connect-steps a { color: var(--monica-primary); }
|
||||||
.platform-icon {
|
.platform-icon {
|
||||||
width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center;
|
width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center;
|
||||||
justify-content: center; color: #fff; font-weight: 700; flex-shrink: 0; font-size: 14px;
|
justify-content: center; color: #fff; font-weight: 700; flex-shrink: 0; font-size: 14px;
|
||||||
|
|||||||
@@ -6,28 +6,67 @@
|
|||||||
<div class="panel-h"><h2>Add account</h2></div>
|
<div class="panel-h"><h2>Add account</h2></div>
|
||||||
<div class="panel-b">
|
<div class="panel-b">
|
||||||
<div class="connect-grid">
|
<div class="connect-grid">
|
||||||
<button type="button" class="connect-card" disabled>
|
<a class="connect-card{% if connect_platform == 'facebook' %} is-active{% endif %}"
|
||||||
|
href="{% url 'social:account_list' %}?connect=facebook">
|
||||||
<span class="platform-icon meta">f</span>
|
<span class="platform-icon meta">f</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Facebook Page</strong>
|
<strong>Facebook Page</strong>
|
||||||
<p>Connect a Page you manage. OAuth wiring comes next.</p>
|
<p>Connect a Page you manage with a Page access token.</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</a>
|
||||||
<button type="button" class="connect-card" disabled>
|
<a class="connect-card{% if connect_platform == 'instagram' %} is-active{% endif %}"
|
||||||
|
href="{% url 'social:account_list' %}?connect=instagram">
|
||||||
<span class="platform-icon ig">IG</span>
|
<span class="platform-icon ig">IG</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Instagram Business</strong>
|
<strong>Instagram Business</strong>
|
||||||
<p>Requires a Facebook Page linked to an IG business account.</p>
|
<p>Requires a Facebook Page linked to an IG business account.</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</a>
|
||||||
<button type="button" class="connect-card" disabled>
|
<a class="connect-card{% if connect_platform == 'linkedin' %} is-active{% endif %}"
|
||||||
|
href="{% url 'social:account_list' %}?connect=linkedin">
|
||||||
<span class="platform-icon li">in</span>
|
<span class="platform-icon li">in</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>LinkedIn</strong>
|
<strong>LinkedIn</strong>
|
||||||
<p>Personal or organization page. Tokens expire — re-auth when prompted.</p>
|
<p>Personal or organization page. Tokens expire — reconnect when prompted.</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if connect_meta %}
|
||||||
|
<div class="connect-form-panel" id="connect-form">
|
||||||
|
<h3 style="margin:0 0 8px;font-size:16px">{{ connect_meta.title }}</h3>
|
||||||
|
<ol class="connect-steps">
|
||||||
|
{% for step in connect_meta.steps %}
|
||||||
|
<li>{{ step }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if connect_meta.docs_url %}
|
||||||
|
<li>Reference: <a href="{{ connect_meta.docs_url }}" target="_blank" rel="noopener">platform docs</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="connect">
|
||||||
|
<input type="hidden" name="platform" value="{{ connect_platform }}">
|
||||||
|
{% for name, label, placeholder in connect_meta.fields %}
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_{{ name }}">{{ label }}</label>
|
||||||
|
{% if name == 'access_token' %}
|
||||||
|
<textarea id="id_{{ name }}" name="{{ name }}" required
|
||||||
|
style="min-height:88px" placeholder="{{ placeholder }}"></textarea>
|
||||||
|
{% else %}
|
||||||
|
<input id="id_{{ name }}" name="{{ name }}"
|
||||||
|
{% if name != 'page_id' and name != 'label' %}required{% endif %}
|
||||||
|
placeholder="{{ placeholder }}">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-primary" type="submit">Save account</button>
|
||||||
|
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,10 +101,29 @@
|
|||||||
<span class="badge badge-failed">Inactive</span>
|
<span class="badge badge-failed">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="muted">Connect / disconnect in functionality pass</td>
|
<td>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{% url 'social:account_list' %}?connect={{ account.platform }}">Update tokens</a>
|
||||||
|
{% if account.is_active %}
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="disconnect">
|
||||||
|
<input type="hidden" name="account_id" value="{{ account.pk }}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Disconnect</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="reactivate">
|
||||||
|
<input type="hidden" name="account_id" value="{{ account.pk }}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Mark active</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="4" class="empty-state">No accounts connected yet.</td></tr>
|
<tr><td colspan="4" class="empty-state">No accounts connected yet — pick a platform above.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -84,11 +142,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-h"><h2>Health checks</h2></div>
|
<div class="panel-h"><h2>Next</h2></div>
|
||||||
<div class="panel-b">
|
<div class="panel-b">
|
||||||
<p class="muted">Token test buttons wire up with the connectors next.</p>
|
<a class="btn btn-primary btn-sm" href="{% url 'social:composer' %}">Open composer →</a>
|
||||||
<a class="btn btn-ghost btn-sm" href="{% url 'social:composer' %}">Open composer →</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block extra_js %}
|
||||||
|
{% if connect_platform %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,285 +1,132 @@
|
|||||||
{% extends "portal_base.html" %}
|
{% extends "portal_base.html" %}
|
||||||
{% load static %}
|
|
||||||
{% block title %}Compose · Social{% endblock %}
|
{% block title %}Compose · Social{% endblock %}
|
||||||
{% block topbar_title %}Compose & preview{% endblock %}
|
{% block topbar_title %}Compose & preview{% endblock %}
|
||||||
{% block portal_content %}
|
{% block portal_content %}
|
||||||
<div class="auth-alert" id="li-warn">
|
{% if not accounts %}
|
||||||
<strong>LinkedIn needs re-auth.</strong> Token expired —
|
<div class="auth-alert">
|
||||||
<a href="{% url 'social:account_list' %}">Re-authorize in Accounts</a> before scheduling to LinkedIn.
|
<strong>No social accounts connected.</strong>
|
||||||
|
<a href="{% url 'social:account_list' %}">Connect Facebook, Instagram, or LinkedIn</a> first.
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="designer-layout with-ai">
|
<div class="split">
|
||||||
<div class="designer-controls">
|
|
||||||
<div class="panel">
|
<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-h">
|
||||||
<div class="panel-b form-grid">
|
<h2>Compose</h2>
|
||||||
<div class="field"><label>Caption</label>
|
<a class="btn btn-sm btn-ghost" href="{% url 'social:account_list' %}">Manage accounts</a>
|
||||||
<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.
|
</div>
|
||||||
|
<div class="panel-b">
|
||||||
#JustListed #OpenHouse</textarea>
|
{% if error %}
|
||||||
|
<ul class="portal-flash" style="margin:0 0 12px"><li class="error">{{ error }}</li></ul>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" class="form-grid" id="social-compose">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_body">Caption</label>
|
||||||
|
<textarea id="id_body" name="body" style="min-height:140px"
|
||||||
|
placeholder="Open house this Saturday…"
|
||||||
|
oninput="syncPreview()">{{ form.body }}</textarea>
|
||||||
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
|
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Media</label>
|
<div class="field">
|
||||||
<select id="soc-media" onchange="syncSocial()">
|
<label>Accounts</label>
|
||||||
<option value="{% static "images/grid-layout-1-370x256.jpg" %}">Willow Lane exterior</option>
|
{% for account in accounts %}
|
||||||
<option value="{% static "images/grid-layout-2-370x256.jpg" %}">Kitchen</option>
|
<label class="check-row">
|
||||||
<option value="{% static "images/grid-layout-3-370x256.jpg" %}">Living room</option>
|
<input type="checkbox" name="account_ids" value="{{ account.pk }}"
|
||||||
<option value="">Text only (FB / LI)</option>
|
{% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}>
|
||||||
</select>
|
{{ account.get_platform_display }} · {{ account.label }}
|
||||||
</div>
|
</label>
|
||||||
<div class="field"><label>Platforms</label>
|
{% empty %}
|
||||||
<div class="platform-toggles">
|
<p class="muted">No active accounts.</p>
|
||||||
<label class="toggle-pill"><input type="checkbox" id="plat-fb" checked onchange="syncSocial()"> Facebook</label>
|
{% endfor %}
|
||||||
<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>
|
||||||
<div class="form-grid cols-2">
|
<div class="form-grid cols-2">
|
||||||
<div class="field"><label>Publish</label>
|
<div class="field">
|
||||||
<select id="soc-when-mode"><option>Schedule</option><option>Publish now</option></select>
|
<label for="id_publish_mode">Publish</label>
|
||||||
</div>
|
<select id="id_publish_mode" name="publish_mode">
|
||||||
<div class="field"><label>When</label><input type="datetime-local" value="2026-07-16T09:00"></div>
|
<option value="schedule"{% if form.publish_mode == "schedule" %} selected{% endif %}>Schedule</option>
|
||||||
</div>
|
<option value="now"{% if form.publish_mode == "now" %} selected{% endif %}>Publish now</option>
|
||||||
<div class="field"><label>Preview as</label>
|
<option value="draft"{% if form.publish_mode == "draft" %} selected{% endif %}>Save draft only</option>
|
||||||
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Body</label><textarea name="body" style="min-height:100px">{{ draft }}</textarea></div>
|
<div class="field">
|
||||||
|
<label for="id_scheduled_for">When</label>
|
||||||
|
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
|
||||||
|
value="{{ form.scheduled_for }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
<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="publish">Save / publish</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h"><h2>AI assist</h2></div>
|
||||||
|
<div class="panel-b form-grid">
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="generate">
|
||||||
|
<input type="hidden" name="publish_mode" value="{{ form.publish_mode }}">
|
||||||
|
<input type="hidden" name="scheduled_for" value="{{ form.scheduled_for }}">
|
||||||
|
{% for id in form.account_ids %}
|
||||||
|
<input type="hidden" name="account_ids" value="{{ id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<div class="field">
|
||||||
|
<label for="id_prompt">Prompt</label>
|
||||||
|
<textarea id="id_prompt" name="prompt" style="min-height:88px"
|
||||||
|
placeholder="Warm open-house post for 412 Willow Lane…">{{ form.prompt }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost" type="submit">Generate draft (Ollama)</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint-block">Generated text fills the caption field. Review before publishing.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h"><h2>Preview</h2></div>
|
||||||
|
<div class="panel-b">
|
||||||
|
<div class="preview-pane">
|
||||||
|
<div class="muted" id="preview-empty">Preview updates as you type.</div>
|
||||||
|
<div id="preview-content" hidden style="white-space:pre-wrap"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted" style="font-size:13px;margin-top:12px">
|
||||||
|
<a href="{% url 'social:post_list' %}">View recent posts</a>
|
||||||
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
let previewPlat = 'fb';
|
function syncPreview() {
|
||||||
let lastDraft = '';
|
var body = (document.getElementById('id_body') || {}).value || '';
|
||||||
|
var count = document.getElementById('char-count');
|
||||||
function setPreview(p) {
|
if (count) count.textContent = String(body.length);
|
||||||
previewPlat = p;
|
var empty = document.getElementById('preview-empty');
|
||||||
['fb','ig','li'].forEach(x => {
|
var content = document.getElementById('preview-content');
|
||||||
document.getElementById('preview-' + x).style.display = x === p ? 'block' : 'none';
|
if (!empty || !content) return;
|
||||||
document.getElementById('prev-' + x).classList.toggle('active', x === p);
|
if (!body) {
|
||||||
});
|
empty.hidden = false;
|
||||||
document.getElementById('prev-label').textContent = ({fb:'Facebook',ig:'Instagram',li:'LinkedIn'})[p];
|
content.hidden = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
empty.hidden = true;
|
||||||
function syncSocial() {
|
content.hidden = false;
|
||||||
const cap = document.getElementById('soc-caption').value;
|
content.textContent = body;
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
var mode = document.getElementById('id_publish_mode');
|
||||||
function escapeHtml(s) {
|
var when = document.getElementById('id_scheduled_for');
|
||||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
function syncWhen() {
|
||||||
|
if (!mode || !when) return;
|
||||||
|
when.disabled = mode.value !== 'schedule';
|
||||||
}
|
}
|
||||||
|
if (mode) mode.addEventListener('change', syncWhen);
|
||||||
function appendMsg(role, html) {
|
syncPreview();
|
||||||
const root = document.getElementById('ai-messages');
|
syncWhen();
|
||||||
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+213
-19
@@ -1,12 +1,71 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
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.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 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.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
|
@login_required
|
||||||
@@ -22,42 +81,177 @@ def post_detail(request, pk):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
def account_list(request):
|
def account_list(request):
|
||||||
accounts = SocialAccount.objects.all()
|
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
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def composer(request):
|
def composer(request):
|
||||||
"""Portal composer — optional Ollama draft assist."""
|
"""Compose, schedule, or publish to connected social accounts."""
|
||||||
draft = ""
|
accounts = list(SocialAccount.objects.filter(is_active=True))
|
||||||
|
form = {
|
||||||
|
"body": "",
|
||||||
|
"prompt": "",
|
||||||
|
"publish_mode": "schedule",
|
||||||
|
"scheduled_for": "",
|
||||||
|
"account_ids": [],
|
||||||
|
}
|
||||||
error = ""
|
error = ""
|
||||||
prompt = ""
|
draft = ""
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
prompt = (request.POST.get("prompt") or "").strip()
|
form["body"] = (request.POST.get("body") or "").strip()
|
||||||
platform = (request.POST.get("platform") or "").strip()
|
form["prompt"] = (request.POST.get("prompt") or "").strip()
|
||||||
if prompt:
|
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:
|
try:
|
||||||
draft = generate_social_post(prompt, platform=platform)
|
draft = generate_social_post(form["prompt"])
|
||||||
|
form["body"] = draft
|
||||||
except OllamaError as exc:
|
except OllamaError as exc:
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
body = (request.POST.get("body") or draft).strip()
|
elif action in {"save", "publish"}:
|
||||||
if request.POST.get("action") == "save" and body:
|
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(
|
post = SocialPost.objects.create(
|
||||||
body=body,
|
body=form["body"],
|
||||||
ollama_prompt=prompt,
|
ollama_prompt=form["prompt"],
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
status=status,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
return render(
|
for account in selected:
|
||||||
request,
|
SocialPostTarget.objects.create(
|
||||||
"social/composer.html",
|
post=post,
|
||||||
{"saved": post, "draft": body, "prompt": prompt},
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"social/composer.html",
|
"social/composer.html",
|
||||||
{"draft": draft, "prompt": prompt, "error": error},
|
{
|
||||||
|
"accounts": accounts,
|
||||||
|
"form": form,
|
||||||
|
"error": error,
|
||||||
|
"draft": draft or form["body"],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user