Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"gunicorn>=23.0.0",
"heroicons[django]>=2.11.0",
"pillow>=12.0.0",
"python-dotenv>=1.2.1",
"whitenoise>=6.11.0",
]

Expand Down
16 changes: 16 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@

import os
from pathlib import Path
from dotenv import load_dotenv

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

# Load environment variables from .env file if it exists
load_dotenv(BASE_DIR.parent / ".env")

# Add NVM node path to system PATH if configured (for django-tailwind)
if nvm_node_path := os.environ.get("NVM_NODE_PATH"):
os.environ["PATH"] = f"{nvm_node_path}:{os.environ.get('PATH', '')}"


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
Expand Down Expand Up @@ -187,6 +195,14 @@

TAILWIND_APP_NAME = "theme"

# Configure path to npm (can be overridden via environment variable)
# This is useful when using nvm or other node version managers
# If NVM_NODE_PATH is set, use it; otherwise fallback to NPM_BIN_PATH or default "npm"
if nvm_node_path := os.environ.get("NVM_NODE_PATH"):
NPM_BIN_PATH = f"{nvm_node_path}/npm"
else:
NPM_BIN_PATH = os.environ.get("NPM_BIN_PATH", "npm")

# =============================================================================
# Security Settings for Production (behind reverse proxy like Cloudflare Tunnel)
# =============================================================================
Expand Down
1 change: 1 addition & 0 deletions src/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
path("media/<int:pk>/edit/", views.media_edit, name="media_edit"),
path("media/<int:pk>/delete/", views.media_delete, name="media_delete"),
path("search/", views.search_media, name="search"),
path("load-more/", views.load_more_media, name="load_more_media"),
path("agents/search-htmx/", views.agent_search_htmx, name="agent_search_htmx"),
path("agents/select-htmx/", views.agent_select_htmx, name="agent_select_htmx"),
path("media/validate_field/", validate_media_field, name="media_validate_field"),
Expand Down
69 changes: 61 additions & 8 deletions src/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.contrib.auth.decorators import login_required
from django.core.management import call_command
from django.core.management.base import CommandError
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import FileResponse
from django.shortcuts import get_object_or_404, redirect, render
Expand Down Expand Up @@ -62,6 +63,16 @@ def _get_field_choices():
}


def _build_search_queryset(query):
"""Build a filtered queryset based on search query."""
return Media.objects.filter(
Q(title__icontains=query)
| Q(contributors__name__icontains=query)
| Q(pub_year__icontains=query)
| Q(review__icontains=query),
).distinct()


def _apply_filters(queryset, filters):
"""Apply filters to a queryset and return (queryset, contributor)."""
contributor = None
Expand Down Expand Up @@ -96,8 +107,14 @@ def index(request):
queryset = Media.objects.order_by(ordering)
queryset, contributor = _apply_filters(queryset, filters)

# Pagination: 20 items per page
page_number = request.GET.get("page", 1)
paginator = Paginator(queryset, 20)
page_obj = paginator.get_page(page_number)

