diff --git a/src/apps/api/serializers.py b/src/apps/api/serializers.py index cf47f58..4c1a63d 100644 --- a/src/apps/api/serializers.py +++ b/src/apps/api/serializers.py @@ -18,7 +18,7 @@ class LineSerializer(serializers.ModelSerializer): fields = ["id", "text"] class PostSerializer(serializers.ModelSerializer): - name = serializers.ReadOnlyField() + name = serializers.ReadOnlyField(source="title") url = serializers.CharField(source="get_absolute_url", read_only=True) lines = LineSerializer(many=True, read_only=True) diff --git a/src/apps/api/tests/integrated/test_views.py b/src/apps/api/tests/integrated/test_views.py index c46214f..36a2622 100644 --- a/src/apps/api/tests/integrated/test_views.py +++ b/src/apps/api/tests/integrated/test_views.py @@ -14,8 +14,8 @@ class BaseAPITest(TestCase): class PostDetailAPITest(BaseAPITest): def test_returns_post_with_lines(self): post = Post.objects.create(owner=self.user) - Line.objects.create(text="line 1", post=post) - Line.objects.create(text="line 2", post=post) + Line.objects.create(text="line 1", post=post, author=self.user) + Line.objects.create(text="line 2", post=post, author=self.user) response = self.client.get(f"/api/posts/{post.id}/") @@ -49,7 +49,7 @@ class PostLinesAPITest(BaseAPITest): def test_cannot_add_duplicate_line_to_post(self): post = Post.objects.create(owner=self.user) - Line.objects.create(text="post line", post=post) + Line.objects.create(text="post line", post=post, author=self.user) duplicate_response = self.client.post( f"/api/posts/{post.id}/lines/", {"text": "post line"}, @@ -61,7 +61,7 @@ class PostLinesAPITest(BaseAPITest): class PostsAPITest(BaseAPITest): def test_get_returns_only_users_posts(self): post1 = Post.objects.create(owner=self.user) - Line.objects.create(text="line 1", post=post1) + Line.objects.create(text="line 1", post=post1, author=self.user) other_user = User.objects.create_user("other@example.com") Post.objects.create(owner=other_user) diff --git a/src/apps/api/views.py b/src/apps/api/views.py index 69b1191..ebc7f7f 100644 --- a/src/apps/api/views.py +++ b/src/apps/api/views.py @@ -18,7 +18,7 @@ class PostLinesAPI(APIView): post = get_object_or_404(Post, id=post_id) serializer = LineSerializer(data=request.data, context={"post": post}) if serializer.is_valid(): - serializer.save(post=post) + serializer.save(post=post, author=request.user) return Response(serializer.data, status=201) return Response(serializer.errors, status=400) @@ -29,8 +29,9 @@ class PostsAPI(APIView): return Response(serializer.data) def post(self, request): - post = Post.objects.create(owner=request.user) - line = Line.objects.create(text=request.data.get("text", ""), post=post) + text = request.data.get("text", "") + post = Post.objects.create(owner=request.user, title=text[:35]) + Line.objects.create(text=text, post=post, author=request.user) serializer = PostSerializer(post) return Response(serializer.data, status=201) diff --git a/src/apps/billboard/forms.py b/src/apps/billboard/forms.py index 0754298..383bd00 100644 --- a/src/apps/billboard/forms.py +++ b/src/apps/billboard/forms.py @@ -13,10 +13,11 @@ class LineForm(forms.Form): required=True, ) - def save(self, for_post): + def save(self, for_post, author): return Line.objects.create( post=for_post, text=self.cleaned_data["text"], + author=author, ) @@ -31,5 +32,5 @@ class ExistingPostLineForm(LineForm): raise forms.ValidationError(DUPLICATE_LINE_ERROR) return text - def save(self): - return super().save(for_post=self._for_post) + def save(self, author): + return super().save(for_post=self._for_post, author=author) diff --git a/src/apps/billboard/migrations/0004_post_title_line_author_created_at.py b/src/apps/billboard/migrations/0004_post_title_line_author_created_at.py new file mode 100644 index 0000000..852129d --- /dev/null +++ b/src/apps/billboard/migrations/0004_post_title_line_author_created_at.py @@ -0,0 +1,98 @@ +# Adds Post.title, Line.author (PROTECT FK to lyric.User), Line.created_at. +# Backfills Post.title from first-line text (truncate 32 + "…" past 35 chars, +# or hardcoded "Notes & recognitions" for KIND_NOTE_UNLOCK), and Line.author +# from Post.owner — except KIND_NOTE_UNLOCK + ownerless rows, which attribute +# to the seeded `adman` User. Depends on lyric/0003_seed_adman so adman +# exists before backfill runs. + +from django.db import migrations, models +from django.db.models import deletion +from django.utils import timezone + + +_NOTE_UNLOCK_TITLE = "Notes & recognitions" + + +def _truncate_title(text, length=35): + if len(text) <= length: + return text + return text[: length - 3] + "..." + + +def backfill(apps, schema_editor): + Post = apps.get_model("billboard", "Post") + Line = apps.get_model("billboard", "Line") + User = apps.get_model("lyric", "User") + + adman = User.objects.filter(username="adman").first() + + for post in Post.objects.all(): + if post.kind == "note_unlock": + post.title = _NOTE_UNLOCK_TITLE + else: + first_line = post.lines.order_by("id").first() + post.title = _truncate_title(first_line.text) if first_line else "" + post.save(update_fields=["title"]) + + now = timezone.now() + for line in Line.objects.select_related("post").all(): + if line.post.kind == "note_unlock": + line.author = adman + elif line.post.owner_id: + line.author_id = line.post.owner_id + else: + line.author = adman + if line.created_at is None: + line.created_at = now + line.save(update_fields=["author", "created_at"]) + + +def reverse_noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("billboard", "0003_post_kind"), + ("lyric", "0003_seed_adman"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="title", + field=models.CharField(default="", max_length=35), + ), + migrations.AddField( + model_name="line", + name="created_at", + field=models.DateTimeField(default=timezone.now), + ), + migrations.AddField( + model_name="line", + name="author", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=deletion.PROTECT, + related_name="authored_lines", + to="lyric.user", + ), + ), + migrations.RunPython(backfill, reverse_noop), + migrations.AlterField( + model_name="line", + name="author", + field=models.ForeignKey( + on_delete=deletion.PROTECT, + related_name="authored_lines", + to="lyric.user", + ), + ), + migrations.AlterField( + model_name="line", + name="created_at", + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py index f42841b..f281a35 100644 --- a/src/apps/billboard/models.py +++ b/src/apps/billboard/models.py @@ -39,9 +39,12 @@ class Post(models.Model): default=KIND_USER_POST, ) - @property - def name(self): - return self.lines.first().text + # Stored title — set explicitly on creation. Note-unlock Posts hardcode + # "Notes & recognitions"; user_post Posts truncate first line to 35 chars + # (32 + "..." past length). Replaces the legacy `name` property which + # gleaned `lines.first().text` lazily and broke if the first Line was + # later edited or deleted. + title = models.CharField(max_length=35, default="") def get_absolute_url(self): return reverse("billboard:view_post", args=[self.id]) @@ -50,9 +53,19 @@ class Post(models.Model): class Line(models.Model): text = models.TextField(default="") post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines") + # `author` PROTECTs against accidental sitewide-entity deletion (notably + # `adman`, the system-author for note_unlock + share_invite Lines). + # User-typed Lines attribute to the typing User; system-rendered Lines + # attribute to adman so the per-line "username" column always renders. + author = models.ForeignKey( + "lyric.User", + on_delete=models.PROTECT, + related_name="authored_lines", + ) + created_at = models.DateTimeField(auto_now_add=True) class Meta: - ordering = ("id",) + ordering = ("created_at", "id") unique_together = ("post", "text") def __str__(self): diff --git a/src/apps/billboard/tests/integrated/test_brief.py b/src/apps/billboard/tests/integrated/test_brief.py index 567ac25..4362556 100644 --- a/src/apps/billboard/tests/integrated/test_brief.py +++ b/src/apps/billboard/tests/integrated/test_brief.py @@ -17,7 +17,7 @@ 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") + self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm", author=self.user) def test_brief_defaults_unread(self): b = Brief.objects.create(owner=self.user, post=self.post, line=self.line) @@ -72,7 +72,7 @@ class ViewPostMarksReadTest(TestCase): 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") + self.line = Line.objects.create(post=self.post, text="entry one", author=self.user) 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) @@ -94,7 +94,7 @@ class ViewPostMarksReadTest(TestCase): 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") + other_line = Line.objects.create(post=other_post, text="other", author=self.user) 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() diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 3477342..35bae24 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -16,7 +16,7 @@ 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 +from apps.lyric.models import User, get_or_create_adman _PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS} @@ -219,14 +219,30 @@ def doff_title(request, slug): # Templates also live under templates/apps/billboard/. URL names sit in the # `billboard:` namespace so reversers across the codebase carry the prefix. +def _truncate_post_title(text, length=35): + """Glean a Post.title from the first user-submitted Line: copy first + `length` chars exactly, or truncate to `length-3` chars + "..." past + that. Mirrors billboard/migrations/0004 backfill helper.""" + if len(text) <= length: + return text + return text[: length - 3] + "..." + + def new_post(request): form = LineForm(data=request.POST) if form.is_valid(): - nupost = Post.objects.create() + # Anonymous compose path (Percival ch. 18 chapters) keeps owner=null + # but still needs an author for the Line FK. We require auth on this + # view's caller paths in practice; no anonymous Lines reach prod. + author = request.user if request.user.is_authenticated else None + nupost = Post.objects.create( + title=_truncate_post_title(form.cleaned_data["text"]), + ) if request.user.is_authenticated: nupost.owner = request.user nupost.save() - form.save(for_post=nupost) + if author is not None: + form.save(for_post=nupost, author=author) return redirect(nupost) else: context = { @@ -253,7 +269,7 @@ def view_post(request, post_id): if request.method == "POST": form = ExistingPostLineForm(for_post=our_post, data=request.POST) if form.is_valid(): - form.save() + form.save(author=request.user) return redirect(our_post) # GET render is the FYI-read contract — flip every unread Brief on this @@ -266,7 +282,7 @@ def view_post(request, post_id): return render(request, "apps/billboard/post.html", { "post": our_post, "form": form, - "page_class": "page-billboard", + "page_class": "page-billpost", }) @@ -306,11 +322,13 @@ def share_post(request, post_id): # 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.unique_together(post, text) constraint. System-authored as adman + # so the per-line "username" column renders the share announcement. line_text = ( f"Shared with {recipient_email} at {timezone.now().isoformat()}" ) - line = Line.objects.create(post=our_post, text=line_text) + adman = get_or_create_adman() + line = Line.objects.create(post=our_post, text=line_text, author=adman) brief = None if request.user.is_authenticated: diff --git a/src/apps/dashboard/static/apps/dashboard/note.js b/src/apps/dashboard/static/apps/dashboard/note.js index 1b5fc27..76c7693 100644 --- a/src/apps/dashboard/static/apps/dashboard/note.js +++ b/src/apps/dashboard/static/apps/dashboard/note.js @@ -24,14 +24,21 @@ const Brief = (() => { const banner = document.createElement('div'); banner.className = 'note-banner'; + // The square mirrors my_notes.html's .note-item__image-box (dashed + // border + "?" placeholder) when the brief carries a square_url — + // currently note_unlock kind, which jumps direct to /billboard/my-notes/. const squareEl = brief.square_url - ? '' + ? '?' : '
'; + // line_text is server-rendered prose from drama.Note.grant_if_new + // (and server-side share_post) — it may carry a `` + // anchor wrapping the Note name. Insert as HTML, NOT escaped text. + // Title is plain (no HTML), so it stays escaped. banner.innerHTML = '
' + '

