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).
* 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.
<details>
<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
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}/<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`.
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:<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
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`
+250 -337
View File
@@ -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,57 +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 = _(
"admin_no_promo_codes",
default="📋 <b>Активные промокоды</b>\n\n❌ Нет активных промокодов"
)
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)
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"🎟 <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
]
)
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:
@@ -86,337 +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_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"
)
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", default="❌ Промокод не найден"), 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="Без ограничений")
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)
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", default="❌ Промокод не найден"), 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_activated", default="активирован") if new_status else _("admin_promo_deactivated", default="деактивирован")
await callback.answer(
_("admin_promo_status_changed", default="✅ Промокод {status}", status=status_text)
)
# Refresh the detail view
callback.data = f"promo_detail:{promo_id}"
await promo_detail_handler(callback, i18n_data, settings, session)
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))
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:
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)
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), 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", default="❌ Промокод не найден"), 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)
text_lines = [
_("admin_promo_activations_title",
default="📋 <b>Активации промокода {code}</b>\n",
code=promo.code)
]
if not activations:
text_lines.append(_("admin_no_activations", default="❌ Нет активаций"))
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.append(
f"👤 <code>{activation.user_id}</code> | 🕐 {activated_at}"
)
if len(activations) > 10:
text_lines.append(f"\n... и еще {len(activations) - 10}")
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")])
text = "\n".join(text_lines)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_promo", default="🔙 К промокоду"),
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:
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)
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)
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", default="✅ Промокод удален", 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", 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:
await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True)
except Exception as e:
logging.error(f"Error deleting promo: {e}")
await callback.answer(_("error_occurred_try_again"), show_alert=True)
# 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
# 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"""
await promo_management_handler(callback, i18n_data, settings, session)
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)
+17 -15
View File
@@ -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 = {
+13 -2
View File
@@ -161,9 +161,20 @@
"admin_promo_edit_button": "✏️ Edit",
"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_updated_success": "Promo <code>{code}</code> updated.",
"admin_promo_deleted_success": "Promo <code>{code}</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 <code>{user_id}</code> not found in bot database.",
+13 -2
View File
@@ -161,9 +161,20 @@
"admin_promo_edit_button": "✏️ Изменить",
"admin_promo_delete_button": "🗑 Удалить",
"admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]",
"admin_promo_updated_success": "Промокод <code>{code}</code> обновлен.",
"admin_promo_deleted_success": "Промокод <code>{code}</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": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",