Cart rendering, bug fixes with cart adding/rming
This commit is contained in:
@@ -16,4 +16,4 @@ POSTGRES_URL = os.getenv(
|
|||||||
|
|
||||||
### Constants ###
|
### 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.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")
|
||||||
|
|||||||
@@ -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
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 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "formatting..."
|
echo "formatting..."
|
||||||
|
ruff check . --fix
|
||||||
black .
|
black .
|
||||||
|
|
||||||
echo "checking psql..."
|
echo "checking psql..."
|
||||||
|
|||||||
Reference in New Issue
Block a user