From 3bd29511e169fc4740d2c83c9127f5c27934d96b Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Tue, 5 Aug 2025 23:49:08 +0300 Subject: [PATCH 1/3] Refactor README.md for improved clarity and localization - Translated the README.md content from English to Russian, enhancing accessibility for Russian-speaking users. - Streamlined the structure of the README to better highlight key features, setup instructions, and project organization. - Updated sections on user and admin functionalities, payment methods, and project technologies to reflect recent changes and improve user understanding. --- README.md | 340 ++++++++++++++++++++++-------------------------------- 1 file changed, 139 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index 83efdc4..2025950 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,183 @@ +# Telegram-бот для продажи подписок Remnawave -# Remnawave Subscription Sales Telegram Bot +Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей. -This Telegram bot is designed to automate the sale and management of subscriptions for a **Remnawave panel**. It integrates with the Remnawave API for user and subscription management and uses YooKassa for processing payments. +## ✨ Ключевые возможности -## ✨ Features +### Для пользователей: +- **Регистрация и выбор языка:** Поддержка русского и английского языков. +- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию. +- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). +- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. +- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. +- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute. -* **User Interaction:** - * User registration with language selection (English/Russian). - * Display of main menu with available actions via inline keyboards. - * Ability for users to view their current subscription status, expiration date, and configuration link. - * Trial subscription system for new users (configurable, manual activation via button). - * Promo code system for users to apply discounts or bonuses. - * Referral program for users to earn bonus subscription days. -* **Subscription Management:** - * Handles subscription purchases for various periods (1, 3, 6, 12 months). - * Integrates with **YooKassa** for payment processing, including fiscal receipt data. - * Supports **Crypto Pay** for payments with fiat currency (RUB by default). - * Automatic subscription activation/extension upon successful payment. - * Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID. - * Updates user status, expiration dates, traffic limits, and internal squads on the Remnawave panel. -* **Admin Panel:** - * Protected by `ADMIN_IDS` (supports multiple administrators). - * **Statistics:** View bot usage (total users, banned, active subscriptions), recent payments, and panel sync status. - * **User Management:** - * Ban/Unban users by Telegram ID or @username (updates local DB and panel). - * View a paginated list of banned users. - * View a "user card" with detailed information and unban option. - * **Broadcast:** Send messages to all users, users with active subscriptions, or users with expired subscriptions. - * **Promo Codes:** Create and view promo codes (bonus days, activation limits, validity). - * **Panel Sync:** Manually trigger synchronization of users and subscriptions from the Remnawave panel to the bot's database, matching by Telegram ID. - * **Activity Logs:** View a paginated list of all user actions (messages, commands, callbacks) or logs for a specific user. -* **Notifications:** - * Automated daily notifications to users about expiring subscriptions (via APScheduler). - * Notifications to users and inviters upon successful referral bonus application. - * Notifications to admin(s) about suspicious promo code input attempts. -* **Security & Technical:** - * Uses parameterized queries to prevent SQL injection. - * Proactive check for suspicious input in promo code field (notifies admin). - * Middleware for checking banned users on every interaction. - * Middleware for logging user actions. - * Webhook support for Telegram and YooKassa for efficient updates. - * Configurable via `.env` file. - * Dockerized for easy deployment. +### Для администраторов: +- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. +- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью. +- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе. +- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской. +- **Управление промокодами:** Создание и просмотр промокодов. +- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave. +- **Логи действий:** Просмотр логов всех действий пользователей. -## 🚀 Technologies Used +## 🚀 Технологии -* **Python 3.11** -* **Aiogram 3.x:** Asynchronous Telegram Bot Framework -* **aiohttp:** For running the webhook server -* **sqlalchemy:** Asynchronous PostgreSQL database interaction -* **YooKassa SDK:** For payment processing -* **APScheduler:** For scheduled tasks (e.g., notifications) -* **Pydantic:** For settings management (loading from `.env`) -* **Docker & Docker Compose:** For containerization and deployment +- **Python 3.11** +- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. +- **aiohttp:** Для запуска веб-сервера (вебхуки). +- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. +- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами. +- **APScheduler:** Для выполнения отложенных задач (например, уведомления об окончании подписки). +- **Pydantic:** Для управления настройками из `.env` файла. +- **Docker & Docker Compose:** Для контейнеризации и развертывания. -## ⚙️ Setup and Configuration +## ⚙️ Установка и запуск -### Prerequisites +### Предварительные требования -* Docker and Docker Compose installed. -* A running instance of a Remnawave panel. -* A Telegram Bot Token. -* A YooKassa Shop ID and Secret Key. +- Установленные Docker и Docker Compose. +- Рабочая панель Remnawave. +- Токен Telegram-бота. +- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.). -### Configuration Steps +### Шаги установки -1. **Clone the Repository:** +1. **Клонируйте репозиторий:** ```bash git clone https://github.com/machka-pasla/remnawave-tg-shop cd remnawave-tg-shop ``` -2. **Create an `.env` File:** - Copy the `env.example` file to `.env` and fill in your specific values: +2. **Создайте и настройте файл `.env`:** + Скопируйте `env.example` в `.env` и заполните своими данными. ```bash cp .env.example .env nano .env ``` - Key variables to configure in `.env`: - * `BOT_TOKEN`: Your Telegram Bot Token from BotFather. - * `ADMIN_IDS`: Comma-separated list of your Telegram User IDs for admin access (e.g., `12345678,98765432`). **Crucial for bot management.** - * `DEFAULT_LANGUAGE`: Default language for new users (e.g., `ru` or `en`). - * `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`. - * `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`). - * `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma). - * `SUBSCRIPTION_MINI_APP_URL`: (Optional) URL of the Telegram mini app for viewing subscription details. If set, the "My Subscription" button will open this mini app and the bot will register it automatically via API. - * `START_COMMAND_DESCRIPTION`: (Optional) Description for the `/start` command shown in the bot's menu. - * **YooKassa Settings:** - * `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa. - * `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa. - * `WEBHOOK_BASE_URL`: Base URL for all webhooks (Telegram, YooKassa, Crypto Pay). Example: `https://webhooks.yourdomain.com`. - * `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`. - * `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts. - * `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor). - * `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`. - * `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`. -* **Crypto Pay Settings:** `CRYPTOPAY_TOKEN`, `CRYPTOPAY_NETWORK` (`mainnet` or `testnet`), `CRYPTOPAY_CURRENCY_TYPE` (`fiat` or `crypto`), `CRYPTOPAY_ASSET` (e.g., `RUB`). Enable with `CRYPTOPAY_ENABLED`. - * **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`, `CRYPTOPAY_ENABLED`. - * **Subscription Options:** For each duration you can use variables like - `1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH` - (and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`). - * **Panel API Settings:** - * `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`). - * `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel. - * `PANEL_WEBHOOK_SECRET`: Secret key for verifying webhooks from the Remnawave panel. - * `USER_SQUAD_UUIDS`: (Optional) Comma-separated list of internal squad UUIDs from your panel to assign to users during creation. - * `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel. - * `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period. - * `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server. - * `LOGS_PAGE_SIZE`: For admin panel log pagination. + Ниже перечислены ключевые переменные. -3. **Locales:** - * Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional. +
+ Основные настройки -4. **Run with Docker Compose:** + | Переменная | Описание | Пример | + | --- | --- | --- | + | `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` | + | `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` | + | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | + | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | + | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` | +
+ +
+ Настройки платежей и вебхуков + + | Переменная | Описание | + | --- | --- | + | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | + | `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | + | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | + | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | + | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | + | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | + | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | + | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | + | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | + | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | +
+ +
+ Настройки подписок + + Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены: + - `1_MONTH_ENABLED`: `true` или `false` + - `RUB_PRICE_1_MONTH`: Цена в рублях + - `STARS_PRICE_1_MONTH`: Цена в Telegram Stars + - `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute + Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`. +
+ +
+ Настройки панели Remnawave + + | Переменная | Описание | + | --- | --- | + | `PANEL_API_URL` | URL API вашей панели Remnawave. | + | `PANEL_API_KEY` | API ключ для доступа к панели. | + | `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. | + | `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. | + | `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). | + + +
+ Настройки пробного периода + + | Переменная | Описание | + | --- | --- | + | `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). | + | `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. | + | `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. | +
+ +3. **Запустите контейнеры:** ```bash docker compose up -d ``` - This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode. + Эта команда скачает образ и запустит сервис в фоновом режиме. -5. **Webhook Setup (Important if using webhooks):** - * **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container. - * Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`). - * Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/cryptopay` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/cryptopay`. - * Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/tribute` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/tribute`. - * If using Telegram webhooks, forward requests for `https://{WEBHOOK_BASE_URL_domain}/` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/`. - * **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`. +4. **Настройка вебхуков (Обязательно):** + Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave. -6. **Database:** - * A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist. + Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом. -7. **Viewing Logs:** + **Пути для перенаправления:** + - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:/webhook/yookassa` + - `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:/webhook/cryptopay` + - `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:/webhook/tribute` + - `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:/webhook/panel` + - **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/`. + + Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `` — порт, указанный в `.env`. + +5. **Просмотр логов:** ```bash docker compose logs -f remnawave-tg-shop ``` -## 🐳 Docker Setup +## 🐳 Docker -### `Dockerfile` +Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки. -```dockerfile -FROM python:3.11-slim - -WORKDIR /app - -COPY requirements.txt . - -# Consider adding build arguments for proxy if needed in your environment -# ARG HTTP_PROXY -# ARG HTTPS_PROXY -# ENV http_proxy=$HTTP_PROXY -# ENV https_proxy=$HTTPS_PROXY - -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -# Ensure main.py is executable if needed, though python command handles it -# RUN chmod +x main.py - -CMD ["python", "main.py"] - -``` - -### `docker-compose.yml` -``` -services: - remnawave-tg-shop: - image: ghcr.io/machka-pasla/remnawave-tg-shop:latest -# build: . - container_name: remnawave-tg-shop - hostname: remnawave-tg-shop - env_file: - - .env -# networks: -# - remnawave-network -# volumes: -# - ./locales:/app/locales - restart: unless-stopped - - postgres: - image: postgres:17 - container_name: remnawave-tg-shop-db - env_file: - - .env - volumes: - - remnawave-tg-shop-db-data:/var/lib/postgresql/data -# networks: -# - remnawave-network - restart: unless-stopped - -# networks: -# remnawave-network: -# external: true - -volumes: - remnawave-tg-shop-db-data: - name: remnawave-tg-shop-db-data -``` - -**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names. - -## 🛠️ Project Structure (Overview) +## 📁 Структура проекта ``` . ├── bot/ -│ ├── filters/ # Custom Aiogram filters (e.g., AdminFilter) -│ ├── handlers/ # Message and callback query handlers (admin and user) -│ ├── keyboards/ # Inline and reply keyboard generators -│ ├── middlewares/ # Custom Aiogram middlewares (i18n, ban check, logger) -│ ├── services/ # Business logic (payments, subscriptions, panel API interaction) -│ ├── states/ # FSM states -│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown +│ ├── filters/ # Пользовательские фильтры Aiogram +│ ├── handlers/ # Обработчики сообщений и колбэков +│ ├── keyboards/ # Клавиатуры +│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана) +│ ├── services/ # Бизнес-логика (платежи, API панели) +│ ├── states/ # Состояния FSM +│ └── main_bot.py # Основная логика бота ├── config/ -│ └── settings.py # Pydantic settings and config parser +│ └── settings.py # Настройки Pydantic ├── db/ -│ ├── dal/ # Data Access Layer (queries, transactions) -│ ├── database_setup.py # DB connection/init setup -│ └── models.py # ORM models (e.g., SQLAlchemy) -├── locales/ # Localization files -│ ├── en.json # English locale -│ └── ru.json # Russian locale -├── .env.example # Example environment variables for local setup -├── .env # Actual environment variables (ignored by Git) -├── Dockerfile # Docker image build instructions -├── docker-compose.yml # Docker Compose orchestration config -├── requirements.txt # List of Python dependencies -├── README.md # Project documentation -└── main.py # Entry point to launch the bot +│ ├── dal/ # Слой доступа к данным (DAL) +│ ├── database_setup.py # Настройка БД +│ └── models.py # Модели SQLAlchemy +├── locales/ # Файлы локализации (ru, en) +├── .env.example # Пример файла с переменными окружения +├── Dockerfile # Инструкции для сборки Docker-образа +├── docker-compose.yml # Файл для оркестрации контейнеров +├── requirements.txt # Зависимости Python +└── main.py # Точка входа в приложение ``` -## 🤝 Contributing +## 🔮 Планы на будущее -Contributions are welcome! +- Расширенные типы промокодов (например, скидки в процентах). -## 🔮 Future Enhancements - -- More detailed analytics for admin. -- Support for different payment methods. -- Advanced promo code types (e.g., percentage discounts). - -## Donations (pls) -- Russian and international cards [LINK](https://t.me/tribute/app?startapp=dqdg) +## ❤️ Поддержка +- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg) +- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE` From 67f31d62e4e2b06fab58331dde43d64ea888d92e Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 6 Aug 2025 01:14:26 +0300 Subject: [PATCH 2/3] Refactor promo code management in admin panel for improved clarity and localization - Simplified the handling of promo codes by consolidating text messages and improving localization for better user experience. - Updated the structure of promo details and management views, enhancing readability and consistency in the admin panel. - Removed deprecated text and streamlined the activation status messages, ensuring accurate and clear communication for admins. --- bot/handlers/admin/promo/manage.py | 146 +++++++++++------------------ 1 file changed, 53 insertions(+), 93 deletions(-) diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py index 1b00406..97a5620 100644 --- a/bot/handlers/admin/promo/manage.py +++ b/bot/handlers/admin/promo/manage.py @@ -34,16 +34,12 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, offset=0) if not promo_models: - text = _( - "admin_no_promo_codes", - default="📋 Активные промокоды\n\n❌ Нет активных промокодов" - ) + text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" else: - lines = [_("admin_active_promo_codes_title", - default="📋 Активные промокоды\n")] + lines = [_("admin_active_promos_list_header"), ""] for promo in promo_models: - validity_str = _("admin_promo_unlimited", default="Без ограничений") + validity_str = _("admin_promo_valid_indefinitely") if promo.valid_until: validity_str = promo.valid_until.strftime("%d.%m.%Y") @@ -90,10 +86,7 @@ async def promo_management_handler(callback: types.CallbackQuery, promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0) if not promo_models: - text = _( - "admin_no_promo_codes_to_manage", - default="🛠 Управление промокодами\n\n❌ Нет промокодов для управления" - ) + text = _("admin_promo_management_empty") await callback.message.edit_text( text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), @@ -103,21 +96,9 @@ async def promo_management_handler(callback: types.CallbackQuery, return builder = InlineKeyboardBuilder() - text_lines = [_("admin_promo_management_title", default="🛠 Управление промокодами\n")] + text = _("admin_promo_management_title") for promo in promo_models: - status_emoji = "✅" if promo.is_active else "❌" - validity_str = _("admin_promo_unlimited", default="∞") - if promo.valid_until: - validity_str = promo.valid_until.strftime("%d.%m") - - text_lines.append( - f"{status_emoji} {promo.code} | " - f"🎁{promo.bonus_days}д | " - f"📊{promo.current_activations}/{promo.max_activations} | " - f"⏰{validity_str}" - ) - builder.row( InlineKeyboardButton( text=f"📝 {promo.code}", @@ -127,13 +108,11 @@ async def promo_management_handler(callback: types.CallbackQuery, builder.row( InlineKeyboardButton( - text=_("admin_back_to_panel", default="🔙 В админ панель"), + text=_("back_to_admin_panel_button"), callback_data="admin_action:main" ) ) - text = "\n".join(text_lines) - try: await callback.message.edit_text( text, @@ -170,80 +149,65 @@ async def promo_detail_handler(callback: types.CallbackQuery, promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) if not promo: - await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) return - # Format promo details - status_text = _("admin_promo_active", default="✅ Активен") if promo.is_active else _("admin_promo_inactive", default="❌ Неактивен") - validity_text = _("admin_promo_unlimited", default="Без ограничений") + status = _("admin_promo_status_active") if promo.is_active else _("admin_promo_status_inactive") + if promo.valid_until and promo.valid_until < datetime.now(timezone.utc): + status = _("admin_promo_status_expired") + elif promo.current_activations >= promo.max_activations: + status = _("admin_promo_status_used_up") + + validity = _("admin_promo_valid_indefinitely") if promo.valid_until: - if promo.valid_until > datetime.now(timezone.utc): - validity_text = promo.valid_until.strftime("%d.%m.%Y %H:%M") - else: - validity_text = f"{promo.valid_until.strftime('%d.%m.%Y %H:%M')} (истек)" + validity = promo.valid_until.strftime("%d.%m.%Y %H:%M") - created_at = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" + created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" - text = _( - "admin_promo_detail_info", - default="🎟 Детали промокода\n\n" - "📝 Код: {code}\n" - "🎁 Бонусные дни: {bonus_days}\n" - "📊 Использований: {current}/{max}\n" - "📋 Статус: {status}\n" - "⏰ Действует до: {validity}\n" - "👤 Создал: {admin_id}\n" - "🕐 Создан: {created}", - code=promo.code, - bonus_days=promo.bonus_days, - current=promo.current_activations, - max=promo.max_activations, - status=status_text, - validity=validity_text, - admin_id=promo.created_by_admin_id, - created=created_at - ) + text = "\n".join([ + _("admin_promo_card_title", code=promo.code), + _("admin_promo_card_bonus_days", days=promo.bonus_days), + _("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations), + _("admin_promo_card_validity", validity=validity), + _("admin_promo_card_status", status=status), + _("admin_promo_card_created", created=created), + _("admin_promo_card_created_by", creator=promo.created_by_admin_id) + ]) # Create management buttons builder = InlineKeyboardBuilder() - # Edit button builder.row( InlineKeyboardButton( - text=_("admin_promo_edit", default="✏️ Редактировать"), + text=_("admin_promo_edit_button"), callback_data=f"promo_edit:{promo_id}" ) ) - # Toggle active/inactive - toggle_text = _("admin_promo_deactivate", default="❌ Деактивировать") if promo.is_active else _("admin_promo_activate", default="✅ Активировать") builder.row( InlineKeyboardButton( - text=toggle_text, + text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}" ) ) - # View activations builder.row( InlineKeyboardButton( - text=_("admin_promo_view_activations", default="📋 Активации"), + text=_("admin_promo_view_activations_button"), callback_data=f"promo_activations:{promo_id}" ) ) - # Delete button builder.row( InlineKeyboardButton( - text=_("admin_promo_delete", default="🗑 Удалить"), + text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}" ) ) - # Back button builder.row( InlineKeyboardButton( - text=_("admin_back_to_management", default="🔙 К управлению"), + text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management" ) ) @@ -263,7 +227,7 @@ async def promo_detail_handler(callback: types.CallbackQuery, await callback.answer() except ValueError: - await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) except Exception as e: logging.error(f"Error in promo detail: {e}") await callback.answer(_("error_occurred_try_again"), show_alert=True) @@ -285,7 +249,7 @@ async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) if not promo: - await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) return new_status = not promo.is_active @@ -294,9 +258,9 @@ async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) if updated: await session.commit() - status_text = _("admin_promo_activated", default="активирован") if new_status else _("admin_promo_deactivated", default="деактивирован") + status_text = _("admin_promo_status_activated") if new_status else _("admin_promo_status_deactivated") await callback.answer( - _("admin_promo_status_changed", default="✅ Промокод {status}", status=status_text) + _("admin_promo_toggle_success", code=promo.code, status=status_text) ) # Refresh the detail view callback.data = f"promo_detail:{promo_id}" @@ -305,7 +269,7 @@ async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, await callback.answer(_("error_occurred_try_again"), show_alert=True) except ValueError: - await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) except Exception as e: logging.error(f"Error toggling promo: {e}") await callback.answer(_("error_occurred_try_again"), show_alert=True) @@ -327,35 +291,31 @@ async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: di promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) if not promo: - await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) return activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id) - text_lines = [ - _("admin_promo_activations_title", - default="📋 Активации промокода {code}\n", - code=promo.code) - ] - if not activations: - text_lines.append(_("admin_no_activations", default="❌ Нет активаций")) + text = _("admin_promo_no_activations", code=promo.code) else: - for activation in activations[:10]: # Show last 10 - activated_at = activation.activated_at.strftime("%d.%m.%Y %H:%M") if activation.activated_at else "N/A" + text_lines = [ + _("admin_promo_activations_header", code=promo.code) + ] + for activation in activations[:20]: text_lines.append( - f"👤 {activation.user_id} | 🕐 {activated_at}" + _("admin_promo_activation_item", + user_id=activation.user_id, + date=activation.activated_at.strftime("%d.%m.%Y %H:%M")) ) - - if len(activations) > 10: - text_lines.append(f"\n... и еще {len(activations) - 10}") - - text = "\n".join(text_lines) + if len(activations) > 20: + text_lines.append(f"\n... (еще {len(activations) - 20})") + text = "\n".join(text_lines) builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( - text=_("admin_back_to_promo", default="🔙 К промокоду"), + text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}" ) ) @@ -375,7 +335,7 @@ async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: di await callback.answer() except ValueError: - await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) except Exception as e: logging.error(f"Error viewing activations: {e}") await callback.answer(_("error_occurred_try_again"), show_alert=True) @@ -399,16 +359,16 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, if promo: await session.commit() await callback.answer( - _("admin_promo_deleted", default="✅ Промокод удален", code=promo.code) + _("admin_promo_deleted_success", code=promo.code) ) # Go back to management callback.data = "admin_action:promo_management" await promo_management_handler(callback, i18n_data, settings, session) else: - await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) except ValueError: - await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + await callback.answer(_("admin_promo_not_found"), show_alert=True) except Exception as e: logging.error(f"Error deleting promo: {e}") await callback.answer(_("error_occurred_try_again"), show_alert=True) @@ -419,4 +379,4 @@ async def manage_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): """Redirect to new unified handler""" - await promo_management_handler(callback, i18n_data, settings, session) \ No newline at end of file + await promo_management_handler(callback, i18n_data, settings, session) From 4675141974cc38829eb14c5f7bf65a763e8b8c1a Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 6 Aug 2025 01:21:47 +0300 Subject: [PATCH 3/3] Enhance promo code management in admin panel with detailed views and export functionality - Introduced a new method to retrieve detailed promo code information, improving the clarity of promo management. - Added functionality to export promo code activations to CSV, enhancing data accessibility for admins. - Streamlined the handling of promo code edits and improved user prompts for better interaction. - Updated localization files to reflect new messages and ensure consistency across the admin panel. --- bot/handlers/admin/promo/manage.py | 537 +++++++++++++---------------- db/dal/promo_code_dal.py | 32 +- locales/en.json | 15 +- locales/ru.json | 15 +- 4 files changed, 288 insertions(+), 311 deletions(-) diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py index 97a5620..9d7c4f5 100644 --- a/bot/handlers/admin/promo/manage.py +++ b/bot/handlers/admin/promo/manage.py @@ -1,4 +1,6 @@ import logging +import csv +import io from aiogram import Router, F, types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext @@ -17,10 +19,45 @@ from bot.middlewares.i18n import JsonI18n router = Router(name="promo_manage_router") -async def view_promo_codes_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - """View all active promo codes""" +async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str): + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + if not promo: + return None, None + + status = _("admin_promo_status_active") if promo.is_active else _("admin_promo_status_inactive") + if promo.valid_until and promo.valid_until < datetime.now(timezone.utc): + status = _("admin_promo_status_expired") + elif promo.current_activations >= promo.max_activations: + status = _("admin_promo_status_used_up") + + validity = _("admin_promo_valid_indefinitely") + if promo.valid_until: + validity = promo.valid_until.strftime("%d.%m.%Y %H:%M") + + created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" + + text = "\n".join([ + _("admin_promo_card_title", code=promo.code), + _("admin_promo_card_bonus_days", days=promo.bonus_days), + _("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations), + _("admin_promo_card_validity", validity=validity), + _("admin_promo_card_status", status=status), + _("admin_promo_card_created", created=created), + _("admin_promo_card_created_by", creator=promo.created_by_admin_id) + ]) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=_("admin_promo_edit_button"), callback_data=f"promo_edit_select:{promo_id}")) + builder.row(InlineKeyboardButton(text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}")) + builder.row(InlineKeyboardButton(text=_("admin_promo_view_activations_button"), callback_data=f"promo_activations:{promo_id}:0")) + builder.row(InlineKeyboardButton(text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}")) + builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management")) + + return text, builder.as_markup() + + +async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n or not callback.message: @@ -28,53 +65,19 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - try: - promo_models = await promo_code_dal.get_all_active_promo_codes(session, - limit=20, - offset=0) - - if not promo_models: - text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" - else: - lines = [_("admin_active_promos_list_header"), ""] - - for promo in promo_models: - validity_str = _("admin_promo_valid_indefinitely") - if promo.valid_until: - validity_str = promo.valid_until.strftime("%d.%m.%Y") - - lines.append( - f"🎟 {promo.code} | " - f"🎁 {promo.bonus_days}д | " - f"📊 {promo.current_activations}/{promo.max_activations} | " - f"⏰ {validity_str}" - ) - - text = "\n".join(lines) - - try: - await callback.message.edit_text( - text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - except Exception: - await callback.message.answer( - text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await callback.answer() - - except Exception as e: - logging.error(f"Error viewing promo codes: {e}") - await callback.answer(_("error_occurred_try_again"), show_alert=True) + promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0) + text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join( + [_("admin_active_promos_list_header"), ""] + [ + f"🎟 {p.code} | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" + for p in promo_models + ] + ) + + await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML") + await callback.answer() -async def promo_management_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - """Main promo management interface""" +async def promo_management_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n or not callback.message: @@ -82,301 +85,251 @@ async def promo_management_handler(callback: types.CallbackQuery, return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - try: - promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0) - - if not promo_models: - text = _("admin_promo_management_empty") - await callback.message.edit_text( - text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await callback.answer() - return - - builder = InlineKeyboardBuilder() - text = _("admin_promo_management_title") - - for promo in promo_models: - builder.row( - InlineKeyboardButton( - text=f"📝 {promo.code}", - callback_data=f"promo_detail:{promo.promo_code_id}" - ) - ) - - builder.row( - InlineKeyboardButton( - text=_("back_to_admin_panel_button"), - callback_data="admin_action:main" - ) - ) - - try: - await callback.message.edit_text( - text, - reply_markup=builder.as_markup(), - parse_mode="HTML" - ) - except Exception: - await callback.message.answer( - text, - reply_markup=builder.as_markup(), - parse_mode="HTML" - ) + promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0) + if not promo_models: + await callback.message.edit_text(_("admin_promo_management_empty"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML") await callback.answer() - - except Exception as e: - logging.error(f"Error in promo management: {e}") - await callback.answer(_("error_occurred_try_again"), show_alert=True) + return + + builder = InlineKeyboardBuilder() + for promo in promo_models: + builder.row(InlineKeyboardButton(text=f"📝 {promo.code}", callback_data=f"promo_detail:{promo.promo_code_id}")) + builder.row(InlineKeyboardButton(text=_("back_to_admin_panel_button"), callback_data="admin_action:main")) + + await callback.message.edit_text(_("admin_promo_management_title"), reply_markup=builder.as_markup(), parse_mode="HTML") + await callback.answer() @router.callback_query(F.data.startswith("promo_detail:")) -async def promo_detail_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - """Show detailed promo code information with management options""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) +async def promo_detail_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession): i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: + current_lang = i18n_data.get("current_language") + if not i18n or not callback.message or not current_lang: await callback.answer("Error processing request.", show_alert=True) return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - + try: promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - - if not promo: - await callback.answer(_("admin_promo_not_found"), show_alert=True) - return - - status = _("admin_promo_status_active") if promo.is_active else _("admin_promo_status_inactive") - if promo.valid_until and promo.valid_until < datetime.now(timezone.utc): - status = _("admin_promo_status_expired") - elif promo.current_activations >= promo.max_activations: - status = _("admin_promo_status_used_up") - - validity = _("admin_promo_valid_indefinitely") - if promo.valid_until: - validity = promo.valid_until.strftime("%d.%m.%Y %H:%M") - - created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" - - text = "\n".join([ - _("admin_promo_card_title", code=promo.code), - _("admin_promo_card_bonus_days", days=promo.bonus_days), - _("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations), - _("admin_promo_card_validity", validity=validity), - _("admin_promo_card_status", status=status), - _("admin_promo_card_created", created=created), - _("admin_promo_card_created_by", creator=promo.created_by_admin_id) - ]) - - # Create management buttons - builder = InlineKeyboardBuilder() - - builder.row( - InlineKeyboardButton( - text=_("admin_promo_edit_button"), - callback_data=f"promo_edit:{promo_id}" - ) - ) - - builder.row( - InlineKeyboardButton( - text=_("admin_promo_toggle_status_button"), - callback_data=f"promo_toggle:{promo_id}" - ) - ) - - builder.row( - InlineKeyboardButton( - text=_("admin_promo_view_activations_button"), - callback_data=f"promo_activations:{promo_id}" - ) - ) - - builder.row( - InlineKeyboardButton( - text=_("admin_promo_delete_button"), - callback_data=f"promo_delete:{promo_id}" - ) - ) - - builder.row( - InlineKeyboardButton( - text=_("admin_promo_back_to_list_button"), - callback_data="admin_action:promo_management" - ) - ) - - try: - await callback.message.edit_text( - text, - reply_markup=builder.as_markup(), - parse_mode="HTML" - ) - except Exception: - await callback.message.answer( - text, - reply_markup=builder.as_markup(), - parse_mode="HTML" - ) - await callback.answer() - - except ValueError: - await callback.answer(_("admin_promo_not_found"), show_alert=True) - except Exception as e: - logging.error(f"Error in promo detail: {e}") - await callback.answer(_("error_occurred_try_again"), show_alert=True) + text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang) + if text: + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + else: + await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True) + except (ValueError, IndexError): + await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True) + await callback.answer() @router.callback_query(F.data.startswith("promo_toggle:")) -async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - """Toggle promo code active status""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) +async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession): i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language service error.", show_alert=True) - return + current_lang = i18n_data.get("current_language") + if not i18n or not callback.message or not current_lang: + return await callback.answer("Language service error.", show_alert=True) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - + try: promo_id = int(callback.data.split(":")[1]) promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - if not promo: - await callback.answer(_("admin_promo_not_found"), show_alert=True) - return - + return await callback.answer(_("admin_promo_not_found"), show_alert=True) + new_status = not promo.is_active - update_data = {"is_active": new_status} - - updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) - if updated: + if await promo_code_dal.update_promo_code(session, promo_id, {"is_active": new_status}): await session.commit() status_text = _("admin_promo_status_activated") if new_status else _("admin_promo_status_deactivated") - await callback.answer( - _("admin_promo_toggle_success", code=promo.code, status=status_text) - ) - # Refresh the detail view - callback.data = f"promo_detail:{promo_id}" - await promo_detail_handler(callback, i18n_data, settings, session) + await callback.answer(_("admin_promo_toggle_success", code=promo.code, status=status_text)) + + text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang) + if text: + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") else: await callback.answer(_("error_occurred_try_again"), show_alert=True) - - except ValueError: + except (ValueError, IndexError): await callback.answer(_("admin_promo_not_found"), show_alert=True) - except Exception as e: - logging.error(f"Error toggling promo: {e}") - await callback.answer(_("error_occurred_try_again"), show_alert=True) @router.callback_query(F.data.startswith("promo_activations:")) -async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - """Show promo code activations""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) +async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error processing request.", show_alert=True) - return + current_lang = i18n_data.get("current_language") + if not i18n or not callback.message or not current_lang: + return await callback.answer("Error processing request.", show_alert=True) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + parts = callback.data.split(":") + promo_id = int(parts[1]) + page = int(parts[2]) + page_size = settings.LOGS_PAGE_SIZE + + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + if not promo: + return await callback.answer(_("admin_promo_not_found"), show_alert=True) + + total_activations = await promo_code_dal.count_promo_activations_by_code_id(session, promo_id) + activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id, limit=page_size, offset=page * page_size) + + builder = InlineKeyboardBuilder() + if not activations: + text = _("admin_promo_no_activations", code=promo.code) + else: + text = _("admin_promo_activations_header", code=promo.code) + "\n\n" + text += "\n".join([_("admin_promo_activation_item", user_id=a.user_id, date=a.activated_at.strftime("%d.%m.%Y %H:%M")) for a in activations]) + + nav_buttons = [] + if page > 0: + nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"promo_activations:{promo_id}:{page-1}")) + if (page + 1) * page_size < total_activations: + nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"promo_activations:{promo_id}:{page+1}")) + if nav_buttons: + builder.row(*nav_buttons) + + builder.row(InlineKeyboardButton(text=_("admin_promo_export_csv_button"), callback_data=f"promo_export:{promo_id}")) + builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}")) + + await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML") + except (ValueError, IndexError): + await callback.answer(_("admin_promo_not_found"), show_alert=True) + await callback.answer() + + +@router.callback_query(F.data.startswith("promo_export:")) +async def promo_export_activations_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language") + if not i18n or not callback.message or not current_lang: + return await callback.answer("Error processing request.", show_alert=True) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) try: promo_id = int(callback.data.split(":")[1]) promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - if not promo: - await callback.answer(_("admin_promo_not_found"), show_alert=True) - return - + return await callback.answer(_("admin_promo_not_found"), show_alert=True) + activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id) - if not activations: - text = _("admin_promo_no_activations", code=promo.code) - else: - text_lines = [ - _("admin_promo_activations_header", code=promo.code) - ] - for activation in activations[:20]: - text_lines.append( - _("admin_promo_activation_item", - user_id=activation.user_id, - date=activation.activated_at.strftime("%d.%m.%Y %H:%M")) - ) - if len(activations) > 20: - text_lines.append(f"\n... (еще {len(activations) - 20})") - text = "\n".join(text_lines) + return await callback.answer(_("admin_promo_no_activations", code=promo.code), show_alert=True) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["User ID", "Activation Date"]) + for act in activations: + writer.writerow([act.user_id, act.activated_at.strftime("%Y-%m-%d %H:%M:%S")]) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text=_("admin_promo_back_to_detail_button"), - callback_data=f"promo_detail:{promo_id}" - ) - ) - - try: - await callback.message.edit_text( - text, - reply_markup=builder.as_markup(), - parse_mode="HTML" - ) - except Exception: - await callback.message.answer( - text, - reply_markup=builder.as_markup(), - parse_mode="HTML" - ) - await callback.answer() - - except ValueError: + output.seek(0) + file = types.BufferedInputFile(output.getvalue().encode('utf-8'), filename=f"promo_{promo.code}_activations.csv") + await callback.message.answer_document(file, caption=_("admin_promo_export_caption", code=promo.code)) + + except (ValueError, IndexError): await callback.answer(_("admin_promo_not_found"), show_alert=True) - except Exception as e: - logging.error(f"Error viewing activations: {e}") - await callback.answer(_("error_occurred_try_again"), show_alert=True) + await callback.answer() @router.callback_query(F.data.startswith("promo_delete:")) -async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - """Delete promo code""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) +async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession): i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language service error.", show_alert=True) - return + current_lang = i18n_data.get("current_language") + if not i18n or not callback.message or not current_lang: + return await callback.answer("Language service error.", show_alert=True) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) try: promo_id = int(callback.data.split(":")[1]) promo = await promo_code_dal.delete_promo_code(session, promo_id) - if promo: await session.commit() - await callback.answer( - _("admin_promo_deleted_success", code=promo.code) - ) - # Go back to management - callback.data = "admin_action:promo_management" - await promo_management_handler(callback, i18n_data, settings, session) + await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True) + await promo_management_handler(callback, i18n_data, {}, session) # Settings not needed here else: await callback.answer(_("admin_promo_not_found"), show_alert=True) - - except ValueError: + except (ValueError, IndexError): await callback.answer(_("admin_promo_not_found"), show_alert=True) - except Exception as e: - logging.error(f"Error deleting promo: {e}") - await callback.answer(_("error_occurred_try_again"), show_alert=True) -# Legacy handlers that redirect to new system -async def manage_promo_codes_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - """Redirect to new unified handler""" +# --- Promo Edit Handlers --- +@router.callback_query(F.data.startswith("promo_edit_select:")) +async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: dict): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language") + if not i18n or not callback.message or not current_lang: + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + promo_id = int(callback.data.split(":")[1]) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}")) + builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}")) + builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}")) + builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}")) + + await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup()) + await callback.answer() + + +@router.callback_query(F.data.startswith("promo_edit_field:")) +async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language") + if not i18n or not callback.message or not current_lang: return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + _, field, promo_id_str = callback.data.split(":") + await state.update_data(promo_id=int(promo_id_str), field_to_edit=field) + + prompts = { + "bonus_days": "admin_promo_prompt_bonus_days", + "max_activations": "admin_promo_prompt_max_activations", + "valid_until": "admin_promo_prompt_validity_days" + } + await state.set_state(AdminStates.waiting_for_promo_edit_details) + await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again"))) + await callback.answer() + +@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details)) +async def process_promo_edit_details(message: types.Message, state: FSMContext, session: AsyncSession, i18n_data: dict): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language") + if not i18n or not message or not current_lang: return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + promo_id = data.get("promo_id") + field = data.get("field_to_edit") + + try: + value = message.text + update_data = {} + + if field == "bonus_days": + update_data["bonus_days"] = int(value) + elif field == "max_activations": + update_data["max_activations"] = int(value) + elif field == "valid_until": + if value.lower() in ['0', 'вечно', 'бессрочно', 'indefinite']: + update_data["valid_until"] = None + else: + days = int(value) + update_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=days) + + if await promo_code_dal.update_promo_code(session, promo_id, update_data): + await session.commit() + await message.answer(_("admin_promo_edit_success")) + + # Reset state and show updated details + await state.clear() + text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang) + if text: + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + else: + await message.answer(_("error_occurred_try_again")) + await state.clear() + + except (ValueError, TypeError): + await message.answer(_("admin_promo_invalid_input")) + # Don't clear state, let them try again + + +async def manage_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): await promo_management_handler(callback, i18n_data, settings, session) diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index 5d1f017..8d2f964 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -10,7 +10,6 @@ from db.models import PromoCode, PromoCodeActivation, User, Payment async def create_promo_code(session: AsyncSession, promo_data: Dict[str, Any]) -> PromoCode: - new_promo = PromoCode(**promo_data) session.add(new_promo) await session.flush() @@ -65,24 +64,25 @@ async def get_all_promo_codes_with_details(session: AsyncSession, limit: int = 5 return result.scalars().all() -async def get_promo_code_by_id(session: AsyncSession, promo_id: int) -> Optional[PromoCode]: - """Get promo code by ID""" - stmt = select(PromoCode).where(PromoCode.promo_code_id == promo_id) - result = await session.execute(stmt) - return result.scalar_one_or_none() - - -async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> List: - """Get activation history for a specific promo code""" - from db.models import PromoCodeActivation +async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int, limit: Optional[int] = None, offset: int = 0) -> List[PromoCodeActivation]: + """Get activation history for a specific promo code with optional pagination.""" stmt = (select(PromoCodeActivation) .where(PromoCodeActivation.promo_code_id == promo_code_id) .order_by(PromoCodeActivation.activated_at.desc()) - .limit(50)) # Limit to last 50 activations + .offset(offset)) + if limit is not None: + stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() +async def count_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> int: + """Count total activations for a specific promo code.""" + stmt = select(func.count()).select_from(PromoCodeActivation).where(PromoCodeActivation.promo_code_id == promo_code_id) + result = await session.execute(stmt) + return result.scalar_one() + + async def update_promo_code(session: AsyncSession, promo_id: int, update_data: Dict[str, Any]) -> Optional[PromoCode]: promo = await get_promo_code_by_id(session, promo_id) @@ -99,6 +99,11 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr promo = await get_promo_code_by_id(session, promo_id) if not promo: return None + # First, delete related activations due to foreign key constraint + activations = await get_promo_activations_by_code_id(session, promo_id) + for activation in activations: + await session.delete(activation) + await session.delete(promo) await session.flush() return promo @@ -124,7 +129,6 @@ async def increment_promo_code_usage( async def get_user_activation_for_promo( session: AsyncSession, promo_code_id: int, user_id: int) -> Optional[PromoCodeActivation]: - stmt = select(PromoCodeActivation).where( PromoCodeActivation.promo_code_id == promo_code_id, PromoCodeActivation.user_id == user_id).limit(1) @@ -137,7 +141,6 @@ async def record_promo_activation( promo_code_id: int, user_id: int, payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]: - existing_activation = await get_user_activation_for_promo( session, promo_code_id, user_id) if existing_activation: @@ -162,7 +165,6 @@ async def record_promo_activation( logging.error( f"Cannot record promo activation: Payment {payment_id} not found." ) - return None activation_data = { diff --git a/locales/en.json b/locales/en.json index 6b8405c..738369e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -161,9 +161,20 @@ "admin_promo_edit_button": "✏️ Edit", "admin_promo_delete_button": "🗑 Delete", "admin_promo_edit_prompt": "Send new details for {code} in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]", - "admin_promo_updated_success": "Promo {code} updated.", - "admin_promo_deleted_success": "Promo {code} deleted.", + "admin_promo_updated_success": "Promo {code} updated.", + "admin_promo_deleted_success": "Promo {code} deleted.", "admin_promo_not_found": "Promo not found.", + "admin_promo_export_csv_button": "📄 Export to CSV", + "admin_promo_export_caption": "📄 Activations for promo code {code}", + "admin_promo_edit_select_field": "Select a field to edit:", + "admin_promo_prompt_bonus_days": "Enter the new number of bonus days:", + "admin_promo_prompt_max_activations": "Enter the new maximum number of activations:", + "admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):", + "admin_promo_edit_success": "Promo code updated successfully.", + "admin_promo_invalid_input": "Invalid input, please try again.", + "admin_promo_edit_bonus_days": "🎁 Bonus Days", + "admin_promo_edit_max_activations": "🔢 Max Activations", + "admin_promo_edit_validity": "⏰ Validity", "admin_ban_user_prompt": "Enter user ID or @username to ban:", "admin_user_not_found_in_bot_db": "User {user_id} not found in bot database.", diff --git a/locales/ru.json b/locales/ru.json index 9f50ed9..9c75ebc 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -161,9 +161,20 @@ "admin_promo_edit_button": "✏️ Изменить", "admin_promo_delete_button": "🗑 Удалить", "admin_promo_edit_prompt": "Отправьте новые данные для {code} в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]", - "admin_promo_updated_success": "Промокод {code} обновлен.", - "admin_promo_deleted_success": "Промокод {code} удален.", + "admin_promo_updated_success": "Промокод {code} обновлен.", + "admin_promo_deleted_success": "Промокод {code} удален.", "admin_promo_not_found": "Промокод не найден.", + "admin_promo_export_csv_button": "📄 Экспорт в CSV", + "admin_promo_export_caption": "📄 Активации промокода {code}", + "admin_promo_edit_select_field": "Выберите поле для редактирования:", + "admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:", + "admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:", + "admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):", + "admin_promo_edit_success": "Промокод успешно обновлен.", + "admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.", + "admin_promo_edit_bonus_days": "🎁 Бонусные дни", + "admin_promo_edit_max_activations": "🔢 Макс. активации", + "admin_promo_edit_validity": "⏰ Срок действия", "admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:", "admin_user_not_found_in_bot_db": "Пользователь {user_id} не найден в базе данных бота.",