diff --git a/config.py b/config.py index 16a3b1f..17bf8fb 100644 --- a/config.py +++ b/config.py @@ -25,6 +25,8 @@ POSTGRES_URL = os.getenv( card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", "")) +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1") + ### Constants ### PAGE_SIZE = 4 diff --git a/dto/__init__.py b/dto/__init__.py index fe0d00a..50c9346 100644 --- a/dto/__init__.py +++ b/dto/__init__.py @@ -1,3 +1,12 @@ from .cart import CartItemDTO +from .control import EditProductContext +from .checkout import CheckoutContext +from .catalogue import CatalogueView, CatalogueType -__all__ = ["CartItemDTO"] +__all__ = [ + "CartItemDTO", + "EditProductContext", + "CheckoutContext", + "CatalogueView", + "CatalogueType", +] diff --git a/dto/catalogue.py b/dto/catalogue.py new file mode 100644 index 0000000..e107d8b --- /dev/null +++ b/dto/catalogue.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Union + +from db.models import Category +from db.models.products import Product + + +class CatalogueType(Enum): + CATEGORIES = "categories" + PRODUCTS = "products" + EMPTY = "empty" + + +@dataclass +class CatalogueView: + view_type: Optional[CatalogueType] = None + category: Optional[Category] = None + children: Optional[list[Category]] = None + products: Optional[list[Product]] = None + page: int = 0 + parent_id: Union[int, str, None] = None + has_next: bool = False + show_menu: bool = False diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh index 0c27bf3..104f198 100644 --- a/entrypoints/startup.sh +++ b/entrypoints/startup.sh @@ -24,6 +24,14 @@ if ! pg_isready -h localhost -p 5432; then fi echo "psql is up and is recieving connections." +echo +echo "checking redis..." +if ! redis-cli ping | grep PONG > /dev/null; then + echo "redis is down, try again" >&2 + exit 1 +fi +echo "redis is up and is recieving connections." + echo echo "running migrations..." alembic upgrade head diff --git a/handlers/client/catalogue.py b/handlers/client/catalogue.py index 2befea1..ed4b105 100644 --- a/handlers/client/catalogue.py +++ b/handlers/client/catalogue.py @@ -3,14 +3,11 @@ from aiogram.types import CallbackQuery from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from config import PAGE_SIZE -from misc.kb import render_category +from config import VERIFIED_ACCOUNTS from misc.kb.client import ( - get_back_to_catalogue, - render_products, + render_catalogue, ) -from repositories.categories import CategoriesRepository -from repositories.products import ProductRepository +from services.catalogue import CatalogueService router = Router() @@ -20,80 +17,23 @@ async def subcatalogue( cb: CallbackQuery, state: FSMContext, session: AsyncSession, - categories_repo: CategoriesRepository, - products_repo: ProductRepository, + catalogue_service: CatalogueService, ): await state.clear() cat_id = cb.data.split(":")[1] + is_admin = cb.from_user.id in VERIFIED_ACCOUNTS - if cat_id == "root": - children = await categories_repo.get_categories_by_parent_id(session) - kb = render_category( - children, show_menu=True, category_cb_factory=lambda c: f"cat:{c.id}" - ) - await cb.message.edit_text( + view = await catalogue_service.build_category_view(session, cat_id) + kb = render_catalogue(view, is_admin=is_admin) + + if cb.message.photo: + await cb.message.delete() + await cb.answer( "category selection", reply_markup=kb, ) return - - cat_id = int(cat_id) - - children = await categories_repo.get_categories_by_parent_id(session, cat_id) - category = await categories_repo.get_category_by_id(session, cat_id) - parent_id = category.parent_id or "root" - - if not children: - products = await products_repo.get_product_by_category( - session, cat_id, limit=PAGE_SIZE + 1, offset=0 - ) - products_len = len(products) - - if not products: - await cb.message.edit_text( - "🍃 Здесь ничего нет...", - reply_markup=get_back_to_catalogue(parent_id), - ) - return - - if cb.message.photo: - await cb.message.delete() - await cb.message.answer( - "hya", - reply_markup=render_products( - products[:PAGE_SIZE], - product_cb_factory=lambda p: f"product:{p.id}", - back_cb=f"cat:{parent_id}", - next_cb=( - f"products:{cat_id}:{0 + 1}" - if products_len > PAGE_SIZE - else None - ), - columns=2, - ), - ) - return - - await cb.message.edit_text( - "hya", - reply_markup=render_products( - products[:PAGE_SIZE], - product_cb_factory=lambda p: f"product:{p.id}", - back_cb=f"cat:{parent_id}", - prev_cb=f"products:{cat_id}:{0 - 1}" if products_len < 0 else None, - next_cb=( - f"products:{cat_id}:{0 + 1}" if products_len > PAGE_SIZE else None - ), - columns=2, - ), - ) - return - - kb = render_category( - children, parent_id, category_cb_factory=lambda c: f"cat:{c.id}" - ) - await cb.message.edit_text( "category selection", reply_markup=kb, diff --git a/handlers/client/products.py b/handlers/client/products.py index ca0eb5b..343f0ba 100644 --- a/handlers/client/products.py +++ b/handlers/client/products.py @@ -5,15 +5,15 @@ from aiogram.types import CallbackQuery, FSInputFile from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from config import PAGE_SIZE, VERIFIED_ACCOUNTS +from config import VERIFIED_ACCOUNTS from misc.kb import admins from misc.kb.client import ( + render_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.catalogue import CatalogueService from services.orders import OrderService router = Router() @@ -29,35 +29,22 @@ async def products_pagination( state: FSMContext, pagination_match: Match[str], session: AsyncSession, - products_repo: ProductRepository, - categories_repo: CategoriesRepository, + catalogue_service: CatalogueService, ): 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( - session, category_id, limit=PAGE_SIZE + 1, offset=page_id * PAGE_SIZE + view = await catalogue_service.build_category_view( + session, category_id, page=page_id ) - products_len = len(products) + is_admin = cb.from_user.id in VERIFIED_ACCOUNTS - await cb.message.edit_reply_markup( - reply_markup=render_products( - products[:PAGE_SIZE], - product_cb_factory=lambda p: f"product:{p.id}", - back_cb=f"cat:{category.parent_id}", - prev_cb=(f"products:{category_id}:{page_id - 1}" if page_id > 0 else None), - next_cb=( - f"products:{category_id}:{page_id + 1}" - if products_len > PAGE_SIZE - else None - ), - columns=2, - ), - ) + kb = render_catalogue(view, is_admin=is_admin) + + await cb.message.edit_reply_markup(reply_markup=kb) @router.callback_query(F.data.startswith("product:")) diff --git a/handlers/client/search.py b/handlers/client/search.py index f864a91..b3f2fbf 100644 --- a/handlers/client/search.py +++ b/handlers/client/search.py @@ -1,12 +1,15 @@ from typing import Optional +import hashlib from aiogram import Router, F from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message from sqlalchemy.ext.asyncio import AsyncSession +from config import PAGE_SIZE from misc.kb.client import render_products from misc.kb.common import return_menu +from misc.redis import RedisClient from misc.states import SearchStorage from repositories.products import ProductRepository @@ -28,31 +31,62 @@ async def searching( state: FSMContext, session: AsyncSession, products_repo: ProductRepository, + redis_client: RedisClient, ): data = await state.get_data() await state.clear() - orig_msg: Optional[Message] = data.get("msg") + await msg.delete() + orig_msg: Optional[Message] = data.get("msg") or await msg.answer("⏳") + await orig_msg.edit_text("⏳ Поиск...") results = await products_repo.search( session, msg.text, ) - if not orig_msg: - await msg.answer( - "here", - reply_markup=render_products( - results, - product_cb_factory=lambda p: f"product:{p.id}", - back_cb="menu:main", - next_cb="", - ), - ) - return + next_cb = None + if len(results) > PAGE_SIZE: + query_hash = hashlib.sha256(msg.text.encode()).hexdigest()[:16] + await redis_client.set_search_query(query_hash, msg.text) + next_cb = f"search:{query_hash}:1" await orig_msg.edit_text( "here", reply_markup=render_products( - results, product_cb_factory=lambda p: f"product:{p.id}", back_cb="menu:main" + results[:PAGE_SIZE], + product_cb_factory=lambda p: f"product:{p.id}", + back_cb="menu:main", + next_cb=next_cb, ), ) + + +@router.callback_query(F.data.startswith("search:")) +async def search_pagination( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + products_repo: ProductRepository, + redis_client: RedisClient, +): + await state.clear() + + data = cb.data.split(":") + q_hash = data[1] + page = int(data[2]) + + query = await redis_client.get_search_query(q_hash) + if not query: + await cb.message.edit_text("🍃 Попробуйте ещё раз...") + + results = await products_repo.search(session, query, offset=PAGE_SIZE * page) + + await cb.message.edit_reply_markup( + reply_markup=render_products( + results[:PAGE_SIZE], + product_cb_factory=lambda p: f"product:{p.id}", + back_cb="menu:main", + next_cb=f"search:{q_hash}:{page + 1}" if len(results) > PAGE_SIZE else None, + prev_cb=f"search:{q_hash}:{page - 1}" if page > 0 else None, + ) + ) diff --git a/main.py b/main.py index eebf905..c03d1df 100644 --- a/main.py +++ b/main.py @@ -3,13 +3,15 @@ import asyncio import logging from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties +from redis.asyncio import Redis -from config import BOT_TOKEN +from config import BOT_TOKEN, REDIS_URL from handlers import admins_routers, client_routers from middlewares.di import DIMiddleware from middlewares.repository import RepositoryMiddleware from middlewares.session import DBSessionMiddleware +from misc.redis import RedisClient from repositories.categories import CategoriesRepository from repositories.invoices import InvoiceRepository from repositories.order_items import OrderItemRepository @@ -17,6 +19,7 @@ from repositories.orders import OrderRepository from repositories.products import ProductRepository from services import OrderService from db.base import async_session +from services.catalogue import CatalogueService logging.basicConfig(level=logging.DEBUG) @@ -36,13 +39,17 @@ async def main(): order_service = OrderService(order_repository, order_items_repository) category_repository = CategoriesRepository() - product_repository = ProductRepository() + catalogue_service = CatalogueService(product_repository, category_repository) invoice_repository = InvoiceRepository() + # Additional Initiations + r = Redis.from_url(REDIS_URL) + redis_client = RedisClient(r) + # DI Middleware Init - dp.update.middleware(DIMiddleware(order_service)) + dp.update.middleware(DIMiddleware(order_service, catalogue_service, redis_client)) dp.update.middleware(DBSessionMiddleware(async_session)) dp.update.middleware( RepositoryMiddleware( diff --git a/middlewares/di.py b/middlewares/di.py index d3acb6a..b0b1461 100644 --- a/middlewares/di.py +++ b/middlewares/di.py @@ -1,12 +1,22 @@ from aiogram import BaseMiddleware from typing import Any, Callable +from redis.asyncio import Redis + from services.orders import OrderService +from services.catalogue import CatalogueService class DIMiddleware(BaseMiddleware): - def __init__(self, order_service: OrderService): + def __init__( + self, + order_service: OrderService, + catalogue_service: CatalogueService, + redis_client: Redis, + ): self._order_service = order_service + self._redis_client = redis_client + self._catalogue_service = catalogue_service async def __call__( self, @@ -15,5 +25,7 @@ class DIMiddleware(BaseMiddleware): data: dict[str, Any], ) -> Any: data["order_service"] = self._order_service + data["catalogue_service"] = self._catalogue_service + data["redis_client"] = self._redis_client return await handler(event, data) diff --git a/misc/kb/client.py b/misc/kb/client.py index ee143bf..52ac02d 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -3,8 +3,11 @@ from typing import Optional, Callable from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder +from config import PAGE_SIZE from db.models import Category, Product from dto.cart import CartItemDTO +from dto.catalogue import CatalogueView, CatalogueType +from .common import return_menu def pagination_row( @@ -59,6 +62,7 @@ def render_category( show_menu: bool = False, *, category_cb_factory: Callable[[Category], str], + is_admin: bool = False, ) -> InlineKeyboardMarkup: btns = [ InlineKeyboardButton( @@ -68,18 +72,27 @@ def render_category( for category in children ] - nav = [] + nav: list[list[InlineKeyboardButton]] = [] - if parent_id is not None: + if is_admin: nav.append( - InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}") + [ + InlineKeyboardButton( + text="📄 Добавить категорию", + callback_data=f"new:category:{parent_id}", + ) + ] ) if show_menu: - nav.append(InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")) + nav.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]) + elif parent_id is not None: + nav.append( + [InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")] + ) return InlineKeyboardBuilder( - [btns[i : i + 2] for i in range(0, len(children), 2)] + [nav], + [btns[i : i + 2] for i in range(0, len(children), 2)] + nav, ).as_markup() @@ -91,6 +104,8 @@ def render_products( prev_cb: Optional[str] = None, next_cb: Optional[str] = None, columns: int = 2, + is_admin: bool = False, + cat_id: Optional[int] = None, ) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() @@ -107,6 +122,13 @@ def render_products( ] builder.row(*row) + if is_admin: + builder.row( + InlineKeyboardButton( + text="📦 Добавить товар", callback_data=f"new:product:{cat_id}" + ) + ) + nav_buttons = [] if prev_cb: nav_buttons.append(InlineKeyboardButton(text="◀️", callback_data=prev_cb)) @@ -119,6 +141,32 @@ def render_products( return builder.as_markup() +def render_catalogue(view: CatalogueView, *, is_admin: bool): + if view.view_type == CatalogueType.CATEGORIES: + return render_category( + children=view.children[:PAGE_SIZE], # type: ignore + parent_id=view.parent_id, + show_menu=view.show_menu, + category_cb_factory=lambda c: f"cat:{c.id}", + is_admin=is_admin, + ) + if view.view_type == CatalogueType.PRODUCTS: + return render_products( + products=view.products[:PAGE_SIZE], # type: ignore + product_cb_factory=lambda p: f"product:{p.id}", + back_cb=f"cat:{view.parent_id}", + prev_cb=( + f"products:{view.category}:{view.page - 1}" if view.page > 0 else None + ), + next_cb=( + f"products:{view.category}:{view.page + 1}" if view.has_next else None + ), + is_admin=is_admin, + ) + + return return_menu + + def render_product_interactions( *, product_id: int, category_id: int, show_cart: bool = False, cart_amount: int = 0 ) -> InlineKeyboardMarkup: diff --git a/misc/redis.py b/misc/redis.py new file mode 100644 index 0000000..03b74ae --- /dev/null +++ b/misc/redis.py @@ -0,0 +1,27 @@ +import logging +from typing import Optional + +from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + + +class RedisClient: + def __init__(self, r: Redis): + self.r = r + + async def set_search_query(self, query_hash: str, q: str, ttl: int = 3600) -> bool: + try: + await self.r.set(f"search:{query_hash}", q, ex=ttl) + return True + except Exception as e: + logger.exception(e) + return False + + async def get_search_query(self, query_hash: str) -> Optional[str]: + try: + res = await self.r.get(f"search:{query_hash}") + return res.decode() + except Exception as e: + logger.exception(e) + return None diff --git a/requirements.txt b/requirements.txt index 2c1c3f9..73f5cd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ asyncpg sqlalchemy[asyncio] psycopg2 phonenumbers -ruff \ No newline at end of file +ruff +redis \ No newline at end of file diff --git a/services/catalogue.py b/services/catalogue.py new file mode 100644 index 0000000..4b44c2e --- /dev/null +++ b/services/catalogue.py @@ -0,0 +1,54 @@ +from typing import Union, Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from dto.catalogue import CatalogueType, CatalogueView +from repositories import ProductRepository, CategoriesRepository +from config import PAGE_SIZE + + +class CatalogueService: + def __init__( + self, product_repo: ProductRepository, categories_repo: CategoriesRepository + ) -> None: + self.product_repo = product_repo + self.categories_repo = categories_repo + + async def build_category_view( + self, session: AsyncSession, cat_id: Optional[Union[int, str]], page: int = 0 + ) -> CatalogueView: + cat_id = ( + int(cat_id) + if isinstance(cat_id, (int, str)) and str(cat_id).isdigit() + else None + ) + + view = CatalogueView(page=page) + children = await self.categories_repo.get_categories_by_parent_id( + session, cat_id + ) + category = await self.categories_repo.get_category_by_id(session, cat_id) + + if not category: # Root category + view.category = "root" + view.show_menu = True + else: # Subcategories + view.category = cat_id + view.parent_id = category.parent_id or "root" + + if not children: # Products + view.view_type = CatalogueType.PRODUCTS + products = await self.product_repo.get_product_by_category( + session, cat_id, limit=PAGE_SIZE + 1, offset=view.page * PAGE_SIZE + ) + + if not products: + view.view_type = CatalogueType.EMPTY + + view.products = products[:PAGE_SIZE] + view.has_next = len(products) > PAGE_SIZE + return view + + view.view_type = CatalogueType.CATEGORIES + view.children = children[:PAGE_SIZE] + + return view