post.html header prose branches on viewer-vs-owner: invitees see "shared with me, @viewer the {title}" + "created by @owner the {title}" instead of the owner-centric "just me / shared between" lines; owner view unchanged — TDD
- 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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
@@ -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)
|
||||||
@@ -285,9 +285,31 @@ def view_post(request, post_id):
|
|||||||
Brief.objects.filter(
|
Brief.objects.filter(
|
||||||
owner=request.user, post=our_post, is_unread=True,
|
owner=request.user, post=our_post, is_unread=True,
|
||||||
).update(is_unread=False)
|
).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", {
|
return render(request, "apps/billboard/post.html", {
|
||||||
"post": our_post,
|
"post": our_post,
|
||||||
"form": form,
|
"form": form,
|
||||||
|
"viewer_is_owner": viewer_is_owner,
|
||||||
|
"other_recipients": other_recipients,
|
||||||
"page_class": "page-billpost",
|
"page_class": "page-billpost",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ def relative_ts(dt):
|
|||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def display_name(user):
|
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 ""
|
return ""
|
||||||
if user.username:
|
if user.username:
|
||||||
return 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
|
"""`@username` when the user has set one; falls back to the truncated
|
||||||
email otherwise (no `@` prefix on bare emails since the address itself
|
email otherwise (no `@` prefix on bare emails since the address itself
|
||||||
already carries the `@`). Used in post.html to colour usernames in the
|
already carries the `@`). Used in post.html to colour usernames in the
|
||||||
--quaUser palette key while leaving emails as-is."""
|
--quaUser palette key while leaving emails as-is. Anonymous viewers
|
||||||
if user is None:
|
render as empty (legacy ownerless-post path)."""
|
||||||
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
return ""
|
return ""
|
||||||
if user.username:
|
if user.username:
|
||||||
return f"@{user.username}"
|
return f"@{user.username}"
|
||||||
|
|||||||
@@ -14,14 +14,27 @@
|
|||||||
<div class="post-page">
|
<div class="post-page">
|
||||||
<header class="post-header">
|
<header class="post-header">
|
||||||
<h3 class="post-title">{{ post.title }}</h3>
|
<h3 class="post-title">{{ post.title }}</h3>
|
||||||
{% with recipients=post.shared_with.all %}
|
{% if viewer_is_owner %}
|
||||||
{% if recipients %}
|
{# Owner viewing — owner-centric prose. "shared between" lists #}
|
||||||
<p class="post-shared-recipients">shared between {% for r in recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
{# every recipient; the self line is the owner's own handle. #}
|
||||||
|
{% if other_recipients %}
|
||||||
|
<p class="post-shared-recipients">shared between {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||||
<p class="post-shared-self">& me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
<p class="post-shared-self">& me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
<p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
|
<p class="post-shared-recipients">shared with {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||||
|
<p class="post-shared-self">& me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
|
||||||
|
{% else %}
|
||||||
|
<p class="post-shared-self">shared with me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="post-created-by">created by <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul id="id_post_table" class="post-lines">
|
<ul id="id_post_table" class="post-lines">
|
||||||
|
|||||||
Reference in New Issue
Block a user