brief sprint C1: relocate Post + Line from dashboard → billboard (no behavior change) — TDD
The Post/Line models always read more like billboard tenants than dashboard ones (1st-person personal vs. 2nd-person provenance feed); the upcoming Brief model needs them in the billboard namespace as the canonical surface they FK into. C1 is a pure relocation w. zero new behavior: 789 ITs + 20 sky/Post FTs green against the moved code.
- billboard.models adds Post (owner + shared_with) + Line (text + post FK), schema mirroring the legacy dashboard models 1:1; Post.get_absolute_url now reverses to `billboard:view_post`.
- billboard.forms adds LineForm + ExistingPostLineForm (moved from dashboard.forms; dashboard/forms.py removed).
- billboard.views absorbs new_post / view_post / share_post / my_posts (templates rendered from apps/billboard/post.html + my_posts.html).
- billboard.urls adds the namespaced routes: /billboard/new-post, /billboard/post/<uuid>/, /billboard/post/<uuid>/share-post, /billboard/users/<uuid>/. dashboard.urls drops the corresponding entries.
- _applet-my-posts + _applet-new-post URL refs now use the billboard: namespace; templates/apps/dashboard/{post,my_posts}.html removed.
- api/serializers + api/views + api/tests/integrated/test_views imports flip dashboard.models → billboard.models (PostSerializer / PostDetailAPI / PostLinesAPI / PostsAPI all retain identifiers — the model rename to Brief lands in C2).
- dashboard/tests/integrated/test_{models,views,forms} + dashboard/tests/unit/test_{models,forms} swap imports; test_views URL strings flip /dashboard/post/ → /billboard/post/, /dashboard/new_post → /billboard/new-post, /dashboard/users/ → /billboard/users/, share_post → share-post (path) / billboard:share_post (reverser). Tests stay in dashboard.tests/ for now — relocation TBD.
- functional_tests/my_posts_page.py URL string flips to /billboard/users/.
- Auto-generated migrations: billboard/0001_initial (CreateModel Post + Line), dashboard/0003_remove_post_* (drops legacy Post + Line), drama/0004_alter_gameevent_verb (incidental — choices field caught up).
This commit drops the dashboard Post/Line tables w/o data preservation; user has confirmed staging-side wipe is acceptable. C2 introduces the Brief model + read-tracking + slide-down banner unification. C3 hooks Note-unlock + share-post-invite + magic-link / invalid-link `messages` calls into the new Brief / banner pipeline.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:20:06 -04:00
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
from django.db import models
|
|
|
|
|
from django.urls import reverse
|
2026-05-08 17:35:46 -04:00
|
|
|
from django.utils import timezone
|
brief sprint C1: relocate Post + Line from dashboard → billboard (no behavior change) — TDD
The Post/Line models always read more like billboard tenants than dashboard ones (1st-person personal vs. 2nd-person provenance feed); the upcoming Brief model needs them in the billboard namespace as the canonical surface they FK into. C1 is a pure relocation w. zero new behavior: 789 ITs + 20 sky/Post FTs green against the moved code.
- billboard.models adds Post (owner + shared_with) + Line (text + post FK), schema mirroring the legacy dashboard models 1:1; Post.get_absolute_url now reverses to `billboard:view_post`.
- billboard.forms adds LineForm + ExistingPostLineForm (moved from dashboard.forms; dashboard/forms.py removed).
- billboard.views absorbs new_post / view_post / share_post / my_posts (templates rendered from apps/billboard/post.html + my_posts.html).
- billboard.urls adds the namespaced routes: /billboard/new-post, /billboard/post/<uuid>/, /billboard/post/<uuid>/share-post, /billboard/users/<uuid>/. dashboard.urls drops the corresponding entries.
- _applet-my-posts + _applet-new-post URL refs now use the billboard: namespace; templates/apps/dashboard/{post,my_posts}.html removed.
- api/serializers + api/views + api/tests/integrated/test_views imports flip dashboard.models → billboard.models (PostSerializer / PostDetailAPI / PostLinesAPI / PostsAPI all retain identifiers — the model rename to Brief lands in C2).
- dashboard/tests/integrated/test_{models,views,forms} + dashboard/tests/unit/test_{models,forms} swap imports; test_views URL strings flip /dashboard/post/ → /billboard/post/, /dashboard/new_post → /billboard/new-post, /dashboard/users/ → /billboard/users/, share_post → share-post (path) / billboard:share_post (reverser). Tests stay in dashboard.tests/ for now — relocation TBD.
- functional_tests/my_posts_page.py URL string flips to /billboard/users/.
- Auto-generated migrations: billboard/0001_initial (CreateModel Post + Line), dashboard/0003_remove_post_* (drops legacy Post + Line), drama/0004_alter_gameevent_verb (incidental — choices field caught up).
This commit drops the dashboard Post/Line tables w/o data preservation; user has confirmed staging-side wipe is acceptable. C2 introduces the Brief model + read-tracking + slide-down banner unification. C3 hooks Note-unlock + share-post-invite + magic-link / invalid-link `messages` calls into the new Brief / banner pipeline.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:20:06 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Post(models.Model):
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def name(self):
|
|
|
|
|
return self.lines.first().text
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
ordering = ("id",)
|
|
|
|
|
unique_together = ("post", "text")
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.text
|
2026-05-08 17:35:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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})"
|
|
|
|
|
)
|