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

@@ -1,7 +1,7 @@
<section
id="id_applet_my_contacts"
id="id_applet_my_buddies"
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" %}
</section>

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

View 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 %}