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

@@ -54,3 +54,35 @@ class MediaUrlTests(SimpleTestCase):
self.assertEqual(match.func.__module__, "django.views.static")
self.assertEqual(match.func.__name__, "serve")
class ContactsPageTests(TestCase):
def test_contacts_page_renders_contact_details(self):
response = self.client.get(reverse("contacts"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "КОНТАКТЫ")
self.assertContains(response, "Беларусь, г. Минск, ул. Тимирязева, д. 123, к.1, пав. 59")
self.assertContains(response, "info@santehray.by")
self.assertContains(response, "+375 (29) 688-18-19")
self.assertContains(response, "+375 (33) 337-67-81")
def test_contacts_page_includes_leaflet_map_assets(self):
response = self.client.get(reverse("contacts"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css")
self.assertContains(response, "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js")
self.assertContains(response, 'id="contacts-map"', html=False)
self.assertContains(response, "L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'")
self.assertContains(response, "OpenStreetMap")
def test_contacts_page_does_not_include_yandex_api(self):
response = self.client.get(reverse("contacts"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "api-maps.yandex.ru")
self.assertNotContains(response, "YANDEX_MAPS_API_KEY")
self.assertContains(response, 'data-lat="53.9334960"', html=False)
self.assertContains(response, 'data-lon="27.4551021"', html=False)
self.assertContains(response, "Открыть адрес в OpenStreetMap")

View File

@@ -11,6 +11,19 @@ from home.forms import FaqQuestionForm
logger = logging.getLogger(__name__)
CONTACT_ADDRESS = "Беларусь, г. Минск, ул. Тимирязева, д. 123, к.1, пав. 59"
CONTACT_EMAIL = "info@santehray.by"
CONTACT_PHONE_PRIMARY = "+375 (29) 688-18-19"
CONTACT_PHONE_PRIMARY_URL = "+375296881819"
CONTACT_PHONE_SECONDARY = "+375 (33) 337-67-81"
CONTACT_PHONE_SECONDARY_URL = "+375333376781"
CONTACT_HOURS_WEEKDAYS = "Пн-Пт: 9.00 - 21.00"
CONTACT_HOURS_WEEKENDS = "Сб-Вс: 10.00 - 18.00"
CONTACT_MAP_CENTER = {"lat": "53.9334960", "lon": "27.4551021", "zoom": 15}
CONTACT_MAP_EXTERNAL_URL = (
"https://www.openstreetmap.org/?mlat=53.9334960&mlon=27.4551021#map=15/53.9334960/27.4551021"
)
def index(request):
form = FaqQuestionForm(request.POST or None)
@@ -58,4 +71,18 @@ def warranty(request):
def contacts(request):
return render(request=request, template_name='home/contacts.html', context={})
context = {
"contact_address": CONTACT_ADDRESS,
"contact_email": CONTACT_EMAIL,
"contact_phone_primary": CONTACT_PHONE_PRIMARY,
"contact_phone_primary_url": CONTACT_PHONE_PRIMARY_URL,
"contact_phone_secondary": CONTACT_PHONE_SECONDARY,
"contact_phone_secondary_url": CONTACT_PHONE_SECONDARY_URL,
"contact_hours_weekdays": CONTACT_HOURS_WEEKDAYS,
"contact_hours_weekends": CONTACT_HOURS_WEEKENDS,
"map_lat": CONTACT_MAP_CENTER["lat"],
"map_lon": CONTACT_MAP_CENTER["lon"],
"map_zoom": CONTACT_MAP_CENTER["zoom"],
"map_external_url": CONTACT_MAP_EXTERNAL_URL,
}
return render(request=request, template_name='home/contacts.html', context=context)

View File

@@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
from favorites.models import Favorite
from items.models import Category, Item, ItemImage, Subcategory
from orders.models import Cart, CartItem
@override_settings(MEDIA_URL="/media/")
@@ -215,6 +216,44 @@ class CatalogViewTests(TestCase):
self.assertEqual(response.status_code, 404)
def test_detail_page_shows_cart_link_for_item_already_in_cart(self):
user = get_user_model().objects.create_user(
username="cart-user",
email="cart-user@example.com",
password="StrongPassword123",
is_active=True,
)
cart = Cart.objects.create(user=user)
CartItem.objects.create(cart=cart, item=self.sink, quantity=1)
self.client.force_login(user)
response = self.client.get("/catalog/plumbing/kitchen-sink/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'href="/orders/cart/"', html=False)
self.assertContains(response, "Перейти в корзину")
def test_detail_page_guest_cart_cta_redirects_to_login(self):
response = self.client.get("/catalog/plumbing/kitchen-sink/")
self.assertEqual(response.status_code, 200)
self.assertContains(
response,
'href="/users/login/?next=/catalog/plumbing/kitchen-sink/"',
html=False,
)
self.assertNotContains(response, "data-guest-cart-detail-button", html=False)
def test_header_cart_link_redirects_guest_to_login(self):
response = self.client.get("/catalog/")
self.assertEqual(response.status_code, 200)
self.assertContains(
response,
'href="/users/login/?next=/orders/cart/"',
html=False,
)
def test_detail_page_wrong_category_returns_404(self):
response = self.client.get("/catalog/decor/kitchen-sink/")

View File

@@ -5,6 +5,7 @@ from django.shortcuts import render
from favorites.services import get_user_favorite_item_ids
from items.models import Category, Item, Subcategory
from orders.services import get_user_cart_item_ids
@dataclass
@@ -78,5 +79,6 @@ def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
'item': item,
'images': images,
'primary_image': images[0] if images else None,
'cart_item_ids': get_user_cart_item_ids(request.user),
'favorite_item_ids': get_user_favorite_item_ids(request.user),
})

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

268
static/css/cart.css Normal file
View File

@@ -0,0 +1,268 @@
.cart-page {
margin-bottom: 88px;
}
.cart-hero {
padding: 28px 0 34px;
}
.cart-kicker {
color: #63768d;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.cart-hero__row {
margin-top: 12px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.cart-title {
font-size: 44px;
line-height: 1.04;
}
.cart-description {
margin-top: 12px;
max-width: 620px;
color: #554971;
font-size: 18px;
line-height: 1.4;
}
.cart-summary-card {
min-width: 220px;
padding: 18px 22px;
border: 1px solid rgba(54, 33, 62, 0.12);
background: rgba(99, 118, 141, 0.06);
}
.cart-summary-card__value {
display: block;
font-size: 34px;
font-weight: 700;
}
.cart-summary-card__label {
display: block;
margin-top: 6px;
color: #554971;
}
.cart-content {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 28px;
align-items: start;
}
.cart-content--guest {
min-height: 280px;
}
.cart-items {
display: flex;
flex-direction: column;
gap: 18px;
}
.cart-item {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 22px;
padding: 22px;
border: 1px solid rgba(54, 33, 62, 0.12);
background: #ffffff;
}
.cart-item__media {
display: block;
aspect-ratio: 4 / 3;
background: rgba(99, 118, 141, 0.08);
overflow: hidden;
}
.cart-item__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cart-item__placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #63768d;
}
.cart-item__body {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 18px;
min-width: 0;
}
.cart-item__category {
color: #63768d;
font-size: 15px;
}
.cart-item__title {
display: inline-block;
margin-top: 8px;
font-family: 'BerlinType', serif;
font-size: 28px;
line-height: 1.1;
}
.cart-item__price {
margin-top: 10px;
color: #554971;
font-size: 18px;
}
.cart-item__controls {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
}
.cart-quantity {
display: inline-flex;
align-items: center;
gap: 10px;
}
.cart-quantity form {
margin: 0;
}
.cart-quantity__button {
width: 38px;
height: 38px;
border: 1px solid #63768d;
background: transparent;
color: #36213e;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.cart-quantity__value {
min-width: 24px;
text-align: center;
font-size: 18px;
}
.cart-item__aside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.cart-item__subtotal {
font-size: 17px;
color: #36213e;
}
.cart-item__remove {
border: 0;
background: transparent;
color: #a14b4b;
cursor: pointer;
text-decoration: underline;
}
.cart-totals {
position: sticky;
top: 120px;
padding: 24px;
border: 1px solid rgba(54, 33, 62, 0.12);
background: rgba(99, 118, 141, 0.06);
}
.cart-totals__label {
color: #554971;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.cart-totals__value {
margin-top: 10px;
font-family: 'BerlinType', serif;
font-size: 36px;
line-height: 1.1;
}
.cart-totals__meta {
margin-top: 12px;
color: #554971;
font-size: 16px;
line-height: 1.4;
}
.cart-empty {
padding: 32px;
border: 1px solid rgba(54, 33, 62, 0.12);
color: #554971;
font-size: 18px;
line-height: 1.4;
}
.cart-item__summary-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
color: #554971;
font-size: 16px;
}
@media (max-width: 900px) {
.cart-page {
padding-left: 24px;
padding-right: 24px;
}
.cart-hero__row,
.cart-content {
grid-template-columns: 1fr;
display: grid;
}
.cart-summary-card,
.cart-totals {
min-width: 0;
position: static;
}
}
@media (max-width: 680px) {
.cart-item {
grid-template-columns: 1fr;
}
.cart-item__controls {
align-items: flex-start;
flex-direction: column;
}
.cart-item__aside {
align-items: flex-start;
}
.cart-title {
font-size: 34px;
}
}

View File

@@ -373,6 +373,10 @@ aside {
margin-top: 88px;
}
.product-summary__cart-form {
display: contents;
}
.product-summary__button {
min-height: 38px;
border: 1px solid #63768d;
@@ -384,6 +388,17 @@ aside {
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}
.product-summary__button--link {
display: flex;
align-items: center;
justify-content: center;
}
.product-summary__button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.product-summary__button--primary {
background: #63768d;
color: #ffffff;

View File

@@ -0,0 +1,242 @@
.contacts-page {
padding-bottom: 120px;
}
.contacts-page__hero {
padding-top: 20px;
}
.contacts-page__hero > h1 {
font-weight: 400;
font-size: 54px;
line-height: 1;
color: #36213e;
}
.contacts-grid {
margin-top: 58px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 80px;
}
.contacts-column {
display: flex;
flex-direction: column;
gap: 42px;
}
.contacts-card h2 {
font-family: 'Noah', sans-serif;
font-weight: 600;
font-size: 30px;
line-height: 1.1;
color: #36213e;
}
.contacts-card p,
.contacts-card a {
font-size: 24px;
line-height: 1.3;
color: #36213e;
}
.contacts-card p + p {
margin-top: 8px;
}
.contacts-card h2 + p,
.contacts-card h2 + a,
.contacts-card h2 + div,
.contacts-card h2 + ul {
margin-top: 18px;
}
.contacts-card a {
text-decoration: underline;
text-underline-offset: 3px;
}
.contacts-map-section {
margin-top: 88px;
}
.contacts-map-section__title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.contacts-map {
width: 100%;
min-height: 520px;
border: 1px solid rgba(54, 33, 62, 0.16);
background: linear-gradient(180deg, rgba(99, 118, 141, 0.08) 0%, rgba(99, 118, 141, 0.03) 100%);
overflow: hidden;
}
.contacts-map .leaflet-control-attribution,
.contacts-map .leaflet-control-attribution a {
font-family: 'Noah', sans-serif;
color: #554971;
}
.contacts-map .leaflet-popup-content-wrapper {
border-radius: 16px;
background: rgba(54, 33, 62, 0.94);
color: #ffffff;
box-shadow: 0 16px 34px rgba(54, 33, 62, 0.22);
}
.contacts-map .leaflet-popup-content {
margin: 12px 16px;
font-family: 'Noah', sans-serif;
font-size: 16px;
font-weight: 700;
}
.contacts-map .leaflet-popup-tip {
background: rgba(54, 33, 62, 0.94);
}
.contacts-map .leaflet-container {
font-family: 'Noah', sans-serif;
}
.contacts-map--fallback {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.contacts-map-fallback {
max-width: 560px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.contacts-map-fallback h3 {
font-family: 'BerlinType', serif;
font-weight: 400;
font-size: 32px;
line-height: 1.1;
color: #36213e;
}
.contacts-map-fallback p,
.contacts-map-fallback a {
font-size: 22px;
line-height: 1.35;
color: #554971;
}
.contacts-map-fallback a {
text-decoration: underline;
text-underline-offset: 3px;
}
.contacts-map-marker {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(54, 33, 62, 0.94);
color: #ffffff;
box-shadow: 0 14px 30px rgba(54, 33, 62, 0.24);
transform: translate(-50%, -100%);
}
.contacts-map-marker span {
width: 12px;
height: 12px;
border-radius: 999px;
background: #b8f3ff;
box-shadow: 0 0 0 5px rgba(184, 243, 255, 0.16);
}
.contacts-map-marker strong {
font-family: 'Noah', sans-serif;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
}
@media (max-width: 900px) {
.contacts-page {
padding: 0 24px 96px;
}
.contacts-grid {
gap: 48px;
}
.contacts-card p,
.contacts-card a,
.contacts-map-fallback p,
.contacts-map-fallback a {
font-size: 21px;
}
}
@media (max-width: 700px) {
.contacts-page {
padding-bottom: 72px;
}
.contacts-page__hero > h1 {
font-size: 42px;
}
.contacts-grid {
margin-top: 40px;
grid-template-columns: 1fr;
gap: 36px;
}
.contacts-column {
gap: 28px;
}
.contacts-card h2 {
font-size: 26px;
}
.contacts-card p,
.contacts-card a,
.contacts-map-fallback p,
.contacts-map-fallback a {
font-size: 18px;
}
.contacts-map-section {
margin-top: 56px;
}
.contacts-map {
min-height: 360px;
}
.contacts-map-fallback h3 {
font-size: 26px;
}
.contacts-map-marker {
padding: 8px 12px;
}
footer .footer-container {
grid-template-columns: 1fr !important;
gap: 28px;
}
}

View File

@@ -550,7 +550,7 @@ footer {
padding-right: 24px;
}
footer {
.footer-container {
grid-template-columns: 1fr 1fr;
padding: 40px 24px;
}
@@ -614,4 +614,9 @@ footer {
width: auto;
flex: 0 0 auto;
}
.footer-container {
grid-template-columns: 1fr;
gap: 28px;
}
}

20
static/js/cart.js Normal file
View File

@@ -0,0 +1,20 @@
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-cart-decrement]').forEach(function (button) {
button.addEventListener('click', function () {
const quantity = Number(button.dataset.currentQuantity || '0');
const removeForm = document.getElementById(button.dataset.removeFormId);
const updateForm = document.getElementById(button.dataset.updateFormId);
if (quantity <= 1) {
if (window.confirm('Удалить товар из корзины?') && removeForm) {
removeForm.submit();
}
return;
}
if (updateForm) {
updateForm.submit();
}
});
});
});

View File

@@ -85,7 +85,7 @@
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01zM8 15C-7.333 4.868 3.279-3.04 7.824 1.143q.09.083.176.171a3 3 0 0 1 .176-.17C12.72-3.042 23.333 4.867 8 15"></path>
</svg>
</a>
<a href="#">
<a href="{% if request.user.is_authenticated %}{% url 'orders:cart' %}{% else %}{% url 'users:login' %}?next={% url 'orders:cart' %}{% endif %}" aria-label="Корзина">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-cart2" viewBox="0 0 16 16">
<path d="M0 2.5A.5.5 0 0 1 .5 2H2a.5.5 0 0 1 .485.379L2.89 4H14.5a.5.5 0 0 1 .485.621l-1.5 6A.5.5 0 0 1 13 11H4a.5.5 0 0 1-.485-.379L1.61 3H.5a.5.5 0 0 1-.5-.5M3.14 5l1.25 5h8.22l1.25-5zM5 13a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0m9-1a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0"></path>

View File

@@ -54,5 +54,7 @@
})
</script>
<script src="{% static 'js/favorites.js' %}"></script>
<script src="{% static 'js/cart.js' %}"></script>
{% block add_js %}{% endblock %}
</body>
</html>

