feat: add color, discount price, and new item attributes to catalog and update UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s

- Introduced `Color` model with admin and added a color field to items
- Added `discount_price` and `is_new` fields to the `Item` model with necessary migrations
- Updated pricing logic to display original and discount prices in templates
- Enhanced product detail and item card templates with color swatches and metadata
- Added sidebar sections for sales and new items with paginated mini-cards
- Updated styles to support discount pricing, swatches, and sidebar mini-cards
- Extended catalog views to filter by sales or new items and updated filtering logic
This commit is contained in:
2026-05-25 18:10:32 +02:00
parent 81e30f853d
commit c42d32c76b
8 changed files with 342 additions and 38 deletions

View File

@@ -3,7 +3,7 @@ from django.contrib import admin
from django.http import JsonResponse
from django.urls import path
from .models import Category, Item, ItemImage, Subcategory
from .models import Category, Color, Item, ItemImage, Subcategory
def get_subcategory_queryset(category_id):
@@ -18,6 +18,11 @@ def get_subcategory_queryset(category_id):
return Subcategory.objects.filter(category_id=category_id).order_by('name')
@admin.register(Color)
class ColorAdmin(admin.ModelAdmin):
list_display = ('name', 'hex_code')
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
@@ -52,8 +57,9 @@ class ItemImageInline(admin.TabularInline):
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
form = ItemAdminForm
list_display = ('name', 'category', 'subcategory', 'price', 'is_available', 'created_at')
list_filter = ('is_available', 'category', 'subcategory', 'created_at')
save_as = True
list_display = ('name', 'category', 'subcategory', 'price', 'discount_price', 'is_new', 'is_available', 'created_at')
list_filter = ('is_available', 'is_new', 'color', 'category', 'subcategory', 'created_at')
prepopulated_fields = {'slug': ('name',)}
inlines = (ItemImageInline,)

View File

@@ -0,0 +1,42 @@
# Generated by Django 6.0.5 on 2026-05-25 15:20
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('items', '0003_subcategory_item_subcategory_and_more'),
]
operations = [
migrations.CreateModel(
name='Color',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Название цвета')),
('hex_code', models.CharField(help_text='Например, #FF5500', max_length=7, verbose_name='Цвет (HEX)')),
],
options={
'verbose_name': 'Цвет',
'verbose_name_plural': 'Цвета',
'ordering': ('name',),
},
),
migrations.AddField(
model_name='item',
name='discount_price',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена со скидкой'),
),
migrations.AddField(
model_name='item',
name='is_new',
field=models.BooleanField(default=False, verbose_name='Новинка'),
),
migrations.AddField(
model_name='item',
name='color',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='items.color', verbose_name='Цвет'),
),
]

View File

@@ -1,7 +1,22 @@
from decimal import Decimal
from django.db import models
from django.core.exceptions import ValidationError
class Color(models.Model):
name = models.CharField(max_length=100, verbose_name='Название цвета')
hex_code = models.CharField(max_length=7, verbose_name='Цвет (HEX)', help_text='Например, #FF5500')
class Meta:
ordering = ('name',)
verbose_name = 'Цвет'
verbose_name_plural = 'Цвета'
def __str__(self):
return f'{self.name} ({self.hex_code})'
class Category(models.Model):
name = models.CharField(max_length=255, verbose_name='Название')
slug = models.SlugField(unique=True, verbose_name='URL')
@@ -53,7 +68,20 @@ class Item(models.Model):
)
description = models.TextField(blank=True, verbose_name='Описание')
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='Цена')
discount_price = models.DecimalField(
max_digits=10, decimal_places=2,
null=True, blank=True,
verbose_name='Цена со скидкой',
)
is_available = models.BooleanField(default=True, verbose_name='В наличии')
is_new = models.BooleanField(default=False, verbose_name='Новинка')
color = models.ForeignKey(
'Color',
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='items',
verbose_name='Цвет',
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления')
@@ -76,6 +104,14 @@ class Item(models.Model):
def primary_image(self):
return next(iter(self.images.all()), None)
@property
def display_price(self) -> Decimal:
return self.discount_price if self.discount_price is not None else self.price
@property
def has_discount(self) -> bool:
return self.discount_price is not None
class ItemImage(models.Model):
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='images', verbose_name='Товар')

View File

