From 22d0507c3f3afebd55acb9c83ca4765850298c91 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 9 May 2026 01:14:11 -0400 Subject: [PATCH] =?UTF-8?q?post.html=20header=20prose=20branches=20on=20vi?= =?UTF-8?q?ewer-vs-owner:=20invitees=20see=20"shared=20with=20me,=20@viewe?= =?UTF-8?q?r=20the=20{title}"=20+=20"created=20by=20@owner=20the=20{title}?= =?UTF-8?q?"=20instead=20of=20the=20owner-centric=20"just=20me=20/=20share?= =?UTF-8?q?d=20between"=20lines;=20owner=20view=20unchanged=20=E2=80=94=20?= =?UTF-8?q?TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - billboard.views.view_post adds viewer_is_owner + other_recipients context vars. is_real_invitee = (auth AND post has owner AND viewer != owner). Anon viewers + ownerless-post legacy path fall through to owner-style rendering (which renders empty gracefully via the at_handle / display_name AnonymousUser guards). - other_recipients = post.shared_with.exclude(viewer) when invitee; .all() otherwise. - post.html .post-header branches: • viewer_is_owner: existing prose ("just me, @owner …" / "shared between {recipients} & me, @owner …"). • sole invitee: "shared with me, @viewer the {viewer.title}" + "created by @owner the {owner.title}". • multi invitee: "shared with {other_recipients}" + "& me, @viewer the {viewer.title}" + "created by @owner the {owner.title}". - lyric_extras at_handle + display_name: guard against AnonymousUser (no .email attribute) — return "" rather than crash. Preserves the Percival ch. 18 anon-views-ownerless-post path. - 12 new ITs in test_post_invitee_view (context vars: viewer_is_owner, other_recipients exclude/include; template prose: sole + multi invitee phrasing, owner unchanged). - 878 IT regression + 8 post-html FT regression green (1 Marionette flake on multi-run that passes in isolation). Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integrated/test_post_invitee_view.py | 152 ++++++++++++++++++ src/apps/billboard/views.py | 22 +++ src/apps/lyric/templatetags/lyric_extras.py | 11 +- src/templates/apps/billboard/post.html | 21 ++- 4 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 src/apps/billboard/tests/integrated/test_post_invitee_view.py diff --git a/src/apps/billboard/tests/integrated/test_post_invitee_view.py b/src/apps/billboard/tests/integrated/test_post_invitee_view.py new file mode 100644 index 0000000..b57d246 --- /dev/null +++ b/src/apps/billboard/tests/integrated/test_post_invitee_view.py @@ -0,0 +1,152 @@ +"""ITs for post.html invitee-vs-owner header rendering. + +The "just me, @owner the {title}" / "shared between … & me, @owner …" lines +were owner-centric (the legacy phrasing assumed the viewer is the post +creator). For an invitee (a user in post.shared_with), that prose is +confusing. This view branches the .post-header block: + + • Owner viewing → unchanged existing prose. + • Invitee viewing (sole) → "shared with me, @viewer the {title}" + + "created by @owner the {owner_title}". + • Invitee viewing (multi) → "shared with {other_recipients ...}" + + "& me, @viewer the {title}" + + "created by @owner the {owner_title}". + +The view layer adds `viewer_is_owner` + `other_recipients` to the +context; template branches on those. +""" +from django.test import TestCase +from django.urls import reverse + +from apps.billboard.models import Line, Post +from apps.lyric.models import User + + +class PostInviteeViewContextTest(TestCase): + """Context vars: viewer_is_owner + other_recipients.""" + + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.bob = User.objects.create(email="bob@test.io", username="bob") + self.post = Post.objects.create(owner=self.owner, title="Coolio") + Line.objects.create(post=self.post, text="seed", author=self.owner) + + def test_owner_viewing_sets_viewer_is_owner_true(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) + self.assertTrue(response.context["viewer_is_owner"]) + + def test_invitee_viewing_sets_viewer_is_owner_false(self): + self.post.shared_with.add(self.alice) + self.client.force_login(self.alice) + response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) + self.assertFalse(response.context["viewer_is_owner"]) + + def test_other_recipients_excludes_viewer(self): + """For an invitee, other_recipients = shared_with minus self.""" + self.post.shared_with.add(self.alice, self.bob) + self.client.force_login(self.alice) + response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) + others = list(response.context["other_recipients"]) + self.assertIn(self.bob, others) + self.assertNotIn(self.alice, others) + + def test_other_recipients_empty_for_sole_invitee(self): + self.post.shared_with.add(self.alice) + self.client.force_login(self.alice) + response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) + self.assertEqual(list(response.context["other_recipients"]), []) + + def test_other_recipients_for_owner_is_full_shared_with(self): + """Owner viewing: other_recipients includes everyone (no self exclusion).""" + self.post.shared_with.add(self.alice, self.bob) + self.client.force_login(self.owner) + response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) + others = list(response.context["other_recipients"]) + self.assertIn(self.alice, others) + self.assertIn(self.bob, others) + + +class PostInviteeViewTemplateTest(TestCase): + """Template prose: invitee branch shows "shared with me, …" / + "created by @owner …" — does NOT show the owner-centric "just me" or + "shared between".""" + + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.bob = User.objects.create(email="bob@test.io", username="bob") + self.post = Post.objects.create(owner=self.owner, title="Coolio") + Line.objects.create(post=self.post, text="seed", author=self.owner) + + def test_sole_invitee_sees_shared_with_me(self): + self.post.shared_with.add(self.alice) + self.client.force_login(self.alice) + response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) + body = response.content.decode() + self.assertIn("shared with me", body) + self.assertIn("@alice", body) + + def test_sole_invitee_does_not_see_just_me_or_shared_between(self): + """Scope to .post-header — the bud-panel JS includes 'just me,' as + a regex literal in inline script, so a body-wide string match + false-positives.""" + import lxml.html + self.post.shared_with.add(self.alice) + self.client.force_login(self.alice) + body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode() + tree = lxml.html.fromstring(body) + header_text = tree.cssselect(".post-header")[0].text_content() + self.assertNotIn("just me,", header_text) + self.assertNotIn("shared between", header_text) + + def test_invitee_sees_created_by_owner(self): + self.post.shared_with.add(self.alice) + self.client.force_login(self.alice) + body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode() + self.assertIn("created by", body) + self.assertIn("@owner", body) + + def test_multi_invitee_sees_shared_with_others_then_amp_me(self): + self.post.shared_with.add(self.alice, self.bob) + self.client.force_login(self.alice) + body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode() + self.assertIn("shared with", body) + self.assertIn("@bob", body) + self.assertIn("& me", body) + self.assertIn("@alice", body) + + def test_multi_invitee_does_not_see_self_in_recipients_line(self): + """The recipients line lists OTHER invitees, not self.""" + self.post.shared_with.add(self.alice, self.bob) + self.client.force_login(self.alice) + body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode() + # Coarse check: "shared with @bob" appears w/o @alice in same line + # (since alice's "@alice" is on the "& me" line below). The full + # body contains both, but the .post-shared-recipients line should + # only list other_recipients (i.e., bob, not alice). + # Use a narrower lxml-style assertion. + import lxml.html + tree = lxml.html.fromstring(body) + recipients_p = tree.cssselect(".post-shared-recipients") + self.assertEqual(len(recipients_p), 1) + rec_text = recipients_p[0].text_content() + self.assertIn("@bob", rec_text) + self.assertNotIn("@alice", rec_text) + + def test_owner_view_unchanged_when_recipients_present(self): + """Owner sees 'shared between' (old behavior).""" + self.post.shared_with.add(self.alice) + self.client.force_login(self.owner) + body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode() + self.assertIn("shared between", body) + self.assertIn("& me", body) + self.assertNotIn("created by", body) + + def test_owner_view_just_me_when_no_recipients(self): + """Owner with no recipients: 'just me, …' (old behavior).""" + self.client.force_login(self.owner) + body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode() + self.assertIn("just me,", body) + self.assertNotIn("created by", body) diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 4655355..8b945ab 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -285,9 +285,31 @@ def view_post(request, post_id): Brief.objects.filter( owner=request.user, post=our_post, is_unread=True, ).update(is_unread=False) + + # Header-prose branching: post.html shows different self/shared lines + # depending on whether the viewer IS the owner. The invitee branch + # ("shared with me, @viewer …" + "created by @owner …") only kicks + # in when (a) the viewer is authenticated AND (b) the post has an + # owner AND (c) the viewer is NOT that owner. Ownerless posts and + # anonymous viewers fall through to the owner-style rendering (which + # handles missing data gracefully via the at_handle/display_name + # filter guards). + is_real_invitee = ( + request.user.is_authenticated + and our_post.owner is not None + and request.user != our_post.owner + ) + viewer_is_owner = not is_real_invitee + if is_real_invitee: + other_recipients = our_post.shared_with.exclude(pk=request.user.pk) + else: + other_recipients = our_post.shared_with.all() + return render(request, "apps/billboard/post.html", { "post": our_post, "form": form, + "viewer_is_owner": viewer_is_owner, + "other_recipients": other_recipients, "page_class": "page-billpost", }) diff --git a/src/apps/lyric/templatetags/lyric_extras.py b/src/apps/lyric/templatetags/lyric_extras.py index 88169c7..d37e0f2 100644 --- a/src/apps/lyric/templatetags/lyric_extras.py +++ b/src/apps/lyric/templatetags/lyric_extras.py @@ -43,7 +43,11 @@ def relative_ts(dt): @register.filter def display_name(user): - if user is None: + # `getattr` guards: AnonymousUser has no `.email` attribute and is not + # None, so `if user is None` doesn't catch it. Render anonymous viewers + # as empty rather than crashing — the legacy ownerless-post path + # (Percival ch. 18) shows post detail to anon visitors. + if user is None or not getattr(user, "is_authenticated", False): return "" if user.username: return user.username @@ -55,8 +59,9 @@ def at_handle(user): """`@username` when the user has set one; falls back to the truncated email otherwise (no `@` prefix on bare emails since the address itself already carries the `@`). Used in post.html to colour usernames in the - --quaUser palette key while leaving emails as-is.""" - if user is None: + --quaUser palette key while leaving emails as-is. Anonymous viewers + render as empty (legacy ownerless-post path).""" + if user is None or not getattr(user, "is_authenticated", False): return "" if user.username: return f"@{user.username}" diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html index 1dd3f20..abccf5f 100644 --- a/src/templates/apps/billboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -14,14 +14,27 @@

