diff --git a/handlers/admins/creation.py b/handlers/admins/creation.py index 99ca112..f645c8c 100644 --- a/handlers/admins/creation.py +++ b/handlers/admins/creation.py @@ -1,21 +1,40 @@ from aiogram import F, Router from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery, Message +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message from sqlalchemy.ext.asyncio import AsyncSession -from dto.catalogue import CategoryActionContext +from dto.catalogue import CategoryActionContext, ProductActionContext from misc.filters import IsVerified from misc.kb.client import created_category, get_back_to_catalogue from misc.kb.common import return_menu from misc.mapper import parse_cat_id from misc.states import AdminControlStorage from repositories.categories import CategoriesRepository +from repositories.products import ProductRepository from services.catalogue import CatalogueService router = Router() -@router.callback_query(IsVerified(), F.data.startswith("new:")) +def skip_product_description_kb(parent_id: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Пропустить", callback_data="new_product:skip_description")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")], + ] + ) + + +def skip_product_photo_kb(parent_id: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Пропустить", callback_data="new_product:skip_photo")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data=f"cat:{parent_id}")], + ] + ) + + +@router.callback_query(IsVerified(), F.data.regexp(r"^new:(category|product):.+$")) async def creation_init( cb: CallbackQuery, state: FSMContext, @@ -41,7 +60,12 @@ async def creation_init( await state.set_data({"ctx": ctx}) if mode == "product": - ... + await cb.message.edit_text( + f"{path} -> ...\n\n📦 Введите название товара.", + reply_markup=get_back_to_catalogue(cat_id), + ) + await state.set_state(AdminControlStorage.new_product_name) + await state.set_data({"ctx": ProductActionContext(cb.message, cat_id)}) @router.message(AdminControlStorage.new_category) @@ -70,3 +94,131 @@ async def create_new_category( category.id, f"🛒 {category.name}", category.parent_id or "root" ), ) + + +@router.message(IsVerified(), AdminControlStorage.new_product_name) +async def create_product_name(msg: Message, state: FSMContext): + if not msg.text or not msg.text.strip(): + await msg.reply("❌ Название не может быть пустым.") + return + + data = await state.get_data() + ctx: ProductActionContext | None = data.get("ctx") + if not ctx: + await state.clear() + await msg.reply("❌ Ошибка при создании товара.", reply_markup=return_menu) + return + + await msg.delete() + await ctx.msg.edit_text( + "📜 Введите описание товара.", + reply_markup=skip_product_description_kb(str(ctx.parent_id)), + ) + await state.update_data(name=msg.text.strip()) + await state.set_state(AdminControlStorage.new_product_description) + + +@router.callback_query(IsVerified(), F.data == "new_product:skip_description") +async def skip_product_description(cb: CallbackQuery, state: FSMContext): + data = await state.get_data() + ctx: ProductActionContext | None = data.get("ctx") + if not ctx: + await state.clear() + await cb.answer("Ошибка при создании товара.", show_alert=True) + return + + await cb.message.edit_text( + "💸 Введите цену товара целым числом.", + reply_markup=get_back_to_catalogue(ctx.parent_id), + ) + await state.update_data(description=None) + await state.set_state(AdminControlStorage.new_product_price) + await cb.answer() + + +@router.message(IsVerified(), AdminControlStorage.new_product_description) +async def create_product_description(msg: Message, state: FSMContext): + if not msg.text or not msg.text.strip(): + await msg.reply("❌ Описание не может быть пустым.") + return + + data = await state.get_data() + ctx: ProductActionContext | None = data.get("ctx") + if not ctx: + await state.clear() + await msg.reply("❌ Ошибка при создании товара.", reply_markup=return_menu) + return + + await msg.delete() + await ctx.msg.edit_text( + "💸 Введите цену товара целым числом.", + reply_markup=get_back_to_catalogue(ctx.parent_id), + ) + await state.update_data(description=msg.text.strip()) + await state.set_state(AdminControlStorage.new_product_price) + + +@router.message(IsVerified(), AdminControlStorage.new_product_price) +async def create_product_price( + msg: Message, + state: FSMContext, + session: AsyncSession, + products_repo: ProductRepository, +): + if not msg.text or not msg.text.isdigit() or int(msg.text) <= 0: + await msg.reply("❌ Цена должна быть положительным целым числом.") + return + + data = await state.get_data() + ctx: ProductActionContext | None = data.get("ctx") + if not ctx: + await state.clear() + await msg.reply("❌ Ошибка при создании товара.", reply_markup=return_menu) + return + + await msg.delete() + product = await products_repo.add_product( + session, + category_id=parse_cat_id(ctx.parent_id), + name=data["name"], + description=data["description"], + price=int(msg.text), + ) + await ctx.msg.edit_text( + "✅ Основные данные сохранены.\n\n📷 Отправьте фотографию товара или пропустите этот шаг.", + reply_markup=skip_product_photo_kb(str(ctx.parent_id)), + ) + await state.update_data(product_id=product.id) + await state.set_state(AdminControlStorage.new_product_photo) + + +@router.callback_query(IsVerified(), F.data == "new_product:skip_photo") +async def skip_product_photo(cb: CallbackQuery, state: FSMContext): + data = await state.get_data() + ctx: ProductActionContext | None = data.get("ctx") + await state.clear() + parent_id = ctx.parent_id if ctx else "root" + await cb.message.edit_text("✅ Товар добавлен.", reply_markup=get_back_to_catalogue(parent_id)) + await cb.answer() + + +@router.message(IsVerified(), AdminControlStorage.new_product_photo) +async def create_product_photo( + msg: Message, + state: FSMContext, + session: AsyncSession, + products_repo: ProductRepository, +): + data = await state.get_data() + if not msg.photo or not data.get("product_id"): + await msg.delete() + await msg.reply("❌ Отправьте именно фотографию или нажмите «Пропустить».") + return + + await state.clear() + await msg.delete() + await products_repo.add_product_file_id( + session, int(data["product_id"]), file_id=msg.photo[-1].file_id + ) + ctx: ProductActionContext = data["ctx"] + await ctx.msg.edit_text("✅ Товар добавлен.", reply_markup=get_back_to_catalogue(ctx.parent_id)) diff --git a/handlers/admins/product_mgmt.py b/handlers/admins/product_mgmt.py index af912f9..7fb7b4f 100644 --- a/handlers/admins/product_mgmt.py +++ b/handlers/admins/product_mgmt.py @@ -13,8 +13,7 @@ from misc.kb.admins import back_to_product_kb from misc.kb.client import get_back_to_catalogue from misc.mapper import parse_cat_id from misc.states import AdminControlStorage -from misc.texts import get_product_description -from misc.texts import product_editing_mapping +from misc.texts import get_product_description, product_editing_mapping from repositories.categories import CategoriesRepository from repositories.products import ProductRepository @@ -210,6 +209,42 @@ async def delete_product_photo( await cb.message.edit_reply_markup(reply_markup=reply_markup) +@router.callback_query(IsVerified(), F.data.regexp(r"^edit_product:\d+:delete$")) +async def request_product_deletion(cb: CallbackQuery, state: FSMContext): + await state.clear() + product_id = int(cb.data.split(":")[1]) + await cb.message.edit_reply_markup(reply_markup=admins.confirm_product_deletion(product_id)) + await cb.answer("Подтвердите удаление товара.") + + +@router.callback_query(IsVerified(), F.data.regexp(r"^delete_product:confirm:\d+$")) +async def delete_product( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + products_repo: ProductRepository, +): + await state.clear() + product_id = int(cb.data.split(":")[2]) + product = await products_repo.get_product_by_id(session, product_id) + if not product: + await cb.answer("Товар уже удалён.", show_alert=True) + return + + category_id = product.category_id + try: + await products_repo.delete_product(session, product_id) + except Exception: + await session.rollback() + logger.exception("Failed to delete product %s", product_id) + await cb.answer("Не удалось удалить товар: он используется в заказах.", show_alert=True) + return + + await cb.message.delete() + await cb.message.answer("✅ Товар удалён.", reply_markup=get_back_to_catalogue(category_id)) + await cb.answer() + + @router.callback_query(F.data.startswith("edit:")) async def edit_entry(cb: CallbackQuery, state: FSMContext): await state.clear() diff --git a/misc/kb/admins.py b/misc/kb/admins.py index fb8ebb7..085d7f3 100644 --- a/misc/kb/admins.py +++ b/misc/kb/admins.py @@ -79,3 +79,16 @@ def edit_product(product: Product): [InlineKeyboardButton(text="⬅️", callback_data=f"cat:{product.category_id}")], ] ).as_markup() + + +def confirm_product_deletion(product_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardBuilder( + [ + [ + InlineKeyboardButton( + text="✅ Да, удалить", callback_data=f"delete_product:confirm:{product_id}" + ), + InlineKeyboardButton(text="❌ Отмена", callback_data=f"product:{product_id}"), + ] + ] + ).as_markup() diff --git a/misc/states.py b/misc/states.py index a3810fc..12f451c 100644 --- a/misc/states.py +++ b/misc/states.py @@ -21,3 +21,7 @@ class AdminControlStorage(StatesGroup): edit_product_photo = State() edit_category = State() new_category = State() + new_product_name = State() + new_product_description = State() + new_product_price = State() + new_product_photo = State() diff --git a/repositories/products.py b/repositories/products.py index e68ecd2..662f7cc 100644 --- a/repositories/products.py +++ b/repositories/products.py @@ -7,6 +7,35 @@ from db.models import Product class ProductRepository: + async def delete_product(self, session: AsyncSession, product_id: int) -> Product | None: + product = await self.get_product_by_id(session, product_id) + if not product: + return None + + await session.delete(product) + await session.commit() + return product + + async def add_product( + self, + session: AsyncSession, + *, + category_id: int | None, + name: str, + description: str, + price: int, + ) -> Product: + product = Product( + category_id=category_id, + name=name, + description=description, + price=price, + ) + session.add(product) + await session.commit() + await session.refresh(product) + return product + async def get_product_by_id( self, session: AsyncSession, product_id: int, *, include_hidden: bool = True ) -> Product | None: