Implement guest buy now checkout
All checks were successful
Build and Push Docker Image / build (push) Successful in 26s

This commit is contained in:
2026-06-15 23:16:10 +02:00
parent f029e11175
commit a84b9af926
14 changed files with 322 additions and 85 deletions

2
.env
View File

@@ -1,5 +1,5 @@
SECRET_KEY=replace-me SECRET_KEY=replace-me
DEBUG=false DEBUG=true
LOG_LEVEL=info LOG_LEVEL=info
WHITENOISE_USE_MANIFEST=false WHITENOISE_USE_MANIFEST=false
WHITENOISE_MANIFEST_STRICT=false WHITENOISE_MANIFEST_STRICT=false

BIN
Baymax.otf Normal file

Binary file not shown.

BIN
ST-Nizhegorodsky.otf Normal file

Binary file not shown.

BIN
Waterway Display Font.ttf Normal file

Binary file not shown.

View File

@@ -381,6 +381,13 @@ class CatalogViewTests(TestCase):
) )
self.assertNotContains(response, "data-guest-cart-detail-button", html=False) self.assertNotContains(response, "data-guest-cart-detail-button", html=False)
def test_detail_page_guest_buy_now_link_does_not_require_login(self):
response = self.client.get("/catalog/plumbing/kitchen-sink/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, f'href="/orders/buy/{self.sink.id}/"', html=False)
self.assertContains(response, "Купить")
def test_header_cart_link_redirects_guest_to_login(self): def test_header_cart_link_redirects_guest_to_login(self):
response = self.client.get("/catalog/") response = self.client.get("/catalog/")

View File

@@ -0,0 +1,28 @@
# Generated by Django on 2026-06-15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("orders", "0004_alter_order_payment_method"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="order",
name="user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="orders",
to=settings.AUTH_USER_MODEL,
verbose_name="Пользователь",
),
),
]

View File

