feat: add item images, product detail page, and gallery support in catalog
All checks were successful
Build and Push Docker Image / build (push) Successful in 45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 45s
- Added `ItemImage` model with migration for image handling and metadata - Implemented product gallery in `detail.html` with thumbnails for additional images - Updated `Item` model to include `primary_image` property for gallery logic - Enhanced `catalog/index.html` to support item thumbnails - Updated `catalog.css` for responsive gallery and item list styling - Extended catalog views to prefetch images for efficient queries - Added comprehensive tests for catalog and detail views - Configured Django to serve media files in development - Updated admin with inline support for managing `ItemImage` entities
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Category, Item
|
||||
from .models import Category, Item, ItemImage
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
@@ -9,8 +9,15 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
|
||||
class ItemImageInline(admin.TabularInline):
|
||||
model = ItemImage
|
||||
extra = 1
|
||||
fields = ('image', 'alt', 'sort_order', 'is_primary')
|
||||
|
||||
|
||||
@admin.register(Item)
|
||||
class ItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'category', 'price', 'is_available', 'created_at')
|
||||
list_filter = ('is_available', 'category', 'created_at')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
inlines = (ItemImageInline,)
|
||||
|
||||
30
items/migrations/0002_itemimage.py
Normal file
30
items/migrations/0002_itemimage.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-26 14:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('items', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='items/', verbose_name='Изображение')),
|
||||
('alt', models.CharField(blank=True, max_length=255, verbose_name='Alt-текст')),
|
||||
('sort_order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
('is_primary', models.BooleanField(default=False, verbose_name='Главное изображение')),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='items.item', verbose_name='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Изображение товара',
|
||||
'verbose_name_plural': 'Изображения товара',
|
||||
'ordering': ('-is_primary', 'sort_order', 'id'),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -29,3 +29,23 @@ class Item(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def primary_image(self):
|
||||
return next(iter(self.images.all()), None)
|
||||
|
||||
|
||||
class ItemImage(models.Model):
|
||||
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='images', verbose_name='Товар')
|
||||
image = models.ImageField(upload_to='items/', verbose_name='Изображение')
|
||||
alt = models.CharField(max_length=255, blank=True, verbose_name='Alt-текст')
|
||||
sort_order = models.PositiveIntegerField(default=0, verbose_name='Порядок')
|
||||
is_primary = models.BooleanField(default=False, verbose_name='Главное изображение')
|
||||
|
||||
class Meta:
|
||||
ordering = ('-is_primary', 'sort_order', 'id')
|
||||
verbose_name = 'Изображение товара'
|
||||
verbose_name_plural = 'Изображения товара'
|
||||
|
||||
def __str__(self):
|
||||
return self.alt or f'Изображение товара {self.item.name}'
|
||||
|
||||
143
items/tests.py
143
items/tests.py
@@ -1,3 +1,142 @@
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
# Create your tests here.
|
||||
from items.models import Category, Item, ItemImage
|
||||
|
||||
|
||||
@override_settings(MEDIA_URL="/media/")
|
||||
class CatalogViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.plumbing = Category.objects.create(name="Сантехника", slug="plumbing")
|
||||
self.decor = Category.objects.create(name="Декор", slug="decor")
|
||||
self.sink = Item.objects.create(
|
||||
name="Кухонная мойка",
|
||||
slug="kitchen-sink",
|
||||
category=self.plumbing,
|
||||
description="Искусственный камень, врезная на столешницу",
|
||||
price="1600.00",
|
||||
is_available=True,
|
||||
)
|
||||
self.mirror = Item.objects.create(
|
||||
name="Зеркало Terra",
|
||||
slug="terra-mirror",
|
||||
category=self.decor,
|
||||
description="Холодная подсветка, фронтальная",
|
||||
price="200.00",
|
||||
is_available=False,
|
||||
)
|
||||
|
||||
def test_catalog_page_shows_items_from_all_categories(self):
|
||||
response = self.client.get("/catalog/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Кухонная мойка")
|
||||
self.assertContains(response, "Зеркало Terra")
|
||||
|
||||
def test_category_page_shows_only_selected_category_items(self):
|
||||
response = self.client.get("/catalog/plumbing/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Кухонная мойка")
|
||||
self.assertNotContains(response, "Зеркало Terra")
|
||||
|
||||
def test_unknown_category_returns_404(self):
|
||||
response = self.client.get("/catalog/unknown/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_sidebar_shows_all_categories_and_all_items_link(self):
|
||||
response = self.client.get("/catalog/")
|
||||
|
||||
self.assertContains(response, "Все товары")
|
||||
self.assertContains(response, "Сантехника")
|
||||
self.assertContains(response, "Декор")
|
||||
|
||||
def test_item_card_uses_primary_image(self):
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/secondary.jpg",
|
||||
sort_order=0,
|
||||
is_primary=False,
|
||||
)
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/primary.jpg",
|
||||
sort_order=10,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
response = self.client.get("/catalog/")
|
||||
|
||||
self.assertContains(response, "/media/items/primary.jpg")
|
||||
self.assertNotContains(response, "/media/items/secondary.jpg")
|
||||
|
||||
def test_item_card_uses_first_ordered_image_without_primary(self):
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/second.jpg",
|
||||
sort_order=20,
|
||||
is_primary=False,
|
||||
)
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/first.jpg",
|
||||
sort_order=10,
|
||||
is_primary=False,
|
||||
)
|
||||
|
||||
response = self.client.get("/catalog/")
|
||||
|
||||
self.assertContains(response, "/media/items/first.jpg")
|
||||
self.assertNotContains(response, "/media/items/second.jpg")
|
||||
|
||||
def test_catalog_item_card_links_to_detail_page(self):
|
||||
response = self.client.get("/catalog/")
|
||||
|
||||
self.assertContains(response, 'href="/catalog/plumbing/kitchen-sink/"')
|
||||
|
||||
def test_detail_page_shows_item_data(self):
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/sink-primary.jpg",
|
||||
alt="Главное фото мойки",
|
||||
sort_order=0,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
response = self.client.get("/catalog/plumbing/kitchen-sink/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Кухонная мойка")
|
||||
self.assertContains(response, "Искусственный камень")
|
||||
self.assertContains(response, "1600,00 руб.")
|
||||
self.assertContains(response, "В наличии")
|
||||
self.assertContains(response, "/media/items/sink-primary.jpg")
|
||||
self.assertContains(response, "Главное фото мойки")
|
||||
|
||||
def test_detail_page_uses_primary_image_first(self):
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/secondary.jpg",
|
||||
sort_order=0,
|
||||
is_primary=False,
|
||||
)
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
image="items/primary.jpg",
|
||||
sort_order=10,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
response = self.client.get("/catalog/plumbing/kitchen-sink/")
|
||||
|
||||
self.assertContains(response, '<img src="/media/items/primary.jpg"', html=False)
|
||||
|
||||
def test_detail_page_unknown_item_returns_404(self):
|
||||
response = self.client.get("/catalog/plumbing/unknown/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_detail_page_wrong_category_returns_404(self):
|
||||
response = self.client.get("/catalog/decor/kitchen-sink/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -4,5 +4,6 @@ from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.index),
|
||||
path('<str:category>/<slug:slug>/', views.detail, name='detail'),
|
||||
path('<str:category>/', views.index)
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, Http404
|
||||
from django.shortcuts import render, redirect
|
||||
from django.shortcuts import render
|
||||
|
||||
from items.models import Category, Item
|
||||
|
||||
|
||||
# Create your views here.
|
||||
|
||||
@dataclass
|
||||
class SelectedCategory:
|
||||
name: str | None
|
||||
@@ -16,15 +14,38 @@ class SelectedCategory:
|
||||
|
||||
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')
|
||||
|
||||
if category is not None:
|
||||
try:
|
||||
cat = Category.objects.get(slug=category.lower())
|
||||
selected_category = SelectedCategory(name=cat.name, id=cat.slug)
|
||||
except Category.DoesNotExist:
|
||||
raise Http404()
|
||||
items = items.filter(category=cat)
|
||||
|
||||
return render(request, 'catalog/index.html', {
|
||||
'categories': Category.objects.all(),
|
||||
'items': Item.objects.all(),
|
||||
'categories': Category.objects.order_by('name'),
|
||||
'items': items,
|
||||
'selected_category': selected_category
|
||||
})
|
||||
|
||||
|
||||
def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
|
||||
try:
|
||||
item = (
|
||||
Item.objects.select_related('category')
|
||||
.prefetch_related('images')
|
||||
.get(category__slug=category.lower(), slug=slug)
|
||||
)
|
||||
except Item.DoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
images = list(item.images.all())
|
||||
|
||||
return render(request, 'catalog/detail.html', {
|
||||
'categories': Category.objects.order_by('name'),
|
||||
'item': item,
|
||||
'images': images,
|
||||
'primary_image': images[0] if images else None,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user