From 930fabc0286aace8562d8aa7361aab887982fa72 Mon Sep 17 00:00:00 2001 From: hexdev Date: Wed, 8 Apr 2026 19:34:25 +0700 Subject: [PATCH] Implement buy flow and migrate DTOs to schemas Move DTO definitions from dto/ to schemas/ and update imports. Add buy handler with FSM state management for device selection, whitelist toggling, and duration choice. Include pricing logic, inline keyboards, and new configuration fields. --- config.py | 5 ++ dto/__init__.py | 0 dto/di.py | 14 ---- dto/tickets.py | 6 -- dto/users.py | 14 ---- handlers/__init__.py | 3 +- handlers/buy.py | 151 +++++++++++++++++++++++++++++++++++++-- handlers/menus.py | 2 +- handlers/support.py | 2 +- keyboards/client.py | 71 +++++++++++++++++- main.py | 2 +- middlewares/di.py | 2 +- misc/utils.py | 46 +++++++++++- repositories/tickets.py | 2 +- repositories/users.py | 2 +- services/user_service.py | 5 +- texts.py | 14 ++++ 17 files changed, 291 insertions(+), 50 deletions(-) delete mode 100644 dto/__init__.py delete mode 100644 dto/di.py delete mode 100644 dto/tickets.py delete mode 100644 dto/users.py diff --git a/config.py b/config.py index 6393a2d..16ea2d4 100644 --- a/config.py +++ b/config.py @@ -13,6 +13,11 @@ class settings(BaseSettings): admin_group_id: int = Field(alias="ADMIN_GROUP_ID") remnawave_url: str = Field(alias="REMNAWAVE_URL") remnawave_token: str = Field(alias="REMNAWAVE_TOKEN") + min_devices: int = Field(3, alias="MIN_DEVICES") + max_devices: int = Field(12, alias="MAX_DEVICES") + per_device_cost: int = Field(50, alias="PER_DEVICE_COST") + whitelist_cost: int = Field(100, alias="WHITELIST_COST") + whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD") settings = settings() # type: ignore diff --git a/dto/__init__.py b/dto/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dto/di.py b/dto/di.py deleted file mode 100644 index 17ea5ec..0000000 --- a/dto/di.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass - -from repositories.tickets import TicketRepository -from repositories.users import UserRepository -from services.user_service import UserService -from remnawave import RemnawaveSDK - - -@dataclass -class DependenciesDTO: - ticket_repository: TicketRepository - user_repository: UserRepository - user_service: UserService - rw_sdk: RemnawaveSDK diff --git a/dto/tickets.py b/dto/tickets.py deleted file mode 100644 index fa33e84..0000000 --- a/dto/tickets.py +++ /dev/null @@ -1,6 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class NewTicketDTO: - creator_id: int diff --git a/dto/users.py b/dto/users.py deleted file mode 100644 index cd6466c..0000000 --- a/dto/users.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class NewUserDTO: - id: int - referal: int - - -@dataclass -class TicketCreator: - id: int - username: Optional[str] = None diff --git a/handlers/__init__.py b/handlers/__init__.py index be3ff80..4a98d04 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -1,4 +1,5 @@ from .menus import router as menus_router from .support import router as support_router +from .buy import router as buy_router -routers = [menus_router, support_router] +routers = [menus_router, support_router, buy_router] diff --git a/handlers/buy.py b/handlers/buy.py index e1bc233..d4e4aef 100644 --- a/handlers/buy.py +++ b/handlers/buy.py @@ -1,10 +1,153 @@ -from aiogram import Router +from typing import Optional +from aiogram import Router, F from aiogram.types import CallbackQuery from aiogram.fsm.context import FSMContext -from dto.di import DependenciesDTO - +from keyboards.client import devices_selector, duration_selector +from misc.utils import calculate_price, convert_int_to_duration +from schemas.subscriptions import SubscriptionPlan +from states.buy import SubscriptionStorage +from texts import ( + CALLBACK_FALLBACK, + CONTEXT_REINITIATED, + NOTHING_PLACEHOLDER, + SUBSCRIPTION_DEVICE_SELECTOR, + SUBSCRIPTION_DURATION_SELECTOR, +) +from config import settings router = Router() -# @router.callback_query() + +@router.callback_query(F.data == "buy") +async def buy_init(cb: CallbackQuery, state: FSMContext): + await state.clear() + + ctx = SubscriptionPlan() + await cb.message.edit_text( + SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)), + reply_markup=devices_selector(settings.min_devices), + ) + + await state.set_state(SubscriptionStorage.devices) + await state.set_data({"ctx": ctx}) + + +@router.callback_query(F.data.startswith("devices:")) +async def device_count_select(cb: CallbackQuery, state: FSMContext): + data = await state.get_data() + await state.clear() + + ctx: Optional[SubscriptionPlan] = data.get("ctx", SubscriptionPlan()) + + cb_data = cb.data.split(":") + devices = int(cb_data[1]) if cb_data[1].isdigit() else settings.min_devices + ctx.devices = devices + ctx.whitelists = ctx.whitelists or devices >= settings.whitelist_device_threshold + + await cb.message.edit_text( + SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)), + reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists), + ) + await state.set_state(SubscriptionStorage.devices) + await state.set_data({"ctx": ctx}) + + +@router.callback_query(SubscriptionStorage.devices, F.data == "whitelist:toggle") +async def toggle_whitelists(cb: CallbackQuery, state: FSMContext): + data = await state.get_data() + await state.clear() + + ctx: Optional[SubscriptionPlan] = data.get("ctx") + if not ctx: + await state.set_state(SubscriptionPlan()) + await state.set_data({"ctx": ctx}) + await cb.answer(CALLBACK_FALLBACK) + return + ctx.whitelists = ( + not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold + ) + + try: + await cb.message.edit_reply_markup( + SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)), + reply_markup=devices_selector( + current=ctx.devices, whitelists=ctx.whitelists + ), + ) + except Exception as exc: + if "message is not modified:" in str(exc): + await cb.answer(NOTHING_PLACEHOLDER) + else: + await cb.answer(CALLBACK_FALLBACK) + await state.set_state(SubscriptionStorage.devices) + await state.set_data({"ctx": ctx}) + + +@router.callback_query(SubscriptionStorage.devices, F.data == "confirm") +async def select_duration_init(cb: CallbackQuery, state: FSMContext): + data = await state.get_data() + await state.clear() + + ctx: Optional[SubscriptionPlan] = data.get("ctx") + if not ctx: + await state.set_state(SubscriptionPlan()) + await state.set_data({"ctx": ctx}) + await cb.answer(CALLBACK_FALLBACK) + return + + try: + await cb.message.edit_text( + SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)), + reply_markup=duration_selector( + current=ctx.duration, back_cb=f"devices:{ctx.devices}" + ), + ) + except Exception as exc: + if "message is not modified:" in str(exc): + await cb.answer(NOTHING_PLACEHOLDER) + else: + await cb.answer(CALLBACK_FALLBACK) + raise + + await state.set_state(SubscriptionStorage.duration) + await state.set_data({"ctx": ctx}) + + +@router.callback_query(F.data.startswith("duration:")) +async def select_duration(cb: CallbackQuery, state: FSMContext): + data = await state.get_data() + await state.clear() + + ctx: Optional[SubscriptionPlan] = data.get("ctx") + if not ctx: + await state.set_state() + await state.set_data({"ctx": ctx}) + await cb.answer(CONTEXT_REINITIATED) + ctx = SubscriptionPlan() + + cb_data = cb.data.split(":") + duration = int(cb_data[1]) if cb_data[1].isdigit() else 1 + ctx.duration = convert_int_to_duration(duration) + try: + await cb.message.edit_text( + SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)), + reply_markup=duration_selector( + current=ctx.duration, back_cb=f"devices:{ctx.devices}" + ), + ) + except Exception as exc: + if "message is not modified:" in str(exc): + await cb.answer(NOTHING_PLACEHOLDER) + else: + await cb.answer(CALLBACK_FALLBACK) + raise + + await state.set_state(SubscriptionStorage.duration) + await state.set_data({"ctx": ctx}) + + +@router.callback_query(SubscriptionStorage.duration, F.data == "confirm") +async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext): + await cb.answer("yay!!!! fuck off.") + ... diff --git a/handlers/menus.py b/handlers/menus.py index fd80459..0118289 100644 --- a/handlers/menus.py +++ b/handlers/menus.py @@ -4,7 +4,7 @@ from aiogram.types import CallbackQuery, Message from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from dto.di import DependenciesDTO +from schemas.di import DependenciesDTO from keyboards.client import main_menu from texts import build_main_menu_text diff --git a/handlers/support.py b/handlers/support.py index fffa37a..98659c8 100644 --- a/handlers/support.py +++ b/handlers/support.py @@ -6,7 +6,7 @@ from aiogram.types import CallbackQuery, Message from sqlalchemy.ext.asyncio import AsyncSession from config import settings -from dto.di import DependenciesDTO +from schemas.di import DependenciesDTO from services.user_service import UserServiceException from states.support import SupportStorage from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT diff --git a/keyboards/client.py b/keyboards/client.py index 0ebb909..fc92341 100644 --- a/keyboards/client.py +++ b/keyboards/client.py @@ -1,6 +1,10 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder +from config import settings +from misc.utils import convert_duration_to_int +from schemas.subscriptions import SubscriptionDuration + main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( [ [ @@ -14,6 +18,71 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( ] ).as_markup() +_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton( + text="⬅️ Назад", callback_data="menu:main" +) + + +def _return_btn( + data: str = "menu:main", text: str = "⬅️ Назад" +) -> InlineKeyboardButton: + return InlineKeyboardButton(text=text, callback_data=data) + + return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( - [[InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]] + [[_return_to_menu_btn]] ).as_markup() + + +def devices_selector(current: int = settings.min_devices, whitelists: bool = False): + builder = InlineKeyboardBuilder() + + buttons = [ + InlineKeyboardButton( + text=f"{'✅ ' if i == current else ''}{i}", callback_data=f"devices:{i}" + ) + for i in range(settings.min_devices, settings.max_devices + 1) + ] + builder.add(*buttons).adjust(6) + + wl_icon = "✅ " if whitelists else "" + builder.row( + InlineKeyboardButton( + text=f"{wl_icon}Обход Белых Списков (+100₽)", + callback_data="whitelist:toggle", + ) + ) + builder.row(InlineKeyboardButton(text="🟢", callback_data="confirm")) + builder.row(_return_to_menu_btn) + + return builder.as_markup() + + +def duration_selector( + current: SubscriptionDuration = SubscriptionDuration.MONTH, + back_cb: str = "menu:main", +) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + duration_int = convert_duration_to_int(current) + + durations = [ + (1, "1 Месяц"), + (3, "3 Месяца"), + (6, "6 Месяцев"), + (12, "1 Год"), + ] + + for val, label in durations: + is_selected = val == duration_int + + builder.button( + text=f"{'✅ ' if is_selected else ''}{label}", + callback_data=f"duration:{val}", + ) + + builder.row( + InlineKeyboardButton(text="🛒 Перейти к оплате", callback_data="confirm") + ) + builder.row(_return_btn(back_cb)) + + return builder.as_markup() diff --git a/main.py b/main.py index 33f6cb5..d89c2a8 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,7 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.client.session.aiohttp import AiohttpSession -from dto.di import DependenciesDTO +from schemas.di import DependenciesDTO from handlers import routers from middlewares import DIMiddleware from config import settings diff --git a/middlewares/di.py b/middlewares/di.py index a1672c7..89219cd 100644 --- a/middlewares/di.py +++ b/middlewares/di.py @@ -3,7 +3,7 @@ from aiogram import BaseMiddleware from aiogram.types import TelegramObject from sqlalchemy.ext.asyncio import async_sessionmaker -from dto.di import DependenciesDTO +from schemas.di import DependenciesDTO class DIMiddleware(BaseMiddleware): diff --git a/misc/utils.py b/misc/utils.py index ffad1b7..a4d96c0 100644 --- a/misc/utils.py +++ b/misc/utils.py @@ -1,7 +1,9 @@ from typing import Optional from random import choice -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from config import settings +from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan import texts QUOTES: list[str] = [ @@ -125,3 +127,45 @@ def format_username(username: Optional[str]) -> str: if username: return "@" + username return texts.NO_USERNAME + + +def convert_duration_to_int(duration: SubscriptionDuration) -> int: + mapping: dict[str, int] = { + SubscriptionDuration.MONTH.value: 1, + SubscriptionDuration.THREE_MONTHS.value: 3, + SubscriptionDuration.SIX_MONTHS.value: 6, + SubscriptionDuration.YEAR.value: 12, + } + return mapping.get(duration.value, 1) + + +def convert_int_to_duration(value: int) -> SubscriptionDuration: + mapping: dict[int, SubscriptionDuration] = { + 1: SubscriptionDuration.MONTH, + 3: SubscriptionDuration.THREE_MONTHS, + 6: SubscriptionDuration.SIX_MONTHS, + 12: SubscriptionDuration.YEAR, + } + return mapping.get(value, SubscriptionDuration.MONTH) + + +def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta: + MONTH_DELTA = timedelta(days=30) + mapping: dict[str, timedelta] = { + SubscriptionDuration.MONTH.value: MONTH_DELTA, + SubscriptionDuration.THREE_MONTHS.value: MONTH_DELTA * 3, + SubscriptionDuration.SIX_MONTHS.value: MONTH_DELTA * 6, + SubscriptionDuration.YEAR.value: MONTH_DELTA * 12, + } + return mapping.get(duration.value, MONTH_DELTA) + + +def calculate_price(subscription: SubscriptionPlan): + return ( + (subscription.devices * settings.per_device_cost) + + ( + int(subscription.whitelists) * settings.whitelist_cost + if subscription.devices < settings.whitelist_device_threshold + else 0 + ) + ) * convert_duration_to_int(subscription.duration) diff --git a/repositories/tickets.py b/repositories/tickets.py index c05a181..8489eff 100644 --- a/repositories/tickets.py +++ b/repositories/tickets.py @@ -3,7 +3,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db.models.tickets import Ticket, TicketStatus -from dto.tickets import NewTicketDTO +from schemas.tickets import NewTicketDTO class TicketRepository: diff --git a/repositories/users.py b/repositories/users.py index f9968a7..ee35aa3 100644 --- a/repositories/users.py +++ b/repositories/users.py @@ -3,7 +3,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db.models import User -from dto.users import NewUserDTO +from schemas.users import NewUserDTO class UserRepository: diff --git a/services/user_service.py b/services/user_service.py index a6a9ca5..e2238c0 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -6,8 +6,8 @@ from remnawave import RemnawaveSDK from config import settings from db.models.tickets import TicketStatus from db.models.users import User -from dto.tickets import NewTicketDTO -from dto.users import NewUserDTO, TicketCreator +from schemas.tickets import NewTicketDTO +from schemas.users import NewUserDTO, TicketCreator from repositories import UserRepository from repositories.tickets import TicketRepository from services.rw import RWUserInfo, get_user_by_telegram_id @@ -107,7 +107,6 @@ class UserService: if ticket: try: - print(ticket.thread_id) await msg.copy_to( chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id ) diff --git a/texts.py b/texts.py index 6da8ea1..eac494d 100644 --- a/texts.py +++ b/texts.py @@ -38,8 +38,19 @@ STANDARD_FALLBACK = ( "❌ Что-то пошло не так, прожмите /start и повторите попытку позже." ) +CALLBACK_FALLBACK = "❌ Что-то пошло не так" +CONTEXT_REINITIATED = "🔴 Контекст данного взаимодействия сброшен. Данные могут отличаться от указанных вами." + GENERAL_PROCESSING = "⏳ Выполняю..." +SUBSCRIPTION_DEVICE_SELECTOR = ( + "выбери колво устройств епта. тут ещё цена есть смотри опа: {price}" +) +WHITELISTS_ALREADY_ON = "уже включены!" +SUBSCRIPTION_DURATION_SELECTOR = ( + "тут короче сколько время. а ещё цена смотри ОПА {price}" +) + SUPPORT_INIT = f"{MALENIA_SUPPORT} Что-то не работает? Жалоба? Предложение?\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n❌ Для отмены — /cancel" SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время." SUPPORT_SIGNATURE = "✍️ Сообщение от технической поддержки:" @@ -122,3 +133,6 @@ TRAFFIC_WITH_LIMIT = "{used} / {limit}" # Шаблон для отображения трафика без лимита: {used} TRAFFIC_NO_LIMIT = "{used}" + + +NOTHING_PLACEHOLDER = "🍃"