diff --git a/src/apps/billboard/mail.py b/src/apps/billboard/mail.py index 196f97d..98f25a2 100644 --- a/src/apps/billboard/mail.py +++ b/src/apps/billboard/mail.py @@ -29,12 +29,18 @@ from apps.lyric.models import get_or_create_mailman, resolve_pronouns from apps.lyric.templatetags.lyric_extras import at_handle -# Invite prose shown to the invitee. `{handle}` = the owner's `@username` -# (via at_handle — falls back to truncated email for handle-less owners); +# Invite prose shown to the invitee. `{handle}` is wrapped in an +# `` whose href routes to the owner's per-bud +# landing page — bud landing page sprint 2026-05-27 replaced the in-Line +# OK/BYE form-button block w. this navigational anchor. post.html's +# `safe`-filter branch is gated on `line.author.username == 'mailman'` +# (alongside 'adman'/'taxman') so the anchor renders as HTML. +# # `{poss}` = the owner's possessive pronoun ("their"/"his"/"her"/…), so the # table reads as the owner's. Em dash matches the @taxman "Look!—" house style. INVITE_TEMPLATE = ( - "Listen!—{handle} invites you to {poss} drawing table. " + 'Listen!—{handle}' + " invites you to {poss} drawing table. " "This invite will expire 24h from the time it was extended." ) @@ -67,6 +73,7 @@ def log_sea_invite(sea_invite): post.save(update_fields=["title"]) text = INVITE_TEMPLATE.format( + owner_id=owner.id, handle=at_handle(owner), poss=resolve_pronouns(owner.pronouns)[2], ) diff --git a/src/apps/billboard/migrations/0010_budshipnote.py b/src/apps/billboard/migrations/0010_budshipnote.py new file mode 100644 index 0000000..754c1e3 --- /dev/null +++ b/src/apps/billboard/migrations/0010_budshipnote.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0 on 2026-05-28 15:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billboard', '0009_alter_brief_kind_alter_post_kind'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BudshipNote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shoptalk', models.CharField(default='', max_length=160)), + ('edited_at', models.DateTimeField(auto_now=True)), + ('bud', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_about', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_written', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-edited_at',), + 'unique_together': {('user', 'bud')}, + }, + ), + ] diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py index 25506a4..21f766c 100644 --- a/src/apps/billboard/models.py +++ b/src/apps/billboard/models.py @@ -260,3 +260,33 @@ def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs): return if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited: instance.delete() + + +class BudshipNote(models.Model): + """Per-relation personal note about a bud — bud landing page sprint + 2026-05-27 ([[project-bud-landing-page-sprint]]). One row per + (user, bud) pair: the user's own shoptalk about that bud, NEVER + visible to the bud. Lazy-created on first shoptalk save so the + absence of a row reads as 'never edited' (drives the `.tt-milestone` + slot on the My Buds tooltip portal — present when ≥1 edit, absent + otherwise).""" + + user = models.ForeignKey( + "lyric.User", + on_delete=models.CASCADE, + related_name="budship_notes_written", + ) + bud = models.ForeignKey( + "lyric.User", + on_delete=models.CASCADE, + related_name="budship_notes_about", + ) + shoptalk = models.CharField(max_length=160, default="") + edited_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("user", "bud") + ordering = ("-edited_at",) + + def __str__(self): + return f"BudshipNote({self.user_id} → {self.bud_id})" diff --git a/src/apps/billboard/static/apps/billboard/my-buds-tooltip.js b/src/apps/billboard/static/apps/billboard/my-buds-tooltip.js new file mode 100644 index 0000000..ec90487 --- /dev/null +++ b/src/apps/billboard/static/apps/billboard/my-buds-tooltip.js @@ -0,0 +1,103 @@ +// Row-click → row-lock + tooltip-portal for the My Buds list. +// +// Bud landing page sprint 2026-05-27 ([[project-bud-landing-page-sprint]]). +// Mirrors apps/applets/row-lock.js's lock/unlock state machine, but binds +// to `.bud-entry` rows (which are NOT `.row-3col`) AND populates the +// shared `#id_tooltip_portal` from the row's data-tt-* attrs. +// +// Click target semantics: +// • Click on the inner `` (the `@` anchor) — let it navigate +// to the bud's landing page. NO lock fires. +// • Click anywhere else inside `.bud-entry` — lock the row + open the +// tooltip portal w. its data-tt-* fields populated. +// • Click outside any `.bud-entry` (and outside the portal) — clear. +// +// `.tt-milestone` is REMOVED from the DOM (vs. emptied) when the row's +// `data-tt-milestone` attr is absent so the FT can distinguish "never +// edited" (slot absent) from "cleared after edit" (slot empty). + +(function () { + 'use strict'; + + var _lockedRow = null; + var _portal = null; + var _milestoneTemplate = null; + + function _clearLock() { + if (_lockedRow) { + _lockedRow.classList.remove('row-locked'); + _lockedRow = null; + } + if (_portal) _portal.classList.remove('active'); + } + + function _findSlot(name) { + if (!_portal) return null; + return _portal.querySelector('.tt-' + name); + } + + function _populatePortal(row) { + if (!_portal) return; + var fields = ['title', 'description', 'email', 'shoptalk']; + fields.forEach(function (f) { + var slot = _findSlot(f); + if (slot) slot.textContent = row.dataset['tt' + f.charAt(0).toUpperCase() + f.slice(1)] || ''; + }); + // .tt-milestone — absent when never-edited; present (+ populated) + // when the row carries data-tt-milestone. + var ms = row.dataset.ttMilestone; + var existing = _findSlot('milestone'); + if (ms) { + if (!existing) { + var span = _milestoneTemplate.cloneNode(true); + span.textContent = ms; + _portal.appendChild(span); + } else { + existing.textContent = ms; + } + } else { + if (existing) existing.remove(); + } + } + + function _onClick(e) { + // Anchor click — let navigation proceed; no lock/portal. + if (e.target.closest('.bud-entry .bud-name a')) { + _clearLock(); + return; + } + var row = e.target.closest('.bud-entry'); + if (row) { + if (row === _lockedRow) { + _clearLock(); + } else { + _clearLock(); + row.classList.add('row-locked'); + _lockedRow = row; + _populatePortal(row); + if (_portal) _portal.classList.add('active'); + } + return; + } + // Click inside the portal itself — preserve lock. + if (_portal && _portal.contains(e.target)) return; + _clearLock(); + } + + function _init() { + _portal = document.getElementById('id_tooltip_portal'); + if (_portal) { + // Snapshot the milestone element shape so we can restore it + // after a never-edited row removes it. + var ms = _portal.querySelector('.tt-milestone'); + if (ms) _milestoneTemplate = ms.cloneNode(false); + } + document.addEventListener('click', _onClick); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', _init); + } else { + _init(); + } +}()); diff --git a/src/apps/billboard/tests/integrated/test_mail.py b/src/apps/billboard/tests/integrated/test_mail.py index b50a025..f3e2321 100644 --- a/src/apps/billboard/tests/integrated/test_mail.py +++ b/src/apps/billboard/tests/integrated/test_mail.py @@ -114,6 +114,19 @@ class LogSeaInviteTest(TestCase): def test_template_uses_listen_hook(self): self.assertTrue(INVITE_TEMPLATE.startswith("Listen!")) + def test_line_wraps_owner_handle_in_post_attribution_anchor(self): + """Bud landing page sprint 2026-05-27 — the inline OK/BYE block + migrates onto bud.html; the @mailman Line now carries an + `` around the owner's handle whose + href routes to the owner's per-bud landing page.""" + _, line, _ = log_sea_invite(self.invite) + self.assertIn('class="post-attribution"', line.text) + self.assertIn( + f'href="/billboard/buds/{self.owner.id}/"', line.text, + ) + # The anchor wraps ONLY the at_handle (not the surrounding prose) + self.assertIn(">@discoman", line.text) + class MailAcceptanceKindTest(TestCase): """The new MAIL_ACCEPTANCE kind is registered on both Post + Brief.""" diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 02bec10..0456ca5 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -1140,3 +1140,350 @@ class BillboardAppletMySignTest(TestCase): # Middle court has a suit, so the suit-icon `` is present + carries # the canonical FA class for the suit (fa-wand-sparkles for BRANDS etc). self.assertTrue(any(cls.startswith("fa-") for cls in (icon.get("class") or "").split())) + + +# ── Per-bud Landing Page ───────────────────────────────────────────────── +# /billboard/buds// + the my_buds row enrichment that surfaces +# the new tooltip-portal data — bud landing page sprint 2026-05-27 (see +# [[project-bud-landing-page-sprint]]). Replaces the @mailman invite Line's +# inline OK/BYE block w. a dedicated page; the My Buds list rows now wrap +# the `@` in an anchor to the bud's page + carry data-tt-* attrs +# the JS portal reads on row-lock click. + + +class BudPageRenderTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="me@buds.io", username="me") + self.alice = User.objects.create(email="alice@buds.io", username="alice") + self.user.buds.add(self.alice) + self.client.force_login(self.user) + + def test_requires_login(self): + self.client.logout() + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertEqual(response.status_code, 302) + + def test_returns_200(self): + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_uses_bud_template(self): + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertTemplateUsed(response, "apps/billboard/bud.html") + + def test_passes_bud_in_context(self): + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertEqual(response.context["bud"], self.alice) + + def test_passes_empty_shoptalk_when_no_note(self): + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertEqual(response.context["shoptalk_text"], "") + self.assertIsNone(response.context["milestone_dt"]) + + def test_header_renders_at_handle_the_title_and_email(self): + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + body = response.content.decode() + self.assertIn("@alice", body) + self.assertIn("the Earthman", body) + self.assertIn("alice@buds.io", body) + + def test_shoptalk_textarea_carries_160_char_maxlength(self): + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + body = response.content.decode() + self.assertRegex( + body, r']+id="id_shoptalk"[^>]*maxlength="160"', + ) + + def test_existing_shoptalk_renders_in_textarea(self): + from apps.billboard.models import BudshipNote + BudshipNote.objects.create( + user=self.user, bud=self.alice, shoptalk="loves chess", + ) + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertEqual(response.context["shoptalk_text"], "loves chess") + self.assertIsNotNone(response.context["milestone_dt"]) + self.assertContains(response, "loves chess") + + +class BudPageAutoAddOnFirstVisitTest(TestCase): + """Visiting bud.html for a non-bud auto-adds them to the user's buds — + mirrors share_post's implicit-add posture so the @mailman post- + attribution anchor lands the inviter on the user's buds graph.""" + + def setUp(self): + self.user = User.objects.create(email="me@auto.io", username="me") + self.alice = User.objects.create(email="alice@auto.io", username="alice") + # alice is NOT in user.buds — auto-add is the contract + self.client.force_login(self.user) + + def test_visit_adds_bud_to_m2m(self): + self.assertNotIn(self.alice, list(self.user.buds.all())) + self.client.get(reverse("billboard:bud_page", args=[self.alice.id])) + self.assertIn(self.alice, list(self.user.buds.all())) + + def test_self_visit_does_not_self_add(self): + # Pathological case: navigating to your own bud page must not seed + # the user as their own bud (M2M is asymmetric self-FK). + self.client.get(reverse("billboard:bud_page", args=[self.user.id])) + self.assertNotIn(self.user, list(self.user.buds.all())) + + def test_already_bud_visit_is_idempotent(self): + self.user.buds.add(self.alice) + self.client.get(reverse("billboard:bud_page", args=[self.alice.id])) + # M2M dedup'd; still one row + self.assertEqual(self.user.buds.filter(pk=self.alice.pk).count(), 1) + + +class BudPagePendingInviteCascadeTest(TestCase): + """`sea_btn_active` + `sea_first_draw_pending` fire iff a non-expired + PENDING SeaInvite exists from this bud (owner) to the viewer (invitee). + Reuses the same template flags `_burger.html` already reads on my_sea + + room — no new template plumbing on bud.html.""" + + def setUp(self): + from apps.gameboard.models import SeaInvite + self.SeaInvite = SeaInvite + self.user = User.objects.create(email="me@inv.io", username="me") + self.alice = User.objects.create(email="alice@inv.io", username="alice") + self.user.buds.add(self.alice) + self.client.force_login(self.user) + + def test_no_invite_no_cascade(self): + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertIsNone(response.context["pending_invite"]) + self.assertFalse(response.context["sea_btn_active"]) + self.assertFalse(response.context["sea_first_draw_pending"]) + + def test_pending_invite_lights_cascade(self): + self.SeaInvite.objects.create( + owner=self.alice, + invitee=self.user, + invitee_email=self.user.email, + status=self.SeaInvite.PENDING, + ) + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertIsNotNone(response.context["pending_invite"]) + self.assertTrue(response.context["sea_btn_active"]) + self.assertTrue(response.context["sea_first_draw_pending"]) + + def test_accepted_invite_does_not_cascade(self): + self.SeaInvite.objects.create( + owner=self.alice, + invitee=self.user, + invitee_email=self.user.email, + status=self.SeaInvite.ACCEPTED, + ) + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertIsNone(response.context["pending_invite"]) + self.assertFalse(response.context["sea_btn_active"]) + + def test_expired_pending_invite_does_not_cascade(self): + inv = self.SeaInvite.objects.create( + owner=self.alice, + invitee=self.user, + invitee_email=self.user.email, + status=self.SeaInvite.PENDING, + ) + self.SeaInvite.objects.filter(pk=inv.pk).update( + created_at=timezone.now() - timezone.timedelta(hours=48), + ) + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertIsNone(response.context["pending_invite"]) + self.assertFalse(response.context["sea_btn_active"]) + + def test_invite_for_other_invitee_ignored(self): + # Pending invite from alice → some other user is irrelevant to ME. + other = User.objects.create(email="other@inv.io", username="other") + self.SeaInvite.objects.create( + owner=self.alice, + invitee=other, + invitee_email=other.email, + status=self.SeaInvite.PENDING, + ) + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertIsNone(response.context["pending_invite"]) + + +class SaveBudShoptalkViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="me@sav.io", username="me") + self.alice = User.objects.create(email="alice@sav.io", username="alice") + self.user.buds.add(self.alice) + self.client.force_login(self.user) + + def test_post_creates_budship_note(self): + from apps.billboard.models import BudshipNote + self.client.post( + reverse("billboard:save_bud_shoptalk", args=[self.alice.id]), + {"shoptalk": "first thoughts"}, + ) + bn = BudshipNote.objects.get(user=self.user, bud=self.alice) + self.assertEqual(bn.shoptalk, "first thoughts") + + def test_post_updates_existing_budship_note(self): + from apps.billboard.models import BudshipNote + BudshipNote.objects.create(user=self.user, bud=self.alice, shoptalk="old") + self.client.post( + reverse("billboard:save_bud_shoptalk", args=[self.alice.id]), + {"shoptalk": "new"}, + ) + bn = BudshipNote.objects.get(user=self.user, bud=self.alice) + self.assertEqual(bn.shoptalk, "new") + + def test_post_caps_at_160_chars(self): + from apps.billboard.models import BudshipNote + self.client.post( + reverse("billboard:save_bud_shoptalk", args=[self.alice.id]), + {"shoptalk": "a" * 300}, + ) + bn = BudshipNote.objects.get(user=self.user, bud=self.alice) + self.assertLessEqual(len(bn.shoptalk), 160) + + def test_get_returns_405(self): + response = self.client.get( + reverse("billboard:save_bud_shoptalk", args=[self.alice.id]) + ) + self.assertEqual(response.status_code, 405) + + def test_requires_login(self): + self.client.logout() + response = self.client.post( + reverse("billboard:save_bud_shoptalk", args=[self.alice.id]), + {"shoptalk": "anon"}, + ) + self.assertEqual(response.status_code, 302) + + +class DeleteBudViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="me@del.io", username="me") + self.alice = User.objects.create(email="alice@del.io", username="alice") + self.user.buds.add(self.alice) + self.client.force_login(self.user) + + def test_post_removes_bud_from_m2m(self): + self.client.post( + reverse("billboard:delete_bud", args=[self.alice.id]) + ) + self.assertNotIn(self.alice, list(self.user.buds.all())) + + def test_post_redirects_to_my_buds(self): + response = self.client.post( + reverse("billboard:delete_bud", args=[self.alice.id]) + ) + self.assertRedirects(response, reverse("billboard:my_buds")) + + def test_get_does_not_remove(self): + self.client.get(reverse("billboard:delete_bud", args=[self.alice.id])) + self.assertIn(self.alice, list(self.user.buds.all())) + + +class MyBudsRowEnrichmentTest(TestCase): + """The my_buds page row now carries the data-tt-* attrs the tooltip + portal reads on row-lock click, plus an anchor wrapping the handle + that routes to the bud's landing page.""" + + def setUp(self): + self.user = User.objects.create(email="me@row.io", username="me") + self.alice = User.objects.create(email="alice@row.io", username="alice") + self.user.buds.add(self.alice) + self.client.force_login(self.user) + + def test_row_carries_data_bud_id(self): + response = self.client.get(reverse("billboard:my_buds")) + self.assertContains(response, f'data-bud-id="{self.alice.id}"') + + def test_row_carries_tt_title_description_email_attrs(self): + response = self.client.get(reverse("billboard:my_buds")) + self.assertContains(response, 'data-tt-title="@alice"') + self.assertContains(response, 'data-tt-description="Earthman"') + self.assertContains(response, 'data-tt-email="alice@row.io"') + + def test_row_renders_at_handle_the_title(self): + response = self.client.get(reverse("billboard:my_buds")) + body = response.content.decode() + self.assertIn("@alice", body) + self.assertIn("the Earthman", body) + + def test_username_wrapped_in_anchor_to_bud_page(self): + response = self.client.get(reverse("billboard:my_buds")) + body = response.content.decode() + bud_page_url = reverse("billboard:bud_page", args=[self.alice.id]) + self.assertRegex( + body, + rf']*href="{bud_page_url}"', + ) + + def test_row_carries_shoptalk_when_set(self): + from apps.billboard.models import BudshipNote + BudshipNote.objects.create( + user=self.user, bud=self.alice, shoptalk="dragonkin", + ) + response = self.client.get(reverse("billboard:my_buds")) + self.assertContains(response, 'data-tt-shoptalk="dragonkin"') + self.assertContains(response, "data-tt-milestone=") + + def test_row_carries_empty_shoptalk_attr_when_never_edited(self): + response = self.client.get(reverse("billboard:my_buds")) + self.assertContains(response, 'data-tt-shoptalk=""') + + def test_row_omits_milestone_when_no_note(self): + response = self.client.get(reverse("billboard:my_buds")) + body = response.content.decode() + self.assertNotIn("data-tt-milestone=", body) + + +class BudshipNoteModelTest(TestCase): + """`BudshipNote(user, bud, shoptalk, edited_at)` — per-relation note.""" + + def setUp(self): + self.user = User.objects.create(email="me@m.io", username="me") + self.bud = User.objects.create(email="b@m.io", username="b") + + def test_unique_per_user_bud_pair(self): + from django.db import IntegrityError + from apps.billboard.models import BudshipNote + BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="x") + with self.assertRaises(IntegrityError): + BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="y") + + def test_edited_at_updates_on_save(self): + from apps.billboard.models import BudshipNote + bn = BudshipNote.objects.create( + user=self.user, bud=self.bud, shoptalk="first", + ) + first_ts = bn.edited_at + bn.shoptalk = "second" + bn.save() + self.assertGreaterEqual(bn.edited_at, first_ts) + + def test_shoptalk_max_length_160(self): + from apps.billboard.models import BudshipNote + f = BudshipNote._meta.get_field("shoptalk") + self.assertEqual(f.max_length, 160) diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 47be175..1663d7a 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -23,6 +23,9 @@ urlpatterns = [ path("my-buds/", views.my_buds, name="my_buds"), path("buds/add", views.add_bud, name="add_bud"), path("buds/search", views.search_buds, name="search_buds"), + path("buds//", views.bud_page, name="bud_page"), + path("buds//shoptalk", views.save_bud_shoptalk, name="save_bud_shoptalk"), + path("buds//delete", views.delete_bud, name="delete_bud"), path("my-sign/", views.my_sign, name="my_sign"), path("my-sign/save", views.save_sign, name="save_sign"), path("my-sign/clear", views.clear_sign, name="clear_sign"), diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 5e00c26..bca67cb 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -10,7 +10,7 @@ from django.shortcuts import redirect, render from apps.applets.utils import applet_context, apply_applet_toggle from apps.billboard.forms import ExistingPostLineForm, LineForm -from apps.billboard.models import Brief, Line, Post +from apps.billboard.models import Brief, BudshipNote, Line, Post from apps.dashboard.views import _PALETTE_DEFS from apps.drama.models import GameEvent, Note, ScrollPosition from apps.epic.models import Room @@ -567,12 +567,96 @@ def share_post(request, post_id): @login_required(login_url="/") def my_buds(request): + """My Buds page — enriched per-row w. shoptalk + milestone for the + tooltip portal (bud landing page sprint 2026-05-27). Attaches + `.shoptalk_text` + `.milestone_dt` to each bud User so the row + template can render data-tt-* attrs without an extra template tag.""" + notes_by_bud = { + bn.bud_id: bn + for bn in BudshipNote.objects.filter(user=request.user) + } + buds = list(request.user.buds.all().select_related("active_title")) + for bud in buds: + bn = notes_by_bud.get(bud.id) + bud.shoptalk_text = bn.shoptalk if bn else "" + bud.milestone_dt = bn.edited_at if bn else None return render(request, "apps/billboard/my_buds.html", { - "buds": request.user.buds.all(), + "buds": buds, "page_class": "page-billbuds", }) +# ── Per-bud landing page ─────────────────────────────────────────────────── +# /billboard/buds// + shoptalk save + bud delete — see +# [[project-bud-landing-page-sprint]]. Replaces the @mailman invite Line's +# inline OK/BYE block w. a dedicated surface; bud.html is also the click +# target of the My Buds row's `@` anchor. + +@login_required(login_url="/") +def bud_page(request, bud_id): + """Render the per-bud landing page. Auto-adds the bud on first visit + (mirrors share_post's implicit-add posture) so following the @mailman + post-attribution anchor from an invite Brief grows the buds graph + without an explicit add step. Self-visits are no-op for the auto-add + branch — users don't accumulate themselves as a bud. + + Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses + the same template variables `_burger.html` already reads on my_sea + + room — server-side conditional renders `glow-handoff` on the burger + + `.active` on the sea sub-btn. The flags fire iff a non-expired + PENDING SeaInvite exists from this bud to the viewer.""" + from django.shortcuts import get_object_or_404 + from apps.gameboard.models import SeaInvite + bud = get_object_or_404(User, id=bud_id) + if bud != request.user and not request.user.buds.filter(id=bud.id).exists(): + request.user.buds.add(bud) + bn = BudshipNote.objects.filter(user=request.user, bud=bud).first() + pending = ( + SeaInvite.objects + .filter(owner=bud, invitee=request.user, status=SeaInvite.PENDING) + .order_by("-created_at") + .first() + ) + if pending is not None and pending.is_expired: + pending = None + return render(request, "apps/billboard/bud.html", { + "bud": bud, + "shoptalk_text": bn.shoptalk if bn else "", + "milestone_dt": bn.edited_at if bn else None, + "pending_invite": pending, + "sea_btn_active": pending is not None, + "sea_first_draw_pending": pending is not None, + "page_class": "page-billbud", + }) + + +@login_required(login_url="/") +def save_bud_shoptalk(request, bud_id): + """POST-only — upsert a BudshipNote w. up to 160 chars of shoptalk.""" + from django.http import HttpResponseNotAllowed + from django.shortcuts import get_object_or_404 + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + bud = get_object_or_404(User, id=bud_id) + text = (request.POST.get("shoptalk") or "")[:160] + BudshipNote.objects.update_or_create( + user=request.user, bud=bud, + defaults={"shoptalk": text}, + ) + return JsonResponse({"ok": True, "shoptalk": text}) + + +@login_required(login_url="/") +def delete_bud(request, bud_id): + """POST-only — remove the bud from the user's M2M; redirect to my_buds. + GET is a silent no-op redirect (no membership change).""" + from django.shortcuts import get_object_or_404 + if request.method == "POST": + bud = get_object_or_404(User, id=bud_id) + request.user.buds.remove(bud) + return redirect("billboard:my_buds") + + def _resolve_recipient(raw): """Resolve a free-form recipient (email OR username) to a User, or None. Email match takes precedence — if the input contains '@' we don't even diff --git a/src/apps/gameboard/tests/integrated/test_sea_invite_views.py b/src/apps/gameboard/tests/integrated/test_sea_invite_views.py index 444b77e..19811c8 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_invite_views.py +++ b/src/apps/gameboard/tests/integrated/test_sea_invite_views.py @@ -138,8 +138,11 @@ class MySeaInviteAcceptDeclineTest(TestCase): class MySeaInvitePostRenderTest(TestCase): - """post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING + - a status badge otherwise, all driven by `line.sea_invite.status`.""" + """post.html — the @mailman invite Line carries a post-attribution + anchor around the owner's handle whose href routes to the owner's + per-bud landing page. Bud landing page sprint 2026-05-27 ([[project- + bud-landing-page-sprint]]) migrated the prior inline OK/BYE/Accepted + block onto bud.html; this class pins the new prose contract.""" def setUp(self): self.owner = User.objects.create(email="owner@test.io", username="discoman") @@ -154,26 +157,31 @@ class MySeaInvitePostRenderTest(TestCase): self.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id]) self.decline_url = reverse("my_sea_invite_decline", args=[self.invite.id]) - def test_pending_invite_renders_ok_bye(self): + def test_pending_invite_renders_post_attribution_anchor_not_buttons(self): content = self.client.get(self.post_url).content.decode() - self.assertIn("invite-ok-btn", content) - self.assertIn("invite-bye-btn", content) - self.assertIn(self.accept_url, content) - self.assertIn(self.decline_url, content) + # OK/BYE buttons + accept/decline form actions migrated onto bud.html. + self.assertNotIn("invite-ok-btn", content) + self.assertNotIn("invite-bye-btn", content) + self.assertNotIn(self.accept_url, content) + self.assertNotIn(self.decline_url, content) + # Anchor wraps the owner's handle, routing to their bud landing page. + self.assertIn('class="post-attribution"', content) + self.assertIn(f"/billboard/buds/{self.owner.id}/", content) - def test_accepted_invite_renders_badge_not_buttons(self): + def test_accepted_invite_renders_no_badge(self): self.invite.status = SeaInvite.ACCEPTED self.invite.accepted_at = timezone.now() self.invite.save() content = self.client.get(self.post_url).content.decode() - self.assertIn("Accepted", content) + # No "Accepted " badge — the sweep is unconditional. + self.assertNotIn("invite-badge--accepted", content) self.assertNotIn(self.accept_url, content) - def test_declined_invite_renders_declined_badge(self): + def test_declined_invite_renders_no_badge(self): self.invite.status = SeaInvite.DECLINED self.invite.save() content = self.client.get(self.post_url).content.decode() - self.assertIn("Declined", content) + self.assertNotIn("invite-badge--declined", content) self.assertNotIn(self.accept_url, content) def test_mailman_line_renders_as_system_with_handles(self): diff --git a/src/core/context_processors.py b/src/core/context_processors.py index 9def416..68f3c3d 100644 --- a/src/core/context_processors.py +++ b/src/core/context_processors.py @@ -4,6 +4,35 @@ def user_palette(request): return {"user_palette": "palette-default"} +def mail_brief_payload(request): + """Inject the user's oldest unread @mailman "Acceptances & rejections" + Brief into every authenticated response context — bud landing page + sprint 2026-05-27 ([[project-bud-landing-page-sprint]]). The base + template renders the payload as a JSON script + auto-fires + Brief.showBanner so the slide-down notification surfaces on the next + page load regardless of where the invitee lands. + + Marked read by `view_post`'s existing unread-flip on GET of the + underlying MAIL_ACCEPTANCE Post — same mark-read contract every other + Brief kind already uses.""" + if not request.user.is_authenticated: + return {} + from apps.billboard.models import Brief + brief = ( + Brief.objects + .filter( + owner=request.user, + kind=Brief.KIND_MAIL_ACCEPTANCE, + is_unread=True, + ) + .order_by("created_at") + .first() + ) + if brief is None: + return {} + return {"mail_brief_payload": brief.to_banner_dict()} + + def navbar_context(request): if not request.user.is_authenticated: return {} diff --git a/src/core/settings.py b/src/core/settings.py index 4cf3e16..d837853 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -105,6 +105,7 @@ TEMPLATES = [ 'django.contrib.messages.context_processors.messages', 'core.context_processors.user_palette', 'core.context_processors.navbar_context', + 'core.context_processors.mail_brief_payload', ], }, }, diff --git a/src/functional_tests/test_bill_bud_page.py b/src/functional_tests/test_bill_bud_page.py new file mode 100644 index 0000000..be12e04 --- /dev/null +++ b/src/functional_tests/test_bill_bud_page.py @@ -0,0 +1,276 @@ +"""FT for the per-bud landing page at /billboard/buds//. + +bud.html is the destination of the @mailman-invite cascade (see +test_bill_mailman_invite_post.py) AND the click target of the +`@` anchor on /billboard/my-buds/ (see test_bill_my_buds_tooltip.py). +It's the singular counterpart of my_buds.html in the same way post.html +is to my_posts.html. + +Spec recap: +- Page renders all the data the tooltip surfaces (title, description, email, + shoptalk, milestone) PLUS a 160-char ` + + + {# 4-btn apparatus mirrors my_sea.html: kit (from base.html) + bud + #} + {# gear + burger. Apparatus loads bud-btn.js (defines window. #} + {# bindBudBtn — does NOT auto-bind); the inline script below opens #} + {# the panel + stubs OK to disabled per the sprint plan. #} + {% include "apps/billboard/_partials/_bud_apparatus.html" with aria_label="Bud" include_suggestions=False %} + + + {% include "apps/billboard/_partials/_bud_gear.html" %} + + {# Burger fan — sea_btn_active + sea_first_draw_pending drive the #} + {# server-side .active / .glow-handoff classes (same vars my_sea + #} + {# room read). burger-btn.js auto-binds on DOMContentLoaded. #} + {% include "apps/gameboard/_partials/_burger.html" %} + + + +{% if pending_invite %} + +{% endif %} +{% endblock content %} diff --git a/src/templates/apps/billboard/my_buds.html b/src/templates/apps/billboard/my_buds.html index e498b45..7903f0a 100644 --- a/src/templates/apps/billboard/my_buds.html +++ b/src/templates/apps/billboard/my_buds.html @@ -11,6 +11,10 @@ {% include "apps/applets/_partials/_applet-list-shell.html" with shell_title="My Buds" shell_list_id="id_buds_list" shell_items=buds shell_item_template="apps/billboard/_partials/_my_buds_item.html" shell_empty="No buds yet." %} +{# Tooltip portal — populated from .bud-entry data-tt-* attrs on row #} +{# click. See my-buds-tooltip.js for the bind logic. #} +{% include "apps/billboard/_partials/_bud_tooltip.html" %} + {# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #} {% include "apps/billboard/_partials/_bud_add_panel.html" %} {% endblock content %} @@ -19,4 +23,5 @@ {# Brief module — needed by _bud_add_panel's OK handler so the #} {# duplicate-add error banner can render via Brief.showDuplicateBanner.#} + {% endblock scripts %} diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html index 949cbd5..d12c5ba 100644 --- a/src/templates/apps/billboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -41,12 +41,14 @@ {% for line in post.lines.all %}
  • {{ line.author|at_handle }} - {# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `` anchor that needs to render as HTML. User-typed Lines stay escaped. `display_text` strips the `[] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %} + {# adman / taxman / mailman-authored Lines (note unlock, share invite, tax ledger, invite cascade) may carry HTML anchors (note-ref / post-attribution). User-typed Lines stay escaped. `display_text` strips the `[] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %} - {# @mailman invite Lines carry an OK/BYE action block driven by #} - {# the linked SeaInvite's status (my-sea invite flow). Non-invite #} - {# system + user Lines have no `sea_invite`, so this is skipped. #} - {% if line.sea_invite %}{% include "apps/billboard/_partials/_invite_actions.html" %}{% endif %} + {# Pre-sprint @mailman invite Lines carried an in-line OK/BYE #} + {# block via _invite_actions.html. Bud landing page sprint #} + {# 2026-05-27 migrates that interaction onto bud.html — the #} + {# Line's prose now embeds a post-attribution anchor (see #} + {# apps.billboard.mail.INVITE_TEMPLATE) that routes to the #} + {# owner's bud page where accept/decline/spectator live. #}
  • {% endfor %} diff --git a/src/templates/core/base.html b/src/templates/core/base.html index dbc98ee..bdb7517 100644 --- a/src/templates/core/base.html +++ b/src/templates/core/base.html @@ -73,6 +73,24 @@ {% block scripts %} {% endblock scripts %} + {# Cross-page @mailman Brief surface — bud landing page sprint #} + {# 2026-05-27. Server-side context processor injects mail_ #} + {# brief_payload on every authenticated response when the user #} + {# has an unread MAIL_ACCEPTANCE Brief; banner auto-fires here #} + {# (note.js is included by pages that need Brief earlier; this #} + {# loads it if no page-block scripts pulled it in already). #} + {% if mail_brief_payload %} + {{ mail_brief_payload|json_script:"id_mail_brief_payload" }} + + + {% endif %}