Product rendering, cart functionality

This commit is contained in:
2026-02-08 12:40:04 +07:00
parent d8cad25f33
commit 9e96da8f53
9 changed files with 399 additions and 14 deletions

View File

@@ -1,13 +1,19 @@
import os
from re import Match from re import Match
from aiogram import Router, F 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.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE 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 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.categories import CategoriesRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository
from services.orders import OrderService from services.orders import OrderService
@@ -86,10 +92,26 @@ async def subcatalogue(
) )
return 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( await cb.message.edit_text(
"hya", "hya",
reply_markup=render_products( reply_markup=render_products(
products[:PAGE_SIZE], products[:PAGE_SIZE],
parent_id=parent_id,
cat_id=cat_id, cat_id=cat_id,
page=0, page=0,
show_prev=products_len < 0, show_prev=products_len < 0,
@@ -117,11 +139,13 @@ async def products_pagination(
pagination_match: Match[str], pagination_match: Match[str],
session: AsyncSession, session: AsyncSession,
products_repo: ProductRepository, products_repo: ProductRepository,
categories_repo: CategoriesRepository,
): ):
await state.clear() await state.clear()
pagination_data = pagination_match.groupdict() pagination_data = pagination_match.groupdict()
category_id = int(pagination_data.get("category_id")) 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)) page_id = int(pagination_data.get("page_id", 0))
products = await products_repo.get_product_by_category( products = await products_repo.get_product_by_category(
@@ -132,9 +156,108 @@ async def products_pagination(
await cb.message.edit_reply_markup( await cb.message.edit_reply_markup(
reply_markup=render_products( reply_markup=render_products(
products[:PAGE_SIZE], products[:PAGE_SIZE],
parent_id=category.parent_id,
cat_id=category_id, cat_id=category_id,
page=page_id, page=page_id,
show_prev=page_id > 0, show_prev=page_id > 0,
show_next=products_len > PAGE_SIZE, 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

22
main.py
View File

@@ -2,6 +2,7 @@ import os
import asyncio import asyncio
import logging import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from config import BOT_TOKEN from config import BOT_TOKEN
from handlers import init_handlers from handlers import init_handlers
@@ -10,6 +11,7 @@ from middlewares.di import DIMiddleware
from middlewares.repository import RepositoryMiddleware from middlewares.repository import RepositoryMiddleware
from middlewares.session import DBSessionMiddleware from middlewares.session import DBSessionMiddleware
from repositories.categories import CategoriesRepository from repositories.categories import CategoriesRepository
from repositories.order_items import OrderItemRepository
from repositories.orders import OrderRepository from repositories.orders import OrderRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository
from services import OrderService from services import OrderService
@@ -21,14 +23,16 @@ logger = logging.getLogger(__name__)
os.makedirs("static/img", exist_ok=True) os.makedirs("static/img", exist_ok=True)
async def main(): async def main():
dp = Dispatcher() dp = Dispatcher()
bot = Bot(token=BOT_TOKEN) bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode="HTML"))
# DB services creation # DB services creation
order_repository = OrderRepository() order_repository = OrderRepository()
order_service = OrderService(order_repository) order_items_repository = OrderItemRepository()
order_service = OrderService(order_repository, order_items_repository)
category_repository = CategoriesRepository() category_repository = CategoriesRepository()
@@ -38,13 +42,23 @@ async def main():
dp.message.middleware(DIMiddleware(order_service)) dp.message.middleware(DIMiddleware(order_service))
dp.message.middleware(DBSessionMiddleware(async_session)) dp.message.middleware(DBSessionMiddleware(async_session))
dp.message.middleware( 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(DIMiddleware(order_service))
dp.callback_query.middleware(DBSessionMiddleware(async_session)) dp.callback_query.middleware(DBSessionMiddleware(async_session))
dp.callback_query.middleware( dp.callback_query.middleware(
RepositoryMiddleware(order_repository, category_repository, product_repository) RepositoryMiddleware(
order_repository,
category_repository,
product_repository,
order_items_repository,
)
) )
init_handlers(dp) init_handlers(dp)

View File

@@ -1,6 +1,7 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from repositories import OrderRepository, CategoriesRepository from repositories import OrderRepository, CategoriesRepository
from repositories.order_items import OrderItemRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository
@@ -10,13 +11,16 @@ class RepositoryMiddleware(BaseMiddleware):
order_repo: OrderRepository, order_repo: OrderRepository,
categories_repo: CategoriesRepository, categories_repo: CategoriesRepository,
products_repo: ProductRepository, products_repo: ProductRepository,
order_items_repo: OrderItemRepository,
) -> None: ) -> None:
self._order_repo = order_repo self._order_repo = order_repo
self._categories_repo = categories_repo self._categories_repo = categories_repo
self._products_repo = products_repo self._products_repo = products_repo
self._order_items_repo = order_items_repo
async def __call__(self, handler, event, data): async def __call__(self, handler, event, data):
data["order_repo"] = self._order_repo data["order_repo"] = self._order_repo
data["categories_repo"] = self._categories_repo data["categories_repo"] = self._categories_repo
data["products_repo"] = self._products_repo data["products_repo"] = self._products_repo
data["order_items_repo"] = self._order_items_repo
return await handler(event, data) return await handler(event, data)

View File

@@ -64,6 +64,7 @@ def render_category(
def render_products( def render_products(
products: list[Product], products: list[Product],
*, *,
parent_id: int,
cat_id: int, cat_id: int,
page: int = 0, page: int = 0,
show_prev: Optional[bool] = False, show_prev: Optional[bool] = False,
@@ -97,10 +98,50 @@ def render_products(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text="⬅️ Назад", text="⬅️ Назад",
callback_data=f"cat:{cat_id}", callback_data=f"cat:{parent_id}",
) )
] ]
] ]
+ btns + btns
+ [nav] + [nav]
).as_markup() ).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()

9
misc/texts.py Normal file
View File

@@ -0,0 +1,9 @@
from db.models import Product
def get_product_description(product: Product) -> str:
output = f"📦 <b>{product.name}</b>\n\n<b><i>🏷️ {product.price}₽</i></b>"
if product.description:
output += f"\n\n📜 {product.description}"
return output

View File

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

View File

@@ -2,7 +2,7 @@ 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 from db.models import Order, OrderStatus
class OrderRepository: class OrderRepository:
@@ -13,9 +13,33 @@ class OrderRepository:
return await session.scalar(stmt) return await session.scalar(stmt)
async def get_orders_by_user( async def get_orders_by_user(
self, session: AsyncSession, user_id: int self, session: AsyncSession, customer: int
) -> list[Order]: ) -> list[Order]:
stmt = select(Order).where(Order.customer == user_id) stmt = select(Order).where(Order.customer == customer)
result = await session.scalars(stmt) result = await session.scalars(stmt)
return list(result) 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

View File

@@ -1,5 +1,5 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, update
from typing import Optional from typing import Optional
from db.models import Product from db.models import Product
@@ -26,3 +26,15 @@ class ProductRepository:
result = await session.scalars(stmt) result = await session.scalars(stmt)
return list(result) 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()

View File

@@ -1,11 +1,117 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models.orders import OrderStatus
from repositories.order_items import OrderItemRepository
from repositories.orders import OrderRepository from repositories.orders import OrderRepository
logger = logging.getLogger(__name__)
class OrderServiceException(Exception): ...
class OrderService: 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_repo = order_repo
self.order_items_repo = order_items_repo
async def get_order_count(self, session: AsyncSession, user_id: int) -> int: async def get_order_count(self, session: AsyncSession, customer: int) -> int:
orders = await self.order_repo.get_orders_by_user(session, user_id) orders = await self.order_repo.get_orders_by_user(session, customer)
return len(orders) 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
)