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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/")
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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")))
|
||||
|
||||
268
static/css/cart.css
Normal file
268
static/css/cart.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
20
static/js/cart.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '© <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
113
templates/orders/cart.html
Normal 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 %}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user