bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
Replaces the @mailman invite Line's inline OK/BYE block w. a dedicated per-bud surface. Three new FTs (test_bill_bud_page, test_bill_my_buds_tooltip, test_bill_mailman_invite_post — landed red 2026-05-27 PM) drive: per-bud landing page rendering 4-btn apparatus + shoptalk textarea + invite-cascade glow handoff; my-buds row tooltip portal w. .tt-title/.tt-description/.tt-email/.tt-shoptalk/.tt-milestone slots; mailman Brief surfacing on any authenticated page-load via context processor + base.html JSON-script.
Models: new `BudshipNote(user, bud, shoptalk[CharField max=160], edited_at)` w. unique_together — per-relation personal note about a bud, never visible to the bud. Lazy-created on first shoptalk save so absence of a row reads as 'never edited' (drives .tt-milestone slot presence).
URLs (billboard): `buds/<uuid:bud_id>/` (bud_page), `buds/<uuid:bud_id>/shoptalk` (save_bud_shoptalk), `buds/<uuid:bud_id>/delete` (delete_bud).
Views: bud_page auto-adds the bud on first visit (mirrors share_post implicit-add); resolves `pending_invite` as non-expired PENDING SeaInvite(owner=bud, invitee=request.user) → drives `sea_btn_active` + `sea_first_draw_pending` flags that _burger.html already reads on my_sea + room. my_buds enriches each bud w. `.shoptalk_text` + `.milestone_dt` so the row template can render data-tt-* attrs without an extra template tag.
mail.py: INVITE_TEMPLATE now interpolates `owner_id` into an `<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>` wrapper around the owner's handle. post.html's existing safe-filter branch (gated on author username == 'mailman') passes it through unescaped. Removed the {% if line.sea_invite %} include path — _invite_actions.html left in place for archival.
Templates: new bud.html (header + shoptalk form + apparatus + gear + burger fan + sea_btn nav inline JS); new _bud_gear.html (NVM→my_buds, DEL→guard portal "Delete this bud?" → POST delete_bud); new _bud_tooltip.html (portal w. .tt-* slots); _my_buds_item.html wraps `@handle` in an anchor to bud_page + carries data-tt-* attrs + " the {{ active_title_display }}"; my_buds.html includes the tooltip portal + loads my-buds-tooltip.js.
JS: new my-buds-tooltip.js binds row clicks → .row-locked + populates #id_tooltip_portal from data-tt-* attrs; anchor clicks pass through to navigate; .tt-milestone is removed from DOM (not just emptied) when never-edited so the FT can distinguish absent vs cleared-after-edit.
SCSS: extend landscape gear-btn rule + #id_*_menu rule w. `.bud-page` + `#id_bud_menu` (otherwise gear-btn collided w. bud-btn in landscape on bud.html). Bump active burger sub-btn z-index to 1 so click hit-test picks the active sub-btn during the 0.25s fan arc-out animation (otherwise a later-in-DOM inactive btn obscured the active target during transition).
Cross-page Brief surface: new `mail_brief_payload` context processor injects the user's oldest unread MAIL_ACCEPTANCE Brief into every authenticated response; base.html renders the JSON-script + auto-fires Brief.showBanner. Mark-read still rides view_post's existing GET unread-flip — no new endpoint.
Pre-existing MySeaInvitePostRenderTest (test_sea_invite_views.py) inverted to match the new contract: the .invite-actions sweep is unconditional (PENDING / ACCEPTED / DECLINED all carry prose only); pinned the post-attribution anchor + bud-page href in its place.
1518 ITs green (1475 app ITs + 43 sprint ITs), 23 sprint FTs green (5 my_buds tooltip + 13 bud page + 5 mailman invite post). Jasmine specs from sprint plan deferred — FT coverage of burger-glow / row-lock / portal-populate paths suffices and the textarea blur-POST flow isn't implemented in this sprint (form is server-action only, save-on-blur AJAX is a follow-on).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1140,3 +1140,350 @@ class BillboardAppletMySignTest(TestCase):
|
||||
# Middle court has a suit, so the suit-icon `<i>` 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/<uuid:bud_id>/ + 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 `@<handle>` 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'<textarea[^>]+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'<span class="bud-name"><a[^>]*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)
|
||||
|
||||
Reference in New Issue
Block a user