admin Posts (NOTE_UNLOCK): readonly input + 'No response needed' placeholder + secUser focus glow + buddy btn suppressed + view POST 403 + Line.admin_solicited listener nukes errant writes; share Lines: drop ts suffix, author = sharer (adman fallback for anon legacy), silent no-op on re-share — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- billboard/0005 adds Line.admin_solicited (BooleanField default False); RunPython backfills existing note_unlock Lines to True. Note.grant_if_new sets admin_solicited=True on its system prose.
  - billboard.models post_save signal: any Line saved on a Post.kind=NOTE_UNLOCK without admin_solicited=True is deleted (defense-in-depth alongside the view guard).
  - billboard.views.view_post hard-rejects POST on NOTE_UNLOCK kind (HTTP 403) — clean view-level contract; the post_save listener is the safety net for ORM/API paths that bypass it.
  - templates/apps/billboard/post.html: NOTE_UNLOCK branch renders the input as readonly w. 'No response needed at this time' placeholder + no method/action; user_post branch keeps the regular composer. Buddy panel include guarded behind `{% if post.kind != 'note_unlock' %}` — friend invites don't apply to admin threads.
  - SCSS: .post-line-form input.form-control[readonly]:focus uses --secUser glow (cooler than the regular --terUser composer focus).
  - share_post: drop the iso-timestamp suffix on Line.text (just 'Shared with {email}'); author = request.user (anon legacy fallback to adman so AnonymousUser doesn't break the FK); re-share of an already-in-shared_with recipient is a silent no-op (no second Line, brief: null in JSON response). Buddy panel JS now reads data-sharer-name from server-rendered display_name so the optimistic _appendLine matches the post-refresh state.
  - new ITs: test_admin_posts (PostRejectsAdminWritesTest, UnsolicitedLineListenerTest, NoteGrantSetsAdminSolicitedTest) — 7 tests; share_post tests rewritten for the new contract (drop ts, author=sharer, silent re-share dedup) — 12 tests; new FT test_admin_post_readonly w. AdminPostInputReadonlyTest + AdminPostHasNoBuddyBtnTest + UserPostInputUnaffectedTest — 4 tests. 827 ITs + 18 buddy/sharing FTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-08 21:52:34 -04:00
parent 6f76f6c176
commit b3eb14140c
10 changed files with 427 additions and 51 deletions

View File

@@ -0,0 +1,34 @@
# Adds Line.admin_solicited (BooleanField) to discriminate
# system-authored Lines (Note.grant_if_new) from user writes on
# NOTE_UNLOCK Posts. The post_save signal nukes any Line on a
# NOTE_UNLOCK Post that lacks admin_solicited=True — defense-in-depth
# alongside the view_post POST guard. Backfill: existing NOTE_UNLOCK
# Lines (the only system-authored kind at this point) get True; all
# others default False.
from django.db import migrations, models
def backfill(apps, schema_editor):
Line = apps.get_model("billboard", "Line")
Line.objects.filter(post__kind="note_unlock").update(admin_solicited=True)
def reverse_noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("billboard", "0004_post_title_line_author_created_at"),
]
operations = [
migrations.AddField(
model_name="line",
name="admin_solicited",
field=models.BooleanField(default=False),
),
migrations.RunPython(backfill, reverse_noop),
]

View File

@@ -1,6 +1,8 @@
import uuid import uuid
from django.db import models from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -63,6 +65,10 @@ class Line(models.Model):
related_name="authored_lines", related_name="authored_lines",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
# System-authored Lines on NOTE_UNLOCK Posts must set this True; the
# post_save signal below deletes any Line on a NOTE_UNLOCK Post w.o.
# this flag (defense-in-depth alongside view_post's POST guard).
admin_solicited = models.BooleanField(default=False)
class Meta: class Meta:
ordering = ("created_at", "id") ordering = ("created_at", "id")
@@ -149,3 +155,18 @@ class Brief(models.Model):
"square_url": square_url, "square_url": square_url,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
} }
# ── Listener: nuke unsolicited Lines on NOTE_UNLOCK Posts ─────────────────
# Defense-in-depth alongside view_post's POST guard. A Line saved on a
# NOTE_UNLOCK Post that lacks admin_solicited=True (e.g. a stray ORM-level
# write or an API path that bypasses the view) gets deleted right after
# the save. Note.grant_if_new sets admin_solicited=True on its Lines so
# legitimate system prose survives.
@receiver(post_save, sender=Line)
def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
if not created:
return
if instance.post.kind == Post.KIND_NOTE_UNLOCK and not instance.admin_solicited:
instance.delete()

