diff --git a/config.py b/config.py index fe57ef4..0364aa4 100644 --- a/config.py +++ b/config.py @@ -16,4 +16,4 @@ POSTGRES_URL = os.getenv( ### Constants ### -PAGE_SIZE = 6 +PAGE_SIZE = 4 diff --git a/db/models/orders.py b/db/models/orders.py index 4a5bf5c..5914ef9 100644 --- a/db/models/orders.py +++ b/db/models/orders.py @@ -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") diff --git a/db/models/products.py b/db/models/products.py index 4bbc800..dabb975 100644 --- a/db/models/products.py +++ b/db/models/products.py @@ -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 diff --git a/dto/__init__.py b/dto/__init__.py new file mode 100644 index 0000000..fe0d00a --- /dev/null +++ b/dto/__init__.py @@ -0,0 +1,3 @@ +from .cart import CartItemDTO + +__all__ = ["CartItemDTO"] diff --git a/dto/cart.py b/dto/cart.py new file mode 100644 index 0000000..6bfdeed --- /dev/null +++ b/dto/cart.py @@ -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 diff --git a/handlers/client.py b/handlers/client.py index 3f89c30..064221c 100644 --- a/handlers/client.py +++ b/handlers/client.py @@ -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, + ), + ) diff --git a/misc/kb/client.py b/misc/kb/client.py index 527f192..e0dc961 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index aad93e8..d93c300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,3 +6,6 @@ skip-string-normalization = false skip-magic-trailing-comma = false preview = false + +[tool.ruff.lint] +ignore=["F405", "F403"] \ No newline at end of file diff --git a/repositories/order_items.py b/repositories/order_items.py index a85b09d..a66d7d4 100644 --- a/repositories/order_items.py +++ b/repositories/order_items.py @@ -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() diff --git a/repositories/orders.py b/repositories/orders.py index 077c172..7e174d3 100644 --- a/repositories/orders.py +++ b/repositories/orders.py @@ -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) diff --git a/services/orders.py b/services/orders.py index 3d84d4b..1c34e79 100644 --- a/services/orders.py +++ b/services/orders.py @@ -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) diff --git a/startup.sh b/startup.sh index 8b3ad8f..890afa5 100644 --- a/startup.sh +++ b/startup.sh @@ -3,6 +3,7 @@ set -e echo "formatting..." +ruff check . --fix black . echo "checking psql..."