diff --git a/NibasaViewer/middleware.py b/NibasaViewer/middleware.py new file mode 100644 index 0000000..f1ed992 --- /dev/null +++ b/NibasaViewer/middleware.py @@ -0,0 +1,65 @@ +from django.template.response import TemplateResponse +from django.core.exceptions import MultipleObjectsReturned + +from viewer.models import UserSettings + + +class UserSettingsMiddleware: + """Middleware that injects `theme` and `sort` into template response context. + + On each request it looks for a UserSettings instance for the authenticated + user. The found values (or model defaults) are attached to the request and + then added to any TemplateResponse's context under the `theme` and `sort` + keys. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Populate request attributes with user settings or defaults. + request.theme = None + request.sort = None + + user = getattr(request, "user", None) + if user and getattr(user, "is_authenticated", False): + # Use filter().first() to avoid exceptions if no settings exist. + try: + settings_obj = UserSettings.objects.get(user=user) + except UserSettings.DoesNotExist: + settings_obj = UserSettings(user=user) + settings_obj.save() + except MultipleObjectsReturned: + settings_obj = UserSettings.objects.filter(user=user).first() + finally: + request.theme = settings_obj.theme + request.sort = settings_obj.sort + + # Fall back to the model defaults when not set from DB. + if request.theme is None: + request.theme = UserSettings._meta.get_field("theme").get_default() + if request.sort is None: + request.sort = UserSettings._meta.get_field("sort").get_default() + + response = self.get_response(request) + return response + + def process_template_response(self, request, response): + """Add `theme` and `sort` to TemplateResponse.context_data. + + This method is only called for responses that implement + `render()` (TemplateResponse-like). We update or create + `context_data` so templates can access `theme` and `sort`. + """ + if not isinstance(response, TemplateResponse): + return response + + ctx = getattr(response, "context_data", None) + if ctx is None: + response.context_data = {"theme": request.theme, "sort": request.sort} + else: + # Do not overwrite existing keys if templates or other processors set them + ctx.setdefault("theme", request.theme) + ctx.setdefault("sort", request.sort) + + return response diff --git a/NibasaViewer/settings.py b/NibasaViewer/settings.py index ad7948c..d5c78f5 100644 --- a/NibasaViewer/settings.py +++ b/NibasaViewer/settings.py @@ -105,6 +105,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "NibasaViewer.middleware.UserSettingsMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] diff --git a/viewer/migrations/0003_usersettings.py b/viewer/migrations/0003_usersettings.py new file mode 100644 index 0000000..3bed3cd --- /dev/null +++ b/viewer/migrations/0003_usersettings.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.3 on 2026-03-25 08:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("viewer", "0002_alter_image_path"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("theme", models.CharField(default="dark", max_length=5)), + ("sort", models.CharField(default="abc", max_length=6)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/viewer/models.py b/viewer/models.py index be4d8f1..4b984c7 100644 --- a/viewer/models.py +++ b/viewer/models.py @@ -2,6 +2,7 @@ from django.utils import timezone from django.conf import settings from django.db.models import ( Model, + CharField, BooleanField, DateTimeField, IntegerField, @@ -15,6 +16,19 @@ def get_gallery_root(): return settings.GALLERY_ROOT +class UserSettings(Model): + """ + User relations to a specific image file by path. + """ + + user = ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, on_delete=CASCADE) + theme = CharField(max_length=5, blank=False, null=False, default='dark') + sort = CharField(max_length=6, blank=False, null=False, default='abc') + + class meta: + ordering = ["pk"] + + class Image(Model): """ User relations to a specific image file by path. diff --git a/viewer/test.py b/viewer/test.py index 95568ed..861028b 100644 --- a/viewer/test.py +++ b/viewer/test.py @@ -11,12 +11,15 @@ from PIL import Image from django.test import Client, RequestFactory, TestCase, override_settings from django.contrib.auth.models import User from django.utils import timezone +from django.http import HttpResponse +from django.template.response import TemplateResponse # Project imports. from viewer.common import SORT_LABELS from viewer.directory import do_recursive_search from viewer.views import gallery_view -from viewer.models import Image as Im +from viewer.models import Image as Im, UserSettings +from NibasaViewer.middleware import UserSettingsMiddleware class GalleryBaseTests(TestCase): @@ -38,6 +41,10 @@ class GalleryBaseTests(TestCase): self.client = Client() self.client.force_login(self.user) self.factory = RequestFactory() + # Ensure UserSettings exists for the test user so middleware-backed + # attributes (`theme`, `sort`) are available during view rendering + # even when tests call views directly via RequestFactory. + UserSettings.objects.get_or_create(user=self.user) self._build_fixture_tree() @@ -97,6 +104,12 @@ class GalleryViewTests(GalleryBaseTests): request = self.factory.get("/gallery/../") request.user = self.user + # When calling the view directly we still need to allow any + # TemplateResponse processing to see the settings. Ensure the + # request has the attributes the middleware would provide. + request.theme = UserSettings._meta.get_field("theme").get_default() + request.sort = UserSettings._meta.get_field("sort").get_default() + response = gallery_view(request, "../") self.assertEqual(response.status_code, 404) @@ -501,3 +514,60 @@ class ImageModelTests(GalleryBaseTests): img.refresh_from_db() self.assertTrue(img.favorite) + + +class UserSettingsModelTests(TestCase): + def test_usersettings_defaults(self): + user = User.objects.create_user("usertest", "u@example.com", "pw") + us = UserSettings.objects.create(user=user) + + self.assertEqual(us.theme, "dark") + self.assertEqual(us.sort, "abc") + + +class UserSettingsMiddlewareTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("mwuser", "mw@example.com", "pw") + self.factory = RequestFactory() + + def test_middleware_creates_settings_and_sets_request(self): + request = self.factory.get("/") + request.user = self.user + + def get_response(req): + return HttpResponse("ok") + + mw = UserSettingsMiddleware(get_response) + response = mw(request) + + # UserSettings should have been created and request attrs populated + self.assertTrue(UserSettings.objects.filter(user=self.user).exists()) + self.assertEqual( + request.theme, UserSettings._meta.get_field("theme").get_default() + ) + self.assertEqual( + request.sort, UserSettings._meta.get_field("sort").get_default() + ) + + def test_process_template_response_injects_and_preserves(self): + # Create a UserSettings with non-defaults + UserSettings.objects.create(user=self.user, theme="light", sort="cba") + + request = self.factory.get("/") + request.user = self.user + + def get_response(req): + # Provide a TemplateResponse that already sets `theme` to ensure + # the middleware does not overwrite existing keys. + return TemplateResponse( + req, "viewer/gallery_view.html", {"theme": "override-theme"} + ) + + mw = UserSettingsMiddleware(get_response) + response = mw(request) + + # process_template_response should set missing keys but preserve existing ones + resp = mw.process_template_response(request, response) + self.assertIsInstance(resp, TemplateResponse) + self.assertEqual(resp.context_data.get("theme"), "override-theme") + self.assertEqual(resp.context_data.get("sort"), "cba")