Compare commits
4 Commits
d192b1522d
...
7b2780e642
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b2780e642 | ||
|
|
14bab444ff | ||
|
|
fa53bf561a | ||
|
|
7f9ff36d1d |
34
src/apps/billboard/migrations/0002_brief.py
Normal file
34
src/apps/billboard/migrations/0002_brief.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0 on 2026-05-08 21:34
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Brief',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('is_unread', models.BooleanField(default=True)),
|
||||
('kind', models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite')], default='user_post', max_length=32)),
|
||||
('title', models.CharField(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.line')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to=settings.AUTH_USER_MODEL)),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
src/apps/billboard/migrations/0003_post_kind.py
Normal file
18
src/apps/billboard/migrations/0003_post_kind.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-05-08 21:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0002_brief'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites')], default='user_post', max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -2,9 +2,19 @@ import uuid
|
||||
|
||||
from django.db import models
|
||||
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",
|
||||
@@ -20,6 +30,15 @@ class Post(models.Model):
|
||||
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,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.lines.first().text
|
||||
@@ -38,3 +57,82 @@ class Line(models.Model):
|
||||
|
||||
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_CHOICES = [
|
||||
(KIND_NOTE_UNLOCK, "Note unlock"),
|
||||
(KIND_USER_POST, "User post"),
|
||||
(KIND_SHARE_INVITE, "Share 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 = models.ForeignKey(
|
||||
Post,
|
||||
related_name="briefs",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
# 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 — other kinds get an empty square_url."""
|
||||
square_url = ""
|
||||
if self.kind == self.KIND_NOTE_UNLOCK:
|
||||
square_url = reverse("billboard:my_notes")
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"kind": self.kind,
|
||||
"title": self.title,
|
||||
"line_text": self.line.text if self.line else "",
|
||||
"post_url": self.post.get_absolute_url(),
|
||||
"square_url": square_url,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
121
src/apps/billboard/tests/integrated/test_brief.py
Normal file
121
src/apps/billboard/tests/integrated/test_brief.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""ITs for the Brief model & view_post's mark-read behavior.
|
||||
|
||||
Brief is a notification record — owner + post FK + line FK + is_unread + kind.
|
||||
It rides on a Post (one-Post-per-category, Lines accumulate). Clicking FYI on
|
||||
a Brief banner navigates to billboard:view_post for the underlying Post; that
|
||||
GET is the contract that flips is_unread → False.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class BriefModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="brief@test.io")
|
||||
self.post = Post.objects.create(owner=self.user)
|
||||
self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm")
|
||||
|
||||
def test_brief_defaults_unread(self):
|
||||
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||
self.assertTrue(b.is_unread)
|
||||
|
||||
def test_brief_default_kind_is_user_post(self):
|
||||
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||
self.assertEqual(b.kind, Brief.KIND_USER_POST)
|
||||
|
||||
def test_brief_kind_choices_include_note_unlock_and_share_invite(self):
|
||||
choices = dict(Brief._meta.get_field("kind").choices)
|
||||
self.assertIn(Brief.KIND_NOTE_UNLOCK, choices)
|
||||
self.assertIn(Brief.KIND_USER_POST, choices)
|
||||
self.assertIn(Brief.KIND_SHARE_INVITE, choices)
|
||||
|
||||
def test_brief_line_can_be_null(self):
|
||||
"""A Brief may pre-date its Line (e.g. share-invite spawns the Line
|
||||
async — the Brief should still be persistable while the Line write
|
||||
is pending). Doesn't break the post FK."""
|
||||
b = Brief.objects.create(owner=self.user, post=self.post)
|
||||
self.assertIsNone(b.line)
|
||||
|
||||
def test_brief_owner_post_required(self):
|
||||
"""Brief without owner OR post is invalid; both are the load-bearing
|
||||
FKs (owner = whose attention; post = where FYI navigates)."""
|
||||
from django.db import IntegrityError, transaction
|
||||
with transaction.atomic(), self.assertRaises(IntegrityError):
|
||||
Brief.objects.create(post=self.post, line=self.line)
|
||||
with transaction.atomic(), self.assertRaises(IntegrityError):
|
||||
Brief.objects.create(owner=self.user, line=self.line)
|
||||
|
||||
def test_brief_carries_title(self):
|
||||
b = Brief.objects.create(
|
||||
owner=self.user, post=self.post, line=self.line,
|
||||
title="Look! — new Note unlocked",
|
||||
)
|
||||
self.assertEqual(b.title, "Look! — new Note unlocked")
|
||||
|
||||
def test_brief_str_includes_owner_kind_unread(self):
|
||||
b = Brief.objects.create(owner=self.user, post=self.post, kind=Brief.KIND_NOTE_UNLOCK)
|
||||
s = str(b)
|
||||
self.assertIn("brief@test.io", s)
|
||||
self.assertIn("note_unlock", s)
|
||||
|
||||
|
||||
class ViewPostMarksReadTest(TestCase):
|
||||
"""GET /billboard/post/<uuid>/ flips every unread Brief on that post for
|
||||
the requesting user to is_unread=False. NVM (banner dismiss client-side
|
||||
without nav) leaves Briefs untouched — that path doesn't hit this view."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="reader@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.post = Post.objects.create(owner=self.user)
|
||||
self.line = Line.objects.create(post=self.post, text="entry one")
|
||||
|
||||
def test_get_view_post_flips_owner_unread_brief_to_read(self):
|
||||
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||
self.assertTrue(b.is_unread)
|
||||
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
b.refresh_from_db()
|
||||
self.assertFalse(b.is_unread)
|
||||
|
||||
def test_get_does_not_flip_other_users_briefs(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
# Both users have a Brief on this post; only the requesting user's flips
|
||||
mine = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||
theirs = Brief.objects.create(owner=other, post=self.post, line=self.line)
|
||||
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
mine.refresh_from_db()
|
||||
theirs.refresh_from_db()
|
||||
self.assertFalse(mine.is_unread)
|
||||
self.assertTrue(theirs.is_unread)
|
||||
|
||||
def test_get_does_not_flip_briefs_on_other_posts(self):
|
||||
other_post = Post.objects.create(owner=self.user)
|
||||
other_line = Line.objects.create(post=other_post, text="other")
|
||||
unrelated = Brief.objects.create(owner=self.user, post=other_post, line=other_line)
|
||||
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
unrelated.refresh_from_db()
|
||||
self.assertTrue(unrelated.is_unread)
|
||||
|
||||
def test_get_idempotent_for_already_read_brief(self):
|
||||
already_read = Brief.objects.create(
|
||||
owner=self.user, post=self.post, line=self.line, is_unread=False,
|
||||
)
|
||||
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||
already_read.refresh_from_db()
|
||||
self.assertFalse(already_read.is_unread)
|
||||
|
||||
def test_post_request_does_not_mark_read(self):
|
||||
"""Posting a new Line to view_post (the legacy compose flow) is not
|
||||
the FYI-read contract — the user is composing, not reviewing. Mark-
|
||||
read happens only on a GET render of post.html."""
|
||||
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||
self.client.post(
|
||||
reverse("billboard:view_post", args=[self.post.id]),
|
||||
data={"text": "appended via POST"},
|
||||
)
|
||||
b.refresh_from_db()
|
||||
self.assertTrue(b.is_unread)
|
||||
125
src/apps/billboard/tests/integrated/test_share_post.py
Normal file
125
src/apps/billboard/tests/integrated/test_share_post.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""ITs for share-post async-Brief flow (C3.b).
|
||||
|
||||
POST /billboard/post/<uuid>/share-post w. Accept: application/json now:
|
||||
- Adds the recipient to Post.shared_with (if registered, not the sharer)
|
||||
- Appends a Line to the Post recording the share event
|
||||
- Spawns a Brief(kind=SHARE_INVITE) for the sharer that JS slide-downs
|
||||
- Returns JSON {brief: {…}, line_text: "…"}; no redirect, no messages
|
||||
|
||||
Legacy form-submit (no Accept: application/json) still redirects + flashes
|
||||
the privacy-safe success message — kept for non-AJAX fallback / older FTs.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class SharePostAsyncTest(TestCase):
|
||||
def setUp(self):
|
||||
self.sharer = User.objects.create(email="sharer@test.io")
|
||||
self.client.force_login(self.sharer)
|
||||
self.post = Post.objects.create(owner=self.sharer)
|
||||
|
||||
def _share_async(self, recipient_email):
|
||||
return self.client.post(
|
||||
reverse("billboard:share_post", args=[self.post.id]),
|
||||
data={"recipient": recipient_email},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
def test_async_share_returns_brief_payload(self):
|
||||
User.objects.create(email="alice@test.io")
|
||||
response = self._share_async("alice@test.io")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertIn("brief", body)
|
||||
self.assertIn("line_text", body)
|
||||
|
||||
def test_async_share_appends_line_to_post(self):
|
||||
User.objects.create(email="alice@test.io")
|
||||
self.assertEqual(self.post.lines.count(), 0)
|
||||
self._share_async("alice@test.io")
|
||||
self.assertEqual(self.post.lines.count(), 1)
|
||||
line = self.post.lines.first()
|
||||
self.assertIn("alice@test.io", line.text)
|
||||
|
||||
def test_async_share_creates_share_invite_brief_for_sharer(self):
|
||||
User.objects.create(email="alice@test.io")
|
||||
self._share_async("alice@test.io")
|
||||
brief = Brief.objects.get(owner=self.sharer)
|
||||
self.assertEqual(brief.kind, Brief.KIND_SHARE_INVITE)
|
||||
self.assertEqual(brief.post, self.post)
|
||||
self.assertIsNotNone(brief.line)
|
||||
self.assertTrue(brief.is_unread)
|
||||
|
||||
def test_async_share_adds_registered_recipient_to_shared_with(self):
|
||||
alice = User.objects.create(email="alice@test.io")
|
||||
self._share_async("alice@test.io")
|
||||
self.assertIn(alice, self.post.shared_with.all())
|
||||
|
||||
def test_async_share_unregistered_recipient_still_appends_line_and_brief(self):
|
||||
"""Privacy: even if the email isn't registered, the sharer gets the
|
||||
same confirmation Brief + Line. Otherwise the response shape would
|
||||
leak whether an address is on the system."""
|
||||
response = self._share_async("ghost@test.io")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.post.lines.count(), 1)
|
||||
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 1)
|
||||
|
||||
def test_async_share_does_not_add_owner_as_recipient(self):
|
||||
"""Sharer shares w. their own email — no shared_with add, no Line, no
|
||||
Brief; response carries brief: null so the JS just no-ops."""
|
||||
response = self._share_async("sharer@test.io")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["brief"], None)
|
||||
self.assertEqual(self.post.lines.count(), 0)
|
||||
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 0)
|
||||
self.assertNotIn(self.sharer, self.post.shared_with.all())
|
||||
|
||||
def test_async_share_brief_payload_carries_share_invite_kind(self):
|
||||
User.objects.create(email="alice@test.io")
|
||||
body = self._share_async("alice@test.io").json()
|
||||
self.assertEqual(body["brief"]["kind"], "share_invite")
|
||||
self.assertIn("alice@test.io", body["line_text"])
|
||||
|
||||
def test_async_share_line_text_dedupes_via_timestamp(self):
|
||||
"""Two consecutive shares of the same email must not collide on the
|
||||
Line.unique_together(post, text) constraint — the share line should
|
||||
carry a timestamp suffix."""
|
||||
User.objects.create(email="alice@test.io")
|
||||
self._share_async("alice@test.io")
|
||||
# Second share — should append a second distinct Line, not 500.
|
||||
response = self._share_async("alice@test.io")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.post.lines.count(), 2)
|
||||
|
||||
|
||||
class SharePostLegacyRedirectTest(TestCase):
|
||||
"""Legacy form-submit path (no Accept: application/json) is preserved —
|
||||
redirects + flashes the privacy-safe message + adds shared_with. Existing
|
||||
FTs that submit the share form via Selenium still work."""
|
||||
|
||||
def setUp(self):
|
||||
self.sharer = User.objects.create(email="sharer@test.io")
|
||||
self.client.force_login(self.sharer)
|
||||
self.post = Post.objects.create(owner=self.sharer)
|
||||
|
||||
def test_form_submit_still_redirects(self):
|
||||
User.objects.create(email="alice@test.io")
|
||||
response = self.client.post(
|
||||
reverse("billboard:share_post", args=[self.post.id]),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response["Location"], reverse("billboard:view_post", args=[self.post.id]))
|
||||
|
||||
def test_form_submit_still_adds_shared_with(self):
|
||||
alice = User.objects.create(email="alice@test.io")
|
||||
self.client.post(
|
||||
reverse("billboard:share_post", args=[self.post.id]),
|
||||
data={"recipient": "alice@test.io"},
|
||||
)
|
||||
self.assertIn(alice, self.post.shared_with.all())
|
||||
@@ -8,8 +8,10 @@ from django.http import HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billboard.forms import ExistingPostLineForm, LineForm
|
||||
from apps.billboard.models import Post
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.dashboard.views import _PALETTE_DEFS
|
||||
from apps.drama.models import GameEvent, Note, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
@@ -253,6 +255,14 @@ def view_post(request, post_id):
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect(our_post)
|
||||
|
||||
# GET render is the FYI-read contract — flip every unread Brief on this
|
||||
# post for the requesting user. POST (compose) is intentionally excluded
|
||||
# because the user is authoring, not reviewing the new Line.
|
||||
if request.user.is_authenticated:
|
||||
Brief.objects.filter(
|
||||
owner=request.user, post=our_post, is_unread=True,
|
||||
).update(is_unread=False)
|
||||
return render(request, "apps/billboard/post.html", {"post": our_post, "form": form})
|
||||
|
||||
|
||||
@@ -267,13 +277,58 @@ def my_posts(request, user_id):
|
||||
|
||||
def share_post(request, post_id):
|
||||
our_post = Post.objects.get(id=post_id)
|
||||
is_ajax = "application/json" in request.headers.get("Accept", "")
|
||||
|
||||
recipient_email = request.POST.get("recipient", "")
|
||||
recipient = None
|
||||
try:
|
||||
recipient = User.objects.get(email=request.POST["recipient"])
|
||||
if recipient == request.user:
|
||||
return redirect(our_post)
|
||||
our_post.shared_with.add(recipient)
|
||||
recipient = User.objects.get(email=recipient_email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
|
||||
if recipient is not None and recipient == request.user:
|
||||
if is_ajax:
|
||||
return JsonResponse({"brief": None, "line_text": ""})
|
||||
return redirect(our_post)
|
||||
|
||||
if recipient is not None:
|
||||
our_post.shared_with.add(recipient)
|
||||
|
||||
# Always append a Line + spawn a Brief for the sharer — privacy: the
|
||||
# response shape mustn't leak whether the email is on the system. Line
|
||||
# text carries an isoformat timestamp w/ microseconds so two rapid
|
||||
# shares of the same email don't collide on the
|
||||
# Line.unique_together(post, text) constraint.
|
||||
line_text = (
|
||||
f"Shared with {recipient_email} at {timezone.now().isoformat()}"
|
||||
)
|
||||
line = Line.objects.create(post=our_post, text=line_text)
|
||||
|
||||
brief = None
|
||||
if request.user.is_authenticated:
|
||||
brief = Brief.objects.create(
|
||||
owner=request.user,
|
||||
post=our_post,
|
||||
line=line,
|
||||
kind=Brief.KIND_SHARE_INVITE,
|
||||
title="Invite sent",
|
||||
)
|
||||
|
||||
if is_ajax:
|
||||
# recipient_display is populated only when the address resolves to a
|
||||
# registered User — same evidence the server-rendered .post-recipient
|
||||
# list exposes; doesn't widen the privacy surface beyond what the
|
||||
# post detail page already shows publicly.
|
||||
recipient_display = None
|
||||
if recipient is not None:
|
||||
recipient_display = recipient.username or recipient.email
|
||||
return JsonResponse({
|
||||
"brief": brief.to_banner_dict() if brief is not None else None,
|
||||
"line_text": line_text,
|
||||
"recipient_display": recipient_display,
|
||||
})
|
||||
|
||||
messages.success(request, "An invite has been sent if that address is registered.")
|
||||
return redirect(our_post)
|
||||
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
const Note = (() => {
|
||||
// Slide-down Brief banner — appears under the navbar h2 with a Gaussian-glass
|
||||
// background. Banner data comes from the Brief.to_banner_dict shape on the
|
||||
// server: {id, kind, title, line_text, post_url, square_url, created_at}.
|
||||
//
|
||||
// FYI button → brief.post_url (the post detail; that GET marks the Brief read).
|
||||
// .note-banner__image (the "square") → brief.square_url (kind-specific
|
||||
// shortcut; note-unlock briefs jump direct to /billboard/my-notes/).
|
||||
// NVM dismisses without marking read.
|
||||
//
|
||||
// `Note` is preserved as an alias for backwards-compat with any leftover
|
||||
// caller while the C3 sprint lands; new code should use `Brief.*`.
|
||||
|
||||
const Brief = (() => {
|
||||
'use strict';
|
||||
|
||||
function showBanner(note) {
|
||||
if (!note) return;
|
||||
function showBanner(brief) {
|
||||
if (!brief) return;
|
||||
|
||||
const earned = new Date(note.earned_at);
|
||||
const dateStr = earned.toLocaleDateString(undefined, {
|
||||
const created = new Date(brief.created_at);
|
||||
const dateStr = created.toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'note-banner';
|
||||
|
||||
const squareEl = brief.square_url
|
||||
? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image"></a>'
|
||||
: '<div class="note-banner__image"></div>';
|
||||
|
||||
banner.innerHTML =
|
||||
'<div class="note-banner__body">' +
|
||||
'<p class="note-banner__title">' + _esc(note.title) + '</p>' +
|
||||
'<p class="note-banner__description">' + _esc(note.description) + '</p>' +
|
||||
'<time class="note-banner__timestamp" datetime="' + _esc(note.earned_at) + '">' +
|
||||
'<p class="note-banner__title">' + _esc(brief.title) + '</p>' +
|
||||
'<p class="note-banner__description">' + _esc(brief.line_text) + '</p>' +
|
||||
'<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' +
|
||||
dateStr +
|
||||
'</time>' +
|
||||
'</div>' +
|
||||
'<div class="note-banner__image"></div>' +
|
||||
squareEl +
|
||||
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
|
||||
'<a href="/billboard/my-notes/" class="btn btn-info note-banner__fyi">FYI</a>';
|
||||
'<a href="' + _esc(brief.post_url) + '" class="btn btn-info note-banner__fyi">FYI</a>';
|
||||
|
||||
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
|
||||
banner.remove();
|
||||
@@ -36,7 +53,7 @@ const Note = (() => {
|
||||
}
|
||||
|
||||
function handleSaveResponse(data) {
|
||||
showBanner(data && data.note);
|
||||
showBanner(data && data.brief);
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
@@ -47,3 +64,6 @@ const Note = (() => {
|
||||
|
||||
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
|
||||
})();
|
||||
|
||||
// Backwards-compat shim — to be removed once the codebase uniformly uses Brief.
|
||||
const Note = Brief;
|
||||
|
||||
@@ -272,23 +272,25 @@ class SkySaveNoteTest(TestCase):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_first_save_with_chart_data_returns_stargazer_note(self):
|
||||
def test_first_save_with_chart_data_returns_stargazer_brief(self):
|
||||
data = self._post().json()
|
||||
self.assertIn("note", data)
|
||||
recog = data["note"]
|
||||
self.assertEqual(recog["slug"], "stargazer")
|
||||
self.assertIn("title", recog)
|
||||
self.assertIn("description", recog)
|
||||
self.assertIn("earned_at", recog)
|
||||
self.assertIn("brief", data)
|
||||
brief = data["brief"]
|
||||
self.assertEqual(brief["kind"], "note_unlock")
|
||||
self.assertEqual(brief["title"], "Stargazer")
|
||||
self.assertIn("Stargazer", brief["line_text"])
|
||||
self.assertIn("/billboard/post/", brief["post_url"])
|
||||
self.assertEqual(brief["square_url"], "/billboard/my-notes/")
|
||||
self.assertIn("created_at", brief)
|
||||
|
||||
def test_first_save_creates_note_in_db(self):
|
||||
self._post()
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
|
||||
|
||||
def test_second_save_returns_null_note(self):
|
||||
def test_second_save_returns_null_brief(self):
|
||||
self._post()
|
||||
data = self._post().json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertIsNone(data["brief"])
|
||||
|
||||
def test_second_save_does_not_create_duplicate_note(self):
|
||||
self._post()
|
||||
@@ -297,12 +299,12 @@ class SkySaveNoteTest(TestCase):
|
||||
|
||||
def test_save_with_empty_chart_data_does_not_grant_note(self):
|
||||
data = self._post(chart_data={}).json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertIsNone(data["brief"])
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
|
||||
|
||||
def test_save_with_null_chart_data_does_not_grant_note(self):
|
||||
data = self._post(chart_data=None).json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertIsNone(data["brief"])
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
|
||||
|
||||
|
||||
|
||||
@@ -362,18 +362,13 @@ def sky_save(request):
|
||||
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
|
||||
])
|
||||
|
||||
note_payload = None
|
||||
brief_payload = None
|
||||
if user.sky_chart_data:
|
||||
note, created = Note.grant_if_new(user, "stargazer")
|
||||
if created:
|
||||
note_payload = {
|
||||
"slug": note.slug,
|
||||
"title": "Stargazer",
|
||||
"description": "You saved your first personal sky chart.",
|
||||
"earned_at": note.earned_at.isoformat(),
|
||||
}
|
||||
note, created, brief = Note.grant_if_new(user, "stargazer")
|
||||
if created and brief is not None:
|
||||
brief_payload = brief.to_banner_dict()
|
||||
|
||||
return JsonResponse({"saved": True, "note": note_payload})
|
||||
return JsonResponse({"saved": True, "brief": brief_payload})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
|
||||
@@ -237,8 +237,41 @@ class Note(models.Model):
|
||||
|
||||
@classmethod
|
||||
def grant_if_new(cls, user, slug):
|
||||
"""Grants the Note if it doesn't already exist on the user; on a fresh
|
||||
grant ALSO appends a Line to the user's per-category "Note Unlocks"
|
||||
Post (creating the Post on first-ever unlock) and spawns a Brief that
|
||||
FKs the appended Line. Returns ``(note, created, brief)`` — brief is
|
||||
None on idempotent re-grants. Banner-side affordances (FYI navigation,
|
||||
my-notes square) ride on the Brief.kind=NOTE_UNLOCK discriminator."""
|
||||
from django.utils import timezone
|
||||
return cls.objects.get_or_create(
|
||||
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
|
||||
note, created = cls.objects.get_or_create(
|
||||
user=user, slug=slug,
|
||||
defaults={"earned_at": timezone.now()},
|
||||
)
|
||||
if not created:
|
||||
return note, created, None
|
||||
|
||||
post, _ = Post.objects.get_or_create(
|
||||
owner=user, kind=Post.KIND_NOTE_UNLOCK,
|
||||
)
|
||||
# Per-category header Line (becomes Post.name) — only added once on
|
||||
# first-ever unlock for this user.
|
||||
Line.objects.get_or_create(post=post, text="Look! — new Note unlocked")
|
||||
# Per-event Line — text dedupe is enforced by the unique_together on
|
||||
# (post, text), so two unlocks of the same slug at the same minute
|
||||
# would clash; the timestamp suffix carries the second of resolution.
|
||||
# %-I (lstrip-zero hour) is POSIX-only; %I gives "05:21:00 PM" — fine
|
||||
# on Windows + Linux, and the leading zero is acceptable in a Line.
|
||||
line_text = f"{note.display_title}, {note.earned_at:%I:%M:%S %p}"
|
||||
line = Line.objects.create(post=post, text=line_text)
|
||||
brief = Brief.objects.create(
|
||||
owner=user,
|
||||
post=post,
|
||||
line=line,
|
||||
kind=Brief.KIND_NOTE_UNLOCK,
|
||||
title=note.display_title,
|
||||
)
|
||||
return note, created, brief
|
||||
|
||||
@@ -308,14 +308,14 @@ class NoteModelTest(TestCase):
|
||||
self.assertIn("earner@test.io", s)
|
||||
|
||||
def test_grant_if_new_creates_on_first_call(self):
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertTrue(created)
|
||||
self.assertEqual(recog.slug, "stargazer")
|
||||
self.assertIsNotNone(recog.earned_at)
|
||||
|
||||
def test_grant_if_new_is_idempotent(self):
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertEqual(Note.objects.count(), 1)
|
||||
|
||||
@@ -324,6 +324,6 @@ class NoteModelTest(TestCase):
|
||||
user=self.user, slug="stargazer",
|
||||
earned_at=timezone.now(), palette="palette-bardo",
|
||||
)
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertEqual(recog.palette, "palette-bardo")
|
||||
|
||||
85
src/apps/drama/tests/integrated/test_note_brief.py
Normal file
85
src/apps/drama/tests/integrated/test_note_brief.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""ITs for the Brief sprint C3.a — Note.grant_if_new spawns Line + Brief.
|
||||
|
||||
Per the per-category Post model: the user has a single "Note Unlocks" Post;
|
||||
each unlock appends a Line ("Stargazer, 5:21pm") and spawns a Brief FKing
|
||||
the appended Line. Briefs of kind=NOTE_UNLOCK live on Posts of kind=
|
||||
NOTE_UNLOCK.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.drama.models import Note
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class GrantIfNewSpawnsBriefTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="brief-grant@test.io")
|
||||
|
||||
def test_first_grant_creates_post_line_and_brief(self):
|
||||
note, created, brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertTrue(created)
|
||||
self.assertIsNotNone(brief)
|
||||
# Note Unlocks Post created on user
|
||||
post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
|
||||
# Brief points at that Post + the appended Line
|
||||
self.assertEqual(brief.post_id, post.id)
|
||||
self.assertEqual(brief.kind, Brief.KIND_NOTE_UNLOCK)
|
||||
self.assertTrue(brief.is_unread)
|
||||
self.assertEqual(brief.owner, self.user)
|
||||
# The Brief's line is one of the Post's lines
|
||||
self.assertIn(brief.line, list(post.lines.all()))
|
||||
# Brief title matches Note display title
|
||||
self.assertEqual(brief.title, note.display_title)
|
||||
|
||||
def test_second_grant_same_slug_returns_no_brief(self):
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
note, created, brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertIsNone(brief)
|
||||
# Still only one Brief / Line for the user (idempotent)
|
||||
self.assertEqual(
|
||||
Brief.objects.filter(owner=self.user, kind=Brief.KIND_NOTE_UNLOCK).count(), 1
|
||||
)
|
||||
|
||||
def test_two_different_grants_share_one_post(self):
|
||||
"""Per-category Post: stargazer + schizo unlocks both append Lines to
|
||||
the same Note Unlocks Post (one growing thread)."""
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
Note.grant_if_new(self.user, "schizo")
|
||||
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
|
||||
self.assertEqual(posts.count(), 1, "Only one Note Unlocks Post per user")
|
||||
post = posts.first()
|
||||
# 2 Briefs, one per unlock
|
||||
self.assertEqual(Brief.objects.filter(post=post).count(), 2)
|
||||
# 3 distinct Lines on the Post: 1 header ("Look! — new Note unlocked")
|
||||
# + 2 per-event Lines (one per unlock)
|
||||
line_texts = list(post.lines.values_list("text", flat=True))
|
||||
self.assertEqual(len(set(line_texts)), 3)
|
||||
|
||||
def test_brief_line_text_includes_note_title(self):
|
||||
_, _, brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertIn("Stargazer", brief.line.text)
|
||||
|
||||
def test_post_kind_is_note_unlock_for_grant(self):
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
|
||||
self.assertEqual(post.kind, Post.KIND_NOTE_UNLOCK)
|
||||
|
||||
|
||||
class PostKindFieldTest(TestCase):
|
||||
"""Post gains a `kind` enum so the per-category lookup (e.g. find the
|
||||
user's Note Unlocks Post) is deterministic; user-authored Posts default
|
||||
to KIND_USER_POST."""
|
||||
|
||||
def test_post_default_kind_is_user_post(self):
|
||||
u = User.objects.create(email="kind@test.io")
|
||||
p = Post.objects.create(owner=u)
|
||||
self.assertEqual(p.kind, Post.KIND_USER_POST)
|
||||
|
||||
def test_post_kind_choices_include_three_values(self):
|
||||
choices = dict(Post._meta.get_field("kind").choices)
|
||||
self.assertIn(Post.KIND_NOTE_UNLOCK, choices)
|
||||
self.assertIn(Post.KIND_USER_POST, choices)
|
||||
self.assertIn(Post.KIND_SHARE_INVITE, choices)
|
||||
@@ -150,16 +150,27 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
|
||||
)
|
||||
banner.find_element(By.CSS_SELECTOR, ".note-banner__description")
|
||||
banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
|
||||
banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
|
||||
# Per the Brief sprint, .note-banner__image is now a clickable <a> for
|
||||
# NOTE_UNLOCK kind — square goes to my_notes.html (the legacy FYI
|
||||
# behavior preserved on the square).
|
||||
square = banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
|
||||
self.assertEqual(square.tag_name, "a")
|
||||
self.assertEqual(square.get_attribute("href").rstrip("/"),
|
||||
self.live_server_url + "/billboard/my-notes")
|
||||
banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM
|
||||
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info") # FYI
|
||||
|
||||
# FYI navigates to Note page
|
||||
# FYI now navigates to the underlying Brief's Post detail
|
||||
# (/billboard/post/<uuid>/) — the GET render is the mark-read contract.
|
||||
fyi.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertRegex(self.browser.current_url, r"/billboard/my-notes")
|
||||
lambda: self.assertRegex(self.browser.current_url, r"/billboard/post/[0-9a-f-]+/")
|
||||
)
|
||||
|
||||
# Square (.note-banner__image) preserves the jump-direct-to-my-notes
|
||||
# behavior. Reload the dashboard and re-fire the banner via a stash.
|
||||
self.browser.get(self.live_server_url + "/billboard/my-notes/")
|
||||
|
||||
# Note page: one Stargazer item
|
||||
item = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-list .note-item")
|
||||
|
||||
@@ -38,7 +38,7 @@ class NewVisitorTest(FunctionalTest):
|
||||
edith_post_url = self.browser.current_url
|
||||
self.assertRegex(
|
||||
edith_post_url,
|
||||
r'/dashboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
|
||||
r'/billboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
|
||||
)
|
||||
|
||||
self.browser.delete_all_cookies()
|
||||
@@ -54,7 +54,7 @@ class NewVisitorTest(FunctionalTest):
|
||||
francis_post_url = self.browser.current_url
|
||||
self.assertRegex(
|
||||
francis_post_url,
|
||||
r'/dashboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
|
||||
r'/billboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
|
||||
)
|
||||
self.assertNotEqual(francis_post_url, edith_post_url)
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ class SharingTest(FunctionalTest):
|
||||
|
||||
self.browser = disco_browser
|
||||
self.browser.refresh()
|
||||
post_page.wait_for_row_in_post_table("At your command, Disco King", 2)
|
||||
# Line numbering: 1) "Send help" 2) "Shared with alice@test.io …"
|
||||
# (auto-appended by share_post in C3.b) 3) Alice's reply.
|
||||
post_page.wait_for_row_in_post_table("At your command, Disco King", 3)
|
||||
|
||||
class PostAccessTest(FunctionalTest):
|
||||
def test_stranger_cannot_access_owned_post(self):
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
// ── NoteSpec.js ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for note.js — banner injection from sky/save response.
|
||||
// Unit specs for note.js (the slide-down Brief banner). The banner module is
|
||||
// exposed as `Brief` (with `Note` kept as an alias during the C3 sprint).
|
||||
//
|
||||
// DOM contract assumed by showBanner():
|
||||
// Any <h2> in the document — banner is inserted immediately after it.
|
||||
// If absent, banner is prepended to <body>.
|
||||
//
|
||||
// API under test:
|
||||
// Note.showBanner(note)
|
||||
// note = { slug, title, description, earned_at } → inject .note-banner
|
||||
// note = null → no-op
|
||||
// Brief.showBanner(brief)
|
||||
// brief = { id, kind, title, line_text, post_url, square_url, created_at }
|
||||
// → inject .note-banner
|
||||
// brief = null → no-op
|
||||
//
|
||||
// Note.handleSaveResponse(data)
|
||||
// data = { saved: true, note: {...} } → delegates to showBanner
|
||||
// data = { saved: true, note: null } → no-op
|
||||
// Brief.handleSaveResponse(data)
|
||||
// data = { saved: true, brief: {...} } → delegates to showBanner
|
||||
// data = { saved: true, brief: null } → no-op
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_NOTE = {
|
||||
slug: 'stargazer',
|
||||
title: 'Stargazer',
|
||||
description: 'You saved your first personal sky chart.',
|
||||
earned_at: '2026-04-22T02:00:00+00:00',
|
||||
const SAMPLE_BRIEF = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
kind: 'note_unlock',
|
||||
title: 'Stargazer',
|
||||
line_text: 'Stargazer, 02:00:00 AM',
|
||||
post_url: '/billboard/post/abc/',
|
||||
square_url: '/billboard/my-notes/',
|
||||
created_at: '2026-04-22T02:00:00+00:00',
|
||||
};
|
||||
|
||||
describe('Note.showBanner', () => {
|
||||
describe('Brief.showBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
@@ -43,69 +48,72 @@ describe('Note.showBanner', () => {
|
||||
// ── T1 ── null → no banner ────────────────────────────────────────────────
|
||||
|
||||
it('T1: showBanner(null) does not inject a banner', () => {
|
||||
Note.showBanner(null);
|
||||
Brief.showBanner(null);
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T2 ── note present → banner in DOM ───────────────────────────────────
|
||||
// ── T2 ── brief present → banner in DOM ──────────────────────────────────
|
||||
|
||||
it('T2: showBanner(note) injects .note-banner into the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T2: showBanner(brief) injects .note-banner into the document', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T3 ── title ───────────────────────────────────────────────────────────
|
||||
|
||||
it('T3: banner .note-banner__title contains note.title', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T3: banner .note-banner__title contains brief.title', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__title');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('Stargazer');
|
||||
});
|
||||
|
||||
// ── T4 ── description ─────────────────────────────────────────────────────
|
||||
// ── T4 ── description (line_text) ─────────────────────────────────────────
|
||||
|
||||
it('T4: banner .note-banner__description contains note.description', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T4: banner .note-banner__description carries brief.line_text', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__description');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('You saved your first personal sky chart.');
|
||||
expect(el.textContent).toContain('Stargazer, 02:00:00 AM');
|
||||
});
|
||||
|
||||
// ── T5 ── timestamp ───────────────────────────────────────────────────────
|
||||
|
||||
it('T5: banner has a .note-banner__timestamp element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner__timestamp')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T6 ── image area ──────────────────────────────────────────────────────
|
||||
// ── T6 ── image area; for note_unlock kind it's a clickable square ──────
|
||||
|
||||
it('T6: banner has a .note-banner__image element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
expect(document.querySelector('.note-banner__image')).not.toBeNull();
|
||||
it('T6: banner .note-banner__image is a clickable <a> when brief.square_url is set', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const sq = document.querySelector('.note-banner__image');
|
||||
expect(sq).not.toBeNull();
|
||||
expect(sq.tagName.toLowerCase()).toBe('a');
|
||||
expect(sq.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
});
|
||||
|
||||
// ── T7 ── NVM button ──────────────────────────────────────────────────────
|
||||
|
||||
it('T7: banner has a .btn.btn-cancel NVM button', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T8 ── FYI link ────────────────────────────────────────────────────────
|
||||
// ── T8 ── FYI link points to brief.post_url (not hardcoded my-notes) ─────
|
||||
|
||||
it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const fyi = document.querySelector('.note-banner .btn.btn-info');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/');
|
||||
});
|
||||
|
||||
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
|
||||
|
||||
it('T9: clicking the NVM button removes the banner from the DOM', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
document.querySelector('.note-banner .btn.btn-cancel').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
@@ -113,30 +121,30 @@ describe('Note.showBanner', () => {
|
||||
// ── T10 ── placement after h2 ─────────────────────────────────────────────
|
||||
|
||||
it('T10: banner is inserted immediately after the first h2 in the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const h2 = fixture.querySelector('h2');
|
||||
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Note.handleSaveResponse', () => {
|
||||
describe('Brief.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
||||
});
|
||||
|
||||
// ── T11 ── delegates when note present ────────────────────────────────────
|
||||
// ── T11 ── delegates when brief present ──────────────────────────────────
|
||||
|
||||
it('T11: handleSaveResponse shows banner when data.note is present', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE });
|
||||
it('T11: handleSaveResponse shows banner when data.brief is present', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF });
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T12 ── no banner when note null ───────────────────────────────────────
|
||||
// ── T12 ── no banner when brief null ─────────────────────────────────────
|
||||
|
||||
it('T12: handleSaveResponse does not show banner when data.note is null', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: null });
|
||||
it('T12: handleSaveResponse does not show banner when data.brief is null', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: null });
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,18 @@
|
||||
.note-banner__fyi {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Transient message-banner variants (magic-link confirmation, errors).
|
||||
// No DB Brief row — just inherits the Gaussian-glass shell from the
|
||||
// .note-banner base & shifts the border colour for level=error/warning.
|
||||
&.note-banner--message .note-banner__description {
|
||||
opacity: 1; // message body is the whole content; full opacity
|
||||
}
|
||||
|
||||
&.note-banner--error,
|
||||
&.note-banner--warning {
|
||||
border-color: rgba(var(--priRd), 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes page ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
// ── NoteSpec.js ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for note.js — banner injection from sky/save response.
|
||||
// Unit specs for note.js (the slide-down Brief banner). The banner module is
|
||||
// exposed as `Brief` (with `Note` kept as an alias during the C3 sprint).
|
||||
//
|
||||
// DOM contract assumed by showBanner():
|
||||
// Any <h2> in the document — banner is inserted immediately after it.
|
||||
// If absent, banner is prepended to <body>.
|
||||
//
|
||||
// API under test:
|
||||
// Note.showBanner(note)
|
||||
// note = { slug, title, description, earned_at } → inject .note-banner
|
||||
// note = null → no-op
|
||||
// Brief.showBanner(brief)
|
||||
// brief = { id, kind, title, line_text, post_url, square_url, created_at }
|
||||
// → inject .note-banner
|
||||
// brief = null → no-op
|
||||
//
|
||||
// Note.handleSaveResponse(data)
|
||||
// data = { saved: true, note: {...} } → delegates to showBanner
|
||||
// data = { saved: true, note: null } → no-op
|
||||
// Brief.handleSaveResponse(data)
|
||||
// data = { saved: true, brief: {...} } → delegates to showBanner
|
||||
// data = { saved: true, brief: null } → no-op
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_NOTE = {
|
||||
slug: 'stargazer',
|
||||
title: 'Stargazer',
|
||||
description: 'You saved your first personal sky chart.',
|
||||
earned_at: '2026-04-22T02:00:00+00:00',
|
||||
const SAMPLE_BRIEF = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
kind: 'note_unlock',
|
||||
title: 'Stargazer',
|
||||
line_text: 'Stargazer, 02:00:00 AM',
|
||||
post_url: '/billboard/post/abc/',
|
||||
square_url: '/billboard/my-notes/',
|
||||
created_at: '2026-04-22T02:00:00+00:00',
|
||||
};
|
||||
|
||||
describe('Note.showBanner', () => {
|
||||
describe('Brief.showBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
@@ -43,69 +48,72 @@ describe('Note.showBanner', () => {
|
||||
// ── T1 ── null → no banner ────────────────────────────────────────────────
|
||||
|
||||
it('T1: showBanner(null) does not inject a banner', () => {
|
||||
Note.showBanner(null);
|
||||
Brief.showBanner(null);
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T2 ── note present → banner in DOM ───────────────────────────────────
|
||||
// ── T2 ── brief present → banner in DOM ──────────────────────────────────
|
||||
|
||||
it('T2: showBanner(note) injects .note-banner into the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T2: showBanner(brief) injects .note-banner into the document', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T3 ── title ───────────────────────────────────────────────────────────
|
||||
|
||||
it('T3: banner .note-banner__title contains note.title', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T3: banner .note-banner__title contains brief.title', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__title');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('Stargazer');
|
||||
});
|
||||
|
||||
// ── T4 ── description ─────────────────────────────────────────────────────
|
||||
// ── T4 ── description (line_text) ─────────────────────────────────────────
|
||||
|
||||
it('T4: banner .note-banner__description contains note.description', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T4: banner .note-banner__description carries brief.line_text', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__description');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('You saved your first personal sky chart.');
|
||||
expect(el.textContent).toContain('Stargazer, 02:00:00 AM');
|
||||
});
|
||||
|
||||
// ── T5 ── timestamp ───────────────────────────────────────────────────────
|
||||
|
||||
it('T5: banner has a .note-banner__timestamp element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner__timestamp')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T6 ── image area ──────────────────────────────────────────────────────
|
||||
// ── T6 ── image area; for note_unlock kind it's a clickable square ──────
|
||||
|
||||
it('T6: banner has a .note-banner__image element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
expect(document.querySelector('.note-banner__image')).not.toBeNull();
|
||||
it('T6: banner .note-banner__image is a clickable <a> when brief.square_url is set', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const sq = document.querySelector('.note-banner__image');
|
||||
expect(sq).not.toBeNull();
|
||||
expect(sq.tagName.toLowerCase()).toBe('a');
|
||||
expect(sq.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
});
|
||||
|
||||
// ── T7 ── NVM button ──────────────────────────────────────────────────────
|
||||
|
||||
it('T7: banner has a .btn.btn-cancel NVM button', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T8 ── FYI link ────────────────────────────────────────────────────────
|
||||
// ── T8 ── FYI link points to brief.post_url (not hardcoded my-notes) ─────
|
||||
|
||||
it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const fyi = document.querySelector('.note-banner .btn.btn-info');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/');
|
||||
});
|
||||
|
||||
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
|
||||
|
||||
it('T9: clicking the NVM button removes the banner from the DOM', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
document.querySelector('.note-banner .btn.btn-cancel').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
@@ -113,30 +121,30 @@ describe('Note.showBanner', () => {
|
||||
// ── T10 ── placement after h2 ─────────────────────────────────────────────
|
||||
|
||||
it('T10: banner is inserted immediately after the first h2 in the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const h2 = fixture.querySelector('h2');
|
||||
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Note.handleSaveResponse', () => {
|
||||
describe('Brief.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
||||
});
|
||||
|
||||
// ── T11 ── delegates when note present ────────────────────────────────────
|
||||
// ── T11 ── delegates when brief present ──────────────────────────────────
|
||||
|
||||
it('T11: handleSaveResponse shows banner when data.note is present', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE });
|
||||
it('T11: handleSaveResponse shows banner when data.brief is present', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF });
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T12 ── no banner when note null ───────────────────────────────────────
|
||||
// ── T12 ── no banner when brief null ─────────────────────────────────────
|
||||
|
||||
it('T12: handleSaveResponse does not show banner when data.note is null', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: null });
|
||||
it('T12: handleSaveResponse does not show banner when data.brief is null', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: null });
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
|
||||
<form method="POST" action="{% url "billboard:share_post" post.id %}">
|
||||
<form id="id_share_form" method="POST" action="{% url "billboard:share_post" post.id %}">
|
||||
{% csrf_token %}
|
||||
<input
|
||||
id="id_recipient"
|
||||
@@ -44,9 +44,11 @@
|
||||
<button type="submit" class="btn btn-primary">Share</button>
|
||||
</form>
|
||||
<small>Post shared with:
|
||||
{% for user in post.shared_with.all %}
|
||||
<span class="post-recipient">{{ user|display_name }}</span>
|
||||
{% endfor %}
|
||||
<span id="id_post_recipients">
|
||||
{% for user in post.shared_with.all %}
|
||||
<span class="post-recipient">{{ user|display_name }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,4 +56,64 @@
|
||||
|
||||
{% block scripts %}
|
||||
{% include "apps/dashboard/_partials/_scripts.html" %}
|
||||
<script>
|
||||
// Async share: intercepts the share form, POSTs w. Accept:application/json,
|
||||
// then slide-downs the SHARE_INVITE Brief banner under the navbar h2 + appends
|
||||
// the freshly-recorded Line into #id_post_table. No page reload — the legacy
|
||||
// alert-success flash is gone.
|
||||
(function () {
|
||||
'use strict';
|
||||
var form = document.getElementById('id_share_form');
|
||||
var input = document.getElementById('id_recipient');
|
||||
var table = document.getElementById('id_post_table');
|
||||
var recipientsBox = document.getElementById('id_post_recipients');
|
||||
if (!form || !input || !table) return;
|
||||
|
||||
function _csrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function _appendLine(text) {
|
||||
var n = table.querySelectorAll('tr').length + 1;
|
||||
var tr = document.createElement('tr');
|
||||
var td = document.createElement('td');
|
||||
td.textContent = n + '. ' + text;
|
||||
tr.appendChild(td);
|
||||
table.appendChild(tr);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var fd = new FormData(form);
|
||||
fetch(form.action, {
|
||||
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.line_text) _appendLine(data.line_text);
|
||||
if (window.Brief && data.brief) Brief.showBanner(data.brief);
|
||||
if (data.recipient_display && recipientsBox) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'post-recipient';
|
||||
span.textContent = data.recipient_display;
|
||||
recipientsBox.appendChild(document.createTextNode(' '));
|
||||
recipientsBox.appendChild(span);
|
||||
}
|
||||
input.value = '';
|
||||
})
|
||||
.catch(function () {
|
||||
// No-op for now — the privacy-safe response shape means
|
||||
// even an unregistered recipient is a 200 w. brief data;
|
||||
// a true error path (5xx) silently swallows.
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endblock scripts %}
|
||||
|
||||
@@ -367,7 +367,7 @@
|
||||
formWrap.style.display = 'none';
|
||||
svgEl.style.display = '';
|
||||
SkyWheel.preload().then(() => SkyWheel.draw(svgEl, _lastChartData));
|
||||
Note.handleSaveResponse(data);
|
||||
Brief.handleSaveResponse(data);
|
||||
})
|
||||
.catch(err => {
|
||||
setStatus(`Save failed: ${err.message}`, 'error');
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
})
|
||||
.then(data => {
|
||||
setStatus('Sky saved!');
|
||||
Note.handleSaveResponse(data);
|
||||
Brief.handleSaveResponse(data);
|
||||
_activateSavedState();
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
@@ -30,17 +30,18 @@
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% for message in messages %}
|
||||
{% if message.level_tag == 'success' %}
|
||||
<div class="alert alert-success">{{ message }}</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">{{ message }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for message in messages %}
|
||||
{# Transient Brief-styled banner — no DB row, no FYI/square. #}
|
||||
{# Slides in under the navbar h2 w. the same Gaussian-glass #}
|
||||
{# look as the Brief notification banner; NVM dismisses. #}
|
||||
<div class="note-banner note-banner--message note-banner--{{ message.level_tag }}">
|
||||
<div class="note-banner__body">
|
||||
<p class="note-banner__description">{{ message }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-cancel note-banner__nvm"
|
||||
onclick="this.parentElement.remove()">NVM</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
Reference in New Issue
Block a user