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>
This commit is contained in:
35
src/apps/billboard/forms.py
Normal file
35
src/apps/billboard/forms.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django import forms
|
||||
|
||||
from .models import Line
|
||||
|
||||
|
||||
DUPLICATE_LINE_ERROR = "You've already logged this to your post"
|
||||
EMPTY_LINE_ERROR = "You can't have an empty post line"
|
||||
|
||||
|
||||
class LineForm(forms.Form):
|
||||
text = forms.CharField(
|
||||
error_messages={"required": EMPTY_LINE_ERROR},
|
||||
required=True,
|
||||
)
|
||||
|
||||
def save(self, for_post):
|
||||
return Line.objects.create(
|
||||
post=for_post,
|
||||
text=self.cleaned_data["text"],
|
||||
)
|
||||
|
||||
|
||||
class ExistingPostLineForm(LineForm):
|
||||
def __init__(self, for_post, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._for_post = for_post
|
||||
|
||||
def clean_text(self):
|
||||
text = self.cleaned_data["text"]
|
||||
if self._for_post.lines.filter(text=text).exists():
|
||||
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
|
||||
return text
|
||||
|
||||
def save(self):
|
||||
return super().save(for_post=self._for_post)
|
||||
38
src/apps/billboard/migrations/0001_initial.py
Normal file
38
src/apps/billboard/migrations/0001_initial.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0 on 2026-05-08 21:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Post',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)),
|
||||
('shared_with', models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Line',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.TextField(default='')),
|
||||
('post', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='billboard.post')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('id',),
|
||||
'unique_together': {('post', 'text')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
src/apps/billboard/migrations/__init__.py
Normal file
0
src/apps/billboard/migrations/__init__.py
Normal file
40
src/apps/billboard/models.py
Normal file
40
src/apps/billboard/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
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
|
||||
@@ -13,4 +13,9 @@ urlpatterns = [
|
||||
path("note/<slug:slug>/doff", views.doff_title, name="doff_title"),
|
||||
path("room/<uuid:room_id>/scroll/", views.scroll, name="scroll"),
|
||||
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||
# Post/Line CRUD (relocated from apps.dashboard.urls)
|
||||
path("new-post", views.new_post, name="new_post"),
|
||||
path("post/<uuid:post_id>/", views.view_post, name="view_post"),
|
||||
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
|
||||
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
||||
]
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import json
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.html import mark_safe
|
||||
from django.db.models import Max, Q
|
||||
from django.http import JsonResponse
|
||||
from django.http import HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.dashboard.forms import LineForm
|
||||
from apps.dashboard.models import Post
|
||||
from apps.billboard.forms import ExistingPostLineForm, LineForm
|
||||
from apps.billboard.models import Post
|
||||
from apps.dashboard.views import _PALETTE_DEFS
|
||||
from apps.drama.models import GameEvent, Note, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
from apps.epic.utils import rooms_for_user
|
||||
from apps.lyric.models import User
|
||||
|
||||
_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}
|
||||
|
||||
@@ -211,6 +213,71 @@ def doff_title(request, slug):
|
||||
return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"})
|
||||
|
||||
|
||||
# ── Post / Line CRUD (relocated from apps.dashboard) ────────────────────────
|
||||
# Templates also live under templates/apps/billboard/. URL names sit in the
|
||||
# `billboard:` namespace so reversers across the codebase carry the prefix.
|
||||
|
||||
def new_post(request):
|
||||
form = LineForm(data=request.POST)
|
||||
if form.is_valid():
|
||||
nupost = Post.objects.create()
|
||||
if request.user.is_authenticated:
|
||||
nupost.owner = request.user
|
||||
nupost.save()
|
||||
form.save(for_post=nupost)
|
||||
return redirect(nupost)
|
||||
else:
|
||||
context = {
|
||||
"form": form,
|
||||
"page_class": "page-billboard",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
context["applets"] = applet_context(request.user, "billboard")
|
||||
context["recent_posts"] = _recent_posts(request.user)
|
||||
return render(request, "apps/billboard/billboard.html", context)
|
||||
|
||||
|
||||
def view_post(request, post_id):
|
||||
our_post = Post.objects.get(id=post_id)
|
||||
|
||||
if our_post.owner:
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("/")
|
||||
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
|
||||
return HttpResponseForbidden()
|
||||
|
||||
form = ExistingPostLineForm(for_post=our_post)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect(our_post)
|
||||
return render(request, "apps/billboard/post.html", {"post": our_post, "form": form})
|
||||
|
||||
|
||||
def my_posts(request, user_id):
|
||||
owner = User.objects.get(id=user_id)
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("/")
|
||||
if request.user.id != owner.id:
|
||||
return HttpResponseForbidden()
|
||||
return render(request, "apps/billboard/my_posts.html", {"owner": owner})
|
||||
|
||||
|
||||
def share_post(request, post_id):
|
||||
our_post = Post.objects.get(id=post_id)
|
||||
try:
|
||||
recipient = User.objects.get(email=request.POST["recipient"])
|
||||
if recipient == request.user:
|
||||
return redirect(our_post)
|
||||
our_post.shared_with.add(recipient)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
messages.success(request, "An invite has been sent if that address is registered.")
|
||||
return redirect(our_post)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_scroll_position(request, room_id):
|
||||
if request.method != "POST":
|
||||
|
||||
Reference in New Issue
Block a user