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

@@ -7,6 +7,7 @@ ALLOWED_HOSTS=example.com,www.example.com
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
USE_X_FORWARDED_HOST=true
SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https
SECURE_REFERRER_POLICY=strict-origin-when-cross-origin
SITE_NAME=SantehRay
LOGIN_URL=/users/login/
LOGIN_REDIRECT_URL=/users/profile/

View File

@@ -3,7 +3,13 @@ from unittest.mock import patch
from django.test import SimpleTestCase
from santehray.settings import collect_hosts, collect_origins, normalize_host, normalize_origin
from santehray.settings import (
collect_hosts,
collect_origins,
env_choice,
normalize_host,
normalize_origin,
)
class SettingsNormalizationTests(SimpleTestCase):
@@ -26,3 +32,25 @@ class SettingsNormalizationTests(SimpleTestCase):
def test_normalize_origin_preserves_explicit_scheme_and_port(self):
self.assertEqual(normalize_origin("https://preview-1.azimkin.dev:8443"), "https://preview-1.azimkin.dev:8443")
def test_env_choice_accepts_allowed_value(self):
with patch.dict(os.environ, {"SECURE_REFERRER_POLICY": "strict-origin-when-cross-origin"}):
self.assertEqual(
env_choice(
"SECURE_REFERRER_POLICY",
"same-origin",
{"same-origin", "strict-origin-when-cross-origin"},
),
"strict-origin-when-cross-origin",
)
def test_env_choice_falls_back_for_invalid_value(self):
with patch.dict(os.environ, {"SECURE_REFERRER_POLICY": "invalid-policy"}):
self.assertEqual(
env_choice(
"SECURE_REFERRER_POLICY",
"same-origin",
{"same-origin", "strict-origin-when-cross-origin"},
),
"same-origin",
)

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

View File

@@ -43,4 +43,7 @@ def navigation(request):
"navigation_selected_value": selected_value,
"navigation_selected_category_slug": current_category.slug if current_category else None,
"navigation_selected_subcategory_slug": current_subcategory.slug if current_subcategory else None,
"navigation_search_query": request.GET.get("q", "").strip(),
"navigation_min_price": request.GET.get("min_price", "").strip(),
"navigation_max_price": request.GET.get("max_price", "").strip(),
}

View File

@@ -62,6 +62,17 @@ def env_log_level(name: str, default: str = "INFO") -> str:
return normalized
def env_choice(name: str, default: str, allowed: set[str]) -> str:
value = os.getenv(name, default)
if not value:
return default
normalized = value.strip().lower()
if normalized not in allowed:
return default
return normalized
def merge_unique(*value_lists: list[str]) -> list[str]:
merged: list[str] = []
seen: set[str] = set()
@@ -176,6 +187,20 @@ SECURE_PROXY_SSL_HEADER = env_pair(
"SECURE_PROXY_SSL_HEADER",
("HTTP_X_FORWARDED_PROTO", "https"),
)
SECURE_REFERRER_POLICY = env_choice(
"SECURE_REFERRER_POLICY",
"strict-origin-when-cross-origin",
{
"no-referrer",
"no-referrer-when-downgrade",
"origin",
"origin-when-cross-origin",
"same-origin",
"strict-origin",
"strict-origin-when-cross-origin",
"unsafe-url",
},
)
# Application definition

View File

