Compare commits

...

4 Commits

Author SHA1 Message Date
Disco DeDisco
b3eb14140c 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>
2026-05-08 21:52:34 -04:00
Disco DeDisco
6f76f6c176 post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
  - lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
  - drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
  - billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
  - post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
  - SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
  - _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
  - FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
  - rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
  - 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
Disco DeDisco
ba5f6556c0 buddy btn sprint: banner-anchor + window.Brief fix lands the last red FT — 16/16 buddy + 12 share/jasmine/my_notes + 818 IT regression — TDD
Two small fixes close out the OK→banner gap:

1. Anchor over h2: base.html drops <div id="id_brief_banner_anchor"></div> right before {% block content %} (after the messages block). note.js's showBanner now prefers the explicit anchor over the first <h2> — keeps the banner in the visible content flow on pages where the first h2 is position:absolute (post.html's rotated navbar header was the immediate motivator; sky.html's rotated h2 is the same shape, so this catches that pre-emptively too).
2. window.Brief explicit assignment: const Brief = (...) at script-tag scope is reachable as a bare name but does NOT auto-attach to window. The buddy panel's OK handler gates banner reveal on `if (window.Brief && data.brief)` — that gate was always false, so Brief.showBanner never fired on share-OK even though the chip + Line append in DOM proved the fetch.then() was running. Explicit window.Brief = Brief; window.Note = Note; in note.js (post-IIFE) closes the gap.

Also picks up the deferred page-object update — functional_tests.post_page.PostPage.share_post_with() now drives the buddy-btn flow (click #id_buddy_btn → type → click #id_buddy_panel .btn.btn-confirm → wait for recipient chip), so legacy SharingTest exercises the new pipeline end-to-end.

NoteSpec.js T10 split into T10a/T10b: a covers the anchor-preferred path, b covers the <h2> fallback.

16/16 buddy FTs green (previously 15/16). 12/12 sharing + Jasmine + my_notes FTs green. 818-test IT sweep green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:14:50 -04:00
Disco DeDisco
e465b6a3b3 buddy btn sprint scaffolding: TDD spec + partial template + SCSS + page_class — 15/16 FTs green, 1 captured-red for post-compaction handoff
Pre-compaction handoff for the bottom-left handshake btn that replaces the inline share form on post.html. The full spec lives in functional_tests/test_buddy_btn.py — running it as a red-first TDD checklist for the next agent (or future Disco) to pick up after compaction.

Scaffolding landed:
- functional_tests/test_buddy_btn.py — 16 tests across 6 classes covering presence-only-on-post.html (B1–B3), bottom-left fixed positioning matching kit-btn dimensions, slide-out panel structure (closed/open width, OK btn = .btn-confirm, recipient input left-padding clears the glyph), kit↔buddy mutual-exclusion via opacity, click-outside + Escape dismiss + clear, OK→async share creates Brief + appends Line + chips the recipient + closes the panel + clears the field, and post.html / my_posts.html body class picks up the aperture marker (page-billboard).
- templates/apps/billboard/_partials/_buddy_panel.html — the partial: <button id="id_buddy_btn"><i class="fa-solid fa-handshake"></i></button> + #id_buddy_panel housing #id_recipient and #id_buddy_ok (.btn.btn-confirm). Inline JS mirrors the game-kit.js click/escape/click-outside pattern, toggles html.buddy-open + .active on the btn, intercepts OK to POST share-post w. Accept:application/json (reuses C3.b shape — line_text + brief.to_banner_dict() + recipient_display), appends the chip + line in-DOM, Brief.showBanner shows the slide-down banner, _close clears the input.
- templates/apps/billboard/post.html — drops the inline #id_share_form / #id_recipient / SHARE-primary block + its JS; includes the buddy panel partial at the end of {% block content %}.
- billboard.views.view_post + my_posts now set page_class="page-billboard" so the body class hooks into the aperture SCSS group (the user noted post.html wasn't in that group; this brings it in).
- static_src/scss/_buddy.scss — new partial: #id_buddy_btn fixed bottom-left mirror of #id_kit_btn (3rem circle, secUser border, .active state, transition: opacity 0.15s); #id_buddy_panel slide-out spans calc(100vw - 3rem) (1.5rem each side w. landscape sidebar carve-outs), transform: scaleX(0)→1 from left center on html.buddy-open, opacity 0→1, the recipient input gets padding 0 1rem 0 3.5rem so the glyph doesn't overlap. Mutual exclusion: html.buddy-open #id_kit_btn → opacity:0; html:has(#id_kit_bag_dialog[open]) #id_buddy_btn → opacity:0 (uses :has() per project convention; no JS-side kit-open class needed).
- core.scss imports buddy after game-kit.

15/16 FTs green; the lone red is BuddyBtnOkSubmitsAsyncShareTest.test_ok_creates_brief_appends_line_and_chip — server flow works (Brief is created, recipient chip + line append in DOM both visible in the screendump), only the .note-banner injection isn't surfacing on post.html. Likely cause: note.js inserts after the first <h2>, but post.html's only h2 is the rotated navbar header which is position:absolute, so the banner's geometry parents to that and falls outside the visible aperture. Two clean follow-ups for the post-compaction agent: (a) make Brief.showBanner pick a different anchor when h2.parentElement is position:absolute, or (b) define a #id_brief_banner_anchor in base.html under the page content and have showBanner prefer it.

Also pending for post-compaction: update functional_tests.post_page.PostPage.share_post_with() to drive the new buddy-btn flow (click btn → type → click OK → wait for chip) so the legacy test_sharing FT keeps working — currently it still operates on the inline form selectors that no longer exist.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:00:28 -04:00
37 changed files with 1698 additions and 228 deletions

View File

@@ -18,7 +18,7 @@ class LineSerializer(serializers.ModelSerializer):
fields = ["id", "text"]
class PostSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField()
name = serializers.ReadOnlyField(source="title")
url = serializers.CharField(source="get_absolute_url", read_only=True)
lines = LineSerializer(many=True, read_only=True)

View File

@@ -14,8 +14,8 @@ class BaseAPITest(TestCase):
class PostDetailAPITest(BaseAPITest):
def test_returns_post_with_lines(self):
post = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post)
Line.objects.create(text="line 2", post=post)
Line.objects.create(text="line 1", post=post, author=self.user)
Line.objects.create(text="line 2", post=post, author=self.user)
response = self.client.get(f"/api/posts/{post.id}/")
@@ -49,7 +49,7 @@ class PostLinesAPITest(BaseAPITest):
def test_cannot_add_duplicate_line_to_post(self):
post = Post.objects.create(owner=self.user)
Line.objects.create(text="post line", post=post)
Line.objects.create(text="post line", post=post, author=self.user)
duplicate_response = self.client.post(
f"/api/posts/{post.id}/lines/",
{"text": "post line"},
@@ -61,7 +61,7 @@ class PostLinesAPITest(BaseAPITest):
class PostsAPITest(BaseAPITest):
def test_get_returns_only_users_posts(self):
post1 = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post1)
Line.objects.create(text="line 1", post=post1, author=self.user)
other_user = User.objects.create_user("other@example.com")
Post.objects.create(owner=other_user)

View File

@@ -18,7 +18,7 @@ class PostLinesAPI(APIView):
post = get_object_or_404(Post, id=post_id)
serializer = LineSerializer(data=request.data, context={"post": post})
if serializer.is_valid():
serializer.save(post=post)
serializer.save(post=post, author=request.user)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
@@ -29,8 +29,9 @@ class PostsAPI(APIView):
return Response(serializer.data)
def post(self, request):
post = Post.objects.create(owner=request.user)
line = Line.objects.create(text=request.data.get("text", ""), post=post)
text = request.data.get("text", "")
post = Post.objects.create(owner=request.user, title=text[:35])
Line.objects.create(text=text, post=post, author=request.user)
serializer = PostSerializer(post)
return Response(serializer.data, status=201)

View File

@@ -13,10 +13,11 @@ class LineForm(forms.Form):
required=True,
)
def save(self, for_post):
def save(self, for_post, author):
return Line.objects.create(
post=for_post,
text=self.cleaned_data["text"],
author=author,
)
@@ -31,5 +32,5 @@ class ExistingPostLineForm(LineForm):
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
return text
def save(self):
return super().save(for_post=self._for_post)
def save(self, author):
return super().save(for_post=self._for_post, author=author)

View File

