feat: add subcategory support for catalog and admin
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
- Introduced `Subcategory` model with migration and unique slug constraint per category - Enabled subcategory assignment for items with validation to ensure category matching - Updated catalog views to handle subcategories, filtering items accordingly - Enhanced templates (`index.html`, `side-bar.html`, `item-list.html`, `detail.html`) to display subcategories - Added dynamic subcategory dropdown in Django admin with category filtering - Created JavaScript for admin subcategory handling and AJAX population - Wrote comprehensive tests for subcategory functionality in views and admin
This commit is contained in:
@@ -1,6 +1,21 @@
|
|||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
from .models import Category, Item, ItemImage
|
from .models import Category, Item, ItemImage, Subcategory
|
||||||
|
|
||||||
|
|
||||||
|
def get_subcategory_queryset(category_id):
|
||||||
|
if not category_id:
|
||||||
|
return Subcategory.objects.none()
|
||||||
|
|
||||||
|
try:
|
||||||
|
category_id = int(category_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Subcategory.objects.none()
|
||||||
|
|
||||||
|
return Subcategory.objects.filter(category_id=category_id).order_by('name')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Category)
|
@admin.register(Category)
|
||||||
@@ -9,6 +24,25 @@ class CategoryAdmin(admin.ModelAdmin):
|
|||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Subcategory)
|
||||||
|
class SubcategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'category', 'slug')
|
||||||
|
list_filter = ('category',)
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
|
||||||
|
|
||||||
|
class ItemAdminForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
category_id = self.data.get('category') or getattr(self.instance, 'category_id', None)
|
||||||
|
self.fields['subcategory'].queryset = get_subcategory_queryset(category_id)
|
||||||
|
|
||||||
|
|
||||||
class ItemImageInline(admin.TabularInline):
|
class ItemImageInline(admin.TabularInline):
|
||||||
model = ItemImage
|
model = ItemImage
|
||||||
extra = 1
|
extra = 1
|
||||||
@@ -17,7 +51,33 @@ class ItemImageInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(Item)
|
@admin.register(Item)
|
||||||
class ItemAdmin(admin.ModelAdmin):
|
class ItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'category', 'price', 'is_available', 'created_at')
|
form = ItemAdminForm
|
||||||
list_filter = ('is_available', 'category', 'created_at')
|
list_display = ('name', 'category', 'subcategory', 'price', 'is_available', 'created_at')
|
||||||
|
list_filter = ('is_available', 'category', 'subcategory', 'created_at')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
inlines = (ItemImageInline,)
|
inlines = (ItemImageInline,)
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
js = ('items/admin/item-subcategory.js',)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path(
|
||||||
|
'subcategories/',
|
||||||
|
self.admin_site.admin_view(self.subcategories_view),
|
||||||
|
name='items_item_subcategories',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def subcategories_view(self, request):
|
||||||
|
category_id = request.GET.get('category_id')
|
||||||
|
subcategories = get_subcategory_queryset(category_id)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'subcategories': [
|
||||||
|
{'id': subcategory.id, 'name': subcategory.name}
|
||||||
|
for subcategory in subcategories
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-04-26 20:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('items', '0002_itemimage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Subcategory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='Название')),
|
||||||
|
('slug', models.SlugField(verbose_name='URL')),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='items.category', verbose_name='Категория')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Подкатегория',
|
||||||
|
'verbose_name_plural': 'Подкатегории',
|
||||||
|
'ordering': ('category__name', 'name'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='subcategory',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='items.subcategory', verbose_name='Подкатегория'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='subcategory',
|
||||||
|
constraint=models.UniqueConstraint(fields=('category', 'slug'), name='unique_subcategory_slug_per_category'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class Category(models.Model):
|
class Category(models.Model):
|
||||||
@@ -13,10 +14,43 @@ class Category(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Subcategory(models.Model):
|
||||||
|
category = models.ForeignKey(
|
||||||
|
Category,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='subcategories',
|
||||||
|
verbose_name='Категория',
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=255, verbose_name='Название')
|
||||||
|
slug = models.SlugField(verbose_name='URL')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('category__name', 'name')
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('category', 'slug'),
|
||||||
|
name='unique_subcategory_slug_per_category',
|
||||||
|
)
|
||||||
|
]
|
||||||
|
verbose_name = 'Подкатегория'
|
||||||
|
verbose_name_plural = 'Подкатегории'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Item(models.Model):
|
class Item(models.Model):
|
||||||
name = models.CharField(max_length=255, verbose_name='Название')
|
name = models.CharField(max_length=255, verbose_name='Название')
|
||||||
slug = models.SlugField(unique=True, verbose_name='URL')
|
slug = models.SlugField(unique=True, verbose_name='URL')
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='items', verbose_name='Категория')
|
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='items', verbose_name='Категория')
|
||||||
|
subcategory = models.ForeignKey(
|
||||||
|
Subcategory,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='items',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Подкатегория',
|
||||||
|
)
|
||||||
description = models.TextField(blank=True, verbose_name='Описание')
|
description = models.TextField(blank=True, verbose_name='Описание')
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='Цена')
|
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='Цена')
|
||||||
is_available = models.BooleanField(default=True, verbose_name='В наличии')
|
is_available = models.BooleanField(default=True, verbose_name='В наличии')
|
||||||
@@ -30,6 +64,14 @@ class Item(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
if self.subcategory_id and self.category_id and self.subcategory.category_id != self.category_id:
|
||||||
|
raise ValidationError({
|
||||||
|
'subcategory': 'Подкатегория должна относиться к выбранной категории.'
|
||||||
|
})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_image(self):
|
def primary_image(self):
|
||||||
return next(iter(self.images.all()), None)
|
return next(iter(self.images.all()), None)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from items.models import Category, Item, ItemImage
|
from items.models import Category, Item, ItemImage, Subcategory
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_URL="/media/")
|
@override_settings(MEDIA_URL="/media/")
|
||||||
@@ -8,10 +10,14 @@ class CatalogViewTests(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.plumbing = Category.objects.create(name="Сантехника", slug="plumbing")
|
self.plumbing = Category.objects.create(name="Сантехника", slug="plumbing")
|
||||||
self.decor = Category.objects.create(name="Декор", slug="decor")
|
self.decor = Category.objects.create(name="Декор", slug="decor")
|
||||||
|
self.sinks = Subcategory.objects.create(name="Мойки", slug="sinks", category=self.plumbing)
|
||||||
|
self.taps = Subcategory.objects.create(name="Смесители", slug="taps", category=self.plumbing)
|
||||||
|
self.decor_lights = Subcategory.objects.create(name="Подсветка", slug="lights", category=self.decor)
|
||||||
self.sink = Item.objects.create(
|
self.sink = Item.objects.create(
|
||||||
name="Кухонная мойка",
|
name="Кухонная мойка",
|
||||||
slug="kitchen-sink",
|
slug="kitchen-sink",
|
||||||
category=self.plumbing,
|
category=self.plumbing,
|
||||||
|
subcategory=self.sinks,
|
||||||
description="Искусственный камень, врезная на столешницу",
|
description="Искусственный камень, врезная на столешницу",
|
||||||
price="1600.00",
|
price="1600.00",
|
||||||
is_available=True,
|
is_available=True,
|
||||||
@@ -20,6 +26,7 @@ class CatalogViewTests(TestCase):
|
|||||||
name="Зеркало Terra",
|
name="Зеркало Terra",
|
||||||
slug="terra-mirror",
|
slug="terra-mirror",
|
||||||
category=self.decor,
|
category=self.decor,
|
||||||
|
subcategory=self.decor_lights,
|
||||||
description="Холодная подсветка, фронтальная",
|
description="Холодная подсветка, фронтальная",
|
||||||
price="200.00",
|
price="200.00",
|
||||||
is_available=False,
|
is_available=False,
|
||||||
@@ -39,6 +46,41 @@ class CatalogViewTests(TestCase):
|
|||||||
self.assertContains(response, "Кухонная мойка")
|
self.assertContains(response, "Кухонная мойка")
|
||||||
self.assertNotContains(response, "Зеркало Terra")
|
self.assertNotContains(response, "Зеркало Terra")
|
||||||
|
|
||||||
|
def test_category_page_shows_only_selected_category_subcategories(self):
|
||||||
|
response = self.client.get("/catalog/plumbing/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Мойки")
|
||||||
|
self.assertContains(response, "Смесители")
|
||||||
|
self.assertNotContains(response, "Подсветка")
|
||||||
|
|
||||||
|
def test_subcategory_page_shows_only_selected_subcategory_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/plumbing/?subcategory=sinks")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Кухонная мойка")
|
||||||
|
self.assertNotContains(response, "Смеситель Mono")
|
||||||
|
|
||||||
|
def test_unknown_subcategory_returns_404(self):
|
||||||
|
response = self.client.get("/catalog/plumbing/?subcategory=unknown")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_subcategory_from_another_category_returns_404(self):
|
||||||
|
response = self.client.get("/catalog/plumbing/?subcategory=lights")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def test_unknown_category_returns_404(self):
|
def test_unknown_category_returns_404(self):
|
||||||
response = self.client.get("/catalog/unknown/")
|
response = self.client.get("/catalog/unknown/")
|
||||||
|
|
||||||
@@ -70,6 +112,11 @@ class CatalogViewTests(TestCase):
|
|||||||
self.assertContains(response, "/media/items/primary.jpg")
|
self.assertContains(response, "/media/items/primary.jpg")
|
||||||
self.assertNotContains(response, "/media/items/secondary.jpg")
|
self.assertNotContains(response, "/media/items/secondary.jpg")
|
||||||
|
|
||||||
|
def test_item_card_shows_subcategory(self):
|
||||||
|
response = self.client.get("/catalog/")
|
||||||
|
|
||||||
|
self.assertContains(response, "Сантехника / Мойки")
|
||||||
|
|
||||||
def test_item_card_uses_first_ordered_image_without_primary(self):
|
def test_item_card_uses_first_ordered_image_without_primary(self):
|
||||||
ItemImage.objects.create(
|
ItemImage.objects.create(
|
||||||
item=self.sink,
|
item=self.sink,
|
||||||
@@ -107,6 +154,7 @@ class CatalogViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Кухонная мойка")
|
self.assertContains(response, "Кухонная мойка")
|
||||||
|
self.assertContains(response, "Сантехника / Мойки")
|
||||||
self.assertContains(response, "Искусственный камень")
|
self.assertContains(response, "Искусственный камень")
|
||||||
self.assertContains(response, "1600,00 руб.")
|
self.assertContains(response, "1600,00 руб.")
|
||||||
self.assertContains(response, "В наличии")
|
self.assertContains(response, "В наличии")
|
||||||
@@ -140,3 +188,51 @@ class CatalogViewTests(TestCase):
|
|||||||
response = self.client.get("/catalog/decor/kitchen-sink/")
|
response = self.client.get("/catalog/decor/kitchen-sink/")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_item_subcategory_must_belong_to_item_category(self):
|
||||||
|
item = Item(
|
||||||
|
name="Некорректный товар",
|
||||||
|
slug="invalid-item",
|
||||||
|
category=self.plumbing,
|
||||||
|
subcategory=self.decor_lights,
|
||||||
|
description="",
|
||||||
|
price="100.00",
|
||||||
|
is_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
item.full_clean()
|
||||||
|
|
||||||
|
def test_admin_subcategories_endpoint_filters_by_category(self):
|
||||||
|
user = get_user_model().objects.create_superuser(
|
||||||
|
username="admin",
|
||||||
|
email="admin@example.com",
|
||||||
|
password="password",
|
||||||
|
)
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
response = self.client.get(f"/admin/items/item/subcategories/?category_id={self.plumbing.id}")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.json(),
|
||||||
|
{
|
||||||
|
"subcategories": [
|
||||||
|
{"id": self.sinks.id, "name": "Мойки"},
|
||||||
|
{"id": self.taps.id, "name": "Смесители"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_subcategories_endpoint_returns_empty_without_category(self):
|
||||||
|
user = get_user_model().objects.create_superuser(
|
||||||
|
username="admin",
|
||||||
|
email="admin@example.com",
|
||||||
|
password="password",
|
||||||
|
)
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
response = self.client.get("/admin/items/item/subcategories/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"subcategories": []})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from dataclasses import dataclass
|
|||||||
from django.http import HttpRequest, HttpResponse, Http404
|
from django.http import HttpRequest, HttpResponse, Http404
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
from items.models import Category, Item
|
from items.models import Category, Item, Subcategory
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -12,9 +12,25 @@ class SelectedCategory:
|
|||||||
id: str | None
|
id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SelectedSubcategory:
|
||||||
|
name: str | None
|
||||||
|
id: str | None
|
||||||
|
|
||||||
|
|
||||||
def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
||||||
selected_category = SelectedCategory(name=None, id=None)
|
selected_category = SelectedCategory(name=None, id=None)
|
||||||
items = Item.objects.select_related('category').prefetch_related('images').order_by('category__name', 'name')
|
selected_subcategory = SelectedSubcategory(name=None, id=None)
|
||||||
|
subcategories = Subcategory.objects.none()
|
||||||
|
subcategory_slug = request.GET.get('subcategory')
|
||||||
|
items = (
|
||||||
|
Item.objects.select_related('category', 'subcategory')
|
||||||
|
.prefetch_related('images')
|
||||||
|
.order_by('category__name', 'name')
|
||||||
|
)
|
||||||
|
|
||||||
|
if category is None and subcategory_slug:
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
if category is not None:
|
if category is not None:
|
||||||
try:
|
try:
|
||||||
@@ -22,19 +38,31 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
|||||||
selected_category = SelectedCategory(name=cat.name, id=cat.slug)
|
selected_category = SelectedCategory(name=cat.name, id=cat.slug)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
subcategories = Subcategory.objects.filter(category=cat).order_by('name')
|
||||||
items = items.filter(category=cat)
|
items = items.filter(category=cat)
|
||||||
|
|
||||||
|
if subcategory_slug:
|
||||||
|
try:
|
||||||
|
subcategory_obj = subcategories.get(slug=subcategory_slug.lower())
|
||||||
|
selected_subcategory = SelectedSubcategory(name=subcategory_obj.name, id=subcategory_obj.slug)
|
||||||
|
except Subcategory.DoesNotExist:
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
|
items = items.filter(subcategory=subcategory_obj)
|
||||||
|
|
||||||
return render(request, 'catalog/index.html', {
|
return render(request, 'catalog/index.html', {
|
||||||
'categories': Category.objects.order_by('name'),
|
'categories': Category.objects.order_by('name'),
|
||||||
|
'subcategories': subcategories,
|
||||||
'items': items,
|
'items': items,
|
||||||
'selected_category': selected_category
|
'selected_category': selected_category,
|
||||||
|
'selected_subcategory': selected_subcategory,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
|
def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
|
||||||
try:
|
try:
|
||||||
item = (
|
item = (
|
||||||
Item.objects.select_related('category')
|
Item.objects.select_related('category', 'subcategory')
|
||||||
.prefetch_related('images')
|
.prefetch_related('images')
|
||||||
.get(category__slug=category.lower(), slug=slug)
|
.get(category__slug=category.lower(), slug=slug)
|
||||||
)
|
)
|
||||||
|
|||||||
67
static/items/admin/item-subcategory.js
Normal file
67
static/items/admin/item-subcategory.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
(function () {
|
||||||
|
function getSubcategoriesUrl() {
|
||||||
|
return window.location.pathname.replace(/\/(?:add|\d+\/change)\/?$/, '/subcategories/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptions(select, subcategories, selectedId) {
|
||||||
|
var emptyOption = Array.prototype.find.call(select.options, function (option) {
|
||||||
|
return option.value === '';
|
||||||
|
});
|
||||||
|
var emptyLabel = emptyOption ? emptyOption.textContent : '---------';
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
select.appendChild(new Option(emptyLabel, ''));
|
||||||
|
|
||||||
|
subcategories.forEach(function (subcategory) {
|
||||||
|
var option = new Option(subcategory.name, subcategory.id);
|
||||||
|
|
||||||
|
if (String(subcategory.id) === String(selectedId)) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSubcategories(categorySelect, subcategorySelect, selectedId) {
|
||||||
|
if (!categorySelect.value) {
|
||||||
|
setOptions(subcategorySelect, [], '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = getSubcategoriesUrl() + '?category_id=' + encodeURIComponent(categorySelect.value);
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load subcategories.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
setOptions(subcategorySelect, data.subcategories || [], selectedId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var categorySelect = document.getElementById('id_category');
|
||||||
|
var subcategorySelect = document.getElementById('id_subcategory');
|
||||||
|
|
||||||
|
if (!categorySelect || !subcategorySelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
categorySelect.addEventListener('change', function () {
|
||||||
|
loadSubcategories(categorySelect, subcategorySelect, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!categorySelect.value) {
|
||||||
|
setOptions(subcategorySelect, [], '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<div class="item-card-body">
|
<div class="item-card-body">
|
||||||
<div class="item-card-category">{{ item.category.name }}</div>
|
<div class="item-card-category">
|
||||||
|
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
|
||||||
|
</div>
|
||||||
<h2 class="item-card-title">{{ item.name }}</h2>
|
<h2 class="item-card-title">{{ item.name }}</h2>
|
||||||
<div class="item-card-footer">
|
<div class="item-card-footer">
|
||||||
<span class="item-card-price">{{ item.price }} руб.</span>
|
<span class="item-card-price">{{ item.price }} руб.</span>
|
||||||
|
|||||||
@@ -13,6 +13,20 @@
|
|||||||
|
|
||||||
<section class="sub-categories a-section">
|
<section class="sub-categories a-section">
|
||||||
<h2>Подкатегории</h2>
|
<h2>Подкатегории</h2>
|
||||||
|
{% if selected_category.id != None %}
|
||||||
|
<a href="/catalog/{{ selected_category.id }}/" 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 %}">
|
||||||
|
{{ subcategory.name }}
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<p class="categories-empty">Подкатегории пока не добавлены.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="categories-empty">Выберите категорию.</p>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form action="">
|
<form action="">
|
||||||
|
|||||||
@@ -37,7 +37,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="product-summary" aria-label="Информация о товаре">
|
<section class="product-summary" aria-label="Информация о товаре">
|
||||||
<p class="product-summary__category">{{ item.category.name }}</p>
|
<p class="product-summary__category">
|
||||||
|
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
|
||||||
|
</p>
|
||||||
<h1 class="product-summary__title">{{ item.name }}</h1>
|
<h1 class="product-summary__title">{{ item.name }}</h1>
|
||||||
|
|
||||||
<div class="product-summary__meta">
|
<div class="product-summary__meta">
|
||||||
|
|||||||
@@ -13,12 +13,18 @@
|
|||||||
|
|
||||||
{% if selected_category.id == None %}
|
{% if selected_category.id == None %}
|
||||||
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/catalog/' text_2='Каталог' %}
|
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/catalog/' text_2='Каталог' %}
|
||||||
|
{% elif selected_subcategory.id != None %}
|
||||||
|
{% with category_url='/catalog/'|add:selected_category.id|add:'/' subcategory_url='/catalog/'|add:selected_category.id|add:'/?subcategory='|add:selected_subcategory.id %}
|
||||||
|
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/catalog/' text_2='Каталог' url_3=category_url text_3=selected_category.name url_4=subcategory_url text_4=selected_subcategory.name %}
|
||||||
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/catalog/' text_2='Каталог' text_3=selected_category.name %}
|
{% with category_url='/catalog/'|add:selected_category.id|add:'/' %}
|
||||||
|
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/catalog/' text_2='Каталог' url_3=category_url text_3=selected_category.name %}
|
||||||
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="container catalog-container">
|
<div class="container catalog-container">
|
||||||
{% include 'catalog/_fragments/side-bar.html' with categories=categories selected_category=selected_category %}
|
{% include 'catalog/_fragments/side-bar.html' with categories=categories subcategories=subcategories selected_category=selected_category selected_subcategory=selected_subcategory %}
|
||||||
|
|
||||||
{% include 'catalog/_fragments/item-list.html' with items=items %}
|
{% include 'catalog/_fragments/item-list.html' with items=items %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user