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.__module__, "django.views.static")
self.assertEqual(match.func.__name__, "serve") 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__) 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): def index(request):
form = FaqQuestionForm(request.POST or None) form = FaqQuestionForm(request.POST or None)
@@ -58,4 +71,18 @@ def warranty(request):
def contacts(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 favorites.models import Favorite
from items.models import Category, Item, ItemImage, Subcategory from items.models import Category, Item, ItemImage, Subcategory
from orders.models import Cart, CartItem
@override_settings(MEDIA_URL="/media/") @override_settings(MEDIA_URL="/media/")
@@ -215,6 +216,44 @@ class CatalogViewTests(TestCase):
self.assertEqual(response.status_code, 404) 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): def test_detail_page_wrong_category_returns_404(self):
response = self.client.get("/catalog/decor/kitchen-sink/") 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 favorites.services import get_user_favorite_item_ids
from items.models import Category, Item, Subcategory from items.models import Category, Item, Subcategory
from orders.services import get_user_cart_item_ids
@dataclass @dataclass
@@ -78,5 +79,6 @@ def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
'item': item, 'item': item,
'images': images, 'images': images,
'primary_image': images[0] if images else None, '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), 'favorite_item_ids': get_user_favorite_item_ids(request.user),
}) })

View File

@@ -1,3 +1,22 @@
from django.contrib import admin 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 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; margin-top: 88px;
} }
.product-summary__cart-form {
display: contents;
}
.product-summary__button { .product-summary__button {
min-height: 38px; min-height: 38px;
border: 1px solid #63768d; border: 1px solid #63768d;
@@ -384,6 +388,17 @@ aside {
transition: background-color 0.2s, color 0.2s, border-color 0.2s; 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 { .product-summary__button--primary {
background: #63768d; background: #63768d;
color: #ffffff; 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; padding-right: 24px;
} }
footer { .footer-container {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
padding: 40px 24px; padding: 40px 24px;
} }
@@ -614,4 +614,9 @@ footer {
width: auto; width: auto;
flex: 0 0 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> <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> </svg>
</a> </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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-cart2" viewBox="0 0 16 16"> 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> <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>
<script src="{% static 'js/favorites.js' %}"></script> <script src="{% static 'js/favorites.js' %}"></script>
<script src="{% static 'js/cart.js' %}"></script>
{% block add_js %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -61,7 +61,28 @@
{% endif %} {% endif %}
<div class="product-summary__actions"> <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> <button class="product-summary__button product-summary__button--secondary" type="button">Купить</button>
</div> </div>
</section> </section>

View File

@@ -1,16 +1,110 @@
{% extends "_layouts/base.html" %} {% extends "_layouts/base.html" %}
{% load static %} {% load static %}
{% block title %}Главная{% endblock %} {% block title %}Контакты{% endblock %}
{% block add_css %} {% 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 %} {% endblock %}
{% block content %} {% 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" %} {% include "_fragments/footer.html" %}
{% endblock %} {% 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.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
User = get_user_model() User = get_user_model()
@@ -17,6 +16,7 @@ class UserAuthFlowTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "users/login.html") 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): def test_registration_creates_inactive_user_and_sends_activation_email(self):
response = self.client.post( response = self.client.post(
@@ -101,3 +101,9 @@ class UserAuthFlowTests(TestCase):
self.assertRedirects(response, reverse("users:password_reset_done")) self.assertRedirects(response, reverse("users:password_reset_done"))
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertIn("/users/reset/", mail.outbox[0].body) 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)