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:
@@ -29,12 +29,18 @@ from apps.lyric.models import get_or_create_mailman, resolve_pronouns
|
|||||||
from apps.lyric.templatetags.lyric_extras import at_handle
|
from apps.lyric.templatetags.lyric_extras import at_handle
|
||||||
|
|
||||||
|
|
||||||
# Invite prose shown to the invitee. `{handle}` = the owner's `@username`
|
# Invite prose shown to the invitee. `{handle}` is wrapped in an
|
||||||
# (via at_handle — falls back to truncated email for handle-less owners);
|
# `<a class="post-attribution">` 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
|
# `{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.
|
# table reads as the owner's. Em dash matches the @taxman "Look!—" house style.
|
||||||
INVITE_TEMPLATE = (
|
INVITE_TEMPLATE = (
|
||||||
"Listen!—{handle} invites you to {poss} drawing table. "
|
'Listen!—<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>'
|
||||||
|
" invites you to {poss} drawing table. "
|
||||||
"This invite will expire 24h from the time it was extended."
|
"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"])
|
post.save(update_fields=["title"])
|
||||||
|
|
||||||
text = INVITE_TEMPLATE.format(
|
text = INVITE_TEMPLATE.format(
|
||||||
|
owner_id=owner.id,
|
||||||
handle=at_handle(owner),
|
handle=at_handle(owner),
|
||||||
poss=resolve_pronouns(owner.pronouns)[2],
|
poss=resolve_pronouns(owner.pronouns)[2],
|
||||||
)
|
)
|
||||||
|
|||||||
30
src/apps/billboard/migrations/0010_budshipnote.py
Normal file
30
src/apps/billboard/migrations/0010_budshipnote.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -260,3 +260,33 @@ def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
|
|||||||
return
|
return
|
||||||
if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited:
|
if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited:
|
||||||
instance.delete()
|
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})"
|
||||||
|
|||||||
103
src/apps/billboard/static/apps/billboard/my-buds-tooltip.js
Normal file
103
src/apps/billboard/static/apps/billboard/my-buds-tooltip.js
Normal file
@@ -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 `<a>` (the `@<handle>` 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();
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -114,6 +114,19 @@ class LogSeaInviteTest(TestCase):
|
|||||||
def test_template_uses_listen_hook(self):
|
def test_template_uses_listen_hook(self):
|
||||||
self.assertTrue(INVITE_TEMPLATE.startswith("Listen!"))
|
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
|
||||||
|
`<a class="post-attribution">` 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</a>", line.text)
|
||||||
|
|
||||||
|
|
||||||
class MailAcceptanceKindTest(TestCase):
|
class MailAcceptanceKindTest(TestCase):
|
||||||
"""The new MAIL_ACCEPTANCE kind is registered on both Post + Brief."""
|
"""The new MAIL_ACCEPTANCE kind is registered on both Post + Brief."""
|
||||||
|
|||||||
@@ -1140,3 +1140,350 @@ class BillboardAppletMySignTest(TestCase):
|
|||||||
# Middle court has a suit, so the suit-icon `<i>` is present + carries
|
# 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).
|
# 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()))
|
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)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ urlpatterns = [
|
|||||||
path("my-buds/", views.my_buds, name="my_buds"),
|
path("my-buds/", views.my_buds, name="my_buds"),
|
||||||
path("buds/add", views.add_bud, name="add_bud"),
|
path("buds/add", views.add_bud, name="add_bud"),
|
||||||
path("buds/search", views.search_buds, name="search_buds"),
|
path("buds/search", views.search_buds, name="search_buds"),
|
||||||
|
path("buds/<uuid:bud_id>/", views.bud_page, name="bud_page"),
|
||||||
|
path("buds/<uuid:bud_id>/shoptalk", views.save_bud_shoptalk, name="save_bud_shoptalk"),
|
||||||
|
path("buds/<uuid:bud_id>/delete", views.delete_bud, name="delete_bud"),
|
||||||
path("my-sign/", views.my_sign, name="my_sign"),
|
path("my-sign/", views.my_sign, name="my_sign"),
|
||||||
path("my-sign/save", views.save_sign, name="save_sign"),
|
path("my-sign/save", views.save_sign, name="save_sign"),
|
||||||
path("my-sign/clear", views.clear_sign, name="clear_sign"),
|
path("my-sign/clear", views.clear_sign, name="clear_sign"),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.shortcuts import redirect, render
|
|||||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||||
|
|
||||||
from apps.billboard.forms import ExistingPostLineForm, LineForm
|
from apps.billboard.forms import ExistingPostLineForm, LineForm
|
||||||
from apps.billboard.models import Brief, Line, Post
|
from apps.billboard.models import Brief, BudshipNote, Line, Post
|
||||||
from apps.dashboard.views import _PALETTE_DEFS
|
from apps.dashboard.views import _PALETTE_DEFS
|
||||||
from apps.drama.models import GameEvent, Note, ScrollPosition
|
from apps.drama.models import GameEvent, Note, ScrollPosition
|
||||||
from apps.epic.models import Room
|
from apps.epic.models import Room
|
||||||
@@ -567,12 +567,96 @@ def share_post(request, post_id):
|
|||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def my_buds(request):
|
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", {
|
return render(request, "apps/billboard/my_buds.html", {
|
||||||
"buds": request.user.buds.all(),
|
"buds": buds,
|
||||||
"page_class": "page-billbuds",
|
"page_class": "page-billbuds",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-bud landing page ───────────────────────────────────────────────────
|
||||||
|
# /billboard/buds/<bud_id>/ + 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 `@<handle>` 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):
|
def _resolve_recipient(raw):
|
||||||
"""Resolve a free-form recipient (email OR username) to a User, or None.
|
"""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
|
Email match takes precedence — if the input contains '@' we don't even
|
||||||
|
|||||||
@@ -138,8 +138,11 @@ class MySeaInviteAcceptDeclineTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class MySeaInvitePostRenderTest(TestCase):
|
class MySeaInvitePostRenderTest(TestCase):
|
||||||
"""post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING +
|
"""post.html — the @mailman invite Line carries a post-attribution
|
||||||
a status badge otherwise, all driven by `line.sea_invite.status`."""
|
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):
|
def setUp(self):
|
||||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
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.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||||
self.decline_url = reverse("my_sea_invite_decline", 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()
|
content = self.client.get(self.post_url).content.decode()
|
||||||
self.assertIn("invite-ok-btn", content)
|
# OK/BYE buttons + accept/decline form actions migrated onto bud.html.
|
||||||
self.assertIn("invite-bye-btn", content)
|
self.assertNotIn("invite-ok-btn", content)
|
||||||
self.assertIn(self.accept_url, content)
|
self.assertNotIn("invite-bye-btn", content)
|
||||||
self.assertIn(self.decline_url, 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.status = SeaInvite.ACCEPTED
|
||||||
self.invite.accepted_at = timezone.now()
|
self.invite.accepted_at = timezone.now()
|
||||||
self.invite.save()
|
self.invite.save()
|
||||||
content = self.client.get(self.post_url).content.decode()
|
content = self.client.get(self.post_url).content.decode()
|
||||||
self.assertIn("Accepted", content)
|
# No "Accepted <date>" badge — the sweep is unconditional.
|
||||||
|
self.assertNotIn("invite-badge--accepted", content)
|
||||||
self.assertNotIn(self.accept_url, 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.status = SeaInvite.DECLINED
|
||||||
self.invite.save()
|
self.invite.save()
|
||||||
content = self.client.get(self.post_url).content.decode()
|
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)
|
self.assertNotIn(self.accept_url, content)
|
||||||
|
|
||||||
def test_mailman_line_renders_as_system_with_handles(self):
|
def test_mailman_line_renders_as_system_with_handles(self):
|
||||||
|
|||||||
@@ -4,6 +4,35 @@ def user_palette(request):
|
|||||||
return {"user_palette": "palette-default"}
|
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):
|
def navbar_context(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ TEMPLATES = [
|
|||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'core.context_processors.user_palette',
|
'core.context_processors.user_palette',
|
||||||
'core.context_processors.navbar_context',
|
'core.context_processors.navbar_context',
|
||||||
|
'core.context_processors.mail_brief_payload',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
276
src/functional_tests/test_bill_bud_page.py
Normal file
276
src/functional_tests/test_bill_bud_page.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""FT for the per-bud landing page at /billboard/buds/<bud_id>/.
|
||||||
|
|
||||||
|
bud.html is the destination of the @mailman-invite cascade (see
|
||||||
|
test_bill_mailman_invite_post.py) AND the click target of the
|
||||||
|
`@<handle>` 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 `<textarea>` so the user can edit
|
||||||
|
the shoptalk in place. Save persists to a per-relationship BudshipNote.
|
||||||
|
- 4-button apparatus from my_sea.html: kit / bud / gear / burger.
|
||||||
|
- The bud `#id_bud_btn` opens but its OK button is disabled (.btn-disabled + ×).
|
||||||
|
- Gear menu = NVM (→ my_buds) + DEL (→ shared guard portal "Delete this bud?";
|
||||||
|
DEL deletes the M2M edge, NVM dismisses the portal).
|
||||||
|
- Burger fan: 5 sub-btns default to inactive (.fa-ban icon, no .active class).
|
||||||
|
- Invite-cascade: if the bud has a PENDING SeaInvite where invitee=me,
|
||||||
|
• #id_burger_btn carries .glow-handoff (machine cascade from my_sea.html);
|
||||||
|
• #id_sea_btn in the fan is .active;
|
||||||
|
• clicking the glowing #id_sea_btn navigates to the bud's my-sea page
|
||||||
|
AND clears .glow-handoff but PRESERVES .active.
|
||||||
|
- Visiting bud.html for an inviter who isn't yet in my buds auto-adds them.
|
||||||
|
"""
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.gameboard.models import SeaInvite
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
class BudPageRenderTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_page_header_reads_at_handle_the_title(self):
|
||||||
|
"""Same `@alice the Earthman` framing as the my_buds row, hoisted
|
||||||
|
into the page header so the user sees who they're on the page of."""
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
header = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".bud-page-header"
|
||||||
|
))
|
||||||
|
self.assertIn("@alice the Earthman", header.text)
|
||||||
|
self.assertIn("alice@test.io", header.text)
|
||||||
|
|
||||||
|
def test_shoptalk_textarea_renders_with_160_char_maxlength(self):
|
||||||
|
textarea = self.wait_for(lambda: self._open_and_find_textarea())
|
||||||
|
self.assertEqual(textarea.get_attribute("maxlength"), "160")
|
||||||
|
self.assertEqual(textarea.get_attribute("value") or "", "")
|
||||||
|
|
||||||
|
def _open_and_find_textarea(self):
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
return self.browser.find_element(By.ID, "id_shoptalk")
|
||||||
|
|
||||||
|
|
||||||
|
class BudPageFourButtonApparatusTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_four_apparatus_buttons_present(self):
|
||||||
|
"""kit / bud / gear / burger — same 4-btn slate as my_sea.html.
|
||||||
|
kit_btn is included via base.html so we don't need to render it
|
||||||
|
specially on bud.html."""
|
||||||
|
for btn_id in ("id_kit_btn", "id_bud_btn", "id_burger_btn"):
|
||||||
|
self.wait_for(lambda bid=btn_id: self.browser.find_element(By.ID, bid))
|
||||||
|
# Gear btn is rendered via the shared _gear.html partial — class-
|
||||||
|
# only, not an id. Disambiguate by data-menu-target.
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_bud_btn_panel_ok_is_disabled_and_shows_times(self):
|
||||||
|
"""Per-spec stub: the bud-on-bud OK btn is intentionally disabled
|
||||||
|
for now (.btn-disabled + `×`). Wired later when the bud-of-bud
|
||||||
|
flow exists. Slap-on stub avoids removing the apparatus."""
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||||
|
btn.click()
|
||||||
|
ok = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_bud_panel .btn"
|
||||||
|
))
|
||||||
|
self.assertIn("btn-disabled", ok.get_attribute("class") or "")
|
||||||
|
self.assertEqual(ok.text.strip(), "×")
|
||||||
|
|
||||||
|
def test_burger_fan_all_five_subbtns_inactive_by_default(self):
|
||||||
|
"""Fresh load (no pending SeaInvite to me) → every sub-btn renders
|
||||||
|
WITHOUT .active. The visible icon for each sub-btn is .fa-ban (the
|
||||||
|
`--off` half); the `--on` icon stays hidden by CSS until .active."""
|
||||||
|
burger = self.browser.find_element(By.ID, "id_burger_btn")
|
||||||
|
burger.click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
self.browser.find_element(By.ID, "id_burger_fan").get_attribute("aria-hidden"),
|
||||||
|
"false",
|
||||||
|
))
|
||||||
|
fan_btns = self.browser.find_elements(By.CSS_SELECTOR, "#id_burger_fan .burger-fan-btn")
|
||||||
|
self.assertEqual(len(fan_btns), 5)
|
||||||
|
for b in fan_btns:
|
||||||
|
cls = b.get_attribute("class") or ""
|
||||||
|
self.assertNotIn("active", cls.split())
|
||||||
|
|
||||||
|
|
||||||
|
class BudPageGearMenuTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_gear_nvm_navigates_back_to_my_buds(self):
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
|
||||||
|
).click()
|
||||||
|
nvm = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_bud_menu .btn-cancel"
|
||||||
|
))
|
||||||
|
self.assertEqual(nvm.text.strip(), "NVM")
|
||||||
|
nvm.click()
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"/billboard/my-buds/", self.browser.current_url
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_gear_del_opens_guard_portal_with_delete_this_bud_message(self):
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
|
||||||
|
).click()
|
||||||
|
del_btn = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
|
||||||
|
))
|
||||||
|
self.assertEqual(del_btn.text.strip(), "DEL")
|
||||||
|
del_btn.click()
|
||||||
|
portal = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_guard_portal.active"
|
||||||
|
))
|
||||||
|
self.assertIn(
|
||||||
|
"Delete this bud?",
|
||||||
|
portal.find_element(By.CSS_SELECTOR, ".guard-message").text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_guard_del_removes_bud_from_m2m_and_navigates_away(self):
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
|
||||||
|
).click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
|
||||||
|
)).click()
|
||||||
|
# Guard portal: DEL = .guard-yes, NVM = .guard-no.
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
|
||||||
|
)).click()
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"/billboard/my-buds/", self.browser.current_url
|
||||||
|
))
|
||||||
|
self.assertNotIn(self.alice, list(self.gamer.buds.all()))
|
||||||
|
|
||||||
|
def test_guard_nvm_dismisses_portal_without_navigating_or_deleting(self):
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
|
||||||
|
).click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
|
||||||
|
)).click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_guard_portal.active .guard-no"
|
||||||
|
)).click()
|
||||||
|
# Portal closes; bud is still in M2M; we're still on the bud page.
|
||||||
|
self.wait_for(lambda: self.assertNotIn(
|
||||||
|
"active",
|
||||||
|
self.browser.find_element(By.ID, "id_guard_portal").get_attribute("class") or "",
|
||||||
|
))
|
||||||
|
self.assertIn(self.alice, list(self.gamer.buds.all()))
|
||||||
|
self.assertIn(f"/billboard/buds/{self.alice.id}/", self.browser.current_url)
|
||||||
|
|
||||||
|
|
||||||
|
class BudPageInviteCascadeTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_no_pending_invite_no_glow_no_sea_active(self):
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
burger = self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn"))
|
||||||
|
self.assertNotIn("glow-handoff", (burger.get_attribute("class") or "").split())
|
||||||
|
burger.click()
|
||||||
|
sea = self.browser.find_element(By.ID, "id_sea_btn")
|
||||||
|
self.assertNotIn("active", (sea.get_attribute("class") or "").split())
|
||||||
|
|
||||||
|
def test_pending_invite_glows_burger_and_seats_sea_btn_active(self):
|
||||||
|
"""alice → me PENDING SeaInvite. burger.glow-handoff is rendered
|
||||||
|
server-side (machine cascade — same `glow-handoff` class as the
|
||||||
|
my_sea.html sea_first_draw_pending path)."""
|
||||||
|
SeaInvite.objects.create(
|
||||||
|
owner=self.alice,
|
||||||
|
invitee_email=self.gamer.email,
|
||||||
|
invitee=self.gamer,
|
||||||
|
status=SeaInvite.PENDING,
|
||||||
|
)
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
burger = self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn"))
|
||||||
|
self.assertIn("glow-handoff", (burger.get_attribute("class") or "").split())
|
||||||
|
burger.click()
|
||||||
|
sea = self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_btn"))
|
||||||
|
self.assertIn("active", (sea.get_attribute("class") or "").split())
|
||||||
|
|
||||||
|
def test_click_glowing_sea_btn_navigates_to_buds_my_sea_and_clears_glow(self):
|
||||||
|
"""Click glowing #id_sea_btn → navigates to alice's my-sea spectator
|
||||||
|
page. The .active stays on sea_btn (preserved across pages); the
|
||||||
|
burger's .glow-handoff is consumed by the handoff."""
|
||||||
|
SeaInvite.objects.create(
|
||||||
|
owner=self.alice,
|
||||||
|
invitee_email=self.gamer.email,
|
||||||
|
invitee=self.gamer,
|
||||||
|
status=SeaInvite.PENDING,
|
||||||
|
)
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn")).click()
|
||||||
|
sea = self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_btn"))
|
||||||
|
sea.click()
|
||||||
|
# Final URL is the bud's my-sea spectator page — exact route TBD
|
||||||
|
# at IT time (likely /gameboard/my-sea/spectate/<owner_id>/ or
|
||||||
|
# /gameboard/my-sea/<owner_id>/). The FT pins the OWNER's id in
|
||||||
|
# the URL so the IT picks a route shape that surfaces it.
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
str(self.alice.id), self.browser.current_url
|
||||||
|
))
|
||||||
|
self.wait_for(lambda: self.assertIn("my-sea", self.browser.current_url))
|
||||||
|
|
||||||
|
|
||||||
|
class BudPageAutoAddOnFirstVisitTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
# NOTE: alice is NOT yet in gamer.buds — visiting bud.html for her
|
||||||
|
# via the post-attribution link should auto-add her.
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_visiting_bud_page_for_non_bud_auto_adds_to_m2m(self):
|
||||||
|
"""Per-spec: 'if the inviter wasn't already a bud, navigating there
|
||||||
|
as such automatically adds the bud to the user's bud list.' Same
|
||||||
|
implicit-add posture as share-post → buds.add."""
|
||||||
|
self.assertNotIn(self.alice, list(self.gamer.buds.all()))
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
|
||||||
|
)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".bud-page-header"
|
||||||
|
))
|
||||||
|
self.assertIn(self.alice, list(self.gamer.buds.all()))
|
||||||
168
src/functional_tests/test_bill_mailman_invite_post.py
Normal file
168
src/functional_tests/test_bill_mailman_invite_post.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""FT for the @mailman "Acceptances & rejections" Post + cross-page Brief
|
||||||
|
behaviour after the bud-landing-page sprint refactor.
|
||||||
|
|
||||||
|
Pre-sprint: the @mailman invite Line carried inline OK/BYE form buttons
|
||||||
|
+ an "Accepted <date>" badge (see apps/billboard/_partials/_invite_actions.html).
|
||||||
|
Post-sprint: that interaction surface migrates entirely onto bud.html —
|
||||||
|
the Line's prose becomes the interactive surface via an
|
||||||
|
`<a class="post-attribution">@<owner></a>` inside `span.post-line-text`
|
||||||
|
whose href routes to /billboard/buds/<owner_id>/. The bud.html page then
|
||||||
|
absorbs the accept/decline + spectator-link flow that used to live in the
|
||||||
|
Post.
|
||||||
|
|
||||||
|
Spec recap:
|
||||||
|
- When the bud has invited the user, a Brief slides down wherever the user
|
||||||
|
is on the site; FYI links to the @mailman Acceptances & rejections Post.
|
||||||
|
- The Post's mailman Line contains an `a.post-attribution` around the
|
||||||
|
inviter's handle, href=/billboard/buds/<inviter_id>/.
|
||||||
|
- The Line carries NO `.invite-actions` block (OK/BYE/Accepted/etc. removed).
|
||||||
|
- Following the post-attribution link auto-adds the inviter to the user's
|
||||||
|
buds list if not already a bud (verified separately in
|
||||||
|
test_bill_bud_page.py::BudPageAutoAddOnFirstVisitTest).
|
||||||
|
"""
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.billboard.mail import log_sea_invite
|
||||||
|
from apps.gameboard.models import SeaInvite
|
||||||
|
from apps.lyric.models import User, get_or_create_mailman
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pending_invite(owner, invitee):
|
||||||
|
"""Reusable helper: alice → me PENDING SeaInvite + the @mailman Post +
|
||||||
|
Line + Brief log entry. Mirrors what gameboard.invite-bud would create
|
||||||
|
in real flow; calling log_sea_invite directly avoids dragging the
|
||||||
|
bud-invite POST view into the FT setup."""
|
||||||
|
inv = SeaInvite.objects.create(
|
||||||
|
owner=owner,
|
||||||
|
invitee_email=invitee.email,
|
||||||
|
invitee=invitee,
|
||||||
|
status=SeaInvite.PENDING,
|
||||||
|
)
|
||||||
|
log_sea_invite(inv)
|
||||||
|
return inv
|
||||||
|
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
class MailmanPostStructureTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
get_or_create_mailman()
|
||||||
|
_create_pending_invite(self.alice, self.gamer)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def _navigate_to_mailman_post(self):
|
||||||
|
"""Land on the user's @mailman post directly via the my_posts list.
|
||||||
|
Avoids hard-coding the post UUID."""
|
||||||
|
from apps.billboard.models import Post
|
||||||
|
post = Post.objects.get(owner=self.gamer, kind=Post.KIND_MAIL_ACCEPTANCE)
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/post/{post.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mailman_line_contains_post_attribution_anchor_to_bud_page(self):
|
||||||
|
"""span.post-line-text on the @mailman Line wraps the inviter's
|
||||||
|
handle in an <a class="post-attribution"> whose href is the bud
|
||||||
|
landing page for the inviter. Anchor text = the bud's at_handle
|
||||||
|
(e.g. '@alice'); the surrounding prose stays in plain text."""
|
||||||
|
self._navigate_to_mailman_post()
|
||||||
|
line = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".post-line--system"
|
||||||
|
))
|
||||||
|
anchor = line.find_element(
|
||||||
|
By.CSS_SELECTOR, "span.post-line-text a.post-attribution"
|
||||||
|
)
|
||||||
|
self.assertEqual(anchor.text, "@alice")
|
||||||
|
href = anchor.get_attribute("href")
|
||||||
|
self.assertIn(f"/billboard/buds/{self.alice.id}/", href)
|
||||||
|
|
||||||
|
def test_mailman_line_no_longer_renders_ok_bye_or_accepted_badge(self):
|
||||||
|
"""The pre-sprint .invite-actions block is GONE — the entire
|
||||||
|
accept/decline + accepted-date UX migrates onto bud.html. The Line
|
||||||
|
carries prose only."""
|
||||||
|
self._navigate_to_mailman_post()
|
||||||
|
line = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".post-line--system"
|
||||||
|
))
|
||||||
|
self.assertEqual(
|
||||||
|
line.find_elements(By.CSS_SELECTOR, ".invite-actions"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
line.find_elements(By.CSS_SELECTOR, ".invite-ok-btn"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
line.find_elements(By.CSS_SELECTOR, ".invite-bye-btn"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
line.find_elements(By.CSS_SELECTOR, ".invite-badge--accepted"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mailman_line_no_invite_actions_remain_after_invite_accepted(self):
|
||||||
|
"""Even if the SeaInvite is in ACCEPTED state, the Line stays prose-
|
||||||
|
only — there's no longer an 'Accepted <date>' badge here (that
|
||||||
|
information surfaces on bud.html instead). Asserts the .invite-
|
||||||
|
actions sweep is unconditional, not just PENDING-only."""
|
||||||
|
SeaInvite.objects.filter(invitee=self.gamer).update(status=SeaInvite.ACCEPTED)
|
||||||
|
self._navigate_to_mailman_post()
|
||||||
|
line = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".post-line--system"
|
||||||
|
))
|
||||||
|
self.assertEqual(
|
||||||
|
line.find_elements(By.CSS_SELECTOR, ".invite-actions"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MailmanBriefSurfacesOnAnyPageTest(FunctionalTest):
|
||||||
|
"""The Brief spawned by `log_sea_invite` should surface wherever the
|
||||||
|
user lands next — not only on the Post itself. Existing Brief delivery
|
||||||
|
is page-load based (banner injected by the server-rendered template +
|
||||||
|
note.js Brief.showBanner). The FT verifies the cross-page surface by
|
||||||
|
visiting an unrelated page first."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
get_or_create_mailman()
|
||||||
|
_create_pending_invite(self.alice, self.gamer)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_brief_appears_on_unrelated_page_load(self):
|
||||||
|
"""Land on `/` (not the Post itself). The Brief banner should
|
||||||
|
still be present — the invite spawned an unread Brief, which
|
||||||
|
the next page-load surfaces sitewide via the `mail_brief_
|
||||||
|
payload` context processor + base.html auto-showBanner."""
|
||||||
|
self.browser.get(self.live_server_url + "/")
|
||||||
|
banner = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".note-banner"
|
||||||
|
))
|
||||||
|
self.assertIn(
|
||||||
|
"Acceptances & rejections",
|
||||||
|
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_brief_fyi_navigates_to_mailman_post(self):
|
||||||
|
"""FYI link on the slide-down banner is the cross-page nav into the
|
||||||
|
Acceptances & rejections Post. Click → land on the Post page."""
|
||||||
|
self.browser.get(self.live_server_url + "/")
|
||||||
|
fyi = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".note-banner__fyi"
|
||||||
|
))
|
||||||
|
fyi.click()
|
||||||
|
# Post URL pattern is /billboard/post/<uuid>/
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"/billboard/post/", self.browser.current_url
|
||||||
|
))
|
||||||
|
# And the page is the user's @mailman post (title visible).
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"Acceptances & rejections",
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, ".post-title").text,
|
||||||
|
))
|
||||||
140
src/functional_tests/test_bill_my_buds_tooltip.py
Normal file
140
src/functional_tests/test_bill_my_buds_tooltip.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""FT for the My Buds page — line rendering w. donned title + click-lock
|
||||||
|
tooltip portal + username <a> link to the per-bud landing page.
|
||||||
|
|
||||||
|
Companion to test_bill_my_buds.py (which covers Phase 1 add/render).
|
||||||
|
This file covers the My Buds enrichment that lands alongside the new
|
||||||
|
apps/billboard/bud.html landing page (see test_bill_bud_page.py).
|
||||||
|
|
||||||
|
Spec recap:
|
||||||
|
- Each bud row reads `@<handle> the <Title>` (at_handle + active_title_display).
|
||||||
|
- Clicking the row applies `.row-locked` to the `li.applet-list-entry` AND
|
||||||
|
opens the shared #id_tooltip_portal w. five fields:
|
||||||
|
.tt-title → `@<handle>`
|
||||||
|
.tt-description → bud's donned title ("Earthman" by default)
|
||||||
|
.tt-email → bud's email (small / italic / 0.75 opacity — CSS)
|
||||||
|
.tt-shoptalk → the user's personal note about this bud (160-char cap)
|
||||||
|
.tt-milestone → "edited <relative-ts>" (omitted when no edit yet)
|
||||||
|
- The `@<handle>` itself is an `<a>` element (--terUser by CSS) whose href
|
||||||
|
navigates to the bud's landing page /billboard/buds/<bud_id>/.
|
||||||
|
"""
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
class MyBudsLineRendersBudTitleTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_bud_entry_reads_at_handle_the_title(self):
|
||||||
|
"""Default (no donned Note) → '@alice the Earthman' — active_title_display
|
||||||
|
falls back to 'Earthman' when the bud hasn't donned any Note title.
|
||||||
|
Renders inside .bud-entry; the username span carries the `.bud-name`
|
||||||
|
class for backward compat w. test_bill_my_buds.py."""
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||||
|
entry = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
|
||||||
|
))
|
||||||
|
# Full row text combines handle + title
|
||||||
|
self.assertIn("@alice the Earthman", entry.text)
|
||||||
|
|
||||||
|
|
||||||
|
class MyBudsClickOpensTooltipPortalTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_click_locks_row_and_opens_tooltip_portal(self):
|
||||||
|
"""Click the bud row → li picks up .row-locked AND the shared
|
||||||
|
#id_tooltip_portal becomes .active w. the bud's data populated
|
||||||
|
into .tt-title / .tt-description / .tt-email."""
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||||
|
row = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
|
||||||
|
))
|
||||||
|
self.assertNotIn("row-locked", row.get_attribute("class") or "")
|
||||||
|
|
||||||
|
row.click()
|
||||||
|
|
||||||
|
# Row picks up .row-locked
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"row-locked", row.get_attribute("class") or ""
|
||||||
|
))
|
||||||
|
|
||||||
|
# Portal slides in active
|
||||||
|
portal = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_tooltip_portal.active"
|
||||||
|
))
|
||||||
|
# Field assertions — text() suffices; the SCSS rules (italic,
|
||||||
|
# opacity, glow) are out of FT scope.
|
||||||
|
title = portal.find_element(By.CSS_SELECTOR, ".tt-title").text
|
||||||
|
desc = portal.find_element(By.CSS_SELECTOR, ".tt-description").text
|
||||||
|
email = portal.find_element(By.CSS_SELECTOR, ".tt-email").text
|
||||||
|
self.assertEqual(title, "@alice")
|
||||||
|
self.assertEqual(desc, "Earthman")
|
||||||
|
self.assertEqual(email, "alice@test.io")
|
||||||
|
|
||||||
|
def test_tooltip_milestone_absent_when_shoptalk_never_edited(self):
|
||||||
|
"""`.tt-milestone` renders only when the user has edited the shoptalk
|
||||||
|
for this bud — fresh bud has no BudshipNote row yet, so no edit
|
||||||
|
timestamp exists. The cell should be ABSENT (not empty)."""
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||||
|
row = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
|
||||||
|
))
|
||||||
|
row.click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_tooltip_portal.active"
|
||||||
|
))
|
||||||
|
self.assertEqual(
|
||||||
|
self.browser.find_elements(By.CSS_SELECTOR, "#id_tooltip_portal .tt-milestone"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tooltip_shoptalk_empty_string_when_never_edited(self):
|
||||||
|
"""Shoptalk slot still renders (so the layout slot exists), just
|
||||||
|
with empty body. Distinguishes never-edited (slot empty) from
|
||||||
|
cleared-after-edit (slot empty + .tt-milestone present)."""
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
|
||||||
|
).click()
|
||||||
|
portal = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_tooltip_portal.active"
|
||||||
|
))
|
||||||
|
# .tt-shoptalk exists but is empty
|
||||||
|
shoptalk = portal.find_element(By.CSS_SELECTOR, ".tt-shoptalk")
|
||||||
|
self.assertEqual(shoptalk.text.strip(), "")
|
||||||
|
|
||||||
|
|
||||||
|
class MyBudsUsernameAnchorTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
self.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_username_inside_row_is_anchor_to_bud_page(self):
|
||||||
|
"""The `@alice` text itself is wrapped in an `<a>` element (--terUser
|
||||||
|
per global SCSS); href routes to /billboard/buds/<bud_id>/. The row
|
||||||
|
wrapper still receives clicks for the row-lock + tooltip flow (the
|
||||||
|
anchor is a CHILD of the bud-name span; clicking the row outside
|
||||||
|
the anchor locks; clicking the anchor itself navigates)."""
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||||
|
anchor = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR,
|
||||||
|
f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name a",
|
||||||
|
))
|
||||||
|
self.assertEqual(anchor.text, "@alice")
|
||||||
|
href = anchor.get_attribute("href")
|
||||||
|
self.assertIn(f"/billboard/buds/{self.alice.id}/", href)
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
#id_billboard_applet_menu { @extend %applet-menu; }
|
#id_billboard_applet_menu { @extend %applet-menu; }
|
||||||
#id_billscroll_menu { @extend %applet-menu; }
|
#id_billscroll_menu { @extend %applet-menu; }
|
||||||
#id_my_sea_menu { @extend %applet-menu; }
|
#id_my_sea_menu { @extend %applet-menu; }
|
||||||
|
#id_bud_menu { @extend %applet-menu; }
|
||||||
|
|
||||||
// Page-level gear buttons — fixed to viewport bottom-right
|
// Page-level gear buttons — fixed to viewport bottom-right
|
||||||
.gameboard-page,
|
.gameboard-page,
|
||||||
@@ -95,7 +96,8 @@
|
|||||||
.post-page,
|
.post-page,
|
||||||
.billboard-page,
|
.billboard-page,
|
||||||
.billscroll-page,
|
.billscroll-page,
|
||||||
.my-sea-page {
|
.my-sea-page,
|
||||||
|
.bud-page {
|
||||||
> .gear-btn {
|
> .gear-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 4.2rem;
|
bottom: 4.2rem;
|
||||||
@@ -112,7 +114,8 @@
|
|||||||
#id_post_menu,
|
#id_post_menu,
|
||||||
#id_billboard_applet_menu,
|
#id_billboard_applet_menu,
|
||||||
#id_billscroll_menu,
|
#id_billscroll_menu,
|
||||||
#id_my_sea_menu {
|
#id_my_sea_menu,
|
||||||
|
#id_bud_menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 6.6rem;
|
bottom: 6.6rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
@@ -137,7 +140,8 @@
|
|||||||
.post-page > .gear-btn,
|
.post-page > .gear-btn,
|
||||||
.billboard-page > .gear-btn,
|
.billboard-page > .gear-btn,
|
||||||
.billscroll-page > .gear-btn,
|
.billscroll-page > .gear-btn,
|
||||||
.my-sea-page > .gear-btn {
|
.my-sea-page > .gear-btn,
|
||||||
|
.bud-page > .gear-btn {
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
right: 4.2rem;
|
right: 4.2rem;
|
||||||
@@ -151,7 +155,8 @@
|
|||||||
#id_post_menu,
|
#id_post_menu,
|
||||||
#id_billboard_applet_menu,
|
#id_billboard_applet_menu,
|
||||||
#id_billscroll_menu,
|
#id_billscroll_menu,
|
||||||
#id_my_sea_menu {
|
#id_my_sea_menu,
|
||||||
|
#id_bud_menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 2.6rem;
|
top: 2.6rem;
|
||||||
right: 4.2rem;
|
right: 4.2rem;
|
||||||
|
|||||||
@@ -123,9 +123,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Active sub-btn = fully visible; inactive (default) = 0.6 opacity hint.
|
// Active sub-btn = fully visible; inactive (default) = 0.6 opacity hint.
|
||||||
// Active conditions are wired one-by-one in later sprints.
|
// `z-index: 1` raises the active sub-btn above its inactive siblings so
|
||||||
|
// click hit-testing picks the active one even while the fan is mid-
|
||||||
|
// transition (during the 0.25s arc-out animation all 5 sub-btns share
|
||||||
|
// the fan-centre origin, and a later-in-DOM inactive btn would otherwise
|
||||||
|
// obscure the active target). Active conditions are wired one-by-one in
|
||||||
|
// later sprints.
|
||||||
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn.active {
|
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn:not(.active) {
|
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn:not(.active) {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
11
src/templates/apps/billboard/_partials/_bud_gear.html
Normal file
11
src/templates/apps/billboard/_partials/_bud_gear.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{# Gear menu on bud.html — mirrors apps/billboard/_partials/_post_gear.html. #}
|
||||||
|
{# NVM returns to /billboard/my-buds/; DEL opens the shared #id_guard_portal #}
|
||||||
|
{# from base.html via the `data-confirm` hook + POSTs to delete_bud. #}
|
||||||
|
<div id="id_bud_menu" style="display:none">
|
||||||
|
<a href="{% url 'billboard:my_buds' %}" class="btn btn-cancel">NVM</a>
|
||||||
|
<form method="POST" action="{% url 'billboard:delete_bud' bud.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger" data-confirm="Delete this bud?">DEL</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% include "apps/applets/_partials/_gear.html" with menu_id="id_bud_menu" %}
|
||||||
13
src/templates/apps/billboard/_partials/_bud_tooltip.html
Normal file
13
src/templates/apps/billboard/_partials/_bud_tooltip.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{# Shared tooltip-portal markup for the My Buds list. Hidden by default; #}
|
||||||
|
{# JS reads data-tt-* attrs from the clicked .bud-entry row, populates #}
|
||||||
|
{# the slots below, and toggles .active. .tt-milestone is removed from #}
|
||||||
|
{# the DOM when no data-tt-milestone attr is present so the FT can #}
|
||||||
|
{# distinguish never-edited from cleared-after-edit (slot absent vs. #}
|
||||||
|
{# present-but-empty). #}
|
||||||
|
<div id="id_tooltip_portal" class="tt">
|
||||||
|
<span class="tt-title"></span>
|
||||||
|
<span class="tt-description"></span>
|
||||||
|
<span class="tt-email"></span>
|
||||||
|
<span class="tt-shoptalk"></span>
|
||||||
|
<span class="tt-milestone"></span>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
{% load lyric_extras %}
|
{% load lyric_extras %}
|
||||||
<li class="applet-list-entry bud-entry" data-bud-id="{{ item.id }}">
|
{# My Buds page row — `@<handle> the <Title>` w. anchor on the handle #}
|
||||||
<span class="bud-name">{{ item|at_handle }}</span>
|
{# routing to the bud's landing page + data-tt-* attrs the tooltip #}
|
||||||
|
{# portal reads on row-lock click. `item.shoptalk_text` + `.milestone_ #}
|
||||||
|
{# dt` are attached by the my_buds view from BudshipNote. #}
|
||||||
|
<li class="applet-list-entry bud-entry"
|
||||||
|
data-bud-id="{{ item.id }}"
|
||||||
|
data-tt-title="{{ item|at_handle }}"
|
||||||
|
data-tt-description="{{ item.active_title_display }}"
|
||||||
|
data-tt-email="{{ item.email }}"
|
||||||
|
data-tt-shoptalk="{{ item.shoptalk_text|default:'' }}"
|
||||||
|
{% if item.milestone_dt %}data-tt-milestone="edited {{ item.milestone_dt|relative_ts }}"{% endif %}>
|
||||||
|
<span class="bud-name"><a href="{% url 'billboard:bud_page' item.id %}">{{ item|at_handle }}</a></span>
|
||||||
|
<span class="bud-row-title"> the {{ item.active_title_display }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
85
src/templates/apps/billboard/bud.html
Normal file
85
src/templates/apps/billboard/bud.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load lyric_extras %}
|
||||||
|
|
||||||
|
{% block title_text %}Billbud{% endblock title_text %}
|
||||||
|
{% block header_text %}<span>Bill</span><span>bud</span>{% endblock header_text %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{# note.js exposes window.Brief — needed by the auto-fired @mailman Brief #}
|
||||||
|
{# when this page is the destination of an invite cascade. #}
|
||||||
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||||
|
|
||||||
|
<div class="bud-page">
|
||||||
|
<header class="bud-page-header">
|
||||||
|
<h3>{{ bud|at_handle }} the {{ bud.active_title_display }}</h3>
|
||||||
|
<p class="bud-page-email">{{ bud.email }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="id_bud_shoptalk_form"
|
||||||
|
method="POST"
|
||||||
|
action="{% url 'billboard:save_bud_shoptalk' bud.id %}"
|
||||||
|
class="bud-shoptalk-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label for="id_shoptalk" class="bud-shoptalk-label">Shoptalk</label>
|
||||||
|
<textarea id="id_shoptalk"
|
||||||
|
name="shoptalk"
|
||||||
|
maxlength="160"
|
||||||
|
class="form-control bud-shoptalk-input"
|
||||||
|
placeholder="Your personal note about this bud…">{{ shoptalk_text }}</textarea>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# 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 %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('id_bud_btn');
|
||||||
|
var panel = document.getElementById('id_bud_panel');
|
||||||
|
var ok = document.getElementById('id_bud_ok');
|
||||||
|
if (!btn || !panel || !ok) return;
|
||||||
|
// bud-of-bud flow isn't wired yet — stub OK as disabled w. × text.
|
||||||
|
ok.classList.add('btn-disabled');
|
||||||
|
ok.textContent = '×';
|
||||||
|
ok.setAttribute('disabled', 'disabled');
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var open = document.documentElement.classList.toggle('bud-open');
|
||||||
|
btn.classList.toggle('active', open);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && document.documentElement.classList.contains('bud-open')) {
|
||||||
|
document.documentElement.classList.remove('bud-open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% 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" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||||
|
{% if pending_invite %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var sea = document.getElementById('id_sea_btn');
|
||||||
|
if (!sea) return;
|
||||||
|
// Active sub-btn navigation handler — fires BEFORE burger-btn.js's
|
||||||
|
// delegated fan handler (target-phase before bubble-up) so the click
|
||||||
|
// routes to the bud's spectator my-sea page. Burger then closes the
|
||||||
|
// fan as usual.
|
||||||
|
sea.addEventListener('click', function () {
|
||||||
|
if (!sea.classList.contains('active')) return;
|
||||||
|
window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/';
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
@@ -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." %}
|
{% 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." %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# 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. #}
|
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
|
||||||
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
|
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -19,4 +23,5 @@
|
|||||||
{# Brief module — needed by _bud_add_panel's OK handler so the #}
|
{# Brief module — needed by _bud_add_panel's OK handler so the #}
|
||||||
{# duplicate-add error banner can render via Brief.showDuplicateBanner.#}
|
{# duplicate-add error banner can render via Brief.showDuplicateBanner.#}
|
||||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||||
|
<script src="{% static 'apps/billboard/my-buds-tooltip.js' %}"></script>
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
|||||||
@@ -41,12 +41,14 @@
|
|||||||
{% for line in post.lines.all %}
|
{% for line in post.lines.all %}
|
||||||
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}post-line--system{% endif %}">
|
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}post-line--system{% endif %}">
|
||||||
<span class="post-line-author">{{ line.author|at_handle }}</span>
|
<span class="post-line-author">{{ line.author|at_handle }}</span>
|
||||||
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` 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 %}</span>
|
<span class="post-line-text">{# 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 `[<iso timestamp>] ` 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 %}</span>
|
||||||
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|relative_ts }}</time>
|
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|relative_ts }}</time>
|
||||||
{# @mailman invite Lines carry an OK/BYE action block driven by #}
|
{# Pre-sprint @mailman invite Lines carried an in-line OK/BYE #}
|
||||||
{# the linked SeaInvite's status (my-sea invite flow). Non-invite #}
|
{# block via _invite_actions.html. Bud landing page sprint #}
|
||||||
{# system + user Lines have no `sea_invite`, so this is skipped. #}
|
{# 2026-05-27 migrates that interaction onto bud.html — the #}
|
||||||
{% if line.sea_invite %}{% include "apps/billboard/_partials/_invite_actions.html" %}{% endif %}
|
{# 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. #}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<li class="post-line-buffer" aria-hidden="true"></li>
|
<li class="post-line-buffer" aria-hidden="true"></li>
|
||||||
|
|||||||
@@ -73,6 +73,24 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% endblock 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" }}
|
||||||
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('id_mail_brief_payload');
|
||||||
|
if (!el || !window.Brief || !Brief.showBanner) return;
|
||||||
|
var payload = JSON.parse(el.textContent || '{}');
|
||||||
|
if (payload && payload.title) Brief.showBanner(payload);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
// h2 letter splitter — wrap each character of every .row .col-lg-6 h2
|
// h2 letter splitter — wrap each character of every .row .col-lg-6 h2
|
||||||
// word-span in its own <span> so .scss can use justify-content:
|
// word-span in its own <span> so .scss can use justify-content:
|
||||||
|
|||||||
Reference in New Issue
Block a user