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
83 changes: 83 additions & 0 deletions src/core/templatetags/media_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,86 @@ def status_badge_class(status):
"""

return STATUS_CLASSES.get(status, "badge-ghost")


@register.simple_tag
def query_string(request, **kwargs):
"""
Build a query string from current GET parameters, with updates from kwargs.

Args:
request: The current request object
**kwargs: Parameters to add/update/remove (None to remove)

Returns:
Query string with all parameters (including multi-value params)

Example usage:
<a href="?{% query_string request view_mode='grid' %}">Grid</a>
<a href="?{% query_string request sort=None %}">Clear sort</a>
"""
if not hasattr(request, "GET"):
return ""

# Start with a copy of current GET parameters (handles multi-value)
params = request.GET.copy()

# Update with provided kwargs
for key, value in kwargs.items():
if value is None:
# Remove parameter
params.pop(key, None)
else:
# Set parameter (replaces all values)
params[key] = value

# Build query string
return params.urlencode() if params else ""


@register.simple_tag
def query_string_exclude(request, *exclude_keys):
"""
Build a query string from current GET parameters, excluding specified keys.

Args:
request: The current request object
*exclude_keys: Parameter names to exclude

Returns:
Query string with all parameters except excluded ones

Example usage:
<a href="?{% query_string_exclude request 'page' %}">Without page</a>
"""
if not hasattr(request, "GET"):
return ""

params = request.GET.copy()

for key in exclude_keys:
params.pop(key, None)

return params.urlencode() if params else ""


@register.filter
def toggle_sort_direction(sort_value):
"""
Toggle the direction of a sort parameter.

Args:
sort_value: Current sort value (e.g., '-review_date' or 'review_date')

Returns:
Sort value with inverted direction

