Compare commits

...

11 Commits

Author SHA1 Message Date
24c1c96f19 Added home and back links to gallery view. 2026-03-25 12:35:00 -04:00
5a2bea3040 Assorted CSS tweaks for mobile. 2026-03-25 12:21:49 -04:00
27da3a33d3 Made top bar fit screen borders on mobile. 2026-03-25 12:15:01 -04:00
Miguel Astor
ab81a0a97d test: add clear-search URL and template tests
Add tests for clear_search_url context and template clear-search button when a search is active.
2026-03-25 06:08:00 -04:00
Miguel Astor
b8e7a876a7 tests: accept build_query only preserving search (drop sort/theme from gallery link assertions) 2026-03-25 05:54:41 -04:00
Miguel Astor
377951efbe Fixed issue with sort and theme keys still being applied as GET parameters. 2026-03-25 04:53:36 -04:00
Miguel Astor
73b538b698 feat(viewer): persist theme/sort via toggle view; use middleware-provided theme/sort in views and templates 2026-03-25 04:43:35 -04:00
Miguel Astor
8719be3227 tests: add UserSettings model and middleware tests; ensure tests create UserSettings defaults 2026-03-25 04:25:38 -04:00
Miguel Astor
13ab55d1d3 Changed Image model path to method. 2026-03-24 15:39:09 -04:00
Miguel Astor
5ec793b47d Added kate droppings to gitignore. 2026-03-24 15:34:15 -04:00
Miguel Astor
f658106316 Fixed issue with image links covering all image wrapper area. 2026-03-24 15:34:03 -04:00
15 changed files with 488 additions and 46 deletions

2
.gitignore vendored
View File

@@ -362,3 +362,5 @@ Icon
Network Trash Folder
Temporary Items
.apdisk
.kateproject*

View File

@@ -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

View File

@@ -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",
]

View File

@@ -48,8 +48,8 @@ def get_modification_timestamp(path_obj):
return 0
def build_query(search_text, sort_key, theme):
query = {"sort": sort_key, "theme": theme}
def build_query(search_text):
query = {}
if search_text != "":
query["search"] = search_text
@@ -170,16 +170,22 @@ def build_sort_options(request, search_text, sort_key, theme):
options = []
for option_key, label in SORT_OPTIONS:
query = {"sort": option_key, "theme": theme}
# Build a URL that points to the settings toggle endpoint which will
# persist the chosen sort (and optionally theme) in the user's
# UserSettings and then redirect back to the current view. Include
# the current full path as the `next` parameter so the toggle view
# can return to the same page.
query = {"next": request.get_full_path(), "sort": option_key, "theme": theme}
if search_text != "":
# Include search top-level so templates/tests can assert its presence
query["search"] = search_text
options.append(
{
"key": option_key,
"label": label,
"url": append_query(request.path, query),
"url": append_query("/gallery/toggle-settings/", query),
"is_active": option_key == sort_key,
}
)

View File

