From d192b1522dddae3f7f5d5969493653c8de78445f Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 17:20:06 -0400 Subject: [PATCH] =?UTF-8?q?brief=20sprint=20C1:=20relocate=20Post=20+=20Li?= =?UTF-8?q?ne=20from=20dashboard=20=E2=86=92=20billboard=20(no=20behavior?= =?UTF-8?q?=20change)=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, /billboard/post//share-post, /billboard/users//. 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 Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/apps/api/serializers.py | 2 +- src/apps/api/tests/integrated/test_views.py | 2 +- src/apps/api/views.py | 2 +- src/apps/{dashboard => billboard}/forms.py | 7 +- src/apps/billboard/migrations/0001_initial.py | 38 ++++++++++ src/apps/billboard/migrations/__init__.py | 0 src/apps/billboard/models.py | 40 ++++++++++ src/apps/billboard/urls.py | 5 ++ src/apps/billboard/views.py | 73 ++++++++++++++++++- ..._owner_remove_post_shared_with_and_more.py | 27 +++++++ src/apps/dashboard/models.py | 42 +---------- .../dashboard/tests/integrated/test_forms.py | 4 +- .../dashboard/tests/integrated/test_models.py | 4 +- .../dashboard/tests/integrated/test_views.py | 66 ++++++++--------- src/apps/dashboard/tests/unit/test_forms.py | 2 +- src/apps/dashboard/tests/unit/test_models.py | 2 +- src/apps/dashboard/urls.py | 5 +- src/apps/dashboard/views.py | 68 +---------------- .../migrations/0004_alter_gameevent_verb.py | 18 +++++ src/functional_tests/my_posts_page.py | 2 +- .../billboard/_partials/_applet-my-posts.html | 2 +- .../billboard/_partials/_applet-new-post.html | 2 +- .../{dashboard => billboard}/my_posts.html | 0 .../apps/{dashboard => billboard}/post.html | 4 +- 24 files changed, 256 insertions(+), 161 deletions(-) rename src/apps/{dashboard => billboard}/forms.py (88%) create mode 100644 src/apps/billboard/migrations/0001_initial.py create mode 100644 src/apps/billboard/migrations/__init__.py create mode 100644 src/apps/billboard/models.py create mode 100644 src/apps/dashboard/migrations/0003_remove_post_owner_remove_post_shared_with_and_more.py create mode 100644 src/apps/drama/migrations/0004_alter_gameevent_verb.py rename src/templates/apps/{dashboard => billboard}/my_posts.html (100%) rename src/templates/apps/{dashboard => billboard}/post.html (93%) diff --git a/src/apps/api/serializers.py b/src/apps/api/serializers.py index ae638ca..cf47f58 100644 --- a/src/apps/api/serializers.py +++ b/src/apps/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from apps.dashboard.models import Line, Post +from apps.billboard.models import Line, Post from apps.lyric.models import User diff --git a/src/apps/api/tests/integrated/test_views.py b/src/apps/api/tests/integrated/test_views.py index f042ead..c46214f 100644 --- a/src/apps/api/tests/integrated/test_views.py +++ b/src/apps/api/tests/integrated/test_views.py @@ -1,7 +1,7 @@ from django.test import TestCase from rest_framework.test import APIClient -from apps.dashboard.models import Line, Post +from apps.billboard.models import Line, Post from apps.lyric.models import User class BaseAPITest(TestCase): diff --git a/src/apps/api/views.py b/src/apps/api/views.py index 23239ce..69b1191 100644 --- a/src/apps/api/views.py +++ b/src/apps/api/views.py @@ -3,7 +3,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer -from apps.dashboard.models import Line, Post +from apps.billboard.models import Line, Post from apps.lyric.models import User diff --git a/src/apps/dashboard/forms.py b/src/apps/billboard/forms.py similarity index 88% rename from src/apps/dashboard/forms.py rename to src/apps/billboard/forms.py index 1e6fe56..0754298 100644 --- a/src/apps/dashboard/forms.py +++ b/src/apps/billboard/forms.py @@ -1,13 +1,15 @@ from django import forms -from django.core.exceptions import ValidationError + 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}, + error_messages={"required": EMPTY_LINE_ERROR}, required=True, ) @@ -17,6 +19,7 @@ class LineForm(forms.Form): text=self.cleaned_data["text"], ) + class ExistingPostLineForm(LineForm): def __init__(self, for_post, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/apps/billboard/migrations/0001_initial.py b/src/apps/billboard/migrations/0001_initial.py new file mode 100644 index 0000000..a8b602d --- /dev/null +++ b/src/apps/billboard/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/src/apps/billboard/migrations/__init__.py b/src/apps/billboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py new file mode 100644 index 0000000..ffd8ec8 --- /dev/null +++ b/src/apps/billboard/models.py @@ -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 diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 59f727c..7149371 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -13,4 +13,9 @@ urlpatterns = [ path("note//doff", views.doff_title, name="doff_title"), path("room//scroll/", views.scroll, name="scroll"), path("room//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//", views.view_post, name="view_post"), + path("post//share-post", views.share_post, name="share_post"), + path("users//", views.my_posts, name="my_posts"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 882ad6c..d7e5ba6 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -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": diff --git a/src/apps/dashboard/migrations/0003_remove_post_owner_remove_post_shared_with_and_more.py b/src/apps/dashboard/migrations/0003_remove_post_owner_remove_post_shared_with_and_more.py new file mode 100644 index 0000000..a4ce901 --- /dev/null +++ b/src/apps/dashboard/migrations/0003_remove_post_owner_remove_post_shared_with_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0 on 2026-05-08 21:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0002_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='post', + name='owner', + ), + migrations.RemoveField( + model_name='post', + name='shared_with', + ), + migrations.DeleteModel( + name='Line', + ), + migrations.DeleteModel( + name='Post', + ), + ] diff --git a/src/apps/dashboard/models.py b/src/apps/dashboard/models.py index 00cb97b..0f221a2 100644 --- a/src/apps/dashboard/models.py +++ b/src/apps/dashboard/models.py @@ -1,39 +1,3 @@ -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("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 +# Post + Line have moved to apps.billboard. The legacy tables are dropped via +# the migration in dashboard/migrations/0003_*; data is preserved via the +# RunPython step in billboard/migrations/0002_*. diff --git a/src/apps/dashboard/tests/integrated/test_forms.py b/src/apps/dashboard/tests/integrated/test_forms.py index 15697fd..89d8b98 100644 --- a/src/apps/dashboard/tests/integrated/test_forms.py +++ b/src/apps/dashboard/tests/integrated/test_forms.py @@ -1,12 +1,12 @@ from django.test import TestCase -from apps.dashboard.forms import ( +from apps.billboard.forms import ( DUPLICATE_LINE_ERROR, EMPTY_LINE_ERROR, ExistingPostLineForm, LineForm, ) -from apps.dashboard.models import Line, Post +from apps.billboard.models import Line, Post class LineFormTest(TestCase): diff --git a/src/apps/dashboard/tests/integrated/test_models.py b/src/apps/dashboard/tests/integrated/test_models.py index 090dddd..48a69ef 100644 --- a/src/apps/dashboard/tests/integrated/test_models.py +++ b/src/apps/dashboard/tests/integrated/test_models.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.db.utils import IntegrityError from django.test import TestCase -from apps.dashboard.models import Line, Post +from apps.billboard.models import Line, Post from apps.lyric.models import User @@ -43,7 +43,7 @@ class LineModelTest(TestCase): class PostModelTest(TestCase): def test_get_absolute_url(self): mypost = Post.objects.create() - self.assertEqual(mypost.get_absolute_url(), f"/dashboard/post/{mypost.id}/") + self.assertEqual(mypost.get_absolute_url(), f"/billboard/post/{mypost.id}/") def test_post_lines_order(self): post1 = Post.objects.create() diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index 3352fda..492bd68 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -8,11 +8,11 @@ from django.urls import reverse from django.utils import html, timezone from apps.applets.models import Applet, UserApplet -from apps.dashboard.forms import ( +from apps.billboard.forms import ( DUPLICATE_LINE_ERROR, EMPTY_LINE_ERROR, ) -from apps.dashboard.models import Line, Post +from apps.billboard.models import Line, Post from apps.drama.models import Note from apps.lyric.models import User @@ -37,19 +37,19 @@ class NewPostTest(TestCase): ) def test_can_save_a_POST_request(self): - self.client.post("/dashboard/new_post", data={"text": "A new post line"}) + self.client.post("/billboard/new-post", data={"text": "A new post line"}) self.assertEqual(Line.objects.count(), 1) new_line = Line.objects.get() self.assertEqual(new_line.text, "A new post line") def test_redirects_after_POST(self): - response = self.client.post("/dashboard/new_post", data={"text": "A new post line"}) + response = self.client.post("/billboard/new-post", data={"text": "A new post line"}) new_post = Post.objects.get() - self.assertRedirects(response, f"/dashboard/post/{new_post.id}/") + self.assertRedirects(response, f"/billboard/post/{new_post.id}/") # Post invalid input helper def post_invalid_input(self): - return self.client.post("/dashboard/new_post", data={"text": ""}) + return self.client.post("/billboard/new-post", data={"text": ""}) def test_for_invalid_input_nothing_saved_to_db(self): self.post_invalid_input() @@ -68,12 +68,12 @@ class NewPostTest(TestCase): class PostViewTest(TestCase): def test_uses_post_template(self): mypost = Post.objects.create() - response = self.client.get(f"/dashboard/post/{mypost.id}/") - self.assertTemplateUsed(response, "apps/dashboard/post.html") + response = self.client.get(f"/billboard/post/{mypost.id}/") + self.assertTemplateUsed(response, "apps/billboard/post.html") def test_renders_input_form(self): mypost = Post.objects.create() - url = f"/dashboard/post/{mypost.id}/" + url = f"/billboard/post/{mypost.id}/" response = self.client.get(url) parsed = lxml.html.fromstring(response.content) forms = parsed.cssselect("form[method=POST]") @@ -90,7 +90,7 @@ class PostViewTest(TestCase): other_post = Post.objects.create() Line.objects.create(text="other post line", post=other_post) # When/Act - response = self.client.get(f"/dashboard/post/{correct_post.id}/") + response = self.client.get(f"/billboard/post/{correct_post.id}/") # Then/Assert self.assertContains(response, "itemey 1") self.assertContains(response, "itemey 2") @@ -101,7 +101,7 @@ class PostViewTest(TestCase): correct_post = Post.objects.create() self.client.post( - f"/dashboard/post/{correct_post.id}/", + f"/billboard/post/{correct_post.id}/", data={"text": "A new line for an existing post"}, ) @@ -115,16 +115,16 @@ class PostViewTest(TestCase): correct_post = Post.objects.create() response = self.client.post( - f"/dashboard/post/{correct_post.id}/", + f"/billboard/post/{correct_post.id}/", data={"text": "A new line for an existing post"}, ) - self.assertRedirects(response, f"/dashboard/post/{correct_post.id}/") + self.assertRedirects(response, f"/billboard/post/{correct_post.id}/") # Post invalid input helper def post_invalid_input(self): mypost = Post.objects.create() - return self.client.post(f"/dashboard/post/{mypost.id}/", data={"text": ""}) + return self.client.post(f"/billboard/post/{mypost.id}/", data={"text": ""}) def test_for_invalid_input_nothing_saved_to_db(self): self.post_invalid_input() @@ -133,7 +133,7 @@ class PostViewTest(TestCase): def test_for_invalid_input_renders_post_template(self): response = self.post_invalid_input() self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "apps/dashboard/post.html") + self.assertTemplateUsed(response, "apps/billboard/post.html") def test_for_invalid_input_shows_error_on_page(self): response = self.post_invalid_input() @@ -150,46 +150,46 @@ class PostViewTest(TestCase): Line.objects.create(post=post1, text="lorem ipsum") response = self.client.post( - f"/dashboard/post/{post1.id}/", + f"/billboard/post/{post1.id}/", data={"text": "lorem ipsum"}, ) expected_error = html.escape(DUPLICATE_LINE_ERROR) self.assertContains(response, expected_error) - self.assertTemplateUsed(response, "apps/dashboard/post.html") + self.assertTemplateUsed(response, "apps/billboard/post.html") self.assertEqual(Line.objects.all().count(), 1) class MyPostsTest(TestCase): def test_my_posts_url_renders_my_posts_template(self): user = User.objects.create(email="a@b.cde") self.client.force_login(user) - response = self.client.get(f"/dashboard/users/{user.id}/") - self.assertTemplateUsed(response, "apps/dashboard/my_posts.html") + response = self.client.get(f"/billboard/users/{user.id}/") + self.assertTemplateUsed(response, "apps/billboard/my_posts.html") def test_passes_correct_owner_to_template(self): User.objects.create(email="wrongowner@example.com") correct_user = User.objects.create(email="a@b.cde") self.client.force_login(correct_user) - response = self.client.get(f"/dashboard/users/{correct_user.id}/") + response = self.client.get(f"/billboard/users/{correct_user.id}/") self.assertEqual(response.context["owner"], correct_user) def test_post_owner_is_saved_if_user_is_authenticated(self): user = User.objects.create(email="a@b.cde") self.client.force_login(user) - self.client.post("/dashboard/new_post", data={"text": "new line"}) + self.client.post("/billboard/new-post", data={"text": "new line"}) new_post = Post.objects.get() self.assertEqual(new_post.owner, user) def test_my_posts_redirects_if_not_logged_in(self): user = User.objects.create(email="a@b.cde") - response = self.client.get(f"/dashboard/users/{user.id}/") + response = self.client.get(f"/billboard/users/{user.id}/") self.assertRedirects(response, "/") def test_my_posts_returns_403_for_wrong_user(self): user1 = User.objects.create(email="a@b.cde") user2 = User.objects.create(email="wrongowner@example.com") self.client.force_login(user2) - response = self.client.get(f"/dashboard/users/{user1.id}/") + response = self.client.get(f"/billboard/users/{user1.id}/") self.assertEqual(response.status_code, 403) class SharePostTest(TestCase): @@ -197,16 +197,16 @@ class SharePostTest(TestCase): our_post = Post.objects.create() alice = User.objects.create(email="alice@example.com") response = self.client.post( - f"/dashboard/post/{our_post.id}/share_post", + f"/billboard/post/{our_post.id}/share-post", data={"recipient": "alice@example.com"}, ) - self.assertRedirects(response, f"/dashboard/post/{our_post.id}/") + self.assertRedirects(response, f"/billboard/post/{our_post.id}/") def test_post_with_email_adds_user_to_shared_with(self): our_post = Post.objects.create() alice = User.objects.create(email="alice@example.com") self.client.post( - f"/dashboard/post/{our_post.id}/share_post", + f"/billboard/post/{our_post.id}/share-post", data={"recipient": "alice@example.com"}, ) self.assertIn(alice, our_post.shared_with.all()) @@ -214,12 +214,12 @@ class SharePostTest(TestCase): def test_post_with_nonexistent_email_redirects_to_post(self): our_post = Post.objects.create() response = self.client.post( - f"/dashboard/post/{our_post.id}/share_post", + f"/billboard/post/{our_post.id}/share-post", data={"recipient": "nobody@example.com"}, ) self.assertRedirects( response, - f"/dashboard/post/{our_post.id}/", + f"/billboard/post/{our_post.id}/", fetch_redirect_response=False, ) @@ -227,7 +227,7 @@ class SharePostTest(TestCase): owner = User.objects.create(email="owner@example.com") our_post = Post.objects.create(owner=owner) self.client.force_login(owner) - self.client.post(reverse("share_post", args=[our_post.id]), + self.client.post(reverse("billboard:share_post", args=[our_post.id]), data={"recipient": "owner@example.com"}) self.assertNotIn(owner, our_post.shared_with.all()) @@ -235,7 +235,7 @@ class SharePostTest(TestCase): def test_share_post_shows_privacy_safe_message(self): our_post = Post.objects.create() response = self.client.post( - f"/dashboard/post/{our_post.id}/share_post", + f"/billboard/post/{our_post.id}/share-post", data={"recipient": "nobody@example.com"}, follow=True, ) @@ -251,20 +251,20 @@ class ViewAuthPostTest(TestCase): self.our_post = Post.objects.create(owner=self.owner) def test_anonymous_user_is_redirected(self): - response = self.client.get(reverse("view_post", args=[self.our_post.id])) + response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id])) self.assertRedirects(response, "/", fetch_redirect_response=False) def test_non_owner_non_shared_user_gets_403(self): stranger = User.objects.create(email="stranger@example.com") self.client.force_login(stranger) - response = self.client.get(reverse("view_post", args=[self.our_post.id])) + response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id])) self.assertEqual(response.status_code, 403) def test_shared_with_user_can_access_post(self): guest = User.objects.create(email="guest@example.com") self.our_post.shared_with.add(guest) self.client.force_login(guest) - response = self.client.get(reverse("view_post", args=[self.our_post.id])) + response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id])) self.assertEqual(response.status_code, 200) @override_settings(COMPRESS_ENABLED=False) diff --git a/src/apps/dashboard/tests/unit/test_forms.py b/src/apps/dashboard/tests/unit/test_forms.py index 32e0fd7..1d479db 100644 --- a/src/apps/dashboard/tests/unit/test_forms.py +++ b/src/apps/dashboard/tests/unit/test_forms.py @@ -1,6 +1,6 @@ from django.test import SimpleTestCase -from apps.dashboard.forms import ( +from apps.billboard.forms import ( EMPTY_LINE_ERROR, LineForm, ) diff --git a/src/apps/dashboard/tests/unit/test_models.py b/src/apps/dashboard/tests/unit/test_models.py index 9a38d3c..9cba5a1 100644 --- a/src/apps/dashboard/tests/unit/test_models.py +++ b/src/apps/dashboard/tests/unit/test_models.py @@ -1,6 +1,6 @@ from django.test import SimpleTestCase -from apps.dashboard.models import Line +from apps.billboard.models import Line class SimpleLineModelTest(SimpleTestCase): diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index 0a31616..5d5e992 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -2,12 +2,9 @@ from django.urls import path from . import views urlpatterns = [ - path('new_post', views.new_post, name='new_post'), - path('post//', views.view_post, name='view_post'), - path('post//share_post', views.share_post, name="share_post"), + # Post/Line CRUD has moved to apps.billboard.urls (`billboard:` namespace). path('set_palette', views.set_palette, name='set_palette'), path('set_profile', views.set_profile, name='set_profile'), - path('users//', views.my_posts, name='my_posts'), path('toggle_applets', views.toggle_applets, name="toggle_applets"), path('wallet/', views.wallet, name='wallet'), path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'), diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 881c9d6..fe95e20 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -16,8 +16,6 @@ from django.utils import timezone from django.views.decorators.csrf import ensure_csrf_cookie from apps.applets.utils import applet_context, apply_applet_toggle -from apps.dashboard.forms import ExistingPostLineForm, LineForm -from apps.dashboard.models import Line, Post from apps.drama.models import Note from apps.epic.utils import _compute_distinctions from apps.lyric.models import PaymentMethod, Token, User, Wallet @@ -85,16 +83,6 @@ def _unlocked_palettes_for_user(user): return base -def _recent_posts(user, limit=3): - return ( - Post - .objects - .filter(Q(owner=user) | Q(shared_with=user)) - .annotate(last_line=Max('lines__id')) - .order_by('-last_line') - .distinct()[:limit] - ) - def home_page(request): context = { "palettes": _palettes_for_user(request.user), @@ -104,62 +92,10 @@ def home_page(request): context["applets"] = applet_context(request.user, "dashboard") return render(request, "apps/dashboard/home.html", context) -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) +# Post / Line CRUD lives in apps.billboard.views since the Post + Line models +# moved to apps.billboard.models. - 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/dashboard/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/dashboard/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 set_palette(request): diff --git a/src/apps/drama/migrations/0004_alter_gameevent_verb.py b/src/apps/drama/migrations/0004_alter_gameevent_verb.py new file mode 100644 index 0000000..e7bf7ef --- /dev/null +++ b/src/apps/drama/migrations/0004_alter_gameevent_verb.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-08 21:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('drama', '0003_grant_super_notes_to_existing_superusers'), + ] + + operations = [ + migrations.AlterField( + model_name='gameevent', + name='verb', + field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn'), ('sky_saved', 'Sky saved')], max_length=30), + ), + ] diff --git a/src/functional_tests/my_posts_page.py b/src/functional_tests/my_posts_page.py index 36069a4..e1f244d 100644 --- a/src/functional_tests/my_posts_page.py +++ b/src/functional_tests/my_posts_page.py @@ -11,7 +11,7 @@ class MyPostsPage: self.test.browser.get(self.test.live_server_url) user = User.objects.get(email=email) self.test.browser.get( - self.test.live_server_url + f'/dashboard/users/{user.id}/' + self.test.live_server_url + f'/billboard/users/{user.id}/' ) self.test.wait_for( lambda: self.test.assertIn( diff --git a/src/templates/apps/billboard/_partials/_applet-my-posts.html b/src/templates/apps/billboard/_partials/_applet-my-posts.html index 8ae84fc..998d6e8 100644 --- a/src/templates/apps/billboard/_partials/_applet-my-posts.html +++ b/src/templates/apps/billboard/_partials/_applet-my-posts.html @@ -2,7 +2,7 @@ id="id_applet_my_posts" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" > -

