From 6a7464ee4b43dcbb35426ee76aaca37209fb7e41 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 12 May 2026 22:26:12 -0400 Subject: [PATCH] =?UTF-8?q?post.html:=20gear-btn=20+=20#id=5Fpost=5Fmenu?= =?UTF-8?q?=20(NVM=20/=20DEL=20/=20BYE)=20mirror=20room.html's=20#id=5Froo?= =?UTF-8?q?m=5Fmenu=20=E2=80=94=20all=20Posts=20get=20the=20gear=20w.=20NV?= =?UTF-8?q?M=20(=E2=86=92=20billboard:my=5Fposts);=20user-Posts=20(kind=3D?= =?UTF-8?q?USER=5FPOST=20/=20SHARE=5FINVITE)=20additionally=20surface=20DE?= =?UTF-8?q?L=20for=20the=20author=20(POST=20=E2=86=92=20billboard:delete?= =?UTF-8?q?=5Fpost=20=E2=86=92=20hard-deletes=20the=20Post;=20cascades=20L?= =?UTF-8?q?ines=20via=20FK=20+=20clears=20shared=5Fwith=20M2M)=20and=20BYE?= =?UTF-8?q?=20for=20invitees=20(POST=20=E2=86=92=20billboard:abandon=5Fpos?= =?UTF-8?q?t=20=E2=86=92=20removes=20request.user=20from=20post.shared=5Fw?= =?UTF-8?q?ith;=20owner=20+=20other=20invitees=20keep=20the=20thread);=20a?= =?UTF-8?q?dmin-Posts=20(kind=3DNOTE=5FUNLOCK)=20intentionally=20render=20?= =?UTF-8?q?gear=20w.=20NVM=20only=20since=20the=20system=20thread=20isn't?= =?UTF-8?q?=20user-owned=20(defence-in-depth:=20both=20delete=5Fpost=20+?= =?UTF-8?q?=20abandon=5Fpost=20no-op=20on=20NOTE=5FUNLOCK=20so=20a=20forge?= =?UTF-8?q?d=20POST=20can't=20bypass=20the=20menu's=20branch);=20`=5Fpost?= =?UTF-8?q?=5Fgear.html`=20partial=20gates=20DEL/BYE=20on=20`viewer=5Fis?= =?UTF-8?q?=5Fowner`=20(set=20by=20view=5Fpost=20since=20the=20buds=20spri?= =?UTF-8?q?nt)=20+=20post.kind,=20then=20includes=20the=20shared=20`apps/a?= =?UTF-8?q?pplets/=5Fpartials/=5Fgear.html`=20btn;=20styling=20rides=20the?= =?UTF-8?q?=20existing=20applets.scss=20page-level=20pattern=20=E2=80=94?= =?UTF-8?q?=20`.post-page`=20joins=20`.billboard-page=20/=20.room-page=20/?= =?UTF-8?q?=20.dashboard-page=20/=20.wallet-page=20/=20.gameboard-page=20/?= =?UTF-8?q?=20.billscroll-page`=20in=20the=20`>=20.gear-btn=20{=20position?= =?UTF-8?q?:=20fixed;=20bottom:=204.2rem;=20right:=200.5rem=20}`=20rule=20?= =?UTF-8?q?(and=20the=20landscape=20footer-sidebar=20centred=20variant),?= =?UTF-8?q?=20`#id=5Fpost=5Fmenu`=20joins=20the=20`%applet-menu`=20extensi?= =?UTF-8?q?on=20list=20+=20the=20page-level=20fixed-menu=20rule=20(`bottom?= =?UTF-8?q?:=206.6rem;=20right:=201rem`);=205=20FTs=20in=20test=5Fbill=5Fp?= =?UTF-8?q?ost=5Fgear.py=20(owner=20DEL=20flow,=20invitee=20BYE=20flow,=20?= =?UTF-8?q?3=20menu-shape=20assertions=20for=20owner/invitee/admin)=20+=20?= =?UTF-8?q?11=20ITs=20across=20DeletePostViewTest=20+=20AbandonPostViewTes?= =?UTF-8?q?t=20(302=20redirect=20target,=20side=20effect,=20GET-is-no-op,?= =?UTF-8?q?=20non-owner=20/=20non-invitee=20/=20NOTE=5FUNLOCK=20protection?= =?UTF-8?q?)=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 --- .../billboard/tests/integrated/test_views.py | 121 ++++++++++++++ src/apps/billboard/urls.py | 2 + src/apps/billboard/views.py | 18 +++ src/functional_tests/test_bill_post_gear.py | 150 ++++++++++++++++++ src/static_src/scss/_applets.scss | 5 + .../apps/billboard/_partials/_post_gear.html | 22 +++ src/templates/apps/billboard/post.html | 4 + 7 files changed, 322 insertions(+) create mode 100644 src/functional_tests/test_bill_post_gear.py create mode 100644 src/templates/apps/billboard/_partials/_post_gear.html diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 0d2938d..f59ff06 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -481,3 +481,124 @@ class PostLineRelativeTimestampTest(TestCase): response.content.decode(), r'class="post-line-time"[^>]*>\s*\d{2}\s\w{3}\s*<', ) + + +class DeletePostViewTest(TestCase): + """billboard:delete_post — owner can hard-delete; non-owners are no-op; + note_unlock Posts are protected (defence-in-depth alongside the menu + branch that doesn't render DEL on admin-Posts).""" + + def setUp(self): + from apps.billboard.models import Line, Post + self.Post = Post + self.owner = User.objects.create(email="del-owner@test.io") + self.other = User.objects.create(email="del-other@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="X", + ) + Line.objects.create(post=self.post, text="x", author=self.owner) + + def test_owner_post_redirects_to_my_posts(self): + self.client.force_login(self.owner) + response = self.client.post( + reverse("billboard:delete_post", args=[self.post.id]) + ) + self.assertRedirects( + response, + reverse("billboard:my_posts", args=[self.owner.id]), + fetch_redirect_response=False, + ) + + def test_owner_post_deletes_post(self): + self.client.force_login(self.owner) + self.client.post(reverse("billboard:delete_post", args=[self.post.id])) + self.assertFalse(self.Post.objects.filter(id=self.post.id).exists()) + + def test_non_owner_cannot_delete(self): + self.client.force_login(self.other) + self.client.post(reverse("billboard:delete_post", args=[self.post.id])) + self.assertTrue(self.Post.objects.filter(id=self.post.id).exists()) + + def test_get_does_not_delete(self): + self.client.force_login(self.owner) + self.client.get(reverse("billboard:delete_post", args=[self.post.id])) + self.assertTrue(self.Post.objects.filter(id=self.post.id).exists()) + + def test_note_unlock_post_is_protected(self): + # Even the owner can't DEL a system thread — the gear menu doesn't + # render DEL for note_unlock, but the view is hardened in case the + # POST is forged. + admin_post = self.Post.objects.create( + owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes", + ) + self.client.force_login(self.owner) + self.client.post(reverse("billboard:delete_post", args=[admin_post.id])) + self.assertTrue(self.Post.objects.filter(id=admin_post.id).exists()) + + +class AbandonPostViewTest(TestCase): + """billboard:abandon_post — invitee removes themselves from + post.shared_with; owner unaffected; other invitees unaffected; admin + Posts protected from BYE.""" + + def setUp(self): + from apps.billboard.models import Line, Post + self.Post = Post + self.owner = User.objects.create(email="abandon-owner@test.io") + self.invitee = User.objects.create(email="abandon-invitee@test.io") + self.other = User.objects.create(email="abandon-other@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="Shared", + ) + Line.objects.create(post=self.post, text="x", author=self.owner) + self.post.shared_with.add(self.invitee, self.other) + + def test_invitee_redirects_to_my_posts(self): + self.client.force_login(self.invitee) + response = self.client.post( + reverse("billboard:abandon_post", args=[self.post.id]) + ) + self.assertRedirects( + response, + reverse("billboard:my_posts", args=[self.invitee.id]), + fetch_redirect_response=False, + ) + + def test_invitee_is_removed_from_shared_with(self): + self.client.force_login(self.invitee) + self.client.post(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertNotIn(self.invitee, self.post.shared_with.all()) + + def test_post_survives_invitee_abandonment(self): + self.client.force_login(self.invitee) + self.client.post(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertEqual(self.post.owner, self.owner) + self.assertIn(self.other, self.post.shared_with.all()) + + def test_get_does_not_remove(self): + self.client.force_login(self.invitee) + self.client.get(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertIn(self.invitee, self.post.shared_with.all()) + + def test_non_invitee_post_is_no_op(self): + random_user = User.objects.create(email="random@test.io") + self.client.force_login(random_user) + self.client.post(reverse("billboard:abandon_post", args=[self.post.id])) + self.post.refresh_from_db() + self.assertIn(self.invitee, self.post.shared_with.all()) + self.assertIn(self.other, self.post.shared_with.all()) + + def test_note_unlock_post_is_protected(self): + # Admin Posts have no recipients to begin with, but harden the view + # so a forged BYE can't strip shared_with anyway. + admin_post = self.Post.objects.create( + owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes", + ) + admin_post.shared_with.add(self.invitee) + self.client.force_login(self.invitee) + self.client.post(reverse("billboard:abandon_post", args=[admin_post.id])) + admin_post.refresh_from_db() + self.assertIn(self.invitee, admin_post.shared_with.all()) diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 5035368..460edfe 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -17,6 +17,8 @@ urlpatterns = [ path("new-post", views.new_post, name="new_post"), path("post//", views.view_post, name="view_post"), path("post//share-post", views.share_post, name="share_post"), + path("post//delete", views.delete_post, name="delete_post"), + path("post//abandon", views.abandon_post, name="abandon_post"), path("users//", views.my_posts, name="my_posts"), path("my-buds/", views.my_buds, name="my_buds"), path("buds/add", views.add_bud, name="add_bud"), diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index b9e389b..b0ee6d5 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -328,6 +328,24 @@ def my_posts(request, user_id): }) +@login_required +def delete_post(request, post_id): + if request.method == "POST": + post = Post.objects.get(id=post_id) + if request.user == post.owner and post.kind != Post.KIND_NOTE_UNLOCK: + post.delete() + return redirect("billboard:my_posts", user_id=request.user.id) + + +@login_required +def abandon_post(request, post_id): + if request.method == "POST": + post = Post.objects.get(id=post_id) + if post.kind != Post.KIND_NOTE_UNLOCK: + post.shared_with.remove(request.user) + return redirect("billboard:my_posts", user_id=request.user.id) + + def share_post(request, post_id): our_post = Post.objects.get(id=post_id) is_ajax = "application/json" in request.headers.get("Accept", "") diff --git a/src/functional_tests/test_bill_post_gear.py b/src/functional_tests/test_bill_post_gear.py new file mode 100644 index 0000000..5a53b84 --- /dev/null +++ b/src/functional_tests/test_bill_post_gear.py @@ -0,0 +1,150 @@ +"""FT — post.html gear menu (NVM / DEL / BYE). + +User-authored Posts grow a #id_gear_btn that opens #id_post_menu with: + - NVM (everyone) → navigates to billboard:my_posts + - DEL (owner) → hard-deletes the Post + - BYE (invitee) → removes the viewer from post.shared_with + +Admin-Posts (kind=NOTE_UNLOCK) get the gear menu too but with NVM only — +DEL + BYE don't apply to system-authored threads. + +Mirrors apps/gameboard/_partials/_room_gear.html behaviour; see +test_game_room_gatekeeper.py {owner_can_delete_room, gamer_can_abandon_room} +for the room-side analogues. +""" +from selenium.webdriver.common.by import By + +from apps.billboard.models import Line, Post +from apps.drama.models import Note +from apps.lyric.models import User + +from .base import FunctionalTest + + +class OwnerPostGearTest(FunctionalTest): + """Owner of a user-Post sees NVM + DEL in the gear menu; DEL nukes + the Post and redirects to /billboard/users//.""" + + def setUp(self): + super().setUp() + self.owner = User.objects.create(email="owner@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="Doomed post", + ) + Line.objects.create(post=self.post, text="line 1", author=self.owner) + self.create_pre_authenticated_session("owner@test.io") + + def test_owner_can_delete_post_via_gear_menu(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_post_menu .btn-danger" + ) + ).click() + self.confirm_guard() + + self.wait_for(lambda: self.assertIn( + f"/billboard/users/{self.owner.id}/", self.browser.current_url + )) + self.assertFalse(Post.objects.filter(id=self.post.id).exists()) + + def test_gear_menu_for_owner_shows_NVM_and_DEL_but_not_BYE(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + menu = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_post_menu") + ) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-cancel")) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")) + + +class InviteePostGearTest(FunctionalTest): + """Invitee on a user-Post sees NVM + BYE; BYE removes the viewer + from post.shared_with (owner + other invitees keep the Post).""" + + def setUp(self): + super().setUp() + self.owner = User.objects.create(email="owner@test.io") + self.invitee = User.objects.create(email="invitee@test.io") + self.other = User.objects.create(email="other@test.io") + self.post = Post.objects.create( + owner=self.owner, kind=Post.KIND_USER_POST, title="Shared post", + ) + Line.objects.create(post=self.post, text="line 1", author=self.owner) + self.post.shared_with.add(self.invitee, self.other) + self.create_pre_authenticated_session("invitee@test.io") + + def test_invitee_can_abandon_post_via_gear_menu(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_post_menu .btn-abandon" + ) + ).click() + self.confirm_guard() + + self.wait_for(lambda: self.assertIn( + f"/billboard/users/{self.invitee.id}/", self.browser.current_url + )) + self.post.refresh_from_db() + self.assertNotIn(self.invitee, self.post.shared_with.all()) + # Post survives — owner + other invitee unaffected + self.assertEqual(self.post.owner, self.owner) + self.assertIn(self.other, self.post.shared_with.all()) + + def test_gear_menu_for_invitee_shows_NVM_and_BYE_but_not_DEL(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + menu = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_post_menu") + ) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-cancel")) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")) + + +class AdminPostGearTest(FunctionalTest): + """Admin-Post (kind=NOTE_UNLOCK) shows the gear menu with NVM only — + DEL + BYE don't apply to system-authored threads.""" + + def setUp(self): + super().setUp() + self.gamer = User.objects.create(email="gamer@test.io") + Note.grant_if_new(self.gamer, "stargazer") + self.admin_post = Post.objects.get( + owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK, + ) + self.create_pre_authenticated_session("gamer@test.io") + + def test_admin_post_gear_menu_has_NVM_but_no_DEL_no_BYE(self): + self.browser.get( + self.live_server_url + f"/billboard/post/{self.admin_post.id}/" + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gear-btn") + ).click() + menu = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_post_menu") + ) + self.assertTrue(menu.find_elements(By.CSS_SELECTOR, ".btn-cancel")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")) + self.assertFalse(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")) diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index f8d4795..d0a8a95 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -82,6 +82,7 @@ #id_game_kit_menu { @extend %applet-menu; } #id_wallet_applet_menu { @extend %applet-menu; } #id_room_menu { @extend %applet-menu; } +#id_post_menu { @extend %applet-menu; } #id_billboard_applet_menu { @extend %applet-menu; } #id_billscroll_menu { @extend %applet-menu; } @@ -90,6 +91,7 @@ .dashboard-page, .wallet-page, .room-page, +.post-page, .billboard-page, .billscroll-page { > .gear-btn { @@ -104,6 +106,7 @@ #id_game_applet_menu, #id_game_kit_menu, #id_wallet_applet_menu, +#id_post_menu, #id_billboard_applet_menu, #id_billscroll_menu { position: fixed; @@ -121,6 +124,7 @@ .dashboard-page, .wallet-page, .room-page, + .post-page, .billboard-page, .billscroll-page { > .gear-btn { @@ -135,6 +139,7 @@ #id_game_kit_menu, #id_wallet_applet_menu, #id_room_menu, + #id_post_menu, #id_billboard_applet_menu, #id_billscroll_menu { right: calc((var(--sidebar-w) - 3rem) / 2); diff --git a/src/templates/apps/billboard/_partials/_post_gear.html b/src/templates/apps/billboard/_partials/_post_gear.html new file mode 100644 index 0000000..f4a9de1 --- /dev/null +++ b/src/templates/apps/billboard/_partials/_post_gear.html @@ -0,0 +1,22 @@ +{# Gear menu on post.html — mirrors apps/gameboard/_partials/_room_gear.html. #} +{# All Posts get the gear w. NVM (back to my_posts). #} +{# User-Posts add DEL (owner) or BYE (invitee). #} +{# Admin-Posts (kind=NOTE_UNLOCK) intentionally have NVM only — DEL + BYE #} +{# don't apply to system-authored threads. #} + +{% include "apps/applets/_partials/_gear.html" with menu_id="id_post_menu" %} diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html index 1c8770c..a1f1a64 100644 --- a/src/templates/apps/billboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -88,6 +88,10 @@ {% if post.kind != 'note_unlock' %} {% include "apps/billboard/_partials/_bud_panel.html" %} {% endif %} + + {# Gear btn (bottom-right) + menu — NVM always; DEL (owner) / BYE #} + {# (invitee) gated to user-Posts. #} + {% include "apps/billboard/_partials/_post_gear.html" %} {% endblock content %}