@@ -43,6 +43,8 @@ def _build_catalog_url(
search_query: str = "",
min_price: str = "",
max_price: str = "",
sale: bool = False,
new: bool = False,
) -> str:
base_url = "/catalog/"
if category_slug:
@@ -57,6 +59,10 @@ def _build_catalog_url(
query_params["min_price"] = min_price
if max_price:
query_params["max_price"] = max_price
if sale:
query_params["sale"] = "1"
if new:
query_params["new"] = "1"
if not query_params:
return base_url
@@ -92,6 +98,8 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
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()
filter_sale = request.GET.get('sale') == '1'
filter_new = request.GET.get('new') == '1'
if category is None and selected_scope:
category_slug, selected_subcategory_slug = _resolve_catalog_scope(selected_scope)
@@ -103,6 +111,8 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
)
)
@@ -143,17 +153,23 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
if normalized_search_query in item.name.casefold()
]
item_prices = [item.price for item in items]
if filter_sale:
items = [item for item in items if item.discount_price is not None]
if filter_new:
items = [item for item in items if item.is_new]
item_prices = [item.display_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]
items = [item for item in items if item.display_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]
items = [item for item in items if item.display_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
@@ -165,6 +181,8 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
)
for catalog_category in categories:
catalog_category.catalog_url = _build_catalog_url(
@@ -172,6 +190,8 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
)
subcategories = list(subcategories)
@@ -182,6 +202,8 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
)
for catalog_subcategory in subcategories:
catalog_subcategory.catalog_url = _build_catalog_url(
@@ -190,10 +212,25 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query,
min_price=min_price_raw,
max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
)
price_filter_action = _build_catalog_url(selected_category.id)
sidebar_sale_items = (
Item.objects.filter(discount_price__isnull=False, is_available=True)
.prefetch_related('images')
.order_by('-created_at')[:3]
)
sidebar_new_items = (
Item.objects.filter(is_new=True, is_available=True)
.prefetch_related('images')
.order_by('-created_at')[:3]
)
sale_all_url = _build_catalog_url(sale=True)
new_all_url = _build_catalog_url(new=True)
return render(request, 'catalog/index.html', {
'categories': categories,
'subcategories': subcategories,
@@ -211,13 +248,19 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
'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),
'filter_sale': filter_sale,
'filter_new': filter_new,
'sidebar_sale_items': sidebar_sale_items,
'sidebar_new_items': sidebar_new_items,
'sale_all_url': sale_all_url,
'new_all_url': new_all_url,
})
def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
try:
item = (
Item.objects.select_related('category', 'subcategory')
Item.objects.select_related('category', 'subcategory', 'color')
.prefetch_related('images')
.get(category__slug=category.lower(), slug=slug)
)

View File