@@ -0,0 +1,98 @@
# Adds Post.title, Line.author (PROTECT FK to lyric.User), Line.created_at.
# Backfills Post.title from first-line text (truncate 32 + "…" past 35 chars,
# or hardcoded "Notes & recognitions" for KIND_NOTE_UNLOCK), and Line.author
# from Post.owner — except KIND_NOTE_UNLOCK + ownerless rows, which attribute
# to the seeded `adman` User. Depends on lyric/0003_seed_adman so adman
# exists before backfill runs.
from django.db import migrations, models
from django.db.models import deletion
from django.utils import timezone
_NOTE_UNLOCK_TITLE = "Notes & recognitions"
def _truncate_title(text, length=35):
if len(text) <= length:
return text
return text[: length - 3] + "..."
def backfill(apps, schema_editor):
Post = apps.get_model("billboard", "Post")
Line = apps.get_model("billboard", "Line")
User = apps.get_model("lyric", "User")
adman = User.objects.filter(username="adman").first()
for post in Post.objects.all():
if post.kind == "note_unlock":
post.title = _NOTE_UNLOCK_TITLE
else:
first_line = post.lines.order_by("id").first()
post.title = _truncate_title(first_line.text) if first_line else ""
post.save(update_fields=["title"])
now = timezone.now()
for line in Line.objects.select_related("post").all():
if line.post.kind == "note_unlock":
line.author = adman
elif line.post.owner_id:
line.author_id = line.post.owner_id
else:
line.author = adman
if line.created_at is None:
line.created_at = now
line.save(update_fields=["author", "created_at"])
def reverse_noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("billboard", "0003_post_kind"),
("lyric", "0003_seed_adman"),
]
operations = [
migrations.AddField(
model_name="post",
name="title",
field=models.CharField(default="", max_length=35),
),
migrations.AddField(
model_name="line",
name="created_at",
field=models.DateTimeField(default=timezone.now),
),
migrations.AddField(
model_name="line",
name="author",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=deletion.PROTECT,
related_name="authored_lines",
to="lyric.user",
),
),
migrations.RunPython(backfill, reverse_noop),
migrations.AlterField(
model_name="line",
name="author",
field=models.ForeignKey(
on_delete=deletion.PROTECT,
related_name="authored_lines",
to="lyric.user",
),
),
migrations.AlterField(
model_name="line",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

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
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.utils import timezone
@@ -39,9 +41,12 @@ class Post(models.Model):
default=KIND_USER_POST,
)
@property
def name(self):
return self.lines.first().text
# Stored title — set explicitly on creation. Note-unlock Posts hardcode
# "Notes & recognitions"; user_post Posts truncate first line to 35 chars
# (32 + "..." past length). Replaces the legacy `name` property which
# gleaned `lines.first().text` lazily and broke if the first Line was
# later edited or deleted.
title = models.CharField(max_length=35, default="")
def get_absolute_url(self):
return reverse("billboard:view_post", args=[self.id])
@@ -50,9 +55,23 @@ class Post(models.Model):
class Line(models.Model):
text = models.TextField(default="")
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
# `author` PROTECTs against accidental sitewide-entity deletion (notably
# `adman`, the system-author for note_unlock + share_invite Lines).
# User-typed Lines attribute to the typing User; system-rendered Lines
# attribute to adman so the per-line "username" column always renders.
author = models.ForeignKey(
"lyric.User",
on_delete=models.PROTECT,
related_name="authored_lines",
)
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:
ordering = ("id",)
ordering = ("created_at", "id")
unique_together = ("post", "text")
def __str__(self):
@@ -136,3 +155,18 @@ class Brief(models.Model):
"square_url": square_url,
"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

@@ -17,7 +17,7 @@ class BriefModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="brief@test.io")
self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm")
self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm", author=self.user)
def test_brief_defaults_unread(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
@@ -72,7 +72,7 @@ class ViewPostMarksReadTest(TestCase):
self.user = User.objects.create(email="reader@test.io")
self.client.force_login(self.user)
self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="entry one")
self.line = Line.objects.create(post=self.post, text="entry one", author=self.user)
def test_get_view_post_flips_owner_unread_brief_to_read(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
@@ -94,7 +94,7 @@ class ViewPostMarksReadTest(TestCase):
def test_get_does_not_flip_briefs_on_other_posts(self):
other_post = Post.objects.create(owner=self.user)
other_line = Line.objects.create(post=other_post, text="other")
other_line = Line.objects.create(post=other_post, text="other", author=self.user)
unrelated = Brief.objects.create(owner=self.user, post=other_post, line=other_line)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
unrelated.refresh_from_db()

View File

@@ -85,16 +85,42 @@ class SharePostAsyncTest(TestCase):
self.assertEqual(body["brief"]["kind"], "share_invite")
self.assertIn("alice@test.io", body["line_text"])
def test_async_share_line_text_dedupes_via_timestamp(self):
"""Two consecutive shares of the same email must not collide on the
Line.unique_together(post, text) constraint — the share line should
carry a timestamp suffix."""
def test_async_reshare_same_recipient_is_silent_noop(self):
"""Sharing the same recipient twice is a silent no-op — Post.shared_with
M2M is idempotent so a second add is meaningless, and we don't want a
duplicate Line cluttering the thread. Response is 200 with brief=null."""
User.objects.create(email="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")
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):

View File

@@ -8,7 +8,6 @@ from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
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.models import Brief, Line, Post
@@ -16,7 +15,7 @@ from apps.dashboard.views import _PALETTE_DEFS
from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room
from apps.epic.utils import rooms_for_user
from apps.lyric.models import User
from apps.lyric.models import User, get_or_create_adman
_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}
@@ -219,14 +218,30 @@ def doff_title(request, slug):
# Templates also live under templates/apps/billboard/. URL names sit in the
# `billboard:` namespace so reversers across the codebase carry the prefix.
def _truncate_post_title(text, length=35):
"""Glean a Post.title from the first user-submitted Line: copy first
`length` chars exactly, or truncate to `length-3` chars + "..." past
that. Mirrors billboard/migrations/0004 backfill helper."""
if len(text) <= length:
return text
return text[: length - 3] + "..."
def new_post(request):
form = LineForm(data=request.POST)
if form.is_valid():
nupost = Post.objects.create()
# Anonymous compose path (Percival ch. 18 chapters) keeps owner=null
# but still needs an author for the Line FK. We require auth on this
# view's caller paths in practice; no anonymous Lines reach prod.
author = request.user if request.user.is_authenticated else None
nupost = Post.objects.create(
title=_truncate_post_title(form.cleaned_data["text"]),
)
if request.user.is_authenticated:
nupost.owner = request.user
nupost.save()
form.save(for_post=nupost)
if author is not None:
form.save(for_post=nupost, author=author)
return redirect(nupost)
else:
context = {
@@ -248,12 +263,19 @@ def view_post(request, post_id):
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
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)
if request.method == "POST":
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid():
form.save()
form.save(author=request.user)
return redirect(our_post)
# GET render is the FYI-read contract — flip every unread Brief on this
@@ -263,7 +285,11 @@ def view_post(request, post_id):
Brief.objects.filter(
owner=request.user, post=our_post, is_unread=True,
).update(is_unread=False)
return render(request, "apps/billboard/post.html", {"post": our_post, "form": form})
return render(request, "apps/billboard/post.html", {
"post": our_post,
"form": form,
"page_class": "page-billpost",
})
def my_posts(request, user_id):
@@ -272,7 +298,10 @@ def my_posts(request, user_id):
return redirect("/")
if request.user.id != owner.id:
return HttpResponseForbidden()
return render(request, "apps/billboard/my_posts.html", {"owner": owner})
return render(request, "apps/billboard/my_posts.html", {
"owner": owner,
"page_class": "page-billboard",
})
def share_post(request, post_id):
@@ -292,28 +321,39 @@ def share_post(request, post_id):
return JsonResponse({"brief": None, "line_text": ""})
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)
# Always append a Line + spawn a Brief for the sharer — privacy: the
# 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.
line_text = (
f"Shared with {recipient_email} at {timezone.now().isoformat()}"
)
line = Line.objects.create(post=our_post, text=line_text)
line = None
brief = None
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",
line_text = ""
if not is_reshare:
# Plain "Shared with X" — timestamp display lives on the per-Line
# `<time>` element, not in the prose. Author = sharer (post owner)
# so the per-line "username" column attributes correctly. Anonymous
# shares (legacy Percival ch. 19 ownerless-post path) fall back to
# 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:
# recipient_display is populated only when the address resolves to a

View File

