Instrument webhooks, fix compose layout, and ship social connect.
Deploy Beta / unit-tests (push) Successful in 10s
Deploy Beta / docker (push) Successful in 15s
Deploy Beta / deploy-beta (push) Successful in 1m40s

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:
2026-08-10 05:15:49 -05:00
parent b3a6ee0cd0
commit 58258f2875
10 changed files with 778 additions and 311 deletions
@@ -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>
+1
View File
@@ -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"),
] ]
+74
View File
@@ -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
View File
@@ -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,
+22 -1
View File
@@ -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;
+77 -13
View File
@@ -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 %}
+106 -259
View File
@@ -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 111 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">&nbsp; 💬 &nbsp;</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 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>
<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;
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 111 · 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')) { empty.hidden = true;
return 'I\'d love to show you around this Saturday.\n\n412 Willow Lane is open 111 — 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.'; content.hidden = false;
content.textContent = body;
} }
if (p.includes('hashtag')) { var mode = document.getElementById('id_publish_mode');
const base = current.trim() || 'Just listed — 412 Willow Lane. Open house Saturday 111.'; var when = document.getElementById('id_scheduled_for');
return base.replace(/\s*#\S+/g, '').trim() + '\n\n#JustListed #OpenHouse #RealEstate #HomeTour'; function syncWhen() {
if (!mode || !when) return;
when.disabled = mode.value !== 'schedule';
} }
if (p.includes('linkedin') || p.includes('professional')) { if (mode) mode.addEventListener('change', syncWhen);
return 'Hosting an open house this Saturday, 111, 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'; syncPreview();
} syncWhen();
if (p.includes('open house') || p.includes('willow') || p.includes('draft')) { })();
return 'Open house this Saturday 111 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 111. 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
View File
@@ -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"],
},
) )