Compare commits

...

16 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
Miguel Astor
7cc7f04b80 test: update image view tests and add Image model tests 2026-03-24 15:12:45 -04:00
Miguel Astor
5a6632491a Added model for special galleries. 2026-03-24 15:06:48 -04:00
77d83c58d1 docs: update README with run/test instructions 2026-03-23 23:55:26 -04:00
480ec37ef6 Updated AGENTS and CLAUDE files. 2026-03-23 23:53:14 -04:00
3e209519e5 refactor: split directory and image rendering into viewer.directory, viewer.image; add viewer.common for shared helpers 2026-03-23 23:48:23 -04:00
19 changed files with 1333 additions and 570 deletions

2
.gitignore vendored
View File

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

View File

@@ -22,13 +22,13 @@ This document is intentionally explicit so tasks can be completed with minimal r
## 3) Environment and Setup Commands
- Create venv: `python -m venv .venv`
- Create venv: `.venv/bin/python -m venv .venv`
- Activate venv (bash): `source .venv/bin/activate`
- Install deps: `pip install -r requirements.txt`
- Apply migrations: `python manage.py migrate`
- Run server: `python manage.py runserver`
- Apply migrations: `.venv/bin/python manage.py migrate`
- Run server: `.venv/bin/python manage.py runserver`
Local configuration is loaded from `.env` via `python-dotenv`.
Local configuration is loaded from `.env` via `.venv/bin/python-dotenv`.
Use this minimum `.env` content for gallery paths:
@@ -49,30 +49,30 @@ There is no dedicated linter or formatter config in-repo (no `ruff`, `flake8`, `
Use these commands as the standard validation set:
- Django system checks: `python manage.py check`
- Migration consistency check: `python manage.py makemigrations --check --dry-run`
- Python syntax sanity: `python -m compileall NibasaViewer viewer`
- Run Django tests (all): `python manage.py test`
- Django system checks: `.venv/bin/python manage.py check`
- Migration consistency check: `.venv/bin/python manage.py makemigrations --check --dry-run`
- Python syntax sanity: `.venv/bin/python -m compileall NibasaViewer viewer`
- Run Django tests (all): `.venv/bin/python manage.py test`
Single-test execution patterns (important for fast iteration):
- Single test module: `python manage.py test viewer.test`
- Single test class: `python manage.py test viewer.test.GalleryViewTests`
- Single test method: `python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path`
- Single test module: `.venv/bin/python manage.py test viewer.test`
- Single test class: `.venv/bin/python manage.py test viewer.test.GalleryViewTests`
- Single test method: `.venv/bin/python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path`
Notes:
- If there are currently no tests, add focused tests near the changed behavior before large refactors.
- Prefer `python manage.py test ...` style labels over introducing a second test runner.
- Prefer `.venv/bin/python manage.py test ...` style labels over introducing a second test runner.
## 5) Operational Commands
- Pre-generate thumbnails: `python manage.py makethumbnails`
- Pre-generate thumbnails: `.venv/bin/python manage.py makethumbnails`
- Create auth user (shell):
- `python manage.py shell`
- `.venv/bin/python manage.py shell`
- `from django.contrib.auth.models import User`
- `User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')`
- Collect static files (deploy-time): `python manage.py collectstatic`
- Collect static files (deploy-time): `.venv/bin/python manage.py collectstatic`
## 6) Architecture and Change Boundaries
@@ -90,6 +90,10 @@ Notes:
- `_render_image` in `viewer/views.py` now exposes additional context keys used by the template and tests: `prev_url`, `next_url`, `prev_thumb`, `next_thumb`, `back_url`, `back_thumb`, `home_url`, `home_thumb`, and `image_meta` (dictionary with `filename`, `width`, `height`, `filesize`, `created`, `modified`). Agents modifying image-view behavior should update tests in `viewer/test.py` as well.
- The top-bar sort button for the image view was replaced by an Info button (`fa-circle-info`) which shows a dropdown containing image metadata. The dropdown is vertically scrollable for long content.
- The displayed main image now uses the same drop shadow and rounded corners as thumbnails (styles added to `viewer/static/css/styles.css`).
- The gallery view and image view were split into focused modules: `viewer/directory.py` handles directory browsing and search, `viewer/image.py` handles single-image rendering and metadata, `viewer/common.py` contains shared helpers (URL/query builders, sort helpers, breadcrumbs), and `viewer/utils.py` contains thumbnail helpers (`THUMB_SIZE`, `is_image_file`, `make_thumbnail`). `viewer/views.py` is now a thin delegating layer that routes requests to those modules.
- The image view (`viewer/image.py`) exposes richer context keys used by the template and tests: `prev_url`, `next_url`, `prev_thumb`, `next_thumb`, `back_url`, `back_thumb`, `home_url`, `home_thumb`, and `image_meta` (dictionary with `filename`, `width`, `height`, `filesize`, `created`, `modified`). Agents modifying image-view behavior should update tests in `viewer/test.py` as well.
- The top-bar sort button for the gallery view remains; the image view uses an Info button (`fa-circle-info`) which shows a dropdown containing image metadata. The dropdown is vertically scrollable for long content.
- Thumbnails are 128×128 (`THUMB_SIZE`) and generated lazily on-demand in `viewer/utils.py`; `IMAGE_EXTENSIONS` provides fast extension-based filtering. A management command `.venv/bin/python manage.py makethumbnails` exists for batch pre-generation.
## 7) Code Style Guidelines (Repository-Specific)
@@ -169,8 +173,8 @@ When implementing a change, follow this minimum loop:
Suggested pre-handoff command bundle:
- `python manage.py check`
- `python manage.py test` (or a targeted test label)
- `python -m compileall NibasaViewer viewer`
- `.venv/bin/python manage.py check`
- `.venv/bin/python manage.py test` (or a targeted test label)
- `.venv/bin/python -m compileall NibasaViewer viewer`
This repository values stability, compatibility, and straightforward Django patterns over novelty.

View File

@@ -49,6 +49,13 @@ The project has one Django app: **`viewer`**. All gallery functionality is conso
- `utils.py` - Thumbnail generation helpers
- `templates/` - Pure HTML templates (no JavaScript)
- `static/` - CSS styling and navigation icons
- `views.py` - Thin delegating entry points that route to `viewer/directory.py` and `viewer/image.py` (see `gallery_view` in `viewer/views.py`).
- `directory.py` - Directory browsing, search, and list rendering helpers.
- `image.py` - Single-image rendering and metadata helpers.
- `common.py` - Shared helpers: URL/query builders, sorting, breadcrumbs, thumbnail utilities.
- `utils.py` - Thumbnail generation helpers.
- `templates/` - HTML templates (minimal JS via Bootstrap where needed).
- `static/` - CSS styling and navigation icons
### Gallery Data Flow
@@ -77,20 +84,22 @@ Uses Django's built-in `django.contrib.auth` system:
1. **Directory browsing** when path points to folder:
- Lists subdirectories and images
- Supports search with recursive directory scanning (viewer/views.py:32-52)
- Paginates images at 28 per page (7 columns × 4 rows)
- Converts flat image lists to table rows for HTML rendering
- Supports search with recursive directory scanning (`viewer/directory.py:do_recursive_search`)
- Does not use server-side pagination; the gallery returns full image lists and templates render them in a grid
- Converts image lists to structured context used by templates
2. **Image viewing** when path points to file:
- Displays single image with prev/next navigation
- Finds sibling images in same directory
- Links to full-resolution image
The image view logic now lives in `viewer/image.py` and exposes additional context keys used by templates and tests (see `viewer/test.py`): `prev_url`, `next_url`, `prev_thumb`, `next_thumb`, `back_url`, `back_thumb`, `home_url`, `home_thumb`, and `image_meta` (filename/width/height/filesize/created/modified).
### Thumbnail Generation
**Lazy generation approach** (viewer/utils.py):
- Thumbnails created on-demand when viewing gallery
- 128×128 pixel size (hardcoded)
- 128×128 pixel size (`THUMB_SIZE`) and cached under `THUMBNAILS_ROOT`
- Uses Pillow for image processing
- Fast extension-based image detection (no MIME-type I/O)

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

@@ -9,3 +9,69 @@ To login a user should be manually created by running the following commands in
from django.contrib.auth.models import User
user = User.objects.create_user('<USERNAME>', '<EMAIL>', '<PASSWORD>')
user.save()
## Running the project
This project uses Django. The instructions below show how to run the site in a local/testing (development) mode and a simple release (production) mode. All commands assume you're in the repository root.
- Create and activate a virtualenv (recommended):
.venv/bin/python -m venv .venv
source .venv/bin/activate
- Install dependencies:
pip install -r requirements.txt
- Configure environment variables in a `.env` file (the app reads `GALLERY_ROOT` and `THUMBNAILS_ROOT`). Example `.env` contents:
GALLERY_ROOT=/path/to/images
THUMBNAILS_ROOT=/path/to/thumb-cache
Development / testing mode
- Apply migrations and run the development server:
.venv/bin/python manage.py migrate
.venv/bin/python manage.py runserver
This runs Django's built-in development server (DEBUG mode). It's suitable for local testing and development only.
Release / production mode (simple guide)
- Collect static files and run with a WSGI server like `gunicorn` (example):
.venv/bin/python manage.py collectstatic --noinput
GALLERY_ROOT=/path/to/images THUMBNAILS_ROOT=/path/to/thumb-cache \
gunicorn NibasaViewer.wsgi:application --bind 0.0.0.0:8000 --workers 3
Set `DEBUG=False` and provide a proper `ALLOWED_HOSTS` value in your environment or settings when running in production. Use a reverse proxy (nginx) for serving static files and handling HTTPS in front of your WSGI server.
Running tests
- Run the full Django test suite:
.venv/bin/python manage.py test
- Useful focused test commands (fast iteration):
.venv/bin/python manage.py test viewer.test
.venv/bin/python manage.py test viewer.test.GalleryViewTests
.venv/bin/python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path
- Sanity checks recommended before pushing changes:
.venv/bin/python manage.py check
.venv/bin/python manage.py makemigrations --check --dry-run
.venv/bin/python -m compileall NibasaViewer viewer
Other useful management commands
- Pre-generate thumbnails (batch):
.venv/bin/python manage.py makethumbnails
Notes
- Keep `.env` local and out of version control; use `.env.example` as a template if you want to commit a sample file.
- For production deployments follow standard Django deployment guides (use a dedicated WSGI server, reverse proxy, secure secret management, and proper file permissions).

227
viewer/common.py Normal file
View File

@@ -0,0 +1,227 @@
# Standard library imports.
from pathlib import Path
from urllib.parse import urlencode
from functools import cmp_to_key
# Django imports.
from django.conf import settings
# Project imports.
from .utils import make_thumbnail, is_image_file
###########################################################################################
# Constants. #
###########################################################################################
SORT_OPTIONS = [
("abc", "Alphabetical A-Z"),
("cba", "Alphabetical Z-A"),
("old", "Creation date old to new"),
("new", "Creation date new to old"),
("recent", "Modification date most recent"),
("tnecer", "Modification date least recent"),
]
SORT_LABELS = dict(SORT_OPTIONS)
def normalize_sort(sort_value):
return sort_value if sort_value in SORT_LABELS else "abc"
def normalize_theme(theme_value):
return theme_value if theme_value in ("dark", "light") else "dark"
def get_creation_timestamp(path_obj):
try:
stat_data = path_obj.stat()
return getattr(stat_data, "st_birthtime", stat_data.st_ctime)
except OSError:
return 0
def get_modification_timestamp(path_obj):
try:
return path_obj.stat().st_mtime
except OSError:
return 0
def build_query(search_text):
query = {}
if search_text != "":
query["search"] = search_text
return query
def append_query(url, query_dict):
if len(query_dict) == 0:
return url
return url + "?" + urlencode(query_dict)
def gallery_url(path_obj=None, is_dir=False, query_dict=None):
if query_dict is None:
query_dict = {}
if path_obj is None:
base_url = "/gallery/"
else:
path_text = str(path_obj).replace("\\", "/")
base_url = "/gallery/" + path_text
if is_dir and not base_url.endswith("/"):
base_url += "/"
return append_query(base_url, query_dict)
def sort_images(images, sort_key):
def compare(img_a, img_b):
name_a = img_a.name.lower()
name_b = img_b.name.lower()
rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower()
rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower()
if sort_key == "abc":
if name_a < name_b:
return -1
if name_a > name_b:
return 1
elif sort_key == "cba":
if name_a > name_b:
return -1
if name_a < name_b:
return 1
elif sort_key == "old":
created_a = get_creation_timestamp(img_a)
created_b = get_creation_timestamp(img_b)
if created_a < created_b:
return -1
if created_a > created_b:
return 1
elif sort_key == "new":
created_a = get_creation_timestamp(img_a)
created_b = get_creation_timestamp(img_b)
if created_a > created_b:
return -1
if created_a < created_b:
return 1
elif sort_key == "recent":
modified_a = get_modification_timestamp(img_a)
modified_b = get_modification_timestamp(img_b)
if modified_a > modified_b:
return -1
if modified_a < modified_b:
return 1
elif sort_key == "tnecer":
modified_a = get_modification_timestamp(img_a)
modified_b = get_modification_timestamp(img_b)
if modified_a < modified_b:
return -1
if modified_a > modified_b:
return 1
if name_a < name_b:
return -1
if name_a > name_b:
return 1
if rel_a < rel_b:
return -1
if rel_a > rel_b:
return 1
return 0
return sorted(images, key=cmp_to_key(compare))
def build_breadcrumbs(path_text, query_dict):
breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}]
if path_text == "":
return breadcrumbs
segments = Path(path_text).parts
current = Path("")
for segment in segments:
current = current.joinpath(segment)
breadcrumbs.append(
{"label": segment, "path": gallery_url(current, True, query_dict)}
)
return breadcrumbs
def build_sort_options(request, search_text, sort_key, theme):
options = []
for option_key, label in SORT_OPTIONS:
# 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("/gallery/toggle-settings/", query),
"is_active": option_key == sort_key,
}
)
return options
def get_first_image_thumbnail_url(subdir):
try:
images = sorted(
[
entry
for entry in subdir.iterdir()
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
],
key=lambda item: (
item.name.lower(),
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
),
)
except OSError:
return None
if len(images) == 0:
return None
first_image = images[0]
try:
make_thumbnail(first_image)
rel_path = first_image.relative_to(settings.GALLERY_ROOT)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
if thumb_path.exists():
return "/thumbs/" + str(rel_path).replace("\\", "/")
except Exception:
pass
return None

