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 @@
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 %}