@@ -50,6 +50,116 @@ aside {
line-height: 1.4;
}
.catalog-filter-form {
display: flex;
flex-direction: column;
}
.catalog-price-slider {
display: grid;
gap: 14px;
}
.catalog-price-slider__values {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #554971;
font-size: 16px;
line-height: 1.2;
}
.catalog-price-slider__controls {
position: relative;
height: 32px;
}
.catalog-price-slider__track {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 4px;
transform: translateY(-50%);
background: rgba(99, 118, 141, 0.18);
}
.catalog-price-slider__track-fill {
position: absolute;
top: 0;
bottom: 0;
background: #63768d;
}
.catalog-price-slider__range {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 32px;
margin: 0;
pointer-events: none;
appearance: none;
background: transparent;
}
.catalog-price-slider__range::-webkit-slider-thumb {
width: 18px;
height: 18px;
border: 2px solid #63768d;
border-radius: 50%;
background: #ffffff;
appearance: none;
pointer-events: auto;
cursor: pointer;
box-shadow: 0 0 0 3px rgba(99, 118, 141, 0.08);
}
.catalog-price-slider__range::-moz-range-thumb {
width: 18px;
height: 18px;
border: 2px solid #63768d;
border-radius: 50%;
background: #ffffff;
pointer-events: auto;
cursor: pointer;
box-shadow: 0 0 0 3px rgba(99, 118, 141, 0.08);
}
.catalog-price-slider__range::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
.catalog-price-slider__range::-moz-range-track {
height: 4px;
background: transparent;
}
.catalog-price-slider__range:focus-visible {
outline: none;
}
.catalog-filter-submit {
min-height: 42px;
margin-top: 16px;
border: 1px solid #63768d;
background: #63768d;
color: #ffffff;
font-family: 'Noah', sans-serif;
font-size: 15px;
line-height: 1;
text-transform: uppercase;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.catalog-filter-submit:hover {
background: #554971;
border-color: #554971;
}
.item-list {
flex: 1 1 auto;
min-width: 0;

View File

@@ -0,0 +1,61 @@
document.addEventListener('DOMContentLoaded', function () {
const priceFormatter = new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const formatQueryValue = function (value) {
return value.toFixed(2).replace(/\.?0+$/, '');
};
document.querySelectorAll('[data-price-slider]').forEach(function (slider) {
const minBound = Number.parseFloat(slider.dataset.minBound || '0');
const maxBound = Number.parseFloat(slider.dataset.maxBound || slider.dataset.minBound || '0');
const minRange = slider.querySelector('[data-price-range-min]');
const maxRange = slider.querySelector('[data-price-range-max]');
const minHidden = slider.querySelector('[data-price-hidden-min]');
const maxHidden = slider.querySelector('[data-price-hidden-max]');
const minLabel = slider.querySelector('[data-price-label-min]');
const maxLabel = slider.querySelector('[data-price-label-max]');
const trackFill = slider.querySelector('[data-price-track-fill]');
if (!minRange || !maxRange || !minHidden || !maxHidden || !minLabel || !maxLabel || !trackFill) {
return;
}
const updateSlider = function () {
const minValue = Number.parseFloat(minRange.value);
const maxValue = Number.parseFloat(maxRange.value);
const rangeSpan = maxBound - minBound;
const left = rangeSpan > 0 ? ((minValue - minBound) / rangeSpan) * 100 : 0;
const right = rangeSpan > 0 ? ((maxValue - minBound) / rangeSpan) * 100 : 100;
trackFill.style.left = left + '%';
trackFill.style.width = Math.max(right - left, 0) + '%';
minLabel.textContent = priceFormatter.format(minValue) + ' руб.';
maxLabel.textContent = priceFormatter.format(maxValue) + ' руб.';
minHidden.value = minValue <= minBound ? '' : formatQueryValue(minValue);
maxHidden.value = maxValue >= maxBound ? '' : formatQueryValue(maxValue);
};
minRange.addEventListener('input', function () {
if (Number.parseFloat(minRange.value) > Number.parseFloat(maxRange.value)) {
minRange.value = maxRange.value;
}
updateSlider();
});
maxRange.addEventListener('input', function () {
if (Number.parseFloat(maxRange.value) < Number.parseFloat(minRange.value)) {
maxRange.value = minRange.value;
}
updateSlider();
});
updateSlider();
});
});

View File

@@ -3,7 +3,7 @@
<div class="container">
<div class="header-top">
<div class="logo"><a href="/">SantehRay</a></div>
<form action="">
<form action="/catalog/" method="get">
<div class="search-wrap">
<div class="search-select-wrap">
<div class="search-category-dropdown" data-category-dropdown>
@@ -67,9 +67,11 @@
</div>
</div>
<label>
<input class="search-input" type="text" placeholder="Кто ищет, тот найдет...">
<input class="search-input" type="text" name="q" value="{{ navigation_search_query }}" placeholder="Кто ищет, тот найдет...">
</label>
<button>
<input type="hidden" name="min_price" value="{{ navigation_min_price }}">
<input type="hidden" name="max_price" value="{{ navigation_max_price }}">
<button type="submit" aria-label="Найти товары">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-search" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"></path>

View File

@@ -1,9 +1,9 @@
<aside>
<section class="categories a-section">
<h2>Категории</h2>
<a href="/catalog/" class="category {% if selected_category.id == None %}active{% endif %}">Все товары</a>
<a href="{{ all_items_url }}" class="category {% if selected_category.id == None %}active{% endif %}">Все товары</a>
{% for category in categories %}
<a href="/catalog/{{ category.slug }}/" class="category {% if selected_category.id == category.slug %}active{% endif %}">
<a href="{{ category.catalog_url }}" class="category {% if selected_category.id == category.slug %}active{% endif %}">
{{ category.name }}
</a>
{% empty %}
@@ -14,11 +14,11 @@
<section class="sub-categories a-section">
<h2>Подкатегории</h2>
{% if selected_category.id != None %}
<a href="/catalog/{{ selected_category.id }}/" class="category {% if selected_subcategory.id == None %}active{% endif %}">
<a href="{{ selected_category_url }}" class="category {% if selected_subcategory.id == None %}active{% endif %}">
Все подкатегории
</a>
{% for subcategory in subcategories %}
<a href="/catalog/{{ selected_category.id }}/?subcategory={{ subcategory.slug }}" class="category {% if selected_subcategory.id == subcategory.slug %}active{% endif %}">
<a href="{{ subcategory.catalog_url }}" class="category {% if selected_subcategory.id == subcategory.slug %}active{% endif %}">
{{ subcategory.name }}
</a>
{% empty %}
@@ -29,9 +29,54 @@
{% endif %}
</section>
<form action="">
<form action="{{ price_filter_action }}" method="get" class="catalog-filter-form">
<input type="hidden" name="q" value="{{ current_search_query }}">
{% if selected_subcategory.id != None %}
<input type="hidden" name="subcategory" value="{{ selected_subcategory.id }}">
{% endif %}
<section class="a-section cost-section">
<h2>Стоимость</h2>
<div
class="catalog-price-slider"
data-price-slider
data-min-bound="{{ price_slider_min_bound }}"
data-max-bound="{{ price_slider_max_bound }}"
>
<input type="hidden" name="min_price" value="{{ current_min_price }}" data-price-hidden-min>
<input type="hidden" name="max_price" value="{{ current_max_price }}" data-price-hidden-max>
<div class="catalog-price-slider__values" aria-live="polite">
<span data-price-label-min>{{ price_slider_selected_min }} руб.</span>
<span data-price-label-max>{{ price_slider_selected_max }} руб.</span>
</div>
<div class="catalog-price-slider__controls">
<div class="catalog-price-slider__track">
<div class="catalog-price-slider__track-fill" data-price-track-fill></div>
</div>
<input
type="range"
min="{{ price_slider_min_bound }}"
max="{{ price_slider_max_bound }}"
step="0.01"
value="{{ price_slider_selected_min }}"
class="catalog-price-slider__range"
aria-label="Минимальная цена"
data-price-range-min
>
<input
type="range"
min="{{ price_slider_min_bound }}"
max="{{ price_slider_max_bound }}"
step="0.01"
value="{{ price_slider_selected_max }}"
class="catalog-price-slider__range"
aria-label="Максимальная цена"
data-price-range-max
>
</div>
</div>
<button type="submit" class="catalog-filter-submit">Применить</button>
</section>
<section class="a-section color-section">

View File

@@ -7,6 +7,10 @@
<link rel="stylesheet" href="{% static "css/catalog.css" %}">
{% endblock %}
{% block add_js %}
<script src="{% static 'js/catalog-price-slider.js' %}"></script>
{% endblock %}
{% block content %}
<!-- HEADER -->
{% include "_fragments/header.html" with active="catalog" %}