@@ -24,14 +24,21 @@ const Brief = (() => {
const banner = document.createElement('div');
banner.className = 'note-banner';
// The square mirrors my_notes.html's .note-item__image-box (dashed
// border + "?" placeholder) when the brief carries a square_url —
// currently note_unlock kind, which jumps direct to /billboard/my-notes/.
const squareEl = brief.square_url
? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image"></a>'
? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image note-item__image-box">?</a>'
: '<div class="note-banner__image"></div>';
// line_text is server-rendered prose from drama.Note.grant_if_new
// (and server-side share_post) — it may carry a `<a class="note-ref">`
// anchor wrapping the Note name. Insert as HTML, NOT escaped text.
// Title is plain (no HTML), so it stays escaped.
banner.innerHTML =
'<div class="note-banner__body">' +
'<p class="note-banner__title">' + _esc(brief.title) + '</p>' +
'<p class="note-banner__description">' + _esc(brief.line_text) + '</p>' +
'<p class="note-banner__description">' + (brief.line_text || '') + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' +
dateStr +
'</time>' +
@@ -44,9 +51,15 @@ const Brief = (() => {
banner.remove();
});
var h2 = document.querySelector('h2');
if (h2 && h2.parentNode) {
h2.parentNode.insertBefore(banner, h2.nextSibling);
// Prefer the explicit anchor (set in base.html under the messages
// block, before {% block content %}) — keeps the banner in the
// visible content flow on pages where the first <h2> is
// position:absolute (e.g. post.html's rotated navbar header).
// Falls back to <h2> for pages that pre-date the anchor.
var anchor = document.getElementById('id_brief_banner_anchor')
|| document.querySelector('h2');
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(banner, anchor.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
@@ -67,3 +80,9 @@ const Brief = (() => {
// Backwards-compat shim — to be removed once the codebase uniformly uses Brief.
const Note = Brief;
// `const Brief = (...)` at script-tag scope is reachable as a bare name but
// is NOT auto-attached to window — explicit assignment so callers that gate
// on `if (window.Brief)` (e.g. _buddy_panel.html's OK handler) succeed.
window.Brief = Brief;
window.Note = Note;

View File

@@ -7,19 +7,26 @@ from apps.billboard.forms import (
LineForm,
)
from apps.billboard.models import Line, Post
from apps.lyric.models import User
class LineFormTest(TestCase):
def setUp(self):
self.author = User.objects.create(email="author@test.io")
def test_form_save_handles_saving_to_a_post(self):
mypost = Post.objects.create()
form = LineForm(data={"text": "do re mi"})
self.assertTrue(form.is_valid())
new_line = form.save(for_post=mypost)
new_line = form.save(for_post=mypost, author=self.author)
self.assertEqual(new_line, Line.objects.get())
self.assertEqual(new_line.text, "do re mi")
self.assertEqual(new_line.post, mypost)
class ExistingPostLineFormTest(TestCase):
def setUp(self):
self.author = User.objects.create(email="author@test.io")
def test_form_validation_for_blank_lines(self):
post = Post.objects.create()
form = ExistingPostLineForm(for_post=post, data={"text": ""})
@@ -28,7 +35,7 @@ class ExistingPostLineFormTest(TestCase):
def test_form_validation_for_duplicate_lines(self):
post = Post.objects.create()
Line.objects.create(post=post, text="twins, basil")
Line.objects.create(post=post, text="twins, basil", author=self.author)
form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
@@ -37,5 +44,5 @@ class ExistingPostLineFormTest(TestCase):
mypost = Post.objects.create()
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
self.assertTrue(form.is_valid())
new_line = form.save()
new_line = form.save(author=self.author)
self.assertEqual(new_line, Line.objects.get())

View File

@@ -7,64 +7,66 @@ from apps.lyric.models import User
class LineModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
def test_line_is_related_to_post(self):
mypost = Post.objects.create()
line = Line()
line.post = mypost
line = Line(post=mypost, author=self.user, text="x")
line.save()
self.assertIn(line, mypost.lines.all())
def test_cannot_save_null_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text=None)
line = Line(post=mypost, author=self.user, text=None)
with self.assertRaises(IntegrityError):
line.save()
def test_cannot_save_empty_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text="")
line = Line(post=mypost, author=self.user, text="")
with self.assertRaises(ValidationError):
line.full_clean()
def test_duplicate_lines_are_invalid(self):
mypost = Post.objects.create()
Line.objects.create(post=mypost, text="jklol")
Line.objects.create(post=mypost, author=self.user, text="jklol")
with self.assertRaises(ValidationError):
line = Line(post=mypost, text="jklol")
line = Line(post=mypost, author=self.user, text="jklol")
line.full_clean()
def test_still_can_save_same_line_to_different_posts(self):
post1 = Post.objects.create()
post2 = Post.objects.create()
Line.objects.create(post=post1, text="nojk")
line = Line(post=post2, text="nojk")
Line.objects.create(post=post1, author=self.user, text="nojk")
line = Line(post=post2, author=self.user, text="nojk")
line.full_clean() # should not raise
class PostModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
def test_get_absolute_url(self):
mypost = Post.objects.create()
self.assertEqual(mypost.get_absolute_url(), f"/billboard/post/{mypost.id}/")
def test_post_lines_order(self):
post1 = Post.objects.create()
line1 = Line.objects.create(post=post1, text="i1")
line2 = Line.objects.create(post=post1, text="line 2")
line3 = Line.objects.create(post=post1, text="3")
line1 = Line.objects.create(post=post1, author=self.user, text="i1")
line2 = Line.objects.create(post=post1, author=self.user, text="line 2")
line3 = Line.objects.create(post=post1, author=self.user, text="3")
self.assertEqual(
list(post1.lines.all()),
[line1, line2, line3],
)
def test_posts_can_have_owners(self):
user = User.objects.create(email="a@b.cde")
mypost = Post.objects.create(owner=user)
self.assertIn(mypost, user.posts.all())
mypost = Post.objects.create(owner=self.user)
self.assertIn(mypost, self.user.posts.all())
def test_post_owner_is_optional(self):
Post.objects.create()
def test_post_name_is_first_line_text(self):
post = Post.objects.create()
Line.objects.create(post=post, text="first line")
Line.objects.create(post=post, text="second line")
self.assertEqual(post.name, "first line")
def test_post_title_is_explicit_field(self):
post = Post.objects.create(title="first line")
self.assertEqual(post.title, "first line")

View File

@@ -66,6 +66,13 @@ class NewPostTest(TestCase):
@override_settings(COMPRESS_ENABLED=False)
class PostViewTest(TestCase):
def setUp(self):
self.author = User.objects.create(email="author@test.io")
# POST flows append a Line with author=request.user — non-null FK
# since Line.author is required. force_login so the view's view_post
# path saves cleanly; anonymous compose is no longer supported.
self.client.force_login(self.author)
def test_uses_post_template(self):
mypost = Post.objects.create()
response = self.client.get(f"/billboard/post/{mypost.id}/")
@@ -85,10 +92,10 @@ class PostViewTest(TestCase):
def test_displays_only_lines_for_that_post(self):
# Given/Arrange
correct_post = Post.objects.create()
Line.objects.create(text="itemey 1", post=correct_post)
Line.objects.create(text="itemey 2", post=correct_post)
Line.objects.create(text="itemey 1", post=correct_post, author=self.author)
Line.objects.create(text="itemey 2", post=correct_post, author=self.author)
other_post = Post.objects.create()
Line.objects.create(text="other post line", post=other_post)
Line.objects.create(text="other post line", post=other_post, author=self.author)
# When/Act
response = self.client.get(f"/billboard/post/{correct_post.id}/")
# Then/Assert
@@ -147,7 +154,7 @@ class PostViewTest(TestCase):
def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create()
Line.objects.create(post=post1, text="lorem ipsum")
Line.objects.create(post=post1, text="lorem ipsum", author=self.author)
response = self.client.post(
f"/billboard/post/{post1.id}/",

View File

@@ -18,7 +18,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Token, User, Wallet
from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username
APPLET_ORDER = ["wallet", "username", "palette"]
@@ -112,6 +112,9 @@ def set_palette(request):
def set_profile(request):
if request.method == "POST":
username = request.POST.get("username", "")
if is_reserved_username(username, current_user=request.user):
messages.error(request, "That handle is reserved.")
return redirect("/")
request.user.username = username
request.user.save(update_fields=["username"])
return redirect("/")

View File

@@ -210,6 +210,15 @@ _NOTE_DISPLAY = {
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
}
# Note slugs whose grant prose uses the long admin format ("The administration
# recognizes…") rather than the standard "Look!—new Note unlocked…" format.
# Any slug not in this set gets the standard format.
_ADMIN_NOTE_SLUGS = frozenset({"super-schizo", "super-nomad"})
# Hardcoded title for the per-user "Note unlocks" Post — supplants any
# first-line-glean for posts of kind=NOTE_UNLOCK.
NOTE_UNLOCK_POST_TITLE = "Notes & recognitions"
class Note(models.Model):
user = models.ForeignKey(
@@ -235,17 +244,37 @@ class Note(models.Model):
def display_greeting(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,")
@property
def display_name(self):
"""The Note's *name* (e.g., "Stargazer", "Super-Schizo") — the heading
rendered on the my-notes card. Distinct from `display_title` which is
the *recognition title* the user dons (e.g., "Schizoid Man" for
super-schizo). For all current slugs `slug.title()` recovers the right
casing (.title() capitalizes after non-letter chars, so "super-schizo"
"Super-Schizo"); special-case in `_NOTE_DISPLAY[slug]["name"]` if a
future slug needs a different rendering."""
return _NOTE_DISPLAY.get(self.slug, {}).get("name", self.slug.title())
@classmethod
def grant_if_new(cls, user, slug):
"""Grants the Note if it doesn't already exist on the user; on a fresh
grant ALSO appends a Line to the user's per-category "Note Unlocks"
Post (creating the Post on first-ever unlock) and spawns a Brief that
FKs the appended Line. Returns ``(note, created, brief)`` — brief is
None on idempotent re-grants. Banner-side affordances (FYI navigation,
my-notes square) ride on the Brief.kind=NOTE_UNLOCK discriminator."""
grant ALSO appends a Line to the user's per-category "Notes &
recognitions" Post (creating the Post on first-ever unlock) and spawns
a Brief that FKs the appended Line. Returns ``(note, created, brief)``
— brief is None on idempotent re-grants. Banner-side affordances (FYI
navigation, my-notes square) ride on Brief.kind=NOTE_UNLOCK.
Line text dispatches by slug: admin-grant slugs (super-schizo,
super-nomad) use the long "The administration recognizes…" format;
every other slug uses the standard "Look!—new Note unlocked. {Note
name} recognizes {username} the {title}." format. Both wrap the Note
name in a `note-ref` anchor pointing at /billboard/my-notes/.
Author is hardcoded to the seeded `adman` User; the per-line username
column then attributes the Line correctly."""
from django.utils import timezone
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import get_or_create_adman
note, created = cls.objects.get_or_create(
user=user, slug=slug,
@@ -256,17 +285,40 @@ class Note(models.Model):
post, _ = Post.objects.get_or_create(
owner=user, kind=Post.KIND_NOTE_UNLOCK,
defaults={"title": NOTE_UNLOCK_POST_TITLE},
)
# Existing Note-unlock Posts (pre-0004 migration) might lack a title
# if they predate this code path's get_or_create defaults. Heal once.
if post.title != NOTE_UNLOCK_POST_TITLE:
post.title = NOTE_UNLOCK_POST_TITLE
post.save(update_fields=["title"])
username = user.username or user.email
note_anchor = (
f'<a class="note-ref" href="/billboard/my-notes/">'
f'{note.display_name}</a>'
)
if slug in _ADMIN_NOTE_SLUGS:
line_text = (
f"The administration recognizes {username} for {note_anchor}, "
f"which comes with the customary title of {note.display_title}. "
"This does not entail any additional benefits."
)
else:
line_text = (
f"Look!—new Note unlocked. {note_anchor} "
f"recognizes {username} the {note.display_title}."
)
# Lazy get-or-create: TransactionTestCase flushes the migration-seeded
# adman row, so tests that create superusers (which auto-grants
# super-schizo + super-nomad via the User post_save signal) need a
# safety net. Production migrations seed it once.
adman = get_or_create_adman()
line = Line.objects.create(
post=post, text=line_text, author=adman,
admin_solicited=True,
)
# Per-category header Line (becomes Post.name) — only added once on
# first-ever unlock for this user.
Line.objects.get_or_create(post=post, text="Look! — new Note unlocked")
# Per-event Line — text dedupe is enforced by the unique_together on
# (post, text), so two unlocks of the same slug at the same minute
# would clash; the timestamp suffix carries the second of resolution.
# %-I (lstrip-zero hour) is POSIX-only; %I gives "05:21:00 PM" — fine
# on Windows + Linux, and the leading zero is acceptable in a Line.
line_text = f"{note.display_title}, {note.earned_at:%I:%M:%S %p}"
line = Line.objects.create(post=post, text=line_text)
brief = Brief.objects.create(
owner=user,
post=post,

View File

@@ -45,7 +45,7 @@ class GrantIfNewSpawnsBriefTest(TestCase):
def test_two_different_grants_share_one_post(self):
"""Per-category Post: stargazer + schizo unlocks both append Lines to
the same Note Unlocks Post (one growing thread)."""
the same "Notes & recognitions" Post (one growing thread)."""
Note.grant_if_new(self.user, "stargazer")
Note.grant_if_new(self.user, "schizo")
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
@@ -53,10 +53,11 @@ class GrantIfNewSpawnsBriefTest(TestCase):
post = posts.first()
# 2 Briefs, one per unlock
self.assertEqual(Brief.objects.filter(post=post).count(), 2)
# 3 distinct Lines on the Post: 1 header ("Look! — new Note unlocked")
# + 2 per-event Lines (one per unlock)
# 2 distinct Lines on the Post — one per grant. The standalone
# "Look! — new Note unlocked" header Line was dropped in the May-8b
# refactor; the standard format now embeds that text inline per Line.
line_texts = list(post.lines.values_list("text", flat=True))
self.assertEqual(len(set(line_texts)), 3)
self.assertEqual(len(set(line_texts)), 2)
def test_brief_line_text_includes_note_title(self):
_, _, brief = Note.grant_if_new(self.user, "stargazer")

View File

@@ -0,0 +1,29 @@
from django.contrib.auth.hashers import make_password
from django.db import migrations
def seed_adman(apps, schema_editor):
User = apps.get_model("lyric", "User")
User.objects.get_or_create(
username="adman",
defaults={
"email": "adman@earthmanrpg.local",
"password": make_password(None),
"is_staff": False,
"is_superuser": False,
"searchable": False,
},
)
def reverse_noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("lyric", "0002_user_pronouns"),
]
operations = [
migrations.RunPython(seed_adman, reverse_noop),
]

View File

@@ -37,6 +37,47 @@ def resolve_pronouns(pronouns_key):
return row["subj"], row["obj"], row["poss"]
# ── Reserved usernames ────────────────────────────────────────────────────
# Sitewide entities that shouldn't be impersonated by new account names.
# Compared lower-case in username assignment paths (set_profile, etc.).
# `adman` is the system author for Note-unlock + share-invite Lines (seeded
# in lyric/0003_seed_adman). The author's handles (disco, discoman,
# hamildong) are NOT in this set yet — discoman is the founder's actual
# username and existing tests assign it; revisit if/when other-entity
# impersonation becomes a concrete concern.
RESERVED_USERNAMES = frozenset({"adman"})
def is_reserved_username(name, current_user=None):
"""True if `name` is reserved AND not already owned by `current_user`."""
n = (name or "").strip().lower()
if not n:
return False
if current_user is not None and (current_user.username or "").lower() == n:
return False
return n in RESERVED_USERNAMES
def get_or_create_adman():
"""Idempotent fetch of the sitewide `adman` User — system-author for
Note-unlock + share-invite Lines. Production migrations seed it once
(lyric/0003_seed_adman); TransactionTestCase flushes the row between
tests, so view code that authors Lines as adman calls this helper."""
from django.contrib.auth.hashers import make_password
adman, _ = User.objects.get_or_create(
username="adman",
defaults={
"email": "adman@earthmanrpg.local",
"password": make_password(None),
"is_staff": False,
"is_superuser": False,
"searchable": False,
},
)
return adman
class UserManager(BaseUserManager):
def create_user(self, email):
user = self.model(email=email)
@@ -99,6 +140,16 @@ class User(AbstractBaseUser):
REQUIRED_FIELDS = []
USERNAME_FIELD = "email"
@property
def active_title_display(self):
"""Render-ready string for "{username} the {title}" attributions —
returns the donned Note's recognition title, or 'Earthman' when no
Note is donned. The 'Earthman' default mirrors the dashboard greeting
fallback in dashboard/views.home_page."""
if self.active_title_id:
return self.active_title.display_title
return "Earthman"
@property
def pronoun_subj(self):
return resolve_pronouns(self.pronouns)[0]

View File

@@ -9,15 +9,30 @@ class PostPage:
self.test = test
def get_table_rows(self):
return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_post_table tr")
# Post-May08b: #id_post_table is now a <ul> of <li class="post-line">
# rows (no <ol> numbering). The CSS selector still works since the
# id is preserved.
return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_post_table .post-line")
@wait
def wait_for_row_in_post_table(self, line_text, line_number):
expected_row_text = f"{line_number}. {line_text}"
def wait_for_row_in_post_table(self, line_text, line_number=None):
# `line_number` retained for backwards compat with callers that
# passed a position counter — ignored now (lines order by created_at,
# numbering is gone). Match by text containment instead.
rows = self.get_table_rows()
self.test.assertIn(expected_row_text, [row.text for row in rows])
row_texts = [row.text for row in rows]
self.test.assertTrue(
any(line_text in t for t in row_texts),
f"Line text {line_text!r} not in any row: {row_texts!r}",
)
def get_line_input_box(self):
# /billboard/ new-post applet uses #id_text (creates a fresh Post);
# post.html aperture uses #id_post_line_text (appends to existing).
# `add_post_line` is called from both contexts, so probe in order.
boxes = self.test.browser.find_elements(By.ID, "id_post_line_text")
if boxes:
return boxes[0]
return self.test.browser.find_element(By.ID, "id_text")
def add_post_line(self, line_text):
@@ -40,8 +55,19 @@ class PostPage:
)
def share_post_with(self, email):
self.get_share_box().send_keys(email)
self.get_share_box().send_keys(Keys.ENTER)
# Buddy-btn flow (post-Brief sprint): click bottom-left handshake,
# type the email in the slide-out, click the .btn-confirm OK, wait
# for the recipient chip.
buddy_btn = self.test.browser.find_element(By.ID, "id_buddy_btn")
buddy_btn.click()
recipient = self.test.wait_for(
lambda: self.test.browser.find_element(By.ID, "id_recipient")
)
recipient.send_keys(email)
ok = self.test.browser.find_element(
By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm"
)
ok.click()
self.test.wait_for(
lambda: self.test.assertIn(
email, [item.text for item in self.get_shared_with_list()]
@@ -49,4 +75,8 @@ class PostPage:
)
def get_post_owner(self):
return self.test.browser.find_element(By.ID, "id_post_owner").text
# `<span id="id_post_owner" hidden>` — Selenium .text returns "" for
# hidden elements, so read textContent attribute instead.
return self.test.browser.find_element(
By.ID, "id_post_owner"
).get_attribute("textContent")

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

@@ -18,12 +18,12 @@ class LineValidationTest(FunctionalTest):
post_page.get_line_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
lambda: self.browser.find_element(By.CSS_SELECTOR, 'input[name="text"]:invalid')
)
post_page.get_line_input_box().send_keys("Purchase milk")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid")
lambda: self.browser.find_element(By.CSS_SELECTOR, 'input[name="text"]:valid')
)
post_page.get_line_input_box().send_keys(Keys.ENTER)
@@ -33,14 +33,14 @@ class LineValidationTest(FunctionalTest):
post_page.wait_for_row_in_post_table("Purchase milk", 1)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
lambda: self.browser.find_element(By.CSS_SELECTOR, 'input[name="text"]:invalid')
)
post_page.get_line_input_box().send_keys("Make tea")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
"#id_text:valid",
'input[name="text"]:valid',
)
)
post_page.get_line_input_box().send_keys(Keys.ENTER)