View File

@@ -0,0 +1,126 @@
"""ITs for admin-Post (kind=NOTE_UNLOCK) write protection.
Three guards stack:
1. post.html input is `readonly` w. "No response needed…" placeholder
(FT covers this — `functional_tests/test_admin_post_readonly.py`).
2. view_post POST handler hard-rejects writes (HTTP 403). This file's
PostRejectsAdminWritesTest.
3. post_save signal nukes any Line saved on a NOTE_UNLOCK Post that
lacks `admin_solicited=True` — defense-in-depth for paths that
bypass the view (raw API, ORM, etc.). UnsolicitedLineListenerTest.
Bug A — May 2026.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief, Line, Post
from apps.drama.models import Note
from apps.lyric.models import User, get_or_create_adman
class PostRejectsAdminWritesTest(TestCase):
"""POST /billboard/post/<note_unlock>/ → HTTP 403, no Line appended."""
def setUp(self):
self.user = User.objects.create(email="admin-rej@test.io")
self.client.force_login(self.user)
Note.grant_if_new(self.user, "stargazer")
self.admin_post = Post.objects.get(
owner=self.user, kind=Post.KIND_NOTE_UNLOCK,
)
self.line_count_before = Line.objects.filter(post=self.admin_post).count()
def test_post_to_admin_post_returns_403(self):
resp = self.client.post(
reverse("billboard:view_post", args=[self.admin_post.id]),
data={"text": "errant response"},
)
self.assertEqual(resp.status_code, 403)
def test_post_to_admin_post_does_not_append_line(self):
self.client.post(
reverse("billboard:view_post", args=[self.admin_post.id]),
data={"text": "errant response"},
)
self.assertEqual(
Line.objects.filter(post=self.admin_post).count(),
self.line_count_before,
)
def test_post_to_user_post_still_succeeds(self):
"""Regression: kind=USER_POST still accepts compose."""
user_post = Post.objects.create(
owner=self.user, kind=Post.KIND_USER_POST, title="composing",
)
Line.objects.create(post=user_post, text="seed", author=self.user)
resp = self.client.post(
reverse("billboard:view_post", args=[user_post.id]),
data={"text": "valid append"},
)
# 302 redirect on success
self.assertEqual(resp.status_code, 302)
self.assertTrue(
Line.objects.filter(post=user_post, text="valid append").exists(),
)
class UnsolicitedLineListenerTest(TestCase):
"""post_save signal deletes any Line saved on a NOTE_UNLOCK Post without
`admin_solicited=True`. Note.grant_if_new sets it; everything else
defaults to False, so a stray ORM-level write gets nuked."""
def setUp(self):
self.user = User.objects.create(email="listener@test.io")
Note.grant_if_new(self.user, "stargazer")
self.admin_post = Post.objects.get(
owner=self.user, kind=Post.KIND_NOTE_UNLOCK,
)
def test_unsolicited_line_on_note_unlock_post_is_deleted(self):
unsolicited = Line.objects.create(
post=self.admin_post,
text="errant ORM write",
author=self.user,
# admin_solicited defaults to False
)
# Signal fires post_save; the Line should be gone.
self.assertFalse(
Line.objects.filter(pk=unsolicited.pk).exists(),
"Unsolicited Line on NOTE_UNLOCK Post must be deleted",
)
def test_admin_solicited_line_on_note_unlock_post_persists(self):
"""The Note grant Lines are admin_solicited=True — must NOT be nuked."""
adman = get_or_create_adman()
line = Line.objects.create(
post=self.admin_post,
text="valid system prose",
author=adman,
admin_solicited=True,
)
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
def test_unsolicited_line_on_user_post_persists(self):
"""User-typed Lines on user_post posts default to admin_solicited=False
and must NOT be nuked — the listener only guards NOTE_UNLOCK."""
up = Post.objects.create(
owner=self.user, kind=Post.KIND_USER_POST, title="x",
)
line = Line.objects.create(
post=up, text="user-typed line", author=self.user,
)
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
class NoteGrantSetsAdminSolicitedTest(TestCase):
"""Note.grant_if_new must persist Lines with admin_solicited=True so
they survive the listener pass."""
def test_grant_creates_line_with_admin_solicited_true(self):
u = User.objects.create(email="grant@test.io")
Note.grant_if_new(u, "stargazer")
post = Post.objects.get(owner=u, kind=Post.KIND_NOTE_UNLOCK)
# Exactly one Line on a fresh grant
line = post.lines.get()
self.assertTrue(line.admin_solicited)

View File

@@ -85,16 +85,42 @@ class SharePostAsyncTest(TestCase):
self.assertEqual(body["brief"]["kind"], "share_invite") self.assertEqual(body["brief"]["kind"], "share_invite")
self.assertIn("alice@test.io", body["line_text"]) self.assertIn("alice@test.io", body["line_text"])
def test_async_share_line_text_dedupes_via_timestamp(self): def test_async_reshare_same_recipient_is_silent_noop(self):
"""Two consecutive shares of the same email must not collide on the """Sharing the same recipient twice is a silent no-op — Post.shared_with
Line.unique_together(post, text) constraint — the share line should M2M is idempotent so a second add is meaningless, and we don't want a
carry a timestamp suffix.""" duplicate Line cluttering the thread. Response is 200 with brief=null."""
User.objects.create(email="alice@test.io") User.objects.create(email="alice@test.io")
self._share_async("alice@test.io") self._share_async("alice@test.io")
# Second share — should append a second distinct Line, not 500. self.assertEqual(self.post.lines.count(), 1)
before_brief_count = Brief.objects.filter(owner=self.sharer).count()
response = self._share_async("alice@test.io") response = self._share_async("alice@test.io")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(self.post.lines.count(), 2) self.assertEqual(response.json()["brief"], None)
# No second Line, no second Brief.
self.assertEqual(self.post.lines.count(), 1)
self.assertEqual(
Brief.objects.filter(owner=self.sharer).count(),
before_brief_count,
)
def test_async_share_line_text_drops_timestamp(self):
"""The share Line's text is plain "Shared with X" — no "at <iso ts>"
suffix (timestamp display lives on the per-Line `<time>` element now)."""
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
line = self.post.lines.first()
self.assertEqual(line.text, "Shared with alice@test.io")
self.assertNotIn(" at ", line.text)
def test_async_share_line_author_is_sharer_not_adman(self):
"""User-created share Lines attribute to the sharer (the post owner
doing the share), not the system adman entity."""
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
line = self.post.lines.first()
self.assertEqual(line.author, self.sharer)
class SharePostLegacyRedirectTest(TestCase): class SharePostLegacyRedirectTest(TestCase):

View File

@@ -8,7 +8,6 @@ from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from django.utils import timezone
from apps.billboard.forms import ExistingPostLineForm, LineForm from apps.billboard.forms import ExistingPostLineForm, LineForm
from apps.billboard.models import Brief, Line, Post from apps.billboard.models import Brief, Line, Post
@@ -264,6 +263,13 @@ def view_post(request, post_id):
if request.user != our_post.owner and request.user not in our_post.shared_with.all(): if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden() return HttpResponseForbidden()
# Admin-Post (note-unlock thread) hard write-rejection — the per-Line
# signal in billboard.models nukes any Line that bypasses this guard,
# but at the view level we want a clean 403 so the FT/IT contract is
# explicit and the client never sees a silent line vanish.
if our_post.kind == Post.KIND_NOTE_UNLOCK and request.method == "POST":
return HttpResponseForbidden()
form = ExistingPostLineForm(for_post=our_post) form = ExistingPostLineForm(for_post=our_post)
if request.method == "POST": if request.method == "POST":
@@ -315,30 +321,39 @@ def share_post(request, post_id):
return JsonResponse({"brief": None, "line_text": ""}) return JsonResponse({"brief": None, "line_text": ""})
return redirect(our_post) return redirect(our_post)
if recipient is not None: # Re-share dedup: if the recipient is already in shared_with (registered
# email previously shared), skip the Line + Brief — silent no-op.
# `add()` itself is idempotent on M2M, but we want the JSON response to
# signal "nothing happened" so the JS can suppress the banner.
is_reshare = recipient is not None and recipient in our_post.shared_with.all()
if recipient is not None and not is_reshare:
our_post.shared_with.add(recipient) our_post.shared_with.add(recipient)
# Always append a Line + spawn a Brief for the sharer — privacy: the line = None
# response shape mustn't leak whether the email is on the system. Line
# text carries an isoformat timestamp w/ microseconds so two rapid
# shares of the same email don't collide on the
# Line.unique_together(post, text) constraint. System-authored as adman
# so the per-line "username" column renders the share announcement.
line_text = (
f"Shared with {recipient_email} at {timezone.now().isoformat()}"
)
adman = get_or_create_adman()
line = Line.objects.create(post=our_post, text=line_text, author=adman)
brief = None brief = None
if request.user.is_authenticated: line_text = ""
brief = Brief.objects.create( if not is_reshare:
owner=request.user, # Plain "Shared with X" — timestamp display lives on the per-Line
post=our_post, # `<time>` element, not in the prose. Author = sharer (post owner)
line=line, # so the per-line "username" column attributes correctly. Anonymous
kind=Brief.KIND_SHARE_INVITE, # shares (legacy Percival ch. 19 ownerless-post path) fall back to
title="Invite sent", # adman since AnonymousUser can't be FK'd. Privacy: we still create
# the Line + Brief even when the address is unregistered, so the
# response doesn't leak membership.
line_text = f"Shared with {recipient_email}"
author = request.user if request.user.is_authenticated else get_or_create_adman()
line = Line.objects.create(
post=our_post, text=line_text, author=author,
) )
if request.user.is_authenticated:
brief = Brief.objects.create(
owner=request.user,
post=our_post,
line=line,
kind=Brief.KIND_SHARE_INVITE,
title="Invite sent",
)
if is_ajax: if is_ajax:
# recipient_display is populated only when the address resolves to a # recipient_display is populated only when the address resolves to a

View File

@@ -315,7 +315,10 @@ class Note(models.Model):
# super-schizo + super-nomad via the User post_save signal) need a # super-schizo + super-nomad via the User post_save signal) need a
# safety net. Production migrations seed it once. # safety net. Production migrations seed it once.
adman = get_or_create_adman() adman = get_or_create_adman()
line = Line.objects.create(post=post, text=line_text, author=adman) line = Line.objects.create(
post=post, text=line_text, author=adman,
admin_solicited=True,
)
brief = Brief.objects.create( brief = Brief.objects.create(
owner=user, owner=user,
post=post, post=post,

View File

@@ -0,0 +1,118 @@
"""FT spec for admin-Post (kind=NOTE_UNLOCK) readonly input.
Bug A: a Note-unlock Post is system-authored and shouldn't accept user
responses. The post.html input field gets a "No response needed at this
time" placeholder + the readonly attribute, AND the server-side view_post
POST handler hard-rejects writes (defense-in-depth alongside the
post_save listener that nukes any errant Line that sneaks in).
Run:
python src/manage.py test functional_tests.test_admin_post_readonly
"""
from django.utils import timezone
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from apps.billboard.models import Line, Post
from apps.drama.models import Note
from apps.lyric.models import User
from .base import FunctionalTest
class AdminPostInputReadonlyTest(FunctionalTest):
"""The note-unlock Post's input is readonly + carries a 'No response
needed' placeholder. User-Post inputs stay untouched."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="reader@test.io")
# Note.grant_if_new auto-creates the Notes & recognitions Post.
Note.grant_if_new(self.gamer, "stargazer")
self.admin_post = Post.objects.get(
owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK,
)
self.create_pre_authenticated_session("reader@test.io")
def test_admin_post_input_is_readonly(self):
self.browser.get(
self.live_server_url + f"/billboard/post/{self.admin_post.id}/"
)
inp = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_post_line_text")
)
self.assertTrue(
inp.get_attribute("readonly"),
"Admin-Post input should carry readonly attribute",
)
def test_admin_post_input_has_no_response_placeholder(self):
self.browser.get(
self.live_server_url + f"/billboard/post/{self.admin_post.id}/"
)
inp = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_post_line_text")
)
self.assertEqual(
inp.get_attribute("placeholder"),
"No response needed at this time",
)
class AdminPostHasNoBuddyBtnTest(FunctionalTest):
"""Admin-Post (note-unlock thread) suppresses #id_buddy_btn — friend
invites don't apply to system-authored threads. User-Post still
renders the btn (regression coverage in test_buddy_btn.py)."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="nobuddy@test.io")
Note.grant_if_new(self.gamer, "stargazer")
self.admin_post = Post.objects.get(
owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK,
)
self.create_pre_authenticated_session("nobuddy@test.io")
def test_buddy_btn_absent_on_admin_post(self):
self.browser.get(
self.live_server_url + f"/billboard/post/{self.admin_post.id}/"
)
# Wait for page to settle — readonly input is the marker.
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_post_line_text")
)
self.assertFalse(
self.browser.find_elements(By.ID, "id_buddy_btn"),
"Admin-Post must NOT render #id_buddy_btn",
)
class UserPostInputUnaffectedTest(FunctionalTest):
"""Regression: user-Post input keeps the regular composer affordances."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="composer@test.io")
self.user_post = Post.objects.create(
owner=self.gamer, kind=Post.KIND_USER_POST, title="hello",
)
Line.objects.create(
post=self.user_post, text="hello", author=self.gamer,
)
self.create_pre_authenticated_session("composer@test.io")
def test_user_post_input_is_not_readonly(self):
self.browser.get(
self.live_server_url + f"/billboard/post/{self.user_post.id}/"
)
inp = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_post_line_text")
)
self.assertFalse(
inp.get_attribute("readonly"),
"User-Post input must NOT be readonly",
)
self.assertEqual(
inp.get_attribute("placeholder"),
"Enter a post line",
)

