rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard

- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-22 22:32:34 -04:00
parent 6d9d3d4f54
commit 473e6bc45a
54 changed files with 1373 additions and 1283 deletions

View File

@@ -1,30 +1,30 @@
from rest_framework import serializers
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class ItemSerializer(serializers.ModelSerializer):
class LineSerializer(serializers.ModelSerializer):
text = serializers.CharField()
def validate_text(self, value):
note = self.context["note"]
if note.item_set.filter(text=value).exists():
post = self.context["post"]
if post.lines.filter(text=value).exists():
raise serializers.ValidationError("duplicate")
return value
class Meta:
model = Item
model = Line
fields = ["id", "text"]
class NoteSerializer(serializers.ModelSerializer):
class PostSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField()
url = serializers.CharField(source="get_absolute_url", read_only=True)
items = ItemSerializer(many=True, read_only=True, source="item_set")
lines = LineSerializer(many=True, read_only=True)
class Meta:
model = Note
fields = ["id", "name", "url", "items"]
model = Post
fields = ["id", "name", "url", "lines"]
class UserSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -1,7 +1,7 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class BaseAPITest(TestCase):
@@ -11,76 +11,76 @@ class BaseAPITest(TestCase):
self.user = User.objects.create_user("test@example.com")
self.client.force_authenticate(user=self.user)
class NoteDetailAPITest(BaseAPITest):
def test_returns_note_with_items(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note)
Item.objects.create(text="item 2", note=note)
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)
response = self.client.get(f"/api/notes/{note.id}/")
response = self.client.get(f"/api/posts/{post.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], str(note.id))
self.assertEqual(len(response.data["items"]), 2)
self.assertEqual(response.data["id"], str(post.id))
self.assertEqual(len(response.data["lines"]), 2)
class NoteItemsAPITest(BaseAPITest):
def test_can_add_item_to_note(self):
note = Note.objects.create(owner=self.user)
class PostLinesAPITest(BaseAPITest):
def test_can_add_line_to_post(self):
post = Post.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "a new item"},
f"/api/posts/{post.id}/lines/",
{"text": "a new line"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Item.objects.first().text, "a new item")
self.assertEqual(Line.objects.count(), 1)
self.assertEqual(Line.objects.first().text, "a new line")
def test_cannot_add_empty_item_to_note(self):
note = Note.objects.create(owner=self.user)
def test_cannot_add_empty_line_to_post(self):
post = Post.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
f"/api/posts/{post.id}/lines/",
{"text": ""},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_cannot_add_duplicate_item_to_note(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="note item", note=note)
def test_cannot_add_duplicate_line_to_post(self):
post = Post.objects.create(owner=self.user)
Line.objects.create(text="post line", post=post)
duplicate_response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "note item"},
f"/api/posts/{post.id}/lines/",
{"text": "post line"},
)
self.assertEqual(duplicate_response.status_code, 400)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Line.objects.count(), 1)
class NotesAPITest(BaseAPITest):
def test_get_returns_only_users_notes(self):
note1 = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note1)
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)
other_user = User.objects.create_user("other@example.com")
Note.objects.create(owner=other_user)
Post.objects.create(owner=other_user)
response = self.client.get("/api/notes/")
response = self.client.get("/api/posts/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(note1.id))
self.assertEqual(response.data[0]["id"], str(post1.id))
def test_post_creates_note_with_item(self):
def test_post_creates_post_with_line(self):
response = self.client.post(
"/api/notes/",
{"text": "first item"},
"/api/posts/",
{"text": "first line"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(Note.objects.first().owner, self.user)
self.assertEqual(Item.objects.first().text, "first item")
self.assertEqual(Post.objects.count(), 1)
self.assertEqual(Post.objects.first().owner, self.user)
self.assertEqual(Line.objects.first().text, "first line")
class UserSearchAPITest(BaseAPITest):
def test_returns_users_matching_username(self):
@@ -98,7 +98,7 @@ class UserSearchAPITest(BaseAPITest):
def test_non_searchable_users_are_excluded(self):
alice = User.objects.create_user("alice@example.com")
alice.username = "princessAli"
alice.save() # searchable defaults to False
alice.save() # searchable defaults to False
response = self.client.get("/api/users/?q=prin")

View File

@@ -1,19 +1,19 @@
from django.test import SimpleTestCase
from apps.api.serializers import ItemSerializer, NoteSerializer
from apps.api.serializers import LineSerializer, PostSerializer
class ItemSerializerTest(SimpleTestCase):
class LineSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ItemSerializer()
serializer = LineSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "text"},
)
class NoteSerializerTest(SimpleTestCase):
class PostSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = NoteSerializer()
serializer = PostSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "name", "url", "items"},
{"id", "name", "url", "lines"},
)

View File

@@ -4,8 +4,8 @@ from . import views
urlpatterns = [
path('notes/', views.NotesAPI.as_view(), name='api_notes'),
path('notes/<uuid:note_id>/', views.NoteDetailAPI.as_view(), name='api_note_detail'),
path('notes/<uuid:note_id>/items/', views.NoteItemsAPI.as_view(), name='api_note_items'),
path('posts/', views.PostsAPI.as_view(), name='api_posts'),
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'),
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
]

View File

