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.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
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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.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)
|
||||
|
||||
@@ -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": []})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
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 %}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user