Category rendering, DB columns refined.

This commit is contained in:
2026-02-04 18:09:33 +07:00
parent d0b2a78618
commit 0e95c2986a
16 changed files with 215 additions and 15 deletions

View File

@@ -0,0 +1,36 @@
"""Optional constraints fixed, price argument added for product
Revision ID: 8756e344268a
Revises: af58496e0468
Create Date: 2026-02-04 17:23:47.783119
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "8756e344268a"
down_revision: Union[str, Sequence[str], None] = "af58496e0468"
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("categories", "name", existing_type=sa.TEXT(), nullable=False)
op.add_column("products", sa.Column("price", sa.Integer(), nullable=False))
op.alter_column("products", "name", existing_type=sa.TEXT(), nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("products", "name", existing_type=sa.TEXT(), nullable=True)
op.drop_column("products", "price")
op.alter_column("categories", "name", existing_type=sa.TEXT(), nullable=True)
# ### end Alembic commands ###

View File

@@ -8,5 +8,6 @@ logger = logging.getLogger(__name__)
BOT_TOKEN = os.getenv("BOT_TOKEN", "") BOT_TOKEN = os.getenv("BOT_TOKEN", "")
POSTGRES_URL = os.getenv( POSTGRES_URL = os.getenv(
"POSTGRES_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" "POSTGRES_URL",
"postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres",
) )

View File

@@ -9,7 +9,7 @@ class Category(Base):
__tablename__ = "categories" __tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[Optional[str]] = mapped_column(Text) name: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("categories.id")) parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("categories.id"))
parent: Mapped["Category"] = relationship("Category", remote_side=[id]) parent: Mapped["Category"] = relationship("Category", remote_side=[id])
@@ -20,8 +20,9 @@ class Product(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
category_id: Mapped[Optional[int]] = mapped_column(ForeignKey("categories.id")) category_id: Mapped[Optional[int]] = mapped_column(ForeignKey("categories.id"))
name: Mapped[Optional[str]] = mapped_column(Text) 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()
search_vector: Mapped[str] = mapped_column(TSVECTOR) search_vector: Mapped[str] = mapped_column(TSVECTOR)

0
dto/__init__.py Normal file
View File

7
dto/categories.py Normal file
View File

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

View File

@@ -1,10 +1,12 @@
from aiogram import Router from aiogram import Router, F
from aiogram.types import 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 misc.kb import main_menu_kb from misc.kb import main_menu_kb, render_category
from repositories.categories import CategoriesRepository
from services.categories import CategoryService
from services.orders import OrderService from services.orders import OrderService
router = Router() router = Router()
@@ -19,6 +21,15 @@ async def main_menu(msg: Message, state: FSMContext):
) # TODO: Write a welcome message ) # TODO: Write a welcome message
@router.callback_query(F.data.startswith("menu:"))
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)
) # TODO: Write a welcome message
@router.message(Command("orders")) @router.message(Command("orders"))
async def get_orders( async def get_orders(
msg: Message, msg: Message,
@@ -30,3 +41,33 @@ async def get_orders(
count = await order_service.get_order_count(session, msg.from_user.id) count = await order_service.get_order_count(session, msg.from_user.id)
await msg.answer(f"you have {count} orders.") await msg.answer(f"you have {count} orders.")
@router.callback_query(F.data.startswith("cat:"))
async def subcatalogue(
cb: CallbackQuery,
state: FSMContext,
category_service: CategoryService,
session: AsyncSession,
categories_repo: CategoriesRepository,
):
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)
else:
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)
await cb.message.edit_text(
"category selection",
reply_markup=kb,
)

11
main.py
View File

@@ -8,9 +8,11 @@ from handlers import init_handlers
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 repositories.categories import CategoriesRepository
from repositories.orders import OrderRepository from repositories.orders import OrderRepository
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)
@@ -26,11 +28,18 @@ async def main():
order_repository = OrderRepository() order_repository = OrderRepository()
order_service = OrderService(order_repository) order_service = OrderService(order_repository)
category_repository = CategoriesRepository()
category_service = CategoryService(category_repository)
# DI Middleware Init # DI Middleware Init
dp.message.middleware(DIMiddleware(order_service)) dp.message.middleware(DIMiddleware(order_service, category_service))
dp.message.middleware(DBSessionMiddleware(async_session)) dp.message.middleware(DBSessionMiddleware(async_session))
dp.message.middleware(RepositoryMiddleware()) dp.message.middleware(RepositoryMiddleware())
dp.callback_query.middleware(DIMiddleware(order_service, category_service))
dp.callback_query.middleware(DBSessionMiddleware(async_session))
dp.callback_query.middleware(RepositoryMiddleware())
init_handlers(dp) init_handlers(dp)
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@@ -1,12 +1,14 @@
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): def __init__(self, order_service: OrderService, category_service: CategoryService):
self._order_service = order_service self._order_service = order_service
self._category_service = category_service
async def __call__( async def __call__(
self, self,
@@ -15,5 +17,6 @@ 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,9 +1,10 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from repositories.orders import OrderRepository from repositories import OrderRepository, CategoriesRepository
class RepositoryMiddleware(BaseMiddleware): class RepositoryMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data): async def __call__(self, handler, event, data):
data["order_repo"] = OrderRepository() data["order_repo"] = OrderRepository()
data["categories_repo"] = CategoriesRepository()
return await handler(event, data) return await handler(event, data)

View File

@@ -1,20 +1,58 @@
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
async def cart_btn(user_id: int) -> InlineKeyboardButton:
def cart_btn(user_id: int) -> InlineKeyboardButton:
return InlineKeyboardButton( return InlineKeyboardButton(
text="🛒 Корзина ()", callback_data="cart" text="🛒 Корзина ()", callback_data="cart"
) # TODO: Database integration, get cart items count ) # 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)
async def main_menu_kb(user_id: int) -> InlineKeyboardMarkup: async def main_menu_kb(user_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder( return InlineKeyboardBuilder(
[ [
[ [
InlineKeyboardButton(text="📦 Каталог", callback_data="catalogue"), InlineKeyboardButton(text="📦 Каталог", callback_data="cat:root"),
InlineKeyboardButton(text="🔍", callback_data="find"), InlineKeyboardButton(text="🔍", callback_data="find"),
], ],
[await cart_btn(user_id)], [cart_btn(user_id)],
] ]
).as_markup() ).as_markup()
async def render_category(
children: list[CategoryDTO],
parent_id: Optional[int] = None,
show_menu: bool = False,
) -> InlineKeyboardMarkup:
btns = [
InlineKeyboardButton(
text=category.name,
callback_data=f"cat:{category.id}",
)
for category in children
]
nav = []
if parent_id is not None:
nav.append(
InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")
)
if show_menu:
nav.append(InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main"))
return InlineKeyboardBuilder(
[btns[i : i + 2] for i in range(0, len(children), 2)] + [nav],
).as_markup()

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from db.models import Category
class CategoriesRepository:
async def get_category_by_id(
self, session: AsyncSession, category_id: int
) -> Optional[Category]:
stmt = select(Category).where(Category.id == category_id)
return await session.scalar(stmt)
async def get_category_by_parent_id(
self, session: AsyncSession, parent_id: Optional[int] = None
) -> list[Category]:
stmt = select(Category).where(Category.parent_id == parent_id)
result = await session.scalars(stmt)
return list(result)

View File

@@ -1,8 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import Optional, Union, List from typing import Optional
from db.models import Order, OrderItem, OrderStatus from db.models import Order
class OrderRepository: class OrderRepository:

18
services/categories.py Normal file
View File

@@ -0,0 +1,18 @@
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)

19
startup.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -e
echo "formatting..."
black .
echo "checking psql..."
if ! pg_isready -h localhost -p 5432; then
echo "psql is down, try again" >&2
exit 1
fi
echo "psql is up and is recieving connections."
echo "running migrations..."
alembic upgrade head
echo "starting the bot..."
exec python main.py