feat: admin product deletion

This commit is contained in:
2026-08-13 12:03:56 +07:00
parent 1b78f2ba36
commit 406356c240
5 changed files with 239 additions and 6 deletions

View File

@@ -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} <i>-> ...</i>\n\n<b>📦 Введите название товара.</b>",
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(
"<b>📜 Введите описание товара.</b>",
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(
"<b>💸 Введите цену товара целым числом.</b>",
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(
"<b>💸 Введите цену товара целым числом.</b>",
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<b>📷 Отправьте фотографию товара или пропустите этот шаг.</b>",
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))

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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: