feat: add subcategory support for catalog and admin
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:
2026-04-26 22:53:45 +02:00
parent bb321dfa22
commit 27187c5e53
10 changed files with 366 additions and 12 deletions

View File

@@ -1,6 +1,21 @@
from django import forms
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)
@@ -9,6 +24,25 @@ class CategoryAdmin(admin.ModelAdmin):
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):
model = ItemImage
extra = 1
@@ -17,7 +51,33 @@ class ItemImageInline(admin.TabularInline):
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
list_display = ('name', 'category', 'price', 'is_available', 'created_at')
list_filter = ('is_available', 'category', 'created_at')
form = ItemAdminForm
list_display = ('name', 'category', 'subcategory', 'price', 'is_available', 'created_at')
list_filter = ('is_available', 'category', 'subcategory', 'created_at')
prepopulated_fields = {'slug': ('name',)}
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
],
})

View File

@@ -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'),
),
]

View File

@@ -1,4 +1,5 @@
from django.db import models
from django.core.exceptions import ValidationError
class Category(models.Model):
@@ -13,10 +14,43 @@ class Category(models.Model):
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):
name = models.CharField(max_length=255, verbose_name='Название')
slug = models.SlugField(unique=True, verbose_name='URL')
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='Описание')
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='Цена')
is_available = models.BooleanField(default=True, verbose_name='В наличии')
@@ -30,6 +64,14 @@ class Item(models.Model):
def __str__(self):
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
def primary_image(self):
return next(iter(self.images.all()), None)

View File

@@ -1,6 +1,8 @@
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/")
@@ -8,10 +10,14 @@ class CatalogViewTests(TestCase):
def setUp(self):
self.plumbing = Category.objects.create(name="Сантехника", slug="plumbing")
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(
name="Кухонная мойка",
slug="kitchen-sink",
category=self.plumbing,
subcategory=self.sinks,
description="Искусственный камень, врезная на столешницу",
price="1600.00",
is_available=True,
@@ -20,6 +26,7 @@ class CatalogViewTests(TestCase):
name="Зеркало Terra",
slug="terra-mirror",
category=self.decor,
subcategory=self.decor_lights,
description="Холодная подсветка, фронтальная",
price="200.00",
is_available=False,
@@ -39,6 +46,41 @@ class CatalogViewTests(TestCase):
self.assertContains(response, "Кухонная мойка")
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):
response = self.client.get("/catalog/unknown/")
@@ -70,6 +112,11 @@ class CatalogViewTests(TestCase):
self.assertContains(response, "/media/items/primary.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):
ItemImage.objects.create(
item=self.sink,
@@ -107,6 +154,7 @@ class CatalogViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Кухонная мойка")
self.assertContains(response, "Сантехника / Мойки")
self.assertContains(response, "Искусственный камень")
self.assertContains(response, "1600,00 руб.")
self.assertContains(response, "В наличии")
@@ -140,3 +188,51 @@ class CatalogViewTests(TestCase):
response = self.client.get("/catalog/decor/kitchen-sink/")
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": []})

View File

@@ -3,7 +3,7 @@ from dataclasses import dataclass
from django.http import HttpRequest, HttpResponse, Http404
from django.shortcuts import render
from items.models import Category, Item
from items.models import Category, Item, Subcategory
@dataclass
@@ -12,9 +12,25 @@ class SelectedCategory:
id: str | None
@dataclass
class SelectedSubcategory:
name: str | None
id: str | None
def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
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:
try:
@@ -22,19 +38,31 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
selected_category = SelectedCategory(name=cat.name, id=cat.slug)
except Category.DoesNotExist:
raise Http404()
subcategories = Subcategory.objects.filter(category=cat).order_by('name')
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', {
'categories': Category.objects.order_by('name'),
'subcategories': subcategories,
'items': items,
'selected_category': selected_category
'selected_category': selected_category,
'selected_subcategory': selected_subcategory,
})
def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
try:
item = (
Item.objects.select_related('category')
Item.objects.select_related('category', 'subcategory')
.prefetch_related('images')
.get(category__slug=category.lower(), slug=slug)
)

View 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, [], '');
}
});
}());

View File

@@ -14,7 +14,9 @@
{% endwith %}
<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>
<div class="item-card-footer">
<span class="item-card-price">{{ item.price }} руб.</span>

View File

@@ -13,6 +13,20 @@
<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>
{% 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>
<form action="">

View File

@@ -37,7 +37,9 @@
</section>
<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>
<div class="product-summary__meta">

View File

@@ -13,12 +13,18 @@
{% if selected_category.id == None %}
{% 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 %}
{% 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 %}
<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 %}
</div>