Unification of pagination, admins control and more

This commit is contained in:
2026-03-02 19:02:20 +07:00
parent 035170e46f
commit eab67ccfc1
13 changed files with 271 additions and 118 deletions

View File

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

View File

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

View File

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

View File

@@ -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(
"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
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.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,
),
await cb.answer(
"category selection",
reply_markup=kb,
)
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,

View File

@@ -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:"))

View File

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

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

View File

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

View File

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

View File

@@ -7,3 +7,4 @@ sqlalchemy[asyncio]
psycopg2
phonenumbers
ruff
redis

54
services/catalogue.py Normal file
View 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