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

- 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:
2026-04-26 21:55:32 +02:00
parent d0651274d6
commit bb321dfa22
15 changed files with 817 additions and 23 deletions

View File

@@ -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,)

View 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'),
},
),
]

View File

@@ -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}'

View File

@@ -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)

View File

@@ -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)
]
]

View File

@@ -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,
})