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", "")) card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", ""))
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1")
### Constants ### ### Constants ###
PAGE_SIZE = 4 PAGE_SIZE = 4

View File

@@ -1,3 +1,12 @@
from .cart import CartItemDTO 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 fi
echo "psql is up and is recieving connections." 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
echo "running migrations..." echo "running migrations..."
alembic upgrade head alembic upgrade head

View File

@@ -3,14 +3,11 @@ from aiogram.types import CallbackQuery
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 VERIFIED_ACCOUNTS
from misc.kb import render_category
from misc.kb.client import ( from misc.kb.client import (
get_back_to_catalogue, render_catalogue,
render_products,
) )
from repositories.categories import CategoriesRepository from services.catalogue import CatalogueService
from repositories.products import ProductRepository
router = Router() router = Router()
@@ -20,80 +17,23 @@ async def subcatalogue(
cb: CallbackQuery, cb: CallbackQuery,
state: FSMContext, state: FSMContext,
session: AsyncSession, session: AsyncSession,
categories_repo: CategoriesRepository, catalogue_service: CatalogueService,
products_repo: ProductRepository,
): ):
await state.clear() await state.clear()
cat_id = cb.data.split(":")[1] cat_id = cb.data.split(":")[1]
is_admin = cb.from_user.id in VERIFIED_ACCOUNTS
if cat_id == "root": view = await catalogue_service.build_category_view(session, cat_id)
children = await categories_repo.get_categories_by_parent_id(session) kb = render_catalogue(view, is_admin=is_admin)
kb = render_category(
children, show_menu=True, category_cb_factory=lambda c: f"cat:{c.id}" if cb.message.photo:
) await cb.message.delete()
await cb.message.edit_text( await cb.answer(
"category selection", "category selection",
reply_markup=kb, reply_markup=kb,
) )
return 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( await cb.message.edit_text(
"category selection", "category selection",
reply_markup=kb, reply_markup=kb,

View File

@@ -5,15 +5,15 @@ from aiogram.types import CallbackQuery, FSInputFile
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, VERIFIED_ACCOUNTS from config import VERIFIED_ACCOUNTS
from misc.kb import admins from misc.kb import admins
from misc.kb.client import ( from misc.kb.client import (
render_catalogue,
render_product_interactions, render_product_interactions,
render_products,
) )
from misc.texts import get_product_description from misc.texts import get_product_description
from repositories.categories import CategoriesRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository
from services.catalogue import CatalogueService
from services.orders import OrderService from services.orders import OrderService
router = Router() router = Router()
@@ -29,35 +29,22 @@ async def products_pagination(
state: FSMContext, state: FSMContext,
pagination_match: Match[str], pagination_match: Match[str],
session: AsyncSession, session: AsyncSession,
products_repo: ProductRepository, catalogue_service: CatalogueService,
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( view = await catalogue_service.build_category_view(
session, category_id, limit=PAGE_SIZE + 1, offset=page_id * PAGE_SIZE 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( kb = render_catalogue(view, is_admin=is_admin)
reply_markup=render_products(
products[:PAGE_SIZE], await cb.message.edit_reply_markup(reply_markup=kb)
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,
),
)
@router.callback_query(F.data.startswith("product:")) @router.callback_query(F.data.startswith("product:"))

View File

@@ -1,12 +1,15 @@
from typing import Optional from typing import Optional
import hashlib
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import PAGE_SIZE
from misc.kb.client import render_products from misc.kb.client import render_products
from misc.kb.common import return_menu from misc.kb.common import return_menu
from misc.redis import RedisClient
from misc.states import SearchStorage from misc.states import SearchStorage
from repositories.products import ProductRepository from repositories.products import ProductRepository
@@ -28,31 +31,62 @@ async def searching(
state: FSMContext, state: FSMContext,
session: AsyncSession, session: AsyncSession,
products_repo: ProductRepository, products_repo: ProductRepository,
redis_client: RedisClient,
): ):
data = await state.get_data() data = await state.get_data()
await state.clear() 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( results = await products_repo.search(
session, session,
msg.text, msg.text,
) )
if not orig_msg: next_cb = None
await msg.answer( if len(results) > PAGE_SIZE:
"here", query_hash = hashlib.sha256(msg.text.encode()).hexdigest()[:16]
reply_markup=render_products( await redis_client.set_search_query(query_hash, msg.text)
results, next_cb = f"search:{query_hash}:1"
product_cb_factory=lambda p: f"product:{p.id}",
back_cb="menu:main",
next_cb="",
),
)
return
await orig_msg.edit_text( await orig_msg.edit_text(
"here", "here",
reply_markup=render_products( 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 import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties 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 handlers import admins_routers, client_routers
from middlewares.di import DIMiddleware 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 misc.redis import RedisClient
from repositories.categories import CategoriesRepository from repositories.categories import CategoriesRepository
from repositories.invoices import InvoiceRepository from repositories.invoices import InvoiceRepository
from repositories.order_items import OrderItemRepository from repositories.order_items import OrderItemRepository
@@ -17,6 +19,7 @@ from repositories.orders import OrderRepository
from repositories.products import ProductRepository from repositories.products import ProductRepository
from services import OrderService from services import OrderService
from db.base import async_session from db.base import async_session
from services.catalogue import CatalogueService
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@@ -36,13 +39,17 @@ async def main():
order_service = OrderService(order_repository, order_items_repository) order_service = OrderService(order_repository, order_items_repository)
category_repository = CategoriesRepository() category_repository = CategoriesRepository()
product_repository = ProductRepository() product_repository = ProductRepository()
catalogue_service = CatalogueService(product_repository, category_repository)
invoice_repository = InvoiceRepository() invoice_repository = InvoiceRepository()
# Additional Initiations
r = Redis.from_url(REDIS_URL)
redis_client = RedisClient(r)
# DI Middleware Init # 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(DBSessionMiddleware(async_session))
dp.update.middleware( dp.update.middleware(
RepositoryMiddleware( RepositoryMiddleware(

View File

@@ -1,12 +1,22 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from typing import Any, Callable from typing import Any, Callable
from redis.asyncio import Redis
from services.orders import OrderService from services.orders import OrderService
from services.catalogue import CatalogueService
class DIMiddleware(BaseMiddleware): 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._order_service = order_service
self._redis_client = redis_client
self._catalogue_service = catalogue_service
async def __call__( async def __call__(
self, self,
@@ -15,5 +25,7 @@ class DIMiddleware(BaseMiddleware):
data: dict[str, Any], data: dict[str, Any],
) -> Any: ) -> Any:
data["order_service"] = self._order_service data["order_service"] = self._order_service
data["catalogue_service"] = self._catalogue_service
data["redis_client"] = self._redis_client
return await handler(event, data) return await handler(event, data)

View File

@@ -3,8 +3,11 @@ from typing import Optional, Callable
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import PAGE_SIZE
from db.models import Category, Product from db.models import Category, Product
from dto.cart import CartItemDTO from dto.cart import CartItemDTO
from dto.catalogue import CatalogueView, CatalogueType
from .common import return_menu
def pagination_row( def pagination_row(
@@ -59,6 +62,7 @@ def render_category(
show_menu: bool = False, show_menu: bool = False,
*, *,
category_cb_factory: Callable[[Category], str], category_cb_factory: Callable[[Category], str],
is_admin: bool = False,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
btns = [ btns = [
InlineKeyboardButton( InlineKeyboardButton(
@@ -68,18 +72,27 @@ def render_category(
for category in children for category in children
] ]
nav = [] nav: list[list[InlineKeyboardButton]] = []
if parent_id is not None: if is_admin:
nav.append( nav.append(
InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}") [
InlineKeyboardButton(
text="📄 Добавить категорию",
callback_data=f"new:category:{parent_id}",
)
]
) )
if show_menu: 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( 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() ).as_markup()
@@ -91,6 +104,8 @@ def render_products(
prev_cb: Optional[str] = None, prev_cb: Optional[str] = None,
next_cb: Optional[str] = None, next_cb: Optional[str] = None,
columns: int = 2, columns: int = 2,
is_admin: bool = False,
cat_id: Optional[int] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -107,6 +122,13 @@ def render_products(
] ]
builder.row(*row) builder.row(*row)
if is_admin:
builder.row(
InlineKeyboardButton(
text="📦 Добавить товар", callback_data=f"new:product:{cat_id}"
)
)
nav_buttons = [] nav_buttons = []
if prev_cb: if prev_cb:
nav_buttons.append(InlineKeyboardButton(text="◀️", callback_data=prev_cb)) nav_buttons.append(InlineKeyboardButton(text="◀️", callback_data=prev_cb))
@@ -119,6 +141,32 @@ def render_products(
return builder.as_markup() 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( def render_product_interactions(
*, product_id: int, category_id: int, show_cart: bool = False, cart_amount: int = 0 *, product_id: int, category_id: int, show_cart: bool = False, cart_amount: int = 0
) -> InlineKeyboardMarkup: ) -> 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

@@ -6,4 +6,5 @@ asyncpg
sqlalchemy[asyncio] sqlalchemy[asyncio]
psycopg2 psycopg2
phonenumbers phonenumbers
ruff 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