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.http import JsonResponse
from django.urls import path 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): 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') 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) @admin.register(Category)
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'slug') list_display = ('name', 'slug')
@@ -52,8 +57,9 @@ class ItemImageInline(admin.TabularInline):
@admin.register(Item) @admin.register(Item)
class ItemAdmin(admin.ModelAdmin): class ItemAdmin(admin.ModelAdmin):
form = ItemAdminForm form = ItemAdminForm
list_display = ('name', 'category', 'subcategory', 'price', 'is_available', 'created_at') save_as = True
list_filter = ('is_available', 'category', 'subcategory', 'created_at') 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',)} prepopulated_fields = {'slug': ('name',)}
inlines = (ItemImageInline,) 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.db import models
from django.core.exceptions import ValidationError 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): class Category(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')
@@ -53,7 +68,20 @@ class Item(models.Model):
) )
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='Цена')
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_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='Дата создания') created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления')
@@ -76,6 +104,14 @@ class Item(models.Model):
def primary_image(self): def primary_image(self):
return next(iter(self.images.all()), None) 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): class ItemImage(models.Model):
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='images', verbose_name='Товар') 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 = "", search_query: str = "",
min_price: str = "", min_price: str = "",
max_price: str = "", max_price: str = "",
sale: bool = False,
new: bool = False,
) -> str: ) -> str:
base_url = "/catalog/" base_url = "/catalog/"
if category_slug: if category_slug:
@@ -57,6 +59,10 @@ def _build_catalog_url(
query_params["min_price"] = min_price query_params["min_price"] = min_price
if max_price: if max_price:
query_params["max_price"] = max_price query_params["max_price"] = max_price
if sale:
query_params["sale"] = "1"
if new:
query_params["new"] = "1"
if not query_params: if not query_params:
return base_url 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() min_price_raw = request.GET.get('min_price', '').strip()
max_price_raw = request.GET.get('max_price', '').strip() max_price_raw = request.GET.get('max_price', '').strip()
selected_scope = request.GET.get('category', '').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: if category is None and selected_scope:
category_slug, selected_subcategory_slug = _resolve_catalog_scope(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, search_query=search_query,
min_price=min_price_raw, min_price=min_price_raw,
max_price=max_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() 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_min_bound = min(item_prices, default=Decimal('0'))
slider_max_bound = max(item_prices, default=slider_min_bound) slider_max_bound = max(item_prices, default=slider_min_bound)
min_price = _parse_price(min_price_raw) min_price = _parse_price(min_price_raw)
if min_price is not None: 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) max_price = _parse_price(max_price_raw)
if max_price is not None: 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_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 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, search_query=search_query,
min_price=min_price_raw, min_price=min_price_raw,
max_price=max_price_raw, max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
) )
for catalog_category in categories: for catalog_category in categories:
catalog_category.catalog_url = _build_catalog_url( catalog_category.catalog_url = _build_catalog_url(
@@ -172,6 +190,8 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query, search_query=search_query,
min_price=min_price_raw, min_price=min_price_raw,
max_price=max_price_raw, max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
) )
subcategories = list(subcategories) subcategories = list(subcategories)
@@ -182,6 +202,8 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query, search_query=search_query,
min_price=min_price_raw, min_price=min_price_raw,
max_price=max_price_raw, max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
) )
for catalog_subcategory in subcategories: for catalog_subcategory in subcategories:
catalog_subcategory.catalog_url = _build_catalog_url( catalog_subcategory.catalog_url = _build_catalog_url(
@@ -190,10 +212,25 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
search_query=search_query, search_query=search_query,
min_price=min_price_raw, min_price=min_price_raw,
max_price=max_price_raw, max_price=max_price_raw,
sale=filter_sale,
new=filter_new,
) )
price_filter_action = _build_catalog_url(selected_category.id) 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', { return render(request, 'catalog/index.html', {
'categories': categories, 'categories': categories,
'subcategories': subcategories, '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_min': _format_price_value(slider_selected_min),
'price_slider_selected_max': _format_price_value(slider_selected_max), 'price_slider_selected_max': _format_price_value(slider_selected_max),
'favorite_item_ids': get_user_favorite_item_ids(request.user), '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: def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
try: try:
item = ( item = (
Item.objects.select_related('category', 'subcategory') Item.objects.select_related('category', 'subcategory', 'color')
.prefetch_related('images') .prefetch_related('images')
.get(category__slug=category.lower(), slug=slug) .get(category__slug=category.lower(), slug=slug)
) )

View File

@@ -298,6 +298,12 @@ aside {
margin-top: 18px; margin-top: 18px;
} }
.item-card-price-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.item-card-price { .item-card-price {
color: #36213e; color: #36213e;
font-weight: 700; font-weight: 700;
@@ -305,6 +311,18 @@ aside {
line-height: 1.2; 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 { .item-card-availability {
flex: 0 0 auto; flex: 0 0 auto;
color: #554971; color: #554971;
@@ -411,12 +429,18 @@ aside {
.product-summary__meta { .product-summary__meta {
display: flex; display: flex;
align-items: baseline; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 18px; gap: 18px;
margin-top: 16px; margin-top: 16px;
} }
.product-summary__price-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.product-summary__price { .product-summary__price {
color: #36213e; color: #36213e;
font-weight: 700; font-weight: 700;
@@ -424,6 +448,23 @@ aside {
line-height: 1.2; 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 { .product-summary__availability {
color: #554971; color: #554971;
font-size: 17px; font-size: 17px;
@@ -452,20 +493,8 @@ aside {
display: block; display: block;
} }
.product-summary__swatch--dark { .product-summary__swatch--active {
background: #303438; box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #554971;
}
.product-summary__swatch--graphite {
background: #5f6767;
}
.product-summary__swatch--taupe {
background: #927d68;
}
.product-summary__swatch--sand {
background: #cfc3ae;
} }
.product-summary__description { .product-summary__description {
@@ -606,3 +635,83 @@ aside {
width: 100%; 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> <h2 class="item-card-title">{{ item.name }}</h2>
</a> </a>
<div class="item-card-footer"> <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 %}"> <span class="item-card-availability {% if item.is_available %}available{% else %}unavailable{% endif %}">
{% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %} {% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %}
</span> </span>

View File

@@ -34,6 +34,12 @@
{% if selected_subcategory.id != None %} {% if selected_subcategory.id != None %}
<input type="hidden" name="subcategory" value="{{ selected_subcategory.id }}"> <input type="hidden" name="subcategory" value="{{ selected_subcategory.id }}">
{% endif %} {% 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"> <section class="a-section cost-section">
<h2>Стоимость</h2> <h2>Стоимость</h2>
<div <div
@@ -86,13 +92,57 @@
<section class="a-section material-section"> <section class="a-section material-section">
<h2>Материал</h2> <h2>Материал</h2>
</section> </section>
<section class="a-section promotions-section">
<h2>Акции</h2>
</section>
<section class="a-section new-section">
<h2>Новинки</h2>
</section>
</form> </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> </aside>

View File

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