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:
Disco DeDisco
2026-05-08 22:31:42 -04:00
parent b3eb14140c
commit 5f6002aa70
14 changed files with 562 additions and 23 deletions

View File

@@ -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),
]

View 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)

View File

@@ -13,7 +13,7 @@ from apps.lyric.models import User
def _seed_billboard_applets():
for slug, name, cols, rows in [
("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),
]:
Applet.objects.get_or_create(
@@ -37,7 +37,7 @@ class BillboardViewTest(TestCase):
self.assertIn("applets", response.context)
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("my-scrolls", slugs)
self.assertIn("my-contacts", slugs)
self.assertIn("my-buddies", slugs)
self.assertIn("most-recent-scroll", slugs)
def test_passes_my_rooms_context(self):
@@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase):
)
self.assertEqual(response.status_code, 302)
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)
self.assertFalse(ua.visible)
@@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase):
reverse("billboard:toggle_applets"),
{"applets": [
"my-scrolls",
"my-contacts",
"my-buddies",
"most-recent-scroll",
]},
HTTP_HX_REQUEST="true",
@@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase):
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
def test_second_toggle_preserves_prior_hidden_state(self):
# First toggle: hide Contacts only.
# First toggle: hide My Buddies only.
self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
@@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase):
]},
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(
reverse("billboard:toggle_applets"),
{"applets": [
@@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase):
HTTP_HX_REQUEST="true",
)
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")
self.assertFalse(
UserApplet.objects.get(user=self.user, applet=contacts).visible

View File

@@ -18,4 +18,6 @@ urlpatterns = [
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("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"),
]

View File

@@ -373,6 +373,46 @@ def share_post(request, post_id):
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="/")
def save_scroll_position(request, room_id):
if request.method != "POST":

View 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",
),
),
]

View File

@@ -113,6 +113,14 @@ class User(AbstractBaseUser):
unlocked_decks = models.ManyToManyField(
"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(
"drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",