{{ post.title }}

- {% with recipients=post.shared_with.all %} - {% if recipients %} -

shared between {% for r in recipients %}{{ r|at_handle }}{% if not forloop.last %}, {% endif %}{% endfor %}

+ {% if viewer_is_owner %} + {# Owner viewing — owner-centric prose. "shared between" lists #} + {# every recipient; the self line is the owner's own handle. #} + {% if other_recipients %} +

shared between {% for r in other_recipients %}{{ r|at_handle }}{% if not forloop.last %}, {% endif %}{% endfor %}

& me, {{ post.owner|at_handle }} the {{ post.owner.active_title_display }}

{% else %}

just me, {{ post.owner|at_handle }} the {{ post.owner.active_title_display }}

{% endif %} - {% endwith %} + {% else %} + {# Invitee viewing — "shared with" prose centred on the viewer #} + {# (request.user). Sole invitee collapses to a single line; the #} + {# "created by …" line attributes the post to its founder. #} + {% if other_recipients %} +

shared with {% for r in other_recipients %}{{ r|at_handle }}{% if not forloop.last %}, {% endif %}{% endfor %}

+

& me, {{ request.user|at_handle }} the {{ request.user.active_title_display }}

+ {% else %} +

shared with me, {{ request.user|at_handle }} the {{ request.user.active_title_display }}

+ {% endif %} +

created by {{ post.owner|at_handle }} the {{ post.owner.active_title_display }}

+ {% endif %}