feat: implement shopping cart feature with user authentication support
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s
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:
@@ -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")
|
||||
|
||||
49
orders/migrations/0001_initial.py
Normal file
49
orders/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
69
orders/services.py
Normal 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
|
||||
111
orders/tests.py
111
orders/tests.py
@@ -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"])
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
107
orders/views.py
107
orders/views.py
@@ -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")))
|
||||
|
||||
Reference in New Issue
Block a user