Cart rendering, bug fixes with cart adding/rming

This commit is contained in:
2026-02-08 19:15:48 +07:00
parent e8a61c05b3
commit 6409d66dfd
12 changed files with 252 additions and 38 deletions

View File

@@ -16,4 +16,4 @@ POSTGRES_URL = os.getenv(
### Constants ###
PAGE_SIZE = 6
PAGE_SIZE = 4

View File

@@ -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")

View File

@@ -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
View File

@@ -0,0 +1,3 @@
from .cart import CartItemDTO
__all__ = ["CartItemDTO"]

19
dto/cart.py Normal file
View 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

View File

@@ -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,
),
)

View File

@@ -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,23 +92,11 @@ 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()

View File

@@ -6,3 +6,6 @@ skip-string-normalization = false
skip-magic-trailing-comma = false
preview = false
[tool.ruff.lint]
ignore=["F405", "F403"]

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
await self.order_items_repo.update_order_item_quantity(
session, order_item_id=order_item.id, quantity=new_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)

View File

@@ -3,6 +3,7 @@
set -e
echo "formatting..."
ruff check . --fix
black .
echo "checking psql..."