buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
- applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
- billboard.views.my_buddies + add_buddy:
• my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
• add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
- templates:
• _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
• my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
• _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
- SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
- tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
- 841 ITs (+14) + 4 my_buddies FTs green.
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:
@@ -0,0 +1,41 @@
|
|||||||
|
"""Rename the billboard `my-contacts` applet to `my-buddies` (slug + name).
|
||||||
|
|
||||||
|
User.buddies M2M (lyric/0004) lands at the same time; the applet links
|
||||||
|
to the new /billboard/my-buddies/ page where the user manages their
|
||||||
|
buddy list. "Contacts" was a placeholder name from the original
|
||||||
|
billboard scaffold.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-contacts")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-buddies"
|
||||||
|
applet.name = "My Buddies"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-buddies")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-contacts"
|
||||||
|
applet.name = "Contacts"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0005_seed_pronouns_applet"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, backward),
|
||||||
|
]
|
||||||
143
src/apps/billboard/tests/integrated/test_buddies.py
Normal file
143
src/apps/billboard/tests/integrated/test_buddies.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""ITs for the My Buddies feature (User.buddies M2M + my_buddies view +
|
||||||
|
add_buddy JSON endpoint).
|
||||||
|
|
||||||
|
User.buddies is a self M2M (symmetrical=False) — adding Alice to Disco's
|
||||||
|
list does NOT auto-reciprocate. Implicit auto-add on shared events
|
||||||
|
(post-share, gate-invite) is layered separately in those views.
|
||||||
|
|
||||||
|
Privacy: add_buddy returns 200 with {buddy: null} when the email is
|
||||||
|
unregistered, so the response shape never leaks membership.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserBuddiesM2MTest(TestCase):
|
||||||
|
"""The buddies field is asymmetric — A.buddies.add(B) doesn't
|
||||||
|
reciprocate to B.buddies, only to B.added_as_buddy."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.disco = User.objects.create(email="disco@test.io")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io")
|
||||||
|
|
||||||
|
def test_add_buddy_one_way(self):
|
||||||
|
self.disco.buddies.add(self.alice)
|
||||||
|
self.assertIn(self.alice, self.disco.buddies.all())
|
||||||
|
self.assertNotIn(self.disco, self.alice.buddies.all())
|
||||||
|
|
||||||
|
def test_added_as_buddy_reverse_relation(self):
|
||||||
|
self.disco.buddies.add(self.alice)
|
||||||
|
self.assertIn(self.disco, self.alice.added_as_buddy.all())
|
||||||
|
|
||||||
|
def test_add_is_idempotent(self):
|
||||||
|
self.disco.buddies.add(self.alice)
|
||||||
|
self.disco.buddies.add(self.alice)
|
||||||
|
self.assertEqual(self.disco.buddies.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class MyBuddiesViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.user.buddies.add(self.alice, self.bob)
|
||||||
|
|
||||||
|
def test_my_buddies_renders_template(self):
|
||||||
|
response = self.client.get(reverse("billboard:my_buddies"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/my_buddies.html")
|
||||||
|
|
||||||
|
def test_my_buddies_lists_users_buddies(self):
|
||||||
|
response = self.client.get(reverse("billboard:my_buddies"))
|
||||||
|
buddies = list(response.context["buddies"])
|
||||||
|
self.assertIn(self.alice, buddies)
|
||||||
|
self.assertIn(self.bob, buddies)
|
||||||
|
|
||||||
|
def test_my_buddies_does_not_list_others_buddies(self):
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
carol = User.objects.create(email="carol@test.io", username="carol")
|
||||||
|
other.buddies.add(carol)
|
||||||
|
response = self.client.get(reverse("billboard:my_buddies"))
|
||||||
|
self.assertNotIn(carol, list(response.context["buddies"]))
|
||||||
|
|
||||||
|
def test_my_buddies_redirects_anon_to_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse("billboard:my_buddies"))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class AddBuddyViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_add_registered_email_adds_to_buddies(self):
|
||||||
|
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_buddy"),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(alice, self.user.buddies.all())
|
||||||
|
|
||||||
|
def test_add_returns_buddy_payload_with_username(self):
|
||||||
|
User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_buddy"),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
self.assertIsNotNone(body["buddy"])
|
||||||
|
self.assertEqual(body["buddy"]["username"], "alice")
|
||||||
|
|
||||||
|
def test_add_unregistered_email_returns_null_buddy(self):
|
||||||
|
"""Privacy: 200 with buddy=null so the response shape doesn't leak
|
||||||
|
whether the address is on the system."""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_buddy"),
|
||||||
|
data={"recipient": "ghost@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.json()["buddy"])
|
||||||
|
self.assertEqual(self.user.buddies.count(), 0)
|
||||||
|
|
||||||
|
def test_add_own_email_is_silent_noop(self):
|
||||||
|
"""Adding yourself: no buddy added, response carries buddy=null."""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_buddy"),
|
||||||
|
data={"recipient": "me@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.json()["buddy"])
|
||||||
|
self.assertNotIn(self.user, self.user.buddies.all())
|
||||||
|
|
||||||
|
def test_add_existing_buddy_is_idempotent(self):
|
||||||
|
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.user.buddies.add(alice)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_buddy"),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# Still only one buddy entry — M2M dedup
|
||||||
|
self.assertEqual(self.user.buddies.count(), 1)
|
||||||
|
# Response still carries the buddy payload (so the JS can refresh
|
||||||
|
# an entry if a fast double-click bypassed the data-buddy-id guard).
|
||||||
|
self.assertIsNotNone(response.json()["buddy"])
|
||||||
|
|
||||||
|
def test_add_falls_back_to_email_when_no_username(self):
|
||||||
|
"""Buddy payload returns email when buddy.username is None — display
|
||||||
|
layer matches the navbar fallback (display_name filter)."""
|
||||||
|
User.objects.create(email="anon@test.io")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_buddy"),
|
||||||
|
data={"recipient": "anon@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.json()["buddy"]["username"], "anon@test.io")
|
||||||
|
|
||||||
|
def test_get_returns_405(self):
|
||||||
|
response = self.client.get(reverse("billboard:add_buddy"))
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
@@ -13,7 +13,7 @@ from apps.lyric.models import User
|
|||||||
def _seed_billboard_applets():
|
def _seed_billboard_applets():
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buddies", "My Buddies", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
@@ -37,7 +37,7 @@ class BillboardViewTest(TestCase):
|
|||||||
self.assertIn("applets", response.context)
|
self.assertIn("applets", response.context)
|
||||||
slugs = [e["applet"].slug for e in response.context["applets"]]
|
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||||
self.assertIn("my-scrolls", slugs)
|
self.assertIn("my-scrolls", slugs)
|
||||||
self.assertIn("my-contacts", slugs)
|
self.assertIn("my-buddies", slugs)
|
||||||
self.assertIn("most-recent-scroll", slugs)
|
self.assertIn("most-recent-scroll", slugs)
|
||||||
|
|
||||||
def test_passes_my_rooms_context(self):
|
def test_passes_my_rooms_context(self):
|
||||||
@@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
from apps.applets.models import UserApplet
|
from apps.applets.models import UserApplet
|
||||||
contacts = Applet.objects.get(slug="my-contacts")
|
contacts = Applet.objects.get(slug="my-buddies")
|
||||||
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
||||||
self.assertFalse(ua.visible)
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
reverse("billboard:toggle_applets"),
|
reverse("billboard:toggle_applets"),
|
||||||
{"applets": [
|
{"applets": [
|
||||||
"my-scrolls",
|
"my-scrolls",
|
||||||
"my-contacts",
|
"my-buddies",
|
||||||
"most-recent-scroll",
|
"most-recent-scroll",
|
||||||
]},
|
]},
|
||||||
HTTP_HX_REQUEST="true",
|
HTTP_HX_REQUEST="true",
|
||||||
@@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
|
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
|
||||||
|
|
||||||
def test_second_toggle_preserves_prior_hidden_state(self):
|
def test_second_toggle_preserves_prior_hidden_state(self):
|
||||||
# First toggle: hide Contacts only.
|
# First toggle: hide My Buddies only.
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("billboard:toggle_applets"),
|
reverse("billboard:toggle_applets"),
|
||||||
{"applets": [
|
{"applets": [
|
||||||
@@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
]},
|
]},
|
||||||
HTTP_HX_REQUEST="true",
|
HTTP_HX_REQUEST="true",
|
||||||
)
|
)
|
||||||
# Second toggle: hide Most Recent Scroll additionally — Contacts must stay hidden.
|
# Second toggle: hide Most Recent Scroll additionally — My Buddies must stay hidden.
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("billboard:toggle_applets"),
|
reverse("billboard:toggle_applets"),
|
||||||
{"applets": [
|
{"applets": [
|
||||||
@@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
HTTP_HX_REQUEST="true",
|
HTTP_HX_REQUEST="true",
|
||||||
)
|
)
|
||||||
from apps.applets.models import UserApplet
|
from apps.applets.models import UserApplet
|
||||||
contacts = Applet.objects.get(slug="my-contacts")
|
contacts = Applet.objects.get(slug="my-buddies")
|
||||||
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ urlpatterns = [
|
|||||||
path("post/<uuid:post_id>/", views.view_post, name="view_post"),
|
path("post/<uuid:post_id>/", views.view_post, name="view_post"),
|
||||||
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
|
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
|
||||||
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
||||||
|
path("my-buddies/", views.my_buddies, name="my_buddies"),
|
||||||
|
path("buddies/add", views.add_buddy, name="add_buddy"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -373,6 +373,46 @@ def share_post(request, post_id):
|
|||||||
return redirect(our_post)
|
return redirect(our_post)
|
||||||
|
|
||||||
|
|
||||||
|
# ── My Buddies ────────────────────────────────────────────────────────────
|
||||||
|
# User.buddies is an asymmetric self M2M (lyric/0004). `my_buddies` is the
|
||||||
|
# manage-page; `add_buddy` is the JSON endpoint hit by the buddy panel slide-
|
||||||
|
# out. Privacy: when an entered email isn't a registered User, we 200 with
|
||||||
|
# {buddy: null} so the response shape doesn't leak membership.
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def my_buddies(request):
|
||||||
|
return render(request, "apps/billboard/my_buddies.html", {
|
||||||
|
"buddies": request.user.buddies.all(),
|
||||||
|
"page_class": "page-billbuddies",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def add_buddy(request):
|
||||||
|
if request.method != "POST":
|
||||||
|
from django.http import HttpResponseNotAllowed
|
||||||
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
|
||||||
|
email = (request.POST.get("recipient") or "").strip()
|
||||||
|
|
||||||
|
buddy = None
|
||||||
|
try:
|
||||||
|
candidate = User.objects.get(email=email)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
candidate = None
|
||||||
|
|
||||||
|
if candidate is not None and candidate != request.user:
|
||||||
|
if candidate not in request.user.buddies.all():
|
||||||
|
request.user.buddies.add(candidate)
|
||||||
|
buddy = {
|
||||||
|
"id": str(candidate.id),
|
||||||
|
"username": candidate.username or candidate.email,
|
||||||
|
"email": candidate.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({"buddy": buddy})
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def save_scroll_position(request, room_id):
|
def save_scroll_position(request, room_id):
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
|
|||||||
20
src/apps/lyric/migrations/0004_user_buddies.py
Normal file
20
src/apps/lyric/migrations/0004_user_buddies.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("lyric", "0003_seed_adman"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="buddies",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="added_as_buddy",
|
||||||
|
to="lyric.user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -113,6 +113,14 @@ class User(AbstractBaseUser):
|
|||||||
unlocked_decks = models.ManyToManyField(
|
unlocked_decks = models.ManyToManyField(
|
||||||
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
||||||
)
|
)
|
||||||
|
# Asymmetric self M2M — `user.buddies.all()` = people I've explicitly
|
||||||
|
# added (or implicitly via post-share / game-invite, which auto-adds
|
||||||
|
# the recipient to the inviter's buddies list). `user.added_as_buddy`
|
||||||
|
# = the inverse (people who have me in their buddies list); useful
|
||||||
|
# for the future "buddy changed username" snapshot-accept flow.
|
||||||
|
buddies = models.ManyToManyField(
|
||||||
|
"self", symmetrical=False, blank=True, related_name="added_as_buddy",
|
||||||
|
)
|
||||||
active_title = models.ForeignKey(
|
active_title = models.ForeignKey(
|
||||||
"drama.Note", null=True, blank=True,
|
"drama.Note", null=True, blank=True,
|
||||||
on_delete=models.SET_NULL, related_name="+",
|
on_delete=models.SET_NULL, related_name="+",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buddies", "My Buddies", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
@@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest):
|
|||||||
class BillboardAppletsTest(FunctionalTest):
|
class BillboardAppletsTest(FunctionalTest):
|
||||||
"""
|
"""
|
||||||
FT: billboard page renders three applets in the grid — My Scrolls,
|
FT: billboard page renders three applets in the grid — My Scrolls,
|
||||||
My Contacts, and Most Recent Scroll — with a functioning gear menu.
|
My Buddies, and Most Recent Scroll — with a functioning gear menu.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buddies", "My Buddies", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
@@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||||
)
|
)
|
||||||
self.browser.find_element(By.ID, "id_applet_my_contacts")
|
self.browser.find_element(By.ID, "id_applet_my_buddies")
|
||||||
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
||||||
|
|
||||||
def test_billboard_my_scrolls_lists_rooms(self):
|
def test_billboard_my_scrolls_lists_rooms(self):
|
||||||
@@ -278,7 +278,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
||||||
)
|
)
|
||||||
contacts_cb = menu.find_element(
|
contacts_cb = menu.find_element(
|
||||||
By.CSS_SELECTOR, "input[value='my-contacts']"
|
By.CSS_SELECTOR, "input[value='my-buddies']"
|
||||||
)
|
)
|
||||||
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
||||||
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||||
@@ -286,7 +286,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
|
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertEqual(
|
lambda: self.assertEqual(
|
||||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -305,7 +305,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
# The freshly-rendered menu must reflect DB state (Contacts unchecked)
|
# The freshly-rendered menu must reflect DB state (Contacts unchecked)
|
||||||
contacts_cb = menu.find_element(
|
contacts_cb = menu.find_element(
|
||||||
By.CSS_SELECTOR, "input[value='my-contacts']"
|
By.CSS_SELECTOR, "input[value='my-buddies']"
|
||||||
)
|
)
|
||||||
self.assertFalse(contacts_cb.is_selected())
|
self.assertFalse(contacts_cb.is_selected())
|
||||||
most_recent_scroll_cb = menu.find_element(
|
most_recent_scroll_cb = menu.find_element(
|
||||||
@@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.browser.find_elements(By.ID, "id_applet_my_contacts"),
|
self.browser.find_elements(By.ID, "id_applet_my_buddies"),
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class PronounsAppletFlowTest(FunctionalTest):
|
|||||||
# Billboard applets — page renders blank without these
|
# Billboard applets — page renders blank without these
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-contacts", "Contacts", 4, 3),
|
("my-buddies", "My Buddies", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||||
]:
|
]:
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
|
|||||||
75
src/functional_tests/test_my_buddies.py
Normal file
75
src/functional_tests/test_my_buddies.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""FT for the My Buddies page — buddy btn + slide-out add flow.
|
||||||
|
|
||||||
|
Phase 1 of the buddies sprint: explicit add via my_buddies.html. Phase 2
|
||||||
|
will layer autocomplete (sky-place-style top-3 username suggestions) and
|
||||||
|
implicit auto-add on post-share / gate-invite.
|
||||||
|
"""
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
class MyBuddiesPageTest(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.create_pre_authenticated_session("me@test.io")
|
||||||
|
|
||||||
|
def test_renders_existing_buddies(self):
|
||||||
|
"""Pre-existing buddies show up as entries on first render."""
|
||||||
|
self.gamer.buddies.add(self.alice)
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||||
|
entry = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry .buddy-name")
|
||||||
|
)
|
||||||
|
self.assertEqual(entry.text, "alice")
|
||||||
|
|
||||||
|
def test_empty_state_when_no_buddies(self):
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||||
|
empty = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry--empty")
|
||||||
|
)
|
||||||
|
self.assertIn("No buddies yet", empty.text)
|
||||||
|
|
||||||
|
def test_add_buddy_via_buddy_btn_appends_entry(self):
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
btn.click()
|
||||||
|
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
recipient.send_keys("alice@test.io")
|
||||||
|
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||||
|
ok.click()
|
||||||
|
|
||||||
|
# New entry appears w. alice's username (not the bare email)
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f".buddy-entry[data-buddy-id='{self.alice.id}'] .buddy-name"
|
||||||
|
).text,
|
||||||
|
"alice",
|
||||||
|
))
|
||||||
|
# Server-side persisted
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
self.alice, list(self.gamer.buddies.all())
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_add_unregistered_email_is_silent_noop(self):
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buddies/")
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
btn.click()
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
recipient.send_keys("ghost@test.io")
|
||||||
|
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||||
|
ok.click()
|
||||||
|
|
||||||
|
# Wait for the panel close (a positive signal the request landed)
|
||||||
|
self.wait_for(lambda: self.assertNotIn(
|
||||||
|
"active", btn.get_attribute("class")
|
||||||
|
))
|
||||||
|
# No entries beyond the empty-state row
|
||||||
|
entries = self.browser.find_elements(By.CSS_SELECTOR, ".buddy-entry:not(.buddy-entry--empty)")
|
||||||
|
self.assertEqual(len(entries), 0)
|
||||||
@@ -35,13 +35,15 @@
|
|||||||
|
|
||||||
html:has(body.page-billboard),
|
html:has(body.page-billboard),
|
||||||
html:has(body.page-billscroll),
|
html:has(body.page-billscroll),
|
||||||
html:has(body.page-billpost) {
|
html:has(body.page-billpost),
|
||||||
|
html:has(body.page-billbuddies) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.page-billboard,
|
body.page-billboard,
|
||||||
body.page-billscroll,
|
body.page-billscroll,
|
||||||
body.page-billpost {
|
body.page-billpost,
|
||||||
|
body.page-billbuddies {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -215,6 +217,58 @@ body.page-billpost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── My Buddies page (aperture list + add-buddy panel) ────────────────────
|
||||||
|
// Mirrors .post-page's flex-column / overflow / bottom-anchor pattern;
|
||||||
|
// the add-buddy panel is included from the same _buddy*.scss styling.
|
||||||
|
|
||||||
|
.buddies-page {
|
||||||
|
@extend %billboard-page-base;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.buddies-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.buddies-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_buddies_list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.75rem 0 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.buddy-entry {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
|
||||||
|
.buddy-name {
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--empty {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buddy-entry-buffer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Billboard applet placement ─────────────────────────────────────────────
|
// ── Billboard applet placement ─────────────────────────────────────────────
|
||||||
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
|
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
|
||||||
// Right column (8-wide): Most Recent Scroll spans full height.
|
// Right column (8-wide): Most Recent Scroll spans full height.
|
||||||
@@ -222,13 +276,13 @@ body.page-billpost {
|
|||||||
|
|
||||||
#id_billboard_applets_container {
|
#id_billboard_applets_container {
|
||||||
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
|
#id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
|
||||||
#id_applet_my_contacts { grid-column: 1 / span 4; grid-row: 4 / span 3; }
|
#id_applet_my_buddies { grid-column: 1 / span 4; grid-row: 4 / span 3; }
|
||||||
#id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; }
|
#id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; }
|
||||||
#id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; }
|
#id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; }
|
||||||
|
|
||||||
@container (max-width: 550px) {
|
@container (max-width: 550px) {
|
||||||
#id_applet_my_scrolls,
|
#id_applet_my_scrolls,
|
||||||
#id_applet_my_contacts,
|
#id_applet_my_buddies,
|
||||||
#id_applet_notes,
|
#id_applet_notes,
|
||||||
#id_applet_most_recent_scroll {
|
#id_applet_most_recent_scroll {
|
||||||
grid-column: 1 / span 12;
|
grid-column: 1 / span 12;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<section
|
<section
|
||||||
id="id_applet_my_contacts"
|
id="id_applet_my_buddies"
|
||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
>
|
>
|
||||||
<h2>Contacts</h2>
|
<h2><a href="{% url 'billboard:my_buddies' %}" class="my-buddies-main">My Buddies</a></h2>
|
||||||
{% include "core/_partials/_forthcoming.html" %}
|
{% include "core/_partials/_forthcoming.html" %}
|
||||||
</section>
|
</section>
|
||||||
126
src/templates/apps/billboard/_partials/_buddy_add_panel.html
Normal file
126
src/templates/apps/billboard/_partials/_buddy_add_panel.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{% load static %}
|
||||||
|
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||||
|
{# _buddy_add_panel.html — bottom-left handshake btn + slide-out add- #}
|
||||||
|
{# buddy field. Mirrors _buddy_panel.html (post-share) but POSTs to #}
|
||||||
|
{# add_buddy and appends to #id_buddies_list instead of #id_post_table. #}
|
||||||
|
{# Included by my_buddies.html only. #}
|
||||||
|
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||||
|
|
||||||
|
<button id="id_buddy_btn" type="button" aria-label="Add a buddy">
|
||||||
|
<i class="fa-solid fa-handshake"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="id_buddy_panel" data-add-url="{% url 'billboard:add_buddy' %}">
|
||||||
|
<input id="id_recipient"
|
||||||
|
name="recipient"
|
||||||
|
type="email"
|
||||||
|
placeholder="friend@example.com"
|
||||||
|
autocomplete="off">
|
||||||
|
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var btn = document.getElementById('id_buddy_btn');
|
||||||
|
var panel = document.getElementById('id_buddy_panel');
|
||||||
|
var input = document.getElementById('id_recipient');
|
||||||
|
var ok = document.getElementById('id_buddy_ok');
|
||||||
|
var html = document.documentElement;
|
||||||
|
if (!btn || !panel || !input || !ok) return;
|
||||||
|
|
||||||
|
function _csrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _open() {
|
||||||
|
html.classList.add('buddy-open');
|
||||||
|
btn.classList.add('active');
|
||||||
|
setTimeout(function () { input.focus(); }, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _close(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
html.classList.remove('buddy-open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
if (opts.clear !== false) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (html.classList.contains('buddy-open')) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
_open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!html.classList.contains('buddy-open')) return;
|
||||||
|
if (panel.contains(e.target)) return;
|
||||||
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
_close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function _appendBuddyEntry(buddy) {
|
||||||
|
var list = document.getElementById('id_buddies_list');
|
||||||
|
if (!list || !buddy) return;
|
||||||
|
// Skip if already in DOM (server-side dedup ensures M2M idempotence;
|
||||||
|
// this guards a fast double-click that races the post-add refresh).
|
||||||
|
if (list.querySelector('[data-buddy-id="' + buddy.id + '"]')) return;
|
||||||
|
// Drop the empty-state row if present
|
||||||
|
var empty = list.querySelector('.buddy-entry--empty');
|
||||||
|
if (empty) empty.remove();
|
||||||
|
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'buddy-entry';
|
||||||
|
li.dataset.buddyId = buddy.id;
|
||||||
|
var name = document.createElement('span');
|
||||||
|
name.className = 'buddy-name';
|
||||||
|
name.textContent = buddy.username;
|
||||||
|
li.appendChild(name);
|
||||||
|
var buffer = list.querySelector('.buddy-entry-buffer');
|
||||||
|
if (buffer) list.insertBefore(li, buffer);
|
||||||
|
else list.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
ok.addEventListener('click', function () {
|
||||||
|
var email = input.value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.set('recipient', email);
|
||||||
|
|
||||||
|
fetch(panel.dataset.addUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRFToken': _csrf(),
|
||||||
|
},
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.buddy) _appendBuddyEntry(data.buddy);
|
||||||
|
_close({ clear: true });
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Privacy-safe response shape — even an unregistered email is
|
||||||
|
// a 200 w. {buddy: null}. Network/5xx land here; just close.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
ok.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
30
src/templates/apps/billboard/my_buddies.html
Normal file
30
src/templates/apps/billboard/my_buddies.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
{% load lyric_extras %}
|
||||||
|
|
||||||
|
{% block title_text %}Dashbuddies{% endblock title_text %}
|
||||||
|
{% block header_text %}<span>Dash</span>buddies{% endblock header_text %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="buddies-page">
|
||||||
|
<header class="buddies-header">
|
||||||
|
<h3 class="buddies-title">My Buddies</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul id="id_buddies_list" class="buddies-list">
|
||||||
|
{% for buddy in buddies %}
|
||||||
|
<li class="buddy-entry" data-buddy-id="{{ buddy.id }}">
|
||||||
|
<span class="buddy-name">{{ buddy|display_name }}</span>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="buddy-entry buddy-entry--empty">No buddies yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="buddy-entry-buffer" aria-hidden="true"></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# Buddy btn (bottom-left) + slide-out add-buddy panel — async POST #}
|
||||||
|
{# to add_buddy. Mirror of the post.html share buddy btn but distinct #}
|
||||||
|
{# action (adds to User.buddies M2M) and DOM hooks (#id_buddies_list).#}
|
||||||
|
{% include "apps/billboard/_partials/_buddy_add_panel.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
Reference in New Issue
Block a user