202
viewer/directory.py Normal file
View File

@@ -0,0 +1,202 @@
"""
Directory-related rendering helpers for the gallery view.
"""
# Standard library imports.
from pathlib import Path
# Django imports.
from django.http import HttpResponseNotFound
from django.shortcuts import render
from django.conf import settings
# Project imports.
from .common import (
normalize_sort,
normalize_theme,
build_query,
gallery_url,
append_query,
sort_images,
build_sort_options,
build_breadcrumbs,
get_first_image_thumbnail_url,
is_image_file,
make_thumbnail,
)
def do_recursive_search(start_path, query):
"""
Gets all images and sub-directories inside the start_path whose name matches the given query,
and then joins the results recursively by iterating in each sub-directory.
"""
try:
entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()]
except OSError:
return [], []
all_subdirs = sorted(
[entry for entry in entries if entry.is_dir()],
key=lambda item: (item.name.lower(), str(item).lower()),
)
subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()]
images = sorted(
[
entry
for entry in entries
if entry.is_file()
and is_image_file(entry)
and query.lower() in entry.name.lower()
],
key=lambda item: (item.name.lower(), str(item).lower()),
)
for subdir in all_subdirs:
rec_subdirs, rec_images = do_recursive_search(subdir, query.lower())
subdirs.extend(rec_subdirs)
images.extend(rec_images)
return subdirs, images
def render_directory(request, path_text, full_path):
"""
Renders the gallery view related to directories, be it the contents of an actual directory
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") 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 = [
entry for entry in full_path.iterdir() if not entry.is_symlink()
]
except OSError:
return HttpResponseNotFound("Not found")
current_subdirs = sorted(
[entry for entry in current_entries if entry.is_dir()],
key=lambda item: (
item.name.lower(),
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
),
)
if search_text == "":
images = [
entry
for entry in current_entries
if entry.is_file() and is_image_file(entry)
]
else:
_, images = do_recursive_search(full_path, search_text)
images = sort_images(images, sort_key)
image_data = []
for image in images:
rel_path = image.relative_to(settings.GALLERY_ROOT)
image_url = gallery_url(rel_path, False, query_state)
thumbnail = None
try:
# use shared make_thumbnail so tests can patch viewer.common.make_thumbnail
make_thumbnail(image)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
if thumb_path.exists():
thumbnail = "/thumbs/" + str(rel_path).replace("\\", "/")
except Exception:
pass
image_data.append(
{"path": image_url, "name": image.name, "thumbnail": thumbnail}
)
subdir_data = []
for subdir in current_subdirs:
rel_path = subdir.relative_to(settings.GALLERY_ROOT)
subdir_data.append(
{
"path": gallery_url(rel_path, True, query_state),
"name": subdir.name,
"thumbnail": get_first_image_thumbnail_url(subdir),
}
)
next_theme = "light" if theme == "dark" else "dark"
# 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_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,
"theme": theme,
"sort_key": sort_key,
"sort_label": "",
"sort_options": build_sort_options(request, search_text, sort_key, theme),
"breadcrumbs": build_breadcrumbs(path_text, query_state),
"images": image_data,
"subdirs": subdir_data,
"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
from .common import SORT_LABELS
context["sort_label"] = SORT_LABELS.get(sort_key, "")
return render(request, "gallery_view.html", context)

225
viewer/image.py Normal file
View File

@@ -0,0 +1,225 @@
"""
Image-related rendering helpers for the gallery image view.
"""
# Standard library imports.
from pathlib import Path
import datetime
# Django imports.
from django.http import HttpResponseNotFound
from django.shortcuts import render, redirect
from django.conf import settings
from django.utils import timezone
# Third-party
from PIL import Image
# Project imports.
from .models import Image as Im
from .common import (
normalize_sort,
normalize_theme,
build_query,
gallery_url,
append_query,
sort_images,
build_sort_options,
build_breadcrumbs,
get_first_image_thumbnail_url,
is_image_file,
make_thumbnail,
)
def render_image(request, path_text, full_path):
"""
Renders the view corresponding to an image file.
"""
try:
img = Im.objects.get(path=full_path, user=request.user)
if request.method == "POST":
img.favorite = not img.favorite
except Im.DoesNotExist:
img = Im(path=full_path, user=request.user)
finally:
img.last_visited = timezone.now()
img.visits = img.visits + 1
img.save()
# 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") 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
try:
entries = [
entry
for entry in img_dir.iterdir()
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
]
except OSError:
return HttpResponseNotFound("Not found")
# Sort siblings according to requested sort mode
images_sorted = sort_images(entries, sort_key)
# Find index of current image
try:
index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name)
except StopIteration:
return HttpResponseNotFound("Not found")
prev_path = images_sorted[index - 1] if index > 0 else None
next_path = images_sorted[index + 1] if index < len(images_sorted) - 1 else None
# Helper to produce thumb url for a Path or None
def thumb_for(path_obj):
if path_obj is None:
return None
try:
make_thumbnail(path_obj)
rel = path_obj.relative_to(settings.GALLERY_ROOT)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel)
if thumb_path.exists():
return "/thumbs/" + str(rel).replace("\\", "/")
except Exception:
pass
return None
# Build URLs (preserving query state)
prev_url = None
next_url = None
if prev_path is not None:
rel = prev_path.relative_to(settings.GALLERY_ROOT)
prev_url = gallery_url(rel, False, query_state)
if next_path is not None:
rel = next_path.relative_to(settings.GALLERY_ROOT)
next_url = gallery_url(rel, False, query_state)
# 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)
# Prev/next thumbnails
prev_thumb = thumb_for(prev_path)
next_thumb = thumb_for(next_path)
# Image metadata
width = height = None
filesize = None
created_ts = None
modified_ts = None
try:
img_file = full_path
with Image.open(img_file) as im:
width, height = im.size
except Exception:
pass
try:
stat = full_path.stat()
filesize = stat.st_size
created_ts = getattr(stat, "st_birthtime", stat.st_ctime)
modified_ts = stat.st_mtime
except Exception:
pass
def human_size(bytes_val):
if bytes_val is None:
return None
kb = 1024.0
if bytes_val < kb * 1024:
return f"{bytes_val / kb:.2f} KB"
return f"{bytes_val / (kb * kb):.2f} MB"
def fmt_ts(ts):
if ts is None:
return None
try:
return datetime.datetime.fromtimestamp(ts).strftime("%c")
except Exception:
return None
# Breadcrumbs: include directory breadcrumbs then append file name as label-only
# build_breadcrumbs expects a path_text representing directories only
dir_path_text = dir_text if dir_text != "" else ""
breadcrumbs = build_breadcrumbs(dir_path_text, query_state)
breadcrumbs.append({"label": full_path.name, "path": None})
next_theme = "light" if theme == "dark" else "dark"
theme_query = {
"next": request.get_full_path(),
"sort": sort_key,
"theme": next_theme,
}
if search_text != "":
theme_query["next"] = request.get_full_path()
context = {
"image_path": image,
# keep legacy prev/next names for tests
"prev": prev_path.name if prev_path is not None else None,
"next": next_path.name if next_path is not None else None,
# new richer values
"prev_url": prev_url,
"next_url": next_url,
"prev_thumb": prev_thumb,
"next_thumb": next_thumb,
"back_url": back_url,
"back_thumb": back_thumb,
"home_url": home_url,
"home_thumb": home_thumb,
"image_meta": {
"filename": full_path.name,
"width": width,
"height": height,
"filesize": human_size(filesize),
"created": fmt_ts(created_ts),
"modified": fmt_ts(modified_ts),
"visits": img.visits,
"visited": fmt_ts(img.last_visited.timestamp()),
"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": append_query("/gallery/toggle-settings/", theme_query),
"path": path_text,
}
from .common import SORT_LABELS
context["sort_label"] = SORT_LABELS.get(sort_key, "")
return render(request, "image_view.html", context)

View File

@@ -0,0 +1,52 @@
# Generated by Django 6.0.3 on 2026-03-24 18:56
import django.db.models.deletion
import django.utils.timezone
import pathlib
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Image",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"path",
models.FilePathField(
path=pathlib.PurePosixPath("/home/miky/Imágenes")
),
),
("favorite", models.BooleanField(default=False)),
(
"last_visited",
models.DateTimeField(default=django.utils.timezone.now),
),
("visits", models.IntegerField(default=0)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

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

45
viewer/models.py Normal file
View File

@@ -0,0 +1,45 @@
from django.utils import timezone
from django.conf import settings
from django.db.models import (
Model,
CharField,
BooleanField,
DateTimeField,
IntegerField,
FilePathField,
ForeignKey,
CASCADE
)
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=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)
class meta:
ordering = ["pk"]
get_latest_by = "-last_visited"

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 */
@@ -240,7 +239,7 @@ body {
}
.info-menu {
min-width: 220px;
min-width: 275px;
border-radius: 8px;
}
@@ -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

@@ -117,12 +117,23 @@
{% endfor %}
</div>
<form method="post" action="{% url 'gallery_view_path' path=path %}">
{% csrf_token %}
<button class="btn btn-sm btn-plain" type="submit" title="Favorite" aria-label="Fav image" name="favorite">
{% if image_meta.favorite %}
<i class="fa-solid fa-star"></i>
{% else %}
<i class="fa-regular fa-star"></i>
{% endif %}
</button>
</form>
<div class="dropdown ms-auto">
<button class="btn btn-sm btn-plain" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Info" aria-label="Image info">
<i class="fa-solid fa-circle-info"></i>
</button>
<div class="dropdown-menu dropdown-menu-end info-menu p-2">
<div style="max-height:220px; overflow:auto;">
<div style="max-height:300px; overflow:auto;">
<div class="small text-muted">{{ image_meta.filename|truncatechars:40 }}</div>
{% if image_meta.width and image_meta.height %}
<div>{{ image_meta.width }} x {{ image_meta.height }} px</div>
@@ -138,6 +149,14 @@
<div class="small text-muted">Modification date</div>
<div>{{ image_meta.modified }}</div>
{% endif %}
{% if image_meta.visits %}
<div class="small text-muted">Visits:</div>
<div>{{ image_meta.visits }}</div>
{% endif %}
{% if image_meta.visited %}
<div class="small text-muted">Last visit:</div>
<div>{{ image_meta.visited }}</div>
{% endif %}
</div>
</div>
</div>
@@ -145,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

@@ -10,9 +10,16 @@ from PIL import Image
# Django imports.
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.views import SORT_LABELS, do_recursive_search, gallery_view
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, UserSettings
from NibasaViewer.middleware import UserSettingsMiddleware
class GalleryBaseTests(TestCase):
@@ -34,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()
@@ -93,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)
@@ -176,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")
@@ -215,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)
@@ -280,7 +320,7 @@ class GalleryViewTests(GalleryBaseTests):
self.assertNotIn("symlink_sub", names)
def test_thumbnail_failure_does_not_break_render(self):
with patch("viewer.views.make_thumbnail", side_effect=Exception("fail")):
with patch("viewer.common.make_thumbnail", side_effect=Exception("fail")):
response = self.client.get("/gallery/")
self.assertEqual(response.status_code, 200)
@@ -337,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")
@@ -361,6 +402,14 @@ class GalleryViewTests(GalleryBaseTests):
)
self.assertIsNotNone(image_meta.get("created"))
self.assertIsNotNone(image_meta.get("modified"))
# New image metadata fields: visits (int), visited (formatted ts), favorite (bool)
self.assertIn("visits", image_meta)
self.assertIsInstance(image_meta.get("visits"), int)
self.assertGreaterEqual(image_meta.get("visits"), 1)
self.assertIn("visited", image_meta)
self.assertIsNotNone(image_meta.get("visited"))
self.assertIn("favorite", image_meta)
self.assertIsInstance(image_meta.get("favorite"), bool)
def test_sort_modes_recent_and_tnecer_use_mtime(self):
recent = self.client.get("/gallery/?sort=recent")
@@ -383,7 +432,7 @@ class GalleryViewTests(GalleryBaseTests):
def fake_creation(path_obj):
return creation_values.get(path_obj.name, 0)
with patch("viewer.views.get_creation_timestamp", side_effect=fake_creation):
with patch("viewer.common.get_creation_timestamp", side_effect=fake_creation):
old = self.client.get("/gallery/?sort=old")
new = self.client.get("/gallery/?sort=new")
@@ -416,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)
@@ -449,3 +509,100 @@ class GalleryTemplateTests(GalleryBaseTests):
css_path = Path(__file__).resolve().parent / "static" / "css" / "styles.css"
css = css_path.read_text(encoding="utf-8")
self.assertIn("box-shadow: 0 5px 20px", css)
class ImageModelTests(GalleryBaseTests):
def test_image_model_defaults_and_path_storage(self):
# Create a model entry and verify defaults
path_str = str(self.gallery_root / "alpha.jpg")
img = Im.objects.create(user=self.user, path=path_str)
self.assertEqual(img.visits, 0)
self.assertFalse(img.favorite)
self.assertIsNotNone(img.last_visited)
# FilePathField should store the provided path string
self.assertEqual(img.path, path_str)
def test_get_request_increments_visits_and_updates_last_visited(self):
path_url = "/gallery/alpha.jpg/"
before = timezone.now()
response = self.client.get(path_url, follow=True)
self.assertEqual(response.status_code, 200)
img = Im.objects.get(user=self.user, path=str(self.gallery_root / "alpha.jpg"))
self.assertGreaterEqual(img.visits, 1)
self.assertIsNotNone(img.last_visited)
# last_visited should be updated to a recent timestamp
self.assertGreaterEqual(img.last_visited, before)
def test_post_toggles_favorite_flag(self):
path_url = "/gallery/alpha.jpg/"
# Ensure initial state
img, _ = Im.objects.get_or_create(
user=self.user, path=str(self.gallery_root / "alpha.jpg")
)
img.favorite = False
img.save()
response = self.client.post(path_url, follow=True)
self.assertEqual(response.status_code, 200)
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,275 +1,23 @@
# Standard library imports.
from pathlib import Path
from urllib.parse import urlencode
from functools import cmp_to_key
"""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. 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 render, redirect
from django.shortcuts import redirect
from django.core.exceptions import MultipleObjectsReturned
# Project imports.
from .utils import make_thumbnail, is_image_file
from PIL import Image
import datetime
# Local helpers split into modules
from .directory import render_directory
from .image import render_image
###########################################################################################
# Constants. #
###########################################################################################
SORT_OPTIONS = [
("abc", "Alphabetical A-Z"),
("cba", "Alphabetical Z-A"),
("old", "Creation date old to new"),
("new", "Creation date new to old"),
("recent", "Modification date most recent"),
("tnecer", "Modification date least recent"),
]
SORT_LABELS = dict(SORT_OPTIONS)
###########################################################################################
# Helper functions. #
###########################################################################################
def normalize_sort(sort_value):
return sort_value if sort_value in SORT_LABELS else "abc"
def normalize_theme(theme_value):
return theme_value if theme_value in ("dark", "light") else "dark"
def get_creation_timestamp(path_obj):
try:
stat_data = path_obj.stat()
return getattr(stat_data, "st_birthtime", stat_data.st_ctime)
except OSError:
return 0
def get_modification_timestamp(path_obj):
try:
return path_obj.stat().st_mtime
except OSError:
return 0
def build_query(search_text, sort_key, theme):
query = {"sort": sort_key, "theme": theme}
if search_text != "":
query["search"] = search_text
return query
def append_query(url, query_dict):
if len(query_dict) == 0:
return url
return url + "?" + urlencode(query_dict)
def gallery_url(path_obj=None, is_dir=False, query_dict=None):
if query_dict is None:
query_dict = {}
if path_obj is None:
base_url = "/gallery/"
else:
path_text = str(path_obj).replace("\\", "/")
base_url = "/gallery/" + path_text
if is_dir and not base_url.endswith("/"):
base_url += "/"
return append_query(base_url, query_dict)
def do_recursive_search(start_path, query):
"""
Gets all images and sub-directories inside the start_path whose name matches the given query,
and then joins the results recursively by iterating in each sub-directory.
"""
try:
entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()]
except OSError:
return [], []
all_subdirs = sorted(
[entry for entry in entries if entry.is_dir()],
key=lambda item: (item.name.lower(), str(item).lower()),
)
subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()]
images = sorted(
[
entry
for entry in entries
if entry.is_file()
and is_image_file(entry)
and query.lower() in entry.name.lower()
],
key=lambda item: (item.name.lower(), str(item).lower()),
)
for subdir in all_subdirs:
rec_subdirs, rec_images = do_recursive_search(subdir, query.lower())
subdirs.extend(rec_subdirs)
images.extend(rec_images)
return subdirs, images
def sort_images(images, sort_key):
def compare(img_a, img_b):
name_a = img_a.name.lower()
name_b = img_b.name.lower()
rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower()
rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower()
if sort_key == "abc":
if name_a < name_b:
return -1
if name_a > name_b:
return 1
elif sort_key == "cba":
if name_a > name_b:
return -1
if name_a < name_b:
return 1
elif sort_key == "old":
created_a = get_creation_timestamp(img_a)
created_b = get_creation_timestamp(img_b)
if created_a < created_b:
return -1
if created_a > created_b:
return 1
elif sort_key == "new":
created_a = get_creation_timestamp(img_a)
created_b = get_creation_timestamp(img_b)
if created_a > created_b:
return -1
if created_a < created_b:
return 1
elif sort_key == "recent":
modified_a = get_modification_timestamp(img_a)
modified_b = get_modification_timestamp(img_b)
if modified_a > modified_b:
return -1
if modified_a < modified_b:
return 1
elif sort_key == "tnecer":
modified_a = get_modification_timestamp(img_a)
modified_b = get_modification_timestamp(img_b)
if modified_a < modified_b:
return -1
if modified_a > modified_b:
return 1
if name_a < name_b:
return -1
if name_a > name_b:
return 1
if rel_a < rel_b:
return -1
if rel_a > rel_b:
return 1
return 0
return sorted(images, key=cmp_to_key(compare))
def build_breadcrumbs(path_text, query_dict):
breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}]
if path_text == "":
return breadcrumbs
segments = Path(path_text).parts
current = Path("")
for segment in segments:
current = current.joinpath(segment)
breadcrumbs.append(
{"label": segment, "path": gallery_url(current, True, query_dict)}
)
return breadcrumbs
def build_sort_options(request, search_text, sort_key, theme):
options = []
for option_key, label in SORT_OPTIONS:
query = {"sort": option_key, "theme": theme}
if search_text != "":
query["search"] = search_text
options.append(
{
"key": option_key,
"label": label,
"url": append_query(request.path, query),
"is_active": option_key == sort_key,
}
)
return options
def get_first_image_thumbnail_url(subdir):
try:
images = sorted(
[
entry
for entry in subdir.iterdir()
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
],
key=lambda item: (
item.name.lower(),
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
),
)
except OSError:
return None
if len(images) == 0:
return None
first_image = images[0]
try:
make_thumbnail(first_image)
rel_path = first_image.relative_to(settings.GALLERY_ROOT)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
if thumb_path.exists():
return "/thumbs/" + str(rel_path).replace("\\", "/")
except Exception:
pass
return None
###########################################################################################
# View functions. #
###########################################################################################
from .models import UserSettings
@login_required
@@ -277,257 +25,6 @@ def index(request):
return redirect("gallery_view_root")
def _render_directory(request, path_text, full_path):
"""
Renders the gallery view related to directories, be it the contents of an actual directory
in the file system, or logical gallery directories like search result pages.
"""
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)
try:
current_entries = [
entry for entry in full_path.iterdir() if not entry.is_symlink()
]
except OSError:
return HttpResponseNotFound("Not found")
current_subdirs = sorted(
[entry for entry in current_entries if entry.is_dir()],
key=lambda item: (
item.name.lower(),
str(item.relative_to(settings.GALLERY_ROOT)).lower(),
),
)
if search_text == "":
images = [
entry
for entry in current_entries
if entry.is_file() and is_image_file(entry)
]
else:
_, images = do_recursive_search(full_path, search_text)
images = sort_images(images, sort_key)
image_data = []
for image in images:
rel_path = image.relative_to(settings.GALLERY_ROOT)
image_url = gallery_url(rel_path, False, query_state)
thumbnail = None
try:
make_thumbnail(image)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path)
if thumb_path.exists():
thumbnail = "/thumbs/" + str(rel_path).replace("\\", "/")
except Exception:
pass
image_data.append(
{"path": image_url, "name": image.name, "thumbnail": thumbnail}
)
subdir_data = []
for subdir in current_subdirs:
rel_path = subdir.relative_to(settings.GALLERY_ROOT)
subdir_data.append(
{
"path": gallery_url(rel_path, True, query_state),
"name": subdir.name,
"thumbnail": get_first_image_thumbnail_url(subdir),
}
)
next_theme = "light" if theme == "dark" else "dark"
theme_query = {"sort": sort_key, "theme": next_theme}
if search_text != "":
theme_query["search"] = search_text
search_action_query = {"sort": sort_key, "theme": theme}
context = {
"path": path_text,
"search_text": search_text,
"theme": theme,
"sort_key": sort_key,
"sort_label": SORT_LABELS[sort_key],
"sort_options": build_sort_options(request, search_text, sort_key, theme),
"breadcrumbs": build_breadcrumbs(path_text, query_state),
"images": image_data,
"subdirs": subdir_data,
"theme_toggle_url": append_query(request.path, theme_query),
"search_action_url": append_query(request.path, search_action_query),
}
return render(request, "gallery_view.html", context)
def _render_image(request, path_text, full_path):
"""
Renders the view corresponding to an image file.
"""
# Preserve query state (sort, search, theme) similar to gallery view
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)
image = Path("/imgs/").joinpath(path_text)
img_dir = full_path.parent
try:
entries = [
entry
for entry in img_dir.iterdir()
if entry.is_file() and not entry.is_symlink() and is_image_file(entry)
]
except OSError:
return HttpResponseNotFound("Not found")
# Sort siblings according to requested sort mode
images_sorted = sort_images(entries, sort_key)
# Find index of current image
try:
index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name)
except StopIteration:
return HttpResponseNotFound("Not found")
prev_path = images_sorted[index - 1] if index > 0 else None
next_path = images_sorted[index + 1] if index < len(images_sorted) - 1 else None
# Helper to produce thumb url for a Path or None
def thumb_for(path_obj):
if path_obj is None:
return None
try:
make_thumbnail(path_obj)
rel = path_obj.relative_to(settings.GALLERY_ROOT)
thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel)
if thumb_path.exists():
return "/thumbs/" + str(rel).replace("\\", "/")
except Exception:
pass
return None
# Build URLs (preserving query state)
prev_url = None
next_url = None
if prev_path is not None:
rel = prev_path.relative_to(settings.GALLERY_ROOT)
prev_url = gallery_url(rel, False, query_state)
if next_path is not None:
rel = next_path.relative_to(settings.GALLERY_ROOT)
next_url = gallery_url(rel, False, query_state)
# 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)
# Prev/next thumbnails
prev_thumb = thumb_for(prev_path)
next_thumb = thumb_for(next_path)
# Image metadata
width = height = None
filesize = None
created_ts = None
modified_ts = None
try:
img_file = full_path
with Image.open(img_file) as im:
width, height = im.size
except Exception:
pass
try:
stat = full_path.stat()
filesize = stat.st_size
created_ts = getattr(stat, "st_birthtime", stat.st_ctime)
modified_ts = stat.st_mtime
except Exception:
pass
def human_size(bytes_val):
if bytes_val is None:
return None
kb = 1024.0
if bytes_val < kb * 1024:
return f"{bytes_val / kb:.2f} KB"
return f"{bytes_val / (kb * kb):.2f} MB"
def fmt_ts(ts):
if ts is None:
return None
try:
return datetime.datetime.fromtimestamp(ts).strftime("%c")
except Exception:
return None
# Breadcrumbs: include directory breadcrumbs then append file name as label-only
# build_breadcrumbs expects a path_text representing directories only
dir_path_text = dir_text if dir_text != "" else ""
breadcrumbs = build_breadcrumbs(dir_path_text, query_state)
breadcrumbs.append({"label": full_path.name, "path": None})
next_theme = "light" if theme == "dark" else "dark"
theme_query = {"sort": sort_key, "theme": next_theme}
if search_text != "":
theme_query["search"] = search_text
context = {
"image_path": image,
# keep legacy prev/next names for tests
"prev": prev_path.name if prev_path is not None else None,
"next": next_path.name if next_path is not None else None,
# new richer values
"prev_url": prev_url,
"next_url": next_url,
"prev_thumb": prev_thumb,
"next_thumb": next_thumb,
"back_url": back_url,
"back_thumb": back_thumb,
"home_url": home_url,
"home_thumb": home_thumb,
"image_meta": {
"filename": full_path.name,
"width": width,
"height": height,
"filesize": human_size(filesize),
"created": fmt_ts(created_ts),
"modified": fmt_ts(modified_ts),
},
"breadcrumbs": breadcrumbs,
"theme": theme,
"sort_key": sort_key,
"sort_label": SORT_LABELS[sort_key],
"sort_options": build_sort_options(request, search_text, sort_key, theme),
"theme_toggle_url": append_query(request.path, theme_query),
}
return render(request, "image_view.html", context)
@login_required
def gallery_view(request, path=None):
"""
@@ -549,6 +46,54 @@ def gallery_view(request, path=None):
path_text = path if path is not None else ""
if candidate.is_dir():
return _render_directory(request, path_text, candidate)
return render_directory(request, path_text, candidate)
return _render_image(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)