Compare commits
4 Commits
a218391ea5
...
20c5f6f589
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c5f6f589 | ||
|
|
c099479740 | ||
|
|
ca835059c2 | ||
|
|
9548a2cd15 |
@@ -8,6 +8,7 @@ cssselect==1.3.0
|
|||||||
dj-database-url
|
dj-database-url
|
||||||
Django==6.0
|
Django==6.0
|
||||||
django-compressor
|
django-compressor
|
||||||
|
django-htmx
|
||||||
django-libsass
|
django-libsass
|
||||||
django-stubs==5.2.8
|
django-stubs==5.2.8
|
||||||
django-stubs-ext==5.2.8
|
django-stubs-ext==5.2.8
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ cssselect==1.3.0
|
|||||||
Django==6.0
|
Django==6.0
|
||||||
dj-database-url
|
dj-database-url
|
||||||
django-compressor
|
django-compressor
|
||||||
|
django-htmx
|
||||||
django-libsass
|
django-libsass
|
||||||
django-stubs==5.2.8
|
django-stubs==5.2.8
|
||||||
django-stubs-ext==5.2.8
|
django-stubs-ext==5.2.8
|
||||||
|
|||||||
37
src/apps/dashboard/migrations/0002_applet_userapplet.py
Normal file
37
src/apps/dashboard/migrations/0002_applet_userapplet.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-04 20:34
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Applet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('default_visible', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserApplet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('visible', models.BooleanField(default=True)),
|
||||||
|
('applet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dashboard.applet')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_applets', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'applet')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
17
src/apps/dashboard/migrations/0003_seed_applets.py
Normal file
17
src/apps/dashboard/migrations/0003_seed_applets.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("dashboard", "Applet")
|
||||||
|
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("dashboard", "0002_applet_userapplet"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_applets, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -38,3 +38,26 @@ class Item(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.text
|
return self.text
|
||||||
|
|
||||||
|
class Applet(models.Model):
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
default_visible = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class UserApplet(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"lyric.User",
|
||||||
|
related_name="user_applets",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
applet = models.ForeignKey(
|
||||||
|
Applet,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
visible = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("user", "applet")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.dashboard.models import Item, List
|
from apps.dashboard.models import Applet, Item, List, UserApplet
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -68,3 +68,27 @@ class ListModelTest(TestCase):
|
|||||||
Item.objects.create(list=list_, text="first item")
|
Item.objects.create(list=list_, text="first item")
|
||||||
Item.objects.create(list=list_, text="second item")
|
Item.objects.create(list=list_, text="second item")
|
||||||
self.assertEqual(list_.name, "first item")
|
self.assertEqual(list_.name, "first item")
|
||||||
|
|
||||||
|
class AppletModelTest(TestCase):
|
||||||
|
def test_applet_can_be_created(self):
|
||||||
|
applet = Applet.objects.create(slug="my-applet", name="My Applet", default_visible=True)
|
||||||
|
self.assertEqual(Applet.objects.get(slug="my-applet"), applet)
|
||||||
|
|
||||||
|
def test_applet_slug_is_unique(self):
|
||||||
|
Applet.objects.create(slug="my-applet", name="First")
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
Applet.objects.create(slug="my-applet", name="Second")
|
||||||
|
|
||||||
|
class UserAppletModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="a@b.cde")
|
||||||
|
self.applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
|
||||||
|
def test_user_applet_links_user_to_applet(self):
|
||||||
|
ua = UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
|
||||||
|
self.assertIn(ua, self.user.user_applets.all())
|
||||||
|
|
||||||
|
def test_user_applet_unique_per_user_and_applet(self):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.applet, visible=False)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from apps.dashboard.forms import (
|
|||||||
DUPLICATE_ITEM_ERROR,
|
DUPLICATE_ITEM_ERROR,
|
||||||
EMPTY_ITEM_ERROR,
|
EMPTY_ITEM_ERROR,
|
||||||
)
|
)
|
||||||
from apps.dashboard.models import Item, List
|
from apps.dashboard.models import Applet, Item, List, UserApplet
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +210,11 @@ class ShareListTest(TestCase):
|
|||||||
f"/dashboard/list/{our_list.id}/share_list",
|
f"/dashboard/list/{our_list.id}/share_list",
|
||||||
data={"recipient": "nobody@example.com"},
|
data={"recipient": "nobody@example.com"},
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f"/dashboard/list/{our_list.id}/")
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
f"/dashboard/list/{our_list.id}/",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_share_list_does_not_add_owner_as_recipient(self):
|
def test_share_list_does_not_add_owner_as_recipient(self):
|
||||||
owner = User.objects.create(email="owner@example.com")
|
owner = User.objects.create(email="owner@example.com")
|
||||||
@@ -256,45 +260,45 @@ class ViewAuthListTest(TestCase):
|
|||||||
response = self.client.get(reverse("view_list", args=[self.our_list.id]))
|
response = self.client.get(reverse("view_list", args=[self.our_list.id]))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
class SetThemeTest(TestCase):
|
class SetPaletteTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(email="a@b.cde")
|
self.user = User.objects.create(email="a@b.cde")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.url = reverse("home")
|
self.url = reverse("home")
|
||||||
|
|
||||||
def test_anonymous_user_is_redirected_home(self):
|
def test_anonymous_user_is_redirected_home(self):
|
||||||
response = self.client.post("/dashboard/set_theme")
|
response = self.client.post("/dashboard/set_palette")
|
||||||
self.assertRedirects(response, "/")
|
self.assertRedirects(response, "/")
|
||||||
|
|
||||||
def test_set_theme_updates_user_theme(self):
|
def test_set_palette_updates_user_palette(self):
|
||||||
User.objects.filter(pk=self.user.pk).update(theme="theme-sheol")
|
User.objects.filter(pk=self.user.pk).update(palette="palette-sheol")
|
||||||
self.client.post("/dashboard/set_theme", data={"theme": "theme-default"})
|
self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertEqual(self.user.theme, "theme-default")
|
self.assertEqual(self.user.palette, "palette-default")
|
||||||
|
|
||||||
def test_locked_theme_is_rejected(self):
|
def test_locked_palette_is_rejected(self):
|
||||||
response = self.client.post("/dashboard/set_theme", data={"theme": "theme-nirvana"})
|
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-nirvana"})
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertEqual(self.user.theme, "theme-default")
|
self.assertEqual(self.user.palette, "palette-default")
|
||||||
self.assertRedirects(response, "/")
|
self.assertRedirects(response, "/")
|
||||||
|
|
||||||
def test_set_theme_redirects_home(self):
|
def test_set_palette_redirects_home(self):
|
||||||
response = self.client.post("/dashboard/set_theme", data={"theme": "theme-default"})
|
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
|
||||||
self.assertRedirects(response, "/")
|
self.assertRedirects(response, "/")
|
||||||
|
|
||||||
def test_my_lists_contains_set_theme_form(self):
|
def test_my_lists_contains_set_palette_form(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
parsed = lxml.html.fromstring(response.content)
|
parsed = lxml.html.fromstring(response.content)
|
||||||
forms = parsed.cssselect('form[action="/dashboard/set_theme"]')
|
forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
|
||||||
self.assertEqual(len(forms), 1)
|
self.assertEqual(len(forms), 1)
|
||||||
|
|
||||||
def test_active_theme_swatch_has_active_class(self):
|
def test_active_palette_swatch_has_active_class(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
parsed = lxml.html.fromstring(response.content)
|
parsed = lxml.html.fromstring(response.content)
|
||||||
[active] = parsed.cssselect(".swatch.active")
|
[active] = parsed.cssselect(".swatch.active")
|
||||||
self.assertIn("theme-default", active.classes)
|
self.assertIn("palette-default", active.classes)
|
||||||
|
|
||||||
def test_locked_themes_are_not_forms(self):
|
def test_locked_palettes_are_not_forms(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
parsed = lxml.html.fromstring(response.content)
|
parsed = lxml.html.fromstring(response.content)
|
||||||
locked = parsed.cssselect(".swatch.locked")
|
locked = parsed.cssselect(".swatch.locked")
|
||||||
@@ -303,11 +307,11 @@ class SetThemeTest(TestCase):
|
|||||||
for swatch in locked:
|
for swatch in locked:
|
||||||
self.assertNotEqual(swatch.tag, "button")
|
self.assertNotEqual(swatch.tag, "button")
|
||||||
|
|
||||||
def test_theme_picker_count_matches_context(self):
|
def test_palette_picker_count_matches_context(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
parsed = lxml.html.fromstring(response.content)
|
parsed = lxml.html.fromstring(response.content)
|
||||||
swatches = parsed.cssselect(".swatch")
|
swatches = parsed.cssselect(".swatch")
|
||||||
self.assertEqual(len(swatches), len(response.context["themes"]))
|
self.assertEqual(len(swatches), len(response.context["palettes"]))
|
||||||
|
|
||||||
class ProfileViewTest(TestCase):
|
class ProfileViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -341,3 +345,60 @@ class ProfileViewTest(TestCase):
|
|||||||
[username_input] = parsed.cssselect("#id_new_username")
|
[username_input] = parsed.cssselect("#id_new_username")
|
||||||
self.assertEqual("discoman", username_input.get("value"))
|
self.assertEqual("discoman", username_input.get("value"))
|
||||||
|
|
||||||
|
class ToggleAppletsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
|
self.url = reverse("toggle_applets")
|
||||||
|
|
||||||
|
def test_unauthenticated_user_is_redirected(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, f"/?next={self.url}", fetch_redirect_response=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
|
||||||
|
self.client.post(self.url, {"applets": ["username"]})
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=self.palette_applet)
|
||||||
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
|
def test_redirects_on_normal_post(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, {"applets": ["username", "palette"]}
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, reverse("home"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_returns_200_on_htmx_post(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["username", "palette"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_htmx_post_renders_visible_applets_only(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["username"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1)
|
||||||
|
self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0)
|
||||||
|
|
||||||
|
class AppletVisibilityContextTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.palette_applet, visible=False)
|
||||||
|
|
||||||
|
def test_dash_reflects_user_applet_visibility(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]}
|
||||||
|
self.assertFalse(applet_map["palette"])
|
||||||
|
self.assertTrue(applet_map["username"])
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ urlpatterns = [
|
|||||||
path('new_list', views.new_list, name='new_list'),
|
path('new_list', views.new_list, name='new_list'),
|
||||||
path('list/<uuid:list_id>/', views.view_list, name='view_list'),
|
path('list/<uuid:list_id>/', views.view_list, name='view_list'),
|
||||||
path('list/<uuid:list_id>/share_list', views.share_list, name="share_list"),
|
path('list/<uuid:list_id>/share_list', views.share_list, name="share_list"),
|
||||||
path('set_theme', views.set_theme, name='set_theme'),
|
path('set_palette', views.set_palette, name='set_palette'),
|
||||||
path('set_profile', views.set_profile, name='set_profile'),
|
path('set_profile', views.set_profile, name='set_profile'),
|
||||||
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
|
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
|
||||||
|
path('toggle_applets', views.toggle_applets, name="toggle_applets"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponseForbidden
|
from django.http import HttpResponse, HttpResponseForbidden
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
from .forms import ExistingListItemForm, ItemForm
|
from apps.dashboard.forms import ExistingListItemForm, ItemForm
|
||||||
from .models import Item, List
|
from apps.dashboard.models import Applet, Item, List, UserApplet
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
UNLOCKED_THEMES = frozenset(["theme-default"])
|
UNLOCKED_PALETTES = frozenset(["palette-default"])
|
||||||
THEMES = [
|
PALETTES = [
|
||||||
{"name": "theme-default", "label": "Earthman", "locked": False},
|
{"name": "palette-default", "label": "Earthman", "locked": False},
|
||||||
{"name": "theme-nirvana", "label": "Nirvana", "locked": True},
|
{"name": "palette-nirvana", "label": "Nirvana", "locked": True},
|
||||||
{"name": "theme-sheol", "label": "Sheol", "locked": True},
|
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _applet_context(user):
|
||||||
|
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
||||||
|
return [
|
||||||
|
{"applet": applet, "visible": ua_map.get(applet.pk, applet.default_visible)}
|
||||||
|
for applet in Applet.objects.all()
|
||||||
|
]
|
||||||
|
|
||||||
def home_page(request):
|
def home_page(request):
|
||||||
return render(
|
context = {"form": ItemForm(), "palettes": PALETTES}
|
||||||
request, "apps/dashboard/home.html", {
|
if request.user.is_authenticated:
|
||||||
"form": ItemForm(),
|
context["applets"] = _applet_context(request.user)
|
||||||
"themes": THEMES,
|
return render(request, "apps/dashboard/home.html", context)
|
||||||
})
|
|
||||||
|
|
||||||
def new_list(request):
|
def new_list(request):
|
||||||
form = ItemForm(data=request.POST)
|
form = ItemForm(data=request.POST)
|
||||||
@@ -74,12 +80,12 @@ def share_list(request, list_id):
|
|||||||
return redirect(our_list)
|
return redirect(our_list)
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def set_theme(request):
|
def set_palette(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
theme = request.POST.get("theme", "")
|
palette = request.POST.get("palette", "")
|
||||||
if theme in UNLOCKED_THEMES:
|
if palette in UNLOCKED_PALETTES:
|
||||||
request.user.theme = theme
|
request.user.palette = palette
|
||||||
request.user.save(update_fields=["theme"])
|
request.user.save(update_fields=["palette"])
|
||||||
return redirect("home")
|
return redirect("home")
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@@ -89,3 +95,19 @@ def set_profile(request):
|
|||||||
request.user.username = username
|
request.user.username = username
|
||||||
request.user.save(update_fields=["username"])
|
request.user.save(update_fields=["username"])
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def toggle_applets(request):
|
||||||
|
checked = request.POST.getlist("applets")
|
||||||
|
for applet in Applet.objects.all():
|
||||||
|
UserApplet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
applet=applet,
|
||||||
|
defaults={"visible": applet.slug in checked},
|
||||||
|
)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render(request, "apps/dashboard/_partials/_applets.html", {
|
||||||
|
"applets": _applet_context(request.user),
|
||||||
|
"palettes": PALETTES,
|
||||||
|
})
|
||||||
|
return redirect("home")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-05 19:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lyric', '0003_user_theme'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='user',
|
||||||
|
name='theme',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='palette',
|
||||||
|
field=models.CharField(default='palette-default', max_length=32),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -26,7 +26,7 @@ class User(AbstractBaseUser):
|
|||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
username = models.CharField(max_length=35, unique=True, null=True, blank=True)
|
username = models.CharField(max_length=35, unique=True, null=True, blank=True)
|
||||||
searchable = models.BooleanField(default=False)
|
searchable = models.BooleanField(default=False)
|
||||||
theme = models.CharField(max_length=32, default="theme-default")
|
palette = models.CharField(max_length=32, default="palette-default")
|
||||||
|
|
||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
is_superuser = models.BooleanField(default=False)
|
is_superuser = models.BooleanField(default=False)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class UserManagerTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(user.check_password("correct-password"))
|
self.assertTrue(user.check_password("correct-password"))
|
||||||
|
|
||||||
class UserThemeTest(TestCase):
|
class UserPaletteTest(TestCase):
|
||||||
def test_theme_field_defaults_to_theme_default(self):
|
def test_palette_field_defaults_to_palette_default(self):
|
||||||
user = User.objects.create(email="a@b.cde")
|
user = User.objects.create(email="a@b.cde")
|
||||||
self.assertEqual(user.theme, "theme-default")
|
self.assertEqual(user.palette, "palette-default")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
def user_theme(request):
|
def user_palette(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return {"user_theme": request.user.theme}
|
return {"user_palette": request.user.palette}
|
||||||
return {"user_theme": "theme-default"}
|
return {"user_palette": "palette-default"}
|
||||||
@@ -77,6 +77,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'django_htmx.middleware.HtmxMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'core.urls'
|
ROOT_URLCONF = 'core.urls'
|
||||||
@@ -91,7 +92,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'core.context_processors.user_theme',
|
'core.context_processors.user_palette',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from .base import FunctionalTest
|
|||||||
|
|
||||||
|
|
||||||
class SiteThemeTest(FunctionalTest):
|
class SiteThemeTest(FunctionalTest):
|
||||||
def test_page_renders_with_earthman_theme(self):
|
def test_page_renders_with_earthman_palette(self):
|
||||||
self.browser.get(self.live_server_url)
|
self.browser.get(self.live_server_url)
|
||||||
body = self.browser.find_element(By.TAG_NAME, "body")
|
body = self.browser.find_element(By.TAG_NAME, "body")
|
||||||
self.assertIn("theme-default", body.get_attribute("class"))
|
self.assertIn("palette-default", body.get_attribute("class"))
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
|
from selenium.common.exceptions import NoSuchElementException
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
|
from apps.dashboard.models import Applet
|
||||||
|
|
||||||
|
|
||||||
class DashboardMaintenanceTest(FunctionalTest):
|
class DashboardMaintenanceTest(FunctionalTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
|
|
||||||
def test_user_without_username_can_claim_unclaimed_username(self):
|
def test_user_without_username_can_claim_unclaimed_username(self):
|
||||||
# 1. Create a pre-authenticated session for discoman@example.com
|
# 1. Create a pre-authenticated session for discoman@example.com
|
||||||
self.create_pre_authenticated_session("discoman@example.com")
|
self.create_pre_authenticated_session("discoman@example.com")
|
||||||
@@ -37,3 +44,73 @@ class DashboardMaintenanceTest(FunctionalTest):
|
|||||||
self.browser.find_element(By.CSS_SELECTOR, "#id_new_username").get_attribute("value")
|
self.browser.find_element(By.CSS_SELECTOR, "#id_new_username").get_attribute("value")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_user_can_toggle_applet_visibility_via_gear_menu(self):
|
||||||
|
# 1. Auth as discoman@example.com, navigate home
|
||||||
|
self.create_pre_authenticated_session("discoman@example.com")
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
# 2. Assert both applets present on page (id_applet_username, id_applet_palette)
|
||||||
|
self.browser.find_element(By.ID, "id_applet_username")
|
||||||
|
self.browser.find_element(By.ID, "id_applet_palette")
|
||||||
|
# 3. Click el w. id="id_dash_gear"
|
||||||
|
dash_gear = self.browser.find_element(By.ID, "id_dash_gear")
|
||||||
|
dash_gear.click()
|
||||||
|
# 4. A menu appears; wait_for el w. id="id_applet_menu"
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(
|
||||||
|
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 5. Find two checkboxes in menu, name="username" & name="palette"; assert both .is_selected()
|
||||||
|
menu = self.browser.find_element(By.ID, "id_applet_menu")
|
||||||
|
username_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="username"]')
|
||||||
|
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
|
||||||
|
self.assertTrue(username_cb.is_selected())
|
||||||
|
self.assertTrue(palette_cb.is_selected())
|
||||||
|
# 6. Click palette box to uncheck it
|
||||||
|
palette_cb.click()
|
||||||
|
self.assertFalse(palette_cb.is_selected())
|
||||||
|
self.browser.execute_script("window.__no_reload_marker = true")
|
||||||
|
# 7. Submit the menu form via [type="submit"] btn inside menu
|
||||||
|
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertFalse(
|
||||||
|
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 8. wait_for palette applet to be gone
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertRaises(
|
||||||
|
NoSuchElementException,
|
||||||
|
self.browser.find_element,
|
||||||
|
By.ID, "id_applet_palette"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 9. assert id_applet_username remains
|
||||||
|
self.browser.find_element(By.ID, "id_applet_username")
|
||||||
|
# 10. Click gear again, find menu, find palette checkbox; assert now NOT selected
|
||||||
|
dash_gear.click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(
|
||||||
|
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
menu = self.browser.find_element(By.ID, "id_applet_menu")
|
||||||
|
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
|
||||||
|
self.assertFalse(palette_cb.is_selected())
|
||||||
|
# 11. Click it to re-check box; submit
|
||||||
|
palette_cb.click()
|
||||||
|
self.assertTrue(palette_cb.is_selected())
|
||||||
|
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
|
||||||
|
# 12. wait_for id_applet_palette to reappear
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertFalse(
|
||||||
|
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(
|
||||||
|
self.browser.find_element(By.ID, "id_applet_palette")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(self.browser.execute_script("return window.__no_reload_marker === true"))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
.theme-picker {
|
.palette-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-picker-item {
|
.palette-picker-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@import 'rootvars';
|
@import 'rootvars';
|
||||||
@import 'base';
|
@import 'base';
|
||||||
@import 'button-pad';
|
@import 'button-pad';
|
||||||
@import 'theme-picker';
|
@import 'palette-picker';
|
||||||
|
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -301,8 +301,8 @@
|
|||||||
--decClh: 255, 174, 0;
|
--decClh: 255, 174, 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Default Earthman Theme */
|
/* Default Earthman Palette */
|
||||||
.theme-default {
|
.palette-default {
|
||||||
--priUser: var(--terBrk);
|
--priUser: var(--terBrk);
|
||||||
--secUser: var(--priKhk);
|
--secUser: var(--priKhk);
|
||||||
--terUser: var(--priMze);
|
--terUser: var(--priMze);
|
||||||
@@ -314,8 +314,8 @@
|
|||||||
--ninUser: var(--priCtn);
|
--ninUser: var(--priCtn);
|
||||||
--decUser: var(--terCtn);
|
--decUser: var(--terCtn);
|
||||||
}
|
}
|
||||||
/* Grave Sheol Theme */
|
/* Grave Sheol Palette */
|
||||||
.theme-sheol {
|
.palette-sheol {
|
||||||
--priUser: var(--priPu);
|
--priUser: var(--priPu);
|
||||||
--secUser: var(--quiPu);
|
--secUser: var(--quiPu);
|
||||||
--terUser: var(--terFs);
|
--terUser: var(--terFs);
|
||||||
@@ -327,8 +327,8 @@
|
|||||||
--ninUser: var(--sixPu);
|
--ninUser: var(--sixPu);
|
||||||
--decUser: var(--terPu);
|
--decUser: var(--terPu);
|
||||||
}
|
}
|
||||||
/* Blissful Nirvana Theme */
|
/* Blissful Nirvana Palette */
|
||||||
.theme-nirvana {
|
.palette-nirvana {
|
||||||
--priUser: var(--priU);
|
--priUser: var(--priU);
|
||||||
--secUser: var(--quiU);
|
--secUser: var(--quiU);
|
||||||
--terUser: var(--terMe);
|
--terUser: var(--terMe);
|
||||||
@@ -340,8 +340,8 @@
|
|||||||
--ninUser: var(--sixCu);
|
--ninUser: var(--sixCu);
|
||||||
--decUser: var(--terU);
|
--decUser: var(--terU);
|
||||||
}
|
}
|
||||||
/* Disco Inferno Theme */
|
/* Disco Inferno Palette */
|
||||||
.theme-inferno {
|
.palette-inferno {
|
||||||
--priUser: var(--quaSwp);
|
--priUser: var(--quaSwp);
|
||||||
--secUser: var(--priSwp);
|
--secUser: var(--priSwp);
|
||||||
--terUser: var(--terBld);
|
--terUser: var(--terBld);
|
||||||
@@ -353,8 +353,8 @@
|
|||||||
--ninUser: var(--priMst);
|
--ninUser: var(--priMst);
|
||||||
--decUser: var(--terMst);
|
--decUser: var(--terMst);
|
||||||
}
|
}
|
||||||
/* Torre Terrestre Theme */
|
/* Torre Terrestre Palette */
|
||||||
.theme-terrestre {
|
.palette-terrestre {
|
||||||
--priUser: var(--priAdm);
|
--priUser: var(--priAdm);
|
||||||
--secUser: var(--quaAdm);
|
--secUser: var(--quaAdm);
|
||||||
--terUser: var(--sixAdm);
|
--terUser: var(--sixAdm);
|
||||||
@@ -366,8 +366,8 @@
|
|||||||
--ninUser: var(--sixPer);
|
--ninUser: var(--sixPer);
|
||||||
--decUser: var(--terMrb);
|
--decUser: var(--terMrb);
|
||||||
}
|
}
|
||||||
/* Fantastia Celestia Theme */
|
/* Fantastia Celestia Palette */
|
||||||
.theme-celestia {
|
.palette-celestia {
|
||||||
--priUser: var(--octClh);
|
--priUser: var(--octClh);
|
||||||
--secUser: var(--sixClh);
|
--secUser: var(--sixClh);
|
||||||
--terUser: var(--quaClh);
|
--terUser: var(--quaClh);
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
--decUser: var(--quiClh);
|
--decUser: var(--quiClh);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme Classes */
|
/* Palette Classes */
|
||||||
.priUser {
|
.priUser {
|
||||||
color: rgba(var(--priUser), 1);
|
color: rgba(var(--priUser), 1);
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/static_src/vendor/htmx.min.js
vendored
Normal file
1
src/static_src/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
61
src/templates/apps/dashboard/_partials/_applets.html
Normal file
61
src/templates/apps/dashboard/_partials/_applets.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% load lyric_extras %}
|
||||||
|
<div id="id_applets_container">
|
||||||
|
<div id="id_applet_menu" style="display:none;">
|
||||||
|
<form
|
||||||
|
hx-post="{% url "toggle_applets" %}"
|
||||||
|
hx-target="#id_applets_container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for entry in applets %}
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="applets"
|
||||||
|
value="{{ entry.applet.slug }}"
|
||||||
|
{% if entry.visible %}checked{% endif %}
|
||||||
|
>
|
||||||
|
{{ entry.applet.name }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-confirm">OK</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for entry in applets %}
|
||||||
|
{% if entry.visible %}
|
||||||
|
{% if entry.applet.slug == "username" %}
|
||||||
|
<section id="id_applet_username">
|
||||||
|
<h1>{{ user|display_name }}</h1>
|
||||||
|
<div class="form-container">
|
||||||
|
<form method="POST" action="{% url "set_profile" %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input
|
||||||
|
id="id_new_username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
value="{{ user.username|default:'' }}"
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% elif entry.applet.slug == "palette" %}
|
||||||
|
<section id="id_applet_palette" class="palette">
|
||||||
|
{% for palette in palettes %}
|
||||||
|
<div class="palette-item">
|
||||||
|
<div class="swatch {{ palette.name }}{% if user_palette == palette.name %} active{% endif %}{% if palette.locked %} locked{% endif %}"></div>
|
||||||
|
{% if not palette.locked %}
|
||||||
|
<form method="POST" action="{% url "set_palette" %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="palette" value="{{ palette.name }}" class="btn btn-confirm">OK</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-disabled">×</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -15,29 +15,12 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<section class="theme-picker">
|
<button
|
||||||
{% for theme in themes %}
|
id="id_dash_gear"
|
||||||
<div class="theme-picker-item">
|
onclick="document.getElementById('id_applet_menu').style.display='block'"
|
||||||
<div class="swatch {{ theme.name }}{% if user_theme == theme.name %} active{% endif %}{% if theme.locked %} locked{% endif %}"></div>
|
>
|
||||||
{% if not theme.locked %}
|
⚙
|
||||||
<form method="POST" action="{% url 'set_theme' %}">
|
</button>
|
||||||
{% csrf_token %}
|
{% include "apps/dashboard/_partials/_applets.html" %}
|
||||||
<button type="submit" name="theme" value="{{ theme.name }}" class="btn btn-confirm">OK</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<span class="btn btn-disabled">×</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
<section id="id_applet_username">
|
|
||||||
<h1>{{ user|display_name }}</h1>
|
|
||||||
<div class="form-container">
|
|
||||||
<form method="POST" action="{% url "set_profile" %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input id="id_new_username" name="username" required value="{{ user.username|default:'' }}">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{% endcompress %}
|
{% endcompress %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="{{ user_theme }}">
|
<body class="{{ user_palette }}">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@@ -77,9 +77,27 @@
|
|||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
<script src="{% static "vendor/htmx.min.js" %}"></script>
|
||||||
|
<script>
|
||||||
|
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||||
|
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');
|
||||||
|
});
|
||||||
|
function getCookie(name) {
|
||||||
|
let val = null;
|
||||||
|
if (document.cookie) {
|
||||||
|
for (let c of document.cookie.split(';')) {
|
||||||
|
c = c.trim();
|
||||||
|
if (c.startsWith(name + '=')) {
|
||||||
|
val = decodeURIComponent(c.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user