feat: implement shopping cart feature with user authentication support
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s

- Added `Cart` and `CartItem` models with migrations for managing user-specific carts and items
- Developed services for cart-related operations: adding, removing, and updating cart items
- Built specialized templates (`cart.html`) and styled them with new CSS assets (`cart.css`)
- Enabled real-time UI updates via AJAX in shopping cart interactions (`cart.js`)
- Modified header to dynamically display cart link with user authentication awareness
- Added Leaflet-based interactive map to the contacts page and integrated CSS/JS dependencies
- Expanded tests for cart functionality, item detail page cart integration, and contacts page maps
This commit is contained in:
2026-05-15 23:18:16 +02:00
parent d2126e0fba
commit 52cbc1c2b3
22 changed files with 1317 additions and 18 deletions

View File

@@ -1,3 +1,22 @@
from django.contrib import admin
# Register your models here.
from orders.models import Cart, CartItem
class CartItemInline(admin.TabularInline):
model = CartItem
extra = 0
@admin.register(Cart)
class CartAdmin(admin.ModelAdmin):
list_display = ("user", "updated_at", "created_at")
search_fields = ("user__username", "user__email")
inlines = [CartItemInline]
@admin.register(CartItem)
class CartItemAdmin(admin.ModelAdmin):
list_display = ("item", "cart", "quantity", "updated_at")
list_select_related = ("cart", "item", "cart__user")
search_fields = ("item__name", "cart__user__username", "cart__user__email")

View File

@@ -0,0 +1,49 @@
# Generated by Django 6.0.4 on 2026-05-15 20:26
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('items', '0003_subcategory_item_subcategory_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cart',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Корзина',
'verbose_name_plural': 'Корзины',
},
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)], verbose_name='Количество')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.cart', verbose_name='Корзина')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_entries', to='items.item', verbose_name='Товар')),
],
options={
'verbose_name': 'Позиция корзины',
'verbose_name_plural': 'Позиции корзины',
'ordering': ('created_at', 'id'),
'constraints': [models.UniqueConstraint(fields=('cart', 'item'), name='unique_cart_item_per_cart')],
},
),
]

View File

