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
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:
@@ -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 () {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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="/")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -699,21 +699,43 @@ 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(
|
||||
|
||||
# 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,
|
||||
defaults={"status": RoomInvite.PENDING},
|
||||
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: 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,
|
||||
@@ -722,13 +744,17 @@ def invite_gamer(request, room_id):
|
||||
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)
|
||||
|
||||
@@ -379,3 +379,49 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
|
||||
"page-billboard" in cls or "page-billpost" in cls,
|
||||
f"my_posts.html body class missing aperture marker: {cls!r}",
|
||||
)
|
||||
|
||||
|
||||
class BudBtnDuplicateShareErrorTest(FunctionalTest):
|
||||
"""Re-sharing a post w. someone already in post.shared_with triggers
|
||||
the error Brief titled `@<username> is already present`. FYI on the
|
||||
Brief dismisses + adds .bud-duplicate-flash to the existing
|
||||
.post-recipient[data-user-id=…] chip."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.sharer = User.objects.create(email="bud@test.io", username="bud")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.post = _seed_a_post(self.sharer)
|
||||
self.post.shared_with.add(self.alice)
|
||||
self.create_pre_authenticated_session("bud@test.io")
|
||||
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||
|
||||
def test_duplicate_share_shows_error_brief_and_fyi_flashes_chip(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys("alice@test.io")
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
|
||||
|
||||
title = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
|
||||
))
|
||||
self.assertEqual(title.text, "@alice is already present")
|
||||
|
||||
# Existing chip carries data-user-id so the FYI highlight can find it
|
||||
chip = self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".post-recipient[data-user-id='{self.alice.id}']"
|
||||
)
|
||||
self.assertNotIn("bud-duplicate-flash", chip.get_attribute("class") or "")
|
||||
|
||||
# FYI dismisses + applies flash class
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
|
||||
).click()
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
|
||||
[],
|
||||
))
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
"bud-duplicate-flash", chip.get_attribute("class") or ""
|
||||
))
|
||||
|
||||
@@ -10,7 +10,7 @@ epic:invite_gamer w. Accept: application/json — server returns
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.billboard.models import Brief
|
||||
from apps.epic.models import Room, RoomInvite
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import FunctionalTest
|
||||
@@ -114,3 +114,64 @@ class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
self.alice, list(self.owner.buds.all())
|
||||
))
|
||||
|
||||
|
||||
class GatekeeperBudBtnDuplicateInviteErrorTest(FunctionalTest):
|
||||
"""Re-inviting a recipient already seated in the room triggers the
|
||||
error Brief titled `@<username> is already present`. FYI on the Brief
|
||||
dismisses + adds .bud-duplicate-flash to the existing
|
||||
.gate-slot.filled[data-user-id=…] element. Pending-but-unseated
|
||||
duplicates also surface the Brief but FYI has no slot to highlight."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.room = Room.objects.create(name="Dup Room", owner=self.owner)
|
||||
# Seat alice via a GateSlot — _gate_positions renders .gate-slot.filled
|
||||
# cells from GateSlot records (TableSeat spins up later at SIG SELECT),
|
||||
# so the duplicate-highlight target lives there during gatekeeper phase.
|
||||
GateSlot.objects.create(
|
||||
room=self.room, gamer=self.alice, slot_number=1,
|
||||
status=GateSlot.FILLED,
|
||||
)
|
||||
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
|
||||
self.create_pre_authenticated_session("owner@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
|
||||
def test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot(self):
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys("alice@test.io")
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
|
||||
|
||||
title = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
|
||||
))
|
||||
self.assertEqual(title.text, "@alice is already present")
|
||||
|
||||
# No new RoomInvite or Brief persisted server-side on duplicate
|
||||
self.assertFalse(RoomInvite.objects.filter(
|
||||
room=self.room, invitee_email="alice@test.io",
|
||||
).exists())
|
||||
self.assertEqual(
|
||||
Brief.objects.filter(owner=self.owner, kind=Brief.KIND_GAME_INVITE).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
slot = self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".gate-slot.filled[data-user-id='{self.alice.id}']"
|
||||
)
|
||||
self.assertNotIn("bud-duplicate-flash", slot.get_attribute("class") or "")
|
||||
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
|
||||
).click()
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
|
||||
[],
|
||||
))
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
"bud-duplicate-flash", slot.get_attribute("class") or ""
|
||||
))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from .base import FunctionalTest
|
||||
|
||||
@@ -8,7 +10,10 @@ class JasmineTest(FunctionalTest):
|
||||
|
||||
def check_results():
|
||||
result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result")
|
||||
if "0 failures" not in result.text:
|
||||
# Word-boundary anchor — Jasmine 6 reports as "N specs, X failures".
|
||||
# Plain `"0 failures" in text` matches "10 failures", "20 failures",
|
||||
# etc., letting up to 99 failed specs slip past as green.
|
||||
if not re.search(r"(?<!\d)0 failures\b", result.text):
|
||||
failures = self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".jasmine-failed .jasmine-description"
|
||||
)
|
||||
|
||||
@@ -88,3 +88,44 @@ class MyBudsPageTest(FunctionalTest):
|
||||
# No bud entries (the empty-state row has its own --empty class)
|
||||
entries = self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")
|
||||
self.assertEqual(len(entries), 0)
|
||||
|
||||
def test_re_add_existing_bud_shows_already_present_brief_and_fyi_flashes_bud_name(self):
|
||||
"""Re-adding an existing bud: server returns already_present=true;
|
||||
client renders the error Brief titled `@alice is already present`;
|
||||
clicking FYI dismisses the Brief AND adds .bud-duplicate-flash to
|
||||
the existing .bud-name cell."""
|
||||
self.gamer.buds.add(self.alice)
|
||||
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||
btn.click()
|
||||
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||
recipient.send_keys("alice@test.io")
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
|
||||
|
||||
# Error Brief appears w. the duplicate title
|
||||
title = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
|
||||
))
|
||||
self.assertEqual(title.text, "@alice is already present")
|
||||
|
||||
# Only one .bud-entry — no second alice row appended
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")),
|
||||
1,
|
||||
)
|
||||
|
||||
# Pre-flash: the .bud-name carries no flash class yet
|
||||
bud_name = self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name"
|
||||
)
|
||||
self.assertNotIn("bud-duplicate-flash", bud_name.get_attribute("class") or "")
|
||||
|
||||
# Click FYI → Brief dismisses AND .bud-name gets the flash class
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi").click()
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
|
||||
[],
|
||||
))
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
"bud-duplicate-flash", bud_name.get_attribute("class") or ""
|
||||
))
|
||||
|
||||
@@ -136,6 +136,123 @@ describe('Brief.showBanner', () => {
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Brief.showDuplicateBanner — the "already added" error variant. Distinct
|
||||
// from showBanner because: the server doesn't persist a Brief row for these
|
||||
// (transient, client-only), title is rendered as `@<username> is already
|
||||
// present`, there's no date/square/post_url, and the FYI button toggles
|
||||
// `.bud-duplicate-flash` onto a caller-supplied target element instead of
|
||||
// navigating.
|
||||
//
|
||||
// API under test:
|
||||
// Brief.showDuplicateBanner({ display_name, target_selector? })
|
||||
// null / missing display_name → no banner
|
||||
// target_selector matches an element → FYI click adds .bud-duplicate-flash
|
||||
// target_selector missing or matches nothing → FYI just dismisses
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Brief.showDuplicateBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = document.createElement('div');
|
||||
fixture.id = 'dup-fixture';
|
||||
fixture.innerHTML =
|
||||
'<h2>Dash</h2>' +
|
||||
'<ul id="bud-fixture-list">' +
|
||||
'<li class="bud-entry" data-bud-id="42">' +
|
||||
'<span class="bud-name">alice</span>' +
|
||||
'</li>' +
|
||||
'</ul>';
|
||||
document.body.appendChild(fixture);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
||||
fixture.remove();
|
||||
});
|
||||
|
||||
it('D1: missing display_name → no banner', () => {
|
||||
Brief.showDuplicateBanner({});
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('D2: title reads "@<display_name> is already present"', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
const title = document.querySelector('.note-banner__title');
|
||||
expect(title).not.toBeNull();
|
||||
expect(title.textContent).toBe('@alice is already present');
|
||||
});
|
||||
|
||||
it('D3: banner carries the .note-banner--duplicate modifier class', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
const b = document.querySelector('.note-banner');
|
||||
expect(b).not.toBeNull();
|
||||
expect(b.classList.contains('note-banner--duplicate')).toBeTrue();
|
||||
});
|
||||
|
||||
it('D4: duplicate banner has no .note-banner__description or .note-banner__timestamp', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
expect(document.querySelector('.note-banner__description')).toBeNull();
|
||||
expect(document.querySelector('.note-banner__timestamp')).toBeNull();
|
||||
});
|
||||
|
||||
it('D5: FYI button is a <button>, not an <a> (no navigation)', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
const fyi = document.querySelector('.note-banner .note-banner__fyi');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.tagName.toLowerCase()).toBe('button');
|
||||
});
|
||||
|
||||
it('D6: NVM dismisses without adding .bud-duplicate-flash anywhere', () => {
|
||||
Brief.showDuplicateBanner({
|
||||
display_name: 'alice',
|
||||
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
|
||||
});
|
||||
document.querySelector('.note-banner__nvm').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
const name = fixture.querySelector('.bud-name');
|
||||
expect(name.classList.contains('bud-duplicate-flash')).toBeFalse();
|
||||
});
|
||||
|
||||
it('D7: FYI dismisses AND adds .bud-duplicate-flash to the target element', () => {
|
||||
Brief.showDuplicateBanner({
|
||||
display_name: 'alice',
|
||||
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
|
||||
});
|
||||
document.querySelector('.note-banner__fyi').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
const name = fixture.querySelector('.bud-name');
|
||||
expect(name.classList.contains('bud-duplicate-flash')).toBeTrue();
|
||||
});
|
||||
|
||||
it('D8: FYI dismisses cleanly when target_selector is missing', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
document.querySelector('.note-banner__fyi').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('D9: FYI dismisses cleanly when target_selector matches nothing', () => {
|
||||
Brief.showDuplicateBanner({
|
||||
display_name: 'alice',
|
||||
target_selector: '.gate-slot[data-user-id="does-not-exist"]',
|
||||
});
|
||||
document.querySelector('.note-banner__fyi').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('D10: title escapes HTML in display_name', () => {
|
||||
Brief.showDuplicateBanner({ display_name: '<script>x</script>' });
|
||||
const title = document.querySelector('.note-banner__title');
|
||||
expect(title.textContent).toBe('@<script>x</script> is already present');
|
||||
expect(title.querySelector('script')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Brief.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -171,3 +171,17 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duplicate-add highlight ─────────────────────────────────────────────
|
||||
//
|
||||
// Eased-in flash applied by Brief.showDuplicateBanner's FYI button to a
|
||||
// caller-supplied target element — one of .bud-entry .bud-name (My Buds),
|
||||
// .post-recipient (post share), or .gate-slot.filled (gatekeeper invite).
|
||||
// Persists until page refresh; --terUser color + --ninUser text-shadow
|
||||
// per the duplicate-guard spec.
|
||||
|
||||
.bud-duplicate-flash {
|
||||
color: rgba(var(--terUser), 1);
|
||||
text-shadow: 0 0 0.5em rgba(var(--ninUser), 1);
|
||||
transition: color 600ms ease, text-shadow 600ms ease;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,123 @@ describe('Brief.showBanner', () => {
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Brief.showDuplicateBanner — the "already added" error variant. Distinct
|
||||
// from showBanner because: the server doesn't persist a Brief row for these
|
||||
// (transient, client-only), title is rendered as `@<username> is already
|
||||
// present`, there's no date/square/post_url, and the FYI button toggles
|
||||
// `.bud-duplicate-flash` onto a caller-supplied target element instead of
|
||||
// navigating.
|
||||
//
|
||||
// API under test:
|
||||
// Brief.showDuplicateBanner({ display_name, target_selector? })
|
||||
// null / missing display_name → no banner
|
||||
// target_selector matches an element → FYI click adds .bud-duplicate-flash
|
||||
// target_selector missing or matches nothing → FYI just dismisses
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Brief.showDuplicateBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = document.createElement('div');
|
||||
fixture.id = 'dup-fixture';
|
||||
fixture.innerHTML =
|
||||
'<h2>Dash</h2>' +
|
||||
'<ul id="bud-fixture-list">' +
|
||||
'<li class="bud-entry" data-bud-id="42">' +
|
||||
'<span class="bud-name">alice</span>' +
|
||||
'</li>' +
|
||||
'</ul>';
|
||||
document.body.appendChild(fixture);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
||||
fixture.remove();
|
||||
});
|
||||
|
||||
it('D1: missing display_name → no banner', () => {
|
||||
Brief.showDuplicateBanner({});
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('D2: title reads "@<display_name> is already present"', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
const title = document.querySelector('.note-banner__title');
|
||||
expect(title).not.toBeNull();
|
||||
expect(title.textContent).toBe('@alice is already present');
|
||||
});
|
||||
|
||||
it('D3: banner carries the .note-banner--duplicate modifier class', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
const b = document.querySelector('.note-banner');
|
||||
expect(b).not.toBeNull();
|
||||
expect(b.classList.contains('note-banner--duplicate')).toBeTrue();
|
||||
});
|
||||
|
||||
it('D4: duplicate banner has no .note-banner__description or .note-banner__timestamp', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
expect(document.querySelector('.note-banner__description')).toBeNull();
|
||||
expect(document.querySelector('.note-banner__timestamp')).toBeNull();
|
||||
});
|
||||
|
||||
it('D5: FYI button is a <button>, not an <a> (no navigation)', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
const fyi = document.querySelector('.note-banner .note-banner__fyi');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.tagName.toLowerCase()).toBe('button');
|
||||
});
|
||||
|
||||
it('D6: NVM dismisses without adding .bud-duplicate-flash anywhere', () => {
|
||||
Brief.showDuplicateBanner({
|
||||
display_name: 'alice',
|
||||
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
|
||||
});
|
||||
document.querySelector('.note-banner__nvm').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
const name = fixture.querySelector('.bud-name');
|
||||
expect(name.classList.contains('bud-duplicate-flash')).toBeFalse();
|
||||
});
|
||||
|
||||
it('D7: FYI dismisses AND adds .bud-duplicate-flash to the target element', () => {
|
||||
Brief.showDuplicateBanner({
|
||||
display_name: 'alice',
|
||||
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
|
||||
});
|
||||
document.querySelector('.note-banner__fyi').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
const name = fixture.querySelector('.bud-name');
|
||||
expect(name.classList.contains('bud-duplicate-flash')).toBeTrue();
|
||||
});
|
||||
|
||||
it('D8: FYI dismisses cleanly when target_selector is missing', () => {
|
||||
Brief.showDuplicateBanner({ display_name: 'alice' });
|
||||
document.querySelector('.note-banner__fyi').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('D9: FYI dismisses cleanly when target_selector matches nothing', () => {
|
||||
Brief.showDuplicateBanner({
|
||||
display_name: 'alice',
|
||||
target_selector: '.gate-slot[data-user-id="does-not-exist"]',
|
||||
});
|
||||
document.querySelector('.note-banner__fyi').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('D10: title escapes HTML in display_name', () => {
|
||||
Brief.showDuplicateBanner({ display_name: '<script>x</script>' });
|
||||
const title = document.querySelector('.note-banner__title');
|
||||
expect(title.textContent).toBe('@<script>x</script> is already present');
|
||||
expect(title.querySelector('script')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Brief.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
onSuccess: function (data) {
|
||||
if (data.bud) _appendBudEntry(data.bud);
|
||||
},
|
||||
duplicateTargetSelector: function (data) {
|
||||
return '.bud-entry[data-bud-id="' + data.recipient_user_id + '"] .bud-name';
|
||||
},
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
@@ -35,5 +35,12 @@ bindBudBtn({
|
||||
onSuccess: function (data) {
|
||||
if (window.Brief && data.brief) Brief.showBanner(data.brief);
|
||||
},
|
||||
duplicateTargetSelector: function (data) {
|
||||
// Pending RoomInvite duplicates have no recipient_user_id (no
|
||||
// visible slot to highlight); selector returning null is fine —
|
||||
// showDuplicateBanner's FYI just dismisses without easing.
|
||||
if (!data.recipient_user_id) return null;
|
||||
return '.gate-slot.filled[data-user-id="' + data.recipient_user_id + '"]';
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,9 @@
|
||||
// under .post-header. State transitions:
|
||||
// 0 → 1+ recipients : "just me, X" → "shared between {chip}" + "& me, X"
|
||||
// ≥1 → +1 recipients: append chip + ", " separator before existing.
|
||||
function _appendRecipientChip(displayName) {
|
||||
// `userId` is stamped onto the chip as data-user-id so a later duplicate-
|
||||
// share attempt can highlight this same element via .bud-duplicate-flash.
|
||||
function _appendRecipientChip(displayName, userId) {
|
||||
if (!displayName) return;
|
||||
var header = document.querySelector('.post-page .post-header');
|
||||
if (!header) return;
|
||||
@@ -78,6 +80,7 @@
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'post-recipient';
|
||||
chip.textContent = displayName;
|
||||
if (userId) chip.dataset.userId = userId;
|
||||
|
||||
if (existingRecipients) {
|
||||
existingRecipients.appendChild(document.createTextNode(', '));
|
||||
@@ -103,7 +106,12 @@
|
||||
onSuccess: function (data) {
|
||||
if (data.line_text) _appendLine(data.line_text);
|
||||
if (window.Brief && data.brief) Brief.showBanner(data.brief);
|
||||
if (data.recipient_display) _appendRecipientChip(data.recipient_display);
|
||||
if (data.recipient_display) {
|
||||
_appendRecipientChip(data.recipient_display, data.recipient_user_id);
|
||||
}
|
||||
},
|
||||
duplicateTargetSelector: function (data) {
|
||||
return '.post-recipient[data-user-id="' + data.recipient_user_id + '"]';
|
||||
},
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Billbuds{% endblock title_text %}
|
||||
@@ -13,3 +14,9 @@
|
||||
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
|
||||
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
{# Brief module — needed by _bud_add_panel's OK handler so the #}
|
||||
{# duplicate-add error banner can render via Brief.showDuplicateBanner.#}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{# Owner viewing — owner-centric prose. "shared between" lists #}
|
||||
{# every recipient; the self line is the owner's own handle. #}
|
||||
{% if other_recipients %}
|
||||
<p class="post-shared-recipients">shared between {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||
<p class="post-shared-recipients">shared between {% for r in other_recipients %}<span class="post-recipient post-attribution" data-user-id="{{ r.id }}">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||
<p class="post-shared-self">& me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||
{% else %}
|
||||
<p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
|
||||
@@ -28,7 +28,7 @@
|
||||
{# (request.user). Sole invitee collapses to a single line; the #}
|
||||
{# "created by …" line attributes the post to its founder. #}
|
||||
{% if other_recipients %}
|
||||
<p class="post-shared-recipients">shared with {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||
<p class="post-shared-recipients">shared with {% for r in other_recipients %}<span class="post-recipient post-attribution" data-user-id="{{ r.id }}">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
|
||||
<p class="post-shared-self">& me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
|
||||
{% else %}
|
||||
<p class="post-shared-self">shared with me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="position-strip">
|
||||
{% for pos in gate_positions %}
|
||||
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}"
|
||||
data-slot="{{ pos.slot.slot_number }}">
|
||||
data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}"{% endif %}>
|
||||
<span class="slot-number">{{ pos.slot.slot_number }}</span>
|
||||
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
|
||||
{% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
|
||||
|
||||
Reference in New Issue
Block a user