From d8cad25f338488d485f06b1b32764a2781f80bbb Mon Sep 17 00:00:00 2001 From: hexdev Date: Fri, 6 Feb 2026 20:08:05 +0700 Subject: [PATCH] Products Rendering, removal of deprecated files --- .gitignore | 5 +- ...file_id_not_null_constraint_edited_true.py | 32 +++++++ .../e9de494cf92c_img_path_file_id_added.py | 38 ++++++++ config.py | 6 ++ db/models/products.py | 2 + dto/__init__.py | 0 dto/categories.py | 7 -- handlers/client.py | 91 ++++++++++++++++--- main.py | 19 ++-- middlewares/di.py | 5 +- middlewares/repository.py | 16 +++- misc/kb/client.py | 64 +++++++++++-- postgresql.fish | 3 +- repositories/__init__.py | 3 +- repositories/categories.py | 2 +- repositories/products.py | 28 ++++++ services/categories.py | 18 ---- 17 files changed, 277 insertions(+), 62 deletions(-) create mode 100644 alembic/versions/2c9553f99e03_file_id_not_null_constraint_edited_true.py create mode 100644 alembic/versions/e9de494cf92c_img_path_file_id_added.py delete mode 100644 dto/__init__.py delete mode 100644 dto/categories.py create mode 100644 repositories/products.py delete mode 100644 services/categories.py diff --git a/.gitignore b/.gitignore index ec981db..fb5aab0 100644 --- a/.gitignore +++ b/.gitignore @@ -172,4 +172,7 @@ cython_debug/ .ruff_cache/ # PyPI configuration file -.pypirc \ No newline at end of file +.pypirc + +# Static / IMG +static/img \ No newline at end of file diff --git a/alembic/versions/2c9553f99e03_file_id_not_null_constraint_edited_true.py b/alembic/versions/2c9553f99e03_file_id_not_null_constraint_edited_true.py new file mode 100644 index 0000000..2bbce18 --- /dev/null +++ b/alembic/versions/2c9553f99e03_file_id_not_null_constraint_edited_true.py @@ -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 ### diff --git a/alembic/versions/e9de494cf92c_img_path_file_id_added.py b/alembic/versions/e9de494cf92c_img_path_file_id_added.py new file mode 100644 index 0000000..9486a41 --- /dev/null +++ b/alembic/versions/e9de494cf92c_img_path_file_id_added.py @@ -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 ### diff --git a/config.py b/config.py index 4e23dca..663d60c 100644 --- a/config.py +++ b/config.py @@ -5,9 +5,15 @@ from dotenv import load_dotenv load_dotenv(override=True) logger = logging.getLogger(__name__) +### Environment Variables ### + BOT_TOKEN = os.getenv("BOT_TOKEN", "") POSTGRES_URL = os.getenv( "POSTGRES_URL", "postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres", ) + +### Constants ### + +PAGE_SIZE = 5 diff --git a/db/models/products.py b/db/models/products.py index 7b3d9a2..4bbc800 100644 --- a/db/models/products.py +++ b/db/models/products.py @@ -23,6 +23,8 @@ class Product(Base): name: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[Optional[str]] = mapped_column(Text) 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) diff --git a/dto/__init__.py b/dto/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dto/categories.py b/dto/categories.py deleted file mode 100644 index 5189f6a..0000000 --- a/dto/categories.py +++ /dev/null @@ -1,7 +0,0 @@ -from dataclasses import dataclass - - -@dataclass(slots=True) -class CategoryDTO: - id: int - name: str diff --git a/handlers/client.py b/handlers/client.py index 92073c5..7b40855 100644 --- a/handlers/client.py +++ b/handlers/client.py @@ -1,12 +1,15 @@ +from re import Match from aiogram import Router, F from aiogram.types import CallbackQuery, Message from aiogram.filters import Command from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession +from config import PAGE_SIZE 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 services.categories import CategoryService +from repositories.products import ProductRepository from services.orders import OrderService router = Router() @@ -17,7 +20,7 @@ async def main_menu(msg: Message, state: FSMContext): await state.clear() 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 @@ -26,7 +29,7 @@ async def main_menu_cb(cb: CallbackQuery, state: FSMContext): await state.clear() 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 @@ -47,27 +50,91 @@ async def get_orders( async def subcatalogue( cb: CallbackQuery, state: FSMContext, - category_service: CategoryService, session: AsyncSession, categories_repo: CategoriesRepository, + products_repo: ProductRepository, ): await state.clear() cat_id = cb.data.split(":")[1] if cat_id == "root": - children = await category_service.get_children(session) - kb = await render_category(children, show_menu=True) + children = await categories_repo.get_categories_by_parent_id(session) + 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) - category = await categories_repo.get_category_by_id(session, cat_id) - parent_id = category.parent_id or "root" - kb = await render_category(children, parent_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 + + 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( "category selection", reply_markup=kb, ) + + +@router.callback_query( + F.data.regexp(r"products:(?P.*):(?P\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, + ), + ) diff --git a/main.py b/main.py index eef6048..e770ece 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +import os import asyncio import logging from aiogram import Bot, Dispatcher @@ -10,14 +11,15 @@ from middlewares.repository import RepositoryMiddleware from middlewares.session import DBSessionMiddleware from repositories.categories import CategoriesRepository from repositories.orders import OrderRepository +from repositories.products import ProductRepository from services import OrderService from db.base import async_session -from services.categories import CategoryService logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) +os.makedirs("static/img", exist_ok=True) async def main(): dp = Dispatcher() @@ -29,16 +31,21 @@ async def main(): order_service = OrderService(order_repository) category_repository = CategoriesRepository() - category_service = CategoryService(category_repository) + + product_repository = ProductRepository() # 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(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(RepositoryMiddleware()) + dp.callback_query.middleware( + RepositoryMiddleware(order_repository, category_repository, product_repository) + ) init_handlers(dp) diff --git a/middlewares/di.py b/middlewares/di.py index 4c3e26a..d3acb6a 100644 --- a/middlewares/di.py +++ b/middlewares/di.py @@ -1,14 +1,12 @@ from aiogram import BaseMiddleware from typing import Any, Callable -from services.categories import CategoryService from services.orders import OrderService class DIMiddleware(BaseMiddleware): - def __init__(self, order_service: OrderService, category_service: CategoryService): + def __init__(self, order_service: OrderService): self._order_service = order_service - self._category_service = category_service async def __call__( self, @@ -17,6 +15,5 @@ class DIMiddleware(BaseMiddleware): data: dict[str, Any], ) -> Any: data["order_service"] = self._order_service - data["category_service"] = self._category_service return await handler(event, data) diff --git a/middlewares/repository.py b/middlewares/repository.py index 7b50694..961f3c2 100644 --- a/middlewares/repository.py +++ b/middlewares/repository.py @@ -1,10 +1,22 @@ from aiogram import BaseMiddleware from repositories import OrderRepository, CategoriesRepository +from repositories.products import ProductRepository 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): - data["order_repo"] = OrderRepository() - data["categories_repo"] = CategoriesRepository() + data["order_repo"] = self._order_repo + data["categories_repo"] = self._categories_repo + data["products_repo"] = self._products_repo return await handler(event, data) diff --git a/misc/kb/client.py b/misc/kb/client.py index ecdbcb3..a6c8e6c 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -3,7 +3,8 @@ from typing import Optional from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 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: @@ -12,13 +13,15 @@ def cart_btn(user_id: int) -> InlineKeyboardButton: ) # TODO: Database integration, get cart items count -def get_back_btn( - data: str = "menu:main", text: Optional[str] = "⬅️ Назад" -) -> InlineKeyboardButton: - return InlineKeyboardButton(text=text, callback_data=data) +def get_back_to_catalogue( + cat_id: int, *, text: Optional[str] = "⬅️ Назад" +) -> InlineKeyboardMarkup: + 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( [ [ @@ -30,8 +33,8 @@ async def main_menu_kb(user_id: int) -> InlineKeyboardMarkup: ).as_markup() -async def render_category( - children: list[CategoryDTO], +def render_category( + children: list[Category], parent_id: Optional[int] = None, show_menu: bool = False, ) -> InlineKeyboardMarkup: @@ -56,3 +59,48 @@ async def render_category( return InlineKeyboardBuilder( [btns[i : i + 2] for i in range(0, len(children), 2)] + [nav], ).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() diff --git a/postgresql.fish b/postgresql.fish index 55f0c03..1f96f03 100755 --- a/postgresql.fish +++ b/postgresql.fish @@ -1,2 +1 @@ -docker start test-postgres -pg_isready -h localhost -p 5432 \ No newline at end of file +docker run --name test-postgres -e POSTGRES_PASSWORD=HEXDEVFUCKINGSUCKS! -p 5432:5432 -d postgres \ No newline at end of file diff --git a/repositories/__init__.py b/repositories/__init__.py index 78afc50..7d3fed3 100644 --- a/repositories/__init__.py +++ b/repositories/__init__.py @@ -1,4 +1,5 @@ from .orders import OrderRepository from .categories import CategoriesRepository +from .products import ProductRepository -__all__ = ["OrderRepository", "CategoriesRepository"] +__all__ = ["OrderRepository", "CategoriesRepository", "ProductRepository"] diff --git a/repositories/categories.py b/repositories/categories.py index f9c6830..dd35f0b 100644 --- a/repositories/categories.py +++ b/repositories/categories.py @@ -12,7 +12,7 @@ class CategoriesRepository: stmt = select(Category).where(Category.id == category_id) 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 ) -> list[Category]: stmt = select(Category).where(Category.parent_id == parent_id) diff --git a/repositories/products.py b/repositories/products.py new file mode 100644 index 0000000..53cdff7 --- /dev/null +++ b/repositories/products.py @@ -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) diff --git a/services/categories.py b/services/categories.py deleted file mode 100644 index 0a36ff8..0000000 --- a/services/categories.py +++ /dev/null @@ -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)