Cart rendering, bug fixes with cart adding/rming
This commit is contained in:
@@ -16,4 +16,4 @@ POSTGRES_URL = os.getenv(
|
||||
|
||||
### Constants ###
|
||||
|
||||
PAGE_SIZE = 6
|
||||
PAGE_SIZE = 4
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import ForeignKey, BigInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import ENUM
|
||||
from db.base import Base
|
||||
from db.models.products import Product
|
||||
|
||||
|
||||
class OrderStatus(enum.Enum):
|
||||
@@ -33,3 +34,4 @@ class OrderItem(Base):
|
||||
quantity: Mapped[int] = mapped_column(default=1)
|
||||
|
||||
order: Mapped["Order"] = relationship("Order", back_populates="items")
|
||||
product: Mapped["Product"] = relationship("Product")
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Optional
|
||||
from sqlalchemy import ForeignKey, Text, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
|
||||
from db.base import Base
|
||||
|
||||
|
||||
|
||||
3
dto/__init__.py
Normal file
3
dto/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cart import CartItemDTO
|
||||
|
||||
__all__ = ["CartItemDTO"]
|
||||
19
dto/cart.py
Normal file
19
dto/cart.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CartItemDTO:
|
||||
product_id: int
|
||||
name: str
|
||||
quantity: int
|
||||
price: int
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return self.price * self.quantity
|
||||
|
||||
|
||||
@dataclass
|
||||
class CartDTO:
|
||||
items: list[CartItemDTO]
|
||||
total: int
|
||||
@@ -10,6 +10,7 @@ from config import PAGE_SIZE
|
||||
from misc.kb import main_menu_kb, render_category
|
||||
from misc.kb.client import (
|
||||
get_back_to_catalogue,
|
||||
render_cart,
|
||||
render_product_interactions,
|
||||
render_products,
|
||||
)
|
||||
@@ -265,6 +266,9 @@ async def add_to_cart(
|
||||
else:
|
||||
show_cart = False
|
||||
quantity = 0
|
||||
await order_service.clear_cart_item(
|
||||
session, customer=cb.from_user.id, product_id=product_id
|
||||
)
|
||||
|
||||
try:
|
||||
await cb.message.edit_reply_markup(
|
||||
@@ -275,6 +279,69 @@ async def add_to_cart(
|
||||
cart_amount=quantity,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
await cb.answer("🍃 Корзина уже пуста.")
|
||||
return
|
||||
except Exception as e:
|
||||
await cb.answer("🍃")
|
||||
raise e
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cart")
|
||||
async def cart_init(
|
||||
cb: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
order_service: OrderService,
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
cart = await order_service.build_cart_dto(session, customer=cb.from_user.id)
|
||||
|
||||
await cb.message.edit_text(
|
||||
f"total: {cart.total}₽",
|
||||
reply_markup=render_cart(
|
||||
cart.items[:PAGE_SIZE], show_next=len(cart.items) > PAGE_SIZE
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("cart"))
|
||||
async def cart_cmd(
|
||||
msg: Message,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
order_service: OrderService,
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
cart = await order_service.build_cart_dto(session, customer=msg.from_user.id)
|
||||
|
||||
await msg.answer(
|
||||
f"total: {cart.total}₽",
|
||||
reply_markup=render_cart(
|
||||
cart.items[:PAGE_SIZE], show_next=len(cart.items) > PAGE_SIZE
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cart:"))
|
||||
async def cart_pagination(
|
||||
cb: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
order_service: OrderService,
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
page = int(cb.data.split(":")[1])
|
||||
|
||||
cart = await order_service.build_cart_dto(
|
||||
session, customer=cb.from_user.id, offset=page * PAGE_SIZE
|
||||
)
|
||||
|
||||
await cb.message.edit_reply_markup(
|
||||
reply_markup=render_cart(
|
||||
cart.items[:PAGE_SIZE],
|
||||
page=page,
|
||||
show_next=len(cart.items) > PAGE_SIZE,
|
||||
show_prev=page > 0,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,14 +3,31 @@ from typing import Optional
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from db.models import Category
|
||||
from db.models.products import Product
|
||||
from db.models import Category, Product
|
||||
from dto.cart import CartItemDTO
|
||||
|
||||
|
||||
def pagination_row(
|
||||
*,
|
||||
page: int,
|
||||
prev_cb: Optional[str] = None,
|
||||
next_cb: Optional[str] = None,
|
||||
) -> list[InlineKeyboardButton]:
|
||||
row = []
|
||||
|
||||
if prev_cb:
|
||||
row.append(InlineKeyboardButton(text="⬅️", callback_data=prev_cb))
|
||||
|
||||
row.append(InlineKeyboardButton(text=str(page + 1), callback_data="..."))
|
||||
|
||||
if next_cb:
|
||||
row.append(InlineKeyboardButton(text="➡️", callback_data=next_cb))
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def cart_btn(cart_items: int) -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(
|
||||
text=f"🛒 Корзина ({cart_items})", callback_data="cart"
|
||||
)
|
||||
return InlineKeyboardButton(text=f"🛒 Корзина ({cart_items})", callback_data="cart")
|
||||
|
||||
|
||||
def get_back_to_catalogue(
|
||||
@@ -75,22 +92,10 @@ def render_products(
|
||||
for product in products
|
||||
]
|
||||
|
||||
nav = []
|
||||
|
||||
if show_prev:
|
||||
nav.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️", callback_data=f"products:{cat_id}:{page - 1}"
|
||||
)
|
||||
)
|
||||
|
||||
nav.append(InlineKeyboardButton(text=str(page), callback_data="..."))
|
||||
|
||||
if show_next:
|
||||
nav.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️", callback_data=f"products:{cat_id}:{page + 1}"
|
||||
)
|
||||
nav = pagination_row(
|
||||
page=page,
|
||||
prev_cb=f"products:{cat_id}:{page - 1}" if show_prev else None,
|
||||
next_cb=f"products:{cat_id}:{page + 1}" if show_next else None,
|
||||
)
|
||||
|
||||
return InlineKeyboardBuilder(
|
||||
@@ -145,3 +150,38 @@ def render_product_interactions(
|
||||
btns.append([InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}")])
|
||||
|
||||
return InlineKeyboardBuilder(btns).as_markup()
|
||||
|
||||
|
||||
def render_cart(
|
||||
cart_items: list[CartItemDTO],
|
||||
*,
|
||||
page: int = 0,
|
||||
show_prev: Optional[bool] = False,
|
||||
show_next: Optional[bool] = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
btns = [
|
||||
InlineKeyboardButton(
|
||||
text=f"{cart_item.quantity} | {cart_item.name} | {cart_item.total}₽",
|
||||
callback_data=f"product:{cart_item.product_id}",
|
||||
)
|
||||
for cart_item in cart_items
|
||||
]
|
||||
|
||||
nav = pagination_row(
|
||||
page=page,
|
||||
prev_cb=f"cart:{page - 1}" if show_prev else None,
|
||||
next_cb=f"cart:{page + 1}" if show_next else None,
|
||||
)
|
||||
|
||||
return InlineKeyboardBuilder(
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="menu:main",
|
||||
)
|
||||
]
|
||||
]
|
||||
+ [btns[i : i + 2] for i in range(0, len(cart_items), 2)]
|
||||
+ [nav]
|
||||
).as_markup()
|
||||
|
||||
@@ -6,3 +6,6 @@ skip-string-normalization = false
|
||||
skip-magic-trailing-comma = false
|
||||
|
||||
preview = false
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore=["F405", "F403"]
|
||||
@@ -1,8 +1,8 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from typing import Optional
|
||||
|
||||
from db.models.orders import Order, OrderItem
|
||||
from db.models.orders import OrderItem
|
||||
|
||||
|
||||
class OrderItemRepository:
|
||||
@@ -13,13 +13,22 @@ class OrderItemRepository:
|
||||
return await session.scalar(stmt)
|
||||
|
||||
async def get_items_by_order(
|
||||
self, session: AsyncSession, order_id: int
|
||||
self, session: AsyncSession, *, order_id: int, limit: int, offset: int = 0
|
||||
) -> list[OrderItem]:
|
||||
stmt = select(OrderItem).where(OrderItem.order_id == order_id)
|
||||
stmt = (
|
||||
select(OrderItem)
|
||||
.where(OrderItem.order_id == order_id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await session.scalars(stmt)
|
||||
|
||||
return list(result)
|
||||
|
||||
async def get_items_count(self, session: AsyncSession, *, order_id: int) -> int:
|
||||
stmt = select(func.count(OrderItem.id)).where(OrderItem.order_id == order_id)
|
||||
return await session.scalar(stmt) or 0
|
||||
|
||||
async def get_item_by_order_and_product(
|
||||
self, session: AsyncSession, *, order_id: int, product_id: int
|
||||
) -> Optional[OrderItem]:
|
||||
@@ -58,3 +67,9 @@ class OrderItemRepository:
|
||||
await session.commit()
|
||||
|
||||
return order_item
|
||||
|
||||
async def delete_item(self, session: AsyncSession, item_id: int):
|
||||
stmt = delete(OrderItem.__table__).where(OrderItem.id == item_id)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
@@ -2,7 +2,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
|
||||
from db.models import Order, OrderStatus
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Order, OrderStatus, OrderItem
|
||||
|
||||
|
||||
class OrderRepository:
|
||||
@@ -28,6 +30,7 @@ class OrderRepository:
|
||||
.where(Order.customer == customer)
|
||||
.where(Order.status == OrderStatus.DRAFT)
|
||||
.limit(1)
|
||||
.options(selectinload(Order.items).selectinload(OrderItem.product))
|
||||
)
|
||||
return await session.scalar(stmt)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import PAGE_SIZE
|
||||
from db.models.orders import OrderStatus
|
||||
from dto.cart import CartDTO, CartItemDTO
|
||||
from repositories.order_items import OrderItemRepository
|
||||
from repositories.orders import OrderRepository
|
||||
|
||||
@@ -44,6 +46,7 @@ class OrderService:
|
||||
order_item = await self.order_items_repo.create_order_item(
|
||||
session, order_id=order.id, product_id=product_id, quantity=quantity
|
||||
)
|
||||
return quantity
|
||||
else:
|
||||
await self.order_items_repo.update_order_item_quantity(
|
||||
session, order_item.id, quantity=order_item.quantity + quantity
|
||||
@@ -75,13 +78,37 @@ class OrderService:
|
||||
"attempt of decreasing quantity of non-existing order_item, potential callback_query exploit, ignoring..."
|
||||
)
|
||||
|
||||
new_quantity = max(0, order_item.quantity - quantity)
|
||||
new_quantity = order_item.quantity - quantity
|
||||
if new_quantity > 0:
|
||||
await self.order_items_repo.update_order_item_quantity(
|
||||
session, order_item_id=order_item.id, quantity=new_quantity
|
||||
)
|
||||
else:
|
||||
await self.order_items_repo.delete_item(session, order_item.id)
|
||||
|
||||
return new_quantity
|
||||
|
||||
async def clear_from_cart(
|
||||
self, session: AsyncSession, *, customer: int, product_id: int
|
||||
):
|
||||
order = await self.order_repo.get_draft_order_by_user(session, customer)
|
||||
|
||||
if not order:
|
||||
raise OrderServiceException(
|
||||
"attempt of removing product from non-existing cart, potential callback_query exploit, ignoring..."
|
||||
)
|
||||
|
||||
order_item = await self.order_items_repo.get_item_by_order_and_product(
|
||||
session, order_id=order.id, product_id=product_id
|
||||
)
|
||||
|
||||
if not order_item:
|
||||
raise OrderServiceException(
|
||||
"attempt of decreasing quantity of non-existing order_item, potential callback_query exploit, ignoring..."
|
||||
)
|
||||
|
||||
await self.order_items_repo.delete_item(session, item_id=order_item.id)
|
||||
|
||||
async def get_cart_product_amount(
|
||||
self, session: AsyncSession, *, customer: int, product_id: int
|
||||
) -> int:
|
||||
@@ -127,7 +154,40 @@ class OrderService:
|
||||
if not order:
|
||||
return 0
|
||||
|
||||
order_item = await self.order_items_repo.get_items_by_order(
|
||||
session, order_id=order.id
|
||||
return await self.order_items_repo.get_items_count(session, order_id=order.id)
|
||||
|
||||
async def build_cart_dto(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
customer: int,
|
||||
limit: int = PAGE_SIZE + 1,
|
||||
offset: int = 0,
|
||||
) -> CartDTO:
|
||||
order = await self.order_repo.get_draft_order_by_user(session, customer)
|
||||
cart_items = []
|
||||
|
||||
if not order:
|
||||
return CartDTO(items=cart_items, total=0)
|
||||
|
||||
order_items = await self.order_items_repo.get_items_by_order(
|
||||
session, order_id=order.id, limit=limit, offset=offset
|
||||
)
|
||||
return len(order_item)
|
||||
|
||||
total = 0
|
||||
|
||||
for item in order_items:
|
||||
if item.quantity <= 0:
|
||||
await self.order_items_repo.delete_item(session, item_id=item.id)
|
||||
continue
|
||||
|
||||
cart_item = CartItemDTO(
|
||||
product_id=item.product_id,
|
||||
name=item.product.name,
|
||||
quantity=item.quantity,
|
||||
price=item.product.price,
|
||||
)
|
||||
cart_items.append(cart_item)
|
||||
total += cart_item.total
|
||||
|
||||
return CartDTO(items=cart_items, total=total)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
set -e
|
||||
|
||||
echo "formatting..."
|
||||
ruff check . --fix
|
||||
black .
|
||||
|
||||
echo "checking psql..."
|
||||
|
||||
Reference in New Issue
Block a user