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

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