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

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.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)

View File

@@ -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": []})

View File

@@ -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)
) )

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 %} {% 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>

View File

@@ -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="">

View File

@@ -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">

View File

@@ -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>