context = {
"media_list": queryset,
"media_list": page_obj.object_list,
"page_obj": page_obj,
"view_mode": view_mode,
"order_by": ordering,
"sort_field": sort_field,
Expand Down Expand Up @@ -165,25 +182,61 @@ def media_delete(request, pk):
return redirect("media_edit", pk=pk)


@login_required
def load_more_media(request):
"""HTMX view: load next page of media items for infinite scrolling."""
view_mode = request.GET.get("view_mode", "list")
sort_field, sort, ordering = _resolve_sorting(request)
filters = _extract_filters(request)
query = request.GET.get("search", "")

# Build queryset based on whether it's a search or not
queryset = _build_search_queryset(query) if query else Media.objects.all()

queryset = queryset.order_by(ordering)
queryset, contributor = _apply_filters(queryset, filters)

# Pagination
page_number = request.GET.get("page", 1)
paginator = Paginator(queryset, 20)
page_obj = paginator.get_page(page_number)

context = {
"media_list": page_obj.object_list,
"page_obj": page_obj,
"view_mode": view_mode,
"order_by": ordering,
"sort_field": sort_field,
"sort": sort,
"contributor": contributor,
"filters": filters,
**_get_field_choices(),
}

# Return only the items + load more button
return render(request, "partials/media-items-page.html", context)


@login_required
def search_media(request):
query = request.GET.get("search", "")
view_mode = request.GET.get("view_mode", "list")
sort_field, sort, ordering = _resolve_sorting(request)
filters = _extract_filters(request)

media = Media.objects.filter(
Q(title__icontains=query)
| Q(contributors__name__icontains=query)
| Q(pub_year__icontains=query)
| Q(review__icontains=query),
).distinct()
media = _build_search_queryset(query)

media, contributor = _apply_filters(media, filters)
media = media.order_by(ordering)

# Pagination: 20 items per page
page_number = request.GET.get("page", 1)
paginator = Paginator(media, 20)
page_obj = paginator.get_page(page_number)

context = {
"media_list": media,
"media_list": page_obj.object_list,
"page_obj": page_obj,
"view_mode": view_mode,
"order_by": ordering,
"sort_field": sort_field,
Expand Down
2 changes: 1 addition & 1 deletion src/templates/media.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ <h1 class="text-4xl my-4">{% translate "My media" %}</h1>
hx-indicator="#spinner"
hx-on::before-request="document.getElementById('media-list').style.opacity = 0.4"
hx-on::after-request="document.getElementById('media-list').style.opacity = 1" />
{% include "partials/spinner.html" %}
{% include "partials/spinner.html" with id="spinner" size="xl" htmx_indicator=True %}
</label>
<a href="{% url 'media_add' %}" class="btn btn-primary">{% heroicon_outline "plus" %}{% translate "Add" %}</a>
</div>
Expand Down
24 changes: 24 additions & 0 deletions src/templates/partials/load-more-trigger.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% load i18n %}
{# This partial creates a "Load more" trigger that uses HTMX infinite scroll #}
{% if page_obj.has_next %}
{% if view_mode == 'grid' %}
<div id="load-more-trigger"
hx-get="{% url 'load_more_media' %}?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-include="#view-mode-input, #sort, #type, #status, #score, #review-from, #review-to, [name='search']"
class="col-span-full flex justify-center py-4">
{% include "partials/spinner.html" with size="lg" show_text=True text=_("Loading more...") inline=True %}
</div>
Comment on lines +5 to +12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for filter input definitions in templates
echo "=== Checking for filter input IDs in templates ==="
rg -n "id=\"(view-mode-input|sort|type|status|score|review-from|review-to|contributor)\"" src/templates/

echo -e "\n=== Checking name attributes for filters ==="
rg -n "name=\"(contributor|type|status|score|review_from|review_to|view_mode|sort|search)\"" src/templates/

echo -e "\n=== Checking filter extraction in views.py ==="
rg -n "request.GET.get\(" src/core/views.py | head -20

echo -e "\n=== Looking for filter variable assignments ==="
rg -n "filters\[.*\]|request.GET.get" src/core/views.py | head -30

Repository: PascalRepond/datakult

Length of output: 3928


Add contributor filter to hx-include to preserve filter state on infinite scroll.

The HTMX configuration correctly implements infinite scroll with hx-trigger="revealed" and hx-swap="outerHTML", and most filter state is properly preserved. However, the hx-include attribute is missing the contributor filter. When users apply a contributor filter and scroll to load more items, the contributor selection will not be sent in subsequent requests, causing the pagination to ignore that filter.

Add #contributor to the hx-include list:

Updated hx-include
hx-include="#view-mode-input, #sort, #type, #status, #score, #review-from, #review-to, #contributor, [name='search']"
🤖 Prompt for AI Agents
In src/templates/partials/load-more-trigger.html around lines 5 to 12, the
hx-include list omits the contributor filter so subsequent infinite-scroll
requests lose that filter; update the hx-include attribute to include
#contributor among the selectors (i.e., add ", #contributor" into the existing
comma-separated list) so the contributor selection is sent with each hx-get
request.

{% else %}
<tr id="load-more-trigger"
hx-get="{% url 'load_more_media' %}?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-include="#view-mode-input, #sort, #type, #status, #score, #review-from, #review-to, [name='search']">
<td colspan="8" class="text-center py-4">
{% include "partials/spinner.html" with size="lg" show_text=True text=_("Loading more...") inline=True %}
</td>
</tr>
{% endif %}
{% endif %}
4 changes: 4 additions & 0 deletions src/templates/partials/media-items-page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{# This partial renders media items for a single page + the load more trigger #}
{% include "partials/media-items.html" %}
{% include "partials/load-more-trigger.html" %}
6 changes: 3 additions & 3 deletions src/templates/partials/media-list.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{% load i18n %}
<div id="media-list">
{% if media_list %}
{% if page_obj %}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the media-items-page.html file
find . -name "media-items-page.html" -o -name "media-items*" | head -20

Repository: PascalRepond/datakult

Length of output: 153


🏁 Script executed:

# Check the media-list.html file to see the full context
cat -n src/templates/partials/media-list.html

Repository: PascalRepond/datakult

Length of output: 1567


🏁 Script executed:

# Search for empty state handling patterns in template files
rg "No media|empty|if.*object_list|if.*count" src/templates/partials/ -A 2 -B 2

Repository: PascalRepond/datakult

Length of output: 914


🏁 Script executed:

# Check the media-items-page.html file
cat -n src/templates/partials/media-items-page.html

Repository: PascalRepond/datakult

Length of output: 279


🏁 Script executed:

# Also check media-items.html for reference
cat -n src/templates/partials/media-items.html

Repository: PascalRepond/datakult

Length of output: 5046


Check the pagination condition to properly handle empty states.

The condition changed from {% if media_list %} to {% if page_obj %}, but this introduces a bug. Django's pagination object (page_obj) is always truthy even when the result set is empty. Since media-items.html has no empty state handling and only loops through media_list, an empty query will render an empty grid or table with no visual feedback to the user. The "No media found" message (lines 30-34) won't display.

Change the condition to {% if page_obj.object_list %} to properly show the empty state message when there are no results.

🤖 Prompt for AI Agents
In src/templates/partials/media-list.html around line 3, the template currently
checks {% if page_obj %} which is always truthy even when there are no results;
change the condition to check the actual list by using {% if
page_obj.object_list %} so the empty-state block (the "No media found" message
at lines ~30-34) will render when the query returns no items.

{% if view_mode == 'grid' %}
{# Grid view - Cards layout #}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4"
id="media-container">{% include "partials/media-items.html" %}</div>
id="media-container">{% include "partials/media-items-page.html" %}</div>
{% else %}
{# List view - Table layout #}
<div class="overflow-x-auto">
Expand All @@ -22,7 +22,7 @@
</tr>
</thead>
<tbody>
{% include "partials/media-items.html" %}
{% include "partials/media-items-page.html" %}
</tbody>
</table>
</div>
Expand Down
20 changes: 18 additions & 2 deletions src/templates/partials/spinner.html
Original file line number Diff line number Diff line change
@@ -1,2 +1,18 @@
<div id="spinner"
class="loading loading-spinner loading-xl text-primary htmx-indicator"></div>
{% comment %}
Spinner component - Unified loading indicator
Parameters:
- id: HTML id attribute (default: "spinner")
- size: "xs", "sm", "md", "lg", "xl" (default: "md")
- color: "primary", "secondary", "accent", "neutral" (default: "primary")
- htmx_indicator: boolean - add htmx-indicator class (default: false)
- show_text: boolean - show loading text (default: false)
- text: custom loading text (default: "Loading...")
- inline: boolean - display inline with text (default: false)
{% endcomment %}
{% load i18n %}
<div {% if id %}id="{{ id }}"{% endif %}
class="loading loading-spinner loading-{{ size|default:'md' }} text-{{ color|default:'primary' }}{% if htmx_indicator %} htmx-indicator{% endif %}{% if inline %} inline-block{% endif %}">
</div>
{% if show_text %}
<span class="ml-2">{{ text|default:_("Loading...") }}</span>
{% endif %}
147 changes: 147 additions & 0 deletions src/tests/core/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,3 +571,150 @@ def test_backup_import_handles_errors(self, logged_in_client):
# Should redirect back to backup manage with error message
assert response.status_code == 302
assert response.url == reverse("backup_manage")


class TestPaginationBehavior:
"""Tests for pagination behavior across views."""

def test_index_paginates_results(self, logged_in_client, media_factory):
"""Index view paginates results with 20 items per page."""
# Create 25 media items
for i in range(25):
media_factory(title=f"Media {i}")

response = logged_in_client.get(reverse("home"))

assert response.status_code == 200
assert "page_obj" in response.context
assert len(response.context["media_list"]) == 20
assert response.context["page_obj"].has_next()

def test_index_second_page_shows_remaining_items(self, logged_in_client, media_factory):
"""Index view shows remaining items on second page."""
# Create 25 media items
for i in range(25):
media_factory(title=f"Media {i}")

response = logged_in_client.get(reverse("home"), {"page": 2})

assert response.status_code == 200
assert len(response.context["media_list"]) == 5
assert response.context["page_obj"].has_previous()
assert not response.context["page_obj"].has_next()

def test_search_paginates_results(self, logged_in_client, media_factory):
"""Search view paginates results."""
# Create 25 media items with searchable title
for i in range(25):
media_factory(title=f"Searchable {i}")

response = logged_in_client.get(reverse("search"), {"search": "Searchable"})

assert response.status_code == 200
assert len(response.context["media_list"]) == 20
assert response.context["page_obj"].has_next()


class TestLoadMoreMediaView:
"""Tests for the load_more_media view (infinite scroll / lazy-loading)."""

def test_load_more_requires_login(self, client):
"""The load_more_media view requires authentication."""
response = client.get(reverse("load_more_media"))

assert response.status_code == 302
assert "/login/" in response.url

def test_load_more_returns_partial_template(self, logged_in_client, media_factory):
"""Load more view returns the media-items-page partial template."""
for i in range(25):
media_factory(title=f"Media {i}")

response = logged_in_client.get(reverse("load_more_media"), {"page": 2})

assert response.status_code == 200
assert "partials/media-items-page.html" in [t.name for t in response.templates]

def test_load_more_returns_next_page_items(self, logged_in_client, media_factory):
"""Load more view returns items for the requested page."""
# Create 25 items
for i in range(25):
media_factory(title=f"Media {i:02d}")

# Request page 2 (items 21-25)
response = logged_in_client.get(reverse("load_more_media"), {"page": 2})

assert response.status_code == 200
assert len(response.context["media_list"]) == 5

def test_load_more_preserves_sorting(self, logged_in_client, media_factory):
"""Load more view preserves sort order from initial request."""
media_factory(title="A", score=5)
media_factory(title="B", score=8)
media_factory(title="C", score=3)

response = logged_in_client.get(reverse("load_more_media"), {"page": 1, "sort": "score"})

assert response.status_code == 200
scores = [m.score for m in response.context["media_list"] if m.score is not None]
# Should be sorted ascending by score
assert scores == sorted(scores)

def test_load_more_preserves_filters(self, logged_in_client, media_factory):
"""Load more view preserves filters from initial request."""
media_factory(title="Book 1", media_type="BOOK")
media_factory(title="Film 1", media_type="FILM")
media_factory(title="Book 2", media_type="BOOK")

response = logged_in_client.get(reverse("load_more_media"), {"page": 1, "type": "BOOK"})

assert response.status_code == 200
media_types = [m.media_type for m in response.context["media_list"]]
assert all(mt == "BOOK" for mt in media_types)

def test_load_more_with_search_query(self, logged_in_client, media_factory):
"""Load more view applies search query when provided."""
media_factory(title="Python Guide")
media_factory(title="JavaScript Guide")
media_factory(title="Python Cookbook")

response = logged_in_client.get(reverse("load_more_media"), {"page": 1, "search": "Python"})

assert response.status_code == 200
assert len(response.context["media_list"]) == 2
titles = [m.title for m in response.context["media_list"]]
assert all("Python" in title for title in titles)

def test_load_more_page_obj_has_next_when_more_pages(self, logged_in_client, media_factory):
"""Load more view sets has_next correctly when more pages exist."""
# Create 45 items (3 pages)
for i in range(45):
media_factory(title=f"Media {i}")

response = logged_in_client.get(reverse("load_more_media"), {"page": 2})

assert response.status_code == 200
assert response.context["page_obj"].has_next()
assert response.context["page_obj"].number == 2

def test_load_more_page_obj_no_next_on_last_page(self, logged_in_client, media_factory):
"""Load more view sets has_next to False on last page."""
# Create 25 items (2 pages)
for i in range(25):
media_factory(title=f"Media {i}")

response = logged_in_client.get(reverse("load_more_media"), {"page": 2})

assert response.status_code == 200
assert not response.context["page_obj"].has_next()
assert response.context["page_obj"].number == 2

def test_load_more_includes_view_mode_in_context(self, logged_in_client, media_factory):
"""Load more view includes view_mode in context for template rendering."""
media_factory(title="Test")

response_list = logged_in_client.get(reverse("load_more_media"), {"page": 1, "view_mode": "list"})
response_grid = logged_in_client.get(reverse("load_more_media"), {"page": 1, "view_mode": "grid"})

assert response_list.context["view_mode"] == "list"
assert response_grid.context["view_mode"] == "grid"
Loading