Compare commits
11 Commits
7cc7f04b80
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c1c96f19 | |||
| 5a2bea3040 | |||
| 27da3a33d3 | |||
|
|
ab81a0a97d | ||
|
|
b8e7a876a7 | ||
|
|
377951efbe | ||
|
|
73b538b698 | ||
|
|
8719be3227 | ||
|
|
13ab55d1d3 | ||
|
|
5ec793b47d | ||
|
|
f658106316 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -362,3 +362,5 @@ Icon
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
.kateproject*
|
||||
|
||||
65
NibasaViewer/middleware.py
Normal file
65
NibasaViewer/middleware.py
Normal 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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
19
viewer/migrations/0002_alter_image_path.py
Normal file
19
viewer/migrations/0002_alter_image_path.py
Normal 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),
|
||||
),
|
||||
]
|
||||
39
viewer/migrations/0003_usersettings.py
Normal file
39
viewer/migrations/0003_usersettings.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -164,11 +164,11 @@
|
||||
|
||||
<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">
|
||||
<div class="image-wrapper">
|
||||
<a href="{{ image_path }}" target="_blank">
|
||||
<img src="{{ image_path }}" alt="{{ image_path.name }}" class="image-full">
|
||||
</div>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
123
viewer/test.py
123
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user