@@ -16,6 +16,7 @@ from .common import (
normalize_theme,
build_query,
gallery_url,
append_query,
sort_images,
build_sort_options,
build_breadcrumbs,
@@ -68,10 +69,17 @@ def render_directory(request, path_text, full_path):
in the file system, or logical gallery directories like search result pages.
"""
# Search remains a GET parameter. For sort and theme prefer explicit
# GET parameters when present (so query-preserving links behave as
# callers expect), otherwise fall back to middleware-provided settings.
search_text = request.GET.get("search", "").strip()
sort_key = normalize_sort(request.GET.get("sort", "abc"))
theme = normalize_theme(request.GET.get("theme", "dark"))
query_state = build_query(search_text, sort_key, theme)
sort_key = normalize_sort(
request.GET.get("sort") or getattr(request, "sort", None) or "abc"
)
theme = normalize_theme(
request.GET.get("theme") or getattr(request, "theme", None) or "dark"
)
query_state = build_query(search_text)
try:
current_entries = [
@@ -130,13 +138,37 @@ def render_directory(request, path_text, full_path):
)
next_theme = "light" if theme == "dark" else "dark"
theme_query = {"sort": sort_key, "theme": next_theme}
# The theme toggle button now calls the persistent settings endpoint.
# Include `next` (for redirect back) and also surface `sort` and `search`
# as top-level query parameters so templates/tests can inspect them easily.
theme_toggle_query = {
"next": request.get_full_path(),
"theme": next_theme,
"sort": sort_key,
}
if search_text != "":
theme_query["search"] = search_text
theme_toggle_query["search"] = search_text
search_action_query = {"sort": sort_key, "theme": theme}
# Back (directory) and Home (root) links and thumbnails
dir_rel = None
try:
# derive directory path text relative to gallery root
dir_rel = full_path.parent.relative_to(settings.GALLERY_ROOT)
dir_text = str(dir_rel).replace("\\", "/")
except Exception:
dir_text = ""
back_url = gallery_url(
Path(dir_text) if dir_text != "" else None, True, query_state
)
back_thumb = get_first_image_thumbnail_url(full_path.parent)
home_url = gallery_url(None, True, query_state)
home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT)
context = {
"path": path_text,
"search_text": search_text,
@@ -147,12 +179,19 @@ def render_directory(request, path_text, full_path):
"breadcrumbs": build_breadcrumbs(path_text, query_state),
"images": image_data,
"subdirs": subdir_data,
"theme_toggle_url": gallery_url(
Path(path_text) if path_text != "" else None, True, theme_query
"back_url": back_url,
"back_thumb": back_thumb,
"home_url": home_url,
"home_thumb": home_thumb,
"theme_toggle_url": append_query(
"/gallery/toggle-settings/", theme_toggle_query
),
"search_action_url": gallery_url(
Path(path_text) if path_text != "" else None, True, search_action_query
),
"clear_search_url": gallery_url(
Path(path_text) if path_text != "" else None, True, None
),
}
# sort_label depends on SORT_LABELS in common; import lazily to avoid circulars

View File

@@ -23,6 +23,7 @@ from .common import (
normalize_theme,
build_query,
gallery_url,
append_query,
sort_images,
build_sort_options,
build_breadcrumbs,
@@ -40,7 +41,7 @@ def render_image(request, path_text, full_path):
try:
img = Im.objects.get(path=full_path, user=request.user)
if request.method == 'POST':
if request.method == "POST":
img.favorite = not img.favorite
except Im.DoesNotExist:
@@ -51,11 +52,17 @@ def render_image(request, path_text, full_path):
img.visits = img.visits + 1
img.save()
# Preserve query state (sort, search, theme) similar to gallery view
# Search remains a GET parameter. For sort and theme prefer explicit
# GET parameters when present, otherwise fall back to middleware-provided
# settings on the request.
search_text = request.GET.get("search", "").strip()
sort_key = normalize_sort(request.GET.get("sort", "abc"))
theme = normalize_theme(request.GET.get("theme", "dark"))
query_state = build_query(search_text, sort_key, theme)
sort_key = normalize_sort(
request.GET.get("sort") or getattr(request, "sort", None) or "abc"
)
theme = normalize_theme(
request.GET.get("theme") or getattr(request, "theme", None) or "dark"
)
query_state = build_query(search_text)
image = Path("/imgs/").joinpath(path_text)
img_dir = full_path.parent
@@ -169,9 +176,13 @@ def render_image(request, path_text, full_path):
breadcrumbs.append({"label": full_path.name, "path": None})
next_theme = "light" if theme == "dark" else "dark"
theme_query = {"sort": sort_key, "theme": next_theme}
theme_query = {
"next": request.get_full_path(),
"sort": sort_key,
"theme": next_theme,
}
if search_text != "":
theme_query["search"] = search_text
theme_query["next"] = request.get_full_path()
context = {
"image_path": image,
@@ -196,16 +207,14 @@ def render_image(request, path_text, full_path):
"modified": fmt_ts(modified_ts),
"visits": img.visits,
"visited": fmt_ts(img.last_visited.timestamp()),
"favorite": img.favorite
"favorite": img.favorite,
},
"breadcrumbs": breadcrumbs,
"theme": theme,
"sort_key": sort_key,
"sort_label": "",
"sort_options": build_sort_options(request, search_text, sort_key, theme),
"theme_toggle_url": gallery_url(
Path(dir_path_text) if dir_path_text != "" else None, True, theme_query
),
"theme_toggle_url": append_query("/gallery/toggle-settings/", theme_query),
"path": path_text,
}

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0.3 on 2026-03-24 19:38
import viewer.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("viewer", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="image",
name="path",
field=models.FilePathField(path=viewer.models.get_gallery_root),
),
]

View File

@@ -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,
),
),
],
),
]

View File

@@ -2,6 +2,7 @@ from django.utils import timezone
from django.conf import settings
from django.db.models import (
Model,
CharField,
BooleanField,
DateTimeField,
IntegerField,
@@ -11,13 +12,30 @@ from django.db.models import (
)
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.
"""
user = ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, on_delete=CASCADE)
path = FilePathField(path=settings.GALLERY_ROOT, blank=False, null=False)
path = FilePathField(path=get_gallery_root, blank=False, null=False)
favorite = BooleanField(blank=False, null=False, default=False)
last_visited = DateTimeField(blank=False, null=False, default=timezone.now)
visits = IntegerField(blank=False, null=False, default=0)

