diff --git a/alembic/versions/8756e344268a_optional_constraints_fixed_price_.py b/alembic/versions/8756e344268a_optional_constraints_fixed_price_.py new file mode 100644 index 0000000..b831a24 --- /dev/null +++ b/alembic/versions/8756e344268a_optional_constraints_fixed_price_.py @@ -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 ### diff --git a/config.py b/config.py index 4dc1b70..4e23dca 100644 --- a/config.py +++ b/config.py @@ -8,5 +8,6 @@ logger = logging.getLogger(__name__) BOT_TOKEN = os.getenv("BOT_TOKEN", "") POSTGRES_URL = os.getenv( - "POSTGRES_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" + "POSTGRES_URL", + "postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres", ) diff --git a/db/models/products.py b/db/models/products.py index aa188d2..7b3d9a2 100644 --- a/db/models/products.py +++ b/db/models/products.py @@ -9,7 +9,7 @@ class Category(Base): __tablename__ = "categories" 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: Mapped["Category"] = relationship("Category", remote_side=[id]) @@ -20,8 +20,9 @@ class Product(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 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) + price: Mapped[int] = mapped_column() search_vector: Mapped[str] = mapped_column(TSVECTOR) diff --git a/dto/__init__.py b/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dto/categories.py b/dto/categories.py new file mode 100644 index 0000000..5189f6a --- /dev/null +++ b/dto/categories.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CategoryDTO: + id: int + name: str diff --git a/handlers/client.py b/handlers/client.py index a59e22a..92073c5 100644 --- a/handlers/client.py +++ b/handlers/client.py @@ -1,10 +1,12 @@ -from aiogram import Router -from aiogram.types import Message +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 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 router = Router() @@ -19,6 +21,15 @@ async def main_menu(msg: Message, state: FSMContext): ) # 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")) async def get_orders( msg: Message, @@ -30,3 +41,33 @@ async def get_orders( count = await order_service.get_order_count(session, msg.from_user.id) 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, + ) diff --git a/main.py b/main.py index fa88832..eef6048 100644 --- a/main.py +++ b/main.py @@ -8,9 +8,11 @@ from handlers import init_handlers from middlewares.di import DIMiddleware from middlewares.repository import RepositoryMiddleware from middlewares.session import DBSessionMiddleware +from repositories.categories import CategoriesRepository from repositories.orders import OrderRepository from services import OrderService from db.base import async_session +from services.categories import CategoryService logging.basicConfig(level=logging.DEBUG) @@ -26,11 +28,18 @@ async def main(): order_repository = OrderRepository() order_service = OrderService(order_repository) + category_repository = CategoriesRepository() + category_service = CategoryService(category_repository) + # 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(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) await dp.start_polling(bot) diff --git a/middlewares/di.py b/middlewares/di.py index d3acb6a..4c3e26a 100644 --- a/middlewares/di.py +++ b/middlewares/di.py @@ -1,12 +1,14 @@ 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): + def __init__(self, order_service: OrderService, category_service: CategoryService): self._order_service = order_service + self._category_service = category_service async def __call__( self, @@ -15,5 +17,6 @@ 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 bf99883..7b50694 100644 --- a/middlewares/repository.py +++ b/middlewares/repository.py @@ -1,9 +1,10 @@ from aiogram import BaseMiddleware -from repositories.orders import OrderRepository +from repositories import OrderRepository, CategoriesRepository class RepositoryMiddleware(BaseMiddleware): async def __call__(self, handler, event, data): data["order_repo"] = OrderRepository() + data["categories_repo"] = CategoriesRepository() return await handler(event, data) diff --git a/misc/kb/client.py b/misc/kb/client.py index 131e4d2..ecdbcb3 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -1,20 +1,58 @@ +from typing import Optional + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 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( text="🛒 Корзина ()", callback_data="cart" ) # 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: return InlineKeyboardBuilder( [ [ - InlineKeyboardButton(text="📦 Каталог", callback_data="catalogue"), + InlineKeyboardButton(text="📦 Каталог", callback_data="cat:root"), InlineKeyboardButton(text="🔍", callback_data="find"), ], - [await cart_btn(user_id)], + [cart_btn(user_id)], ] ).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() diff --git a/postgresql.fish b/postgresql.fish index 7c61324..55f0c03 100755 --- a/postgresql.fish +++ b/postgresql.fish @@ -1 +1,2 @@ -docker run --name test-postgres -e POSTGRES_PASSWORD=HEXDEVFUCKINGSUCKS! -p 5432:5432 -d postgres \ No newline at end of file +docker start test-postgres +pg_isready -h localhost -p 5432 \ No newline at end of file diff --git a/repositories/__init__.py b/repositories/__init__.py index e69de29..78afc50 100644 --- a/repositories/__init__.py +++ b/repositories/__init__.py @@ -0,0 +1,4 @@ +from .orders import OrderRepository +from .categories import CategoriesRepository + +__all__ = ["OrderRepository", "CategoriesRepository"] diff --git a/repositories/categories.py b/repositories/categories.py new file mode 100644 index 0000000..f9c6830 --- /dev/null +++ b/repositories/categories.py @@ -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) diff --git a/repositories/orders.py b/repositories/orders.py index 1618d70..b8bde11 100644 --- a/repositories/orders.py +++ b/repositories/orders.py @@ -1,8 +1,8 @@ from sqlalchemy.ext.asyncio import AsyncSession 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: diff --git a/services/categories.py b/services/categories.py new file mode 100644 index 0000000..0a36ff8 --- /dev/null +++ b/services/categories.py @@ -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) diff --git a/startup.sh b/startup.sh new file mode 100644 index 0000000..8b3ad8f --- /dev/null +++ b/startup.sh @@ -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 \ No newline at end of file