- Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set.
- epic.invite_gamer view refactor:
• Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat).
• Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username).
• RoomInvite stores the resolved User's email (or raw input if unregistered).
• Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User.
• Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null.
• Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before.
• Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch.
- _gatekeeper.html: gate-invite-panel block (lines 62-71) removed.
- new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE.
- room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner.
- Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add).
- test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable).
- test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references).
- 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper 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>
197 lines
7.0 KiB
Python
197 lines
7.0 KiB
Python
import uuid
|
|
|
|
from django.db import models
|
|
from django.db.models.signals import post_save
|
|
from django.dispatch import receiver
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
|
|
class Post(models.Model):
|
|
KIND_NOTE_UNLOCK = "note_unlock"
|
|
KIND_USER_POST = "user_post"
|
|
KIND_SHARE_INVITE = "share_invite"
|
|
KIND_CHOICES = [
|
|
(KIND_NOTE_UNLOCK, "Note unlocks"),
|
|
(KIND_USER_POST, "User post"),
|
|
(KIND_SHARE_INVITE, "Share invites"),
|
|
]
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
owner = models.ForeignKey(
|
|
"lyric.User",
|
|
related_name="posts",
|
|
blank=True,
|
|
null=True,
|
|
on_delete=models.CASCADE,
|
|
)
|
|
|
|
shared_with = models.ManyToManyField(
|
|
"lyric.User",
|
|
related_name="shared_posts",
|
|
blank=True,
|
|
)
|
|
|
|
# `kind` discriminates per-category Posts — e.g. Note.grant_if_new appends
|
|
# to the user's single (owner=user, kind=NOTE_UNLOCK) Post; user-authored
|
|
# composes default to KIND_USER_POST.
|
|
kind = models.CharField(
|
|
max_length=32,
|
|
choices=KIND_CHOICES,
|
|
default=KIND_USER_POST,
|
|
)
|
|
|
|
# Stored title — set explicitly on creation. Note-unlock Posts hardcode
|
|
# "Notes & recognitions"; user_post Posts truncate first line to 35 chars
|
|
# (32 + "..." past length). Replaces the legacy `name` property which
|
|
# gleaned `lines.first().text` lazily and broke if the first Line was
|
|
# later edited or deleted.
|
|
title = models.CharField(max_length=35, default="")
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("billboard:view_post", args=[self.id])
|
|
|
|
|
|
class Line(models.Model):
|
|
text = models.TextField(default="")
|
|
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
|
|
# `author` PROTECTs against accidental sitewide-entity deletion (notably
|
|
# `adman`, the system-author for note_unlock + share_invite Lines).
|
|
# User-typed Lines attribute to the typing User; system-rendered Lines
|
|
# attribute to adman so the per-line "username" column always renders.
|
|
author = models.ForeignKey(
|
|
"lyric.User",
|
|
on_delete=models.PROTECT,
|
|
related_name="authored_lines",
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
# System-authored Lines on NOTE_UNLOCK Posts must set this True; the
|
|
# post_save signal below deletes any Line on a NOTE_UNLOCK Post w.o.
|
|
# this flag (defense-in-depth alongside view_post's POST guard).
|
|
admin_solicited = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
ordering = ("created_at", "id")
|
|
unique_together = ("post", "text")
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
class Brief(models.Model):
|
|
"""A slide-down notification record. Owner = whose attention; post = where
|
|
FYI navigates (and where mark-read happens on GET); line = the specific
|
|
appended Line that triggered it (so the banner can surface its text).
|
|
|
|
`kind` discriminates the affordances the banner renders. NOTE_UNLOCK
|
|
Briefs get a clickable square that jumps direct to my_notes.html;
|
|
SHARE_INVITE Briefs render the invitation copy; USER_POST is the legacy
|
|
user-authored compose flow.
|
|
|
|
Magic-link confirmation + invalid-link banners use the same Gaussian-glass
|
|
visual styling but ride no Brief row (transient one-shot).
|
|
"""
|
|
KIND_NOTE_UNLOCK = "note_unlock"
|
|
KIND_USER_POST = "user_post"
|
|
KIND_SHARE_INVITE = "share_invite"
|
|
KIND_GAME_INVITE = "game_invite"
|
|
KIND_CHOICES = [
|
|
(KIND_NOTE_UNLOCK, "Note unlock"),
|
|
(KIND_USER_POST, "User post"),
|
|
(KIND_SHARE_INVITE, "Share invite"),
|
|
(KIND_GAME_INVITE, "Game invite"),
|
|
]
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
owner = models.ForeignKey(
|
|
"lyric.User",
|
|
related_name="briefs",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
# Post is nullable now: KIND_GAME_INVITE briefs ride on a Room FK
|
|
# instead of a Post (the gatekeeper invite confirmation has no post
|
|
# to navigate to). Post FKs only set for note_unlock / user_post /
|
|
# share_invite kinds.
|
|
post = models.ForeignKey(
|
|
Post,
|
|
related_name="briefs",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
# Room FK — set only on KIND_GAME_INVITE briefs; FYI navigates to
|
|
# the gatekeeper page for that room.
|
|
room = models.ForeignKey(
|
|
"epic.Room",
|
|
related_name="briefs",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
# Line is nullable because a share_invite-style Brief can race ahead of its
|
|
# async-appended Line write; the post FK alone is enough to navigate.
|
|
line = models.ForeignKey(
|
|
Line,
|
|
related_name="briefs",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
is_unread = models.BooleanField(default=True)
|
|
kind = models.CharField(
|
|
max_length=32,
|
|
choices=KIND_CHOICES,
|
|
default=KIND_USER_POST,
|
|
)
|
|
title = models.CharField(max_length=255, blank=True)
|
|
created_at = models.DateTimeField(default=timezone.now)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return (
|
|
f"Brief({self.kind}, {self.owner.email}, "
|
|
f"unread={self.is_unread})"
|
|
)
|
|
|
|
def to_banner_dict(self):
|
|
"""Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind
|
|
carries a square_url pointing at /billboard/my-notes/ so the
|
|
thumbnail-square inside the banner jumps direct to the user's Note
|
|
collection. GAME_INVITE kind has no Post — the FYI link navigates
|
|
to the gatekeeper page for the brief's Room instead."""
|
|
square_url = ""
|
|
if self.kind == self.KIND_NOTE_UNLOCK:
|
|
square_url = reverse("billboard:my_notes")
|
|
if self.post_id:
|
|
post_url = self.post.get_absolute_url()
|
|
elif self.room_id:
|
|
post_url = reverse("epic:gatekeeper", args=[self.room_id])
|
|
else:
|
|
post_url = ""
|
|
return {
|
|
"id": str(self.id),
|
|
"kind": self.kind,
|
|
"title": self.title,
|
|
"line_text": self.line.text if self.line else "",
|
|
"post_url": post_url,
|
|
"square_url": square_url,
|
|
"created_at": self.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
# ── Listener: nuke unsolicited Lines on NOTE_UNLOCK Posts ─────────────────
|
|
# Defense-in-depth alongside view_post's POST guard. A Line saved on a
|
|
# NOTE_UNLOCK Post that lacks admin_solicited=True (e.g. a stray ORM-level
|
|
# write or an API path that bypasses the view) gets deleted right after
|
|
# the save. Note.grant_if_new sets admin_solicited=True on its Lines so
|
|
# legitimate system prose survives.
|
|
|
|
@receiver(post_save, sender=Line)
|
|
def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
|
|
if not created:
|
|
return
|
|
if instance.post.kind == Post.KIND_NOTE_UNLOCK and not instance.admin_solicited:
|
|
instance.delete()
|