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: