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
+ )