From a3aaafd7acd327beb3b98baec44642524dc7070c Mon Sep 17 00:00:00 2001 From: hexdev Date: Wed, 11 Feb 2026 20:53:47 +0700 Subject: [PATCH] separated clients handlers, cart->checkout ppline --- config.py | 1 + dto/cart.py | 2 + dto/checkout.py | 18 ++ handlers/__init__.py | 11 +- handlers/admins.py | 9 - handlers/admins/__init__.py | 1 + handlers/client.py | 347 ----------------------------------- handlers/client/__init__.py | 15 ++ handlers/client/cart.py | 80 ++++++++ handlers/client/catalogue.py | 91 +++++++++ handlers/client/checkout.py | 122 ++++++++++++ handlers/client/menu.py | 37 ++++ handlers/client/products.py | 164 +++++++++++++++++ handlers/client/security.py | 20 ++ main.py | 8 +- misc/kb/client.py | 38 +++- misc/states.py | 7 + misc/texts.py | 16 ++ misc/utils.py | 10 + services/orders.py | 30 ++- 20 files changed, 658 insertions(+), 369 deletions(-) create mode 100644 dto/checkout.py delete mode 100644 handlers/admins.py create mode 100644 handlers/admins/__init__.py delete mode 100644 handlers/client.py create mode 100644 handlers/client/__init__.py create mode 100644 handlers/client/cart.py create mode 100644 handlers/client/catalogue.py create mode 100644 handlers/client/checkout.py create mode 100644 handlers/client/menu.py create mode 100644 handlers/client/products.py create mode 100644 handlers/client/security.py create mode 100644 misc/states.py create mode 100644 misc/utils.py diff --git a/config.py b/config.py index 0364aa4..08aab4e 100644 --- a/config.py +++ b/config.py @@ -17,3 +17,4 @@ POSTGRES_URL = os.getenv( ### Constants ### PAGE_SIZE = 4 +VERIFIED_ACCOUNTS = [1026030711] diff --git a/dto/cart.py b/dto/cart.py index 6bfdeed..ff65736 100644 --- a/dto/cart.py +++ b/dto/cart.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional @dataclass(frozen=True) @@ -17,3 +18,4 @@ class CartItemDTO: class CartDTO: items: list[CartItemDTO] total: int + order_id: Optional[int] = None diff --git a/dto/checkout.py b/dto/checkout.py new file mode 100644 index 0000000..66df67e --- /dev/null +++ b/dto/checkout.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import Optional + +from aiogram.types import Message + + +@dataclass +class CheckoutContext: + orig_msg: Message + order_id: int + name: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + + +@dataclass +class PaymentContext: + notification_msg: Message diff --git a/handlers/__init__.py b/handlers/__init__.py index 2394d0b..964c004 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -1,9 +1,4 @@ -from aiogram import Dispatcher +from .client import routers as client_routers +from .admins import routers as admins_routers -from .client import router as client_router -from .admins import router as admins_router - - -def init_handlers(dp: Dispatcher): - dp.include_router(client_router) - dp.include_router(admins_router) +__all__ = ["client_routers", "admins_routers"] diff --git a/handlers/admins.py b/handlers/admins.py deleted file mode 100644 index 4637031..0000000 --- a/handlers/admins.py +++ /dev/null @@ -1,9 +0,0 @@ -from aiogram import Router -from aiogram.types import Message - -router = Router() - - -@router.message() -async def echo(msg: Message): - await msg.answer(msg.text) diff --git a/handlers/admins/__init__.py b/handlers/admins/__init__.py new file mode 100644 index 0000000..e411497 --- /dev/null +++ b/handlers/admins/__init__.py @@ -0,0 +1 @@ +routers = [] diff --git a/handlers/client.py b/handlers/client.py deleted file mode 100644 index 064221c..0000000 --- a/handlers/client.py +++ /dev/null @@ -1,347 +0,0 @@ -import os -from re import Match -from aiogram import Router, F -from aiogram.types import CallbackQuery, FSInputFile, Message -from aiogram.filters import Command -from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession - -from config import PAGE_SIZE -from misc.kb import main_menu_kb, render_category -from misc.kb.client import ( - get_back_to_catalogue, - render_cart, - render_product_interactions, - render_products, -) -from misc.texts import get_product_description -from repositories.categories import CategoriesRepository -from repositories.products import ProductRepository -from services.orders import OrderService - -router = Router() - - -@router.message(Command("start")) -async def main_menu( - msg: Message, state: FSMContext, session: AsyncSession, order_service: OrderService -): - await state.clear() - - cart_items = await order_service.get_cart_items_count(session, msg.from_user.id) - await msg.answer( - "hii!", reply_markup=main_menu_kb(cart_items) - ) # TODO: Write a welcome message - - -@router.callback_query(F.data.startswith("menu:")) -async def main_menu_cb( - cb: CallbackQuery, - state: FSMContext, - session: AsyncSession, - order_service: OrderService, -): - await state.clear() - - cart_items = await order_service.get_cart_items_count(session, cb.from_user.id) - await cb.message.edit_text( - "hii!", reply_markup=main_menu_kb(cart_items) - ) # TODO: Write a welcome message - - -@router.message(Command("orders")) -async def get_orders( - msg: Message, - state: FSMContext, - order_service: OrderService, - session: AsyncSession, -): - await state.clear() - - 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, - session: AsyncSession, - categories_repo: CategoriesRepository, - products_repo: ProductRepository, -): - await state.clear() - - cat_id = cb.data.split(":")[1] - - if cat_id == "root": - children = await categories_repo.get_categories_by_parent_id(session) - kb = render_category(children, show_menu=True) - await cb.message.edit_text( - "category selection", - reply_markup=kb, - ) - return - - cat_id = int(cat_id) - - children = await categories_repo.get_categories_by_parent_id(session, cat_id) - category = await categories_repo.get_category_by_id(session, cat_id) - parent_id = category.parent_id or "root" - - if not children: - products = await products_repo.get_product_by_category( - session, cat_id, limit=PAGE_SIZE + 1, offset=0 - ) - products_len = len(products) - - if not products: - await cb.message.edit_text( - "🍃 Здесь ничего нет...", - reply_markup=get_back_to_catalogue(parent_id), - ) - return - - if cb.message.photo: - await cb.message.delete() - await cb.message.answer( - "hya", - reply_markup=render_products( - products[:PAGE_SIZE], - parent_id=parent_id, - cat_id=cat_id, - page=0, - show_prev=products_len < 0, - show_next=products_len > PAGE_SIZE, - ), - ) - return - - await cb.message.edit_text( - "hya", - reply_markup=render_products( - products[:PAGE_SIZE], - parent_id=parent_id, - cat_id=cat_id, - page=0, - show_prev=products_len < 0, - show_next=products_len > PAGE_SIZE, - ), - ) - return - - kb = render_category(children, parent_id) - - await cb.message.edit_text( - "category selection", - reply_markup=kb, - ) - - -@router.callback_query( - F.data.regexp(r"products:(?P.*):(?P\d+)").as_( - "pagination_match" - ) -) -async def products_pagination( - cb: CallbackQuery, - state: FSMContext, - pagination_match: Match[str], - session: AsyncSession, - products_repo: ProductRepository, - categories_repo: CategoriesRepository, -): - await state.clear() - - pagination_data = pagination_match.groupdict() - category_id = int(pagination_data.get("category_id")) - category = await categories_repo.get_category_by_id(session, category_id) - page_id = int(pagination_data.get("page_id", 0)) - - products = await products_repo.get_product_by_category( - session, category_id, limit=PAGE_SIZE + 1, offset=page_id * PAGE_SIZE - ) - products_len = len(products) - - await cb.message.edit_reply_markup( - reply_markup=render_products( - products[:PAGE_SIZE], - parent_id=category.parent_id, - cat_id=category_id, - page=page_id, - show_prev=page_id > 0, - show_next=products_len > PAGE_SIZE, - ), - ) - - -@router.callback_query(F.data.startswith("product:")) -async def product_card( - cb: CallbackQuery, - state: FSMContext, - session: AsyncSession, - products_repo: ProductRepository, - order_service: OrderService, -): - await state.clear() - - product_id = int(cb.data.split(":")[1]) - product = await products_repo.get_product_by_id(session, product_id) - - order_item = await order_service.get_cart_product_amount( - session, customer=cb.from_user.id, product_id=product_id - ) - - if product.file_id: - await cb.message.delete() - - msg = await cb.message.answer_photo( - product.file_id, - caption=get_product_description(product), - reply_markup=render_product_interactions( - product_id=product.id, - category_id=product.category_id, - show_cart=bool(order_item), - cart_amount=order_item, - ), - ) - return - - if not os.path.isfile(product.img_path): - await cb.message.edit_text( - get_product_description(product), - reply_markup=render_product_interactions( - product_id=product.id, - category_id=product.category_id, - show_cart=bool(order_item), - cart_amount=order_item, - ), - ) - return - - await cb.message.delete() - msg = await cb.message.answer_photo( - FSInputFile(product.img_path), - caption=get_product_description(product), - reply_markup=render_product_interactions( - product_id=product.id, - category_id=product.category_id, - show_cart=bool(order_item), - cart_amount=order_item, - ), - ) - - if not msg.photo: - return # TODO: Add logging - - await products_repo.add_product_file_id( - session, product_id, file_id=msg.photo[-1].file_id - ) - - -@router.callback_query(F.data.startswith("cart_action:")) -async def add_to_cart( - cb: CallbackQuery, - state: FSMContext, - session: AsyncSession, - order_service: OrderService, - products_repo: ProductRepository, -): - await state.clear() - - action = cb.data.split(":")[1] - product_id = int(cb.data.split(":")[2]) - product = await products_repo.get_product_by_id(session, product_id) - show_cart = True - - if action == "add": - quantity = await order_service.add_to_cart( - session, customer=cb.from_user.id, product_id=product_id - ) - elif action == "remove": - quantity = await order_service.remove_from_cart( - session, customer=cb.from_user.id, product_id=product_id - ) - show_cart = bool(quantity) - else: - show_cart = False - quantity = 0 - await order_service.clear_cart_item( - session, customer=cb.from_user.id, product_id=product_id - ) - - try: - await cb.message.edit_reply_markup( - reply_markup=render_product_interactions( - product_id=product_id, - category_id=product.category_id, - show_cart=show_cart, - cart_amount=quantity, - ) - ) - except Exception as e: - await cb.answer("🍃") - raise e - - -@router.callback_query(F.data == "cart") -async def cart_init( - cb: CallbackQuery, - state: FSMContext, - session: AsyncSession, - order_service: OrderService, -): - await state.clear() - - cart = await order_service.build_cart_dto(session, customer=cb.from_user.id) - - await cb.message.edit_text( - f"total: {cart.total}₽", - reply_markup=render_cart( - cart.items[:PAGE_SIZE], show_next=len(cart.items) > PAGE_SIZE - ), - ) - - -@router.message(Command("cart")) -async def cart_cmd( - msg: Message, - state: FSMContext, - session: AsyncSession, - order_service: OrderService, -): - await state.clear() - - cart = await order_service.build_cart_dto(session, customer=msg.from_user.id) - - await msg.answer( - f"total: {cart.total}₽", - reply_markup=render_cart( - cart.items[:PAGE_SIZE], show_next=len(cart.items) > PAGE_SIZE - ), - ) - - -@router.callback_query(F.data.startswith("cart:")) -async def cart_pagination( - cb: CallbackQuery, - state: FSMContext, - session: AsyncSession, - order_service: OrderService, -): - await state.clear() - - page = int(cb.data.split(":")[1]) - - cart = await order_service.build_cart_dto( - session, customer=cb.from_user.id, offset=page * PAGE_SIZE - ) - - await cb.message.edit_reply_markup( - reply_markup=render_cart( - cart.items[:PAGE_SIZE], - page=page, - show_next=len(cart.items) > PAGE_SIZE, - show_prev=page > 0, - ), - ) diff --git a/handlers/client/__init__.py b/handlers/client/__init__.py new file mode 100644 index 0000000..7b55806 --- /dev/null +++ b/handlers/client/__init__.py @@ -0,0 +1,15 @@ +from .menu import router as menu_router +from .catalogue import router as catalogue_router +from .products import router as products_router +from .cart import router as cart_router +from .checkout import router as checkout_router +from .security import router as security_router + +routers = [ + menu_router, + catalogue_router, + products_router, + cart_router, + checkout_router, + security_router, +] diff --git a/handlers/client/cart.py b/handlers/client/cart.py new file mode 100644 index 0000000..66e50fa --- /dev/null +++ b/handlers/client/cart.py @@ -0,0 +1,80 @@ +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 config import PAGE_SIZE +from misc.kb.client import ( + render_cart, +) +from services.orders import OrderService + +router = Router() + + +@router.callback_query(F.data == "cart") +async def cart_init( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + order_service: OrderService, +): + await state.clear() + + cart = await order_service.build_pagination_cart_dto( + session, customer=cb.from_user.id + ) + + await cb.message.edit_text( + f"total: {cart.total}₽", + reply_markup=render_cart( + cart.items[:PAGE_SIZE], show_next=len(cart.items) > PAGE_SIZE + ), + ) + + +@router.message(Command("cart")) +async def cart_cmd( + msg: Message, + state: FSMContext, + session: AsyncSession, + order_service: OrderService, +): + await state.clear() + + cart = await order_service.build_pagination_cart_dto( + session, customer=msg.from_user.id + ) + + await msg.answer( + f"total: {cart.total}₽", + reply_markup=render_cart( + cart.items[:PAGE_SIZE], show_next=len(cart.items) > PAGE_SIZE + ), + ) + + +@router.callback_query(F.data.startswith("cart:")) +async def cart_pagination( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + order_service: OrderService, +): + await state.clear() + + page = int(cb.data.split(":")[1]) + + cart = await order_service.build_pagination_cart_dto( + session, customer=cb.from_user.id, offset=page * PAGE_SIZE + ) + + await cb.message.edit_reply_markup( + reply_markup=render_cart( + cart.items[:PAGE_SIZE], + page=page, + show_next=len(cart.items) > PAGE_SIZE, + show_prev=page > 0, + ), + ) diff --git a/handlers/client/catalogue.py b/handlers/client/catalogue.py new file mode 100644 index 0000000..29ea8dc --- /dev/null +++ b/handlers/client/catalogue.py @@ -0,0 +1,91 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from config import PAGE_SIZE +from misc.kb import render_category +from misc.kb.client import ( + get_back_to_catalogue, + render_products, +) +from repositories.categories import CategoriesRepository +from repositories.products import ProductRepository + +router = Router() + + +@router.callback_query(F.data.startswith("cat:")) +async def subcatalogue( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + categories_repo: CategoriesRepository, + products_repo: ProductRepository, +): + await state.clear() + + cat_id = cb.data.split(":")[1] + + if cat_id == "root": + children = await categories_repo.get_categories_by_parent_id(session) + kb = render_category(children, show_menu=True) + await cb.message.edit_text( + "category selection", + reply_markup=kb, + ) + return + + cat_id = int(cat_id) + + children = await categories_repo.get_categories_by_parent_id(session, cat_id) + category = await categories_repo.get_category_by_id(session, cat_id) + parent_id = category.parent_id or "root" + + if not children: + products = await products_repo.get_product_by_category( + session, cat_id, limit=PAGE_SIZE + 1, offset=0 + ) + products_len = len(products) + + if not products: + await cb.message.edit_text( + "🍃 Здесь ничего нет...", + reply_markup=get_back_to_catalogue(parent_id), + ) + return + + if cb.message.photo: + await cb.message.delete() + await cb.message.answer( + "hya", + reply_markup=render_products( + products[:PAGE_SIZE], + parent_id=parent_id, + cat_id=cat_id, + page=0, + show_prev=products_len < 0, + show_next=products_len > PAGE_SIZE, + ), + ) + return + + await cb.message.edit_text( + "hya", + reply_markup=render_products( + products[:PAGE_SIZE], + parent_id=parent_id, + cat_id=cat_id, + page=0, + show_prev=products_len < 0, + show_next=products_len > PAGE_SIZE, + ), + ) + return + + kb = render_category(children, parent_id) + + await cb.message.edit_text( + "category selection", + reply_markup=kb, + ) diff --git a/handlers/client/checkout.py b/handlers/client/checkout.py new file mode 100644 index 0000000..8234b10 --- /dev/null +++ b/handlers/client/checkout.py @@ -0,0 +1,122 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery, Message +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from dto.checkout import CheckoutContext +from misc.kb.client import ( + order_confirmation, + order_specs_confirmation, + return_menu, +) +from misc.states import CheckoutStorage +from misc.texts import get_order_item_list +from misc.utils import is_valid_phone +from repositories.orders import OrderRepository +from services.orders import OrderService + +router = Router() + + +@router.callback_query(F.data == "checkout") +async def checkout( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + order_service: OrderService, +): + await state.clear() + + cart = await order_service.build_full_cart_dto(session, cb.from_user.id) + await cb.message.edit_text( + text=get_order_item_list(cart), reply_markup=order_confirmation(cart.order_id) + ) + + +@router.callback_query(F.data.startswith("confirm_order:")) +async def order_confirm( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + order_repo: OrderRepository, +): + await state.clear() + + order_id = cb.data.split(":")[1] + if not order_id.isdigit(): + await cb.message.edit_text("🍃 Что-то пошло не так.", reply_markup=return_menu) + return + + order = await order_repo.get_order_by_id(session, int(order_id)) + if not order or order.customer != cb.from_user.id: + await cb.message.edit_text("🍃 Что-то пошло не так.", reply_markup=return_menu) + return + + await cb.message.edit_text("👤 Укажите ваше имя:", reply_markup=return_menu) + + ctx = CheckoutContext(orig_msg=cb.message, order_id=order.id) + await state.set_state(CheckoutStorage.name) + await state.set_data({"ctx": ctx}) + + +@router.message(CheckoutStorage.name) +async def checkout_name(msg: Message, state: FSMContext): + data = await state.get_data() + ctx: CheckoutContext = data.get("ctx", CheckoutContext) + await state.clear() + + ctx.name = msg.text + await msg.delete() + + await ctx.orig_msg.edit_text( + f"👤 Имя: {ctx.name}\n\n📱 Укажите ваш номер телефона:", + reply_markup=return_menu, + ) + + await state.set_state(CheckoutStorage.phone) + await state.set_data({"ctx": ctx}) + + +@router.message(CheckoutStorage.phone) +async def checkout_phone(msg: Message, state: FSMContext): + data = await state.get_data() + ctx: CheckoutContext = data.get("ctx", CheckoutContext) + await state.clear() + + await msg.delete() + if not is_valid_phone(msg.text): + await ctx.orig_msg.edit_text( + f"❌ {msg.text} не является действительным номером, попробуйте ещё раз.", + reply_markup=return_menu, + ) + await state.set_state(CheckoutStorage.phone) + await state.set_data({"ctx": ctx}) + return + + ctx.phone = msg.text + await ctx.orig_msg.edit_text( + f"👤 Имя: {ctx.name}\n📱 Номер телефона: {ctx.phone}\n\n📍 Укажите ваш адрес (город, улица):", + reply_markup=return_menu, + ) + await state.set_state(CheckoutStorage.address) + await state.set_data({"ctx": ctx}) + + +@router.message(CheckoutStorage.address) +async def checkout_address(msg: Message, state: FSMContext): + data = await state.get_data() + ctx: CheckoutContext = data.get("ctx", CheckoutContext) + await state.clear() + + await msg.delete() + + ctx.address = msg.text + await ctx.orig_msg.edit_text( + "‼️ Проверьте правильность ваших данных.\n\n" + "━━━━━━━━━━━━━━\n\n" + f"👤 Имя: {ctx.name}\n" + f"📱 Номер телефона: {ctx.phone}\n" + f"📍 Ваш адрес: {ctx.address}\n\n" + "━━━━━━━━━━━━━━", + reply_markup=order_specs_confirmation(ctx.order_id), + ) # TODO: State-driven Payment System. diff --git a/handlers/client/menu.py b/handlers/client/menu.py new file mode 100644 index 0000000..6bcee3e --- /dev/null +++ b/handlers/client/menu.py @@ -0,0 +1,37 @@ +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 services.orders import OrderService + +router = Router() + + +@router.message(Command("start")) +async def main_menu( + msg: Message, state: FSMContext, session: AsyncSession, order_service: OrderService +): + await state.clear() + + cart_items = await order_service.get_cart_items_count(session, msg.from_user.id) + await msg.answer( + "hii!", reply_markup=main_menu_kb(cart_items) + ) # TODO: Write a welcome message + + +@router.callback_query(F.data.startswith("menu:")) +async def main_menu_cb( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + order_service: OrderService, +): + await state.clear() + + cart_items = await order_service.get_cart_items_count(session, cb.from_user.id) + await cb.message.edit_text( + "hii!", reply_markup=main_menu_kb(cart_items) + ) # TODO: Write a welcome message diff --git a/handlers/client/products.py b/handlers/client/products.py new file mode 100644 index 0000000..3af58cb --- /dev/null +++ b/handlers/client/products.py @@ -0,0 +1,164 @@ +import os +from re import Match +from aiogram import Router, F +from aiogram.types import CallbackQuery, FSInputFile +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from config import PAGE_SIZE +from misc.kb.client import ( + render_product_interactions, + render_products, +) +from misc.texts import get_product_description +from repositories.categories import CategoriesRepository +from repositories.products import ProductRepository +from services.orders import OrderService + +router = Router() + + +@router.callback_query( + F.data.regexp(r"products:(?P.*):(?P\d+)").as_( + "pagination_match" + ) +) +async def products_pagination( + cb: CallbackQuery, + state: FSMContext, + pagination_match: Match[str], + session: AsyncSession, + products_repo: ProductRepository, + categories_repo: CategoriesRepository, +): + await state.clear() + + pagination_data = pagination_match.groupdict() + category_id = int(pagination_data.get("category_id")) + category = await categories_repo.get_category_by_id(session, category_id) + page_id = int(pagination_data.get("page_id", 0)) + + products = await products_repo.get_product_by_category( + session, category_id, limit=PAGE_SIZE + 1, offset=page_id * PAGE_SIZE + ) + products_len = len(products) + + await cb.message.edit_reply_markup( + reply_markup=render_products( + products[:PAGE_SIZE], + parent_id=category.parent_id, + cat_id=category_id, + page=page_id, + show_prev=page_id > 0, + show_next=products_len > PAGE_SIZE, + ), + ) + + +@router.callback_query(F.data.startswith("product:")) +async def product_card( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + products_repo: ProductRepository, + order_service: OrderService, +): + await state.clear() + + product_id = int(cb.data.split(":")[1]) + product = await products_repo.get_product_by_id(session, product_id) + + order_item = await order_service.get_cart_product_amount( + session, customer=cb.from_user.id, product_id=product_id + ) + + if product.file_id: + await cb.message.delete() + + msg = await cb.message.answer_photo( + product.file_id, + caption=get_product_description(product), + reply_markup=render_product_interactions( + product_id=product.id, + category_id=product.category_id, + show_cart=bool(order_item), + cart_amount=order_item, + ), + ) + return + + if not os.path.isfile(product.img_path): + await cb.message.edit_text( + get_product_description(product), + reply_markup=render_product_interactions( + product_id=product.id, + category_id=product.category_id, + show_cart=bool(order_item), + cart_amount=order_item, + ), + ) + return + + await cb.message.delete() + msg = await cb.message.answer_photo( + FSInputFile(product.img_path), + caption=get_product_description(product), + reply_markup=render_product_interactions( + product_id=product.id, + category_id=product.category_id, + show_cart=bool(order_item), + cart_amount=order_item, + ), + ) + + if not msg.photo: + return # TODO: Add logging + + await products_repo.add_product_file_id( + session, product_id, file_id=msg.photo[-1].file_id + ) + + +@router.callback_query(F.data.startswith("cart_action:")) +async def add_to_cart( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + order_service: OrderService, + products_repo: ProductRepository, +): + await state.clear() + + action = cb.data.split(":")[1] + product_id = int(cb.data.split(":")[2]) + product = await products_repo.get_product_by_id(session, product_id) + show_cart = True + + if action == "add": + quantity = await order_service.add_to_cart( + session, customer=cb.from_user.id, product_id=product_id + ) + elif action == "remove": + quantity = await order_service.remove_from_cart( + session, customer=cb.from_user.id, product_id=product_id + ) + show_cart = bool(quantity) + else: + show_cart = False + quantity = 0 + await order_service.clear_cart_item( + session, customer=cb.from_user.id, product_id=product_id + ) + + try: + await cb.message.edit_reply_markup( + reply_markup=render_product_interactions( + product_id=product_id, + category_id=product.category_id, + show_cart=show_cart, + cart_amount=quantity, + ) + ) + except Exception as e: + await cb.answer("🍃") + raise e diff --git a/handlers/client/security.py b/handlers/client/security.py new file mode 100644 index 0000000..303c14f --- /dev/null +++ b/handlers/client/security.py @@ -0,0 +1,20 @@ +from aiogram import Router, F +from aiogram.enums import MessageOriginType +from aiogram.types import Message + +from config import VERIFIED_ACCOUNTS + +router = Router() + + +@router.message(F.forward_origin) +async def security_check(msg: Message): + if ( + msg.forward_origin.type == MessageOriginType.USER + and msg.forward_origin.sender_user.id in VERIFIED_ACCOUNTS + ): + text = "🍃 Вы общаетесь с оффициальным аккаунтом Екатерины." + else: + text = "❌ Вы общаетесь с подставным лицом. Мы советуем закончить диалог с мошенником как можно быстрее." + + await msg.answer(text) diff --git a/main.py b/main.py index 540ab11..aa4465e 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from config import BOT_TOKEN -from handlers import init_handlers +from handlers import admins_routers, client_routers from middlewares.di import DIMiddleware from middlewares.repository import RepositoryMiddleware @@ -61,7 +61,11 @@ async def main(): ) ) - init_handlers(dp) + for ar in admins_routers: + dp.include_router(ar) + + for cr in client_routers: + dp.include_router(cr) await dp.start_polling(bot) diff --git a/misc/kb/client.py b/misc/kb/client.py index e0dc961..bf78318 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -6,6 +6,10 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from db.models import Category, Product from dto.cart import CartItemDTO +return_menu = InlineKeyboardBuilder( + [[InlineKeyboardButton(text="⬅️ В меню", callback_data="menu:main")]] +).as_markup() + def pagination_row( *, @@ -15,6 +19,9 @@ def pagination_row( ) -> list[InlineKeyboardButton]: row = [] + if not (prev_cb or next_cb): + return row + if prev_cb: row.append(InlineKeyboardButton(text="⬅️", callback_data=prev_cb)) @@ -161,7 +168,7 @@ def render_cart( ) -> InlineKeyboardMarkup: btns = [ InlineKeyboardButton( - text=f"{cart_item.quantity} | {cart_item.name} | {cart_item.total}₽", + text=f"🛒 {cart_item.quantity} | {cart_item.name} | {cart_item.total}₽", callback_data=f"product:{cart_item.product_id}", ) for cart_item in cart_items @@ -184,4 +191,33 @@ def render_cart( ] + [btns[i : i + 2] for i in range(0, len(cart_items), 2)] + [nav] + + [[InlineKeyboardButton(text="🔷 Оформить заказ", callback_data="checkout")]] + ).as_markup() + + +def order_confirmation(order_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardBuilder( + [ + [ + InlineKeyboardButton( + text="🟢 Подтвердить заказ", + callback_data=f"confirm_order:{order_id}", + ) + ], + [InlineKeyboardButton(text="❌ Отменить", callback_data="menu:main")], + ] + ).as_markup() + + +def order_specs_confirmation(order_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardBuilder( + [ + [ + InlineKeyboardButton( + text="🟢 Перейти к оплате", + callback_data=f"payment:{order_id}", + ) + ], + [InlineKeyboardButton(text="❌", callback_data="menu:main")], + ] ).as_markup() diff --git a/misc/states.py b/misc/states.py new file mode 100644 index 0000000..55bb514 --- /dev/null +++ b/misc/states.py @@ -0,0 +1,7 @@ +from aiogram.fsm.state import StatesGroup, State + + +class CheckoutStorage(StatesGroup): + name = State() + phone = State() + address = State() diff --git a/misc/texts.py b/misc/texts.py index e2bb7d3..0c93fe6 100644 --- a/misc/texts.py +++ b/misc/texts.py @@ -1,4 +1,5 @@ from db.models import Product +from dto.cart import CartDTO def get_product_description(product: Product) -> str: @@ -7,3 +8,18 @@ def get_product_description(product: Product) -> str: output += f"\n\n📜 {product.description}" return output + + +def get_order_item_list(cart: CartDTO): + text = "🛍 Состав заказа:\n" + for i, product in enumerate(cart.items): + text += f"\n{i+1}. {product.name}\n•\t{product.quantity} × {product.price} = {product.total}₽\n" + + text += f"\n━━━━━━━━━━━━━━\n💰 Итого: {cart.total}₽" + + return text + + +# def get_order_notification(cart: CartDTO): +# text +# FIXME diff --git a/misc/utils.py b/misc/utils.py new file mode 100644 index 0000000..df374c5 --- /dev/null +++ b/misc/utils.py @@ -0,0 +1,10 @@ +import phonenumbers +from phonenumbers import NumberParseException + + +def is_valid_phone(phone: str, region="RU") -> bool: + try: + parsed = phonenumbers.parse(phone, region) + return phonenumbers.is_valid_number(parsed) + except NumberParseException: + return False diff --git a/services/orders.py b/services/orders.py index 1c34e79..4c04d7a 100644 --- a/services/orders.py +++ b/services/orders.py @@ -156,7 +156,7 @@ class OrderService: return await self.order_items_repo.get_items_count(session, order_id=order.id) - async def build_cart_dto( + async def build_pagination_cart_dto( self, session: AsyncSession, *, @@ -190,4 +190,30 @@ class OrderService: cart_items.append(cart_item) total += cart_item.total - return CartDTO(items=cart_items, total=total) + return CartDTO(items=cart_items, total=total, order_id=order.id) + + async def build_full_cart_dto(self, session: AsyncSession, customer: int): + order = await self.order_repo.get_draft_order_by_user(session, customer) + + cart_items = [] + total = 0 + + if not order: + return CartDTO(items=cart_items, total=total) + + for cart_item in order.items: + if cart_item.quantity <= 0: + await self.order_items_repo.delete_item(session, item_id=cart_item.id) + continue + + dto = CartItemDTO( + product_id=cart_item.product.id, + name=cart_item.product.name, + quantity=cart_item.quantity, + price=cart_item.product.price, + ) + + cart_items.append(dto) + total += dto.total + + return CartDTO(cart_items, total=total, order_id=order.id)