View File

@@ -0,0 +1,381 @@
"""FT spec for the Buddy btn sprint — post.html bottom-left handshake button.
Written red BEFORE implementation as a TDD handoff so the post-compaction
agent (or future Disco) can land the feature without losing intent. Run:
python src/manage.py test functional_tests.test_buddy_btn
All tests should be RED initially. Implementation lands when they go green.
────────────────────────────────────────────────────────────────────────────
SPEC SUMMARY
────────────────────────────────────────────────────────────────────────────
• A new #id_buddy_btn (<i class="fa-solid fa-handshake">) sits bottom-left
of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same
fixed/circular/secUser-bordered look + .active-state styling.
• Lives in a partial template, e.g. apps/billboard/_partials/_buddy_panel.html,
included only by post.html (NOT the global base.html — buddy is post-only).
• Replaces the inline share form on post.html: typing the recipient now
happens in a slide-out under the buddy btn.
• Click #id_buddy_btn → recipient field grows L→R under it, spanning
`100vw - 3rem` (1.5rem padding each side). The field is vertically
centred on the buddy btn's centre, w. healthy left padding so the typed
text + placeholder don't overlap the btn glyph.
• An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the
field (replaces the legacy big SHARE .btn-primary).
• While the recipient field is open: html.buddy-open is set; #id_kit_btn
quickly eases to opacity 0. Symmetric: when html.kit-open is set,
#id_buddy_btn eases to opacity 0.
• Click OK → POST share-post async (existing C3.b endpoint), clears the
field, closes the slide-out, slide-down Brief banner appears.
• Click outside the field/btn → closes the slide-out, clears the field
(no Brief). Reopening shows the placeholder, not the prior typed value.
• Escape key closes the slide-out (same as kit btn pattern).
• post.html + my_posts.html should be aperture-styled (body.page-billboard
or new body.page-post if needed; the user noted post.html isn't
currently in the base/applet scss aperture group).
────────────────────────────────────────────────────────────────────────────
IMPLEMENTATION CHECKLIST (post-compaction)
────────────────────────────────────────────────────────────────────────────
1. Create templates/apps/billboard/_partials/_buddy_panel.html w. the btn
+ slide-out form + inline JS.
2. Edit templates/apps/billboard/post.html:
- Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn
block (the share JS moves into _buddy_panel.html).
- Add `{% include "apps/billboard/_partials/_buddy_panel.html" %}`.
3. Edit billboard.views.view_post (or my_posts) context:
- "page_class": "page-billboard" (or new page-post) so the body class
picks up the aperture SCSS.
4. SCSS — add to _game-kit.scss neighbour or new _buddy.scss:
- #id_buddy_btn: position fixed bottom-left, mirror of #id_kit_btn
(3rem circle, secUser border, .active state, etc.)
- #id_buddy_panel (the slide-out wrapper): position fixed,
left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when
kit btn visible — but mutual-exclusion makes that moot), bottom-
aligned w. the btn centre, transition transform/width L→R.
- html.buddy-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
- html.kit-open #id_buddy_btn { opacity: 0; transition: opacity 0.15s; }
- The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on
the trailing edge of the slide-out.
5. JS in _buddy_panel.html:
- Mirror game-kit.js click/escape/click-outside pattern.
- Toggle html.buddy-open + #id_buddy_btn.active.
- On submit/OK: fetch POST share-post w. Accept:application/json,
reuse the C3.b response handling (line append, banner via
Brief.showBanner, recipient_display chip append).
- On dismiss-without-OK: clear input.value.
6. post.html + my_posts.html: add the body class hook so the aperture
SCSS engages (probably page-billboard already, just need to confirm).
7. Update functional_tests.post_page.PostPage.share_post_with() to
drive the buddy-btn flow (click btn → type → click OK → wait for chip).
8. Re-run test_sharing.SharingTest — should still pass once the page-
object mirrors the new flow.
────────────────────────────────────────────────────────────────────────────
KNOWN AT TIME OF WRITING
────────────────────────────────────────────────────────────────────────────
- #id_kit_btn lives in templates/core/base.html (line ~55), styled in
static_src/scss/_game-kit.scss; toggled by apps/dashboard/static/.../game-kit.js.
- The C3.b share-post async endpoint accepts Accept: application/json,
returns {brief, line_text, recipient_display}; intercepted by the
inline JS in post.html's existing #id_share_form. That JS moves into
_buddy_panel.html.
- body.page-billboard class is set by billboard:billboard view; post.html
needs it (or its own class) added in billboard.views.view_post.
"""
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from apps.applets.models import Applet
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import User
from .base import FunctionalTest
def _seed_a_post(user):
"""Create a Post w. one Line so view_post renders w/o redirect."""
p = Post.objects.create(owner=user, title="seed line")
Line.objects.create(post=p, text="seed line", author=user)
return p
class BuddyBtnPresenceTest(FunctionalTest):
"""The buddy btn is post-only — present on post.html, absent elsewhere."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="my-posts",
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
)
self.gamer = User.objects.create(email="buddy@test.io")
self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io")
# ── B1 ──────────────────────────────────────────────────────────────────
def test_buddy_btn_renders_on_post_html(self):
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake")
self.assertIsNotNone(icon)
# ── B2 ──────────────────────────────────────────────────────────────────
def test_buddy_btn_absent_on_dashboard(self):
self.browser.get(self.live_server_url + "/")
# Allow page to settle
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn"))
# ── B3 ──────────────────────────────────────────────────────────────────
def test_buddy_btn_absent_on_billboard_index(self):
self.browser.get(self.live_server_url + "/billboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn"))
class BuddyBtnPositionTest(FunctionalTest):
"""The btn sits bottom-left, mirror of #id_kit_btn's bottom-right."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="buddy@test.io")
self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_buddy_btn_is_fixed_bottom_left(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
cs = self.browser.execute_script(
"var s = getComputedStyle(arguments[0]); "
"return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};",
btn,
)
self.assertEqual(cs["position"], "fixed")
# bottom + left are non-auto/0; right is auto (or large) so the btn
# hugs the bottom-left corner.
self.assertNotEqual(cs["bottom"], "auto")
self.assertNotEqual(cs["left"], "auto")
def test_buddy_btn_size_matches_kit_btn(self):
"""Same circular-3rem look — visually a mirror pair."""
btn = self.browser.find_element(By.ID, "id_buddy_btn")
kit = self.browser.find_element(By.ID, "id_kit_btn")
b_box = btn.size
k_box = kit.size
self.assertEqual(b_box["width"], k_box["width"])
self.assertEqual(b_box["height"], k_box["height"])
class BuddyBtnSlideOutTest(FunctionalTest):
"""Click the buddy btn → recipient field + OK btn slide out under it."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="buddy@test.io")
self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_recipient_field_hidden_until_click(self):
"""Pre-click: the field is in DOM but visually closed (e.g. width 0
or transform scaleX(0)) — assertion checks it doesn't take its full
viewport-spanning width yet."""
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
# The field+OK panel is rendered (so JS can transition it) but should
# be in a closed state — assert the panel container exists and the
# input is not displayed at full width (a CSS-driven slide-out).
panel = self.browser.find_element(By.ID, "id_buddy_panel")
# Before click, panel visible-width should be < viewport / 2 (closed)
viewport_w = self.browser.execute_script("return window.innerWidth;")
self.assertLess(panel.size["width"], viewport_w / 2)
def test_click_buddy_btn_reveals_recipient_field_and_ok_btn(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
self.assertTrue(recipient.is_displayed())
# OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
self.assertEqual(ok.text.strip().upper(), "OK")
# Buddy btn picks up .active when open (mirror kit-btn pattern)
self.assertIn("active", btn.get_attribute("class"))
def test_panel_spans_almost_full_viewport_when_open(self):
"""When open, the panel spans 100vw - 3rem (1.5rem each side)."""
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
btn.click()
panel = self.browser.find_element(By.ID, "id_buddy_panel")
# Wait for the slide-out transition to settle
self.wait_for(lambda: self.assertGreater(
panel.size["width"],
self.browser.execute_script("return window.innerWidth;") * 0.7,
))
def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
pad = self.browser.execute_script(
"return parseFloat(getComputedStyle(arguments[0]).paddingLeft);",
recipient,
)
# At least 2.5rem (40px-ish) so the buddy glyph (3rem circle) doesn't
# overlap the placeholder/typed text.
self.assertGreaterEqual(pad, 32)
class BuddyKitMutualExclusionTest(FunctionalTest):
"""When kit btn is active, buddy btn fades to 0 — and vice-versa."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="buddy@test.io")
self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_buddy_active_fades_kit_btn(self):
buddy = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
kit = self.browser.find_element(By.ID, "id_kit_btn")
buddy.click()
self.wait_for(lambda: self.assertEqual(
self.browser.execute_script(
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
kit,
),
0.0,
))
def test_kit_active_fades_buddy_btn(self):
kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
buddy = self.browser.find_element(By.ID, "id_buddy_btn")
kit.click()
self.wait_for(lambda: self.assertEqual(
self.browser.execute_script(
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
buddy,
),
0.0,
))
class BuddyBtnDismissTest(FunctionalTest):
"""Click outside / Escape closes the panel; field is cleared; reopening
shows the placeholder, not the previously-typed value."""
def setUp(self):
super().setUp()
self.gamer = User.objects.create(email="buddy@test.io")
self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def _open_and_type(self, text):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys(text)
return btn, recipient
def test_click_outside_dismisses_and_clears(self):
btn, recipient = self._open_and_type("alice@test.io")
# Click somewhere far from the panel + btn
body = self.browser.find_element(By.TAG_NAME, "body")
self.browser.execute_script(
"var e = new MouseEvent('click', {bubbles:true, clientX: 100, clientY: 100});"
"document.querySelector('h2').dispatchEvent(e);"
)
self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
# Reopening: input value should be cleared
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
self.assertEqual(recipient.get_attribute("value"), "")
def test_escape_dismisses_and_clears(self):
btn, recipient = self._open_and_type("alice@test.io")
recipient.send_keys(Keys.ESCAPE)
self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
self.assertEqual(recipient.get_attribute("value"), "")
class BuddyBtnOkSubmitsAsyncShareTest(FunctionalTest):
"""OK → POST share-post (Accept:application/json) → Brief banner +
recipient chip appended; field clears; panel closes."""
def setUp(self):
super().setUp()
self.sharer = User.objects.create(email="buddy@test.io")
self.recipient = User.objects.create(email="alice@test.io")
self.post = _seed_a_post(self.sharer)
self.create_pre_authenticated_session("buddy@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_ok_creates_brief_appends_line_and_chip(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
btn.click()
recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient_input.send_keys("alice@test.io")
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
ok.click()
# 1. Brief is created server-side
self.wait_for(lambda: self.assertEqual(
Brief.objects.filter(owner=self.sharer, kind=Brief.KIND_SHARE_INVITE).count(),
1,
))
# 2. Banner appears
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
# 3. .post-recipient chip appended
self.wait_for(lambda: self.assertTrue(
self.browser.find_elements(By.CSS_SELECTOR, ".post-recipient")
))
# 4. Field cleared
recipient_input = self.browser.find_element(By.ID, "id_recipient")
self.assertEqual(recipient_input.get_attribute("value"), "")
# 5. Panel closed (btn no longer .active)
self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
class PostHtmlAperturePageClassTest(FunctionalTest):
"""post.html and my_posts.html should pick up the aperture body class
so they share the navbar/footer scroll-lock pattern w. sky.html etc."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="my-posts",
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
)
self.gamer = User.objects.create(email="buddy@test.io")
self.post = _seed_a_post(self.gamer)
self.create_pre_authenticated_session("buddy@test.io")
def test_post_html_body_carries_billboard_or_post_page_class(self):
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
cls = body.get_attribute("class")
self.assertTrue(
"page-billboard" in cls or "page-billpost" in cls,
f"post.html body class missing aperture marker: {cls!r}",
)
def test_my_posts_html_body_carries_billboard_or_post_page_class(self):
self.browser.get(self.live_server_url + f"/billboard/users/{self.gamer.id}/")
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
cls = body.get_attribute("class")
self.assertTrue(
"page-billboard" in cls or "page-billpost" in cls,
f"my_posts.html body class missing aperture marker: {cls!r}",
)

View File

@@ -118,9 +118,17 @@ describe('Brief.showBanner', () => {
expect(document.querySelector('.note-banner')).toBeNull();
});
// ── T10 ── placement after h2 ─────────────────────────────────────────────
// ── T10 ── placement: anchor preferred over h2 ───────────────────────────
it('T10: banner is inserted immediately after the first h2 in the document', () => {
it('T10a: banner is inserted as nextSibling of #id_brief_banner_anchor when present', () => {
const anchor = document.createElement('div');
anchor.id = 'id_brief_banner_anchor';
fixture.appendChild(anchor);
Brief.showBanner(SAMPLE_BRIEF);
expect(anchor.nextElementSibling.classList.contains('note-banner')).toBeTrue();
});
it('T10b: falls back to inserting after the first h2 when anchor is absent', () => {
Brief.showBanner(SAMPLE_BRIEF);
const h2 = fixture.querySelector('h2');
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();

View File

@@ -34,12 +34,14 @@
}
html:has(body.page-billboard),
html:has(body.page-billscroll) {
html:has(body.page-billscroll),
html:has(body.page-billpost) {
overflow: hidden;
}
body.page-billboard,
body.page-billscroll {
body.page-billscroll,
body.page-billpost {
overflow: hidden;
.container {
@@ -112,6 +114,107 @@ body.page-billscroll {
}
}
// ── Dashpost page (bottom-anchored thread + composer) ─────────────────────
// Mirrors billscroll's flex-column / overflow-y / scroll-buffer pattern,
// with the composer pinned at the bottom (flex-shrink: 0) so the thread
// breathes against the viewport bottom and the input stays in reach.
.post-page {
@extend %billboard-page-base;
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.5rem;
.post-header {
flex-shrink: 0;
.post-title {
margin: 0 0 0.25rem;
font-weight: bold;
}
.post-shared-recipients,
.post-shared-self {
margin: 0;
font-size: 0.85rem;
opacity: 0.75;
}
}
#id_post_table {
list-style: none;
margin: 0;
padding: 0 0.75rem 0 0;
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
// Bottom-anchor: scroll buffer above the lines pushes them down
// until they fill from the bottom; once content exceeds the
// aperture, normal scrolling kicks in.
justify-content: flex-end;
.post-line {
display: grid;
grid-template-columns: minmax(4rem, auto) 1fr minmax(3rem, auto);
align-items: baseline;
gap: 0.5rem;
padding: 0.25rem 0;
.post-line-author {
font-weight: bold;
opacity: 0.75;
white-space: nowrap;
font-size: 0.85rem;
}
.post-line-text {
min-width: 0;
overflow-wrap: anywhere;
}
.post-line-time {
font-size: 0.75rem;
opacity: 0.5;
text-align: right;
white-space: nowrap;
}
// System-authored Lines (adman) get a subtler typographic key
// — the inline `<a class="note-ref">` carries the emphasis.
&.post-line--system .post-line-text {
font-style: italic;
opacity: 0.85;
}
}
.post-line-buffer {
flex-shrink: 0;
height: 0.25rem;
}
}
.post-line-form {
flex-shrink: 0;
margin: 0;
padding-top: 0.25rem;
input.form-control {
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);
}
}
}
}
// ── Billboard applet placement ─────────────────────────────────────────────
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
// Right column (8-wide): Most Recent Scroll spans full height.

View File

@@ -0,0 +1,120 @@
// ── Buddy btn (bottom-left mirror of #id_kit_btn) ─────────────────────────
//
// Lives on post.html only — slide-out recipient field for the share-post
// async flow. Mutually exclusive w. #id_kit_btn (bottom-right): when one is
// active (.active class on btn + html.{kit|buddy}-open class on root), the
// other quickly fades to opacity 0.
//
// Spec: functional_tests/test_buddy_btn.py.
#id_buddy_btn {
position: fixed;
bottom: 0.5rem;
left: 0.5rem;
@media (orientation: landscape) {
left: 1rem;
bottom: 0.5rem;
top: auto;
}
@media (orientation: landscape) and (min-width: 1800px) {
left: 2.5rem; // mirror the doubled 8rem sidebar centring
}
z-index: 318;
font-size: 1.75rem;
cursor: pointer;
color: rgba(var(--secUser), 1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
transition: opacity 0.15s ease;
&.active {
color: rgba(var(--quaUser), 1);
border-color: rgba(var(--quaUser), 1);
}
}
// Slide-out panel: collapsed by default; opens to span ~viewport - 3rem.
#id_buddy_panel {
position: fixed;
bottom: 0.5rem; // align bottom edge w. buddy btn
left: 1.5rem;
right: 1.5rem;
height: 3rem; // match buddy btn height for vertical-centre alignment
z-index: 317;
display: flex;
align-items: center;
gap: 0.5rem;
pointer-events: none;
overflow: hidden;
// Closed state — collapse leftward into the buddy btn
transform-origin: left center;
transform: scaleX(0);
transition: transform 0.2s ease-out, opacity 0.15s ease;
opacity: 0;
@media (orientation: landscape) {
left: calc(4rem + 0.5rem); // clear the navbar sidebar
right: calc(4rem + 0.5rem); // clear the footer sidebar
}
@media (orientation: landscape) and (min-width: 1800px) {
left: calc(8rem + 0.5rem);
right: calc(8rem + 0.5rem);
}
#id_recipient {
flex: 1;
min-width: 0;
height: 100%;
// Generous left padding so the buddy btn glyph (3rem circle pinned
// at left:1.5rem) doesn't visually overlap the placeholder/typed text.
padding: 0 1rem 0 3.5rem;
background-color: rgba(var(--priUser), 1);
color: rgba(var(--secUser), 1);
border: 0.1rem solid rgba(var(--secUser), 0.5);
border-radius: 1.5rem;
font-family: inherit;
&:focus {
outline: none;
border-color: rgba(var(--terUser), 0.75);
box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5);
}
}
.btn.btn-confirm {
flex-shrink: 0;
}
}
// html.buddy-open: slide the panel out, fade the kit btn away.
html.buddy-open {
#id_buddy_panel {
transform: scaleX(1);
opacity: 1;
pointer-events: auto;
}
#id_kit_btn {
opacity: 0;
pointer-events: none;
}
}
// Kit dialog open: hide the buddy btn. We don't add an `html.kit-open`
// class (game-kit.js uses [open] on the dialog + .active on the btn), so
// the mutual-exclusion is driven by `:has()` against the open dialog.
html:has(#id_kit_bag_dialog[open]) #id_buddy_btn {
opacity: 0;
pointer-events: none;
}

View File

@@ -28,6 +28,7 @@
opacity: 0.75;
}
// Default (no square_url) — flat blue chip placeholder.
.note-banner__image {
width: 3rem;
height: 3rem;
@@ -36,6 +37,18 @@
border-radius: 2px;
}
// Note-unlock variant — clickable <a> jumping to /billboard/my-notes/.
// Mirrors the Stargazer dotted-`?` square in my_notes.html so a brief
// visually parses as a note-card preview. Pairs with the
// `note-item__image-box` class added on the JS side so the banner picks
// up the same dashed border + hover.
a.note-banner__image {
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.note-banner__nvm,
.note-banner__fyi {
flex-shrink: 0;

View File

@@ -13,6 +13,7 @@
@import 'note';
@import 'tooltips';
@import 'game-kit';
@import 'buddy';
@import 'wallet-tokens';

View File

@@ -112,9 +112,9 @@
// plutonium (Pluto)
--priPu: 29, 18, 38;
--secPu: 59, 44, 71;
--terPu: 84, 71, 97;
--quaPu: 109, 98, 128;
--quiPu: 169, 155, 194;
--terPu: 89, 76, 102;
--quaPu: 129, 118, 148;
--quiPu: 189, 175, 214;
--sixPu: 235, 211, 217;
/* Chroma Palette */
@@ -189,8 +189,8 @@
--quiVt: 64, 30, 100;
--sixVt: 43, 20, 66;
// fuschia (A-Stone)
--priFs: 158, 61, 150;
--secFs: 133, 47, 126;
--priFs: 178, 71, 170;
--secFs: 138, 52, 131;
--terFs: 107, 31, 101;
--quaFs: 83, 17, 78;
--quiFs: 61, 5, 56;

View File

@@ -118,9 +118,17 @@ describe('Brief.showBanner', () => {
expect(document.querySelector('.note-banner')).toBeNull();
});
// ── T10 ── placement after h2 ─────────────────────────────────────────────
// ── T10 ── placement: anchor preferred over h2 ───────────────────────────
it('T10: banner is inserted immediately after the first h2 in the document', () => {
it('T10a: banner is inserted as nextSibling of #id_brief_banner_anchor when present', () => {
const anchor = document.createElement('div');
anchor.id = 'id_brief_banner_anchor';
fixture.appendChild(anchor);
Brief.showBanner(SAMPLE_BRIEF);
expect(anchor.nextElementSibling.classList.contains('note-banner')).toBeTrue();
});
it('T10b: falls back to inserting after the first h2 when anchor is absent', () => {
Brief.showBanner(SAMPLE_BRIEF);
const h2 = fixture.querySelector('h2');
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();

View File

@@ -7,7 +7,7 @@
<ul>
{% for post in recent_posts %}
<li>
<a href="{{ post.get_absolute_url }}">{{ post.name }}</a>
<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
</li>
{% empty %}
<li>No posts yet.</li>

View File

@@ -0,0 +1,185 @@
{% load static %}
{% load lyric_extras %}
{# ─────────────────────────────────────────────────────────────────────── #}
{# _buddy_panel.html — bottom-left handshake btn + slide-out recipient #}
{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #}
{# right). Included by post.html only. #}
{# #}
{# Spec lives in functional_tests/test_buddy_btn.py — write it red-first. #}
{# Run: #}
{# python src/manage.py test functional_tests.test_buddy_btn #}
{# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_buddy_btn" type="button" aria-label="Share with a buddy">
<i class="fa-solid fa-handshake"></i>
</button>
<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"
name="recipient"
type="email"
placeholder="friend@example.com"
autocomplete="off">
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button>
</div>
<script>
(function () {
'use strict';
var btn = document.getElementById('id_buddy_btn');
var panel = document.getElementById('id_buddy_panel');
var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_buddy_ok');
var html = document.documentElement;
if (!btn || !panel || !input || !ok) return;
function _csrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _open() {
html.classList.add('buddy-open');
btn.classList.add('active');
// small delay before focus so the slide-out animation can play
setTimeout(function () { input.focus(); }, 60);
}
function _close(opts) {
opts = opts || {};
html.classList.remove('buddy-open');
btn.classList.remove('active');
if (opts.clear !== false) input.value = '';
}
btn.addEventListener('click', function () {
if (html.classList.contains('buddy-open')) {
_close();
} else {
_open();
}
});
// Escape closes the panel, clears the field
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close();
});
// Click-outside dismiss — same pattern as game-kit.js
document.addEventListener('click', function (e) {
if (!html.classList.contains('buddy-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
_close();
});
// OK → POST share-post async; reuses the C3.b response handling so the
// recipient chip + brief banner + post-line append all light up.
// Post-May08b layout: #id_post_table is a <ul> of <li class="post-line">
// 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) {
var list = document.getElementById('id_post_table');
if (!list) return;
var li = document.createElement('li');
li.className = 'post-line';
var author = document.createElement('span');
author.className = 'post-line-author';
author.textContent = panel.dataset.sharerName || '';
var body = document.createElement('span');
body.className = 'post-line-text';
body.textContent = text;
var time = document.createElement('time');
time.className = 'post-line-time';
var now = new Date();
time.dateTime = now.toISOString();
time.textContent = now.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
li.appendChild(author);
li.appendChild(body);
li.appendChild(time);
// Insert before the trailing buffer if present
var buffer = list.querySelector('.post-line-buffer');
if (buffer) list.insertBefore(li, buffer);
else list.appendChild(li);
}
// The shared-with header lives outside #id_buddy_panel — it's two <p>
// siblings under .post-header. State transitions:
// 0 → 1+ recipients : "just me, X" turns into
// "shared between {chip}" + "& me, X"
// ≥1 → +1 recipients: append chip + ", " separator before existing
// recipient(s).
function _appendRecipientChip(displayName) {
if (!displayName) return;
var header = document.querySelector('.post-page .post-header');
if (!header) return;
var existingRecipients = header.querySelector('.post-shared-recipients');
var selfLine = header.querySelector('.post-shared-self');
var chip = document.createElement('span');
chip.className = 'post-recipient';
chip.textContent = displayName;
if (existingRecipients) {
existingRecipients.appendChild(document.createTextNode(', '));
existingRecipients.appendChild(chip);
return;
}
// 0 → 1+ transition: build the recipients line, rewrite the self
// line from "just me, …" to "& me, …".
var recipientsLine = document.createElement('p');
recipientsLine.className = 'post-shared-recipients';
recipientsLine.appendChild(document.createTextNode('shared between '));
recipientsLine.appendChild(chip);
if (selfLine) {
header.insertBefore(recipientsLine, selfLine);
// Replace "just me," prefix with "& me,"
selfLine.textContent = selfLine.textContent.replace(/^just me,/, '& me,');
} else {
header.appendChild(recipientsLine);
}
}
ok.addEventListener('click', function () {
var email = input.value.trim();
if (!email) return;
var fd = new FormData();
fd.set('recipient', email);
fetch(panel.dataset.shareUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-CSRFToken': _csrf(),
},
body: fd,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (data.line_text) _appendLine(data.line_text);
if (window.Brief && data.brief) Brief.showBanner(data.brief);
if (data.recipient_display) _appendRecipientChip(data.recipient_display);
_close({ clear: true });
})
.catch(function () {
// swallow — privacy-safe response shape means even an
// unregistered recipient is a 200; only network/5xx land here.
});
});
// Submit-on-Enter inside the input mirrors clicking OK
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ok.click();
}
});
}());
</script>

View File

@@ -8,13 +8,13 @@
<h3>{{ owner|display_name }}'s posts</h3>
<ul>
{% for post in owner.posts.all %}
<li><a href="{{ post.get_absolute_url }}">{{ post.name }}</a></li>
<li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
{% endfor %}
</ul>
<h3>Posts shared with me</h3>
<ul>
{% for post in owner.shared_posts.all %}
<li><a href="{{ post.get_absolute_url }}">{{ post.name }}</a></li>
<li><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></li>
{% endfor %}
</ul>
{% endblock content %}

View File

@@ -5,115 +5,79 @@
{% block header_text %}<span>Dash</span>post{% endblock header_text %}
{% block extra_header %}
{% url "billboard:view_post" post.id as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
{% endblock extra_header %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<small>Post created by: <span id="id_post_owner">{{ post.owner|display_name }}</span></small>
<table id="id_post_table" class="table">
{% for line in post.lines.all %}
<tr><td>{{ forloop.counter }}. {{ line.text }}</td></tr>
{% endfor %}
</table>
</div>
</div>
{# Hidden owner span — preserves the existing FT contract that reads the #}
{# post owner via #id_post_owner. Visible owner attribution moved into the #}
{# self line ("just me, …" / "& me, …") below. #}
<span id="id_post_owner" hidden>{{ post.owner|display_name }}</span>
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="post-page">
<header class="post-header">
<h3 class="post-title">{{ post.title }}</h3>
{% with recipients=post.shared_with.all %}
{% if recipients %}
<p class="post-shared-recipients">shared between {% for r in recipients %}<span class="post-recipient">{{ r|display_name }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-self">&amp; me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p>
{% else %}
<p class="post-shared-self">just me, {{ post.owner|display_name }} the {{ post.owner.active_title_display }}</p>
{% endif %}
{% endwith %}
</header>
<form id="id_share_form" method="POST" action="{% url "billboard:share_post" post.id %}">
{% csrf_token %}
<input
id="id_recipient"
name="recipient"
class="form-control form-control-lg{% if form.errors.recipient %} is-invalid{% endif %}"
placeholder="friend@example.com"
aria-describedby="id_recipient_feedback"
required
/>
{% if form.errors.recipient %}
<div id="id_recipient_feedback" class="invalid-feedback">
{{ form.errors.recipient.0 }}
</div>
{% endif %}
<ul id="id_post_table" class="post-lines">
{% for line in post.lines.all %}
<li class="post-line {% if line.author.username == 'adman' %}post-line--system{% endif %}">
<span class="post-line-author">{{ line.author|display_name }}</span>
<span class="post-line-text">{# adman-authored Lines (note unlock + share invite system prose) carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.text }}{% endif %}</span>
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|date:'g:i A' }}</time>
</li>
{% endfor %}
<li class="post-line-buffer" aria-hidden="true"></li>
</ul>
<button type="submit" class="btn btn-primary">Share</button>
</form>
<small>Post shared with:
<span id="id_post_recipients">
{% for user in post.shared_with.all %}
<span class="post-recipient">{{ user|display_name }}</span>
{% endfor %}
</span>
</small>
</div>
</div>
{# Admin-Post (note-unlock thread) input is read-only: the user can't #}
{# respond, and the placeholder calls that out. View_post hard-rejects #}
{# POSTs to NOTE_UNLOCK posts; the post_save Line signal is the safety #}
{# net for ORM-level / API writes that bypass the view. #}
{% if post.kind == 'note_unlock' %}
<form id="id_post_line_form" class="post-line-form">
<input
id="id_post_line_text"
name="text"
class="form-control"
placeholder="No response needed at this time"
readonly
/>
</form>
{% else %}
<form id="id_post_line_form" method="POST" action="{% url 'billboard:view_post' post.id %}" class="post-line-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. #}
{# 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>
{% endblock content %}
{% block scripts %}
{% include "apps/dashboard/_partials/_scripts.html" %}
<script>
// Async share: intercepts the share form, POSTs w. Accept:application/json,
// then slide-downs the SHARE_INVITE Brief banner under the navbar h2 + appends
// the freshly-recorded Line into #id_post_table. No page reload — the legacy
// alert-success flash is gone.
(function () {
'use strict';
var form = document.getElementById('id_share_form');
var input = document.getElementById('id_recipient');
var table = document.getElementById('id_post_table');
var recipientsBox = document.getElementById('id_post_recipients');
if (!form || !input || !table) return;
function _csrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _appendLine(text) {
var n = table.querySelectorAll('tr').length + 1;
var tr = document.createElement('tr');
var td = document.createElement('td');
td.textContent = n + '. ' + text;
tr.appendChild(td);
table.appendChild(tr);
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(form);
fetch(form.action, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-CSRFToken': _csrf(),
},
body: fd,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (data.line_text) _appendLine(data.line_text);
if (window.Brief && data.brief) Brief.showBanner(data.brief);
if (data.recipient_display && recipientsBox) {
var span = document.createElement('span');
span.className = 'post-recipient';
span.textContent = data.recipient_display;
recipientsBox.appendChild(document.createTextNode(' '));
recipientsBox.appendChild(span);
}
input.value = '';
})
.catch(function () {
// No-op for now — the privacy-safe response shape means
// even an unregistered recipient is a 200 w. brief data;
// a true error path (5xx) silently swallows.
});
});
}());
</script>
{% endblock scripts %}

View File

@@ -3,7 +3,10 @@
<script src="{% static "apps/dashboard/note.js" %}"></script>
<script>
window.onload = () => {
// #id_text — new-post applet on billboard.html;
// #id_post_line_text — post.html bottom-anchored aperture.
initialize("#id_text");
initialize("#id_post_line_text");
bindPaletteSwatches();
bindPaletteWheel();
};

View File

@@ -44,6 +44,11 @@
{% endfor %}
{% endif %}
{# Anchor for Brief.showBanner — banner inserts as nextSibling so #}
{# it lands at the top of page content on every base.html-extending #}
{# page, regardless of where (or whether) <h2> is positioned. #}
<div id="id_brief_banner_anchor"></div>
{% block content %}
{% endblock content %}