Unification of pagination, admins control and more
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
24
dto/catalogue.py
Normal file
24
dto/catalogue.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:"))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
13
main.py
13
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
27
misc/redis.py
Normal file
27
misc/redis.py
Normal file
@@ -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
|
||||
@@ -6,4 +6,5 @@ asyncpg
|
||||
sqlalchemy[asyncio]
|
||||
psycopg2
|
||||
phonenumbers
|
||||
ruff
|
||||
ruff
|
||||
redis
|
||||
54
services/catalogue.py
Normal file
54
services/catalogue.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user