feat: add price filter and dynamic slider to catalog
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s

- Implemented JavaScript-based price slider for catalog filtering
- Updated catalog views to handle min/max price inputs and compute slider bounds
- Enhanced templates (`index.html`, `side-bar.html`) with filtering form and updated URLs
- Added CSS for price slider styling and form layout
- Introduced utility functions for URL building and price parsing
- Expanded tests for price filtering, sliders, and sidebar active states
- Updated context processor and header for dynamic navigation with price filters
This commit is contained in:
2026-05-16 11:12:24 +02:00
parent 52cbc1c2b3
commit 3d356c7d6a
11 changed files with 579 additions and 11 deletions

View File

@@ -73,6 +73,114 @@ class CatalogViewTests(TestCase):
self.assertContains(response, "Кухонная мойка")
self.assertNotContains(response, "Смеситель Mono")
def test_catalog_search_filters_items_by_name(self):
response = self.client.get("/catalog/", {"q": "мойка"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Кухонная мойка")
self.assertNotContains(response, "Зеркало Terra")
def test_catalog_search_is_case_insensitive(self):
response = self.client.get("/catalog/", {"q": "МОЙКА"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Кухонная мойка")
self.assertNotContains(response, "Зеркало Terra")
def test_category_search_filters_items_within_selected_category(self):
Item.objects.create(
name="Смеситель Terra",
slug="terra-tap",
category=self.plumbing,
subcategory=self.taps,
description="Смеситель для ванной",
price="450.00",
is_available=True,
)
response = self.client.get("/catalog/plumbing/", {"q": "terra"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Смеситель Terra")
self.assertNotContains(response, "Зеркало Terra")
def test_subcategory_search_filters_items_within_selected_subcategory(self):
Item.objects.create(
name="Terra Смеситель",
slug="terra-tap",
category=self.plumbing,
subcategory=self.taps,
description="Смеситель для раковины",
price="450.00",
is_available=True,
)
Item.objects.create(
name="Terra Мойка",
slug="terra-sink",
category=self.plumbing,
subcategory=self.sinks,
description="Кухонная мойка",
price="900.00",
is_available=True,
)
response = self.client.get("/catalog/plumbing/", {"subcategory": "sinks", "q": "terra"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Terra Мойка")
self.assertNotContains(response, "Terra Смеситель")
def test_min_price_filters_items(self):
response = self.client.get("/catalog/", {"min_price": "300"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Кухонная мойка")
self.assertNotContains(response, "Зеркало Terra")
def test_max_price_filters_items(self):
response = self.client.get("/catalog/", {"max_price": "300"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Зеркало Terra")
self.assertNotContains(response, "Кухонная мойка")
def test_price_range_filters_items(self):
Item.objects.create(
name="Смеситель Mono",
slug="mono-tap",
category=self.plumbing,
subcategory=self.taps,
description="Однорычажный смеситель",
price="320.00",
is_available=True,
)
response = self.client.get("/catalog/", {"min_price": "300", "max_price": "500"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Смеситель Mono")
self.assertNotContains(response, "Кухонная мойка")
self.assertNotContains(response, "Зеркало Terra")
def test_header_search_redirects_to_selected_category_url(self):
response = self.client.get(
"/catalog/",
{"category": "category:plumbing", "q": "terra", "min_price": "100", "max_price": "500"},
)
self.assertRedirects(response, "/catalog/plumbing/?q=terra&min_price=100&max_price=500")
def test_header_search_redirects_to_selected_subcategory_url(self):
response = self.client.get(
"/catalog/",
{"category": "subcategory:plumbing:sinks", "q": "terra", "min_price": "100", "max_price": "500"},
)
self.assertRedirects(
response,
"/catalog/plumbing/?subcategory=sinks&q=terra&min_price=100&max_price=500",
)
def test_unknown_subcategory_returns_404(self):
response = self.client.get("/catalog/plumbing/?subcategory=unknown")
@@ -95,6 +203,35 @@ class CatalogViewTests(TestCase):
self.assertContains(response, "Сантехника")
self.assertContains(response, "Декор")
def test_sidebar_links_preserve_active_search_and_price_filters(self):
response = self.client.get("/catalog/plumbing/", {"q": "terra", "min_price": "100", "max_price": "500"})
self.assertContains(response, 'href="/catalog/?q=terra&min_price=100&max_price=500"')
self.assertContains(response, 'href="/catalog/plumbing/?q=terra&min_price=100&max_price=500"')
self.assertContains(
response,
'href="/catalog/plumbing/?subcategory=sinks&q=terra&min_price=100&max_price=500"',
)
def test_catalog_forms_repeat_active_search_and_price_values(self):
response = self.client.get(
"/catalog/plumbing/",
{"subcategory": "sinks", "q": "terra", "min_price": "100", "max_price": "500"},
)
self.assertContains(response, 'name="q" value="terra"', html=False)
self.assertContains(response, 'name="min_price" value="100"', html=False)
self.assertContains(response, 'name="max_price" value="500"', html=False)
def test_catalog_price_filter_renders_range_slider(self):
response = self.client.get("/catalog/")
self.assertContains(response, 'data-price-slider', html=False)
self.assertContains(response, 'type="range"', count=2, html=False)
self.assertContains(response, 'min="200.00"', html=False)
self.assertContains(response, 'max="1600.00"', html=False)
self.assertContains(response, 'src="/static/js/catalog-price-slider.js"', html=False)
def test_item_card_uses_primary_image(self):
ItemImage.objects.create(
item=self.sink,

View File

@@ -1,7 +1,9 @@
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from urllib.parse import urlencode
from django.http import HttpRequest, HttpResponse, Http404
from django.shortcuts import render
from django.shortcuts import redirect, render
from favorites.services import get_user_favorite_item_ids
from items.models import Category, Item, Subcategory
@@ -20,11 +22,90 @@ class SelectedSubcategory:
id: str | None
def _parse_price(value: str | None) -> Decimal | None:
if value is None:
return None
normalized_value = value.strip()
if not normalized_value:
return None
try:
return Decimal(normalized_value)
except InvalidOperation:
return None
def _build_catalog_url(
category_slug: str | None = None,
subcategory_slug: str | None = None,
*,
search_query: str = "",
min_price: str = "",
max_price: str = "",
) -> str:
base_url = "/catalog/"
if category_slug:
base_url = f"/catalog/{category_slug}/"
query_params: dict[str, str] = {}
if subcategory_slug:
query_params["subcategory"] = subcategory_slug
if search_query:
query_params["q"] = search_query
if min_price:
query_params["min_price"] = min_price
if max_price:
query_params["max_price"] = max_price
if not query_params:
return base_url
return f"{base_url}?{urlencode(query_params)}"
def _resolve_catalog_scope(scope_value: str | None) -> tuple[str | None, str | None]:
if not scope_value:
return None, None
parts = [part.strip().lower() for part in scope_value.split(":")]
if len(parts) == 2 and parts[0] == "category" and parts[1]:
return parts[1], None
if len(parts) == 3 and parts[0] == "subcategory" and parts[1] and parts[2]:
return parts[1], parts[2]
return None, None
def _format_price_value(value: Decimal) -> str:
return format(value, "f")
def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
selected_category = SelectedCategory(name=None, id=None)
selected_subcategory = SelectedSubcategory(name=None, id=None)
subcategories = Subcategory.objects.none()
subcategory_slug = request.GET.get('subcategory')
search_query = request.GET.get('q', '').strip()
min_price_raw = request.GET.get('min_price', '').strip()
max_price_raw = request.GET.get('max_price', '').strip()
selected_scope = request.GET.get('category', '').strip()
if category is None and selected_scope:
category_slug, selected_subcategory_slug = _resolve_catalog_scope(selected_scope)
if category_slug:
return redirect(
_build_catalog_url(
category_slug,
selected_subcategory_slug,
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
)
)
items = (
Item.objects.select_related('category', 'subcategory')
.prefetch_related('images')
@@ -52,12 +133,83 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
items = items.filter(subcategory=subcategory_obj)
items = list(items)
if search_query:
normalized_search_query = search_query.casefold()
items = [
item
for item in items
if normalized_search_query in item.name.casefold()
]
item_prices = [item.price for item in items]
slider_min_bound = min(item_prices, default=Decimal('0'))
slider_max_bound = max(item_prices, default=slider_min_bound)
min_price = _parse_price(min_price_raw)
if min_price is not None:
items = [item for item in items if item.price >= min_price]
max_price = _parse_price(max_price_raw)
if max_price is not None:
items = [item for item in items if item.price <= max_price]
slider_selected_min = min_price if min_price is not None else slider_min_bound
slider_selected_max = max_price if max_price is not None else slider_max_bound
slider_selected_min = min(max(slider_selected_min, slider_min_bound), slider_max_bound)
slider_selected_max = min(max(slider_selected_max, slider_min_bound), slider_max_bound)
categories = list(Category.objects.order_by('name'))
all_items_url = _build_catalog_url(
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
)
for catalog_category in categories:
catalog_category.catalog_url = _build_catalog_url(
catalog_category.slug,
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
)
subcategories = list(subcategories)
selected_category_url = None
if selected_category.id is not None:
selected_category_url = _build_catalog_url(
selected_category.id,
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
)
for catalog_subcategory in subcategories:
catalog_subcategory.catalog_url = _build_catalog_url(
selected_category.id,
catalog_subcategory.slug,
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
)
price_filter_action = _build_catalog_url(selected_category.id)
return render(request, 'catalog/index.html', {
'categories': Category.objects.order_by('name'),
'categories': categories,
'subcategories': subcategories,
'items': items,
'selected_category': selected_category,
'selected_subcategory': selected_subcategory,
'all_items_url': all_items_url,
'selected_category_url': selected_category_url,
'price_filter_action': price_filter_action,
'current_search_query': search_query,
'current_min_price': min_price_raw,
'current_max_price': max_price_raw,
'price_slider_min_bound': _format_price_value(slider_min_bound),
'price_slider_max_bound': _format_price_value(slider_max_bound),
'price_slider_selected_min': _format_price_value(slider_selected_min),
'price_slider_selected_max': _format_price_value(slider_selected_max),
'favorite_item_ids': get_user_favorite_item_ids(request.user),
})