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 ### ### 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.dialects.postgresql import ENUM
from db.base import Base from db.base import Base
from db.models.products import Product
class OrderStatus(enum.Enum): class OrderStatus(enum.Enum):
@@ -33,3 +34,4 @@ class OrderItem(Base):
quantity: Mapped[int] = mapped_column(default=1) quantity: Mapped[int] = mapped_column(default=1)
order: Mapped["Order"] = relationship("Order", back_populates="items") 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 import ForeignKey, Text, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from db.base import Base 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 import main_menu_kb, render_category
from misc.kb.client import ( from misc.kb.client import (
get_back_to_catalogue, get_back_to_catalogue,
render_cart,
render_product_interactions, render_product_interactions,
render_products, render_products,
) )
@@ -265,6 +266,9 @@ async def add_to_cart(
else: else:
show_cart = False show_cart = False
quantity = 0 quantity = 0
await order_service.clear_cart_item(
session, customer=cb.from_user.id, product_id=product_id
)
try: try:
await cb.message.edit_reply_markup( await cb.message.edit_reply_markup(
@@ -275,6 +279,69 @@ async def add_to_cart(
cart_amount=quantity, cart_amount=quantity,
) )
) )
except Exception: except Exception as e:
await cb.answer("🍃 Корзина уже пуста.") await cb.answer("🍃")
return 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.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from db.models import Category from db.models import Category, Product
from db.models.products import 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: def cart_btn(cart_items: int) -> InlineKeyboardButton:
return InlineKeyboardButton( return InlineKeyboardButton(text=f"🛒 Корзина ({cart_items})", callback_data="cart")
text=f"🛒 Корзина ({cart_items})", callback_data="cart"
)
def get_back_to_catalogue( def get_back_to_catalogue(
@@ -75,23 +92,11 @@ def render_products(
for product in products for product in products
] ]
nav = [] nav = pagination_row(
page=page,
if show_prev: prev_cb=f"products:{cat_id}:{page - 1}" if show_prev else None,
nav.append( next_cb=f"products:{cat_id}:{page + 1}" if show_next else None,
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}"
)
)
return InlineKeyboardBuilder( return InlineKeyboardBuilder(
[ [
@@ -145,3 +150,38 @@ def render_product_interactions(
btns.append([InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}")]) btns.append([InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}")])
return InlineKeyboardBuilder(btns).as_markup() 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 skip-magic-trailing-comma = false
preview = false preview = false
[tool.ruff.lint]
ignore=["F405", "F403"]

View File

@@ -1,8 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update from sqlalchemy import delete, func, select, update
from typing import Optional from typing import Optional
from db.models.orders import Order, OrderItem from db.models.orders import OrderItem
class OrderItemRepository: class OrderItemRepository:
@@ -13,13 +13,22 @@ class OrderItemRepository:
return await session.scalar(stmt) return await session.scalar(stmt)
async def get_items_by_order( 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]: ) -> 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) result = await session.scalars(stmt)
return list(result) 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( async def get_item_by_order_and_product(
self, session: AsyncSession, *, order_id: int, product_id: int self, session: AsyncSession, *, order_id: int, product_id: int
) -> Optional[OrderItem]: ) -> Optional[OrderItem]:
@@ -58,3 +67,9 @@ class OrderItemRepository:
await session.commit() await session.commit()
return order_item 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 sqlalchemy import select
from typing import Optional from typing import Optional
from db.models import Order, OrderStatus from sqlalchemy.orm import selectinload
from db.models import Order, OrderStatus, OrderItem
class OrderRepository: class OrderRepository:
@@ -28,6 +30,7 @@ class OrderRepository:
.where(Order.customer == customer) .where(Order.customer == customer)
.where(Order.status == OrderStatus.DRAFT) .where(Order.status == OrderStatus.DRAFT)
.limit(1) .limit(1)
.options(selectinload(Order.items).selectinload(OrderItem.product))
) )
return await session.scalar(stmt) return await session.scalar(stmt)

View File

@@ -1,6 +1,8 @@
import logging import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE
from db.models.orders import OrderStatus from db.models.orders import OrderStatus
from dto.cart import CartDTO, CartItemDTO
from repositories.order_items import OrderItemRepository from repositories.order_items import OrderItemRepository
from repositories.orders import OrderRepository from repositories.orders import OrderRepository
@@ -44,6 +46,7 @@ class OrderService:
order_item = await self.order_items_repo.create_order_item( order_item = await self.order_items_repo.create_order_item(
session, order_id=order.id, product_id=product_id, quantity=quantity session, order_id=order.id, product_id=product_id, quantity=quantity
) )
return quantity
else: else:
await self.order_items_repo.update_order_item_quantity( await self.order_items_repo.update_order_item_quantity(
session, order_item.id, quantity=order_item.quantity + 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..." "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
await self.order_items_repo.update_order_item_quantity( if new_quantity > 0:
session, order_item_id=order_item.id, quantity=new_quantity 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 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( async def get_cart_product_amount(
self, session: AsyncSession, *, customer: int, product_id: int self, session: AsyncSession, *, customer: int, product_id: int
) -> int: ) -> int:
@@ -127,7 +154,40 @@ class OrderService:
if not order: if not order:
return 0 return 0
order_item = await self.order_items_repo.get_items_by_order( return await self.order_items_repo.get_items_count(session, order_id=order.id)
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 set -e
echo "formatting..." echo "formatting..."
ruff check . --fix
black . black .
echo "checking psql..." echo "checking psql..."