bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries {already_present, recipient_display, recipient_user_id}; bud-btn.js branches on already_present → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads @<username> is already present, NVM dismisses, FYI dismisses AND eases in the .bud-duplicate-flash class (color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms) onto the existing element (.bud-entry .bud-name / .post-recipient[data-user-id=…] / .gate-slot.filled[data-user-id=…]); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — "0 failures" in result.text matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. re.search(r"(?<!\d)0 failures\b", …) (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

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-12 16:40:15 -04:00
parent 264ed5968e
commit be919c7aff
19 changed files with 738 additions and 38 deletions

View File

@@ -4,12 +4,23 @@
// • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud)
//
// Owns: csrf cookie read, open/close + .bud-open html-class, button click,
// Escape, click-outside, Enter-in-input, OK POST + JSON routing.
// Escape, click-outside, Enter-in-input, OK POST + JSON routing, and the
// `data.already_present` duplicate-guard branch (error Brief instead of
// the normal onSuccess append).
//
// Each caller drives it with `bindBudBtn({submitUrl, autocompleteUrl?, onSuccess})`.
// `onSuccess(data)` does the panel-specific DOM updates (line append, chip
// append, bud-entry append, Brief banner — whatever the response carries).
// _close({clear: true}) fires automatically on a successful response.
// Each caller drives it with:
// bindBudBtn({
// submitUrl,
// autocompleteUrl?,
// onSuccess(data), // success path: line/chip/entry/Brief
// duplicateTargetSelector?(data), // selector for .bud-duplicate-flash
// })
//
// `onSuccess(data)` does the panel-specific DOM updates on the new-row path.
// `duplicateTargetSelector(data)` returns a CSS selector for the existing
// element to highlight when the FYI button on the error Brief is clicked
// (.bud-name / .post-recipient / .gate-slot.filled — varies by page).
// _close({clear: true}) fires automatically on every successful response.
//
// `autocompleteUrl` enables bud-autocomplete on the input (post-share +
// gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions.
@@ -87,7 +98,21 @@
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (typeof opts.onSuccess === 'function') opts.onSuccess(data);
if (data && data.already_present) {
// Skip onSuccess — there's nothing to append. Show the
// error Brief instead. target_selector resolves at call
// time from the caller's per-page callback.
if (window.Brief) {
var sel = (typeof opts.duplicateTargetSelector === 'function')
? opts.duplicateTargetSelector(data) : null;
Brief.showDuplicateBanner({
display_name: data.recipient_display,
target_selector: sel,
});
}
} else if (typeof opts.onSuccess === 'function') {
opts.onSuccess(data);
}
_close({ clear: true });
})
.catch(function () {

View File

@@ -239,3 +239,84 @@ class SharePostImplicitAutoAddTest(TestCase):
"""Privacy: unregistered email doesn't touch the buds graph."""
self._share("ghost@test.io")
self.assertEqual(self.sharer.buds.count(), 0)
class AddBudAlreadyPresentTest(TestCase):
"""Duplicate-add guard: add_bud's JSON response distinguishes "newly
added" from "already a bud" so the bud-btn JS can render an error
Brief titled `@<username> is already present` instead of the normal
bud-entry append. `recipient_user_id` is the highlight target id that
the Brief FYI button toggles `.bud-duplicate-flash` onto."""
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.user.buds.add(self.alice)
def _add(self, recipient):
return self.client.post(
reverse("billboard:add_bud"), {"recipient": recipient},
)
def test_already_present_true_when_candidate_is_already_a_bud(self):
self.assertTrue(self._add("alice@test.io").json()["already_present"])
def test_already_present_false_for_new_bud(self):
User.objects.create(email="bob@test.io", username="bob")
self.assertFalse(self._add("bob@test.io").json()["already_present"])
def test_already_present_false_for_unregistered_email(self):
self.assertFalse(self._add("ghost@test.io").json()["already_present"])
def test_recipient_display_carries_username_on_duplicate(self):
self.assertEqual(self._add("alice@test.io").json()["recipient_display"], "alice")
def test_recipient_user_id_carries_alice_id_on_duplicate(self):
self.assertEqual(
self._add("alice@test.io").json()["recipient_user_id"],
str(self.alice.id),
)
def test_username_typed_recipient_also_detects_duplicate(self):
"""A user typing 'alice' (no @) resolves the same way as the email."""
self.assertTrue(self._add("alice").json()["already_present"])
class SharePostAlreadyPresentTest(TestCase):
"""Duplicate-share guard mirrors AddBudAlreadyPresentTest — when the
recipient is already in post.shared_with, response carries
already_present + recipient_user_id for the .post-recipient highlight."""
def setUp(self):
from apps.billboard.models import Post
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
self.client.force_login(self.sharer)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.post = Post.objects.create(owner=self.sharer)
self.post.shared_with.add(self.alice)
def _share(self, recipient):
return self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_already_present_true_when_recipient_in_shared_with(self):
self.assertTrue(self._share("alice@test.io").json()["already_present"])
def test_already_present_false_for_new_recipient(self):
User.objects.create(email="bob@test.io", username="bob")
self.assertFalse(self._share("bob@test.io").json()["already_present"])
def test_recipient_user_id_present_on_duplicate(self):
self.assertEqual(
self._share("alice@test.io").json()["recipient_user_id"],
str(self.alice.id),
)
def test_recipient_display_already_present_on_duplicate(self):
"""`recipient_display` already exists on the success path; on
duplicate the same field must carry the matched user's handle."""
self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice")

View File

@@ -392,12 +392,16 @@ def share_post(request, post_id):
# list exposes; doesn't widen the privacy surface beyond what the
# post detail page already shows publicly.
recipient_display = None
recipient_user_id = None
if recipient is not None:
recipient_display = recipient.username or recipient.email
recipient_user_id = str(recipient.id)
return JsonResponse({
"brief": brief.to_banner_dict() if brief is not None else None,
"line_text": line_text,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
"already_present": is_reshare,
})
messages.success(request, "An invite has been sent if that address is registered.")
@@ -446,16 +450,28 @@ def add_bud(request):
candidate = _resolve_recipient(request.POST.get("recipient"))
bud = None
already_present = False
recipient_display = None
recipient_user_id = None
if candidate is not None and candidate != request.user:
if candidate not in request.user.buds.all():
already_present = candidate in request.user.buds.all()
if not already_present:
request.user.buds.add(candidate)
display = candidate.username or candidate.email
bud = {
"id": str(candidate.id),
"username": candidate.username or candidate.email,
"username": display,
"email": candidate.email,
}
recipient_display = display
recipient_user_id = str(candidate.id)
return JsonResponse({"bud": bud})
return JsonResponse({
"bud": bud,
"already_present": already_present,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
})
@login_required(login_url="/")

View File

@@ -69,13 +69,54 @@ const Brief = (() => {
showBanner(data && data.brief);
}
// Error-variant banner for "@<username> is already present". Distinct
// from showBanner because it's purely client-side (no Brief DB row);
// title carries the recipient handle; there's no date/square/post_url;
// FYI is a <button> (no navigation) that toggles `.bud-duplicate-flash`
// onto a caller-supplied target element + dismisses. NVM just dismisses.
function showDuplicateBanner(opts) {
if (!opts || !opts.display_name) return;
var banner = document.createElement('div');
banner.className = 'note-banner note-banner--duplicate';
banner.innerHTML =
'<div class="note-banner__body">' +
'<p class="note-banner__title">@' + _esc(opts.display_name) + ' is already present</p>' +
'</div>' +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
'<button type="button" class="btn btn-info note-banner__fyi">FYI</button>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove();
});
banner.querySelector('.note-banner__fyi').addEventListener('click', function () {
if (opts.target_selector) {
var target = document.querySelector(opts.target_selector);
if (target) target.classList.add('bud-duplicate-flash');
}
banner.remove();
});
var anchor = document.getElementById('id_brief_banner_anchor')
|| document.querySelector('h2');
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(banner, anchor.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
function _esc(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
return {
showBanner: showBanner,
showDuplicateBanner: showDuplicateBanner,
handleSaveResponse: handleSaveResponse,
};
})();
// Backwards-compat shim — to be removed once the codebase uniformly uses Brief.

View File

@@ -141,3 +141,88 @@ class InviteGamerLegacyFormTest(TestCase):
self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())
class InviteGamerAlreadyPresentTest(TestCase):
"""Duplicate-invite guard: a gatekeeper invite gets `already_present:
true` in its JSON response when the recipient is either (a) already
seated in the room or (b) has a pending RoomInvite. `recipient_user_id`
is set only when seated — pending invitees have no visible
`.gate-slot.filled` DOM element to highlight, so the FYI button just
dismisses without easing in the flash class."""
def setUp(self):
from apps.epic.models import GateSlot, TableSeat
self.GateSlot = GateSlot
self.TableSeat = TableSeat
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.client.force_login(self.owner)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingo", owner=self.owner)
def _invite(self, recipient):
return self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_already_present_true_when_recipient_is_seated_via_gate_slot(self):
"""Gatekeeper phase: 'seated' means a GateSlot.FILLED w. gamer set."""
self.GateSlot.objects.create(
room=self.room, gamer=self.alice, slot_number=1,
status=self.GateSlot.FILLED,
)
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
def test_already_present_true_when_recipient_has_table_seat(self):
"""Post-gatekeeper phase: TableSeat exists once slots transition."""
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
def test_recipient_user_id_carries_alice_id_when_seated(self):
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
body = self._invite("alice@test.io").json()
self.assertEqual(body["recipient_user_id"], str(self.alice.id))
def test_already_present_true_when_recipient_has_pending_room_invite(self):
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email="alice@test.io", status=RoomInvite.PENDING,
)
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
def test_recipient_user_id_null_when_only_invited_not_seated(self):
"""Pending invites have no visible slot element to highlight."""
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email="alice@test.io", status=RoomInvite.PENDING,
)
self.assertIsNone(self._invite("alice@test.io").json()["recipient_user_id"])
def test_already_present_false_for_brand_new_recipient(self):
self.assertFalse(self._invite("alice@test.io").json()["already_present"])
def test_brief_is_null_on_duplicate(self):
"""No new Brief on duplicate — the client renders the error Brief
from the already_present flag instead."""
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
self.assertIsNone(self._invite("alice@test.io").json()["brief"])
def test_no_new_room_invite_on_duplicate(self):
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email="alice@test.io", status=RoomInvite.PENDING,
)
self._invite("alice@test.io")
self.assertEqual(
RoomInvite.objects.filter(room=self.room, invitee_email="alice@test.io").count(),
1,
)
def test_recipient_display_carries_username_on_duplicate(self):
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
self.assertEqual(
self._invite("alice@test.io").json()["recipient_display"],
"alice",
)

