feat: implement order management and checkout functionality
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
- Added `Order` and `OrderItem` models with migrations for managing orders and their details - Created `CheckoutForm` for validating order data and customer information - Built templates for order confirmation, checkout success, and emails (HTML and plain text) - Added logic for email notifications upon successful order placement - Enhanced shopping cart template with checkout panel integration - Updated styles and JavaScript for streamlined checkout UX - Refactored cart view to support item summaries and final totals during checkout
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from orders.models import Cart, CartItem
|
||||
from orders.models import Cart, CartItem, Order, OrderItem
|
||||
|
||||
|
||||
class CartItemInline(admin.TabularInline):
|
||||
@@ -20,3 +20,44 @@ 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")
|
||||
|
||||
|
||||
class OrderItemInline(admin.TabularInline):
|
||||
model = OrderItem
|
||||
extra = 0
|
||||
readonly_fields = ("item", "item_name", "item_slug", "item_price", "quantity", "line_total")
|
||||
can_delete = False
|
||||
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "customer_name", "email", "phone", "status", "total", "created_at")
|
||||
list_filter = ("status", "delivery_method", "payment_method", "created_at")
|
||||
list_select_related = ("user",)
|
||||
search_fields = ("id", "customer_name", "email", "phone", "user__username", "user__email")
|
||||
readonly_fields = (
|
||||
"user",
|
||||
"customer_name",
|
||||
"phone",
|
||||
"email",
|
||||
"delivery_method",
|
||||
"region",
|
||||
"address",
|
||||
"payment_method",
|
||||
"promo_code",
|
||||
"products_total",
|
||||
"discount_total",
|
||||
"delivery_total",
|
||||
"floor_lift_total",
|
||||
"total",
|
||||
"status",
|
||||
"created_at",
|
||||
)
|
||||
inlines = [OrderItemInline]
|
||||
|
||||
|
||||
@admin.register(OrderItem)
|
||||
class OrderItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("item_name", "order", "quantity", "item_price", "line_total")
|
||||
list_select_related = ("order", "item")
|
||||
search_fields = ("item_name", "order__customer_name", "order__email")
|
||||
|
||||
56
orders/forms.py
Normal file
56
orders/forms.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from django import forms
|
||||
|
||||
from orders.models import Order
|
||||
|
||||
|
||||
class CheckoutForm(forms.Form):
|
||||
delivery_method = forms.ChoiceField(
|
||||
choices=Order.DeliveryMethod.choices,
|
||||
initial=Order.DeliveryMethod.COURIER,
|
||||
widget=forms.RadioSelect,
|
||||
label="Способ доставки",
|
||||
)
|
||||
region = forms.CharField(max_length=255, required=False, label="Регион")
|
||||
address = forms.CharField(max_length=512, required=False, label="Адрес")
|
||||
payment_method = forms.ChoiceField(
|
||||
choices=Order.PaymentMethod.choices,
|
||||
initial=Order.PaymentMethod.CARD,
|
||||
widget=forms.RadioSelect,
|
||||
label="Способ оплаты",
|
||||
)
|
||||
customer_name = forms.CharField(max_length=255, label="ФИО")
|
||||
phone = forms.CharField(max_length=32, label="Телефон")
|
||||
email = forms.EmailField(label="E-mail")
|
||||
recipient_matches_customer = forms.BooleanField(required=False, initial=True)
|
||||
promo_code = forms.CharField(max_length=64, required=False, label="Промокод")
|
||||
terms_accepted = forms.BooleanField(required=True, label="Согласие с условиями")
|
||||
|
||||
def clean_email(self):
|
||||
return self.cleaned_data["email"].strip().lower()
|
||||
|
||||
def clean_promo_code(self):
|
||||
return self.cleaned_data["promo_code"].strip()
|
||||
|
||||
def clean_customer_name(self):
|
||||
return self.cleaned_data["customer_name"].strip()
|
||||
|
||||
def clean_phone(self):
|
||||
return self.cleaned_data["phone"].strip()
|
||||
|
||||
def clean_region(self):
|
||||
return self.cleaned_data["region"].strip()
|
||||
|
||||
def clean_address(self):
|
||||
return self.cleaned_data["address"].strip()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
delivery_method = cleaned_data.get("delivery_method")
|
||||
|
||||
if delivery_method == Order.DeliveryMethod.COURIER:
|
||||
if not cleaned_data.get("region"):
|
||||
self.add_error("region", "Укажите регион доставки.")
|
||||
if not cleaned_data.get("address"):
|
||||
self.add_error("address", "Укажите адрес доставки.")
|
||||
|
||||
return cleaned_data
|
||||
63
orders/migrations/0002_order_orderitem.py
Normal file
63
orders/migrations/0002_order_orderitem.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-21 11:58
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('items', '0003_subcategory_item_subcategory_and_more'),
|
||||
('orders', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('customer_name', models.CharField(max_length=255, verbose_name='ФИО')),
|
||||
('phone', models.CharField(max_length=32, verbose_name='Телефон')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='E-mail')),
|
||||
('delivery_method', models.CharField(choices=[('courier', 'Курьерская доставка'), ('pickup', 'Самовывоз')], default='courier', max_length=16, verbose_name='Способ доставки')),
|
||||
('region', models.CharField(blank=True, max_length=255, verbose_name='Регион')),
|
||||
('address', models.CharField(blank=True, max_length=512, verbose_name='Адрес')),
|
||||
('payment_method', models.CharField(choices=[('card', 'Банковская карта'), ('credit', 'Кредит'), ('installment', 'Рассрочка')], default='card', max_length=16, verbose_name='Способ оплаты')),
|
||||
('promo_code', models.CharField(blank=True, max_length=64, verbose_name='Промокод')),
|
||||
('products_total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма товаров')),
|
||||
('discount_total', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Скидка')),
|
||||
('delivery_total', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Доставка')),
|
||||
('floor_lift_total', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Подъём на этаж')),
|
||||
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Итого к оплате')),
|
||||
('status', models.CharField(choices=[('paid', 'Оплачен')], default='paid', max_length=16, verbose_name='Статус')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заказ',
|
||||
'verbose_name_plural': 'Заказы',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('item_name', models.CharField(max_length=255, verbose_name='Название товара')),
|
||||
('item_slug', models.SlugField(blank=True, verbose_name='URL товара')),
|
||||
('item_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Цена товара')),
|
||||
('quantity', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)], verbose_name='Количество')),
|
||||
('line_total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма строки')),
|
||||
('item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_entries', to='items.item', verbose_name='Товар')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Позиция заказа',
|
||||
'verbose_name_plural': 'Позиции заказа',
|
||||
'ordering': ('id',),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -57,3 +57,92 @@ class CartItem(models.Model):
|
||||
@property
|
||||
def subtotal(self):
|
||||
return self.item.price * self.quantity
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
class DeliveryMethod(models.TextChoices):
|
||||
COURIER = "courier", "Курьерская доставка"
|
||||
PICKUP = "pickup", "Самовывоз"
|
||||
|
||||
class PaymentMethod(models.TextChoices):
|
||||
CARD = "card", "Банковская карта"
|
||||
CREDIT = "credit", "Кредит"
|
||||
INSTALLMENT = "installment", "Рассрочка"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PAID = "paid", "Оплачен"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="orders",
|
||||
verbose_name="Пользователь",
|
||||
)
|
||||
customer_name = models.CharField(max_length=255, verbose_name="ФИО")
|
||||
phone = models.CharField(max_length=32, verbose_name="Телефон")
|
||||
email = models.EmailField(verbose_name="E-mail")
|
||||
delivery_method = models.CharField(
|
||||
max_length=16,
|
||||
choices=DeliveryMethod.choices,
|
||||
default=DeliveryMethod.COURIER,
|
||||
verbose_name="Способ доставки",
|
||||
)
|
||||
region = models.CharField(max_length=255, blank=True, verbose_name="Регион")
|
||||
address = models.CharField(max_length=512, blank=True, verbose_name="Адрес")
|
||||
payment_method = models.CharField(
|
||||
max_length=16,
|
||||
choices=PaymentMethod.choices,
|
||||
default=PaymentMethod.CARD,
|
||||
verbose_name="Способ оплаты",
|
||||
)
|
||||
promo_code = models.CharField(max_length=64, blank=True, verbose_name="Промокод")
|
||||
products_total = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Сумма товаров")
|
||||
discount_total = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Скидка")
|
||||
delivery_total = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Доставка")
|
||||
floor_lift_total = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Подъём на этаж")
|
||||
total = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Итого к оплате")
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=Status.choices,
|
||||
default=Status.PAID,
|
||||
verbose_name="Статус",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at", "-id")
|
||||
verbose_name = "Заказ"
|
||||
verbose_name_plural = "Заказы"
|
||||
|
||||
def __str__(self):
|
||||
return f"Заказ №{self.pk}"
|
||||
|
||||
|
||||
class OrderItem(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="items",
|
||||
verbose_name="Заказ",
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="order_entries",
|
||||
verbose_name="Товар",
|
||||
)
|
||||
item_name = models.CharField(max_length=255, verbose_name="Название товара")
|
||||
item_slug = models.SlugField(blank=True, verbose_name="URL товара")
|
||||
item_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена товара")
|
||||
quantity = models.PositiveIntegerField(validators=[MinValueValidator(1)], verbose_name="Количество")
|
||||
line_total = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Сумма строки")
|
||||
|
||||
class Meta:
|
||||
ordering = ("id",)
|
||||
verbose_name = "Позиция заказа"
|
||||
verbose_name_plural = "Позиции заказа"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.item_name} x {self.quantity}"
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.db import transaction
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import formats
|
||||
|
||||
from items.models import Item
|
||||
from orders.models import Cart, CartItem
|
||||
from orders.models import Cart, CartItem, Order, OrderItem
|
||||
|
||||
|
||||
DELIVERY_PRICE = Decimal("500.00")
|
||||
FLOOR_LIFT_PRICE = Decimal("45.00")
|
||||
ZERO_MONEY = Decimal("0.00")
|
||||
|
||||
|
||||
def format_currency(value: Decimal) -> str:
|
||||
return f"{formats.number_format(value, 2)} руб."
|
||||
|
||||
|
||||
def calculate_checkout_totals(cart_items: list[CartItem], delivery_method: str) -> dict[str, Decimal]:
|
||||
products_total = sum(
|
||||
(cart_item.item.price * cart_item.quantity for cart_item in cart_items),
|
||||
start=ZERO_MONEY,
|
||||
)
|
||||
discount_total = ZERO_MONEY
|
||||
delivery_total = DELIVERY_PRICE if delivery_method == Order.DeliveryMethod.COURIER else ZERO_MONEY
|
||||
floor_lift_total = FLOOR_LIFT_PRICE if delivery_method == Order.DeliveryMethod.COURIER else ZERO_MONEY
|
||||
total = products_total - discount_total + delivery_total + floor_lift_total
|
||||
|
||||
return {
|
||||
"products_total": products_total,
|
||||
"discount_total": discount_total,
|
||||
"delivery_total": delivery_total,
|
||||
"floor_lift_total": floor_lift_total,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def get_or_create_cart(user: Any) -> Cart:
|
||||
cart, _ = Cart.objects.get_or_create(user=user)
|
||||
return cart
|
||||
@@ -38,6 +66,16 @@ def get_user_cart(user: Any) -> Cart | None:
|
||||
)
|
||||
|
||||
|
||||
def get_user_cart_items(user: Any) -> list[CartItem]:
|
||||
cart = get_user_cart(user)
|
||||
if not cart:
|
||||
return []
|
||||
|
||||
return list(
|
||||
cart.items.select_related("item__category", "item__subcategory").prefetch_related("item__images")
|
||||
)
|
||||
|
||||
|
||||
def add_item_to_cart(user: Any, item: Item, quantity: int = 1) -> tuple[CartItem, bool]:
|
||||
if quantity < 1:
|
||||
raise ValidationError("Количество товара должно быть не меньше 1.")
|
||||
@@ -67,3 +105,75 @@ def update_cart_item_quantity(user: Any, item_id: int, quantity: int) -> CartIte
|
||||
cart_item.quantity = quantity
|
||||
cart_item.save(update_fields=["quantity", "updated_at"])
|
||||
return cart_item
|
||||
|
||||
|
||||
@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("Корзина пуста.")
|
||||
|
||||
totals = calculate_checkout_totals(cart_items, cleaned_data["delivery_method"])
|
||||
order = Order.objects.create(
|
||||
user=user,
|
||||
customer_name=cleaned_data["customer_name"],
|
||||
phone=cleaned_data["phone"],
|
||||
email=cleaned_data["email"],
|
||||
delivery_method=cleaned_data["delivery_method"],
|
||||
region=cleaned_data.get("region", ""),
|
||||
address=cleaned_data.get("address", ""),
|
||||
payment_method=cleaned_data["payment_method"],
|
||||
promo_code=cleaned_data.get("promo_code", ""),
|
||||
products_total=totals["products_total"],
|
||||
discount_total=totals["discount_total"],
|
||||
delivery_total=totals["delivery_total"],
|
||||
floor_lift_total=totals["floor_lift_total"],
|
||||
total=totals["total"],
|
||||
status=Order.Status.PAID,
|
||||
)
|
||||
|
||||
OrderItem.objects.bulk_create(
|
||||
[
|
||||
OrderItem(
|
||||
order=order,
|
||||
item=cart_item.item,
|
||||
item_name=cart_item.item.name,
|
||||
item_slug=cart_item.item.slug,
|
||||
item_price=cart_item.item.price,
|
||||
quantity=cart_item.quantity,
|
||||
line_total=cart_item.item.price * cart_item.quantity,
|
||||
)
|
||||
for cart_item in cart_items
|
||||
]
|
||||
)
|
||||
cart.items.all().delete()
|
||||
return order
|
||||
|
||||
|
||||
def send_order_confirmation_email(order: Order) -> None:
|
||||
order = (
|
||||
Order.objects.select_related("user")
|
||||
.prefetch_related("items")
|
||||
.get(pk=order.pk)
|
||||
)
|
||||
context = {
|
||||
"order": order,
|
||||
"site_name": getattr(settings, "SITE_NAME", "SantehRay"),
|
||||
}
|
||||
subject = render_to_string("orders/emails/order_confirmation_subject.txt", context).strip()
|
||||
text_body = render_to_string("orders/emails/order_confirmation_email.txt", context)
|
||||
html_body = render_to_string("orders/emails/order_confirmation_email.html", context)
|
||||
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[order.email],
|
||||
bcc=[settings.ADMINISTRATOR_EMAIL],
|
||||
)
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
message.send()
|
||||
|
||||
125
orders/tests.py
125
orders/tests.py
@@ -1,16 +1,21 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from items.models import Category, Item, ItemImage, Subcategory
|
||||
from orders.models import Cart, CartItem
|
||||
from orders.models import Cart, CartItem, Order, OrderItem
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@override_settings(MEDIA_URL="/media/")
|
||||
@override_settings(
|
||||
MEDIA_URL="/media/",
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
ADMINISTRATOR_EMAIL="admin@example.com",
|
||||
)
|
||||
class CartFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
@@ -40,6 +45,22 @@ class CartFlowTests(TestCase):
|
||||
is_available=False,
|
||||
)
|
||||
|
||||
def checkout_data(self, **overrides):
|
||||
data = {
|
||||
"delivery_method": "courier",
|
||||
"region": "Минск",
|
||||
"address": "ул. Тимирязева, 123",
|
||||
"payment_method": "card",
|
||||
"customer_name": "Иван Петров",
|
||||
"phone": "+375296881819",
|
||||
"email": "buyer@example.com",
|
||||
"recipient_matches_customer": "on",
|
||||
"promo_code": "SPRING",
|
||||
"terms_accepted": "on",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
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)
|
||||
@@ -99,8 +120,10 @@ class CartFlowTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Кухонная мойка")
|
||||
self.assertContains(response, "Оформление заказа")
|
||||
self.assertContains(response, "1600,00 руб.")
|
||||
self.assertContains(response, "3200,00 руб.")
|
||||
self.assertContains(response, "3745,00 руб.")
|
||||
self.assertContains(response, "/media/items/cart-item.jpg")
|
||||
|
||||
def test_cart_page_redirects_guest_to_login(self):
|
||||
@@ -108,3 +131,101 @@ class CartFlowTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn(reverse("users:login"), response["Location"])
|
||||
|
||||
def test_checkout_creates_paid_order_clears_cart_and_sends_email(self):
|
||||
cart = Cart.objects.create(user=self.user)
|
||||
CartItem.objects.create(cart=cart, item=self.item, quantity=2)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(reverse("orders:checkout"), data=self.checkout_data())
|
||||
|
||||
order = Order.objects.get()
|
||||
self.assertRedirects(response, reverse("orders:success", kwargs={"order_id": order.id}))
|
||||
self.assertEqual(order.status, Order.Status.PAID)
|
||||
self.assertEqual(order.products_total, 3200)
|
||||
self.assertEqual(order.discount_total, 0)
|
||||
self.assertEqual(order.delivery_total, 500)
|
||||
self.assertEqual(order.floor_lift_total, 45)
|
||||
self.assertEqual(order.total, 3745)
|
||||
self.assertEqual(order.promo_code, "SPRING")
|
||||
self.assertFalse(CartItem.objects.filter(cart=cart).exists())
|
||||
|
||||
order_item = OrderItem.objects.get(order=order)
|
||||
self.assertEqual(order_item.item_name, "Кухонная мойка")
|
||||
self.assertEqual(order_item.quantity, 2)
|
||||
self.assertEqual(order_item.line_total, 3200)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].to, ["buyer@example.com"])
|
||||
self.assertEqual(mail.outbox[0].bcc, ["admin@example.com"])
|
||||
self.assertIn(f"заказ №{order.id}", mail.outbox[0].subject.lower())
|
||||
self.assertIn("Кухонная мойка", mail.outbox[0].body)
|
||||
|
||||
def test_checkout_rejects_empty_cart(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(reverse("orders:checkout"), data=self.checkout_data())
|
||||
|
||||
self.assertRedirects(response, reverse("orders:cart"))
|
||||
self.assertFalse(Order.objects.exists())
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_checkout_requires_address_for_courier_delivery(self):
|
||||
cart = Cart.objects.create(user=self.user)
|
||||
CartItem.objects.create(cart=cart, item=self.item, quantity=1)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("orders:checkout"),
|
||||
data=self.checkout_data(region="", address=""),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertContains(response, "Укажите регион доставки.", status_code=400)
|
||||
self.assertContains(response, "Укажите адрес доставки.", status_code=400)
|
||||
self.assertFalse(Order.objects.exists())
|
||||
|
||||
def test_checkout_pickup_does_not_require_address_or_add_services(self):
|
||||
cart = Cart.objects.create(user=self.user)
|
||||
CartItem.objects.create(cart=cart, item=self.item, quantity=1)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("orders:checkout"),
|
||||
data=self.checkout_data(delivery_method="pickup", region="", address=""),
|
||||
)
|
||||
|
||||
order = Order.objects.get()
|
||||
self.assertRedirects(response, reverse("orders:success", kwargs={"order_id": order.id}))
|
||||
self.assertEqual(order.delivery_total, 0)
|
||||
self.assertEqual(order.floor_lift_total, 0)
|
||||
self.assertEqual(order.total, 1600)
|
||||
|
||||
def test_success_page_is_only_available_to_order_owner(self):
|
||||
other_user = User.objects.create_user(
|
||||
username="other-user",
|
||||
email="other@example.com",
|
||||
password="StrongPassword123",
|
||||
is_active=True,
|
||||
)
|
||||
order = Order.objects.create(
|
||||
user=self.user,
|
||||
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=500,
|
||||
floor_lift_total=45,
|
||||
total=2145,
|
||||
status=Order.Status.PAID,
|
||||
)
|
||||
self.client.force_login(other_user)
|
||||
|
||||
response = self.client.get(reverse("orders:success", kwargs={"order_id": order.id}))
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -10,4 +10,6 @@ urlpatterns = [
|
||||
path("cart/add/", views.add, name="add"),
|
||||
path("cart/remove/", views.remove, name="remove"),
|
||||
path("cart/update-quantity/", views.update_quantity, name="update_quantity"),
|
||||
path("checkout/", views.checkout, name="checkout"),
|
||||
path("success/<int:order_id>/", views.success, name="success"),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -10,12 +8,16 @@ 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.forms import CheckoutForm
|
||||
from orders.models import CartItem, Order
|
||||
from orders.services import (
|
||||
add_item_to_cart,
|
||||
calculate_checkout_totals,
|
||||
create_order_from_cart,
|
||||
format_currency,
|
||||
get_user_cart,
|
||||
get_user_cart_items,
|
||||
remove_item_from_cart,
|
||||
send_order_confirmation_email,
|
||||
update_cart_item_quantity,
|
||||
)
|
||||
|
||||
@@ -33,29 +35,78 @@ def resolve_next_url(request: HttpRequest, fallback: str) -> str:
|
||||
|
||||
@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)
|
||||
cart_items = get_user_cart_items(request.user)
|
||||
total_quantity = sum(cart_item.quantity for cart_item in cart_items)
|
||||
totals = calculate_checkout_totals(cart_items, Order.DeliveryMethod.COURIER)
|
||||
form = CheckoutForm(initial={
|
||||
"customer_name": request.user.get_full_name() or request.user.get_username(),
|
||||
"email": request.user.email,
|
||||
})
|
||||
|
||||
return render(request, "orders/cart.html", {
|
||||
"cart_items": cart_items,
|
||||
"checkout_form": form,
|
||||
"total_quantity": total_quantity,
|
||||
"total_amount": total_amount,
|
||||
"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"]),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def checkout(request: HttpRequest) -> HttpResponse:
|
||||
cart_items = get_user_cart_items(request.user)
|
||||
if not cart_items:
|
||||
messages.error(request, "Корзина пуста. Добавьте товары перед оформлением заказа.")
|
||||
return redirect("orders:cart")
|
||||
|
||||
form = CheckoutForm(request.POST)
|
||||
if not form.is_valid():
|
||||
total_quantity = sum(cart_item.quantity for cart_item in cart_items)
|
||||
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:
|
||||
order = create_order_from_cart(request.user, form.cleaned_data)
|
||||
except ValidationError as error:
|
||||
messages.error(request, error.message)
|
||||
return redirect("orders:cart")
|
||||
|
||||
try:
|
||||
send_order_confirmation_email(order)
|
||||
except Exception:
|
||||
messages.error(request, "Заказ создан, но письмо с деталями не удалось отправить. Мы свяжемся с вами.")
|
||||
else:
|
||||
messages.success(request, "Оплата прошла успешно. Детали заказа отправлены на почту.")
|
||||
|
||||
return redirect("orders:success", order_id=order.id)
|
||||
|
||||
|
||||
@login_required
|
||||
def success(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||
order = get_object_or_404(
|
||||
Order.objects.prefetch_related("items"),
|
||||
pk=order_id,
|
||||
user=request.user,
|
||||
)
|
||||
return render(request, "orders/success.html", {"order": order})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def add(request: HttpRequest) -> HttpResponse:
|
||||
|
||||
Reference in New Issue
Block a user