' + _esc(brief.title) + '

' + - '

' + _esc(brief.line_text) + '

' + + '

' + (brief.line_text || '') + '

' + '' + diff --git a/src/apps/dashboard/tests/integrated/test_forms.py b/src/apps/dashboard/tests/integrated/test_forms.py index 89d8b98..204010f 100644 --- a/src/apps/dashboard/tests/integrated/test_forms.py +++ b/src/apps/dashboard/tests/integrated/test_forms.py @@ -7,19 +7,26 @@ from apps.billboard.forms import ( LineForm, ) from apps.billboard.models import Line, Post +from apps.lyric.models import User class LineFormTest(TestCase): + def setUp(self): + self.author = User.objects.create(email="author@test.io") + def test_form_save_handles_saving_to_a_post(self): mypost = Post.objects.create() form = LineForm(data={"text": "do re mi"}) self.assertTrue(form.is_valid()) - new_line = form.save(for_post=mypost) + new_line = form.save(for_post=mypost, author=self.author) self.assertEqual(new_line, Line.objects.get()) self.assertEqual(new_line.text, "do re mi") self.assertEqual(new_line.post, mypost) class ExistingPostLineFormTest(TestCase): + def setUp(self): + self.author = User.objects.create(email="author@test.io") + def test_form_validation_for_blank_lines(self): post = Post.objects.create() form = ExistingPostLineForm(for_post=post, data={"text": ""}) @@ -28,7 +35,7 @@ class ExistingPostLineFormTest(TestCase): def test_form_validation_for_duplicate_lines(self): post = Post.objects.create() - Line.objects.create(post=post, text="twins, basil") + Line.objects.create(post=post, text="twins, basil", author=self.author) form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"}) self.assertFalse(form.is_valid()) self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR]) @@ -37,5 +44,5 @@ class ExistingPostLineFormTest(TestCase): mypost = Post.objects.create() form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"}) self.assertTrue(form.is_valid()) - new_line = form.save() + new_line = form.save(author=self.author) self.assertEqual(new_line, Line.objects.get()) diff --git a/src/apps/dashboard/tests/integrated/test_models.py b/src/apps/dashboard/tests/integrated/test_models.py index 48a69ef..5310737 100644 --- a/src/apps/dashboard/tests/integrated/test_models.py +++ b/src/apps/dashboard/tests/integrated/test_models.py @@ -7,64 +7,66 @@ from apps.lyric.models import User class LineModelTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="a@b.cde") + def test_line_is_related_to_post(self): mypost = Post.objects.create() - line = Line() - line.post = mypost + line = Line(post=mypost, author=self.user, text="x") line.save() self.assertIn(line, mypost.lines.all()) def test_cannot_save_null_post_lines(self): mypost = Post.objects.create() - line = Line(post=mypost, text=None) + line = Line(post=mypost, author=self.user, text=None) with self.assertRaises(IntegrityError): line.save() def test_cannot_save_empty_post_lines(self): mypost = Post.objects.create() - line = Line(post=mypost, text="") + line = Line(post=mypost, author=self.user, text="") with self.assertRaises(ValidationError): line.full_clean() def test_duplicate_lines_are_invalid(self): mypost = Post.objects.create() - Line.objects.create(post=mypost, text="jklol") + Line.objects.create(post=mypost, author=self.user, text="jklol") with self.assertRaises(ValidationError): - line = Line(post=mypost, text="jklol") + line = Line(post=mypost, author=self.user, text="jklol") line.full_clean() def test_still_can_save_same_line_to_different_posts(self): post1 = Post.objects.create() post2 = Post.objects.create() - Line.objects.create(post=post1, text="nojk") - line = Line(post=post2, text="nojk") + Line.objects.create(post=post1, author=self.user, text="nojk") + line = Line(post=post2, author=self.user, text="nojk") line.full_clean() # should not raise class PostModelTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="a@b.cde") + def test_get_absolute_url(self): mypost = Post.objects.create() self.assertEqual(mypost.get_absolute_url(), f"/billboard/post/{mypost.id}/") def test_post_lines_order(self): post1 = Post.objects.create() - line1 = Line.objects.create(post=post1, text="i1") - line2 = Line.objects.create(post=post1, text="line 2") - line3 = Line.objects.create(post=post1, text="3") + line1 = Line.objects.create(post=post1, author=self.user, text="i1") + line2 = Line.objects.create(post=post1, author=self.user, text="line 2") + line3 = Line.objects.create(post=post1, author=self.user, text="3") self.assertEqual( list(post1.lines.all()), [line1, line2, line3], ) def test_posts_can_have_owners(self): - user = User.objects.create(email="a@b.cde") - mypost = Post.objects.create(owner=user) - self.assertIn(mypost, user.posts.all()) + mypost = Post.objects.create(owner=self.user) + self.assertIn(mypost, self.user.posts.all()) def test_post_owner_is_optional(self): Post.objects.create() - def test_post_name_is_first_line_text(self): - post = Post.objects.create() - Line.objects.create(post=post, text="first line") - Line.objects.create(post=post, text="second line") - self.assertEqual(post.name, "first line") + def test_post_title_is_explicit_field(self): + post = Post.objects.create(title="first line") + self.assertEqual(post.title, "first line") diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index 492bd68..a1457f5 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -66,6 +66,13 @@ class NewPostTest(TestCase): @override_settings(COMPRESS_ENABLED=False) class PostViewTest(TestCase): + def setUp(self): + self.author = User.objects.create(email="author@test.io") + # POST flows append a Line with author=request.user — non-null FK + # since Line.author is required. force_login so the view's view_post + # path saves cleanly; anonymous compose is no longer supported. + self.client.force_login(self.author) + def test_uses_post_template(self): mypost = Post.objects.create() response = self.client.get(f"/billboard/post/{mypost.id}/") @@ -85,10 +92,10 @@ class PostViewTest(TestCase): def test_displays_only_lines_for_that_post(self): # Given/Arrange correct_post = Post.objects.create() - Line.objects.create(text="itemey 1", post=correct_post) - Line.objects.create(text="itemey 2", post=correct_post) + Line.objects.create(text="itemey 1", post=correct_post, author=self.author) + Line.objects.create(text="itemey 2", post=correct_post, author=self.author) other_post = Post.objects.create() - Line.objects.create(text="other post line", post=other_post) + Line.objects.create(text="other post line", post=other_post, author=self.author) # When/Act response = self.client.get(f"/billboard/post/{correct_post.id}/") # Then/Assert @@ -147,7 +154,7 @@ class PostViewTest(TestCase): def test_duplicate_line_validation_errors_end_up_on_post_page(self): post1 = Post.objects.create() - Line.objects.create(post=post1, text="lorem ipsum") + Line.objects.create(post=post1, text="lorem ipsum", author=self.author) response = self.client.post( f"/billboard/post/{post1.id}/", diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index c67a2de..3c3ba65 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -18,7 +18,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie from apps.applets.utils import applet_context, apply_applet_toggle from apps.drama.models import Note from apps.epic.utils import _compute_distinctions -from apps.lyric.models import PaymentMethod, Token, User, Wallet +from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username APPLET_ORDER = ["wallet", "username", "palette"] @@ -112,6 +112,9 @@ def set_palette(request): def set_profile(request): if request.method == "POST": username = request.POST.get("username", "") + if is_reserved_username(username, current_user=request.user): + messages.error(request, "That handle is reserved.") + return redirect("/") request.user.username = username request.user.save(update_fields=["username"]) return redirect("/") diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 428a23e..d73af80 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -210,6 +210,15 @@ _NOTE_DISPLAY = { "super-nomad": {"greeting": "Howdy,", "title": "Stranger"}, } +# Note slugs whose grant prose uses the long admin format ("The administration +# recognizes…") rather than the standard "Look!—new Note unlocked…" format. +# Any slug not in this set gets the standard format. +_ADMIN_NOTE_SLUGS = frozenset({"super-schizo", "super-nomad"}) + +# Hardcoded title for the per-user "Note unlocks" Post — supplants any +# first-line-glean for posts of kind=NOTE_UNLOCK. +NOTE_UNLOCK_POST_TITLE = "Notes & recognitions" + class Note(models.Model): user = models.ForeignKey( @@ -235,17 +244,37 @@ class Note(models.Model): def display_greeting(self): return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,") + @property + def display_name(self): + """The Note's *name* (e.g., "Stargazer", "Super-Schizo") — the heading + rendered on the my-notes card. Distinct from `display_title` which is + the *recognition title* the user dons (e.g., "Schizoid Man" for + super-schizo). For all current slugs `slug.title()` recovers the right + casing (.title() capitalizes after non-letter chars, so "super-schizo" + → "Super-Schizo"); special-case in `_NOTE_DISPLAY[slug]["name"]` if a + future slug needs a different rendering.""" + return _NOTE_DISPLAY.get(self.slug, {}).get("name", self.slug.title()) + @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.""" + grant ALSO appends a Line to the user's per-category "Notes & + recognitions" 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 Brief.kind=NOTE_UNLOCK. + + Line text dispatches by slug: admin-grant slugs (super-schizo, + super-nomad) use the long "The administration recognizes…" format; + every other slug uses the standard "Look!—new Note unlocked. {Note + name} recognizes {username} the {title}." format. Both wrap the Note + name in a `note-ref` anchor pointing at /billboard/my-notes/. + Author is hardcoded to the seeded `adman` User; the per-line username + column then attributes the Line correctly.""" from django.utils import timezone from apps.billboard.models import Brief, Line, Post + from apps.lyric.models import get_or_create_adman note, created = cls.objects.get_or_create( user=user, slug=slug, @@ -256,17 +285,37 @@ class Note(models.Model): post, _ = Post.objects.get_or_create( owner=user, kind=Post.KIND_NOTE_UNLOCK, + defaults={"title": NOTE_UNLOCK_POST_TITLE}, ) - # 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) + # Existing Note-unlock Posts (pre-0004 migration) might lack a title + # if they predate this code path's get_or_create defaults. Heal once. + if post.title != NOTE_UNLOCK_POST_TITLE: + post.title = NOTE_UNLOCK_POST_TITLE + post.save(update_fields=["title"]) + + username = user.username or user.email + note_anchor = ( + f'
' + f'{note.display_name}' + ) + if slug in _ADMIN_NOTE_SLUGS: + line_text = ( + f"The administration recognizes {username} for {note_anchor}, " + f"which comes with the customary title of {note.display_title}. " + "This does not entail any additional benefits." + ) + else: + line_text = ( + f"Look!—new Note unlocked. {note_anchor} " + f"recognizes {username} the {note.display_title}." + ) + + # Lazy get-or-create: TransactionTestCase flushes the migration-seeded + # adman row, so tests that create superusers (which auto-grants + # super-schizo + super-nomad via the User post_save signal) need a + # safety net. Production migrations seed it once. + adman = get_or_create_adman() + line = Line.objects.create(post=post, text=line_text, author=adman) brief = Brief.objects.create( owner=user, post=post, diff --git a/src/apps/drama/tests/integrated/test_note_brief.py b/src/apps/drama/tests/integrated/test_note_brief.py index 14ca98b..bd80e87 100644 --- a/src/apps/drama/tests/integrated/test_note_brief.py +++ b/src/apps/drama/tests/integrated/test_note_brief.py @@ -45,7 +45,7 @@ class GrantIfNewSpawnsBriefTest(TestCase): 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).""" + the same "Notes & recognitions" 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) @@ -53,10 +53,11 @@ class GrantIfNewSpawnsBriefTest(TestCase): 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) + # 2 distinct Lines on the Post — one per grant. The standalone + # "Look! — new Note unlocked" header Line was dropped in the May-8b + # refactor; the standard format now embeds that text inline per Line. line_texts = list(post.lines.values_list("text", flat=True)) - self.assertEqual(len(set(line_texts)), 3) + self.assertEqual(len(set(line_texts)), 2) def test_brief_line_text_includes_note_title(self): _, _, brief = Note.grant_if_new(self.user, "stargazer") diff --git a/src/apps/lyric/migrations/0003_seed_adman.py b/src/apps/lyric/migrations/0003_seed_adman.py new file mode 100644 index 0000000..09d27f9 --- /dev/null +++ b/src/apps/lyric/migrations/0003_seed_adman.py @@ -0,0 +1,29 @@ +from django.contrib.auth.hashers import make_password +from django.db import migrations + + +def seed_adman(apps, schema_editor): + User = apps.get_model("lyric", "User") + User.objects.get_or_create( + username="adman", + defaults={ + "email": "adman@earthmanrpg.local", + "password": make_password(None), + "is_staff": False, + "is_superuser": False, + "searchable": False, + }, + ) + + +def reverse_noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("lyric", "0002_user_pronouns"), + ] + operations = [ + migrations.RunPython(seed_adman, reverse_noop), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index a35324c..71a8ba3 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -37,6 +37,47 @@ def resolve_pronouns(pronouns_key): return row["subj"], row["obj"], row["poss"] +# ── Reserved usernames ──────────────────────────────────────────────────── +# Sitewide entities that shouldn't be impersonated by new account names. +# Compared lower-case in username assignment paths (set_profile, etc.). +# `adman` is the system author for Note-unlock + share-invite Lines (seeded +# in lyric/0003_seed_adman). The author's handles (disco, discoman, +# hamildong) are NOT in this set yet — discoman is the founder's actual +# username and existing tests assign it; revisit if/when other-entity +# impersonation becomes a concrete concern. + +RESERVED_USERNAMES = frozenset({"adman"}) + + +def is_reserved_username(name, current_user=None): + """True if `name` is reserved AND not already owned by `current_user`.""" + n = (name or "").strip().lower() + if not n: + return False + if current_user is not None and (current_user.username or "").lower() == n: + return False + return n in RESERVED_USERNAMES + + +def get_or_create_adman(): + """Idempotent fetch of the sitewide `adman` User — system-author for + Note-unlock + share-invite Lines. Production migrations seed it once + (lyric/0003_seed_adman); TransactionTestCase flushes the row between + tests, so view code that authors Lines as adman calls this helper.""" + from django.contrib.auth.hashers import make_password + adman, _ = User.objects.get_or_create( + username="adman", + defaults={ + "email": "adman@earthmanrpg.local", + "password": make_password(None), + "is_staff": False, + "is_superuser": False, + "searchable": False, + }, + ) + return adman + + class UserManager(BaseUserManager): def create_user(self, email): user = self.model(email=email) @@ -99,6 +140,16 @@ class User(AbstractBaseUser): REQUIRED_FIELDS = [] USERNAME_FIELD = "email" + @property + def active_title_display(self): + """Render-ready string for "{username} the {title}" attributions — + returns the donned Note's recognition title, or 'Earthman' when no + Note is donned. The 'Earthman' default mirrors the dashboard greeting + fallback in dashboard/views.home_page.""" + if self.active_title_id: + return self.active_title.display_title + return "Earthman" + @property def pronoun_subj(self): return resolve_pronouns(self.pronouns)[0] diff --git a/src/functional_tests/post_page.py b/src/functional_tests/post_page.py index ab419ec..0d73a99 100644 --- a/src/functional_tests/post_page.py +++ b/src/functional_tests/post_page.py @@ -9,15 +9,30 @@ class PostPage: self.test = test def get_table_rows(self): - return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_post_table tr") + # Post-May08b: #id_post_table is now a