Compare commits

..
5 Commits
Author SHA1 Message Date
Machka PaslaandGitHub 5c8099168d Merge pull request #63 from machka-pasla/dev
Dev
2025-08-06 01:24:06 +03:00
machka-pasla 4675141974 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.
2025-08-06 01:21:47 +03:00
machka-pasla 67f31d62e4 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.
2025-08-06 01:14:26 +03:00
Machka PaslaandGitHub c969d52ce1 Update README.md 2025-08-05 23:50:11 +03:00
machka-pasla 3bd29511e1 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.
2025-08-05 23:49:08 +03:00
5 changed files with 432 additions and 557 deletions
+139 -201
View File
@@ -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). - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
* 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. - **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
* **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.
## 🚀 Technologies Used ## 🚀 Технологии
* **Python 3.11** - **Python 3.11**
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
* **aiohttp:** For running the webhook server - **aiohttp:** Для запуска веб-сервера (вебхуки).
* **sqlalchemy:** Asynchronous PostgreSQL database interaction - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
* **YooKassa SDK:** For payment processing - **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
* **APScheduler:** For scheduled tasks (e.g., notifications) - **APScheduler:** Для выполнения отложенных задач (например, уведомления об окончании подписки).
* **Pydantic:** For settings management (loading from `.env`) - **Pydantic:** Для управления настройками из `.env` файла.
* **Docker & Docker Compose:** For containerization and deployment - **Docker & Docker Compose:** Для контейнеризации и развертывания.
## ⚙️ Setup and Configuration ## ⚙️ Установка и запуск
### Prerequisites ### Предварительные требования
* Docker and Docker Compose installed. - Установленные Docker и Docker Compose.
* A running instance of a Remnawave panel. - Рабочая панель Remnawave.
* A Telegram Bot Token. - Токен Telegram-бота.
* A YooKassa Shop ID and Secret Key. - Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
### Configuration Steps ### Шаги установки
1. **Clone the Repository:** 1. **Клонируйте репозиторий:**
```bash ```bash
git clone https://github.com/machka-pasla/remnawave-tg-shop git clone https://github.com/machka-pasla/remnawave-tg-shop
cd remnawave-tg-shop cd remnawave-tg-shop
``` ```
2. **Create an `.env` File:** 2. **Создайте и настройте файл `.env`:**
Copy the `env.example` file to `.env` and fill in your specific values: Скопируйте `env.example` в `.env` и заполните своими данными.
```bash ```bash
cp .env.example .env cp .env.example .env
nano .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:** <details>
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional. <summary><b>Основные настройки</b></summary>
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` |
</details>
<details>
<summary><b>Настройки платежей и вебхуков</b></summary>
| Переменная | Описание |
| --- | --- |
| `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`). |
</details>
<details>
<summary><b>Настройки подписок</b></summary>
Для каждого периода (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`.
</details>
<details>
<summary><b>Настройки панели Remnawave</b></summary>
| Переменная | Описание |
| --- | --- |
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
| `PANEL_API_KEY` | API ключ для доступа к панели. |
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
</gidetails>
<details>
<summary><b>Настройки пробного периода</b></summary>
| Переменная | Описание |
| --- | --- |
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details>
3. **Запустите контейнеры:**
```bash ```bash
docker compose up -d 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):** 4. **Настройка вебхуков (Обязательно):**
* **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. Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
* 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}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
* **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`.
6. **Database:** Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
* 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.
7. **Viewing Logs:** **Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
5. **Просмотр логов:**
```bash ```bash
docker compose logs -f remnawave-tg-shop 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/ ├── bot/
│ ├── filters/ # Custom Aiogram filters (e.g., AdminFilter) │ ├── filters/ # Пользовательские фильтры Aiogram
│ ├── handlers/ # Message and callback query handlers (admin and user) │ ├── handlers/ # Обработчики сообщений и колбэков
│ ├── keyboards/ # Inline and reply keyboard generators │ ├── keyboards/ # Клавиатуры
│ ├── middlewares/ # Custom Aiogram middlewares (i18n, ban check, logger) │ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
│ ├── services/ # Business logic (payments, subscriptions, panel API interaction) │ ├── services/ # Бизнес-логика (платежи, API панели)
│ ├── states/ # FSM states │ ├── states/ # Состояния FSM
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown │ └── main_bot.py # Основная логика бота
├── config/ ├── config/
│ └── settings.py # Pydantic settings and config parser │ └── settings.py # Настройки Pydantic
├── db/ ├── db/
│ ├── dal/ # Data Access Layer (queries, transactions) │ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # DB connection/init setup │ ├── database_setup.py # Настройка БД
│ └── models.py # ORM models (e.g., SQLAlchemy) │ └── models.py # Модели SQLAlchemy
├── locales/ # Localization files ├── locales/ # Файлы локализации (ru, en)
├── en.json # English locale ├── .env.example # Пример файла с переменными окружения
│ └── ru.json # Russian locale ├── Dockerfile # Инструкции для сборки Docker-образа
├── .env.example # Example environment variables for local setup ├── docker-compose.yml # Файл для оркестрации контейнеров
├── .env # Actual environment variables (ignored by Git) ├── requirements.txt # Зависимости Python
── Dockerfile # Docker image build instructions ── main.py # Точка входа в приложение
├── 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
``` ```
## 🤝 Contributing ## 🔮 Планы на будущее
Contributions are welcome! - Расширенные типы промокодов (например, скидки в процентах).
## 🔮 Future Enhancements ## ❤️ Поддержка
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
- More detailed analytics for admin. - Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
- 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)
+250 -337
View File
@@ -1,4 +1,6 @@
import logging import logging
import csv
import io
from aiogram import Router, F, types from aiogram import Router, F, types
from aiogram.filters import StateFilter from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
@@ -17,10 +19,45 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="promo_manage_router") router = Router(name="promo_manage_router")
async def view_promo_codes_handler(callback: types.CallbackQuery, async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str):
i18n_data: dict, settings: Settings, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
session: AsyncSession): promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
"""View all active promo codes""" 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message: if not i18n or not callback.message:
@@ -28,57 +65,19 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = 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)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
limit=20, [_("admin_active_promos_list_header"), ""] + [
offset=0) f"🎟 <code>{p.code}</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
if not promo_models: ]
text = _( )
"admin_no_promo_codes",
default="📋 <b>Активные промокоды</b>\n\n❌ Нет активных промокодов" await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
) await callback.answer()
else:
lines = [_("admin_active_promo_codes_title",
default="📋 <b>Активные промокоды</b>\n")]
for promo in promo_models:
validity_str = _("admin_promo_unlimited", default="Без ограничений")
if promo.valid_until:
validity_str = promo.valid_until.strftime("%d.%m.%Y")
lines.append(
f"🎟 <code>{promo.code}</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)
async def promo_management_handler(callback: types.CallbackQuery, async def promo_management_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
i18n_data: dict, settings: Settings,
session: AsyncSession):
"""Main promo management interface"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message: if not i18n or not callback.message:
@@ -86,337 +85,251 @@ async def promo_management_handler(callback: types.CallbackQuery,
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = 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)
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")
if not promo_models:
text = _(
"admin_no_promo_codes_to_manage",
default="🛠 <b>Управление промокодами</b>\n\n❌ Нет промокодов для управления"
)
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_lines = [_("admin_promo_management_title", default="🛠 <b>Управление промокодами</b>\n")]
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} <code>{promo.code}</code> | "
f"🎁{promo.bonus_days}д | "
f"📊{promo.current_activations}/{promo.max_activations} | "
f"{validity_str}"
)
builder.row(
InlineKeyboardButton(
text=f"📝 {promo.code}",
callback_data=f"promo_detail:{promo.promo_code_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel", default="🔙 В админ панель"),
callback_data="admin_action:main"
)
)
text = "\n".join(text_lines)
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() await callback.answer()
return
except Exception as e:
logging.error(f"Error in promo management: {e}") builder = InlineKeyboardBuilder()
await callback.answer(_("error_occurred_try_again"), show_alert=True) 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:")) @router.callback_query(F.data.startswith("promo_detail:"))
async def promo_detail_handler(callback: types.CallbackQuery, async def promo_detail_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") 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) await callback.answer("Error processing request.", show_alert=True)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try: try:
promo_id = int(callback.data.split(":")[1]) promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
if text:
if not promo: await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) else:
return await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
except (ValueError, IndexError):
# Format promo details await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
status_text = _("admin_promo_active", default="✅ Активен") if promo.is_active else _("admin_promo_inactive", default="❌ Неактивен") await callback.answer()
validity_text = _("admin_promo_unlimited", default="Без ограничений")
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')} (истек)"
created_at = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
text = _(
"admin_promo_detail_info",
default="🎟 <b>Детали промокода</b>\n\n"
"📝 Код: <code>{code}</code>\n"
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
"📊 Использований: <b>{current}/{max}</b>\n"
"📋 Статус: {status}\n"
"⏰ Действует до: <b>{validity}</b>\n"
"👤 Создал: <code>{admin_id}</code>\n"
"🕐 Создан: <b>{created}</b>",
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
)
# Create management buttons
builder = InlineKeyboardBuilder()
# Edit button
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit", default="✏️ Редактировать"),
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,
callback_data=f"promo_toggle:{promo_id}"
)
)
# View activations
builder.row(
InlineKeyboardButton(
text=_("admin_promo_view_activations", default="📋 Активации"),
callback_data=f"promo_activations:{promo_id}"
)
)
# Delete button
builder.row(
InlineKeyboardButton(
text=_("admin_promo_delete", default="🗑 Удалить"),
callback_data=f"promo_delete:{promo_id}"
)
)
# Back button
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_management", default="🔙 К управлению"),
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_invalid_promo_id", default="❌ Неверный ID промокода"), 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)
@router.callback_query(F.data.startswith("promo_toggle:")) @router.callback_query(F.data.startswith("promo_toggle:"))
async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
settings: Settings, session: AsyncSession):
"""Toggle promo code active status"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: current_lang = i18n_data.get("current_language")
await callback.answer("Language service error.", show_alert=True) if not i18n or not callback.message or not current_lang:
return return await callback.answer("Language service error.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try: try:
promo_id = int(callback.data.split(":")[1]) promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo: if not promo:
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) return await callback.answer(_("admin_promo_not_found"), show_alert=True)
return
new_status = not promo.is_active new_status = not promo.is_active
update_data = {"is_active": new_status} if await promo_code_dal.update_promo_code(session, promo_id, {"is_active": new_status}):
updated = await promo_code_dal.update_promo_code(session, promo_id, update_data)
if updated:
await session.commit() 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( await callback.answer(_("admin_promo_toggle_success", code=promo.code, status=status_text))
_("admin_promo_status_changed", default="✅ Промокод {status}", status=status_text)
) text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
# Refresh the detail view if text:
callback.data = f"promo_detail:{promo_id}" await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
await promo_detail_handler(callback, i18n_data, settings, session)
else: else:
await callback.answer(_("error_occurred_try_again"), show_alert=True) await callback.answer(_("error_occurred_try_again"), show_alert=True)
except (ValueError, IndexError):
except ValueError: await callback.answer(_("admin_promo_not_found"), show_alert=True)
await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), 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:")) @router.callback_query(F.data.startswith("promo_activations:"))
async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
settings: Settings, session: AsyncSession):
"""Show promo code activations"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message: current_lang = i18n_data.get("current_language")
await callback.answer("Error processing request.", show_alert=True) if not i18n or not callback.message or not current_lang:
return 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) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try: try:
promo_id = int(callback.data.split(":")[1]) promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo: if not promo:
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) return 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) activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id)
text_lines = [
_("admin_promo_activations_title",
default="📋 <b>Активации промокода {code}</b>\n",
code=promo.code)
]
if not activations: if not activations:
text_lines.append(_("admin_no_activations", default="❌ Нет активаций")) return await callback.answer(_("admin_promo_no_activations", code=promo.code), show_alert=True)
else:
for activation in activations[:10]: # Show last 10 output = io.StringIO()
activated_at = activation.activated_at.strftime("%d.%m.%Y %H:%M") if activation.activated_at else "N/A" writer = csv.writer(output)
text_lines.append( writer.writerow(["User ID", "Activation Date"])
f"👤 <code>{activation.user_id}</code> | 🕐 {activated_at}" for act in activations:
) writer.writerow([act.user_id, act.activated_at.strftime("%Y-%m-%d %H:%M:%S")])
if len(activations) > 10:
text_lines.append(f"\n... и еще {len(activations) - 10}")
text = "\n".join(text_lines) output.seek(0)
file = types.BufferedInputFile(output.getvalue().encode('utf-8'), filename=f"promo_{promo.code}_activations.csv")
builder = InlineKeyboardBuilder() await callback.message.answer_document(file, caption=_("admin_promo_export_caption", code=promo.code))
builder.row(
InlineKeyboardButton( except (ValueError, IndexError):
text=_("admin_back_to_promo", default="🔙 К промокоду"), await callback.answer(_("admin_promo_not_found"), show_alert=True)
callback_data=f"promo_detail:{promo_id}" await callback.answer()
)
)
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_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True)
except Exception as e:
logging.error(f"Error viewing activations: {e}")
await callback.answer(_("error_occurred_try_again"), show_alert=True)
@router.callback_query(F.data.startswith("promo_delete:")) @router.callback_query(F.data.startswith("promo_delete:"))
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
settings: Settings, session: AsyncSession):
"""Delete promo code"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: current_lang = i18n_data.get("current_language")
await callback.answer("Language service error.", show_alert=True) if not i18n or not callback.message or not current_lang:
return return await callback.answer("Language service error.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try: try:
promo_id = int(callback.data.split(":")[1]) promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.delete_promo_code(session, promo_id) promo = await promo_code_dal.delete_promo_code(session, promo_id)
if promo: if promo:
await session.commit() await session.commit()
await callback.answer( await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True)
_("admin_promo_deleted", default="✅ Промокод удален", code=promo.code) await promo_management_handler(callback, i18n_data, {}, session) # Settings not needed here
)
# Go back to management
callback.data = "admin_action:promo_management"
await promo_management_handler(callback, i18n_data, settings, session)
else: else:
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) await callback.answer(_("admin_promo_not_found"), show_alert=True)
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
# --- 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"))
except ValueError: # Reset state and show updated details
await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) await state.clear()
except Exception as e: text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
logging.error(f"Error deleting promo: {e}") if text:
await callback.answer(_("error_occurred_try_again"), show_alert=True) 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
# Legacy handlers that redirect to new system async def manage_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def manage_promo_codes_handler(callback: types.CallbackQuery, await promo_management_handler(callback, i18n_data, settings, session)
i18n_data: dict, settings: Settings,
session: AsyncSession):
"""Redirect to new unified handler"""
await promo_management_handler(callback, i18n_data, settings, session)
+17 -15
View File
@@ -10,7 +10,6 @@ from db.models import PromoCode, PromoCodeActivation, User, Payment
async def create_promo_code(session: AsyncSession, async def create_promo_code(session: AsyncSession,
promo_data: Dict[str, Any]) -> PromoCode: promo_data: Dict[str, Any]) -> PromoCode:
new_promo = PromoCode(**promo_data) new_promo = PromoCode(**promo_data)
session.add(new_promo) session.add(new_promo)
await session.flush() await session.flush()
@@ -65,24 +64,25 @@ async def get_all_promo_codes_with_details(session: AsyncSession, limit: int = 5
return result.scalars().all() return result.scalars().all()
async def get_promo_code_by_id(session: AsyncSession, promo_id: int) -> Optional[PromoCode]: async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int, limit: Optional[int] = None, offset: int = 0) -> List[PromoCodeActivation]:
"""Get promo code by ID""" """Get activation history for a specific promo code with optional pagination."""
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
stmt = (select(PromoCodeActivation) stmt = (select(PromoCodeActivation)
.where(PromoCodeActivation.promo_code_id == promo_code_id) .where(PromoCodeActivation.promo_code_id == promo_code_id)
.order_by(PromoCodeActivation.activated_at.desc()) .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) result = await session.execute(stmt)
return result.scalars().all() 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, async def update_promo_code(session: AsyncSession, promo_id: int,
update_data: Dict[str, Any]) -> Optional[PromoCode]: update_data: Dict[str, Any]) -> Optional[PromoCode]:
promo = await get_promo_code_by_id(session, promo_id) 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) promo = await get_promo_code_by_id(session, promo_id)
if not promo: if not promo:
return None 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.delete(promo)
await session.flush() await session.flush()
return promo return promo
@@ -124,7 +129,6 @@ async def increment_promo_code_usage(
async def get_user_activation_for_promo( async def get_user_activation_for_promo(
session: AsyncSession, promo_code_id: int, session: AsyncSession, promo_code_id: int,
user_id: int) -> Optional[PromoCodeActivation]: user_id: int) -> Optional[PromoCodeActivation]:
stmt = select(PromoCodeActivation).where( stmt = select(PromoCodeActivation).where(
PromoCodeActivation.promo_code_id == promo_code_id, PromoCodeActivation.promo_code_id == promo_code_id,
PromoCodeActivation.user_id == user_id).limit(1) PromoCodeActivation.user_id == user_id).limit(1)
@@ -137,7 +141,6 @@ async def record_promo_activation(
promo_code_id: int, promo_code_id: int,
user_id: int, user_id: int,
payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]: payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]:
existing_activation = await get_user_activation_for_promo( existing_activation = await get_user_activation_for_promo(
session, promo_code_id, user_id) session, promo_code_id, user_id)
if existing_activation: if existing_activation:
@@ -162,7 +165,6 @@ async def record_promo_activation(
logging.error( logging.error(
f"Cannot record promo activation: Payment {payment_id} not found." f"Cannot record promo activation: Payment {payment_id} not found."
) )
return None return None
activation_data = { activation_data = {
+13 -2
View File
@@ -161,9 +161,20 @@
"admin_promo_edit_button": "✏️ Edit", "admin_promo_edit_button": "✏️ Edit",
"admin_promo_delete_button": "🗑 Delete", "admin_promo_delete_button": "🗑 Delete",
"admin_promo_edit_prompt": "Send new details for <code>{code}</code> in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]", "admin_promo_edit_prompt": "Send new details for <code>{code}</code> in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
"admin_promo_updated_success": "Promo <code>{code}</code> updated.", "admin_promo_updated_success": "Promo {code} updated.",
"admin_promo_deleted_success": "Promo <code>{code}</code> deleted.", "admin_promo_deleted_success": "Promo {code} deleted.",
"admin_promo_not_found": "Promo not found.", "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_ban_user_prompt": "Enter user ID or @username to ban:",
"admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.", "admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.",
+13 -2
View File
@@ -161,9 +161,20 @@
"admin_promo_edit_button": "✏️ Изменить", "admin_promo_edit_button": "✏️ Изменить",
"admin_promo_delete_button": "🗑 Удалить", "admin_promo_delete_button": "🗑 Удалить",
"admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]", "admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]",
"admin_promo_updated_success": "Промокод <code>{code}</code> обновлен.", "admin_promo_updated_success": "Промокод {code} обновлен.",
"admin_promo_deleted_success": "Промокод <code>{code}</code> удален.", "admin_promo_deleted_success": "Промокод {code} удален.",
"admin_promo_not_found": "Промокод не найден.", "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_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.", "admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",