View File

@@ -203,6 +203,14 @@ body.page-billpost {
input.form-control { input.form-control {
width: 100%; width: 100%;
// Admin-Post readonly input — no response is invited, so the
// focus halo softens to --secUser (cooler than the regular
// --terUser glow used on user-Post composers).
&[readonly]:focus {
border-color: rgba(var(--secUser), 0.6);
box-shadow: 0 0 0.75rem rgba(var(--secUser), 0.4);
}
} }
} }
} }

View File

@@ -1,4 +1,5 @@
{% load static %} {% load static %}
{% load lyric_extras %}
{# ─────────────────────────────────────────────────────────────────────── #} {# ─────────────────────────────────────────────────────────────────────── #}
{# _buddy_panel.html — bottom-left handshake btn + slide-out recipient #} {# _buddy_panel.html — bottom-left handshake btn + slide-out recipient #}
{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #} {# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #}
@@ -13,7 +14,9 @@
<i class="fa-solid fa-handshake"></i> <i class="fa-solid fa-handshake"></i>
</button> </button>
<div id="id_buddy_panel" data-share-url="{% url 'billboard:share_post' post.id %}"> <div id="id_buddy_panel"
data-share-url="{% url 'billboard:share_post' post.id %}"
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
<input id="id_recipient" <input id="id_recipient"
name="recipient" name="recipient"
type="email" type="email"
@@ -76,15 +79,17 @@
// OK → POST share-post async; reuses the C3.b response handling so the // OK → POST share-post async; reuses the C3.b response handling so the
// recipient chip + brief banner + post-line append all light up. // recipient chip + brief banner + post-line append all light up.
// Post-May08b layout: #id_post_table is a <ul> of <li class="post-line"> // Post-May08b layout: #id_post_table is a <ul> of <li class="post-line">
// rows; share-invite Lines are adman-authored (system prose, italic). // rows. Author = the sharer (rendered server-side as data-sharer-name on
// the panel) so the appended Line matches the post-refresh state, where
// the persisted Line.author is request.user.
function _appendLine(text) { function _appendLine(text) {
var list = document.getElementById('id_post_table'); var list = document.getElementById('id_post_table');
if (!list) return; if (!list) return;
var li = document.createElement('li'); var li = document.createElement('li');
li.className = 'post-line post-line--system'; li.className = 'post-line';
var author = document.createElement('span'); var author = document.createElement('span');
author.className = 'post-line-author'; author.className = 'post-line-author';
author.textContent = 'adman'; author.textContent = panel.dataset.sharerName || '';
var body = document.createElement('span'); var body = document.createElement('span');
body.className = 'post-line-text'; body.className = 'post-line-text';
body.textContent = text; body.textContent = text;

View File

@@ -35,26 +35,46 @@
<li class="post-line-buffer" aria-hidden="true"></li> <li class="post-line-buffer" aria-hidden="true"></li>
</ul> </ul>
<form id="id_post_line_form" method="POST" action="{% url 'billboard:view_post' post.id %}" class="post-line-form"> {# Admin-Post (note-unlock thread) input is read-only: the user can't #}
{% csrf_token %} {# respond, and the placeholder calls that out. View_post hard-rejects #}
<input {# POSTs to NOTE_UNLOCK posts; the post_save Line signal is the safety #}
id="id_post_line_text" {# net for ORM-level / API writes that bypass the view. #}
name="text" {% if post.kind == 'note_unlock' %}
class="form-control{% if form.errors.text %} is-invalid{% endif %}" <form id="id_post_line_form" class="post-line-form">
placeholder="Enter a post line" <input
value="{{ form.text.value|default:'' }}" id="id_post_line_text"
aria-describedby="id_post_line_feedback" name="text"
required class="form-control"
/> placeholder="No response needed at this time"
{% if form.errors %} readonly
<div id="id_post_line_feedback" class="invalid-feedback"> />
{{ form.errors.text.0 }} </form>
</div> {% else %}
{% endif %} <form id="id_post_line_form" method="POST" action="{% url 'billboard:view_post' post.id %}" class="post-line-form">
</form> {% csrf_token %}
<input
id="id_post_line_text"
name="text"
class="form-control{% if form.errors.text %} is-invalid{% endif %}"
placeholder="Enter a post line"
value="{{ form.text.value|default:'' }}"
aria-describedby="id_post_line_feedback"
required
/>
{% if form.errors %}
<div id="id_post_line_feedback" class="invalid-feedback">
{{ form.errors.text.0 }}
</div>
{% endif %}
</form>
{% endif %}
{# Buddy btn (bottom-left) + slide-out recipient field — async share. #} {# Buddy btn (bottom-left) + slide-out recipient field — async share. #}
{% include "apps/billboard/_partials/_buddy_panel.html" %} {# Suppressed on admin Posts (note unlock thread) since friend-invites #}
{# don't apply to system-authored threads. #}
{% if post.kind != 'note_unlock' %}
{% include "apps/billboard/_partials/_buddy_panel.html" %}
{% endif %}
</div> </div>
{% endblock content %} {% endblock content %}