View File

@@ -142,7 +142,7 @@ body {
min-width: 0;
}
.crumb-link {
.crumb-link, .crumb-link-last {
color: var(--text);
text-decoration: none;
font-size: 15px;
@@ -189,7 +189,6 @@ body {
/* Image view specific styles */
.image-content {
overflow: auto;
padding: 18px;
flex: 1 1 auto; /* occupy available vertical space inside main-area */
display: flex;
align-items: center; /* center the image vertically */
@@ -381,13 +380,30 @@ body.theme-dark .small.text-muted {
@media (max-width: 991.98px) {
.app-shell {
padding: 12px;
padding: 0px;
gap: 12px;
}
.offcanvas {
width: 100vw !important;
}
.top-bar {
border-radius: 0px;
}
.crumb-link {
display: none;
}
.crumb-sep {
display: none;
}
.gallery-scroll {
padding-left: 16px;
padding-right: 16px;
}
}
@media (max-width: 575.98px) {

View File

@@ -33,6 +33,11 @@
<div class="input-group input-group-sm">
<input type="search" name="search" class="form-control search-input" placeholder="Search..." value="{{ search_text }}">
<span class="input-group-text search-addon"><i class="fa-solid fa-magnifying-glass"></i></span>
{% if search_text %}
<a class="btn btn-sm btn-plain ms-auto" href="{{ clear_search_url }}" aria-label="Clear search">
<i class="fa-solid fa-xmark"></i>
</a>
{% endif %}
</div>
</form>
@@ -42,6 +47,28 @@
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
{% if path != '' %}
<hr>
<a href="{{ back_url }}" class="subdir-item">
{% if back_thumb %}
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Back</span>
</a>
<a href="{{ home_url }}" class="subdir-item">
{% if home_thumb %}
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Home</span>
</a>
{% endif %}
<hr>
<div class="sidebar-scroll flex-grow-1">
@@ -98,6 +125,28 @@
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
{% if path != '' %}
<hr>
<a href="{{ back_url }}" class="subdir-item">
{% if back_thumb %}
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Back</span>
</a>
<a href="{{ home_url }}" class="subdir-item">
{% if home_thumb %}
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Home</span>
</a>
{% endif %}
<hr>
<div class="sidebar-scroll" style="max-height: 200px;">
@@ -137,7 +186,7 @@
{% if not forloop.first %}
<span class="crumb-sep">/</span>
{% endif %}
<a href="{{ crumb.path }}" class="crumb-link">{{ crumb.label|truncatechars:45 }}</a>
<a href="{{ crumb.path }}" class="{% if not forloop.last %}crumb-link{% else %}crumb-link-last{% endif %}">{{ crumb.label|truncatechars:45 }}</a>
{% endfor %}
</div>
@@ -212,6 +261,28 @@
<a href="#" class="sidebar-link">Most visited</a>
<a href="#" class="sidebar-link">Recently visited</a>
{% if path != '' %}
<hr>
<a href="{{ back_url }}" class="subdir-item">
{% if back_thumb %}
<img src="{{ back_thumb }}" class="subdir-thumb" alt="back thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Back</span>
</a>
<a href="{{ home_url }}" class="subdir-item">
{% if home_thumb %}
<img src="{{ home_thumb }}" class="subdir-thumb" alt="home thumb">
{% else %}
<span class="subdir-fallback"><i class="fa-solid fa-image"></i></span>
{% endif %}
<span>Home</span>
</a>
{% endif %}
<hr>
<div class="sidebar-scroll flex-grow-1">

View File

@@ -164,12 +164,12 @@
<section class="gallery-scroll flex-grow-1 d-flex">
<div class="image-content w-100">
<a href="{{ image_path }}" target="_blank">
<div class="image-wrapper">
<a href="{{ image_path }}" target="_blank">
<img src="{{ image_path }}" alt="{{ image_path.name }}" class="image-full">
</div>
</a>
</div>
</div>
</section>
</main>
</div>

View File

@@ -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)
@@ -180,26 +193,29 @@ class GalleryViewTests(GalleryBaseTests):
breadcrumbs = response.context["breadcrumbs"]
self.assertTrue(breadcrumbs[0]["path"].find("/gallery/?") == 0)
self.assertIn("search=match", breadcrumbs[0]["path"])
self.assertIn("sort=recent", breadcrumbs[0]["path"])
self.assertIn("theme=light", breadcrumbs[0]["path"])
# build_query now only preserves search; sort/theme are not included
self.assertNotIn("sort=", breadcrumbs[0]["path"])
self.assertNotIn("theme=", breadcrumbs[0]["path"])
def test_subdir_links_preserve_query(self):
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
subdir_paths = [subdir["path"] for subdir in response.context["subdirs"]]
# Only search is preserved in gallery links via build_query
self.assertTrue(any(path.find("search=match") != -1 for path in subdir_paths))
self.assertTrue(any(path.find("sort=recent") != -1 for path in subdir_paths))
self.assertTrue(any(path.find("theme=light") != -1 for path in subdir_paths))
self.assertFalse(any(path.find("sort=") != -1 for path in subdir_paths))
self.assertFalse(any(path.find("theme=") != -1 for path in subdir_paths))
def test_image_links_preserve_query(self):
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
image_paths = [image["path"] for image in response.context["images"]]
# Only search is preserved in gallery links via build_query
self.assertTrue(any(path.find("search=match") != -1 for path in image_paths))
self.assertTrue(any(path.find("sort=recent") != -1 for path in image_paths))
self.assertTrue(any(path.find("theme=light") != -1 for path in image_paths))
self.assertFalse(any(path.find("sort=") != -1 for path in image_paths))
self.assertFalse(any(path.find("theme=") != -1 for path in image_paths))
def test_theme_toggle_url_preserves_query(self):
response = self.client.get("/gallery/?search=match&sort=recent&theme=dark")
@@ -219,6 +235,26 @@ class GalleryViewTests(GalleryBaseTests):
self.assertIn("sort=recent", url)
self.assertIn("theme=light", url)
def test_clear_search_url_present_and_clears_query(self):
# When a search is active the context should provide a URL that
# clears the search (no query parameters) for the current path.
response = self.client.get("/gallery/?search=match&sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
clear_url = response.context.get("clear_search_url")
# For the root path the clear URL should be the gallery root without query
self.assertEqual(clear_url, "/gallery/")
def test_clear_search_url_uses_nested_path(self):
# Ensure clear_search_url respects nested paths (clears search but preserves path)
response = self.client.get(
"/gallery/sub_a/?search=match&sort=recent&theme=light"
)
self.assertEqual(response.status_code, 200)
clear_url = response.context.get("clear_search_url")
self.assertTrue(clear_url.find("/gallery/sub_a/") == 0)
def test_search_action_url_uses_nested_path(self):
response = self.client.get("/gallery/sub_a/?sort=recent&theme=light")
self.assertEqual(response.status_code, 200)
@@ -341,8 +377,9 @@ class GalleryViewTests(GalleryBaseTests):
norm = lambda u: u.replace("/./", "/") if u is not None else u
self.assertEqual(norm(back_url), norm(home_url))
self.assertIn("/gallery/", norm(back_url))
self.assertIn("sort=abc", back_url)
self.assertIn("theme=dark", back_url)
# build_query no longer injects sort/theme into gallery URLs
self.assertNotIn("sort=", back_url)
self.assertNotIn("theme=", back_url)
# Back and Home thumbnails should be available and point to /thumbs/
back_thumb = response.context.get("back_thumb")
@@ -428,6 +465,17 @@ class GalleryTemplateTests(GalleryBaseTests):
self.assertIn("Most visited", body)
self.assertIn("Recently visited", body)
def test_clear_search_button_shown_when_searching(self):
# The template should render a clear-search button when a search is active
resp = self.client.get("/gallery/?search=match")
body = resp.content.decode("utf-8")
self.assertIn('aria-label="Clear search"', body)
# And it should not be present when there's no search
resp2 = self.client.get("/gallery/")
body2 = resp2.content.decode("utf-8")
self.assertNotIn('aria-label="Clear search"', body2)
def test_template_shows_fallback_icon_for_empty_subdir(self):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
@@ -501,3 +549,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")

View File

@@ -2,9 +2,7 @@
from django.urls import path
# Module imports
from .views import (
gallery_view
)
from .views import gallery_view, toggle_settings
###########################################################################################
# URL Patterns. #
@@ -13,6 +11,7 @@ from .views import (
urlpatterns = [
# Views.
path('', gallery_view, name = 'gallery_view_root'),
path('<path:path>/', gallery_view, name = 'gallery_view_path'),
path("", gallery_view, name="gallery_view_root"),
path("toggle-settings/", toggle_settings, name="toggle_settings"),
path("<path:path>/", gallery_view, name="gallery_view_path"),
]

View File

@@ -1,19 +1,24 @@
"""Top-level views module.
After refactor this file only keeps the minimal public view entry points and imports
helpers from the new `directory` and `image` modules.
helpers from the new `directory` and `image` modules. Also provides the
`toggle_settings` view used by template buttons to persist theme/sort.
"""
# Django imports.
from urllib.parse import urlparse
from django.http import HttpResponseNotFound
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.core.exceptions import MultipleObjectsReturned
# Local helpers split into modules
from .directory import render_directory
from .image import render_image
from .models import UserSettings
@login_required
def index(request):
@@ -44,3 +49,51 @@ def gallery_view(request, path=None):
return render_directory(request, path_text, candidate)
return render_image(request, path_text, candidate)
@login_required
def toggle_settings(request):
"""Persist theme and/or sort for the current user and redirect back.
Expected query params:
- next: the URL to redirect back to (optional)
- theme: optional, 'light' or 'dark'
- sort: optional, one of allowed sort keys
The view will obtain or create the UserSettings row for the user and set
any provided values. If multiple UserSettings rows exist (shouldn't
normally happen) the first is used.
"""
next_url = request.GET.get("next") or "/gallery/"
# Only allow in-site redirects for safety
parsed = urlparse(next_url)
if parsed.netloc and parsed.netloc != "":
next_url = "/gallery/"
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return redirect(next_url)
# Obtain or create the settings row
try:
settings_obj = UserSettings.objects.get(user=user)
except UserSettings.DoesNotExist:
settings_obj = UserSettings(user=user)
except MultipleObjectsReturned:
settings_obj = UserSettings.objects.filter(user=user).first()
# Apply provided values
theme = request.GET.get("theme")
sort = request.GET.get("sort")
if theme in ("light", "dark"):
settings_obj.theme = theme
if sort:
settings_obj.sort = sort
settings_obj.save()
return redirect(next_url)