buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts 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,40 @@
|
||||
"""Rename the My Buddies applet → My Buds (slug + name).
|
||||
|
||||
UI-vocabulary tightening — see lyric/0005_rename_buddies_to_buds for the
|
||||
parallel User.buddies → User.buds field rename. BILLBUDDIES overflowed
|
||||
the page-header band; BILLBUDS fits cleanly.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
try:
|
||||
applet = Applet.objects.get(slug="my-buddies")
|
||||
except Applet.DoesNotExist:
|
||||
return
|
||||
applet.slug = "my-buds"
|
||||
applet.name = "My Buds"
|
||||
applet.save(update_fields=["slug", "name"])
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
try:
|
||||
applet = Applet.objects.get(slug="my-buds")
|
||||
except Applet.DoesNotExist:
|
||||
return
|
||||
applet.slug = "my-buddies"
|
||||
applet.name = "My Buddies"
|
||||
applet.save(update_fields=["slug", "name"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0006_rename_contacts_to_buddies"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, backward),
|
||||
]
|
||||
17
src/apps/billboard/migrations/0006_alter_line_options.py
Normal file
17
src/apps/billboard/migrations/0006_alter_line_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0 on 2026-05-09 03:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0005_line_admin_solicited'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='line',
|
||||
options={'ordering': ('created_at', 'id')},
|
||||
),
|
||||
]
|
||||
@@ -1,143 +0,0 @@
|
||||
"""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)
|
||||
143
src/apps/billboard/tests/integrated/test_buds.py
Normal file
143
src/apps/billboard/tests/integrated/test_buds.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""ITs for the My Buds feature (User.buds M2M + my_buds view +
|
||||
add_bud JSON endpoint).
|
||||
|
||||
User.buds 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_bud returns 200 with {bud: 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 UserBudsM2MTest(TestCase):
|
||||
"""The buds field is asymmetric — A.buds.add(B) doesn't
|
||||
reciprocate to B.buds, only to B.added_as_bud."""
|
||||
|
||||
def setUp(self):
|
||||
self.disco = User.objects.create(email="disco@test.io")
|
||||
self.alice = User.objects.create(email="alice@test.io")
|
||||
|
||||
def test_add_bud_one_way(self):
|
||||
self.disco.buds.add(self.alice)
|
||||
self.assertIn(self.alice, self.disco.buds.all())
|
||||
self.assertNotIn(self.disco, self.alice.buds.all())
|
||||
|
||||
def test_added_as_bud_reverse_relation(self):
|
||||
self.disco.buds.add(self.alice)
|
||||
self.assertIn(self.disco, self.alice.added_as_bud.all())
|
||||
|
||||
def test_add_is_idempotent(self):
|
||||
self.disco.buds.add(self.alice)
|
||||
self.disco.buds.add(self.alice)
|
||||
self.assertEqual(self.disco.buds.count(), 1)
|
||||
|
||||
|
||||
class MyBudsViewTest(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.buds.add(self.alice, self.bob)
|
||||
|
||||
def test_my_buds_renders_template(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/billboard/my_buds.html")
|
||||
|
||||
def test_my_buds_lists_users_buds(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
buds = list(response.context["buds"])
|
||||
self.assertIn(self.alice, buds)
|
||||
self.assertIn(self.bob, buds)
|
||||
|
||||
def test_my_buds_does_not_list_others_buds(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
carol = User.objects.create(email="carol@test.io", username="carol")
|
||||
other.buds.add(carol)
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertNotIn(carol, list(response.context["buds"]))
|
||||
|
||||
def test_my_buds_redirects_anon_to_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class AddBudViewTest(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_buds(self):
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(alice, self.user.buds.all())
|
||||
|
||||
def test_add_returns_bud_payload_with_username(self):
|
||||
User.objects.create(email="alice@test.io", username="alice")
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
body = response.json()
|
||||
self.assertIsNotNone(body["bud"])
|
||||
self.assertEqual(body["bud"]["username"], "alice")
|
||||
|
||||
def test_add_unregistered_email_returns_null_bud(self):
|
||||
"""Privacy: 200 with bud=null so the response shape doesn't leak
|
||||
whether the address is on the system."""
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "ghost@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["bud"])
|
||||
self.assertEqual(self.user.buds.count(), 0)
|
||||
|
||||
def test_add_own_email_is_silent_noop(self):
|
||||
"""Adding yourself: no bud added, response carries bud=null."""
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "me@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(response.json()["bud"])
|
||||
self.assertNotIn(self.user, self.user.buds.all())
|
||||
|
||||
def test_add_existing_bud_is_idempotent(self):
|
||||
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.user.buds.add(alice)
|
||||
response = self.client.post(
|
||||
reverse("billboard:add_bud"),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Still only one bud entry — M2M dedup
|
||||
self.assertEqual(self.user.buds.count(), 1)
|
||||
# Response still carries the bud payload (so the JS can refresh
|
||||
# an entry if a fast double-click bypassed the data-bud-id guard).
|
||||
self.assertIsNotNone(response.json()["bud"])
|
||||
|
||||
def test_add_falls_back_to_email_when_no_username(self):
|
||||
"""Bud payload returns email when bud.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_bud"),
|
||||
data={"recipient": "anon@test.io"},
|
||||
)
|
||||
self.assertEqual(response.json()["bud"]["username"], "anon@test.io")
|
||||
|
||||
def test_get_returns_405(self):
|
||||
response = self.client.get(reverse("billboard:add_bud"))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
@@ -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-buddies", "My Buddies", 4, 3),
|
||||
("my-buds", "My Buds", 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-buddies", slugs)
|
||||
self.assertIn("my-buds", 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-buddies")
|
||||
contacts = Applet.objects.get(slug="my-buds")
|
||||
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-buddies",
|
||||
"my-buds",
|
||||
"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 My Buddies only.
|
||||
# First toggle: hide My Buds 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 — My Buddies must stay hidden.
|
||||
# Second toggle: hide Most Recent Scroll additionally — My Buds 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-buddies")
|
||||
contacts = Applet.objects.get(slug="my-buds")
|
||||
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
||||
self.assertFalse(
|
||||
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
||||
|
||||
@@ -18,6 +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"),
|
||||
path("my-buds/", views.my_buds, name="my_buds"),
|
||||
path("buds/add", views.add_bud, name="add_bud"),
|
||||
]
|
||||
|
||||
@@ -300,7 +300,7 @@ def my_posts(request, user_id):
|
||||
return HttpResponseForbidden()
|
||||
return render(request, "apps/billboard/my_posts.html", {
|
||||
"owner": owner,
|
||||
"page_class": "page-billboard",
|
||||
"page_class": "page-billposts",
|
||||
})
|
||||
|
||||
|
||||
@@ -373,44 +373,44 @@ 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.
|
||||
# ── My Buds ───────────────────────────────────────────────────────────────
|
||||
# User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds`
|
||||
# is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel
|
||||
# slide-out. Privacy: when an entered email isn't a registered User, we
|
||||
# 200 with {bud: 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",
|
||||
def my_buds(request):
|
||||
return render(request, "apps/billboard/my_buds.html", {
|
||||
"buds": request.user.buds.all(),
|
||||
"page_class": "page-billbuds",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def add_buddy(request):
|
||||
def add_bud(request):
|
||||
if request.method != "POST":
|
||||
from django.http import HttpResponseNotAllowed
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
|
||||
email = (request.POST.get("recipient") or "").strip()
|
||||
|
||||
buddy = None
|
||||
bud = 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 = {
|
||||
if candidate not in request.user.buds.all():
|
||||
request.user.buds.add(candidate)
|
||||
bud = {
|
||||
"id": str(candidate.id),
|
||||
"username": candidate.username or candidate.email,
|
||||
"email": candidate.email,
|
||||
}
|
||||
|
||||
return JsonResponse({"buddy": buddy})
|
||||
return JsonResponse({"bud": bud})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
|
||||
@@ -83,6 +83,6 @@ const Note = Brief;
|
||||
|
||||
// `const Brief = (...)` at script-tag scope is reachable as a bare name but
|
||||
// is NOT auto-attached to window — explicit assignment so callers that gate
|
||||
// on `if (window.Brief)` (e.g. _buddy_panel.html's OK handler) succeed.
|
||||
// on `if (window.Brief)` (e.g. _bud_panel.html's OK handler) succeed.
|
||||
window.Brief = Brief;
|
||||
window.Note = Note;
|
||||
|
||||
32
src/apps/lyric/migrations/0005_rename_buddies_to_buds.py
Normal file
32
src/apps/lyric/migrations/0005_rename_buddies_to_buds.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Rename User.buddies → User.buds.
|
||||
|
||||
Django's RenameField doesn't rename the implicit M2M through table
|
||||
(`lyric_user_buddies` → `lyric_user_buds`), so we drop and re-add the
|
||||
field. The buddies M2M was introduced one commit prior (0004) — no
|
||||
production data to preserve. UI-vocabulary tightening (BILLBUDDIES
|
||||
overflowed the page-header band; in-game term collapses to BILLBUDS).
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("lyric", "0004_user_buddies"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="buddies",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="buds",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="added_as_bud",
|
||||
to="lyric.user",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -113,13 +113,13 @@ 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
|
||||
# Asymmetric self M2M — `user.buds.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",
|
||||
# the recipient to the inviter's buds list). `user.added_as_bud` is
|
||||
# the inverse (people who have me in their buds list); useful for
|
||||
# the future "bud changed username" snapshot-accept flow.
|
||||
buds = models.ManyToManyField(
|
||||
"self", symmetrical=False, blank=True, related_name="added_as_bud",
|
||||
)
|
||||
active_title = models.ForeignKey(
|
||||
"drama.Note", null=True, blank=True,
|
||||
|
||||
Reference in New Issue
Block a user