View File

@@ -61,7 +61,28 @@
{% endif %}
<div class="product-summary__actions">
<button class="product-summary__button product-summary__button--primary" type="button">В корзину</button>
{% if item.id in cart_item_ids %}
<a class="product-summary__button product-summary__button--primary product-summary__button--link" href="{% url 'orders:cart' %}">
Перейти в корзину
</a>
{% elif request.user.is_authenticated %}
<form method="post" action="{% url 'orders:add' %}" class="product-summary__cart-form">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button class="product-summary__button product-summary__button--primary" type="submit" {% if not item.is_available %}disabled{% endif %}>
В корзину
</button>
</form>
{% else %}
<a
class="product-summary__button product-summary__button--primary product-summary__button--link"
href="{% url 'users:login' %}?next={{ request.get_full_path|urlencode }}"
{% if not item.is_available %}aria-disabled="true"{% endif %}
>
В корзину
</a>
{% endif %}
<button class="product-summary__button product-summary__button--secondary" type="button">Купить</button>
</div>
</section>

View File

@@ -1,16 +1,110 @@
{% extends "_layouts/base.html" %}
{% load static %}
{% block title %}Главная{% endblock %}
{% block title %}Контакты{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/contacts.css' %}">
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
>
{% endblock %}
{% block content %}
{% include "_fragments/header.html" %}
{% include "_fragments/header.html" with active="contacts" breadcrumb=1 %}
<main class="container contacts-page">
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/contacts/' text_2='Контакты' %}
<section class="contacts-page__hero">
<h1>КОНТАКТЫ</h1>
<div class="contacts-grid">
<div class="contacts-column">
<section class="contacts-card">
<h2>Адрес:</h2>
<p>{{ contact_address }}</p>
</section>
<section class="contacts-card">
<h2>Режим работы:</h2>
<p>{{ contact_hours_weekdays }}</p>
<p>{{ contact_hours_weekends }}</p>
</section>
</div>
<div class="contacts-column">
<section class="contacts-card">
<h2>Телефон:</h2>
<p><a href="tel:{{ contact_phone_primary_url }}">{{ contact_phone_primary }}</a></p>
<p><a href="tel:{{ contact_phone_secondary_url }}">{{ contact_phone_secondary }}</a></p>
</section>
<section class="contacts-card">
<h2>E-mail</h2>
<p><a href="mailto:{{ contact_email }}">{{ contact_email }}</a></p>
</section>
</div>
</div>
</section>
<section class="contacts-map-section" aria-labelledby="contacts-map-title">
<h2 class="contacts-map-section__title" id="contacts-map-title">Как нас найти</h2>
<div
id="contacts-map"
class="contacts-map"
data-lat="{{ map_lat }}"
data-lon="{{ map_lon }}"
data-zoom="{{ map_zoom }}"
data-title="SantehRay"
></div>
<noscript>
<div class="contacts-map contacts-map--fallback contacts-map--noscript">
<div class="contacts-map-fallback">
<h3>Для интерактивной карты нужен JavaScript.</h3>
<a href="{{ map_external_url }}" target="_blank" rel="noreferrer">Открыть адрес в OpenStreetMap</a>
</div>
</div>
</noscript>
</section>
</main>
{# TODO #}
В разработке
{% include "_fragments/footer.html" %}
{% endblock %}
{% block add_js %}
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const mapRoot = document.getElementById('contacts-map')
if (!mapRoot || typeof L === 'undefined') {
return
}
const lat = Number(mapRoot.dataset.lat)
const lon = Number(mapRoot.dataset.lon)
const zoom = Number(mapRoot.dataset.zoom)
const title = mapRoot.dataset.title || 'SantehRay'
const map = L.map(mapRoot, {
scrollWheelZoom: true,
}).setView([lat, lon], zoom)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map)
L.marker([lat, lon]).addTo(map).bindPopup(title).openPopup()
})
</script>
{% endblock %}

