Products Rendering, removal of deprecated files

This commit is contained in:
2026-02-06 20:08:05 +07:00
parent 0e95c2986a
commit d8cad25f33
17 changed files with 277 additions and 62 deletions

3
.gitignore vendored
View File

@@ -173,3 +173,6 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Static / IMG
static/img

View File

@@ -0,0 +1,32 @@
"""file id not null constraint edited -> true
Revision ID: 2c9553f99e03
Revises: e9de494cf92c
Create Date: 2026-02-06 19:39:28.107493
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2c9553f99e03"
down_revision: Union[str, Sequence[str], None] = "e9de494cf92c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("products", "file_id", existing_type=sa.TEXT(), nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("products", "file_id", existing_type=sa.TEXT(), nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Img Path + File Id added
Revision ID: e9de494cf92c
Revises: 8756e344268a
Create Date: 2026-02-06 19:19:44.437326
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e9de494cf92c"
down_revision: Union[str, Sequence[str], None] = "8756e344268a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("products", sa.Column("img_path", sa.Text(), nullable=False))
op.add_column("products", sa.Column("file_id", sa.Text(), nullable=False))
op.create_unique_constraint(None, "products", ["file_id"])
op.create_unique_constraint(None, "products", ["img_path"])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "products", type_="unique")
op.drop_constraint(None, "products", type_="unique")
op.drop_column("products", "file_id")
op.drop_column("products", "img_path")
# ### end Alembic commands ###

View File

@@ -5,9 +5,15 @@ from dotenv import load_dotenv
load_dotenv(override=True) load_dotenv(override=True)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
### Environment Variables ###
BOT_TOKEN = os.getenv("BOT_TOKEN", "") BOT_TOKEN = os.getenv("BOT_TOKEN", "")
POSTGRES_URL = os.getenv( POSTGRES_URL = os.getenv(
"POSTGRES_URL", "POSTGRES_URL",
"postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres", "postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres",
) )
### Constants ###
PAGE_SIZE = 5

View File

@@ -23,6 +23,8 @@ class Product(Base):
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text) description: Mapped[Optional[str]] = mapped_column(Text)
price: Mapped[int] = mapped_column() price: Mapped[int] = mapped_column()
img_path: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
file_id: Mapped[str] = mapped_column(Text, nullable=True, unique=True)
search_vector: Mapped[str] = mapped_column(TSVECTOR) search_vector: Mapped[str] = mapped_column(TSVECTOR)

View File

View File

@@ -1,7 +0,0 @@
from dataclasses import dataclass
@dataclass(slots=True)
class CategoryDTO:
id: int
name: str

View File

@@ -1,12 +1,15 @@
from re import Match
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from aiogram.filters import Command from aiogram.filters import Command
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 misc.kb import main_menu_kb, render_category from misc.kb import main_menu_kb, render_category
from misc.kb.client import get_back_to_catalogue, render_products
from repositories.categories import CategoriesRepository from repositories.categories import CategoriesRepository
from services.categories import CategoryService from repositories.products import ProductRepository
from services.orders import OrderService from services.orders import OrderService
router = Router() router = Router()
@@ -17,7 +20,7 @@ async def main_menu(msg: Message, state: FSMContext):
await state.clear() await state.clear()
await msg.answer( await msg.answer(
"hii!", reply_markup=await main_menu_kb(msg.from_user.id) "hii!", reply_markup=main_menu_kb(msg.from_user.id)
) # TODO: Write a welcome message ) # TODO: Write a welcome message
@@ -26,7 +29,7 @@ async def main_menu_cb(cb: CallbackQuery, state: FSMContext):
await state.clear() await state.clear()
await cb.message.edit_text( await cb.message.edit_text(
"hii!", reply_markup=await main_menu_kb(cb.from_user.id) "hii!", reply_markup=main_menu_kb(cb.from_user.id)
) # TODO: Write a welcome message ) # TODO: Write a welcome message
@@ -47,27 +50,91 @@ async def get_orders(
async def subcatalogue( async def subcatalogue(
cb: CallbackQuery, cb: CallbackQuery,
state: FSMContext, state: FSMContext,
category_service: CategoryService,
session: AsyncSession, session: AsyncSession,
categories_repo: CategoriesRepository, categories_repo: CategoriesRepository,
products_repo: ProductRepository,
): ):
await state.clear() await state.clear()
cat_id = cb.data.split(":")[1] cat_id = cb.data.split(":")[1]
if cat_id == "root": if cat_id == "root":
children = await category_service.get_children(session) children = await categories_repo.get_categories_by_parent_id(session)
kb = await render_category(children, show_menu=True) kb = render_category(children, show_menu=True)
await cb.message.edit_text(
"category selection",
reply_markup=kb,
)
return
else:
cat_id = int(cat_id) cat_id = int(cat_id)
children = await category_service.get_children(session, 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) category = await categories_repo.get_category_by_id(session, cat_id)
parent_id = category.parent_id or "root" parent_id = category.parent_id or "root"
kb = await render_category(children, parent_id)
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
await cb.message.edit_text(
"hya",
reply_markup=render_products(
products[:PAGE_SIZE],
cat_id=cat_id,
page=0,
show_prev=products_len < 0,
show_next=products_len > PAGE_SIZE,
),
)
return
kb = render_category(children, parent_id)
await cb.message.edit_text( await cb.message.edit_text(
"category selection", "category selection",
reply_markup=kb, reply_markup=kb,
) )
@router.callback_query(
F.data.regexp(r"products:(?P<category_id>.*):(?P<page_id>\d+)").as_(
"pagination_match"
)
)
async def products_pagination(
cb: CallbackQuery,
state: FSMContext,
pagination_match: Match[str],
session: AsyncSession,
products_repo: ProductRepository,
):
await state.clear()
pagination_data = pagination_match.groupdict()
category_id = int(pagination_data.get("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
)
products_len = len(products)
await cb.message.edit_reply_markup(
reply_markup=render_products(
products[:PAGE_SIZE],
cat_id=category_id,
page=page_id,
show_prev=page_id > 0,
show_next=products_len > PAGE_SIZE,
),
)

19
main.py
View File

@@ -1,3 +1,4 @@
import os
import asyncio import asyncio
import logging import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
@@ -10,14 +11,15 @@ from middlewares.repository import RepositoryMiddleware
from middlewares.session import DBSessionMiddleware from middlewares.session import DBSessionMiddleware
from repositories.categories import CategoriesRepository from repositories.categories import CategoriesRepository
from repositories.orders import OrderRepository from repositories.orders import OrderRepository
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.categories import CategoryService
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
os.makedirs("static/img", exist_ok=True)
async def main(): async def main():
dp = Dispatcher() dp = Dispatcher()
@@ -29,16 +31,21 @@ async def main():
order_service = OrderService(order_repository) order_service = OrderService(order_repository)
category_repository = CategoriesRepository() category_repository = CategoriesRepository()
category_service = CategoryService(category_repository)
product_repository = ProductRepository()
# DI Middleware Init # DI Middleware Init
dp.message.middleware(DIMiddleware(order_service, category_service)) dp.message.middleware(DIMiddleware(order_service))
dp.message.middleware(DBSessionMiddleware(async_session)) dp.message.middleware(DBSessionMiddleware(async_session))
dp.message.middleware(RepositoryMiddleware()) dp.message.middleware(
RepositoryMiddleware(order_repository, category_repository, product_repository)
)
dp.callback_query.middleware(DIMiddleware(order_service, category_service)) dp.callback_query.middleware(DIMiddleware(order_service))
dp.callback_query.middleware(DBSessionMiddleware(async_session)) dp.callback_query.middleware(DBSessionMiddleware(async_session))
dp.callback_query.middleware(RepositoryMiddleware()) dp.callback_query.middleware(
RepositoryMiddleware(order_repository, category_repository, product_repository)
)
init_handlers(dp) init_handlers(dp)

View File

@@ -1,14 +1,12 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from typing import Any, Callable from typing import Any, Callable
from services.categories import CategoryService
from services.orders import OrderService from services.orders import OrderService
class DIMiddleware(BaseMiddleware): class DIMiddleware(BaseMiddleware):
def __init__(self, order_service: OrderService, category_service: CategoryService): def __init__(self, order_service: OrderService):
self._order_service = order_service self._order_service = order_service
self._category_service = category_service
async def __call__( async def __call__(
self, self,
@@ -17,6 +15,5 @@ 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["category_service"] = self._category_service
return await handler(event, data) return await handler(event, data)

View File

@@ -1,10 +1,22 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from repositories import OrderRepository, CategoriesRepository from repositories import OrderRepository, CategoriesRepository
from repositories.products import ProductRepository
class RepositoryMiddleware(BaseMiddleware): class RepositoryMiddleware(BaseMiddleware):
def __init__(
self,
order_repo: OrderRepository,
categories_repo: CategoriesRepository,
products_repo: ProductRepository,
) -> None:
self._order_repo = order_repo
self._categories_repo = categories_repo
self._products_repo = products_repo
async def __call__(self, handler, event, data): async def __call__(self, handler, event, data):
data["order_repo"] = OrderRepository() data["order_repo"] = self._order_repo
data["categories_repo"] = CategoriesRepository() data["categories_repo"] = self._categories_repo
data["products_repo"] = self._products_repo
return await handler(event, data) return await handler(event, data)

View File

@@ -3,7 +3,8 @@ from typing import Optional
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 dto.categories import CategoryDTO from db.models import Category
from db.models.products import Product
def cart_btn(user_id: int) -> InlineKeyboardButton: def cart_btn(user_id: int) -> InlineKeyboardButton:
@@ -12,13 +13,15 @@ def cart_btn(user_id: int) -> InlineKeyboardButton:
) # TODO: Database integration, get cart items count ) # TODO: Database integration, get cart items count
def get_back_btn( def get_back_to_catalogue(
data: str = "menu:main", text: Optional[str] = "⬅️ Назад" cat_id: int, *, text: Optional[str] = "⬅️ Назад"
) -> InlineKeyboardButton: ) -> InlineKeyboardMarkup:
return InlineKeyboardButton(text=text, callback_data=data) return InlineKeyboardBuilder(
[[InlineKeyboardButton(text=text, callback_data=f"cat:{cat_id}")]]
).as_markup()
async def main_menu_kb(user_id: int) -> InlineKeyboardMarkup: def main_menu_kb(user_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder( return InlineKeyboardBuilder(
[ [
[ [
@@ -30,8 +33,8 @@ async def main_menu_kb(user_id: int) -> InlineKeyboardMarkup:
).as_markup() ).as_markup()
async def render_category( def render_category(
children: list[CategoryDTO], children: list[Category],
parent_id: Optional[int] = None, parent_id: Optional[int] = None,
show_menu: bool = False, show_menu: bool = False,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
@@ -56,3 +59,48 @@ async def render_category(
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()
def render_products(
products: list[Product],
*,
cat_id: int,
page: int = 0,
show_prev: Optional[bool] = False,
show_next: Optional[bool] = False,
) -> InlineKeyboardMarkup:
btns = [
[InlineKeyboardButton(text=product.name, callback_data=f"product:{product.id}")]
for product in products
]
nav = []
if show_prev:
nav.append(
InlineKeyboardButton(
text="⬅️", callback_data=f"products:{cat_id}:{page - 1}"
)
)
nav.append(InlineKeyboardButton(text=str(page), callback_data="..."))
if show_next:
nav.append(
InlineKeyboardButton(
text="➡️", callback_data=f"products:{cat_id}:{page + 1}"
)
)
return InlineKeyboardBuilder(
[
[
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"cat:{cat_id}",
)
]
]
+ btns
+ [nav]
).as_markup()

View File

@@ -1,2 +1 @@
docker start test-postgres docker run --name test-postgres -e POSTGRES_PASSWORD=HEXDEVFUCKINGSUCKS! -p 5432:5432 -d postgres
pg_isready -h localhost -p 5432

View File

@@ -1,4 +1,5 @@
from .orders import OrderRepository from .orders import OrderRepository
from .categories import CategoriesRepository from .categories import CategoriesRepository
from .products import ProductRepository
__all__ = ["OrderRepository", "CategoriesRepository"] __all__ = ["OrderRepository", "CategoriesRepository", "ProductRepository"]

View File

@@ -12,7 +12,7 @@ class CategoriesRepository:
stmt = select(Category).where(Category.id == category_id) stmt = select(Category).where(Category.id == category_id)
return await session.scalar(stmt) return await session.scalar(stmt)
async def get_category_by_parent_id( async def get_categories_by_parent_id(
self, session: AsyncSession, parent_id: Optional[int] = None self, session: AsyncSession, parent_id: Optional[int] = None
) -> list[Category]: ) -> list[Category]:
stmt = select(Category).where(Category.parent_id == parent_id) stmt = select(Category).where(Category.parent_id == parent_id)

28
repositories/products.py Normal file
View File

@@ -0,0 +1,28 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from db.models import Product
class ProductRepository:
async def get_product_by_id(
self, session: AsyncSession, product_id: int
) -> Optional[Product]:
stmt = select(Product).where(Product.id == product_id)
return await session.scalar(stmt)
async def get_product_by_category(
self, session: AsyncSession, category_id: int, *, limit: int, offset: int
) -> list[Product]:
stmt = (
select(Product)
.where(Product.category_id == category_id)
.order_by(Product.id)
.offset(offset)
.limit(limit)
)
result = await session.scalars(stmt)
return list(result)

View File

@@ -1,18 +0,0 @@
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from dto.categories import CategoryDTO
from repositories import CategoriesRepository
class CategoryService:
def __init__(self, category_repo: CategoriesRepository):
self.category_repo = category_repo
async def get_children(
self, session: AsyncSession, parent_id: Optional[int] = None
) -> list[CategoryDTO]:
result = await self.category_repo.get_category_by_parent_id(session, parent_id)
return [
CategoryDTO(x.id, x.name) for x in result
] # FIXME: Optimization may be required here - O(n)