From 9e96da8f530618ed096e649dd0a9cf5e4fe55919 Mon Sep 17 00:00:00 2001 From: hexdev Date: Sun, 8 Feb 2026 12:40:04 +0700 Subject: [PATCH] Product rendering, cart functionality --- handlers/client.py | 127 +++++++++++++++++++++++++++++++++++- main.py | 22 +++++-- middlewares/repository.py | 4 ++ misc/kb/client.py | 43 +++++++++++- misc/texts.py | 9 +++ repositories/order_items.py | 52 +++++++++++++++ repositories/orders.py | 30 ++++++++- repositories/products.py | 14 +++- services/orders.py | 112 ++++++++++++++++++++++++++++++- 9 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 misc/texts.py create mode 100644 repositories/order_items.py diff --git a/handlers/client.py b/handlers/client.py index 7b40855..b7f334a 100644 --- a/handlers/client.py +++ b/handlers/client.py @@ -1,13 +1,19 @@ +import os from re import Match from aiogram import Router, F -from aiogram.types import CallbackQuery, Message +from aiogram.types import CallbackQuery, FSInputFile, Message from aiogram.filters import Command from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from config import PAGE_SIZE from misc.kb import main_menu_kb, render_category -from misc.kb.client import get_back_to_catalogue, render_products +from misc.kb.client import ( + get_back_to_catalogue, + render_product_interactions, + render_products, +) +from misc.texts import get_product_description from repositories.categories import CategoriesRepository from repositories.products import ProductRepository from services.orders import OrderService @@ -86,10 +92,26 @@ async def subcatalogue( ) return + if cb.message.photo: + await cb.message.delete() + await cb.message.answer( + "hya", + reply_markup=render_products( + products[:PAGE_SIZE], + parent_id=parent_id, + cat_id=cat_id, + page=0, + show_prev=products_len < 0, + show_next=products_len > PAGE_SIZE, + ), + ) + return + await cb.message.edit_text( "hya", reply_markup=render_products( products[:PAGE_SIZE], + parent_id=parent_id, cat_id=cat_id, page=0, show_prev=products_len < 0, @@ -117,11 +139,13 @@ async def products_pagination( pagination_match: Match[str], session: AsyncSession, products_repo: ProductRepository, + categories_repo: CategoriesRepository, ): await state.clear() pagination_data = pagination_match.groupdict() category_id = int(pagination_data.get("category_id")) + category = await categories_repo.get_category_by_id(session, category_id) page_id = int(pagination_data.get("page_id", 0)) products = await products_repo.get_product_by_category( @@ -132,9 +156,108 @@ async def products_pagination( await cb.message.edit_reply_markup( reply_markup=render_products( products[:PAGE_SIZE], + parent_id=category.parent_id, cat_id=category_id, page=page_id, show_prev=page_id > 0, show_next=products_len > PAGE_SIZE, ), ) + + +@router.callback_query(F.data.startswith("product:")) +async def product_card( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + products_repo: ProductRepository, + order_service: OrderService, +): + await state.clear() + + product_id = int(cb.data.split(":")[1]) + product = await products_repo.get_product_by_id(session, product_id) + + order_item = await order_service.get_cart_product_amount( + session, customer=cb.from_user.id, product_id=product_id + ) + + if product.file_id: + await cb.message.delete() + + msg = await cb.message.answer_photo( + product.file_id, + caption=get_product_description(product), + reply_markup=render_product_interactions( + product_id=product.id, + category_id=product.category_id, + show_cart=bool(order_item), + cart_amount=order_item, + ), + ) + return + + if not os.path.isfile(product.img_path): + await cb.answer("something has gone wrong, try again later...") + return # TODO: Add logging + Admin notifications + + await cb.message.delete() + msg = await cb.message.answer_photo( + FSInputFile(product.img_path), + caption=get_product_description(product), + reply_markup=render_product_interactions( + product_id=product.id, + category_id=product.category_id, + show_cart=bool(order_item), + cart_amount=order_item, + ), + ) + + if not msg.photo: + return # TODO: Add logging + + await products_repo.add_product_file_id( + session, product_id, file_id=msg.photo[-1].file_id + ) + + +@router.callback_query(F.data.startswith("cart_action:")) +async def add_to_cart( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + order_service: OrderService, + products_repo: ProductRepository, +): + await state.clear() + + action = cb.data.split(":")[1] + product_id = int(cb.data.split(":")[2]) + product = await products_repo.get_product_by_id(session, product_id) + show_cart = True + + if action == "add": + quantity = await order_service.add_to_cart( + session, customer=cb.from_user.id, product_id=product_id + ) + elif action == "remove": + quantity = await order_service.remove_from_cart( + session, customer=cb.from_user.id, product_id=product_id + ) + show_cart = bool(quantity) + else: + show_cart = False + quantity = 0 + + try: + await cb.message.edit_reply_markup( + reply_markup=render_product_interactions( + product_id=product_id, + category_id=product.category_id, + show_cart=show_cart, + cart_amount=quantity, + ) + ) + except Exception: + await cb.answer("🍃 Корзина уже пуста.") + return diff --git a/main.py b/main.py index e770ece..540ab11 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import os import asyncio import logging from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties from config import BOT_TOKEN from handlers import init_handlers @@ -10,6 +11,7 @@ from middlewares.di import DIMiddleware from middlewares.repository import RepositoryMiddleware from middlewares.session import DBSessionMiddleware from repositories.categories import CategoriesRepository +from repositories.order_items import OrderItemRepository from repositories.orders import OrderRepository from repositories.products import ProductRepository from services import OrderService @@ -21,14 +23,16 @@ logger = logging.getLogger(__name__) os.makedirs("static/img", exist_ok=True) + async def main(): dp = Dispatcher() - bot = Bot(token=BOT_TOKEN) + bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode="HTML")) # DB services creation order_repository = OrderRepository() - order_service = OrderService(order_repository) + order_items_repository = OrderItemRepository() + order_service = OrderService(order_repository, order_items_repository) category_repository = CategoriesRepository() @@ -38,13 +42,23 @@ async def main(): dp.message.middleware(DIMiddleware(order_service)) dp.message.middleware(DBSessionMiddleware(async_session)) dp.message.middleware( - RepositoryMiddleware(order_repository, category_repository, product_repository) + RepositoryMiddleware( + order_repository, + category_repository, + product_repository, + order_items_repository, + ) ) dp.callback_query.middleware(DIMiddleware(order_service)) dp.callback_query.middleware(DBSessionMiddleware(async_session)) dp.callback_query.middleware( - RepositoryMiddleware(order_repository, category_repository, product_repository) + RepositoryMiddleware( + order_repository, + category_repository, + product_repository, + order_items_repository, + ) ) init_handlers(dp) diff --git a/middlewares/repository.py b/middlewares/repository.py index 961f3c2..ecf0d3c 100644 --- a/middlewares/repository.py +++ b/middlewares/repository.py @@ -1,6 +1,7 @@ from aiogram import BaseMiddleware from repositories import OrderRepository, CategoriesRepository +from repositories.order_items import OrderItemRepository from repositories.products import ProductRepository @@ -10,13 +11,16 @@ class RepositoryMiddleware(BaseMiddleware): order_repo: OrderRepository, categories_repo: CategoriesRepository, products_repo: ProductRepository, + order_items_repo: OrderItemRepository, ) -> None: self._order_repo = order_repo self._categories_repo = categories_repo self._products_repo = products_repo + self._order_items_repo = order_items_repo async def __call__(self, handler, event, data): data["order_repo"] = self._order_repo data["categories_repo"] = self._categories_repo data["products_repo"] = self._products_repo + data["order_items_repo"] = self._order_items_repo return await handler(event, data) diff --git a/misc/kb/client.py b/misc/kb/client.py index a6c8e6c..70c67bf 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -64,6 +64,7 @@ def render_category( def render_products( products: list[Product], *, + parent_id: int, cat_id: int, page: int = 0, show_prev: Optional[bool] = False, @@ -97,10 +98,50 @@ def render_products( [ InlineKeyboardButton( text="⬅️ Назад", - callback_data=f"cat:{cat_id}", + callback_data=f"cat:{parent_id}", ) ] ] + btns + [nav] ).as_markup() + + +def render_product_interactions( + *, product_id: int, category_id: int, show_cart: bool = False, cart_amount: int = 0 +) -> InlineKeyboardMarkup: + btns: list[list[InlineKeyboardButton]] = [] + + if show_cart: + btns.append( + [ + InlineKeyboardButton( + text="➖", callback_data=f"cart_action:remove:{product_id}" + ), + InlineKeyboardButton(text=f"🛒 {cart_amount}", callback_data="..."), + InlineKeyboardButton( + text="➕", callback_data=f"cart_action:add:{product_id}" + ), + ] + ) + btns.append( + [ + InlineKeyboardButton( + text="❌ Удалить из корзины", + callback_data=f"cart_action:clear:{product_id}", + ) + ] + ) + + else: + btns.append( + [ + InlineKeyboardButton( + text="🛒 В корзину", callback_data=f"cart_action:add:{product_id}" + ) + ] + ) + + btns.append([InlineKeyboardButton(text="⬅️", callback_data=f"cat:{category_id}")]) + + return InlineKeyboardBuilder(btns).as_markup() diff --git a/misc/texts.py b/misc/texts.py new file mode 100644 index 0000000..e2bb7d3 --- /dev/null +++ b/misc/texts.py @@ -0,0 +1,9 @@ +from db.models import Product + + +def get_product_description(product: Product) -> str: + output = f"📦 {product.name}\n\n🏷️ {product.price}₽" + if product.description: + output += f"\n\n📜 {product.description}" + + return output diff --git a/repositories/order_items.py b/repositories/order_items.py new file mode 100644 index 0000000..79484f8 --- /dev/null +++ b/repositories/order_items.py @@ -0,0 +1,52 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from typing import Optional + +from db.models.orders import OrderItem + + +class OrderItemRepository: + async def get_item_by_id( + self, session: AsyncSession, item_id: int + ) -> Optional[OrderItem]: + stmt = select(OrderItem).where(OrderItem.id == item_id) + return await session.scalar(stmt) + + async def get_item_by_order_and_product( + self, session: AsyncSession, *, order_id: int, product_id: int + ) -> Optional[OrderItem]: + stmt = ( + select(OrderItem) + .where(OrderItem.order_id == order_id) + .where(OrderItem.product_id == product_id) + ) + return await session.scalar(stmt) + + async def update_order_item_quantity( + self, session: AsyncSession, order_item_id: int, *, quantity: int = 0 + ): + stmt = ( + update(OrderItem.__table__) + .where(OrderItem.id == order_item_id) + .values(quantity=quantity) + ) + + await session.execute(stmt) + await session.commit() + + async def create_order_item( + self, + session: AsyncSession, + *, + order_id: int, + product_id: int, + quantity: int = 1, + ) -> OrderItem: + order_item = OrderItem( + order_id=order_id, product_id=product_id, quantity=quantity + ) + + session.add(order_item) + await session.commit() + + return order_item diff --git a/repositories/orders.py b/repositories/orders.py index b8bde11..077c172 100644 --- a/repositories/orders.py +++ b/repositories/orders.py @@ -2,7 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import Optional -from db.models import Order +from db.models import Order, OrderStatus class OrderRepository: @@ -13,9 +13,33 @@ class OrderRepository: return await session.scalar(stmt) async def get_orders_by_user( - self, session: AsyncSession, user_id: int + self, session: AsyncSession, customer: int ) -> list[Order]: - stmt = select(Order).where(Order.customer == user_id) + stmt = select(Order).where(Order.customer == customer) result = await session.scalars(stmt) return list(result) + + async def get_draft_order_by_user( + self, session: AsyncSession, customer: int + ) -> Optional[Order]: + stmt = ( + select(Order) + .where(Order.customer == customer) + .where(Order.status == OrderStatus.DRAFT) + .limit(1) + ) + return await session.scalar(stmt) + + async def create_order( + self, + session: AsyncSession, + customer: int, + status: OrderStatus = OrderStatus.DRAFT, + ): + order = Order(customer=customer, status=status) + + session.add(order) + await session.commit() + + return order diff --git a/repositories/products.py b/repositories/products.py index 53cdff7..6da6d05 100644 --- a/repositories/products.py +++ b/repositories/products.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, update from typing import Optional from db.models import Product @@ -26,3 +26,15 @@ class ProductRepository: result = await session.scalars(stmt) return list(result) + + async def add_product_file_id( + self, session: AsyncSession, product_id: int, *, file_id: str + ) -> None: + stmt = ( + update(Product.__table__) + .where(Product.id == product_id) + .where(Product.file_id is None) + .values(file_id=file_id) + ) + await session.execute(stmt) + await session.commit() diff --git a/services/orders.py b/services/orders.py index 739947f..5e3ee2a 100644 --- a/services/orders.py +++ b/services/orders.py @@ -1,11 +1,117 @@ +import logging from sqlalchemy.ext.asyncio import AsyncSession +from db.models.orders import OrderStatus +from repositories.order_items import OrderItemRepository from repositories.orders import OrderRepository +logger = logging.getLogger(__name__) + + +class OrderServiceException(Exception): ... + class OrderService: - def __init__(self, order_repo: OrderRepository): + def __init__( + self, order_repo: OrderRepository, order_items_repo: OrderItemRepository + ): self.order_repo = order_repo + self.order_items_repo = order_items_repo - async def get_order_count(self, session: AsyncSession, user_id: int) -> int: - orders = await self.order_repo.get_orders_by_user(session, user_id) + async def get_order_count(self, session: AsyncSession, customer: int) -> int: + orders = await self.order_repo.get_orders_by_user(session, customer) return len(orders) + + async def add_to_cart( + self, + session: AsyncSession, + *, + customer: int, + product_id: int, + quantity: int = 1, + ) -> int: + order = await self.order_repo.get_draft_order_by_user(session, customer) + + if not order: + order = await self.order_repo.create_order( + session, customer, OrderStatus.DRAFT + ) + + 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: + order_item = await self.order_items_repo.create_order_item( + session, order_id=order.id, product_id=product_id, quantity=quantity + ) + else: + await self.order_items_repo.update_order_item_quantity( + session, order_item.id, quantity=order_item.quantity + quantity + ) + + return order_item.quantity + quantity + + async def remove_from_cart( + self, + session: AsyncSession, + *, + customer: int, + product_id: int, + quantity: int = 1, + ) -> 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..." + ) + + 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 + ) + + return new_quantity + + async def get_cart_product_amount( + self, session: AsyncSession, *, customer: int, product_id: int + ) -> int: + order = await self.order_repo.get_draft_order_by_user(session, customer) + + if not order: + return 0 + + order_item = await self.order_items_repo.get_item_by_order_and_product( + session, order_id=order.id, product_id=product_id + ) + return order_item.quantity + + async def clear_cart_item(self, session, *, customer: int, product_id: int) -> None: + order = await self.order_repo.get_draft_order_by_user(session, customer) + + if not order: + raise OrderServiceException( + "attempt of clearing a 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 clearing an order_item that isn't in a cart, potential callback_query exploit, ignoring..." + ) + + await self.order_items_repo.update_order_item_quantity( + session, order_item_id=order_item.id, quantity=0 + )