@@ -298,6 +298,12 @@ aside {
margin-top: 18px;
}
.item-card-price-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.item-card-price {
color: #36213e;
font-weight: 700;
@@ -305,6 +311,18 @@ aside {
line-height: 1.2;
}
.item-card-price--discount {
color: #c54c5d;
}
.item-card-price-original {
color: #8a8a9a;
font-size: 15px;
font-weight: 400;
text-decoration: line-through;
line-height: 1.2;
}
.item-card-availability {
flex: 0 0 auto;
color: #554971;
@@ -411,12 +429,18 @@ aside {
.product-summary__meta {
display: flex;
align-items: baseline;
align-items: center;
flex-wrap: wrap;
gap: 18px;
margin-top: 16px;
}
.product-summary__price-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.product-summary__price {
color: #36213e;
font-weight: 700;
@@ -424,6 +448,23 @@ aside {
line-height: 1.2;
}
.product-summary__price--discount {
color: #c54c5d;
}
.product-summary__price-original {
color: #8a8a9a;
font-size: 18px;
font-weight: 400;
text-decoration: line-through;
line-height: 1.2;
}
.product-summary__color-name {
font-size: 16px;
color: #554971;
}
.product-summary__availability {
color: #554971;
font-size: 17px;
@@ -452,20 +493,8 @@ aside {
display: block;
}
.product-summary__swatch--dark {
background: #303438;
}
.product-summary__swatch--graphite {
background: #5f6767;
}
.product-summary__swatch--taupe {
background: #927d68;
}
.product-summary__swatch--sand {
background: #cfc3ae;
.product-summary__swatch--active {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #554971;
}
.product-summary__description {
@@ -606,3 +635,83 @@ aside {
width: 100%;
}
}
/* Sidebar section links (Акции / Новинки headings) */
.sidebar-section-link {
color: inherit;
text-decoration: none;
transition: color 0.2s;
}
.sidebar-section-link:hover,
.sidebar-section-link.active {
color: #36213e;
text-decoration: underline;
}
/* Sidebar mini-cards */
.sidebar-mini-card {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
}
.sidebar-mini-card__img-link {
flex: 0 0 52px;
width: 52px;
height: 52px;
display: block;
overflow: hidden;
background: rgba(99, 118, 141, 0.1);
border-radius: 4px;
}
.sidebar-mini-card__img-link img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sidebar-mini-card__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-mini-card__name {
color: #36213e;
font-size: 15px;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-decoration: none;
transition: text-decoration-color 0.2s;
}
.sidebar-mini-card__name:hover {
text-decoration: underline;
}
.sidebar-mini-card__price {
color: #554971;
font-size: 14px;
font-weight: 600;
}
.sidebar-more-link {
display: inline-block;
margin-top: 4px;
color: #63768d;
font-size: 15px;
text-decoration: underline;
transition: color 0.2s;
}
.sidebar-more-link:hover {
color: #36213e;
}

View File

@@ -48,7 +48,14 @@
<h2 class="item-card-title">{{ item.name }}</h2>
</a>
<div class="item-card-footer">
<span class="item-card-price">{{ item.price }} руб.</span>
<div class="item-card-price-block">
<span class="item-card-price{% if item.has_discount %} item-card-price--discount{% endif %}">
{{ item.display_price }} руб.
</span>
{% if item.has_discount %}
<span class="item-card-price-original">{{ item.price }} руб.</span>
{% endif %}
</div>
<span class="item-card-availability {% if item.is_available %}available{% else %}unavailable{% endif %}">
{% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %}
</span>

View File

@@ -34,6 +34,12 @@
{% if selected_subcategory.id != None %}
<input type="hidden" name="subcategory" value="{{ selected_subcategory.id }}">
{% endif %}
{% if filter_sale %}
<input type="hidden" name="sale" value="1">
{% endif %}
{% if filter_new %}
<input type="hidden" name="new" value="1">
{% endif %}
<section class="a-section cost-section">
<h2>Стоимость</h2>
<div
@@ -86,13 +92,57 @@
<section class="a-section material-section">
<h2>Материал</h2>
</section>
<section class="a-section promotions-section">
<h2>Акции</h2>
</section>
<section class="a-section new-section">
<h2>Новинки</h2>
</section>
</form>
<section class="a-section promotions-section">
<h2>
<a href="{{ sale_all_url }}" class="sidebar-section-link{% if filter_sale %} active{% endif %}">Акции</a>
</h2>
{% for sale_item in sidebar_sale_items %}
<div class="sidebar-mini-card">
{% with img=sale_item.primary_image %}
{% if img %}
<a href="{% url 'detail' sale_item.category.slug sale_item.slug %}" class="sidebar-mini-card__img-link">
<img src="{{ img.image.url }}" alt="{{ img.alt|default:sale_item.name }}">
</a>
{% endif %}
{% endwith %}
<div class="sidebar-mini-card__body">
<a href="{% url 'detail' sale_item.category.slug sale_item.slug %}" class="sidebar-mini-card__name">{{ sale_item.name }}</a>
<span class="sidebar-mini-card__price">{{ sale_item.display_price }} руб.</span>
</div>
</div>
{% empty %}
<p class="categories-empty">Акций пока нет.</p>
{% endfor %}
{% if sidebar_sale_items %}
<a href="{{ sale_all_url }}" class="sidebar-more-link">Ещё</a>
{% endif %}
</section>
<section class="a-section new-section">
<h2>
<a href="{{ new_all_url }}" class="sidebar-section-link{% if filter_new %} active{% endif %}">Новинки</a>
</h2>
{% for new_item in sidebar_new_items %}
<div class="sidebar-mini-card">
{% with img=new_item.primary_image %}
{% if img %}
<a href="{% url 'detail' new_item.category.slug new_item.slug %}" class="sidebar-mini-card__img-link">
<img src="{{ img.image.url }}" alt="{{ img.alt|default:new_item.name }}">
</a>
{% endif %}
{% endwith %}
<div class="sidebar-mini-card__body">
<a href="{% url 'detail' new_item.category.slug new_item.slug %}" class="sidebar-mini-card__name">{{ new_item.name }}</a>
<span class="sidebar-mini-card__price">{{ new_item.display_price }} руб.</span>
</div>
</div>
{% empty %}
<p class="categories-empty">Новинок пока нет.</p>
{% endfor %}
{% if sidebar_new_items %}
<a href="{{ new_all_url }}" class="sidebar-more-link">Ещё</a>
{% endif %}
</section>
</aside>

View File

@@ -43,18 +43,29 @@
<h1 class="product-summary__title">{{ item.name }}</h1>
<div class="product-summary__meta">
<span class="product-summary__price">{{ item.price }} руб.</span>
<div class="product-summary__price-block">
<span class="product-summary__price{% if item.has_discount %} product-summary__price--discount{% endif %}">
{{ item.display_price }} руб.
</span>
{% if item.has_discount %}
<span class="product-summary__price-original">{{ item.price }} руб.</span>
{% endif %}
</div>
<span class="product-summary__availability {% if item.is_available %}available{% else %}unavailable{% endif %}">
{% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %}
</span>
</div>
<div class="product-summary__swatches" aria-hidden="true">
<span class="product-summary__swatch product-summary__swatch--dark"></span>
<span class="product-summary__swatch product-summary__swatch--graphite"></span>
<span class="product-summary__swatch product-summary__swatch--taupe"></span>
<span class="product-summary__swatch product-summary__swatch--sand"></span>
</div>
{% if item.color %}
<div class="product-summary__swatches" aria-label="Цвет товара">
<span
class="product-summary__swatch product-summary__swatch--active"
style="background: {{ item.color.hex_code }};"
title="{{ item.color.name }}"
></span>
<span class="product-summary__color-name">{{ item.color.name }}</span>
</div>
{% endif %}
{% if item.description %}
<div class="product-summary__description">{{ item.description|linebreaksbr }}</div>