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,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",
)