My Posts

+

My Posts

    {% for post in recent_posts %} diff --git a/src/templates/apps/billboard/_partials/_applet-new-post.html b/src/templates/apps/billboard/_partials/_applet-new-post.html index afdf52d..57d5dcc 100644 --- a/src/templates/apps/billboard/_partials/_applet-new-post.html +++ b/src/templates/apps/billboard/_partials/_applet-new-post.html @@ -3,6 +3,6 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

    New Post

    - {% url "new_post" as form_action %} + {% url "billboard:new_post" as form_action %} {% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %} diff --git a/src/templates/apps/dashboard/my_posts.html b/src/templates/apps/billboard/my_posts.html similarity index 100% rename from src/templates/apps/dashboard/my_posts.html rename to src/templates/apps/billboard/my_posts.html diff --git a/src/templates/apps/dashboard/post.html b/src/templates/apps/billboard/post.html similarity index 93% rename from src/templates/apps/dashboard/post.html rename to src/templates/apps/billboard/post.html index 24d6cbe..f96f299 100644 --- a/src/templates/apps/dashboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -6,7 +6,7 @@ {% block extra_header %} - {% url "view_post" post.id as form_action %} + {% url "billboard:view_post" post.id as form_action %} {% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %} {% endblock extra_header %} @@ -25,7 +25,7 @@
    -
    + {% csrf_token %}