View File

@@ -699,36 +699,62 @@ def invite_gamer(request, room_id):
# username-typed invite doesn't store the raw username as if it were
# an email); falls back to the raw input for unregistered addresses.
invitee_email = candidate.email if candidate else raw
RoomInvite.objects.get_or_create(
room=room,
inviter=request.user,
invitee_email=invitee_email,
defaults={"status": RoomInvite.PENDING},
)
# Buds graph: symmetric auto-add on registered recipients (mirrors
# share_post). Idempotent on M2M; no-op on unregistered recipients.
if candidate is not None:
request.user.buds.add(candidate)
candidate.buds.add(request.user)
# Brief: confirmation banner for the inviter. Brief.post stays null;
# banner FYI navigates to the room's gatekeeper page via Brief.room.
brief = Brief.objects.create(
owner=request.user,
post=None,
room=room,
kind=Brief.KIND_GAME_INVITE,
title="Invite sent",
# Duplicate-invite guard: "already present" = recipient is either
# already seated in the room OR has a (pending/accepted) RoomInvite.
# During gatekeeper phase the visible `.gate-slot.filled` cells are
# GateSlot-driven (TableSeats spin up later at SIG SELECT), so check
# both — GateSlot.FILLED catches the in-phase case, TableSeat catches
# the post-phase case. Seated recipients carry recipient_user_id so
# the client can find the .gate-slot.filled[data-user-id="X"]
# highlight target; pending invitees have no visible slot, so
# recipient_user_id stays null.
already_seated = candidate is not None and (
GateSlot.objects.filter(
room=room, gamer=candidate, status=GateSlot.FILLED,
).exists()
or TableSeat.objects.filter(room=room, gamer=candidate).exists()
)
already_invited = RoomInvite.objects.filter(
room=room, invitee_email=invitee_email,
).exists()
already_present = already_seated or already_invited
brief = None
if not already_present:
RoomInvite.objects.create(
room=room,
inviter=request.user,
invitee_email=invitee_email,
status=RoomInvite.PENDING,
)
# Buds graph: symmetric auto-add on registered recipients (mirrors
# share_post). Idempotent on M2M; no-op on unregistered recipients.
if candidate is not None:
request.user.buds.add(candidate)
candidate.buds.add(request.user)
# Brief: confirmation banner for the inviter. Brief.post stays
# null; banner FYI navigates to the room's gatekeeper page via
# Brief.room.
brief = Brief.objects.create(
owner=request.user,
post=None,
room=room,
kind=Brief.KIND_GAME_INVITE,
title="Invite sent",
)
recipient_user_id = str(candidate.id) if already_seated else None
if is_ajax:
recipient_display = None
if candidate is not None:
recipient_display = candidate.username or candidate.email
return JsonResponse({
"brief": brief.to_banner_dict(),
"brief": brief.to_banner_dict() if brief is not None else None,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
"already_present": already_present,
})
return redirect("epic:gatekeeper", room_id=room_id)