113
templates/orders/cart.html Normal file
View File

@@ -0,0 +1,113 @@
{% extends "_layouts/base.html" %}
{% load static %}
{% block title %}Корзина{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static "css/cart.css" %}">
{% endblock %}
{% block content %}
{% include "_fragments/header.html" %}
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/orders/cart/' text_2='Корзина' %}
<main class="container cart-page">
<section class="cart-hero">
<p class="cart-kicker">Покупки</p>
<div class="cart-hero__row">
<div>
<h1 class="cart-title">Корзина</h1>
<p class="cart-description">
Проверьте выбранные позиции, измените количество или удалите лишнее перед оформлением.
</p>
</div>
<div class="cart-summary-card">
<span class="cart-summary-card__value" data-cart-total-quantity>{{ total_quantity }}</span>
<span class="cart-summary-card__label">товаров в корзине</span>
</div>
</div>
</section>
<section class="cart-content" aria-label="Содержимое корзины">
{% if cart_items %}
<div class="cart-items">
{% for cart_item in cart_items %}
{% with item=cart_item.item %}
<article class="cart-item" data-auth-cart-item data-current-quantity="{{ cart_item.quantity }}">
<a class="cart-item__media" href="{% url 'detail' item.category.slug item.slug %}">
{% with primary_image=item.primary_image %}
{% if primary_image %}
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
{% else %}
<div class="cart-item__placeholder">Нет фото</div>
{% endif %}
{% endwith %}
</a>
<div class="cart-item__body">
<div class="cart-item__main">
<p class="cart-item__category">
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
</p>
<a class="cart-item__title" href="{% url 'detail' item.category.slug item.slug %}">{{ item.name }}</a>
<p class="cart-item__price">{{ item.price }} руб.</p>
</div>
<div class="cart-item__controls">
<div class="cart-quantity">
<button
class="cart-quantity__button"
type="button"
data-cart-decrement
data-remove-form-id="cart-remove-{{ cart_item.id }}"
data-update-form-id="cart-decrement-{{ cart_item.id }}"
data-current-quantity="{{ cart_item.quantity }}"
>
-
</button>
<span class="cart-quantity__value">{{ cart_item.quantity }}</span>
<form id="cart-increment-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:1 }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}">
<button class="cart-quantity__button" type="submit">+</button>
</form>
<form id="cart-decrement-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:-1 }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}">
</form>
</div>
<div class="cart-item__aside">
<span class="cart-item__subtotal">{{ cart_item.subtotal }} руб.</span>
<form id="cart-remove-{{ cart_item.id }}" method="post" action="{% url 'orders:remove' %}">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}">
<button class="cart-item__remove" type="submit">Удалить</button>
</form>
</div>
</div>
</div>
</article>
{% endwith %}
{% endfor %}
</div>
<aside class="cart-totals">
<p class="cart-totals__label">Итого</p>
<p class="cart-totals__value" data-cart-total-amount>{{ total_amount }}</p>
<p class="cart-totals__meta">Позиции можно изменить прямо на этой странице.</p>
</aside>
{% else %}
<p class="cart-empty">Корзина пока пуста.</p>
{% endif %}
</section>
</main>
{% include "_fragments/footer.html" %}
{% endblock %}

View File

@@ -6,7 +6,6 @@ from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
User = get_user_model()
@@ -17,6 +16,7 @@ class UserAuthFlowTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "users/login.html")
self.assertNotContains(response, 'name="guest_cart_payload"', html=False)
def test_registration_creates_inactive_user_and_sends_activation_email(self):
response = self.client.post(
@@ -101,3 +101,9 @@ class UserAuthFlowTests(TestCase):
self.assertRedirects(response, reverse("users:password_reset_done"))
self.assertEqual(len(mail.outbox), 1)
self.assertIn("/users/reset/", mail.outbox[0].body)
def test_registration_page_does_not_render_guest_cart_payload_field(self):
response = self.client.get(reverse("users:register"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'name="guest_cart_payload"', html=False)