@@ -2,36 +2,36 @@ from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from apps.api.serializers import ItemSerializer, NoteSerializer, UserSerializer
from apps.dashboard.models import Item, Note
from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class NoteDetailAPI(APIView):
def get(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = NoteSerializer(note)
class PostDetailAPI(APIView):
def get(self, request, post_id):
post = get_object_or_404(Post, id=post_id)
serializer = PostSerializer(post)
return Response(serializer.data)
class NoteItemsAPI(APIView):
def post(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = ItemSerializer(data=request.data, context={"note": note})
class PostLinesAPI(APIView):
def post(self, request, post_id):
post = get_object_or_404(Post, id=post_id)
serializer = LineSerializer(data=request.data, context={"post": post})
if serializer.is_valid():
serializer.save(note=note)
serializer.save(post=post)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class NotesAPI(APIView):
class PostsAPI(APIView):
def get(self, request):
notes = Note.objects.filter(owner=request.user)
serializer = NoteSerializer(notes, many=True)
posts = Post.objects.filter(owner=request.user)
serializer = PostSerializer(posts, many=True)
return Response(serializer.data)
def post(self, request):
note = Note.objects.create(owner=request.user)
item = Item.objects.create(text=request.data.get("text", ""), note=note)
serializer = NoteSerializer(note)
post = Post.objects.create(owner=request.user)
line = Line.objects.create(text=request.data.get("text", ""), post=post)
serializer = PostSerializer(post)
return Response(serializer.data, status=201)
class UserSearchAPI(APIView):

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def rename_note_applets_to_post(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-note').update(slug='new-post', name='New Post', context='billboard')
Applet.objects.filter(slug='my-notes').update(slug='my-posts', name='My Posts', context='billboard')
def reverse_rename_note_applets_to_post(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-post').update(slug='new-note', name='New Note', context='dashboard')
Applet.objects.filter(slug='my-posts').update(slug='my-notes', name='My Notes', context='dashboard')
class Migration(migrations.Migration):
dependencies = [
('applets', '0010_recognition_applet'),
]
operations = [
migrations.RunPython(rename_note_applets_to_post, reverse_rename_note_applets_to_post),
]

View File

@@ -0,0 +1,31 @@
from django.db import migrations
def rename_recognition_applet_to_notes(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='billboard-recognition').update(
slug='billboard-notes',
name='My Notes',
)
def reverse_rename_recognition_applet_to_notes(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='billboard-notes').update(
slug='billboard-recognition',
name='Recognition',
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0011_rename_note_applets_to_post'),
]
operations = [
migrations.RunPython(
rename_recognition_applet_to_notes,
reverse_rename_recognition_applet_to_notes,
),
]

View File

@@ -8,7 +8,7 @@
// ── helpers ──────────────────────────────────────────────────────────────
function _activeModal() {
return _activeItem && _activeItem.querySelector('.recog-palette-modal');
return _activeItem && _activeItem.querySelector('.note-palette-modal');
}
function _paletteClass(el) {
@@ -26,14 +26,14 @@
// Clone from <template> if the modal is not in the DOM yet.
var existing = _activeModal();
if (!existing) {
var tpl = _activeItem.querySelector('.recog-palette-modal-tpl');
var tpl = _activeItem.querySelector('.note-palette-modal-tpl');
if (!tpl) return;
var clone = tpl.content.firstElementChild.cloneNode(true);
_activeItem.appendChild(clone);
_wireModal();
}
_state = 'open';
var confirmEl = _activeModal().querySelector('.recog-palette-confirm');
var confirmEl = _activeModal().querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = true;
}
@@ -49,7 +49,7 @@
if (!modal) return;
// Swatch body → preview (remove modal from DOM)
modal.querySelectorAll('.recog-swatch-body').forEach(function (body) {
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
body.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(body.parentElement);
@@ -60,17 +60,17 @@
// OK button (not inside confirm submenu) → show confirm submenu
modal.querySelectorAll('.btn.btn-confirm').forEach(function (btn) {
if (btn.closest('.recog-palette-confirm')) return;
if (btn.closest('.note-palette-confirm')) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(btn.parentElement);
var confirmEl = modal.querySelector('.recog-palette-confirm');
var confirmEl = modal.querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = false;
});
});
// Confirm YES → POST and update DOM
modal.querySelectorAll('.recog-palette-confirm .btn.btn-confirm').forEach(function (btn) {
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
_doSetPalette();
@@ -78,10 +78,10 @@
});
// Confirm NVM → hide confirm submenu
modal.querySelectorAll('.recog-palette-confirm .btn.btn-cancel').forEach(function (btn) {
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
btn.closest('.recog-palette-confirm').hidden = true;
btn.closest('.note-palette-confirm').hidden = true;
});
});
@@ -106,10 +106,10 @@
.then(function (r) { return r.json(); })
.then(function () {
_closeModal();
var imageBox = _activeItem.querySelector('.recog-item__image-box');
var imageBox = _activeItem.querySelector('.note-item__image-box');
if (imageBox) {
var swatch = document.createElement('div');
swatch.className = 'recog-item__palette ' + palette;
swatch.className = 'note-item__palette ' + palette;
imageBox.parentNode.replaceChild(swatch, imageBox);
}
});
@@ -119,10 +119,10 @@
function _init() {
// Image-box click → open modal
document.querySelectorAll('.recog-item__image-box').forEach(function (box) {
document.querySelectorAll('.note-item__image-box').forEach(function (box) {
box.addEventListener('click', function (e) {
e.stopPropagation();
_activeItem = box.closest('.recog-item');
_activeItem = box.closest('.note-item');
_openModal();
});
});

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from django.utils import timezone
from apps.applets.models import Applet
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -164,65 +164,65 @@ class BillscrollViewTest(TestCase):
self.assertContains(response, 'class="drama-event-time"')
class RecognitionPageViewTest(TestCase):
class NotePageViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog@test.io")
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/billboard/recognition/")
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 302)
def test_returns_200(self):
response = self.client.get("/billboard/recognition/")
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 200)
def test_uses_recognition_template(self):
response = self.client.get("/billboard/recognition/")
self.assertTemplateUsed(response, "apps/billboard/recognition.html")
def test_uses_note_page_template(self):
response = self.client.get("/billboard/my-notes/")
self.assertTemplateUsed(response, "apps/billboard/my_notes.html")
def test_passes_recognitions_in_context(self):
recog = Recognition.objects.create(
def test_passes_notes_in_context(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertIn(recog, response.context["recognitions"])
response = self.client.get("/billboard/my-notes/")
self.assertIn(recog, response.context["notes"])
def test_excludes_other_users_recognitions(self):
def test_excludes_other_users_notes(self):
other = User.objects.create(email="other@test.io")
Recognition.objects.create(
Note.objects.create(
user=other, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertEqual(list(response.context["recognitions"]), [])
response = self.client.get("/billboard/my-notes/")
self.assertEqual(list(response.context["notes"]), [])
def test_renders_recog_list_and_items(self):
Recognition.objects.create(
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertContains(response, 'class="recog-list"')
self.assertContains(response, 'class="recog-item"')
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-list"')
self.assertContains(response, 'class="note-item"')
def test_renders_recog_item_title_description_image_box(self):
Recognition.objects.create(
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertContains(response, 'class="recog-item__title"')
self.assertContains(response, 'class="recog-item__description"')
self.assertContains(response, 'class="recog-item__image-box"')
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-item__title"')
self.assertContains(response, 'class="note-item__description"')
self.assertContains(response, 'class="note-item__image-box"')
class RecognitionSetPaletteViewTest(TestCase):
class NoteSetPaletteViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="setpal@test.io")
self.client.force_login(self.user)
self.recognition = Recognition.objects.create(
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.url = "/billboard/recognition/stargazer/set-palette"
self.url = "/billboard/note/stargazer/set-palette"
def test_requires_login(self):
self.client.logout()
@@ -233,14 +233,14 @@ class RecognitionSetPaletteViewTest(TestCase):
)
self.assertEqual(response.status_code, 302)
def test_sets_palette_on_recognition(self):
def test_sets_palette_on_note(self):
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.recognition.refresh_from_db()
self.assertEqual(self.recognition.palette, "palette-bardo")
self.note.refresh_from_db()
self.assertEqual(self.note.palette, "palette-bardo")
def test_returns_200_with_ok(self):
response = self.client.post(
@@ -253,7 +253,7 @@ class RecognitionSetPaletteViewTest(TestCase):
def test_returns_404_for_slug_user_does_not_own(self):
response = self.client.post(
"/billboard/recognition/schizo/set-palette",
"/billboard/note/schizo/set-palette",
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)

View File

@@ -7,8 +7,8 @@ app_name = "billboard"
urlpatterns = [
path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("recognition/", views.recognition_page, name="recognition"),
path("recognition/<slug:slug>/set-palette", views.recognition_set_palette, name="recognition_set_palette"),
path("my-notes/", views.my_notes, name="my_notes"),
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
]

View File

@@ -6,11 +6,24 @@ from django.http import JsonResponse
from django.shortcuts import redirect, render
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import GameEvent, Recognition, ScrollPosition
from apps.dashboard.forms import LineForm
from apps.dashboard.models import Post
from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room
from apps.epic.utils import rooms_for_user
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]
)
@login_required(login_url="/")
def billboard(request):
my_rooms = rooms_for_user(request.user).order_by("-created_at")
@@ -42,6 +55,8 @@ def billboard(request):
"recent_events": recent_events,
"viewer": request.user,
"applets": applet_context(request.user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
"page_class": "page-billboard",
})
@@ -53,6 +68,8 @@ def toggle_billboard_applets(request):
if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", {
"applets": applet_context(request.user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
})
return redirect("billboard:billboard")
@@ -71,7 +88,7 @@ def room_scroll(request, room_id):
})
_RECOGNITION_META = {
_NOTE_META = {
"stargazer": {
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
@@ -91,35 +108,35 @@ _RECOGNITION_META = {
@login_required(login_url="/")
def recognition_set_palette(request, slug):
def note_set_palette(request, slug):
from django.http import Http404
try:
recognition = Recognition.objects.get(user=request.user, slug=slug)
except Recognition.DoesNotExist:
note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist:
raise Http404
if request.method == "POST":
body = json.loads(request.body)
recognition.palette = body.get("palette", "")
recognition.save(update_fields=["palette"])
note.palette = body.get("palette", "")
note.save(update_fields=["palette"])
return JsonResponse({"ok": True})
@login_required(login_url="/")
def recognition_page(request):
qs = Recognition.objects.filter(user=request.user)
recognitions = [
def my_notes(request):
qs = Note.objects.filter(user=request.user)
note_items = [
{
"obj": r,
"title": _RECOGNITION_META.get(r.slug, {}).get("title", r.slug),
"description": _RECOGNITION_META.get(r.slug, {}).get("description", ""),
"palette_options": _RECOGNITION_META.get(r.slug, {}).get("palette_options", []),
"obj": n,
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
}
for r in qs
for n in qs
]
return render(request, "apps/billboard/recognition.html", {
"recognitions": qs,
"recognition_items": recognitions,
"page_class": "page-recognition",
return render(request, "apps/billboard/my_notes.html", {
"notes": qs,
"note_items": note_items,
"page_class": "page-notes",
})

View File

@@ -1,32 +1,32 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import Item
from .models import Line
DUPLICATE_ITEM_ERROR = "You've already logged this to your note"
EMPTY_ITEM_ERROR = "You can't have an empty note item"
DUPLICATE_LINE_ERROR = "You've already logged this to your post"
EMPTY_LINE_ERROR = "You can't have an empty post line"
class ItemForm(forms.Form):
class LineForm(forms.Form):
text = forms.CharField(
error_messages = {"required": EMPTY_ITEM_ERROR},
error_messages = {"required": EMPTY_LINE_ERROR},
required=True,
)
def save(self, for_note):
return Item.objects.create(
note=for_note,
def save(self, for_post):
return Line.objects.create(
post=for_post,
text=self.cleaned_data["text"],
)
class ExistingNoteItemForm(ItemForm):
def __init__(self, for_note, *args, **kwargs):
class ExistingPostLineForm(LineForm):
def __init__(self, for_post, *args, **kwargs):
super().__init__(*args, **kwargs)
self._for_note = for_note
self._for_post = for_post
def clean_text(self):
text = self.cleaned_data["text"]
if self._for_note.item_set.filter(text=text).exists():
raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
if self._for_post.lines.filter(text=text).exists():
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
return text
def save(self):
return super().save(for_note=self._for_note)
return super().save(for_post=self._for_post)

View File

@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0003_alter_note_owner_alter_note_shared_with'),
]
operations = [
migrations.RenameModel('Note', 'Post'),
migrations.RenameModel('Item', 'Line'),
migrations.RenameField('Line', 'note', 'post'),
migrations.AlterUniqueTogether(
name='line',
unique_together={('post', 'text')},
),
]

View File

@@ -4,11 +4,11 @@ from django.db import models
from django.urls import reverse
class Note(models.Model):
class Post(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
"lyric.User",
related_name="notes",
related_name="posts",
blank=True,
null=True,
on_delete=models.CASCADE,
@@ -16,24 +16,24 @@ class Note(models.Model):
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_notes",
related_name="shared_posts",
blank=True,
)
@property
def name(self):
return self.item_set.first().text
return self.lines.first().text
def get_absolute_url(self):
return reverse("view_note", args=[self.id])
return reverse("view_post", args=[self.id])
class Item(models.Model):
class Line(models.Model):
text = models.TextField(default="")
note = models.ForeignKey(Note, default=None, on_delete=models.CASCADE)
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
class Meta:
ordering = ("id",)
unique_together = ("note", "text")
unique_together = ("post", "text")
def __str__(self):
return self.text

View File

@@ -0,0 +1,49 @@
const Note = (() => {
'use strict';
function showBanner(note) {
if (!note) return;
const earned = new Date(note.earned_at);
const dateStr = earned.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
const banner = document.createElement('div');
banner.className = 'note-banner';
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) + '">' +
dateStr +
'</time>' +
'</div>' +
'<div class="note-banner__image"></div>' +
'<button type="button" class="btn btn-danger note-banner__nvm">NVM</button>' +
'<a href="/billboard/my-notes/" class="btn btn-caution note-banner__fyi">FYI</a>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove();
});
var h2 = document.querySelector('h2');
if (h2 && h2.parentNode) {
h2.parentNode.insertBefore(banner, h2.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
function handleSaveResponse(data) {
showBanner(data && data.note);
}
function _esc(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
})();

View File

@@ -1,49 +0,0 @@
const Recognition = (() => {
'use strict';
function showBanner(recognition) {
if (!recognition) return;
const earned = new Date(recognition.earned_at);
const dateStr = earned.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
const banner = document.createElement('div');
banner.className = 'recog-banner';
banner.innerHTML =
'<div class="recog-banner__body">' +
'<p class="recog-banner__title">' + _esc(recognition.title) + '</p>' +
'<p class="recog-banner__description">' + _esc(recognition.description) + '</p>' +
'<time class="recog-banner__timestamp" datetime="' + _esc(recognition.earned_at) + '">' +
dateStr +
'</time>' +
'</div>' +
'<div class="recog-banner__image"></div>' +
'<button type="button" class="btn btn-danger recog-banner__nvm">NVM</button>' +
'<a href="/billboard/recognition/" class="btn btn-caution recog-banner__fyi">FYI</a>';
banner.querySelector('.recog-banner__nvm').addEventListener('click', function () {
banner.remove();
});
var h2 = document.querySelector('h2');
if (h2 && h2.parentNode) {
h2.parentNode.insertBefore(banner, h2.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
function handleSaveResponse(data) {
showBanner(data && data.recognition);
}
function _esc(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
})();

View File

@@ -1,41 +1,41 @@
from django.test import TestCase
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
ExistingNoteItemForm,
ItemForm,
DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR,
ExistingPostLineForm,
LineForm,
)
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
class ItemFormTest(TestCase):
def test_form_save_handles_saving_to_a_note(self):
mynote = Note.objects.create()
form = ItemForm(data={"text": "do re mi"})
class LineFormTest(TestCase):
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_item = form.save(for_note=mynote)
self.assertEqual(new_item, Item.objects.get())
self.assertEqual(new_item.text, "do re mi")
self.assertEqual(new_item.note, mynote)
new_line = form.save(for_post=mypost)
self.assertEqual(new_line, Line.objects.get())
self.assertEqual(new_line.text, "do re mi")
self.assertEqual(new_line.post, mypost)
class ExistingNoteItemFormTest(TestCase):
def test_form_validation_for_blank_items(self):
note = Note.objects.create()
form = ExistingNoteItemForm(for_note=note, data={"text": ""})
class ExistingPostLineFormTest(TestCase):
def test_form_validation_for_blank_lines(self):
post = Post.objects.create()
form = ExistingPostLineForm(for_post=post, data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])
def test_form_validation_for_duplicate_items(self):
note = Note.objects.create()
Item.objects.create(note=note, text="twins, basil")
form = ExistingNoteItemForm(for_note=note, data={"text": "twins, basil"})
def test_form_validation_for_duplicate_lines(self):
post = Post.objects.create()
Line.objects.create(post=post, text="twins, basil")
form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR])
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
def test_form_save(self):
mynote = Note.objects.create()
form = ExistingNoteItemForm(for_note=mynote, data={"text": "howdy"})
mypost = Post.objects.create()
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
self.assertTrue(form.is_valid())
new_item = form.save()
self.assertEqual(new_item, Item.objects.get())
new_line = form.save()
self.assertEqual(new_line, Line.objects.get())

View File

@@ -2,69 +2,69 @@ from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class ItemModelTest(TestCase):
def test_item_is_related_to_note(self):
mynote = Note.objects.create()
item = Item()
item.note = mynote
item.save()
self.assertIn(item, mynote.item_set.all())
class LineModelTest(TestCase):
def test_line_is_related_to_post(self):
mypost = Post.objects.create()
line = Line()
line.post = mypost
line.save()
self.assertIn(line, mypost.lines.all())
def test_cannot_save_null_note_items(self):
mynote = Note.objects.create()
item = Item(note=mynote, text=None)
def test_cannot_save_null_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text=None)
with self.assertRaises(IntegrityError):
item.save()
line.save()
def test_cannot_save_empty_note_items(self):
mynote = Note.objects.create()
item = Item(note=mynote, text="")
def test_cannot_save_empty_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text="")
with self.assertRaises(ValidationError):
item.full_clean()
line.full_clean()
def test_duplicate_items_are_invalid(self):
mynote = Note.objects.create()
Item.objects.create(note=mynote, text="jklol")
def test_duplicate_lines_are_invalid(self):
mypost = Post.objects.create()
Line.objects.create(post=mypost, text="jklol")
with self.assertRaises(ValidationError):
item = Item(note=mynote, text="jklol")
item.full_clean()
line = Line(post=mypost, text="jklol")
line.full_clean()
def test_still_can_save_same_item_to_different_notes(self):
note1 = Note.objects.create()
note2 = Note.objects.create()
Item.objects.create(note=note1, text="nojk")
item = Item(note=note2, text="nojk")
item.full_clean() # should not raise
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.full_clean() # should not raise
class NoteModelTest(TestCase):
class PostModelTest(TestCase):
def test_get_absolute_url(self):
mynote = Note.objects.create()
self.assertEqual(mynote.get_absolute_url(), f"/dashboard/note/{mynote.id}/")
mypost = Post.objects.create()
self.assertEqual(mypost.get_absolute_url(), f"/dashboard/post/{mypost.id}/")
def test_note_items_order(self):
note1 = Note.objects.create()
item1 = Item.objects.create(note=note1, text="i1")
item2 = Item.objects.create(note=note1, text="item 2")
item3 = Item.objects.create(note=note1, text="3")
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")
self.assertEqual(
list(note1.item_set.all()),
[item1, item2, item3],
list(post1.lines.all()),
[line1, line2, line3],
)
def test_notes_can_have_owners(self):
def test_posts_can_have_owners(self):
user = User.objects.create(email="a@b.cde")
mynote = Note.objects.create(owner=user)
self.assertIn(mynote, user.notes.all())
mypost = Post.objects.create(owner=user)
self.assertIn(mypost, user.posts.all())
def test_note_owner_is_optional(self):
Note.objects.create()
def test_post_owner_is_optional(self):
Post.objects.create()
def test_note_name_is_first_item_text(self):
note = Note.objects.create()
Item.objects.create(note=note, text="first item")
Item.objects.create(note=note, text="second item")
self.assertEqual(note.name, "first item")
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")

View File

@@ -3,7 +3,7 @@
sky_view — GET /dashboard/sky/ → renders sky template
sky_preview — GET /dashboard/sky/preview → proxies to PySwiss (no DB write)
sky_save — POST /dashboard/sky/save → saves natal data to User model;
grants Stargazer Recognition on first save with real chart_data
grants Stargazer Note on first save with real chart_data
"""
import json
@@ -12,7 +12,7 @@ from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from apps.drama.models import Recognition
from apps.drama.models import Note
from apps.lyric.models import User
@@ -233,8 +233,8 @@ _REAL_CHART = {
}
class SkySaveRecognitionTest(TestCase):
"""sky_save grants the Stargazer Recognition on the first save with real chart_data."""
class SkySaveNoteTest(TestCase):
"""sky_save grants the Stargazer Note on the first save with real chart_data."""
def setUp(self):
self.user = User.objects.create(email="star@test.io")
@@ -256,35 +256,35 @@ class SkySaveRecognitionTest(TestCase):
content_type="application/json",
)
def test_first_save_with_chart_data_returns_stargazer_recognition(self):
def test_first_save_with_chart_data_returns_stargazer_note(self):
data = self._post().json()
self.assertIn("recognition", data)
recog = data["recognition"]
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)
def test_first_save_creates_recognition_in_db(self):
def test_first_save_creates_note_in_db(self):
self._post()
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 1)
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_second_save_returns_null_recognition(self):
def test_second_save_returns_null_note(self):
self._post()
data = self._post().json()
self.assertIsNone(data["recognition"])
self.assertIsNone(data["note"])
def test_second_save_does_not_create_duplicate_recognition(self):
def test_second_save_does_not_create_duplicate_note(self):
self._post()
self._post()
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 1)
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_save_with_empty_chart_data_does_not_grant_recognition(self):
def test_save_with_empty_chart_data_does_not_grant_note(self):
data = self._post(chart_data={}).json()
self.assertIsNone(data["recognition"])
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 0)
self.assertIsNone(data["note"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
def test_save_with_null_chart_data_does_not_grant_recognition(self):
def test_save_with_null_chart_data_does_not_grant_note(self):
data = self._post(chart_data=None).json()
self.assertIsNone(data["recognition"])
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 0)
self.assertIsNone(data["note"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)

View File

@@ -9,11 +9,11 @@ from django.utils import html, timezone
from apps.applets.models import Applet, UserApplet
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR,
)
from apps.dashboard.models import Item, Note
from apps.drama.models import Recognition
from apps.dashboard.models import Line, Post
from apps.drama.models import Note
from apps.lyric.models import User
@@ -21,64 +21,59 @@ class HomePageTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
def test_uses_home_template(self):
response = self.client.get('/')
self.assertTemplateUsed(response, 'apps/dashboard/home.html')
def test_renders_input_form(self):
response = self.client.get('/')
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[method=POST]')
self.assertIn("/dashboard/new_note", [form.get("action") for form in forms])
[form] = [form for form in forms if form.get("action") == "/dashboard/new_note"]
inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs])
class NewNoteTest(TestCase):
@override_settings(COMPRESS_ENABLED=False)
class NewPostTest(TestCase):
def setUp(self):
user = User.objects.create(email="disco@test.io")
self.client.force_login(user)
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(
slug="new-post",
defaults={"name": "New Post", "context": "billboard", "grid_cols": 9, "grid_rows": 3},
)
def test_can_save_a_POST_request(self):
self.client.post("/dashboard/new_note", data={"text": "A new note item"})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new note item")
self.client.post("/dashboard/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_note", data={"text": "A new note item"})
new_note = Note.objects.get()
self.assertRedirects(response, f"/dashboard/note/{new_note.id}/")
response = self.client.post("/dashboard/new_post", data={"text": "A new post line"})
new_post = Post.objects.get()
self.assertRedirects(response, f"/dashboard/post/{new_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
return self.client.post("/dashboard/new_note", data={"text": ""})
return self.client.post("/dashboard/new_post", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_home_template(self):
def test_for_invalid_input_renders_billboard_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/home.html")
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
@override_settings(COMPRESS_ENABLED=False)
class NoteViewTest(TestCase):
def test_uses_note_template(self):
mynote = Note.objects.create()
response = self.client.get(f"/dashboard/note/{mynote.id}/")
self.assertTemplateUsed(response, "apps/dashboard/note.html")
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")
def test_renders_input_form(self):
mynote = Note.objects.create()
url = f"/dashboard/note/{mynote.id}/"
mypost = Post.objects.create()
url = f"/dashboard/post/{mypost.id}/"
response = self.client.get(url)
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect("form[method=POST]")
@@ -87,62 +82,62 @@ class NoteViewTest(TestCase):
inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs])
def test_displays_only_items_for_that_note(self):
def test_displays_only_lines_for_that_post(self):
# Given/Arrange
correct_note = Note.objects.create()
Item.objects.create(text="itemey 1", note=correct_note)
Item.objects.create(text="itemey 2", note=correct_note)
other_note = Note.objects.create()
Item.objects.create(text="other note item", note=other_note)
correct_post = Post.objects.create()
Line.objects.create(text="itemey 1", post=correct_post)
Line.objects.create(text="itemey 2", post=correct_post)
other_post = Post.objects.create()
Line.objects.create(text="other post line", post=other_post)
# When/Act
response = self.client.get(f"/dashboard/note/{correct_note.id}/")
response = self.client.get(f"/dashboard/post/{correct_post.id}/")
# Then/Assert
self.assertContains(response, "itemey 1")
self.assertContains(response, "itemey 2")
self.assertNotContains(response, "other note item")
self.assertNotContains(response, "other post line")
def test_can_save_a_POST_request_to_an_existing_note(self):
other_note = Note.objects.create()
correct_note = Note.objects.create()
def test_can_save_a_POST_request_to_an_existing_post(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
self.client.post(
f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing note"},
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new item for an existing note")
self.assertEqual(new_item.note, correct_note)
self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new line for an existing post")
self.assertEqual(new_line.post, correct_post)
def test_POST_redirects_to_note_view(self):
other_note = Note.objects.create()
correct_note = Note.objects.create()
def test_POST_redirects_to_post_view(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing note"},
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertRedirects(response, f"/dashboard/note/{correct_note.id}/")
self.assertRedirects(response, f"/dashboard/post/{correct_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
mynote = Note.objects.create()
return self.client.post(f"/dashboard/note/{mynote.id}/", data={"text": ""})
mypost = Post.objects.create()
return self.client.post(f"/dashboard/post/{mypost.id}/", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_note_template(self):
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/note.html")
self.assertTemplateUsed(response, "apps/dashboard/post.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
def test_for_invalid_input_sets_is_invalid_class(self):
response = self.post_invalid_input()
@@ -150,26 +145,26 @@ class NoteViewTest(TestCase):
[input] = parsed.cssselect("input[name=text]")
self.assertIn("is-invalid", set(input.classes))
def test_duplicate_item_validation_errors_end_up_on_note_page(self):
note1 = Note.objects.create()
Item.objects.create(note=note1, text="lorem ipsum")
def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create()
Line.objects.create(post=post1, text="lorem ipsum")
response = self.client.post(
f"/dashboard/note/{note1.id}/",
f"/dashboard/post/{post1.id}/",
data={"text": "lorem ipsum"},
)
expected_error = html.escape(DUPLICATE_ITEM_ERROR)
expected_error = html.escape(DUPLICATE_LINE_ERROR)
self.assertContains(response, expected_error)
self.assertTemplateUsed(response, "apps/dashboard/note.html")
self.assertEqual(Item.objects.all().count(), 1)
self.assertTemplateUsed(response, "apps/dashboard/post.html")
self.assertEqual(Line.objects.all().count(), 1)
class MyNotesTest(TestCase):
def test_my_notes_url_renders_my_notes_template(self):
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_notes.html")
self.assertTemplateUsed(response, "apps/dashboard/my_posts.html")
def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrongowner@example.com")
@@ -178,71 +173,69 @@ class MyNotesTest(TestCase):
response = self.client.get(f"/dashboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user)
def test_note_owner_is_saved_if_user_is_authenticated(self):
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_note", data={"text": "new item"})
new_note = Note.objects.get()
self.assertEqual(new_note.owner, user)
self.client.post("/dashboard/new_post", data={"text": "new line"})
new_post = Post.objects.get()
self.assertEqual(new_post.owner, user)
def test_my_notes_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde")
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}/")
self.assertRedirects(response, "/")
def test_my_notes_returns_403_for_wrong_user(self):
# create two users, login as user_a, request user_b's my_notes url
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}/")
# assert 403
self.assertEqual(response.status_code, 403)
class ShareNoteTest(TestCase):
def test_post_to_share_note_url_redirects_to_note(self):
our_note = Note.objects.create()
class SharePostTest(TestCase):
def test_post_to_share_post_url_redirects_to_post(self):
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/dashboard/note/{our_note.id}/")
self.assertRedirects(response, f"/dashboard/post/{our_post.id}/")
def test_post_with_email_adds_user_to_shared_with(self):
our_note = Note.objects.create()
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertIn(alice, our_note.shared_with.all())
self.assertIn(alice, our_post.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_note(self):
our_note = Note.objects.create()
def test_post_with_nonexistent_email_redirects_to_post(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(
response,
f"/dashboard/note/{our_note.id}/",
f"/dashboard/post/{our_post.id}/",
fetch_redirect_response=False,
)
def test_share_note_does_not_add_owner_as_recipient(self):
def test_share_post_does_not_add_owner_as_recipient(self):
owner = User.objects.create(email="owner@example.com")
our_note = Note.objects.create(owner=owner)
our_post = Post.objects.create(owner=owner)
self.client.force_login(owner)
self.client.post(reverse("share_note", args=[our_note.id]),
self.client.post(reverse("share_post", args=[our_post.id]),
data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_note.shared_with.all())
self.assertNotIn(owner, our_post.shared_with.all())
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
def test_share_note_shows_privacy_safe_message(self):
our_note = Note.objects.create()
def test_share_post_shows_privacy_safe_message(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
follow=True,
)
@@ -252,26 +245,26 @@ class ShareNoteTest(TestCase):
"An invite has been sent if that address is registered.",
)
class ViewAuthNoteTest(TestCase):
class ViewAuthPostTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="disco@example.com")
self.our_note = Note.objects.create(owner=self.owner)
self.our_post = Post.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("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_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_note(self):
def test_shared_with_user_can_access_post(self):
guest = User.objects.create(email="guest@example.com")
self.our_note.shared_with.add(guest)
self.our_post.shared_with.add(guest)
self.client.force_login(guest)
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 200)
@override_settings(COMPRESS_ENABLED=False)
@@ -349,14 +342,14 @@ class SetPaletteTest(TestCase):
swatches = parsed.cssselect(".swatch")
self.assertEqual(len(swatches), len(response.context["palettes"]))
class RecognitionPaletteContextTest(TestCase):
class NotePaletteContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog_palette@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_recognition_palette_unlocks_swatch_in_context(self):
Recognition.objects.create(
def test_note_palette_unlocks_swatch_in_context(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
@@ -365,8 +358,8 @@ class RecognitionPaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertFalse(bardo["locked"])
def test_recognition_palette_shoptalk_contains_recognition_title(self):
Recognition.objects.create(
def test_note_palette_shoptalk_contains_note_title(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
@@ -375,8 +368,8 @@ class RecognitionPaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("Stargazer", bardo["shoptalk"])
def test_recognition_without_palette_field_keeps_swatch_locked(self):
Recognition.objects.create(
def test_note_without_palette_field_keeps_swatch_locked(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette=None,
)
@@ -385,8 +378,8 @@ class RecognitionPaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertTrue(bardo["locked"])
def test_recognition_palette_allows_set_palette_via_view(self):
Recognition.objects.create(
def test_note_palette_allows_set_palette_via_view(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)

View File

@@ -1,13 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.forms import (
EMPTY_ITEM_ERROR,
ItemForm,
EMPTY_LINE_ERROR,
LineForm,
)
class SimpleItemFormTest(SimpleTestCase):
def test_form_validation_for_blank_items(self):
form = ItemForm(data={"text": ""})
class SimpleLineFormTest(SimpleTestCase):
def test_form_validation_for_blank_lines(self):
form = LineForm(data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])

View File

@@ -1,13 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.models import Item
from apps.dashboard.models import Line
class SimpleItemModelTest(SimpleTestCase):
class SimpleLineModelTest(SimpleTestCase):
def test_default_text(self):
item = Item()
self.assertEqual(item.text, "")
line = Line()
self.assertEqual(line.text, "")
def test_string_representation(self):
item = Item(text="sample text")
self.assertEqual(str(item), "sample text")
line = Line(text="sample text")
self.assertEqual(str(line), "sample text")

View File

@@ -2,12 +2,12 @@ from django.urls import path
from . import views
urlpatterns = [
path('new_note', views.new_note, name='new_note'),
path('note/<uuid:note_id>/', views.view_note, name='view_note'),
path('note/<uuid:note_id>/share_note', views.share_note, name="share_note"),
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('set_palette', views.set_palette, name='set_palette'),
path('set_profile', views.set_profile, name='set_profile'),
path('users/<uuid:user_id>/', views.my_notes, name='my_notes'),
path('users/<uuid:user_id>/', 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'),

View File

@@ -15,14 +15,14 @@ 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 ExistingNoteItemForm, ItemForm
from apps.dashboard.models import Item, Note
from apps.drama.models import Recognition
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
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
APPLET_ORDER = ["wallet", "username", "palette"]
_BASE_UNLOCKED = frozenset([
"palette-default",
"palette-cedar",
@@ -40,7 +40,7 @@ _PALETTE_DEFS = [
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
{"name": "palette-celestia", "label": "Celestia", "locked": True},
]
_RECOGNITION_TITLES = {
_NOTE_TITLES = {
"stargazer": "Stargazer",
"schizo": "Schizo",
"nomad": "Nomad",
@@ -54,7 +54,7 @@ def _palettes_for_user(user):
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
granted = {
r.palette: r
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette="")
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
}
result = []
for p in _PALETTE_DEFS:
@@ -62,7 +62,7 @@ def _palettes_for_user(user):
r = granted.get(p["name"])
if r and p["locked"]:
entry["locked"] = False
title = _RECOGNITION_TITLES.get(r.slug, r.slug.capitalize())
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
else:
entry["shoptalk"] = "Placeholder"
@@ -73,89 +73,86 @@ def _palettes_for_user(user):
def _unlocked_palettes_for_user(user):
base = set(_BASE_UNLOCKED)
if user and user.is_authenticated:
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
base.add(r.palette)
return base
def _recent_notes(user, limit=3):
def _recent_posts(user, limit=3):
return (
Note
Post
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_item=Max('item__id'))
.order_by('-last_item')
.annotate(last_line=Max('lines__id'))
.order_by('-last_line')
.distinct()[:limit]
)
def home_page(request):
context = {
"form": ItemForm(),
"palettes": _palettes_for_user(request.user),
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
def new_note(request):
form = ItemForm(data=request.POST)
def new_post(request):
form = LineForm(data=request.POST)
if form.is_valid():
nunote = Note.objects.create()
nupost = Post.objects.create()
if request.user.is_authenticated:
nunote.owner = request.user
nunote.save()
form.save(for_note=nunote)
return redirect(nunote)
nupost.owner = request.user
nupost.save()
form.save(for_post=nupost)
return redirect(nupost)
else:
context = {
"form": form,
"palettes": _palettes_for_user(request.user),
"page_class": "page-dashboard",
"page_class": "page-billboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
context["applets"] = applet_context(request.user, "billboard")
context["recent_posts"] = _recent_posts(request.user)
return render(request, "apps/billboard/billboard.html", context)
def view_note(request, note_id):
our_note = Note.objects.get(id=note_id)
def view_post(request, post_id):
our_post = Post.objects.get(id=post_id)
if our_note.owner:
if our_post.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_note.owner and request.user not in our_note.shared_with.all():
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden()
form = ExistingNoteItemForm(for_note=our_note)
form = ExistingPostLineForm(for_post=our_post)
if request.method == "POST":
form = ExistingNoteItemForm(for_note=our_note, data=request.POST)
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid():
form.save()
return redirect(our_note)
return render(request, "apps/dashboard/note.html", {"note": our_note, "form": form})
return redirect(our_post)
return render(request, "apps/dashboard/post.html", {"post": our_post, "form": form})
def my_notes(request, user_id):
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_notes.html", {"owner": owner})
return render(request, "apps/dashboard/my_posts.html", {"owner": owner})
def share_note(request, note_id):
our_note = Note.objects.get(id=note_id)
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_note)
our_note.shared_with.add(recipient)
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_note)
return redirect(our_post)
@login_required(login_url="/")
def set_palette(request):
@@ -184,8 +181,6 @@ def toggle_applets(request):
return render(request, "apps/dashboard/_partials/_applets.html", {
"applets": applet_context(request.user, "dashboard"),
"palettes": _palettes_for_user(request.user),
"form": ItemForm(),
"recent_notes": _recent_notes(request.user),
})
return redirect("home")
@@ -410,18 +405,18 @@ def sky_save(request):
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
])
recognition_payload = None
note_payload = None
if user.sky_chart_data:
recog, created = Recognition.grant_if_new(user, "stargazer")
note, created = Note.grant_if_new(user, "stargazer")
if created:
recognition_payload = {
"slug": recog.slug,
note_payload = {
"slug": note.slug,
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"earned_at": recog.earned_at.isoformat(),
"earned_at": note.earned_at.isoformat(),
}
return JsonResponse({"saved": True, "recognition": recognition_payload})
return JsonResponse({"saved": True, "note": note_payload})
@login_required(login_url="/")

View File

@@ -0,0 +1,24 @@
import django.conf
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0004_recognition'),
migrations.swappable_dependency(django.conf.settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel('Recognition', 'Note'),
migrations.AlterField(
model_name='note',
name='user',
field=models.ForeignKey(
django.conf.settings.AUTH_USER_MODEL,
on_delete=django.db.models.deletion.CASCADE,
related_name='notes',
),
),
]

View File

@@ -170,10 +170,10 @@ def record(room, verb, actor=None, **data):
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
class Recognition(models.Model):
class Note(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="recognitions",
related_name="notes",
)
slug = models.SlugField(max_length=60)
earned_at = models.DateTimeField()

View File

@@ -2,7 +2,7 @@ from django.test import TestCase
from django.db import IntegrityError
from django.utils import timezone
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -176,44 +176,44 @@ class ScrollPositionModelTest(TestCase):
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
class RecognitionModelTest(TestCase):
class NoteModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="earner@test.io")
def test_can_create_recognition(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertEqual(Recognition.objects.count(), 1)
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(recog.slug, "stargazer")
self.assertEqual(recog.user, self.user)
def test_palette_is_null_by_default(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertIsNone(recog.palette)
def test_palette_can_be_set(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.assertEqual(recog.palette, "palette-bardo")
def test_unique_per_user_and_slug(self):
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
with self.assertRaises(IntegrityError):
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
def test_different_users_can_share_slug(self):
other = User.objects.create(email="other@test.io")
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Recognition.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.assertEqual(Recognition.objects.count(), 2)
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.assertEqual(Note.objects.count(), 2)
def test_str_includes_slug_and_email(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
s = str(recog)
@@ -221,22 +221,22 @@ class RecognitionModelTest(TestCase):
self.assertIn("earner@test.io", s)
def test_grant_if_new_creates_on_first_call(self):
recog, created = Recognition.grant_if_new(self.user, "stargazer")
recog, created = 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):
Recognition.grant_if_new(self.user, "stargazer")
recog, created = Recognition.grant_if_new(self.user, "stargazer")
Note.grant_if_new(self.user, "stargazer")
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(Recognition.objects.count(), 1)
self.assertEqual(Note.objects.count(), 1)
def test_grant_if_new_does_not_overwrite_palette(self):
Recognition.objects.create(
Note.objects.create(
user=self.user, slug="stargazer",
earned_at=timezone.now(), palette="palette-bardo",
)
recog, created = Recognition.grant_if_new(self.user, "stargazer")
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(recog.palette, "palette-bardo")