@@ -79,8 +79,10 @@ class Order(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.SET_NULL,
related_name="orders", related_name="orders",
null=True,
blank=True,
verbose_name="Пользователь", verbose_name="Пользователь",
) )
customer_name = models.CharField(max_length=255, verbose_name="ФИО") customer_name = models.CharField(max_length=255, verbose_name="ФИО")

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any, Sequence
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -18,13 +18,21 @@ FLOOR_LIFT_PRICE = Decimal("45.00")
ZERO_MONEY = Decimal("0.00") ZERO_MONEY = Decimal("0.00")
@dataclass(frozen=True)
class CheckoutLine:
item: Item
quantity: int = 1
selected_color: Any = None
id: str = "direct"
def format_currency(value: Decimal) -> str: def format_currency(value: Decimal) -> str:
return f"{formats.number_format(value, 2)} руб." return f"{formats.number_format(value, 2)} руб."
def calculate_checkout_totals(cart_items: list[CartItem], delivery_method: str) -> dict[str, Decimal]: def calculate_checkout_totals(checkout_items: Sequence[Any], delivery_method: str) -> dict[str, Decimal]:
products_total = sum( products_total = sum(
(cart_item.item.price * cart_item.quantity for cart_item in cart_items), (checkout_item.item.price * checkout_item.quantity for checkout_item in checkout_items),
start=ZERO_MONEY, start=ZERO_MONEY,
) )
discount_total = ZERO_MONEY discount_total = ZERO_MONEY
@@ -114,18 +122,18 @@ def update_cart_item_quantity(user: Any, item_id: int, quantity: int) -> CartIte
@transaction.atomic @transaction.atomic
def create_order_from_cart(user: Any, cleaned_data: dict[str, Any]) -> Order: def create_order_from_checkout_items(
cart = get_user_cart(user) user: Any | None,
if not cart: checkout_items: Sequence[Any],
raise ValidationError("Корзина пуста.") cleaned_data: dict[str, Any],
) -> Order:
if not checkout_items:
raise ValidationError("Нет товаров для оформления заказа.")
cart_items = list(cart.items.select_related("item")) order_user = user if getattr(user, "is_authenticated", False) else None
if not cart_items: totals = calculate_checkout_totals(checkout_items, cleaned_data["delivery_method"])
raise ValidationError("Корзина пуста.")
totals = calculate_checkout_totals(cart_items, cleaned_data["delivery_method"])
order = Order.objects.create( order = Order.objects.create(
user=user, user=order_user,
customer_name=cleaned_data["customer_name"], customer_name=cleaned_data["customer_name"],
phone=cleaned_data["phone"], phone=cleaned_data["phone"],
email=cleaned_data["email"], email=cleaned_data["email"],
@@ -153,13 +161,34 @@ def create_order_from_cart(user: Any, cleaned_data: dict[str, Any]) -> Order:
quantity=cart_item.quantity, quantity=cart_item.quantity,
line_total=cart_item.item.price * cart_item.quantity, line_total=cart_item.item.price * cart_item.quantity,
) )
for cart_item in cart_items for cart_item in checkout_items
] ]
) )
return order
@transaction.atomic
def create_order_from_cart(user: Any, cleaned_data: dict[str, Any]) -> Order:
cart = get_user_cart(user)
if not cart:
raise ValidationError("Корзина пуста.")
cart_items = list(cart.items.select_related("item"))
if not cart_items:
raise ValidationError("Корзина пуста.")
order = create_order_from_checkout_items(user, cart_items, cleaned_data)
cart.items.all().delete() cart.items.all().delete()
return order return order
def create_order_from_item(user: Any | None, item: Item, cleaned_data: dict[str, Any]) -> Order:
if not item.is_available:
raise ValidationError("Недоступный товар нельзя оформить.")
return create_order_from_checkout_items(user, [CheckoutLine(item=item)], cleaned_data)
def send_order_confirmation_email(order: Order) -> None: def send_order_confirmation_email(order: Order) -> None:
order = ( order = (
Order.objects.select_related("user") Order.objects.select_related("user")

View File

@@ -202,6 +202,79 @@ class CartFlowTests(TestCase):
self.assertEqual(order.floor_lift_total, 0) self.assertEqual(order.floor_lift_total, 0)
self.assertEqual(order.total, 1600) self.assertEqual(order.total, 1600)
def test_buy_now_page_is_available_to_guest(self):
response = self.client.get(reverse("orders:buy_now", kwargs={"item_id": self.item.id}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Оформление заказа")
self.assertContains(response, "Кухонная мойка")
self.assertContains(response, f'action="{reverse("orders:buy_now", kwargs={"item_id": self.item.id})}"', html=False)
self.assertNotContains(response, "cart-remove-", html=False)
def test_buy_now_rejects_unavailable_item(self):
response = self.client.get(reverse("orders:buy_now", kwargs={"item_id": self.unavailable_item.id}))
self.assertEqual(response.status_code, 404)
def test_buy_now_creates_guest_order_without_cart_and_sends_email(self):
response = self.client.post(
reverse("orders:buy_now", kwargs={"item_id": self.item.id}),
data=self.checkout_data(),
)
order = Order.objects.get()
self.assertRedirects(response, reverse("orders:success", kwargs={"order_id": order.id}))
self.assertIsNone(order.user)
self.assertEqual(order.products_total, 1600)
self.assertEqual(order.delivery_total, 50)
self.assertEqual(order.floor_lift_total, 45)
self.assertEqual(order.total, 1695)
self.assertFalse(Cart.objects.exists())
order_item = OrderItem.objects.get(order=order)
self.assertEqual(order_item.item_name, "Кухонная мойка")
self.assertEqual(order_item.quantity, 1)
self.assertEqual(order_item.line_total, 1600)
self.assertIn(order.id, self.client.session["guest_order_ids"])
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["buyer@example.com"])
def test_guest_success_page_requires_session_order_access(self):
order = Order.objects.create(
user=None,
customer_name="Иван Петров",
phone="+375296881819",
email="buyer@example.com",
delivery_method=Order.DeliveryMethod.COURIER,
region="Минск",
address="ул. Тимирязева, 123",
payment_method=Order.PaymentMethod.CARD,
products_total=1600,
discount_total=0,
delivery_total=50,
floor_lift_total=45,
total=1695,
status=Order.Status.PAID,
)
response = self.client.get(reverse("orders:success", kwargs={"order_id": order.id}))
self.assertEqual(response.status_code, 404)
def test_guest_success_page_allows_session_order_access(self):
response = self.client.post(
reverse("orders:buy_now", kwargs={"item_id": self.item.id}),
data=self.checkout_data(),
)
order = Order.objects.get()
self.assertRedirects(response, reverse("orders:success", kwargs={"order_id": order.id}))
success_response = self.client.get(reverse("orders:success", kwargs={"order_id": order.id}))
self.assertEqual(success_response.status_code, 200)
self.assertContains(success_response, f"Заказ №{order.id} оформлен")
def test_success_page_is_only_available_to_order_owner(self): def test_success_page_is_only_available_to_order_owner(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(
username="other-user", username="other-user",

View File

@@ -10,6 +10,7 @@ urlpatterns = [
path("cart/add/", views.add, name="add"), path("cart/add/", views.add, name="add"),
path("cart/remove/", views.remove, name="remove"), path("cart/remove/", views.remove, name="remove"),
path("cart/update-quantity/", views.update_quantity, name="update_quantity"), path("cart/update-quantity/", views.update_quantity, name="update_quantity"),
path("buy/<int:item_id>/", views.buy_now, name="buy_now"),
path("checkout/", views.checkout, name="checkout"), path("checkout/", views.checkout, name="checkout"),
path("success/<int:order_id>/", views.success, name="success"), path("success/<int:order_id>/", views.success, name="success"),
] ]

View File

@@ -5,14 +5,16 @@ from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST, require_http_methods
from items.models import Color, Item from items.models import Color, Item
from orders.forms import CheckoutForm from orders.forms import CheckoutForm
from orders.models import CartItem, Order from orders.models import CartItem, Order
from orders.services import ( from orders.services import (
CheckoutLine,
add_item_to_cart, add_item_to_cart,
calculate_checkout_totals, calculate_checkout_totals,
create_order_from_item,
create_order_from_cart, create_order_from_cart,
format_currency, format_currency,
get_user_cart_items, get_user_cart_items,
@@ -22,6 +24,9 @@ from orders.services import (
) )
GUEST_ORDER_SESSION_KEY = "guest_order_ids"
def resolve_next_url(request: HttpRequest, fallback: str) -> str: def resolve_next_url(request: HttpRequest, fallback: str) -> str:
next_url = request.POST.get("next") or fallback next_url = request.POST.get("next") or fallback
if not url_has_allowed_host_and_scheme( if not url_has_allowed_host_and_scheme(
@@ -33,18 +38,19 @@ def resolve_next_url(request: HttpRequest, fallback: str) -> str:
return next_url return next_url
@login_required def build_checkout_context(
def cart(request: HttpRequest) -> HttpResponse: checkout_items: list,
cart_items = get_user_cart_items(request.user) form: CheckoutForm,
total_quantity = sum(cart_item.quantity for cart_item in cart_items) *,
totals = calculate_checkout_totals(cart_items, Order.DeliveryMethod.COURIER) direct_checkout: bool = False,
form = CheckoutForm(initial={ direct_item: Item | None = None,
"customer_name": request.user.get_full_name() or request.user.get_username(), ) -> dict:
"email": request.user.email, total_quantity = sum(checkout_item.quantity for checkout_item in checkout_items)
}) delivery_method = form.data.get("delivery_method") if form.is_bound else Order.DeliveryMethod.COURIER
totals = calculate_checkout_totals(checkout_items, delivery_method or Order.DeliveryMethod.COURIER)
return render(request, "orders/cart.html", { return {
"cart_items": cart_items, "cart_items": checkout_items,
"checkout_form": form, "checkout_form": form,
"total_quantity": total_quantity, "total_quantity": total_quantity,
"products_total_value": totals["products_total"], "products_total_value": totals["products_total"],
@@ -53,7 +59,36 @@ def cart(request: HttpRequest) -> HttpResponse:
"delivery_total": format_currency(totals["delivery_total"]), "delivery_total": format_currency(totals["delivery_total"]),
"floor_lift_total": format_currency(totals["floor_lift_total"]), "floor_lift_total": format_currency(totals["floor_lift_total"]),
"total_amount": format_currency(totals["total"]), "total_amount": format_currency(totals["total"]),
}) "direct_checkout": direct_checkout,
"direct_item": direct_item,
}
def get_checkout_initial(user) -> dict:
if not getattr(user, "is_authenticated", False):
return {}
return {
"customer_name": user.get_full_name() or user.get_username(),
"email": user.email,
}
def remember_guest_order(request: HttpRequest, order: Order) -> None:
if request.user.is_authenticated:
return
guest_order_ids = request.session.get(GUEST_ORDER_SESSION_KEY, [])
guest_order_ids.append(order.id)
request.session[GUEST_ORDER_SESSION_KEY] = guest_order_ids
@login_required
def cart(request: HttpRequest) -> HttpResponse:
cart_items = get_user_cart_items(request.user)
form = CheckoutForm(initial=get_checkout_initial(request.user))
return render(request, "orders/cart.html", build_checkout_context(cart_items, form))
@login_required @login_required
@@ -66,20 +101,7 @@ def checkout(request: HttpRequest) -> HttpResponse:
form = CheckoutForm(request.POST) form = CheckoutForm(request.POST)
if not form.is_valid(): if not form.is_valid():
total_quantity = sum(cart_item.quantity for cart_item in cart_items) return render(request, "orders/cart.html", build_checkout_context(cart_items, form), status=400)
delivery_method = request.POST.get("delivery_method") or Order.DeliveryMethod.COURIER
totals = calculate_checkout_totals(cart_items, delivery_method)
return render(request, "orders/cart.html", {
"cart_items": cart_items,
"checkout_form": form,
"total_quantity": total_quantity,
"products_total_value": totals["products_total"],
"products_total": format_currency(totals["products_total"]),
"discount_total": format_currency(totals["discount_total"]),
"delivery_total": format_currency(totals["delivery_total"]),
"floor_lift_total": format_currency(totals["floor_lift_total"]),
"total_amount": format_currency(totals["total"]),
}, status=400)
try: try:
order = create_order_from_cart(request.user, form.cleaned_data) order = create_order_from_cart(request.user, form.cleaned_data)
@@ -97,16 +119,73 @@ def checkout(request: HttpRequest) -> HttpResponse:
return redirect("orders:success", order_id=order.id) return redirect("orders:success", order_id=order.id)
@login_required
def success(request: HttpRequest, order_id: int) -> HttpResponse: def success(request: HttpRequest, order_id: int) -> HttpResponse:
order = get_object_or_404( orders = Order.objects.prefetch_related("items")
Order.objects.prefetch_related("items"), if request.user.is_authenticated:
pk=order_id, order = get_object_or_404(orders, pk=order_id, user=request.user)
user=request.user, else:
) guest_order_ids = request.session.get(GUEST_ORDER_SESSION_KEY, [])
if order_id not in guest_order_ids:
raise Http404()
order = get_object_or_404(orders, pk=order_id, user__isnull=True)
return render(request, "orders/success.html", {"order": order}) return render(request, "orders/success.html", {"order": order})
@require_http_methods(["GET", "POST"])
def buy_now(request: HttpRequest, item_id: int) -> HttpResponse:
item = get_object_or_404(
Item.objects.select_related("category", "subcategory").prefetch_related("images"),
pk=item_id,
is_available=True,
)
checkout_items = [CheckoutLine(item=item)]
if request.method == "POST":
form = CheckoutForm(request.POST)
if not form.is_valid():
return render(
request,
"orders/cart.html",
build_checkout_context(
checkout_items,
form,
direct_checkout=True,
direct_item=item,
),
status=400,
)
try:
order = create_order_from_item(request.user, item, form.cleaned_data)
except ValidationError as error:
messages.error(request, error.message)
return redirect("detail", category=item.category.slug, slug=item.slug)
remember_guest_order(request, order)
try:
send_order_confirmation_email(order)
except Exception:
messages.error(request, "Заказ создан, но письмо с деталями не удалось отправить. Мы свяжемся с вами.")
else:
messages.success(request, "Заказ успешно оформлен. Детали отправлены на почту.")
return redirect("orders:success", order_id=order.id)
form = CheckoutForm(initial=get_checkout_initial(request.user))
return render(
request,
"orders/cart.html",
build_checkout_context(
checkout_items,
form,
direct_checkout=True,
direct_item=item,
),
)
@login_required @login_required
@require_POST @require_POST
def add(request: HttpRequest) -> HttpResponse: def add(request: HttpRequest) -> HttpResponse:

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -106,7 +106,13 @@
В корзину В корзину
</a> </a>
{% endif %} {% endif %}
<button class="product-summary__button product-summary__button--secondary" type="button">Купить</button> {% if item.is_available %}
<a class="product-summary__button product-summary__button--secondary product-summary__button--link" href="{% url 'orders:buy_now' item.id %}">
Купить
</a>
{% else %}
<button class="product-summary__button product-summary__button--secondary" type="button" disabled>Купить</button>
{% endif %}
</div> </div>
</section> </section>
</main> </main>

View File

@@ -1,7 +1,7 @@
{% extends "_layouts/base.html" %} {% extends "_layouts/base.html" %}
{% load static l10n %} {% load static l10n %}
{% block title %}Корзина{% endblock %} {% block title %}{% if direct_checkout %}Оформление заказа{% else %}Корзина{% endif %}{% endblock %}
{% block add_css %} {% block add_css %}
<link rel="stylesheet" href="{% static "css/cart.css" %}"> <link rel="stylesheet" href="{% static "css/cart.css" %}">
@@ -10,7 +10,11 @@
{% block content %} {% block content %}
{% include "_fragments/header.html" %} {% include "_fragments/header.html" %}
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/orders/cart/' text_2='Корзина' %} {% if direct_checkout %}
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2=request.path text_2='Оформление заказа' %}
{% else %}
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/orders/cart/' text_2='Корзина' %}
{% endif %}
<main class="container cart-page"> <main class="container cart-page">
{% if cart_items %} {% if cart_items %}
@@ -39,42 +43,50 @@
{% endif %} {% endif %}
<p class="cart-item__price">{{ item.price }} руб.</p> <p class="cart-item__price">{{ item.price }} руб.</p>
<div class="cart-item__controls"> {% if direct_checkout %}
<div class="cart-quantity" aria-label="Количество товара"> <div class="cart-item__controls">
<button <div class="cart-quantity" aria-label="Количество товара">
class="cart-quantity__button" <span class="cart-quantity__value">{{ cart_item.quantity }}</span>
type="button" </div>
data-cart-decrement </div>
data-remove-form-id="cart-remove-{{ cart_item.id }}" {% else %}
data-update-form-id="cart-decrement-{{ cart_item.id }}" <div class="cart-item__controls">
data-current-quantity="{{ cart_item.quantity }}" <div class="cart-quantity" aria-label="Количество товара">
aria-label="Уменьшить количество" <button
> class="cart-quantity__button"
- type="button"
</button> data-cart-decrement
<span class="cart-quantity__value">{{ cart_item.quantity }}</span> data-remove-form-id="cart-remove-{{ cart_item.id }}"
<form id="cart-increment-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}"> data-update-form-id="cart-decrement-{{ cart_item.id }}"
data-current-quantity="{{ cart_item.quantity }}"
aria-label="Уменьшить количество"
>
-
</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" aria-label="Увеличить количество">+</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>
<form id="cart-remove-{{ cart_item.id }}" method="post" action="{% url 'orders:remove' %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}"> <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" aria-label="Увеличить количество">+</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' %}"> <input type="hidden" name="next" value="{% url 'orders:cart' %}">
<button class="cart-item__remove" type="submit" aria-label="Удалить товар">Удалить</button>
</form> </form>
</div> </div>
{% endif %}
<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" aria-label="Удалить товар">Удалить</button>
</form>
</div>
</div> </div>
</article> </article>
{% endwith %} {% endwith %}
@@ -94,7 +106,7 @@
<form <form
class="checkout-form" class="checkout-form"
method="post" method="post"
action="{% url 'orders:checkout' %}" action="{% if direct_checkout %}{% url 'orders:buy_now' direct_item.id %}{% else %}{% url 'orders:checkout' %}{% endif %}"
data-checkout-form data-checkout-form
data-products-total="{{ products_total_value|unlocalize }}" data-products-total="{{ products_total_value|unlocalize }}"
> >