@@ -1,3 +1,59 @@
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
# Create your models here.
from items.models import Item
class Cart(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="cart",
verbose_name="Пользователь",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Корзина"
verbose_name_plural = "Корзины"
def __str__(self):
return f"Корзина пользователя {self.user}"
class CartItem(models.Model):
cart = models.ForeignKey(
Cart,
on_delete=models.CASCADE,
related_name="items",
verbose_name="Корзина",
)
item = models.ForeignKey(
Item,
on_delete=models.CASCADE,
related_name="cart_entries",
verbose_name="Товар",
)
quantity = models.PositiveIntegerField(
validators=[MinValueValidator(1)],
verbose_name="Количество",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
ordering = ("created_at", "id")
constraints = [
models.UniqueConstraint(fields=("cart", "item"), name="unique_cart_item_per_cart"),
]
verbose_name = "Позиция корзины"
verbose_name_plural = "Позиции корзины"
def __str__(self):
return f"{self.item} x {self.quantity}"
@property
def subtotal(self):
return self.item.price * self.quantity

69
orders/services.py Normal file
View File

@@ -0,0 +1,69 @@
from decimal import Decimal
from typing import Any
from django.core.exceptions import ValidationError
from django.utils import formats
from items.models import Item
from orders.models import Cart, CartItem
def format_currency(value: Decimal) -> str:
return f"{formats.number_format(value, 2)} руб."
def get_or_create_cart(user: Any) -> Cart:
cart, _ = Cart.objects.get_or_create(user=user)
return cart
def get_user_cart_item_ids(user: Any) -> set[int]:
if not getattr(user, "is_authenticated", False):
return set()
return set(
CartItem.objects.filter(cart__user=user).values_list("item_id", flat=True)
)
def get_user_cart(user: Any) -> Cart | None:
if not getattr(user, "is_authenticated", False):
return None
return (
Cart.objects.filter(user=user)
.prefetch_related("items__item__images")
.select_related("user")
.first()
)
def add_item_to_cart(user: Any, item: Item, quantity: int = 1) -> tuple[CartItem, bool]:
if quantity < 1:
raise ValidationError("Количество товара должно быть не меньше 1.")
if not item.is_available:
raise ValidationError("Недоступный товар нельзя добавить в корзину.")
cart = get_or_create_cart(user)
cart_item, created = CartItem.objects.get_or_create(
cart=cart,
item=item,
defaults={"quantity": quantity},
)
return cart_item, created
def remove_item_from_cart(user: Any, item_id: int) -> bool:
deleted, _ = CartItem.objects.filter(cart__user=user, item_id=item_id).delete()
return bool(deleted)
def update_cart_item_quantity(user: Any, item_id: int, quantity: int) -> CartItem:
if quantity < 1:
raise ValidationError("Количество товара должно быть не меньше 1.")
cart_item = CartItem.objects.select_related("item").get(cart__user=user, item_id=item_id)
cart_item.quantity = quantity
cart_item.save(update_fields=["quantity", "updated_at"])
return cart_item

View File

@@ -1,3 +1,110 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.db import IntegrityError
from django.test import TestCase, override_settings
from django.urls import reverse
# Create your tests here.
from items.models import Category, Item, ItemImage, Subcategory
from orders.models import Cart, CartItem
User = get_user_model()
@override_settings(MEDIA_URL="/media/")
class CartFlowTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="cart-user",
email="cart@example.com",
password="StrongPassword123",
is_active=True,
)
self.category = Category.objects.create(name="Сантехника", slug="plumbing")
self.subcategory = Subcategory.objects.create(name="Мойки", slug="sinks", category=self.category)
self.item = Item.objects.create(
name="Кухонная мойка",
slug="kitchen-sink",
category=self.category,
subcategory=self.subcategory,
description="Искусственный камень",
price="1600.00",
is_available=True,
)
self.unavailable_item = Item.objects.create(
name="Смеситель",
slug="tap",
category=self.category,
subcategory=self.subcategory,
description="Недоступный товар",
price="400.00",
is_available=False,
)
def test_cart_item_is_unique_per_cart(self):
cart = Cart.objects.create(user=self.user)
CartItem.objects.create(cart=cart, item=self.item, quantity=1)
with self.assertRaises(IntegrityError):
CartItem.objects.create(cart=cart, item=self.item, quantity=2)
def test_add_remove_and_update_quantity_for_authenticated_user(self):
self.client.force_login(self.user)
add_response = self.client.post(
reverse("orders:add"),
data={"item_id": self.item.id, "next": reverse("orders:cart")},
)
self.assertRedirects(add_response, reverse("orders:cart"))
self.assertTrue(CartItem.objects.filter(cart__user=self.user, item=self.item, quantity=1).exists())
update_response = self.client.post(
reverse("orders:update_quantity"),
data={"item_id": self.item.id, "quantity": 3, "next": reverse("orders:cart")},
)
self.assertRedirects(update_response, reverse("orders:cart"))
self.assertTrue(CartItem.objects.filter(cart__user=self.user, item=self.item, quantity=3).exists())
remove_response = self.client.post(
reverse("orders:remove"),
data={"item_id": self.item.id, "next": reverse("orders:cart")},
)
self.assertRedirects(remove_response, reverse("orders:cart"))
self.assertFalse(CartItem.objects.filter(cart__user=self.user, item=self.item).exists())
def test_add_endpoint_rejects_unavailable_item(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("orders:add"),
data={"item_id": self.unavailable_item.id, "next": reverse("orders:cart")},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertFalse(CartItem.objects.filter(cart__user=self.user, item=self.unavailable_item).exists())
self.assertContains(response, "Недоступный товар нельзя добавить в корзину.")
def test_cart_page_shows_authenticated_user_items(self):
cart = Cart.objects.create(user=self.user)
CartItem.objects.create(cart=cart, item=self.item, quantity=2)
ItemImage.objects.create(
item=self.item,
image="items/cart-item.jpg",
alt="Фото мойки",
is_primary=True,
)
self.client.force_login(self.user)
response = self.client.get(reverse("orders:cart"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Кухонная мойка")
self.assertContains(response, "1600,00 руб.")
self.assertContains(response, "3200,00 руб.")
self.assertContains(response, "/media/items/cart-item.jpg")
def test_cart_page_redirects_guest_to_login(self):
response = self.client.get(reverse("orders:cart"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("users:login"), response["Location"])

View File

@@ -1,3 +1,13 @@
from django.urls import path
urlpatterns = []
from orders import views
app_name = "orders"
urlpatterns = [
path("cart/", views.cart, name="cart"),
path("cart/add/", views.add, name="add"),
path("cart/remove/", views.remove, name="remove"),
path("cart/update-quantity/", views.update_quantity, name="update_quantity"),
]

View File

@@ -1,3 +1,106 @@
from django.shortcuts import render
from decimal import Decimal
# Create your views here.
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from items.models import Item
from orders.models import CartItem
from orders.services import (
add_item_to_cart,
format_currency,
get_user_cart,
remove_item_from_cart,
update_cart_item_quantity,
)
def resolve_next_url(request: HttpRequest, fallback: str) -> str:
next_url = request.POST.get("next") or fallback
if not url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return fallback
return next_url
@login_required
def cart(request: HttpRequest) -> HttpResponse:
cart_items: list[CartItem] = []
total_quantity = 0
total_amount = "0,00 руб."
cart_obj = get_user_cart(request.user)
if cart_obj:
cart_items = list(
cart_obj.items.select_related("item__category", "item__subcategory").prefetch_related("item__images")
)
total_quantity = sum(cart_item.quantity for cart_item in cart_items)
total_amount_value = sum(
(cart_item.item.price * cart_item.quantity for cart_item in cart_items),
start=Decimal("0.00"),
)
total_amount = format_currency(total_amount_value)
return render(request, "orders/cart.html", {
"cart_items": cart_items,
"total_quantity": total_quantity,
"total_amount": total_amount,
})
@login_required
@require_POST
def add(request: HttpRequest) -> HttpResponse:
item = get_object_or_404(Item, pk=request.POST.get("item_id"))
try:
_, created = add_item_to_cart(request.user, item)
if created:
messages.success(request, f"Товар «{item.name}» добавлен в корзину.")
else:
messages.info(request, f"Товар «{item.name}» уже есть в корзине.")
except ValidationError as error:
messages.error(request, error.message)
return redirect(resolve_next_url(request, reverse("orders:cart")))
@login_required
@require_POST
def remove(request: HttpRequest) -> HttpResponse:
item = get_object_or_404(Item, pk=request.POST.get("item_id"))
removed = remove_item_from_cart(request.user, item.id)
if removed:
messages.success(request, f"Товар «{item.name}» удалён из корзины.")
else:
messages.info(request, f"Товар «{item.name}» уже отсутствует в корзине.")
return redirect(resolve_next_url(request, reverse("orders:cart")))
@login_required
@require_POST
def update_quantity(request: HttpRequest) -> HttpResponse:
item = get_object_or_404(Item, pk=request.POST.get("item_id"))
try:
quantity = int(request.POST.get("quantity"))
update_cart_item_quantity(request.user, item.id, quantity)
messages.success(request, f"Количество товара «{item.name}» обновлено.")
except (TypeError, ValueError):
messages.error(request, "Некорректное количество товара.")
except CartItem.DoesNotExist as error:
raise Http404() from error
except ValidationError as error:
messages.error(request, error.message)
return redirect(resolve_next_url(request, reverse("orders:cart")))