Example usage:
{{ sort|toggle_sort_direction }}
"""
if not sort_value:
return "review_date"

if sort_value.startswith("-"):
return sort_value[1:]
return f"-{sort_value}"
1 change: 0 additions & 1 deletion src/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
path("media/<int:pk>/", views.media_detail, name="media_detail"),
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"),
Expand Down
209 changes: 114 additions & 95 deletions src/core/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import contextlib
import tarfile
import tempfile
from pathlib import Path

from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.core.management.base import CommandError
from django.core.paginator import Paginator
Expand All @@ -18,7 +20,7 @@


def _resolve_sorting(request):
"""Return validated sorting info: selected field, sort string (with sign), and ordering."""
"""Return validated sorting info: selected field and normalized sort string (with sign)."""
default_field = "review_date"
sort = request.GET.get("sort") or request.GET.get("order_by") or f"-{default_field}"

Expand All @@ -27,20 +29,21 @@ def _resolve_sorting(request):
sort_field = raw_field if raw_field in valid_fields else default_field

is_desc = sort.startswith("-")
ordering = f"-{sort_field}" if is_desc else sort_field
normalized_sort = ordering # ensure field is validated
return sort_field, normalized_sort, ordering
normalized_sort = f"-{sort_field}" if is_desc else sort_field
return sort_field, normalized_sort


def _extract_filters(request):
"""Extract filter parameters from request and return filters dict."""
filters = {
"contributor": request.GET.get("contributor", ""),
"type": request.GET.get("type", ""),
"status": request.GET.get("status", ""),
"score": request.GET.get("score", ""),
"type": request.GET.getlist("type"),
"status": request.GET.getlist("status"),
"score": request.GET.getlist("score"),
"review_from": request.GET.get("review_from", ""),
"review_to": request.GET.get("review_to", ""),
"has_review": request.GET.get("has_review", ""),
"has_cover": request.GET.get("has_cover", ""),
}
filters["has_any"] = any(
[
Expand All @@ -49,16 +52,30 @@ def _extract_filters(request):
filters["score"],
filters["review_from"],
filters["review_to"],
filters["has_review"],
filters["has_cover"],
]
)

# Add display names for active filters
# Add display names for active filters (as list of tuples: (value, label))
if filters["type"]:
filters["type_display"] = dict(Media.media_type.field.choices).get(filters["type"], filters["type"])
type_choices_dict = dict(Media.media_type.field.choices)
filters["type_display"] = [(t, type_choices_dict.get(t, t)) for t in filters["type"]]
if filters["status"]:
filters["status_display"] = dict(Media.status.field.choices).get(filters["status"], filters["status"])
if filters["score"] and filters["score"] != "none":
filters["score_display"] = dict(Media.score.field.choices).get(int(filters["score"]), filters["score"])
status_choices_dict = dict(Media.status.field.choices)
filters["status_display"] = [(s, status_choices_dict.get(s, s)) for s in filters["status"]]
if filters["score"]:
score_choices_dict = dict(Media.score.field.choices)
filters["score_display"] = []
for s in filters["score"]:
if s == "none":
filters["score_display"].append(("none", _("Not rated")))
else:
try:
filters["score_display"].append((s, score_choices_dict.get(int(s), s)))
except ValueError:
# Skip malformed score values from URL
continue

return filters

Expand All @@ -82,39 +99,96 @@ def _build_search_queryset(query):
).distinct()


def _apply_filters(queryset, filters):
"""Apply filters to a queryset and return (queryset, contributor)."""
def _apply_contributor_filter(queryset, contributor_id):
"""Apply contributor filter to queryset and return (queryset, contributor)."""
contributor = None
if filters["contributor"]:
contributor = Agent.objects.filter(pk=filters["contributor"]).first()
if contributor_id:
contributor = Agent.objects.filter(pk=contributor_id).first()
if contributor:
queryset = queryset.filter(contributors=contributor)
if filters["type"]:
queryset = queryset.filter(media_type=filters["type"])
if filters["status"]:
queryset = queryset.filter(status=filters["status"])
if filters["score"]:
if filters["score"] == "none":
queryset = queryset.filter(score__isnull=True)
return queryset, contributor


def _apply_type_filter(queryset, media_types):
"""Apply OR filter for media types."""
if not media_types:
return queryset
return queryset.filter(media_type__in=media_types)


def _apply_status_filter(queryset, statuses):
"""Apply OR filter for statuses."""
if not statuses:
return queryset
return queryset.filter(status__in=statuses)


def _apply_score_filter(queryset, scores):
"""Apply OR filter for scores (including 'none' for null scores)."""
if not scores:
return queryset
score_q = Q()
for score in scores:
if score == "none":
score_q |= Q(score__isnull=True)
else:
queryset = queryset.filter(score=int(filters["score"]))
try:
score_q |= Q(score=int(score))
except ValueError:
# Skip malformed score values from URL
continue
return queryset.filter(score_q)
Comment thread
PascalRepond marked this conversation as resolved.


def _apply_date_and_content_filters(queryset, filters):
"""Apply review date, review content, and cover filters."""
if filters["review_from"]:
queryset = queryset.filter(review_date__gte=filters["review_from"])
# Skip malformed date values from URL
with contextlib.suppress(ValueError, TypeError, ValidationError):
queryset = queryset.filter(review_date__gte=filters["review_from"])
if filters["review_to"]:
queryset = queryset.filter(review_date__lte=filters["review_to"])
# Skip malformed date values from URL
with contextlib.suppress(ValueError, TypeError, ValidationError):
queryset = queryset.filter(review_date__lte=filters["review_to"])
if filters["has_review"] == "empty":
queryset = queryset.filter(Q(review__isnull=True) | Q(review=""))
elif filters["has_review"] == "filled":
queryset = queryset.exclude(Q(review__isnull=True) | Q(review=""))
if filters["has_cover"] == "empty":
queryset = queryset.filter(Q(cover__isnull=True) | Q(cover=""))
elif filters["has_cover"] == "filled":
queryset = queryset.exclude(Q(cover__isnull=True) | Q(cover=""))
return queryset


def _apply_filters(queryset, filters):
"""Apply filters to a queryset and return (queryset, contributor)."""
queryset, contributor = _apply_contributor_filter(queryset, filters["contributor"])
queryset = _apply_type_filter(queryset, filters["type"])
queryset = _apply_status_filter(queryset, filters["status"])
queryset = _apply_score_filter(queryset, filters["score"])
queryset = _apply_date_and_content_filters(queryset, filters)
return queryset, contributor


@login_required
def index(request):
"""Main view for displaying media list."""
# Get query parameters
view_mode = request.GET.get("view_mode", "grid") # 'list' or 'grid'
sort_field, sort, ordering = _resolve_sorting(request)
def _build_media_context(request):
"""
Build and filter media queryset from request parameters.

Returns a tuple of (page_obj, context_dict) ready for rendering.
This consolidates the common logic used by index and load_more_media views.
"""
view_mode = request.GET.get("view_mode", "grid")
sort_field, sort = _resolve_sorting(request)
filters = _extract_filters(request)
search_query = request.GET.get("search", "").strip()

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

queryset = Media.objects.order_by(ordering)
# Apply filters and sorting
queryset, contributor = _apply_filters(queryset, filters)
queryset = queryset.order_by(sort)

# Pagination: 20 items per page
page_number = request.GET.get("page", 1)
Expand All @@ -125,18 +199,20 @@ def index(request):
"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(),
}

# If it's an HTMX request (filters/sorting), return the full list
if request.headers.get("HX-Request"):
return render(request, "partials/media-list.html", context)
return page_obj, context


@login_required
def index(request):
"""Main view for displaying media list."""
_, context = _build_media_context(request)
return render(request, "media.html", context)


Expand Down Expand Up @@ -202,69 +278,12 @@ def media_delete(request, 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", "grid")
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(),
}
_, context = _build_media_context(request)

# 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", "grid")
sort_field, sort, ordering = _resolve_sorting(request)
filters = _extract_filters(request)

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": 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 render(request, "partials/media-list.html", context)


@login_required
def agent_search_htmx(request):
query = request.GET.get("q", "").strip()
Expand Down
Loading