feat: add price filter and dynamic slider to catalog
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
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:
137
items/tests.py
137
items/tests.py
@@ -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,
|
||||
|
||||
156
items/views.py
156
items/views.py
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user