diff --git a/alembic/versions/1d2e799ed0f3_product_img_path_constraints_redacted.py b/alembic/versions/1d2e799ed0f3_product_img_path_constraints_redacted.py new file mode 100644 index 0000000..8cf8b62 --- /dev/null +++ b/alembic/versions/1d2e799ed0f3_product_img_path_constraints_redacted.py @@ -0,0 +1,39 @@ +"""product img_path constraints redacted. + +Revision ID: 1d2e799ed0f3 +Revises: 41a6b57158ea +Create Date: 2026-03-05 20:33:46.459295 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "1d2e799ed0f3" +down_revision: Union[str, Sequence[str], None] = "41a6b57158ea" +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", "img_path", existing_type=sa.TEXT(), nullable=True) + op.drop_constraint(op.f("products_img_path_key"), "products", type_="unique") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint( + op.f("products_img_path_key"), + "products", + ["img_path"], + postgresql_nulls_not_distinct=False, + ) + op.alter_column("products", "img_path", existing_type=sa.TEXT(), nullable=False) + # ### end Alembic commands ### diff --git a/db/models/products.py b/db/models/products.py index dabb975..e941da4 100644 --- a/db/models/products.py +++ b/db/models/products.py @@ -24,7 +24,7 @@ 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) + img_path: Mapped[str] = mapped_column(Text, nullable=True) file_id: Mapped[str] = mapped_column(Text, nullable=True, unique=True) search_vector: Mapped[str] = mapped_column(TSVECTOR) diff --git a/dto/catalogue.py b/dto/catalogue.py index e107d8b..5ed188c 100644 --- a/dto/catalogue.py +++ b/dto/catalogue.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from enum import Enum from typing import Optional, Union +from aiogram.types import Message + from db.models import Category from db.models.products import Product @@ -22,3 +24,17 @@ class CatalogueView: parent_id: Union[int, str, None] = None has_next: bool = False show_menu: bool = False + + +@dataclass +class NewCatalogueElement: + msg: Message + parent_id: Union[int, str] + + +@dataclass +class NewCategoryContext(NewCatalogueElement): ... + + +@dataclass +class NewProductContext(NewCatalogueElement): ... diff --git a/handlers/admins/__init__.py b/handlers/admins/__init__.py index 5e7a61d..054f790 100644 --- a/handlers/admins/__init__.py +++ b/handlers/admins/__init__.py @@ -1,5 +1,6 @@ from .inline_mode import router as inline_router from .menu import router as menu_router from .product_mgmt import router as product_router +from .creation import router as creation_router -routers = [inline_router, menu_router, product_router] +routers = [inline_router, menu_router, product_router, creation_router] diff --git a/handlers/admins/creation.py b/handlers/admins/creation.py new file mode 100644 index 0000000..9a09759 --- /dev/null +++ b/handlers/admins/creation.py @@ -0,0 +1,70 @@ +from aiogram import F, Router +from aiogram.types import CallbackQuery, Message +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from dto.catalogue import NewCategoryContext +from misc.filters import IsVerified +from misc.kb.client import get_back_to_catalogue +from misc.mapper import parse_cat_id +from misc.states import AdminControlStorage +from misc.kb.common import return_menu +from repositories.categories import CategoriesRepository +from services.catalogue import CatalogueService + +router = Router() + + +@router.callback_query(IsVerified(), F.data.startswith("new:")) +async def creation_init( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + catalogue_service: CatalogueService, +): + await state.clear() + + data = cb.data.split(":") + mode = data[1] + cat_id = data[2] + + path = await catalogue_service.get_path(session, cat_id) + + if mode == "category": + await cb.message.edit_text( + f"{path} -> ...\n\n🏷️ Введите название новой категории.", + reply_markup=get_back_to_catalogue(cat_id), + ) + + ctx = NewCategoryContext(cb.message, cat_id) + await state.set_state(AdminControlStorage.new_category) + await state.set_data({"ctx": ctx}) + + if mode == "product": + ... + + +@router.message(AdminControlStorage.new_category) +async def create_new_category( + msg: Message, + state: FSMContext, + session: AsyncSession, + categories_repo: CategoriesRepository, +): + await msg.delete() + + data = await state.get_data() + ctx: NewCategoryContext | None = data.get("ctx") + + if not ctx: + await msg.reply("❌ Ошибка при создании категории.", reply_markup=return_menu) + return + + category = await categories_repo.add_category( + session, name=msg.text, parent_id=parse_cat_id(ctx.parent_id) + ) + + await ctx.msg.edit_text( + f"✅ Категория {msg.text} создана успешно.", + reply_markup=get_back_to_catalogue(category.id, text="🛒 Перейти"), + ) diff --git a/handlers/client/catalogue.py b/handlers/client/catalogue.py index ed4b105..a02ffb4 100644 --- a/handlers/client/catalogue.py +++ b/handlers/client/catalogue.py @@ -26,15 +26,16 @@ async def subcatalogue( view = await catalogue_service.build_category_view(session, cat_id) kb = render_catalogue(view, is_admin=is_admin) + path = await catalogue_service.get_path(session, cat_id) if cb.message.photo: await cb.message.delete() - await cb.answer( - "category selection", + await cb.message.answer( + f"{path}", reply_markup=kb, ) return await cb.message.edit_text( - "category selection", + f"{path}", reply_markup=kb, ) diff --git a/handlers/client/products.py b/handlers/client/products.py index 343f0ba..c92e6a2 100644 --- a/handlers/client/products.py +++ b/handlers/client/products.py @@ -1,7 +1,9 @@ import os +import logging from re import Match from aiogram import Router, F from aiogram.types import CallbackQuery, FSInputFile +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -17,6 +19,7 @@ from services.catalogue import CatalogueService from services.orders import OrderService router = Router() +logger = logging.getLogger(__name__) @router.callback_query( @@ -73,17 +76,9 @@ async def product_card( cart_amount=order_item, ) - if product.file_id: - await cb.message.delete() - - msg = await cb.message.answer_photo( - product.file_id, - caption=get_product_description(product), - reply_markup=kb, - ) - return - - if not os.path.isfile(product.img_path): + if not product.file_id and not ( + product.img_path and os.path.isfile(product.img_path) + ): await cb.message.edit_text( get_product_description(product), reply_markup=kb, @@ -91,6 +86,20 @@ async def product_card( return await cb.message.delete() + + if product.file_id: + msg = await cb.message.answer_photo( + product.file_id, + caption=get_product_description(product), + reply_markup=kb, + ) + return + + try: + await cb.message.edit_text("⏳ Загружаем карточку товара...") + except TelegramBadRequest: + ... + msg = await cb.message.answer_photo( FSInputFile(product.img_path), caption=get_product_description(product), @@ -98,6 +107,7 @@ async def product_card( ) if not msg.photo: + logger.warning("didnt get a photo back wahhh :(") return # TODO: Add logging await products_repo.add_product_file_id( diff --git a/main.py b/main.py index c03d1df..7429300 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import logging from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from redis.asyncio import Redis +from aiogram.client.session.aiohttp import AiohttpSession from config import BOT_TOKEN, REDIS_URL from handlers import admins_routers, client_routers @@ -31,7 +32,12 @@ os.makedirs("static/img", exist_ok=True) async def main(): dp = Dispatcher() - bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode="HTML")) + aiohttp_session = AiohttpSession(proxy="http://127.0.0.1:2080") + bot = Bot( + token=BOT_TOKEN, + default=DefaultBotProperties(parse_mode="HTML"), + session=aiohttp_session, + ) # DB services creation order_repository = OrderRepository() diff --git a/misc/kb/admins.py b/misc/kb/admins.py index 6290adb..4ae629e 100644 --- a/misc/kb/admins.py +++ b/misc/kb/admins.py @@ -1,21 +1,18 @@ -from typing import Optional, Callable +from typing import Optional, Callable, Union from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( [ - [InlineKeyboardButton(text="📦 Изменить товары", callback_data="cat:root")], - [ - InlineKeyboardButton( - text="📜 Изменить категории", callback_data="edit:categories" - ) - ], + [InlineKeyboardButton(text="📦 Изменить каталог", callback_data="cat:root")], # [InlineKeyboardButton(text="", callback_data="")], ] ).as_markup() -def back_to_product_kb(product_id: int, *, cb_factory: Callable[[int], str]): +def back_to_product_kb( + product_id: Union[int, str], *, cb_factory: Callable[[int | str], str] +): return InlineKeyboardBuilder( [[InlineKeyboardButton(text="⬅️", callback_data=cb_factory(product_id))]] ).as_markup() diff --git a/misc/kb/client.py b/misc/kb/client.py index 52ac02d..d2542c1 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -1,4 +1,4 @@ -from typing import Optional, Callable +from typing import Optional, Callable, Union from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder @@ -7,7 +7,7 @@ from config import PAGE_SIZE from db.models import Category, Product from dto.cart import CartItemDTO from dto.catalogue import CatalogueView, CatalogueType -from .common import return_menu +from misc.mapper import serialize_cat_id def pagination_row( @@ -37,11 +37,33 @@ def cart_btn(cart_items: int) -> InlineKeyboardButton: def get_back_to_catalogue( - cat_id: int, *, text: Optional[str] = "⬅️ Назад" + parent_id: int, + *, + text: Optional[str] = "⬅️ Назад", + show_controls: bool = False, + cat_id: Optional[Union[str, int]] = None, + fallback_cb: Optional[str] = "menu:main", ) -> InlineKeyboardMarkup: - return InlineKeyboardBuilder( - [[InlineKeyboardButton(text=text, callback_data=f"cat:{cat_id}")]] - ).as_markup() + + builder = InlineKeyboardBuilder() + + if show_controls and cat_id: + builder.row( + InlineKeyboardButton( + text="➕ Категория", callback_data=f"new:category:{cat_id}" + ), + InlineKeyboardButton( + text="➕ Товар", callback_data=f"new:product:{cat_id}" + ), + ) + + builder.row( + InlineKeyboardButton( + text=text, callback_data=f"cat:{parent_id}" if parent_id else fallback_cb + ), + ) + + return builder.as_markup() def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup: @@ -58,9 +80,10 @@ def main_menu_kb(cart_items: int) -> InlineKeyboardMarkup: def render_category( children: list[Category], - parent_id: Optional[int] = None, + parent_id: Optional[Union[str, int]] = None, show_menu: bool = False, *, + cat_id: Optional[Union[str, int]] = None, category_cb_factory: Callable[[Category], str], is_admin: bool = False, ) -> InlineKeyboardMarkup: @@ -75,12 +98,16 @@ def render_category( nav: list[list[InlineKeyboardButton]] = [] if is_admin: + cat_id = serialize_cat_id(cat_id) nav.append( [ InlineKeyboardButton( - text="📄 Добавить категорию", - callback_data=f"new:category:{parent_id}", - ) + text="➕ Категория", + callback_data=f"new:category:{cat_id}", + ), + InlineKeyboardButton( + text="✍️", callback_data=f"edit:category:{cat_id}" + ), ] ) @@ -124,9 +151,7 @@ def render_products( if is_admin: builder.row( - InlineKeyboardButton( - text="📦 Добавить товар", callback_data=f"new:product:{cat_id}" - ) + InlineKeyboardButton(text="➕ Товар", callback_data=f"new:product:{cat_id}") ) nav_buttons = [] @@ -144,11 +169,12 @@ def render_products( def render_catalogue(view: CatalogueView, *, is_admin: bool): if view.view_type == CatalogueType.CATEGORIES: return render_category( - children=view.children[:PAGE_SIZE], # type: ignore + children=view.children, parent_id=view.parent_id, show_menu=view.show_menu, category_cb_factory=lambda c: f"cat:{c.id}", is_admin=is_admin, + cat_id=view.category, ) if view.view_type == CatalogueType.PRODUCTS: return render_products( @@ -162,9 +188,12 @@ def render_catalogue(view: CatalogueView, *, is_admin: bool): f"products:{view.category}:{view.page + 1}" if view.has_next else None ), is_admin=is_admin, + cat_id=view.category, ) - return return_menu + return get_back_to_catalogue( + view.parent_id, show_controls=is_admin, cat_id=serialize_cat_id(view.category) + ) def render_product_interactions( diff --git a/misc/mapper.py b/misc/mapper.py new file mode 100644 index 0000000..1596b7b --- /dev/null +++ b/misc/mapper.py @@ -0,0 +1,11 @@ +def parse_cat_id(value: str) -> int | None | bool: + if value == "root": + return None + if str(value).isdigit(): + return int(value) + + raise Exception("Invalid Category Id.") + + +def serialize_cat_id(cat_id: int | None) -> str: + return "root" if cat_id is None else str(cat_id) diff --git a/misc/states.py b/misc/states.py index b5c1eac..00c432d 100644 --- a/misc/states.py +++ b/misc/states.py @@ -18,3 +18,4 @@ class SearchStorage(StatesGroup): class AdminControlStorage(StatesGroup): edit_product = State() + new_category = State() diff --git a/misc/texts.py b/misc/texts.py index 07be491..d509329 100644 --- a/misc/texts.py +++ b/misc/texts.py @@ -24,3 +24,8 @@ def get_order_item_list(cart: CartDTO): text += f"\n━━━━━━━━━━━━━━\n💰 Итого: {cart.total}₽" return text + + +def get_breadcrumps_path(products: list[Product]) -> str: + products_str = f" -> {' -> '.join([x.name for x in products])}" if products else "" + return f"🏠 Каталог{products_str}" diff --git a/repositories/categories.py b/repositories/categories.py index dd35f0b..ab2047a 100644 --- a/repositories/categories.py +++ b/repositories/categories.py @@ -1,6 +1,7 @@ +from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from typing import Optional +from sqlalchemy.orm import aliased from db.models import Category @@ -19,3 +20,36 @@ class CategoriesRepository: result = await session.scalars(stmt) return list(result) + + async def get_category_path(self, session: AsyncSession, category_id: int): + base = select(Category.id, Category.parent_id, Category.name).where( + Category.id == category_id + ) + + cte = base.cte(name="path", recursive=True) + + parent = aliased(Category) + + cte = cte.union_all( + select(parent.id, parent.parent_id, parent.name).join( + cte, cte.c.parent_id == parent.id + ) + ) + + stmt = select(cte) + + result = await session.execute(stmt) + + rows = result.all() + + return list(reversed(rows)) + + async def add_category( + self, session: AsyncSession, *, name: str, parent_id: Optional[int] + ) -> Category: + category = Category(name=name, parent_id=parent_id) + + session.add(category) + await session.commit() + + return category diff --git a/repositories/products.py b/repositories/products.py index 9efaf34..347f680 100644 --- a/repositories/products.py +++ b/repositories/products.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update +from sqlalchemy import select from sqlalchemy.sql.expression import func from typing import Optional @@ -32,13 +32,9 @@ class ProductRepository: async def add_product_file_id( self, session: AsyncSession, product_id: int, *, file_id: str ) -> None: - stmt = ( - update(Product) - .where(Product.id == product_id) - .where(Product.file_id is None) - .values(file_id=file_id) - ) - await session.execute(stmt) + product = await self.get_product_by_id(session, product_id) + product.file_id = file_id + await session.commit() async def search( diff --git a/requirements.txt b/requirements.txt index 73f5cd7..f0efaeb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ sqlalchemy[asyncio] psycopg2 phonenumbers ruff -redis \ No newline at end of file +redis +aiohttp-socks \ No newline at end of file diff --git a/services/catalogue.py b/services/catalogue.py index 4b44c2e..d788b15 100644 --- a/services/catalogue.py +++ b/services/catalogue.py @@ -2,6 +2,8 @@ from typing import Union, Optional from sqlalchemy.ext.asyncio import AsyncSession from dto.catalogue import CatalogueType, CatalogueView +from misc.mapper import parse_cat_id, serialize_cat_id +from misc.texts import get_breadcrumps_path from repositories import ProductRepository, CategoriesRepository from config import PAGE_SIZE @@ -16,11 +18,7 @@ class CatalogueService: 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 - ) + cat_id = parse_cat_id(cat_id) view = CatalogueView(page=page) children = await self.categories_repo.get_categories_by_parent_id( @@ -33,22 +31,28 @@ class CatalogueService: view.show_menu = True else: # Subcategories view.category = cat_id - view.parent_id = category.parent_id or "root" + view.parent_id = serialize_cat_id(category.parent_id) 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 products: + view.view_type = CatalogueType.PRODUCTS + view.products = products[:PAGE_SIZE] + view.has_next = len(products) > 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.EMPTY return view view.view_type = CatalogueType.CATEGORIES - view.children = children[:PAGE_SIZE] + view.children = children return view + + async def get_path(self, session: AsyncSession, cat_id: Union[int, str]): + category = int(cat_id) if isinstance(cat_id, int) or cat_id.isdigit() else None + products = await self.categories_repo.get_category_path(session, category) + + return get_breadcrumps_path(products)