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
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:
@@ -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,)
|
||||
|
||||
|
||||
@@ -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='Цвет'),
|
||||
),
|
||||
]
|
||||
@@ -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='Товар')
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user