diff --git a/DEPLOYMENT_CODEX_RU.md b/DEPLOYMENT_CODEX_RU.md new file mode 100644 index 00000000..835ab08c --- /dev/null +++ b/DEPLOYMENT_CODEX_RU.md @@ -0,0 +1,455 @@ +# Передача проекта Codex для развёртывания на новом сервере + +Этот документ предназначен для пользователя и для нового агента Codex. Он описывает +развёртывание именно изменённой версии Remnashop с розыгрышами, расширенными промокодами +и управлением пользователями. + +## 1. Что разворачивать + +- Репозиторий пользователя: `https://github.com/Mandalorec95/remnashop.git` +- Рабочая ветка: `codex/giveaways-user-management` +- Последний основной коммит при создании документа: `6ba19b9` +- Upstream PR для истории: `https://github.com/snoups/remnashop/pull/123` +- Python: 3.12 внутри Docker +- PostgreSQL: 17 +- Redis-совместимое хранилище: Valkey 9 +- Remnawave: версия от `2.7.0` включительно и ниже `2.8.0` + +Перед развёртыванием Codex обязан проверить фактический commit: + +```bash +git branch --show-current +git log -1 --oneline +git status --short +``` + +Ожидается ветка `codex/giveaways-user-management`. Рабочее дерево перед запуском должно +быть чистым. + +## 2. Важное отличие от стандартного Remnashop + +Файлы `docker-compose.prod.*.yml` по умолчанию используют готовый образ: + +```text +ghcr.io/snoups/remnashop:latest +``` + +В этом образе может не быть изменений из пользовательской ветки. Поэтому при запуске +обязательно добавлять `docker-compose.custom.yml`. Он собирает образ из текущего checkout: + +```bash +docker compose \ + -f docker-compose.prod.internal.yml \ + -f docker-compose.custom.yml \ + up -d --build +``` + +Без `docker-compose.custom.yml` развёртывание пользовательской версии считать неверным. + +## 3. Что Codex должен уточнить у пользователя + +До изменения сервера нужно получить ответы: + +1. Это чистая установка или перенос действующего бота? +2. Remnawave уже запущен на этом же сервере? +3. Какой домен будет использовать бот? +4. Где настроен HTTPS/reverse proxy: Caddy, Nginx, Traefik или панель Remnawave? +5. Нужно ли переносить текущую PostgreSQL-базу и каталог `assets`? +6. Где лежит старый `.env`, и доступен ли старый `APP_CRYPT_KEY`? + +Не просить пользователя отправлять токены и пароли в чат. Секреты вводятся непосредственно +на сервере в `.env`. + +## 4. Требования к серверу + +Рекомендуется Ubuntu 22.04/24.04 или другой современный Linux: + +- Docker Engine; +- Docker Compose v2 (`docker compose`); +- Git; +- минимум 2 ГБ RAM, предпочтительно 4 ГБ; +- свободные порты 80/443 для reverse proxy; +- DNS-запись домена, указывающая на сервер; +- исходящие соединения к Telegram и Remnawave; +- корректное системное время и NTP. + +Быстрая проверка: + +```bash +docker --version +docker compose version +git --version +timedatectl status +df -h +free -h +``` + +Codex не должен отключать firewall или публиковать PostgreSQL/Valkey наружу. В compose +PostgreSQL привязан к `127.0.0.1`, а Valkey не публикует порт на хост. + +## 5. Клонирование + +```bash +sudo mkdir -p /opt/remnashop +sudo chown "$USER":"$USER" /opt/remnashop +git clone --branch codex/giveaways-user-management \ + https://github.com/Mandalorec95/remnashop.git /opt/remnashop +cd /opt/remnashop +git status -sb +git log -1 --oneline +``` + +Если репозиторий уже клонирован: + +```bash +cd /opt/remnashop +git fetch origin +git switch codex/giveaways-user-management +git pull --ff-only +``` + +Не выполнять `git reset --hard`, если на сервере есть непроверенные локальные изменения. + +## 6. Выбор production-compose + +### Remnawave находится на том же сервере и уже использует сеть `remnawave-network` + +Использовать: + +```text +docker-compose.prod.internal.yml +``` + +В нём сеть объявлена как `external: true`. До запуска проверить: + +```bash +docker network inspect remnawave-network +``` + +Если Remnawave работает, но сеть называется иначе, сначала изучить его compose. Не создавать +параллельную сеть наугад: контейнер бота должен видеть API Remnawave по имени из +`REMNAWAVE_HOST`. + +### Remnawave удалённый или сети `remnawave-network` ещё нет + +Использовать: + +```text +docker-compose.prod.external.yml +``` + +Несмотря на имя файла, он сам создаёт локальную Docker-сеть (`external: false`). +`REMNAWAVE_HOST` в этом случае должен быть доступным URL/hostname API Remnawave. + +## 7. Настройка `.env` + +Создать файл с закрытыми правами: + +```bash +cd /opt/remnashop +cp .env.example .env +chmod 600 .env +nano .env +``` + +Обязательные значения: + +```dotenv +APP_DOMAIN=bot.example.com +APP_CRYPT_KEY= + +BOT_TOKEN=<токен BotFather> +BOT_SECRET_TOKEN=<случайный секрет> +BOT_OWNER_ID=<числовой Telegram ID владельца> +BOT_SUPPORT_USERNAME= +BOT_MINI_APP=false + +REMNAWAVE_HOST=remnawave +REMNAWAVE_TOKEN= +REMNAWAVE_WEBHOOK_SECRET=<секрет webhook Remnawave> + +DATABASE_PASSWORD=<случайный пароль> +REDIS_PASSWORD=<случайный пароль> +``` + +Полезные команды генерации для новой установки: + +```bash +openssl rand -base64 32 +openssl rand -hex 64 +openssl rand -hex 24 +``` + +Правила: + +- `APP_DOMAIN` указывается без `https://` и без завершающего `/`; +- `BOT_SUPPORT_USERNAME` указывается без `@`; +- `APP_CRYPT_KEY` должен быть Base64-строкой длиной 44 символа; +- `BOT_OWNER_ID` — число, не username; +- не оставлять ни одного обязательного значения `change_me`; +- `.env` нельзя добавлять в Git. + +### Критично при переносе существующей базы + +Нужно сохранить прежний `APP_CRYPT_KEY`. Новый ключ сделает ранее зашифрованные платёжные +настройки и другие секреты в базе нечитаемыми. + +Также желательно перенести прежние: + +- `BOT_TOKEN`; +- `BOT_SECRET_TOKEN`; +- `DATABASE_PASSWORD`, если переносится Docker volume целиком; +- `REMNAWAVE_TOKEN`; +- секреты платёжных шлюзов; +- `REMNAWAVE_WEBHOOK_SECRET`. + +## 8. HTTPS и reverse proxy + +Приложение слушает только: + +```text +127.0.0.1:5000 +``` + +Перед ним нужен HTTPS reverse proxy для `APP_DOMAIN`, направляющий весь трафик на +`http://127.0.0.1:5000`. + +Основные webhook URL: + +- Telegram: `https://APP_DOMAIN/api/v1/telegram` +- Remnawave: `https://APP_DOMAIN/api/v1/remnawave` +- платежи: `https://APP_DOMAIN/api/v1/payments/` + +Не открывать порт 5000 всему интернету, если используется локальный reverse proxy. +После настройки проверить: + +```bash +curl -I https://bot.example.com/ +``` + +Ответ может быть не `200` для корневого URL, но TLS, DNS и соединение должны работать. + +## 9. Чистая установка + +Проверить итоговую конфигурацию без вывода секретов: + +```bash +cd /opt/remnashop +docker compose \ + -f docker-compose.prod.internal.yml \ + -f docker-compose.custom.yml \ + config --services +``` + +Для варианта с удалённым Remnawave заменить `internal` на `external`. + +Собрать и запустить: + +```bash +docker compose \ + -f docker-compose.prod.internal.yml \ + -f docker-compose.custom.yml \ + up -d --build +``` + +Миграции Alembic запускаются автоматически в `docker-entrypoint.sh` контейнера +`remnashop`. Если миграция не прошла, основной контейнер завершится с ошибкой. + +## 10. Перенос существующей установки + +Перед любыми действиями на старом сервере сделать резервные копии. + +### 10.1 Дамп PostgreSQL на старом сервере + +```bash +mkdir -p /opt/backups/remnashop-migration +docker exec remnashop-db sh -lc \ + 'pg_dump -U "${POSTGRES_USER:-remnashop}" \ + -d "${POSTGRES_DB:-remnashop}" \ + --format=custom --no-owner --no-privileges' \ + > /opt/backups/remnashop-migration/remnashop.dump +``` + +Проверить, что файл не пустой: + +```bash +ls -lh /opt/backups/remnashop-migration/remnashop.dump +pg_restore --list /opt/backups/remnashop-migration/remnashop.dump | tail +``` + +Если `pg_restore` отсутствует на хосте, список можно проверить контейнером PostgreSQL. + +### 10.2 Сохранить конфигурацию и assets + +```bash +cp /opt/remnashop/.env /opt/backups/remnashop-migration/env.backup +tar -C /opt/remnashop -czf \ + /opt/backups/remnashop-migration/assets.tar.gz assets +``` + +Файлы резервной копии содержат секреты. Передавать их только защищённым способом, +установить права `600`, после миграции удалить лишние копии. + +### 10.3 Восстановление на новом сервере + +Сначала запустить только PostgreSQL: + +```bash +cd /opt/remnashop +docker compose \ + -f docker-compose.prod.internal.yml \ + -f docker-compose.custom.yml \ + up -d remnashop-db +``` + +Дождаться состояния healthy: + +```bash +docker inspect --format '{{.State.Health.Status}}' remnashop-db +``` + +Восстановление рекомендуется выполнять в пустую базу до первого полного запуска: + +```bash +docker exec -i remnashop-db sh -lc \ + 'pg_restore -U "${POSTGRES_USER:-remnashop}" \ + -d "${POSTGRES_DB:-remnashop}" \ + --no-owner --no-privileges --clean --if-exists' \ + < /opt/backups/remnashop-migration/remnashop.dump +``` + +Для новой пустой базы предупреждения `--clean` об отсутствующих объектах могут быть +нормальными. Любые другие ошибки нужно изучить до запуска приложения. + +Восстановить assets: + +```bash +cd /opt/remnashop +tar -xzf /opt/backups/remnashop-migration/assets.tar.gz +``` + +Затем запустить весь стек. При старте Alembic обновит перенесённую базу до новой схемы. + +## 11. Проверка после запуска + +```bash +cd /opt/remnashop +docker compose \ + -f docker-compose.prod.internal.yml \ + -f docker-compose.custom.yml \ + ps + +docker inspect --format \ + '{{.Name}} status={{.State.Status}} restarts={{.RestartCount}}' \ + remnashop remnashop-taskiq-worker remnashop-taskiq-scheduler + +docker logs --since 10m remnashop +docker logs --since 10m remnashop-taskiq-worker +docker logs --since 10m remnashop-taskiq-scheduler +``` + +Искать: + +```bash +docker logs --since 10m remnashop 2>&1 | \ + grep -E 'ERROR|CRITICAL|Traceback|migration failed' +``` + +Успешное состояние: + +- PostgreSQL и Valkey healthy; +- три контейнера приложения имеют статус running; +- restart count не растёт; +- в логе есть успешное применение миграций; +- Telegram-бот отвечает владельцу; +- открывается админ-панель; +- виден раздел розыгрышей; +- Remnawave API доступен; +- Telegram webhook указывает на новый домен; +- тестовая операция не создаёт ошибок в worker. + +Проверка текущего Telegram webhook без публикации токена в истории shell: + +```bash +set -a +. ./.env +set +a +curl -s "https://api.telegram.org/bot${BOT_TOKEN}/getWebhookInfo" +unset BOT_TOKEN +``` + +Не вставлять вывод с токеном или секретами в публичные логи и чаты. + +## 12. Обновление в будущем + +Перед обновлением: + +1. сделать дамп PostgreSQL; +2. сохранить `.env` и `assets`; +3. проверить `git status`; +4. получить изменения только fast-forward; +5. пересобрать образ. + +```bash +cd /opt/remnashop +git fetch origin +git switch codex/giveaways-user-management +git pull --ff-only + +docker compose \ + -f docker-compose.prod.internal.yml \ + -f docker-compose.custom.yml \ + up -d --build +``` + +После обновления повторить проверки контейнеров и логов. + +## 13. Откат + +Откат кода без отката базы может быть несовместим с уже применёнными миграциями. +Самый безопасный откат: + +1. остановить приложение; +2. восстановить дамп базы, сделанный до обновления; +3. checkout проверенного старого commit; +4. пересобрать и запустить контейнеры; +5. проверить логи и Telegram webhook. + +Команда остановки без удаления данных: + +```bash +docker compose \ + -f docker-compose.prod.internal.yml \ + -f docker-compose.custom.yml \ + down +``` + +Никогда не добавлять `-v` при обычной остановке или откате: этот флаг удалит Docker volumes +PostgreSQL и Valkey. + +## 14. Ограничения и известные замечания ветки + +- Новые файлы розыгрышей, удаления пользователей и миграций прошли Ruff. +- В полном дереве ветки на момент публикации оставались style-замечания Ruff. +- Python compile check для `src` проходил. +- В ветке есть миграции `0022`, `0023`, `0024`. +- При первом старте после переноса нужно особенно внимательно проверить миграционные логи. +- `assets` смонтирован с хоста. Entrypoint не перезаписывает непустой каталог, если + `RESET_ASSETS` не равен `true`. +- Не устанавливать `RESET_ASSETS=true` на перенесённой установке без резервной копии: + существующие assets будут архивированы и заменены defaults. + +## 15. Готовый запрос новому Codex + +Можно передать агенту следующий текст: + +> Разверни Remnashop по инструкции `DEPLOYMENT_CODEX_RU.md`. Работай в +> `/opt/remnashop`, используй репозиторий `Mandalorec95/remnashop` и ветку +> `codex/giveaways-user-management`. Сначала проведи read-only аудит сервера, Docker, +> DNS, reverse proxy, сети Remnawave, текущих контейнеров и резервных копий. Не печатай +> секреты из `.env`. Перед миграциями обязательно создай и проверь дамп PostgreSQL. +> Используй production-compose вместе с `docker-compose.custom.yml`, иначе запустится +> upstream-образ без пользовательских изменений. Не удаляй volumes и не применяй +> разрушительные команды без моего явного подтверждения. После запуска проверь health, +> restart count, миграции, ошибки в логах, Telegram webhook, Remnawave и раздел +> розыгрышей. Продолжай до рабочего результата или чётко названного внешнего блокера. diff --git a/assets/banners/default.jpg b/assets/banners/default.jpg index 5b174072..4059f10a 100644 Binary files a/assets/banners/default.jpg and b/assets/banners/default.jpg differ diff --git a/assets/banners/default22.jpg b/assets/banners/default22.jpg new file mode 100644 index 00000000..64326d21 Binary files /dev/null and b/assets/banners/default22.jpg differ diff --git a/assets/translations/ru/buttons.ftl b/assets/translations/ru/buttons.ftl index cd4a5edd..4230872e 100644 --- a/assets/translations/ru/buttons.ftl +++ b/assets/translations/ru/buttons.ftl @@ -44,6 +44,7 @@ btn-menu = .connect = 🚀 Подключиться .devices = 📱 Устройства .subscription = 💳 Подписка + .giveaways = 🎁 Акции .invite = 👥 Пригласить .support = 🆘 Поддержка .dashboard = 🛠 Панель управления @@ -62,11 +63,18 @@ btn-invite = .qr = 🧾 QR-код .withdraw-points = 💎 Обменять баллы +btn-client-giveaway = + .buy = 💳 Купить подписку для участия + .conditions = 📄 Условия акции + .phone = 📱 Оставить / изменить номер телефона + .results = 🏆 Итоги акции + btn-dashboard = .statistics = 📊 Статистика .users = 👥 Пользователи .broadcast = 📢 Рассылка .promocodes = 🎟 Промокоды + .giveaways = 🎁 Акции .access = 🔓 Режим доступа .remnawave = 🌊 RemnaWave .remnashop = 🛍 RemnaShop @@ -478,8 +486,51 @@ btn-promocode = .lifetime = ⌛ Время жизни .allowed = 👥 Разрешенные пользователи .confirm = ✅ Подтвердить - - .active = { $is_active -> + .deactivate = 🔴 Деактивировать + + .plan-choice = 📦 { $name } + + .audience-choice = { $audience -> + [ALL] 👥 Для всех + [WITH_ACTIVE_SUBSCRIPTION] 🔒 Только с активной подпиской + [WITHOUT_ACTIVE_SUBSCRIPTION] 🔓 Только без активной подписки + *[OTHER] { $audience } + } + + .item = { $is_active -> + [1] 🟢 + *[0] 🔴 + } { $code } — { $discount_percent }% + + .active = { $is_active -> [1] 🟢 *[0] 🔴 - } Статус \ No newline at end of file + } Статус + +btn-giveaway = + .list = 📃 Список акций + .create = ➕ Создать акцию + .entries = 👥 Участники + .winners = 🏆 Победители + .select-winner = 🎲 Выбрать победителя + .select-next-winner = 🎲 Выбрать следующего + .add-participant = ➕ Добавить участника + .enable = 🟢 Включить + .disable = 🔴 Выключить + .keep-disabled = ⚪ Оставить выключенной + .complete = ✅ Завершить + .archive = 🗄 Очистить / архивировать + .archive-confirm = ⚠️ Да, очистить + .rules = 📄 Условия акции + .rules-edit = ✏️ Изменить условия + .delete = 🗑 Удалить акцию + .delete-confirm = Да, удалить + .cancel = Отмена + .confirm = ✅ Подтвердить + .continue = Продолжить ➡️ + .leave-phone = 📱 Оставить номер телефона + .skip-phone = ⏭ Пропустить + .purchase-type = { $selected -> + [1] ✅ + *[0] ◻️ + } { $purchase_type } diff --git a/assets/translations/ru/messages.ftl b/assets/translations/ru/messages.ftl index 572e9023..fe58b20b 100644 --- a/assets/translations/ru/messages.ftl +++ b/assets/translations/ru/messages.ftl @@ -48,9 +48,19 @@ msg-main-menu = } +msg-main-menu-how-to-connect = +
📲 Как подключиться: + • 1. Скачайте приложение Happ + • 2. Нажмите «Подключиться» + • 3. Пролистайте немного вниз + • 4. Нажмите «Добавить подписку»
+ msg-menu-devices = 📱 Управление устройствами + { $devices_unavailable -> + [1] ⚠️ Не удалось получить список устройств. Обратитесь в поддержку. + *[0] Подключено: { $current_count } / { $max_count } { $has_devices -> @@ -58,6 +68,7 @@ msg-menu-devices = *[HAS] Нажмите на устройство чтобы удалить его. Если не хватает устройств — измените подписку. } + } msg-menu-devices-confirm-reissue = 🔄 Перевыпуск подписки @@ -381,7 +392,13 @@ msg-broadcast-view = msg-users-recent-registered = 🆕 Последние зарегистрированные msg-users-recent-activity = 📝 Последние взаимодействующие msg-user-transactions = 🧾 Транзакции пользователя -msg-user-devices = 📱 Устройства пользователя ({ $current_count } / { $max_count }) +msg-user-devices = + 📱 Устройства пользователя ({ $current_count } / { $max_count }) + + { $devices_unavailable -> + [1] ⚠️ Не удалось получить список устройств из Remnawave. + *[0] { empty } + } msg-user-give-access = 🔑 Предоставить доступ к плану msg-users-search = @@ -1087,6 +1104,13 @@ msg-notifications-system = ⚙️ Системные уведомления💳 Подписка msg-subscription-plans = 📦 Выберите план + +msg-subscription-promocode = + 🎟 Активировать промокод + + Введите ваш промокод ниже. + + Промокод будет проверен на соответствие выбранному тарифу. Если тариф ещё не выбран, вернитесь и выберите его после ввода кода. msg-subscription-new-success = Чтобы начать пользоваться нашим сервисом, нажмите кнопку `{ btn-subscription.connect }` и следуйте инструкциям! msg-subscription-renew-success = Ваша подписка продлена на { $added_duration }. @@ -1160,6 +1184,11 @@ msg-subscription-confirm = { msg-subscription-details } + { $promo_code -> + [0] { empty } + *[HAS]
🎟 Промокод { $promo_code } применён
+ } + { $purchase_type -> [RENEW] ⚠️ Текущая подписка будет продлена на выбранный срок. [CHANGE] ⚠️ Текущая подписка будет заменена выбранной без пересчета оставшегося срока. @@ -1252,31 +1281,266 @@ msg-importer-sync-completed = # Promocodes msg-promocodes-main = 🎟 Промокоды + +msg-promocodes-list = 📃 Список промокодов + +msg-promocode-code = + 🏷️ Введите код промокода + + Только латиница, цифры и спецсимволы. Максимум 64 символа. + Пример: VAY20 + +msg-promocode-reward = + 🎁 Введите процент скидки + + Целое число от 1 до 99. + Пример: 20 + +msg-promocode-allowed = + 📦 Выберите тариф + + Промокод будет привязан к выбранному тарифу. + +msg-promocode-availability = + ✴️ Выберите аудиторию + + Определяет, кто может использовать промокод. + +msg-promocode-type = + 🔢 Введите лимит использований + + Целое число больше 0. + Пример: 100 + +msg-promocode-lifetime = + ⌛ Введите срок действия + + Формат: ДД.ММ.ГГГГ или ДД.ММ.ГГГГ ЧЧ:ММ + Пример: 31.12.2026 + msg-promocode-configurator = - 🎟 Конфигуратор промокода + 🎟 Новый промокод — подтверждение
- • Код: { $code } - • Тип: { promocode-type } - • Доступ: { availability-type } - • Статус: { $is_active -> - [1] 🟢 Включен - *[0] 🔴 Выключен - } + • Код: { $code } + • Скидка: { $discount_percent }% + • Тариф: { $plan_name } + • Аудитория: { promo-audience } + • Лимит активаций: { $max_activations } + • Срок действия: { $expires_at_str }
+ Проверьте параметры и нажмите «Подтвердить». + +msg-promocode-view = + 🎟 Промокод +
- { $promocode_type -> - [DURATION] • Длительность: { $reward } - [TRAFFIC] • Трафик: { $reward } - [DEVICES] • Устройства: { $reward } - [SUBSCRIPTION] • Подписка: { frg-plan-snapshot } - [PERSONAL_DISCOUNT] • Персональная скидка: { $reward }% - [PURCHASE_DISCOUNT] • Скидка на покупку: { $reward }% - *[OTHER] { $promocode_type } - } - • Срок действия: { $lifetime } + • Код: { $code } + • Скидка: { $discount_percent }% + • Тариф: { $plan_name } + • Аудитория: { promo-audience } • Лимит активаций: { $max_activations } + • Использовано: { $activations_count } + • Срок действия: { $expires_at_str } + • Статус: { $is_active -> + [1] 🟢 Активен + *[0] 🔴 Неактивен + } +
+ +# Giveaways +msg-giveaways-main = + 🎁 Акции + + Создавайте акции для покупателей выбранных тарифов и сроков подписки. + +msg-giveaways-list = 📃 Список акций + +msg-giveaway-name = + Введите название акции + + Пример: Розыгрыш 15 000 ₽ + +msg-giveaway-start = + Введите дату начала + + Формат: ДД.ММ.ГГГГ или ДД.ММ.ГГГГ ЧЧ:ММ + +msg-giveaway-end = + Введите дату окончания + + Формат: ДД.ММ.ГГГГ или ДД.ММ.ГГГГ ЧЧ:ММ + +msg-giveaway-winner-count = + Введите количество победителей + + Пример: 5 + +msg-giveaway-prize = + Введите сумму приза одному победителю + + Пример: 3000 + +msg-giveaway-plan = Выберите участвующий тариф + +msg-giveaway-duration = Выберите участвующий срок подписки + +msg-giveaway-purchase-types = + Выберите типы покупки + + Отметьте один или несколько вариантов. + +msg-giveaway-rules = + Введите текст условий акции + + До 4000 символов. Отправьте -, чтобы использовать автоматически сформированные условия. + +msg-giveaway-activity = + Включить акцию сразу после создания? + +msg-giveaway-configurator = + 🎁 Новая акция — подтверждение + +
+ Название: { $name } + Период: { $starts_at } — { $ends_at } + Победителей: { $winner_count } + Приз одному: { $prize_amount } ₽ + Тариф: { $plan_name } + Срок: { $duration_days } дней + Типы покупки: { $purchase_types } + Условия: { $rules_text } + Активна: { $is_active -> + [1] да + *[0] нет + } +
+ +msg-giveaway-view = + 🎁 { $name } + +
+ Статус: { $status } + Период: { $starts_at } — { $ends_at } + Тариф: { $plan_name } + Срок: { $duration_days } дней + Типы покупки: { $purchase_types } + Участников: { $entries_count } + Победителей: { $winners_count } из { $winner_count } + Приз одному: { $prize_amount } ₽ + Условия: { $rules_mode }
- Выберите пункт для изменения. \ No newline at end of file +msg-giveaway-rules-view = 📄 Условия акции + +msg-giveaway-rules-edit = + Изменение условий акции + + Отправьте новый текст до 4000 символов или - для автоматических условий. + +msg-giveaway-entries = 👥 Участники акции + +msg-giveaway-winners = 🏆 Победители акции + +msg-giveaway-manual-entry-phone = + Введите номер телефона участника. + + Формат: + 8924830022332 + + Только цифры, без плюса, пробелов, скобок и дефисов. + +msg-giveaway-manual-entry-added = + Участник добавлен ✅ + + Телефон: + { $phone } + + Уникальный код участника: + { $participant_code } + + Отправьте этот код участнику вручную. + +msg-giveaway-participants-shortage = + ⚠️ Уникальных участников меньше, чем запланированных победителей. + +msg-giveaway-archive-confirm = + Вы уверены, что хотите очистить участников и победителей этой акции? + + Записи будут архивированы. Это действие нельзя отменить через интерфейс. + +msg-giveaway-delete-confirm = + Вы уверены, что хотите полностью удалить эту акцию? + + Будут удалены: + — сама акция; + — участники этой акции; + — выбранные победители этой акции. + + Платежи, пользователи и подписки удалены не будут. + + Это действие нельзя отменить. + +# Client giveaways +msg-client-giveaways = 🎁 Акции + +msg-client-giveaways-empty = + Сейчас активных акций нет. + + Следите за обновлениями — новые розыгрыши появятся здесь 🎁 + +msg-client-giveaway-view = + 🎁 { $name } + + Призовой фонд: + { $winner_count } победителей × { $prize_amount } ₽ + + Период акции: + с { $starts_at } до { $ends_at } + + Чтобы участвовать: + купите подписку «{ $plan_name }» на { $duration_days } дней. + + После оплаты бот автоматически выдаст уникальный номер участника. + + Ваш статус: + { $status_text } + + { $has_entry -> + [1] + Ваш номер участника: + { $participant_code } + + Телефон: { $phone } + *[0] + Чтобы стать участником, купите указанную подписку. + } + + { $is_winner -> + [1] + 🎉 Вы стали победителем! + Место: { $winner_rank } + Приз: { $prize_amount } ₽ + + Мы свяжемся с вами для выплаты. + *[0] { empty } + } + +msg-client-giveaway-status = + .participating = Вы уже участвуете ✅ + .not-participating = Вы пока не участвуете. + .completed = Акция завершена. + .winner = 🎉 Победитель + +msg-client-giveaway-conditions = 📄 Условия акции + +msg-client-giveaway-phone = + 📱 Контактный номер + + Текущий номер: { $phone } + + Отправьте новый номер только цифрами, 10–15 символов. + Пример: 8924830022332 + +msg-client-giveaway-results = 🏆 Итоги акции diff --git a/assets/translations/ru/notifications.ftl b/assets/translations/ru/notifications.ftl index f295944b..6ef16f88 100644 --- a/assets/translations/ru/notifications.ftl +++ b/assets/translations/ru/notifications.ftl @@ -113,8 +113,76 @@ ntf-subscription = .plans-unavailable = ❌ В данный момент нет доступных планов. .gateways-unavailable = ❌ В данный момент нет доступных платежных систем. .renew-plan-unavailable = ❌ Текущий план устарел и недоступен для продления. + .renew-plan-changed = ⚠️ Ваш текущий тариф больше недоступен. Выберите новый тариф для продления подписки. .payment-creation-failed = ❌ Ошибка при создании платежа. Попробуйте позже. +ntf-promocode = + .not-found = ❌ Промокод не найден. + .inactive = ❌ Промокод неактивен. + .expired = ❌ Срок действия промокода истёк. + .limit-exceeded = ❌ Лимит активаций промокода исчерпан. + .already-used = ❌ Вы уже использовали этот промокод. + .audience-mismatch = ❌ Этот промокод доступен только клиентам с активной подпиской. + .plan-mismatch = ❌ Промокод не подходит для выбранного тарифа. + .applied = ✅ Промокод принят! Скидка { $discount }% будет применена к вашему заказу. + .code-already-exists = ❌ Промокод с таким кодом уже существует. + .admin-created = ✅ Промокод успешно создан. + .admin-deactivated = ✅ Промокод деактивирован. + +ntf-giveaway = + .registered = + 🎁 Вы стали участником акции! + + Ваш уникальный номер: + { $code } + + Сохраните этот номер до окончания розыгрыша. + + Если хотите, можете оставить контактный номер телефона, чтобы мы быстрее связались с вами в случае выигрыша. + + Формат номера: 8924830022332 + + .phone-request = + Отправьте номер телефона в формате: + + 8924830022332 + + Без плюса, пробелов, скобок и дефисов. + + .phone-invalid = + Номер указан неверно. + + Отправьте номер только цифрами, без плюса и пробелов. + + Пример: 8924830022332 + + .phone-saved = Спасибо, контактный номер сохранён ✅ + .purchase-type-required = ❌ Выберите хотя бы один тип покупки. + .admin-created = ✅ Акция создана. + .status-updated = ✅ Статус акции обновлён. + .winner-selected = 🎉 Победитель выбран. + .winner-unavailable = ⚠️ Нельзя выбрать следующего победителя: нет доступных участников или все места уже заняты. + .completed = ✅ Акция завершена. + .archived = ✅ Акция и её участники архивированы. + .deleted = Акция удалена ✅ + .rules-too-long = ❌ Текст условий не должен превышать 4000 символов. + .rules-updated = ✅ Условия акции обновлены. + .manual-phone-invalid = + Номер указан неверно. + + Введите номер только цифрами. + + Пример: + 8924830022332 + .manual-entry-unavailable = В завершённую или архивную акцию нельзя добавить участника. + .buy-instruction = + Для участия в акции выберите тариф: + { $plan_name } + + Срок: { $duration_days } дней + + После успешной оплаты бот автоматически выдаст номер участника. + ntf-broadcast = .message = { $content } .text-too-long = ❌ Превышено максимальное кол-во символов ({ $max_limit }). @@ -159,3 +227,4 @@ ntf-devices = .deleted = ✅ Устройство удалено. .all-deleted = ✅ Все устройства удалены. .reissued = ✅ Подписка успешно перевыпущена. + .unavailable = ⚠️ Не удалось выполнить операцию с устройствами. Обратитесь в поддержку. diff --git a/assets/translations/ru/utils.ftl b/assets/translations/ru/utils.ftl index f52bc719..920218be 100644 --- a/assets/translations/ru/utils.ftl +++ b/assets/translations/ru/utils.ftl @@ -6,8 +6,6 @@ development = В разработке! test-payment = Тестовый платеж unknown = — -development-promocode = Промокоды еще не реализованы, для мотивации и ускорения разработки можете закинуть монет. - payment-invoice-description = { purchase-type } подписки { $name } на { $duration } inline-invite = @@ -286,7 +284,7 @@ promocode-type = { $promocode_type -> *[OTHER] { $promocode_type } } -availability-type = { $availability_type -> +availability-type = { $availability_type -> [ALL] Для всех [NEW] Для новых [EXISTING] Для существующих @@ -296,6 +294,13 @@ availability-type = { $availability_type -> *[OTHER] { $availability_type } } +promo-audience = { $audience -> + [ALL] Для всех + [WITH_ACTIVE_SUBSCRIPTION] Только с активной подпиской + [WITHOUT_ACTIVE_SUBSCRIPTION] Только без активной подписки + *[OTHER] { $audience } +} + gateway-type = { $gateway_type -> [TELEGRAM_STARS] Telegram Stars [YOOKASSA] ЮKassa diff --git a/docker-compose.custom.yml b/docker-compose.custom.yml new file mode 100644 index 00000000..1b77b54f --- /dev/null +++ b/docker-compose.custom.yml @@ -0,0 +1,22 @@ +# Build and run the code from the currently checked-out repository branch. +# +# Use this file together with one production compose file: +# docker compose -f docker-compose.prod.internal.yml -f docker-compose.custom.yml up -d --build +# or: +# docker compose -f docker-compose.prod.external.yml -f docker-compose.custom.yml up -d --build + +x-custom-build: &custom-build + image: remnashop-custom:local + build: + context: . + dockerfile: Dockerfile + +services: + remnashop: + <<: *custom-build + + remnashop-taskiq-worker: + <<: *custom-build + + remnashop-taskiq-scheduler: + <<: *custom-build diff --git a/src/application/common/dao/__init__.py b/src/application/common/dao/__init__.py index e94ff306..6be9c65e 100644 --- a/src/application/common/dao/__init__.py +++ b/src/application/common/dao/__init__.py @@ -1,6 +1,8 @@ from .broadcast import BroadcastDao +from .giveaway import GiveawayDao from .payment_gateway import PaymentGatewayDao from .plan import PlanDao +from .promocode import PromocodeDao from .referral import ReferralDao from .settings import SettingsDao from .subscription import SubscriptionDao @@ -11,8 +13,10 @@ __all__ = [ "BroadcastDao", + "GiveawayDao", "PaymentGatewayDao", "PlanDao", + "PromocodeDao", "ReferralDao", "SettingsDao", "SubscriptionDao", diff --git a/src/application/common/dao/giveaway.py b/src/application/common/dao/giveaway.py new file mode 100644 index 00000000..92ffce15 --- /dev/null +++ b/src/application/common/dao/giveaway.py @@ -0,0 +1,82 @@ +from datetime import datetime +from typing import Optional, Protocol, runtime_checkable +from uuid import UUID + +from src.application.dto import GiveawayCampaignDto, GiveawayEntryDto +from src.core.enums import GiveawayCampaignStatus, PurchaseType + + +@runtime_checkable +class GiveawayDao(Protocol): + async def create_campaign(self, campaign: GiveawayCampaignDto) -> GiveawayCampaignDto: ... + + async def get_campaign(self, campaign_id: int) -> Optional[GiveawayCampaignDto]: ... + + async def get_campaigns(self) -> list[GiveawayCampaignDto]: ... + + async def get_client_campaigns( + self, + now: datetime, + user_telegram_id: int, + ) -> list[GiveawayCampaignDto]: ... + + async def get_matching_campaigns( + self, + now: datetime, + plan_id: int, + duration_days: int, + purchase_type: PurchaseType, + ) -> list[GiveawayCampaignDto]: ... + + async def set_campaign_status( + self, + campaign_id: int, + status: GiveawayCampaignStatus, + now: datetime, + ) -> Optional[GiveawayCampaignDto]: ... + + async def archive_campaign( + self, + campaign_id: int, + now: datetime, + ) -> Optional[GiveawayCampaignDto]: ... + + async def delete_campaign(self, campaign_id: int) -> bool: ... + + async def update_campaign_rules( + self, + campaign_id: int, + rules_text: Optional[str], + ) -> Optional[GiveawayCampaignDto]: ... + + async def create_entry(self, entry: GiveawayEntryDto) -> Optional[GiveawayEntryDto]: ... + + async def add_manual_entry( + self, + entry: GiveawayEntryDto, + ) -> Optional[GiveawayEntryDto]: ... + + async def get_entry_by_payment(self, payment_id: UUID) -> Optional[GiveawayEntryDto]: ... + + async def get_entry(self, entry_id: int) -> Optional[GiveawayEntryDto]: ... + + async def get_entry_for_user( + self, + campaign_id: int, + user_telegram_id: int, + ) -> Optional[GiveawayEntryDto]: ... + + async def update_phone(self, entry_id: int, phone: str) -> Optional[GiveawayEntryDto]: ... + + async def get_entries(self, campaign_id: int) -> list[GiveawayEntryDto]: ... + + async def get_winners(self, campaign_id: int) -> list[GiveawayEntryDto]: ... + + async def count_entries(self, campaign_id: int) -> int: ... + + async def select_winner( + self, + campaign_id: int, + winner_rank: int, + selected_at: datetime, + ) -> Optional[GiveawayEntryDto]: ... diff --git a/src/application/common/dao/promocode.py b/src/application/common/dao/promocode.py new file mode 100644 index 00000000..a1d9a449 --- /dev/null +++ b/src/application/common/dao/promocode.py @@ -0,0 +1,29 @@ +from typing import Optional, Protocol, runtime_checkable + +from src.application.dto import PromocodeActivationDto, PromocodeDto + + +@runtime_checkable +class PromocodeDao(Protocol): + async def create(self, promocode: PromocodeDto) -> PromocodeDto: ... + + async def get_by_id(self, promocode_id: int) -> Optional[PromocodeDto]: ... + + async def get_by_code(self, code: str) -> Optional[PromocodeDto]: ... + + async def deactivate(self, promocode_id: int) -> None: ... + + async def count_activations(self, promocode_id: int) -> int: ... + + async def has_user_activated(self, promocode_id: int, user_telegram_id: int) -> bool: ... + + async def record_activation( + self, + activation: PromocodeActivationDto, + ) -> PromocodeActivationDto: ... + + async def get_all( + self, + limit: int = 100, + offset: int = 0, + ) -> list[PromocodeDto]: ... diff --git a/src/application/common/dao/transaction.py b/src/application/common/dao/transaction.py index 5f22e988..90fbf5e9 100644 --- a/src/application/common/dao/transaction.py +++ b/src/application/common/dao/transaction.py @@ -12,6 +12,11 @@ async def create(self, transaction: TransactionDto) -> TransactionDto: ... async def get_by_payment_id(self, payment_id: UUID) -> Optional[TransactionDto]: ... + async def get_by_payment_id_for_update( + self, + payment_id: UUID, + ) -> Optional[TransactionDto]: ... + async def get_by_user(self, telegram_id: int) -> list[TransactionDto]: ... async def get_all(self, limit: int = 100, offset: int = 0) -> list[TransactionDto]: ... diff --git a/src/application/common/policy.py b/src/application/common/policy.py index 360a887e..e552cf54 100644 --- a/src/application/common/policy.py +++ b/src/application/common/policy.py @@ -30,6 +30,7 @@ class Permission(UpperStrEnum): VIEW_USERS = auto() VIEW_BROADCAST = auto() VIEW_PROMOCODE = auto() + VIEW_GIVEAWAY = auto() VIEW_ACCESS = auto() VIEW_REMNAWAVE = auto() VIEW_REMNASHOP = auto() @@ -53,6 +54,8 @@ class Permission(UpperStrEnum): # REMNASHOP_GATEWAYS = auto() REMNASHOP_PLAN_EDITOR = auto() + REMNASHOP_PROMOCODE_EDITOR = auto() + REMNASHOP_GIVEAWAY_EDITOR = auto() REMNASHOP_LOGS = auto() # USER_EDITOR = auto() @@ -72,6 +75,9 @@ class Permission(UpperStrEnum): Role.ADMIN: { Permission.VIEW_DASHBOARD, Permission.VIEW_ACCESS, + Permission.VIEW_PROMOCODE, + Permission.VIEW_GIVEAWAY, + Permission.REMNASHOP_GIVEAWAY_EDITOR, }, Role.PREVIEW: { # TODO: Implement demo Bot instance Permission.VIEW_DASHBOARD, diff --git a/src/application/dto/__init__.py b/src/application/dto/__init__.py index 3800cf95..3f3ca6d9 100644 --- a/src/application/dto/__init__.py +++ b/src/application/dto/__init__.py @@ -1,6 +1,7 @@ from .base import BaseDto, TimestampMixin, TrackableMixin from .broadcast import BroadcastDto, BroadcastMessageDto from .build import BuildInfoDto +from .giveaway import ClientGiveawayDto, GiveawayCampaignDto, GiveawayEntryDto from .message_payload import MediaDescriptorDto, MessagePayloadDto from .notification_task import NotificationTaskDto from .payment_gateway import ( @@ -10,6 +11,7 @@ PaymentResultDto, ) from .plan import PlanDto, PlanDurationDto, PlanPriceDto, PlanSnapshotDto +from .promocode import PromocodeActivationDto, PromocodeDto from .referral import ReferralDto, ReferralRewardDto from .settings import ( AccessSettingsDto, @@ -41,6 +43,9 @@ "BroadcastDto", "BroadcastMessageDto", "BuildInfoDto", + "GiveawayCampaignDto", + "GiveawayEntryDto", + "ClientGiveawayDto", "MediaDescriptorDto", "MessagePayloadDto", "NotificationTaskDto", @@ -59,6 +64,8 @@ "PlanDurationDto", "PlanPriceDto", "PlanSnapshotDto", + "PromocodeDto", + "PromocodeActivationDto", "ReferralDto", "ReferralRewardDto", "AccessSettingsDto", diff --git a/src/application/dto/giveaway.py b/src/application/dto/giveaway.py new file mode 100644 index 00000000..06779625 --- /dev/null +++ b/src/application/dto/giveaway.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from typing import Optional +from uuid import UUID + +from src.core.enums import ( + GiveawayCampaignStatus, + GiveawayEntrySource, + GiveawayEntryStatus, + PurchaseType, +) + +from .base import BaseDto, TimestampMixin, TrackableMixin + + +@dataclass(kw_only=True) +class GiveawayCampaignDto(BaseDto, TrackableMixin, TimestampMixin): + name: str + status: GiveawayCampaignStatus + starts_at: datetime + ends_at: datetime + winner_count: int + prize_amount: Decimal + eligible_plan_id: int + eligible_duration_days: int + eligible_purchase_types: list[PurchaseType] = field(default_factory=list) + code_prefix: str = "VAY" + rules_text: Optional[str] = None + completed_at: Optional[datetime] = None + archived_at: Optional[datetime] = None + + +@dataclass(kw_only=True) +class GiveawayEntryDto(BaseDto, TrackableMixin, TimestampMixin): + campaign_id: int + user_telegram_id: Optional[int] + telegram_username: Optional[str] + participant_code: str + transaction_payment_id: Optional[UUID] + plan_id: int + plan_name: str + duration_days: int + purchase_type: Optional[PurchaseType] + entry_source: GiveawayEntrySource = GiveawayEntrySource.AUTO_PURCHASE + phone: Optional[str] = None + status: GiveawayEntryStatus = GiveawayEntryStatus.ELIGIBLE + winner_rank: Optional[int] = None + selected_at: Optional[datetime] = None + + +@dataclass(frozen=True) +class ClientGiveawayDto: + campaign: GiveawayCampaignDto + plan_name: str + entry: Optional[GiveawayEntryDto] = None diff --git a/src/application/dto/promocode.py b/src/application/dto/promocode.py new file mode 100644 index 00000000..ddf72adf --- /dev/null +++ b/src/application/dto/promocode.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional +from uuid import UUID + +from src.core.enums import PromoAudience + +from .base import BaseDto, TimestampMixin, TrackableMixin +from .plan import PlanDto + + +@dataclass(kw_only=True) +class PromocodeDto(BaseDto, TrackableMixin, TimestampMixin): + code: str + discount_percent: int + plan_id: int + audience: PromoAudience + max_activations: int + expires_at: datetime + is_active: bool + + plan: Optional[PlanDto] = field(default=None) + + +@dataclass(kw_only=True) +class PromocodeActivationDto(BaseDto, TrackableMixin): + promocode_id: int + user_telegram_id: int + transaction_payment_id: UUID + activated_at: Optional[datetime] = field(default=None) diff --git a/src/application/dto/transaction.py b/src/application/dto/transaction.py index d019ae70..e1cacfc1 100644 --- a/src/application/dto/transaction.py +++ b/src/application/dto/transaction.py @@ -1,6 +1,6 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from decimal import Decimal -from typing import Self +from typing import Optional, Self from uuid import UUID from src.core.enums import Currency, PaymentGatewayType, PurchaseType, TransactionStatus @@ -43,6 +43,8 @@ class TransactionDto(BaseDto, TrackableMixin, TimestampMixin): currency: Currency plan_snapshot: "PlanSnapshotDto" + promocode_id: Optional[int] = field(default=None) + @property def is_completed(self) -> bool: return self.status == TransactionStatus.COMPLETED diff --git a/src/application/services/pricing.py b/src/application/services/pricing.py index 9520641e..f8f91fe1 100644 --- a/src/application/services/pricing.py +++ b/src/application/services/pricing.py @@ -62,6 +62,42 @@ def calculate(self, user: UserDto, price: Decimal, currency: Currency) -> PriceD final_amount=final_amount, ) + def calculate_with_promo( + self, user: UserDto, price: Decimal, currency: Currency, promo_discount: int + ) -> PriceDetailsDto: + user_discount = max(user.purchase_discount or 0, user.personal_discount or 0) + effective_discount = min(max(user_discount, promo_discount), 99) + + logger.debug( + f"Calculating price with promo for user '{user.telegram_id}': " + f"user_discount='{user_discount}', promo_discount='{promo_discount}', " + f"effective='{effective_discount}'" + ) + + if price <= 0: + return PriceDetailsDto( + original_amount=Decimal(0), + discount_percent=0, + final_amount=Decimal(0), + ) + + discounted = price * (Decimal(100) - Decimal(effective_discount)) / Decimal(100) + final_amount = self.apply_currency_rules(discounted, currency) + + if final_amount == price: + effective_discount = 0 + + logger.info( + f"Price with promo: original='{price}', " + f"effective_discount='{effective_discount}', final='{final_amount}'" + ) + + return PriceDetailsDto( + original_amount=price, + discount_percent=effective_discount, + final_amount=final_amount, + ) + def parse_price(self, input_price: str, currency: Currency) -> Decimal: logger.debug(f"Parsing input price '{input_price}' for currency '{currency}'") diff --git a/src/application/use_cases/gateways/commands/payment.py b/src/application/use_cases/gateways/commands/payment.py index b73a284c..07c8f662 100644 --- a/src/application/use_cases/gateways/commands/payment.py +++ b/src/application/use_cases/gateways/commands/payment.py @@ -1,7 +1,10 @@ +import asyncio import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Optional from uuid import UUID +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from loguru import logger from src.application.common import ( @@ -13,6 +16,7 @@ ) from src.application.common.dao import ( PaymentGatewayDao, + PromocodeDao, ReferralDao, SubscriptionDao, TransactionDao, @@ -21,6 +25,7 @@ from src.application.common.policy import Permission from src.application.common.uow import UnitOfWork from src.application.dto import ( + MessagePayloadDto, PaymentResultDto, PlanSnapshotDto, PriceDetailsDto, @@ -44,6 +49,14 @@ ) from src.application.events import UserPurchaseEvent from src.application.use_cases.gateways.queries.providers import GetPaymentGatewayInstance +from src.application.use_cases.giveaway.commands import ( + RegisterGiveawayEntry, + RegisterGiveawayEntryDto, +) +from src.application.use_cases.promocode.commands.management import ( + RecordPromocodeActivation, + RecordPromocodeActivationDto, +) from src.application.use_cases.referral.commands.rewards import ( AssignReferralRewards, AssignReferralRewardsDto, @@ -111,6 +124,7 @@ class CreatePaymentDto: pricing: PriceDetailsDto purchase_type: PurchaseType gateway_type: PaymentGatewayType + promocode_id: Optional[int] = field(default=None) class CreatePayment(Interactor[CreatePaymentDto, PaymentResultDto]): @@ -151,6 +165,7 @@ async def _execute(self, actor: UserDto, data: CreatePaymentDto) -> PaymentResul pricing=data.pricing, currency=gateway_instance.data.currency, plan_snapshot=data.plan_snapshot, + promocode_id=data.promocode_id, ) async with self.uow: @@ -240,29 +255,35 @@ def __init__( transaction_dao: TransactionDao, subscription_dao: SubscriptionDao, referral_dao: ReferralDao, + promocode_dao: PromocodeDao, event_publisher: EventPublisher, notifier: Notifier, i18n: TranslatorRunner, assign_referral_rewards: AssignReferralRewards, purchase_subscription: PurchaseSubscription, + record_promocode_activation: RecordPromocodeActivation, + register_giveaway_entry: RegisterGiveawayEntry, ) -> None: self.uow = uow self.user_dao = user_dao self.transaction_dao = transaction_dao self.subscription_dao = subscription_dao self.referral_dao = referral_dao + self.promocode_dao = promocode_dao self.event_publisher = event_publisher self.notifier = notifier self.i18n = i18n self.assign_referral_rewards = assign_referral_rewards self.purchase_subscription = purchase_subscription + self.record_promocode_activation = record_promocode_activation + self.register_giveaway_entry = register_giveaway_entry async def _execute(self, actor: UserDto, data: ProcessPaymentDto) -> None: payment_id = data.payment_id new_status = data.new_transaction_status async with self.uow: - transaction = await self.transaction_dao.get_by_payment_id(payment_id) + transaction = await self.transaction_dao.get_by_payment_id_for_update(payment_id) if not transaction: logger.critical(f"Transaction not found for '{payment_id}'") @@ -345,5 +366,73 @@ async def _handle_success(self, user: UserDto, transaction: TransactionDto) -> N PurchaseSubscriptionDto(user, transaction, subscription) ) + try: + giveaway_entries = await self.register_giveaway_entry.system( + RegisterGiveawayEntryDto(user=user, transaction=transaction) + ) + if giveaway_entries: + # Preserve Telegram's visual order: purchase result first, giveaway second. + await asyncio.sleep(1) + for entry in giveaway_entries: + if entry.id is None: + continue + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="btn-giveaway.leave-phone", + callback_data=f"giveaway_phone:{entry.id}", + ) + ], + [ + InlineKeyboardButton( + text="btn-giveaway.skip-phone", + callback_data="giveaway_skip", + ) + ], + ] + ) + await self.notifier.notify_user( + user, + payload=MessagePayloadDto( + i18n_key="ntf-giveaway.registered", + i18n_kwargs={"code": entry.participant_code}, + reply_markup=keyboard, + disable_default_markup=True, + delete_after=None, + ), + ) + logger.info( + f"Giveaway code issued for entry='{entry.id}' " + f"payment='{transaction.payment_id}'" + ) + except Exception: + logger.exception( + f"Giveaway registration failed for payment '{transaction.payment_id}'; " + "VPN purchase remains successful" + ) + if not transaction.pricing.is_free: await self.assign_referral_rewards.system(AssignReferralRewardsDto(user, transaction)) + + if transaction.promocode_id is not None: + already_activated = await self.promocode_dao.has_user_activated( + transaction.promocode_id, user.telegram_id + ) + if not already_activated: + await self.record_promocode_activation.system( + RecordPromocodeActivationDto( + promocode_id=transaction.promocode_id, + user_telegram_id=user.telegram_id, + transaction_payment_id=transaction.payment_id, + ) + ) + logger.info( + f"Recorded promocode activation for user '{user.telegram_id}', " + f"promocode_id='{transaction.promocode_id}'" + ) + else: + logger.warning( + f"Skipped duplicate promocode activation for user '{user.telegram_id}', " + f"promocode_id='{transaction.promocode_id}'" + ) diff --git a/src/application/use_cases/giveaway/__init__.py b/src/application/use_cases/giveaway/__init__.py new file mode 100644 index 00000000..93623413 --- /dev/null +++ b/src/application/use_cases/giveaway/__init__.py @@ -0,0 +1,35 @@ +from typing import Final + +from src.application.common import Interactor + +from .commands import ( + AddManualGiveawayEntry, + ArchiveGiveawayCampaign, + CreateGiveawayCampaign, + DeleteGiveawayCampaign, + RegisterGiveawayEntry, + SaveGiveawayPhone, + SelectGiveawayWinner, + SetGiveawayStatus, + UpdateGiveawayRules, +) +from .queries import ( + GetClientGiveawayConditions, + GetClientGiveawayDetails, + ListClientGiveaways, +) + +GIVEAWAY_USE_CASES: Final[tuple[type[Interactor], ...]] = ( + AddManualGiveawayEntry, + CreateGiveawayCampaign, + SetGiveawayStatus, + ArchiveGiveawayCampaign, + DeleteGiveawayCampaign, + RegisterGiveawayEntry, + SaveGiveawayPhone, + SelectGiveawayWinner, + UpdateGiveawayRules, + ListClientGiveaways, + GetClientGiveawayDetails, + GetClientGiveawayConditions, +) diff --git a/src/application/use_cases/giveaway/commands.py b/src/application/use_cases/giveaway/commands.py new file mode 100644 index 00000000..773b92a8 --- /dev/null +++ b/src/application/use_cases/giveaway/commands.py @@ -0,0 +1,400 @@ +import secrets +import string +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from loguru import logger + +from src.application.common import Interactor +from src.application.common.dao import GiveawayDao, PlanDao +from src.application.common.policy import Permission +from src.application.common.uow import UnitOfWork +from src.application.dto import ( + GiveawayCampaignDto, + GiveawayEntryDto, + TransactionDto, + UserDto, +) +from src.core.enums import GiveawayCampaignStatus, GiveawayEntrySource, PurchaseType +from src.core.utils.time import datetime_now + +_GIVEAWAY_CODE_ALPHABET = string.ascii_uppercase + string.digits + + +def generate_giveaway_code(prefix: str) -> str: + left = "".join(secrets.choice(_GIVEAWAY_CODE_ALPHABET) for _ in range(4)) + right = "".join(secrets.choice(_GIVEAWAY_CODE_ALPHABET) for _ in range(4)) + return f"{prefix}-{left}-{right}" + + +def mask_giveaway_phone(phone: str) -> str: + return f"{phone[:4]}****{phone[-3:]}" + + +@dataclass(frozen=True) +class CreateGiveawayCampaignDto: + name: str + starts_at: datetime + ends_at: datetime + winner_count: int + prize_amount: Decimal + eligible_plan_id: int + eligible_duration_days: int + eligible_purchase_types: list[PurchaseType] + is_active: bool + rules_text: Optional[str] = None + + +class CreateGiveawayCampaign(Interactor[CreateGiveawayCampaignDto, GiveawayCampaignDto]): + required_permission = Permission.REMNASHOP_GIVEAWAY_EDITOR + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute( + self, + actor: UserDto, + data: CreateGiveawayCampaignDto, + ) -> GiveawayCampaignDto: + if not data.name.strip() or len(data.name.strip()) > 128: + raise ValueError("Invalid giveaway name") + if data.ends_at <= data.starts_at: + raise ValueError("Giveaway end must be after start") + if data.winner_count < 1 or data.prize_amount < 0: + raise ValueError("Invalid giveaway prize settings") + if not data.eligible_purchase_types: + raise ValueError("At least one purchase type is required") + + async with self.uow: + campaign = await self.giveaway_dao.create_campaign( + GiveawayCampaignDto( + name=data.name.strip(), + status=( + GiveawayCampaignStatus.ACTIVE + if data.is_active + else GiveawayCampaignStatus.DRAFT + ), + starts_at=data.starts_at, + ends_at=data.ends_at, + winner_count=data.winner_count, + prize_amount=data.prize_amount, + eligible_plan_id=data.eligible_plan_id, + eligible_duration_days=data.eligible_duration_days, + eligible_purchase_types=data.eligible_purchase_types, + code_prefix="VAY", + rules_text=data.rules_text, + ) + ) + await self.uow.commit() + logger.info(f"{actor.log} Created giveaway campaign id='{campaign.id}'") + return campaign + + +@dataclass(frozen=True) +class SetGiveawayStatusDto: + campaign_id: int + status: GiveawayCampaignStatus + + +class SetGiveawayStatus(Interactor[SetGiveawayStatusDto, GiveawayCampaignDto]): + required_permission = Permission.REMNASHOP_GIVEAWAY_EDITOR + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute(self, actor: UserDto, data: SetGiveawayStatusDto) -> GiveawayCampaignDto: + async with self.uow: + campaign = await self.giveaway_dao.set_campaign_status( + data.campaign_id, + data.status, + datetime_now(), + ) + if not campaign: + raise ValueError("Giveaway campaign not found") + await self.uow.commit() + logger.info( + f"{actor.log} Set giveaway campaign '{data.campaign_id}' status='{data.status}'" + ) + return campaign + + +class ArchiveGiveawayCampaign(Interactor[int, GiveawayCampaignDto]): + required_permission = Permission.REMNASHOP_GIVEAWAY_EDITOR + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute(self, actor: UserDto, campaign_id: int) -> GiveawayCampaignDto: + async with self.uow: + campaign = await self.giveaway_dao.archive_campaign(campaign_id, datetime_now()) + if not campaign: + raise ValueError("Giveaway campaign not found") + await self.uow.commit() + logger.warning(f"{actor.log} Archived giveaway campaign '{campaign_id}'") + return campaign + + +class DeleteGiveawayCampaign(Interactor[int, None]): + required_permission = Permission.REMNASHOP_GIVEAWAY_EDITOR + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute(self, actor: UserDto, campaign_id: int) -> None: + async with self.uow: + deleted = await self.giveaway_dao.delete_campaign(campaign_id) + if not deleted: + raise ValueError("Giveaway campaign not found") + await self.uow.commit() + logger.warning(f"{actor.log} Deleted giveaway campaign '{campaign_id}'") + + +@dataclass(frozen=True) +class UpdateGiveawayRulesDto: + campaign_id: int + rules_text: Optional[str] + + +class UpdateGiveawayRules(Interactor[UpdateGiveawayRulesDto, GiveawayCampaignDto]): + required_permission = Permission.REMNASHOP_GIVEAWAY_EDITOR + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute( + self, + actor: UserDto, + data: UpdateGiveawayRulesDto, + ) -> GiveawayCampaignDto: + rules_text = data.rules_text.strip() if data.rules_text else None + if rules_text and len(rules_text) > 4000: + raise ValueError("Giveaway rules are too long") + async with self.uow: + campaign = await self.giveaway_dao.update_campaign_rules( + data.campaign_id, + rules_text, + ) + if not campaign: + raise ValueError("Giveaway campaign not found") + await self.uow.commit() + logger.info(f"{actor.log} Updated rules for giveaway campaign '{data.campaign_id}'") + return campaign + + +@dataclass(frozen=True) +class RegisterGiveawayEntryDto: + user: UserDto + transaction: TransactionDto + + +class RegisterGiveawayEntry( + Interactor[RegisterGiveawayEntryDto, list[GiveawayEntryDto]] +): + required_permission = None + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute( + self, + actor: UserDto, + data: RegisterGiveawayEntryDto, + ) -> list[GiveawayEntryDto]: + transaction = data.transaction + existing = await self.giveaway_dao.get_entry_by_payment(transaction.payment_id) + if existing: + logger.info( + f"Giveaway entry already exists for payment '{transaction.payment_id}'" + ) + return [existing] + + now = datetime_now() + campaigns = await self.giveaway_dao.get_matching_campaigns( + now=now, + plan_id=transaction.plan_snapshot.id, + duration_days=transaction.plan_snapshot.duration, + purchase_type=transaction.purchase_type, + ) + created: list[GiveawayEntryDto] = [] + for campaign in campaigns: + if campaign.id is None: + continue + for _ in range(10): + code = generate_giveaway_code(campaign.code_prefix) + async with self.uow: + entry = await self.giveaway_dao.create_entry( + GiveawayEntryDto( + campaign_id=campaign.id, + user_telegram_id=data.user.telegram_id, + telegram_username=data.user.username, + participant_code=code, + transaction_payment_id=transaction.payment_id, + plan_id=transaction.plan_snapshot.id, + plan_name=transaction.plan_snapshot.name, + duration_days=transaction.plan_snapshot.duration, + purchase_type=transaction.purchase_type, + ) + ) + if entry: + await self.uow.commit() + created.append(entry) + logger.info( + f"Registered giveaway entry id='{entry.id}' " + f"campaign='{campaign.id}' payment='{transaction.payment_id}'" + ) + return created + await self.uow.rollback() + + existing = await self.giveaway_dao.get_entry_by_payment(transaction.payment_id) + if existing: + return [existing] + else: + logger.error( + f"Failed to generate unique giveaway code for campaign '{campaign.id}'" + ) + return created + + +@dataclass(frozen=True) +class AddManualGiveawayEntryDto: + campaign_id: int + phone: str + + +class AddManualGiveawayEntry( + Interactor[AddManualGiveawayEntryDto, GiveawayEntryDto] +): + required_permission = Permission.REMNASHOP_GIVEAWAY_EDITOR + + def __init__( + self, + uow: UnitOfWork, + giveaway_dao: GiveawayDao, + plan_dao: PlanDao, + ) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + self.plan_dao = plan_dao + + async def _execute( + self, + actor: UserDto, + data: AddManualGiveawayEntryDto, + ) -> GiveawayEntryDto: + phone = data.phone.strip() + if not phone.isdigit() or not 10 <= len(phone) <= 15: + raise ValueError("Invalid phone") + + campaign = await self.giveaway_dao.get_campaign(data.campaign_id) + if not campaign: + raise ValueError("Giveaway campaign not found") + if campaign.status not in { + GiveawayCampaignStatus.DRAFT, + GiveawayCampaignStatus.ACTIVE, + }: + raise ValueError("Manual entries cannot be added to this campaign") + + plan = await self.plan_dao.get_by_id(campaign.eligible_plan_id) + if not plan: + raise ValueError("Giveaway campaign plan not found") + + for _ in range(10): + async with self.uow: + entry = await self.giveaway_dao.add_manual_entry( + GiveawayEntryDto( + campaign_id=data.campaign_id, + user_telegram_id=None, + telegram_username=None, + participant_code=generate_giveaway_code(campaign.code_prefix), + transaction_payment_id=None, + plan_id=campaign.eligible_plan_id, + plan_name=plan.name, + duration_days=campaign.eligible_duration_days, + purchase_type=None, + entry_source=GiveawayEntrySource.MANUAL, + phone=phone, + ) + ) + if entry: + await self.uow.commit() + logger.info( + f"{actor.log} Added manual giveaway entry id='{entry.id}' " + f"campaign='{data.campaign_id}' phone='{mask_giveaway_phone(phone)}' " + f"code='{entry.participant_code}'" + ) + return entry + await self.uow.rollback() + + logger.error( + f"{actor.log} Failed to generate a unique manual giveaway code " + f"for campaign='{data.campaign_id}' phone='{mask_giveaway_phone(phone)}'" + ) + raise RuntimeError("Failed to generate unique giveaway participant code") + + +@dataclass(frozen=True) +class SaveGiveawayPhoneDto: + entry_id: int + user_telegram_id: int + phone: str + + +class SaveGiveawayPhone(Interactor[SaveGiveawayPhoneDto, GiveawayEntryDto]): + required_permission = Permission.PUBLIC + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute(self, actor: UserDto, data: SaveGiveawayPhoneDto) -> GiveawayEntryDto: + if not data.phone.isdigit() or not 10 <= len(data.phone) <= 15: + raise ValueError("Invalid phone") + entry = await self.giveaway_dao.get_entry(data.entry_id) + if not entry or entry.user_telegram_id != data.user_telegram_id: + raise PermissionError("Giveaway entry does not belong to user") + async with self.uow: + updated = await self.giveaway_dao.update_phone(data.entry_id, data.phone) + if not updated: + raise ValueError("Giveaway entry not found") + await self.uow.commit() + masked = mask_giveaway_phone(data.phone) + logger.info(f"Saved masked giveaway phone '{masked}' for entry '{data.entry_id}'") + return updated + + +class SelectGiveawayWinner(Interactor[int, GiveawayEntryDto]): + required_permission = Permission.REMNASHOP_GIVEAWAY_EDITOR + + def __init__(self, uow: UnitOfWork, giveaway_dao: GiveawayDao) -> None: + self.uow = uow + self.giveaway_dao = giveaway_dao + + async def _execute(self, actor: UserDto, campaign_id: int) -> GiveawayEntryDto: + async with self.uow: + campaign = await self.giveaway_dao.get_campaign(campaign_id) + if not campaign: + raise ValueError("Giveaway campaign not found") + winners = await self.giveaway_dao.get_winners(campaign_id) + if len(winners) >= campaign.winner_count: + raise ValueError("All giveaway winners already selected") + winner = await self.giveaway_dao.select_winner( + campaign_id, + len(winners) + 1, + datetime_now(), + ) + if not winner: + raise ValueError("No eligible giveaway participants") + await self.uow.commit() + logger.info( + f"{actor.log} Selected giveaway winner entry='{winner.id}' " + f"campaign='{campaign_id}' rank='{winner.winner_rank}'" + ) + return winner diff --git a/src/application/use_cases/giveaway/queries.py b/src/application/use_cases/giveaway/queries.py new file mode 100644 index 00000000..ad05c4d0 --- /dev/null +++ b/src/application/use_cases/giveaway/queries.py @@ -0,0 +1,107 @@ +from src.application.common import Interactor +from src.application.common.dao import GiveawayDao, PlanDao +from src.application.common.policy import Permission +from src.application.dto import ClientGiveawayDto, GiveawayCampaignDto, UserDto +from src.core.enums import GiveawayCampaignStatus +from src.core.utils.time import datetime_now + + +def generate_giveaway_conditions( + campaign: GiveawayCampaignDto, + plan_name: str, +) -> str: + return ( + f"Название: {campaign.name}\n\n" + f"Сроки проведения: с {campaign.starts_at:%d.%m.%Y} " + f"до {campaign.ends_at:%d.%m.%Y}.\n\n" + f"Как участвовать: купите подписку «{plan_name}» " + f"на {campaign.eligible_duration_days} дней в Telegram-боте. " + "После успешной оплаты бот автоматически выдаст один уникальный " + "номер участника на одну покупку.\n\n" + f"Приз: {campaign.winner_count} победителей получат " + f"по {campaign.prize_amount} ₽.\n\n" + "После завершения акции победители случайно выбираются системой " + "среди допущенных участников. Повторная обработка оплаты не выдаёт " + "дополнительный код.\n\n" + "Телефон оставляется по желанию. Организатор может связаться с " + "победителем через Telegram или указанный телефон. При нарушении " + "условий участник может быть исключён." + ) + + +class ListClientGiveaways(Interactor[None, list[ClientGiveawayDto]]): + required_permission = Permission.PUBLIC + + def __init__(self, giveaway_dao: GiveawayDao, plan_dao: PlanDao) -> None: + self.giveaway_dao = giveaway_dao + self.plan_dao = plan_dao + + async def _execute(self, actor: UserDto, data: None) -> list[ClientGiveawayDto]: + campaigns = await self.giveaway_dao.get_client_campaigns( + datetime_now(), + actor.telegram_id, + ) + result: list[ClientGiveawayDto] = [] + for campaign in campaigns: + if campaign.id is None: + continue + plan = await self.plan_dao.get_by_id(campaign.eligible_plan_id) + entry = await self.giveaway_dao.get_entry_for_user( + campaign.id, + actor.telegram_id, + ) + result.append( + ClientGiveawayDto( + campaign=campaign, + plan_name=plan.name if plan else str(campaign.eligible_plan_id), + entry=entry, + ) + ) + return result + + +class GetClientGiveawayDetails(Interactor[int, ClientGiveawayDto]): + required_permission = Permission.PUBLIC + + def __init__(self, giveaway_dao: GiveawayDao, plan_dao: PlanDao) -> None: + self.giveaway_dao = giveaway_dao + self.plan_dao = plan_dao + + async def _execute(self, actor: UserDto, campaign_id: int) -> ClientGiveawayDto: + campaign = await self.giveaway_dao.get_campaign(campaign_id) + if not campaign: + raise ValueError("Giveaway campaign not found") + plan = await self.plan_dao.get_by_id(campaign.eligible_plan_id) + entry = await self.giveaway_dao.get_entry_for_user( + campaign_id, + actor.telegram_id, + ) + now = datetime_now() + is_active = ( + campaign.status == GiveawayCampaignStatus.ACTIVE + and campaign.starts_at <= now <= campaign.ends_at + ) + is_completed_participant = ( + campaign.status == GiveawayCampaignStatus.COMPLETED and entry is not None + ) + if not is_active and not is_completed_participant: + raise PermissionError("Giveaway campaign is not available to this user") + return ClientGiveawayDto( + campaign=campaign, + plan_name=plan.name if plan else str(campaign.eligible_plan_id), + entry=entry, + ) + + +class GetClientGiveawayConditions(Interactor[int, str]): + required_permission = Permission.PUBLIC + + def __init__(self, get_details: GetClientGiveawayDetails) -> None: + self.get_details = get_details + + async def _execute(self, actor: UserDto, campaign_id: int) -> str: + details = await self.get_details(actor, campaign_id) + campaign = details.campaign + if campaign.rules_text: + return campaign.rules_text + return generate_giveaway_conditions(campaign, details.plan_name) diff --git a/src/application/use_cases/promocode/__init__.py b/src/application/use_cases/promocode/__init__.py new file mode 100644 index 00000000..9593f33c --- /dev/null +++ b/src/application/use_cases/promocode/__init__.py @@ -0,0 +1,14 @@ +from typing import Final + +from src.application.common import Interactor + +from .commands.management import CreatePromocode, DeactivatePromocode, RecordPromocodeActivation +from .queries.validation import GetPromocode, ValidatePromocode + +PROMOCODE_USE_CASES: Final[tuple[type[Interactor], ...]] = ( + GetPromocode, + ValidatePromocode, + CreatePromocode, + DeactivatePromocode, + RecordPromocodeActivation, +) diff --git a/src/application/use_cases/promocode/commands/__init__.py b/src/application/use_cases/promocode/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/application/use_cases/promocode/commands/management.py b/src/application/use_cases/promocode/commands/management.py new file mode 100644 index 00000000..eced3e95 --- /dev/null +++ b/src/application/use_cases/promocode/commands/management.py @@ -0,0 +1,145 @@ +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from loguru import logger + +from src.application.common import Interactor +from src.application.common.dao import PromocodeDao +from src.application.common.policy import Permission +from src.application.common.uow import UnitOfWork +from src.application.dto import PromocodeActivationDto, PromocodeDto, UserDto +from src.core.enums import PromoAudience +from src.core.exceptions import ( + PromocodeInvalidDiscountError, + PromocodeInvalidMaxActivationsError, + PromocodeNotFoundError, +) + + +@dataclass(frozen=True) +class CreatePromocodeDto: + code: str + discount_percent: int + plan_id: int + audience: PromoAudience + max_activations: int + expires_at: datetime + + +class CreatePromocode(Interactor[CreatePromocodeDto, PromocodeDto]): + required_permission = Permission.REMNASHOP_PROMOCODE_EDITOR + + def __init__( + self, + uow: UnitOfWork, + promocode_dao: PromocodeDao, + ) -> None: + self.uow = uow + self.promocode_dao = promocode_dao + + async def _execute(self, actor: UserDto, data: CreatePromocodeDto) -> PromocodeDto: + if not (1 <= data.discount_percent <= 99): + logger.debug( + f"{actor.log} Invalid discount_percent '{data.discount_percent}' " + f"for promocode '{data.code}'" + ) + raise PromocodeInvalidDiscountError + + if data.max_activations < 1: + logger.debug( + f"{actor.log} Invalid max_activations '{data.max_activations}' " + f"for promocode '{data.code}'" + ) + raise PromocodeInvalidMaxActivationsError + + async with self.uow: + promocode = await self.promocode_dao.create( + PromocodeDto( + code=data.code, + discount_percent=data.discount_percent, + plan_id=data.plan_id, + audience=data.audience, + max_activations=data.max_activations, + expires_at=data.expires_at, + is_active=True, + ) + ) + await self.uow.commit() + + logger.info( + f"{actor.log} Created promocode '{data.code}' " + f"discount={data.discount_percent}% plan_id={data.plan_id}" + ) + return promocode + + +@dataclass(frozen=True) +class DeactivatePromocodeDto: + promocode_id: int + + +class DeactivatePromocode(Interactor[DeactivatePromocodeDto, None]): + required_permission = Permission.REMNASHOP_PROMOCODE_EDITOR + + def __init__( + self, + uow: UnitOfWork, + promocode_dao: PromocodeDao, + ) -> None: + self.uow = uow + self.promocode_dao = promocode_dao + + async def _execute(self, actor: UserDto, data: DeactivatePromocodeDto) -> None: + promocode = await self.promocode_dao.get_by_id(data.promocode_id) + + if not promocode: + logger.debug(f"{actor.log} Promocode id='{data.promocode_id}' not found") + raise PromocodeNotFoundError + + async with self.uow: + await self.promocode_dao.deactivate(data.promocode_id) + await self.uow.commit() + + logger.info(f"{actor.log} Deactivated promocode id='{data.promocode_id}'") + + +@dataclass(frozen=True) +class RecordPromocodeActivationDto: + promocode_id: int + user_telegram_id: int + transaction_payment_id: UUID + + +class RecordPromocodeActivation(Interactor[RecordPromocodeActivationDto, PromocodeActivationDto]): + required_permission = None + + def __init__( + self, + uow: UnitOfWork, + promocode_dao: PromocodeDao, + ) -> None: + self.uow = uow + self.promocode_dao = promocode_dao + + async def _execute( + self, + actor: UserDto, + data: RecordPromocodeActivationDto, + ) -> PromocodeActivationDto: + async with self.uow: + activation = await self.promocode_dao.record_activation( + PromocodeActivationDto( + promocode_id=data.promocode_id, + user_telegram_id=data.user_telegram_id, + transaction_payment_id=data.transaction_payment_id, + ) + ) + await self.uow.commit() + + logger.info( + f"{actor.log} Recorded promocode activation: " + f"promocode_id='{data.promocode_id}' user='{data.user_telegram_id}' " + f"payment='{data.transaction_payment_id}'" + ) + return activation diff --git a/src/application/use_cases/promocode/queries/__init__.py b/src/application/use_cases/promocode/queries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/application/use_cases/promocode/queries/validation.py b/src/application/use_cases/promocode/queries/validation.py new file mode 100644 index 00000000..288b4b5d --- /dev/null +++ b/src/application/use_cases/promocode/queries/validation.py @@ -0,0 +1,132 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from loguru import logger + +from src.application.common import Interactor +from src.application.common.dao import PromocodeDao, SubscriptionDao +from src.application.common.policy import Permission +from src.application.dto import PromocodeDto, UserDto +from src.core.enums import PromoAudience +from src.core.exceptions import ( + PromocodeAudienceMismatchError, + PromocodeAlreadyUsedError, + PromocodeExpiredError, + PromocodeInactiveError, + PromocodeLimitExceededError, + PromocodeNotFoundError, + PromocodePlanMismatchError, +) + + +@dataclass(frozen=True) +class GetPromocodeDto: + code: str + + +class GetPromocode(Interactor[GetPromocodeDto, PromocodeDto]): + required_permission = Permission.PUBLIC + + def __init__(self, promocode_dao: PromocodeDao) -> None: + self.promocode_dao = promocode_dao + + async def _execute(self, actor: UserDto, data: GetPromocodeDto) -> PromocodeDto: + promocode = await self.promocode_dao.get_by_code(data.code) + + if not promocode: + logger.debug(f"{actor.log} Promocode '{data.code}' not found") + raise PromocodeNotFoundError + + logger.debug(f"{actor.log} Promocode '{data.code}' retrieved (id={promocode.id})") + return promocode + + +@dataclass(frozen=True) +class ValidatePromocodeDto: + code: str + plan_id: int + user_telegram_id: int + + +class ValidatePromocode(Interactor[ValidatePromocodeDto, PromocodeDto]): + required_permission = Permission.PUBLIC + + def __init__( + self, + promocode_dao: PromocodeDao, + subscription_dao: SubscriptionDao, + ) -> None: + self.promocode_dao = promocode_dao + self.subscription_dao = subscription_dao + + async def _execute(self, actor: UserDto, data: ValidatePromocodeDto) -> PromocodeDto: + promocode = await self.promocode_dao.get_by_code(data.code) + + if not promocode: + logger.debug(f"{actor.log} Promocode '{data.code}' not found") + raise PromocodeNotFoundError + + if promocode.id is None: + raise PromocodeNotFoundError + + promocode_id: int = promocode.id + + if not promocode.is_active: + logger.debug(f"{actor.log} Promocode '{data.code}' is inactive") + raise PromocodeInactiveError + + now = datetime.now(tz=timezone.utc) + if promocode.expires_at.replace(tzinfo=timezone.utc) < now: + logger.debug(f"{actor.log} Promocode '{data.code}' has expired") + raise PromocodeExpiredError + + if promocode.plan_id != data.plan_id: + logger.debug( + f"{actor.log} Promocode '{data.code}' is bound to plan_id='{promocode.plan_id}', " + f"got plan_id='{data.plan_id}'" + ) + raise PromocodePlanMismatchError + + activation_count = await self.promocode_dao.count_activations(promocode_id) + if activation_count >= promocode.max_activations: + logger.debug( + f"{actor.log} Promocode '{data.code}' has reached max activations " + f"({activation_count}/{promocode.max_activations})" + ) + raise PromocodeLimitExceededError + + already_used = await self.promocode_dao.has_user_activated( + promocode_id, + data.user_telegram_id, + ) + if already_used: + logger.debug( + f"{actor.log} User '{data.user_telegram_id}' already used " + f"promocode '{data.code}'" + ) + raise PromocodeAlreadyUsedError + + if promocode.audience != PromoAudience.ALL: + subscription = await self.subscription_dao.get_current(data.user_telegram_id) + has_subscription = subscription is not None + + if promocode.audience == PromoAudience.WITH_ACTIVE_SUBSCRIPTION and not has_subscription: + logger.debug( + f"{actor.log} Promocode '{data.code}' requires active subscription, " + f"user '{data.user_telegram_id}' has none" + ) + raise PromocodeAudienceMismatchError + + if promocode.audience == PromoAudience.WITHOUT_ACTIVE_SUBSCRIPTION and has_subscription: + logger.debug( + f"{actor.log} Promocode '{data.code}' requires no active subscription, " + f"user '{data.user_telegram_id}' has one" + ) + raise PromocodeAudienceMismatchError + + logger.info( + f"{actor.log} Promocode '{data.code}' validated successfully " + f"for user '{data.user_telegram_id}'" + ) + return promocode diff --git a/src/application/use_cases/remnawave/commands/management.py b/src/application/use_cases/remnawave/commands/management.py index 8a68d6f7..19307709 100644 --- a/src/application/use_cases/remnawave/commands/management.py +++ b/src/application/use_cases/remnawave/commands/management.py @@ -2,6 +2,7 @@ from loguru import logger from remnapy import RemnawaveSDK +from remnapy.exceptions import AuthenticationError, ForbiddenError, NotFoundError from remnapy.models import DeleteUserAllHwidDeviceRequestDto from src.application.common import Interactor @@ -9,6 +10,7 @@ from src.application.common.policy import Permission from src.application.common.remnawave import Remnawave from src.application.dto import UserDto +from src.core.exceptions import RemnawaveDevicesUnavailableError @dataclass(frozen=True) @@ -54,9 +56,28 @@ async def _execute(self, actor: UserDto, data: None) -> None: f"User '{actor.telegram_id}' has no active subscription or device limit unlimited" ) - result = await self.remnawave_sdk.hwid.delete_all_hwid_user( - DeleteUserAllHwidDeviceRequestDto(user_uuid=current_subscription.user_remna_id) - ) + try: + result = await self.remnawave_sdk.hwid.delete_all_hwid_user( + DeleteUserAllHwidDeviceRequestDto(user_uuid=current_subscription.user_remna_id) + ) + except AuthenticationError as e: + logger.error( + "Remnawave rejected deletion of all HWID devices: " + "API authentication failed (HTTP 401)" + ) + raise RemnawaveDevicesUnavailableError from e + except ForbiddenError as e: + logger.error( + "Remnawave rejected deletion of all HWID devices: " + "access forbidden (HTTP 403); check API token and proxy permissions" + ) + raise RemnawaveDevicesUnavailableError from e + except NotFoundError as e: + logger.warning( + f"Remnawave user '{current_subscription.user_remna_id}' was not found " + "while deleting all HWID devices (HTTP 404)" + ) + raise RemnawaveDevicesUnavailableError from e logger.info(f"{actor.log} Deleted all devices ({result.total})") diff --git a/src/application/use_cases/user/queries/profile.py b/src/application/use_cases/user/queries/profile.py index c86b3c3e..82004537 100644 --- a/src/application/use_cases/user/queries/profile.py +++ b/src/application/use_cases/user/queries/profile.py @@ -11,6 +11,7 @@ from src.application.common.policy import Permission from src.application.dto import SubscriptionDto, UserDto from src.core.config import AppConfig +from src.core.exceptions import RemnawaveDevicesUnavailableError from src.core.types import RemnaUserDto @@ -138,6 +139,7 @@ class GetUserDevicesResultDto: current_count: int max_count: int subscription: SubscriptionDto + devices_unavailable: bool = False class GetUserDevices(Interactor[int, GetUserDevicesResultDto]): @@ -162,13 +164,24 @@ async def _execute(self, actor: UserDto, telegram_id: int) -> GetUserDevicesResu if not subscription: raise ValueError(f"Subscription for '{telegram_id}' not found") - devices = await self.remnawave.get_devices(subscription.user_remna_id) + devices_unavailable = False + try: + devices = await self.remnawave.get_devices(subscription.user_remna_id) + except RemnawaveDevicesUnavailableError: + logger.warning( + f"{actor.log} Could not retrieve devices for user '{telegram_id}', " + f"Remnawave user '{subscription.user_remna_id}' is unavailable" + ) + devices = [] + devices_unavailable = True - logger.info(f"{actor.log} Retrieved '{len(devices)}' devices for user '{telegram_id}'") + if not devices_unavailable: + logger.info(f"{actor.log} Retrieved '{len(devices)}' devices for user '{telegram_id}'") return GetUserDevicesResultDto( devices=devices, current_count=len(devices), max_count=subscription.device_limit, subscription=subscription, + devices_unavailable=devices_unavailable, ) diff --git a/src/core/enums.py b/src/core/enums.py index 70bce16a..13fe52bc 100644 --- a/src/core/enums.py +++ b/src/core/enums.py @@ -78,6 +78,31 @@ class PlanAvailability(UpperStrEnum): LINK = auto() +class PromoAudience(UpperStrEnum): + ALL = auto() + WITH_ACTIVE_SUBSCRIPTION = auto() + WITHOUT_ACTIVE_SUBSCRIPTION = auto() + + +class GiveawayCampaignStatus(UpperStrEnum): + DRAFT = auto() + ACTIVE = auto() + COMPLETED = auto() + ARCHIVED = auto() + + +class GiveawayEntryStatus(UpperStrEnum): + ELIGIBLE = auto() + WINNER = auto() + ARCHIVED = auto() + EXCLUDED = auto() + + +class GiveawayEntrySource(UpperStrEnum): + AUTO_PURCHASE = auto() + MANUAL = auto() + + class PaymentGatewayType(UpperStrEnum): TELEGRAM_STARS = auto() YOOKASSA = auto() diff --git a/src/core/exceptions.py b/src/core/exceptions.py index 066b4222..e2abbad4 100644 --- a/src/core/exceptions.py +++ b/src/core/exceptions.py @@ -4,6 +4,9 @@ class MenuRenderError(Exception): ... +class RemnawaveDevicesUnavailableError(Exception): ... + + class PermissionDeniedError(Exception): ... @@ -51,3 +54,33 @@ class TrialError(Exception): ... class MenuEditorInvalidPayloadError(Exception): ... + + +class PromocodeError(Exception): ... + + +class PromocodeNotFoundError(PromocodeError): ... + + +class PromocodeInactiveError(PromocodeError): ... + + +class PromocodeExpiredError(PromocodeError): ... + + +class PromocodeLimitExceededError(PromocodeError): ... + + +class PromocodeAlreadyUsedError(PromocodeError): ... + + +class PromocodePlanMismatchError(PromocodeError): ... + + +class PromocodeAudienceMismatchError(PromocodeError): ... + + +class PromocodeInvalidDiscountError(PromocodeError): ... + + +class PromocodeInvalidMaxActivationsError(PromocodeError): ... diff --git a/src/infrastructure/database/dao/__init__.py b/src/infrastructure/database/dao/__init__.py index df7529ba..0dac5df2 100644 --- a/src/infrastructure/database/dao/__init__.py +++ b/src/infrastructure/database/dao/__init__.py @@ -1,6 +1,8 @@ from .broadcast import BroadcastDaoImpl +from .giveaway import GiveawayDaoImpl from .payment_gateway import PaymentGatewayDaoImpl from .plan import PlanDaoImpl +from .promocode import PromocodeDaoImpl from .referral import ReferralDaoImpl from .settings import SettingsDaoImpl from .subscription import SubscriptionDaoImpl @@ -11,8 +13,10 @@ __all__ = [ "BroadcastDaoImpl", + "GiveawayDaoImpl", "PaymentGatewayDaoImpl", "PlanDaoImpl", + "PromocodeDaoImpl", "ReferralDaoImpl", "SettingsDaoImpl", "SubscriptionDaoImpl", diff --git a/src/infrastructure/database/dao/giveaway.py b/src/infrastructure/database/dao/giveaway.py new file mode 100644 index 00000000..51c57799 --- /dev/null +++ b/src/infrastructure/database/dao/giveaway.py @@ -0,0 +1,345 @@ +from datetime import datetime +from typing import Optional, cast +from uuid import UUID + +from sqlalchemy import and_, delete, exists, func, or_, select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from src.application.common.dao import GiveawayDao +from src.application.dto import GiveawayCampaignDto, GiveawayEntryDto +from src.core.enums import GiveawayCampaignStatus, GiveawayEntryStatus, PurchaseType +from src.infrastructure.database.models import GiveawayCampaign, GiveawayEntry + + +class GiveawayDaoImpl(GiveawayDao): + def __init__(self, session: AsyncSession) -> None: + self.session = session + + @staticmethod + def _campaign_dto(model: GiveawayCampaign) -> GiveawayCampaignDto: + return GiveawayCampaignDto( + id=model.id, + name=model.name, + status=model.status, + starts_at=model.starts_at, + ends_at=model.ends_at, + winner_count=model.winner_count, + prize_amount=model.prize_amount, + eligible_plan_id=model.eligible_plan_id, + eligible_duration_days=model.eligible_duration_days, + eligible_purchase_types=[ + PurchaseType(value) for value in model.eligible_purchase_types + ], + code_prefix=model.code_prefix, + rules_text=model.rules_text, + completed_at=model.completed_at, + archived_at=model.archived_at, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + @staticmethod + def _entry_dto(model: GiveawayEntry) -> GiveawayEntryDto: + return GiveawayEntryDto( + id=model.id, + campaign_id=model.campaign_id, + user_telegram_id=model.user_telegram_id, + telegram_username=model.telegram_username, + participant_code=model.participant_code, + transaction_payment_id=model.transaction_payment_id, + plan_id=model.plan_id, + plan_name=model.plan_name, + duration_days=model.duration_days, + purchase_type=model.purchase_type, + entry_source=model.entry_source, + phone=model.phone, + status=model.status, + winner_rank=model.winner_rank, + selected_at=model.selected_at, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + async def create_campaign(self, campaign: GiveawayCampaignDto) -> GiveawayCampaignDto: + model = GiveawayCampaign( + name=campaign.name, + status=campaign.status, + starts_at=campaign.starts_at, + ends_at=campaign.ends_at, + winner_count=campaign.winner_count, + prize_amount=campaign.prize_amount, + eligible_plan_id=campaign.eligible_plan_id, + eligible_duration_days=campaign.eligible_duration_days, + eligible_purchase_types=[item.value for item in campaign.eligible_purchase_types], + code_prefix=campaign.code_prefix, + rules_text=campaign.rules_text, + completed_at=campaign.completed_at, + archived_at=campaign.archived_at, + ) + self.session.add(model) + await self.session.flush() + await self.session.refresh(model) + return self._campaign_dto(model) + + async def get_campaign(self, campaign_id: int) -> Optional[GiveawayCampaignDto]: + model = await self.session.scalar( + select(GiveawayCampaign).where(GiveawayCampaign.id == campaign_id) + ) + return self._campaign_dto(model) if model else None + + async def get_campaigns(self) -> list[GiveawayCampaignDto]: + result = await self.session.scalars( + select(GiveawayCampaign).order_by(GiveawayCampaign.created_at.desc()) + ) + return [self._campaign_dto(item) for item in cast(list[GiveawayCampaign], result.all())] + + async def get_client_campaigns( + self, + now: datetime, + user_telegram_id: int, + ) -> list[GiveawayCampaignDto]: + has_entry = exists( + select(GiveawayEntry.id).where( + GiveawayEntry.campaign_id == GiveawayCampaign.id, + GiveawayEntry.user_telegram_id == user_telegram_id, + ) + ) + result = await self.session.scalars( + select(GiveawayCampaign) + .where( + or_( + and_( + GiveawayCampaign.status == GiveawayCampaignStatus.ACTIVE, + GiveawayCampaign.starts_at <= now, + GiveawayCampaign.ends_at >= now, + ), + and_( + GiveawayCampaign.status == GiveawayCampaignStatus.COMPLETED, + has_entry, + ), + ) + ) + .order_by(GiveawayCampaign.ends_at.desc()) + ) + return [self._campaign_dto(item) for item in cast(list[GiveawayCampaign], result.all())] + + async def get_matching_campaigns( + self, + now: datetime, + plan_id: int, + duration_days: int, + purchase_type: PurchaseType, + ) -> list[GiveawayCampaignDto]: + stmt = select(GiveawayCampaign).where( + GiveawayCampaign.status == GiveawayCampaignStatus.ACTIVE, + GiveawayCampaign.starts_at <= now, + GiveawayCampaign.ends_at >= now, + GiveawayCampaign.eligible_plan_id == plan_id, + GiveawayCampaign.eligible_duration_days == duration_days, + GiveawayCampaign.eligible_purchase_types.contains([purchase_type.value]), + ) + result = await self.session.scalars(stmt) + return [self._campaign_dto(item) for item in cast(list[GiveawayCampaign], result.all())] + + async def set_campaign_status( + self, + campaign_id: int, + status: GiveawayCampaignStatus, + now: datetime, + ) -> Optional[GiveawayCampaignDto]: + values: dict = {"status": status} + if status == GiveawayCampaignStatus.COMPLETED: + values["completed_at"] = now + model = await self.session.scalar( + update(GiveawayCampaign) + .where(GiveawayCampaign.id == campaign_id) + .values(**values) + .returning(GiveawayCampaign) + ) + if model: + await self.session.refresh(model) + return self._campaign_dto(model) if model else None + + async def archive_campaign( + self, + campaign_id: int, + now: datetime, + ) -> Optional[GiveawayCampaignDto]: + await self.session.execute( + update(GiveawayEntry) + .where(GiveawayEntry.campaign_id == campaign_id) + .values(status=GiveawayEntryStatus.ARCHIVED) + ) + model = await self.session.scalar( + update(GiveawayCampaign) + .where(GiveawayCampaign.id == campaign_id) + .values(status=GiveawayCampaignStatus.ARCHIVED, archived_at=now) + .returning(GiveawayCampaign) + ) + if model: + await self.session.refresh(model) + return self._campaign_dto(model) if model else None + + async def delete_campaign(self, campaign_id: int) -> bool: + await self.session.execute( + delete(GiveawayEntry).where(GiveawayEntry.campaign_id == campaign_id) + ) + result = await self.session.execute( + delete(GiveawayCampaign).where(GiveawayCampaign.id == campaign_id) + ) + return bool(result.rowcount) + + async def update_campaign_rules( + self, + campaign_id: int, + rules_text: Optional[str], + ) -> Optional[GiveawayCampaignDto]: + model = await self.session.scalar( + update(GiveawayCampaign) + .where(GiveawayCampaign.id == campaign_id) + .values(rules_text=rules_text) + .returning(GiveawayCampaign) + ) + if model: + await self.session.refresh(model) + return self._campaign_dto(model) if model else None + + async def create_entry(self, entry: GiveawayEntryDto) -> Optional[GiveawayEntryDto]: + values = { + "campaign_id": entry.campaign_id, + "user_telegram_id": entry.user_telegram_id, + "telegram_username": entry.telegram_username, + "participant_code": entry.participant_code, + "transaction_payment_id": entry.transaction_payment_id, + "plan_id": entry.plan_id, + "plan_name": entry.plan_name, + "duration_days": entry.duration_days, + "purchase_type": entry.purchase_type, + "entry_source": entry.entry_source, + "phone": entry.phone, + "status": entry.status, + } + model = await self.session.scalar( + insert(GiveawayEntry) + .values(**values) + .on_conflict_do_nothing() + .returning(GiveawayEntry) + ) + if model: + await self.session.refresh(model) + return self._entry_dto(model) if model else None + + async def add_manual_entry( + self, + entry: GiveawayEntryDto, + ) -> Optional[GiveawayEntryDto]: + return await self.create_entry(entry) + + async def get_entry_by_payment(self, payment_id: UUID) -> Optional[GiveawayEntryDto]: + model = await self.session.scalar( + select(GiveawayEntry).where(GiveawayEntry.transaction_payment_id == payment_id) + ) + return self._entry_dto(model) if model else None + + async def get_entry(self, entry_id: int) -> Optional[GiveawayEntryDto]: + model = await self.session.scalar( + select(GiveawayEntry).where(GiveawayEntry.id == entry_id) + ) + return self._entry_dto(model) if model else None + + async def get_entry_for_user( + self, + campaign_id: int, + user_telegram_id: int, + ) -> Optional[GiveawayEntryDto]: + model = await self.session.scalar( + select(GiveawayEntry) + .where( + GiveawayEntry.campaign_id == campaign_id, + GiveawayEntry.user_telegram_id == user_telegram_id, + ) + .order_by(GiveawayEntry.created_at.desc()) + .limit(1) + ) + return self._entry_dto(model) if model else None + + async def update_phone(self, entry_id: int, phone: str) -> Optional[GiveawayEntryDto]: + model = await self.session.scalar( + update(GiveawayEntry) + .where(GiveawayEntry.id == entry_id) + .values(phone=phone) + .returning(GiveawayEntry) + ) + if model: + await self.session.refresh(model) + return self._entry_dto(model) if model else None + + async def get_entries(self, campaign_id: int) -> list[GiveawayEntryDto]: + result = await self.session.scalars( + select(GiveawayEntry) + .where( + GiveawayEntry.campaign_id == campaign_id, + GiveawayEntry.status != GiveawayEntryStatus.ARCHIVED, + ) + .order_by(GiveawayEntry.created_at.desc()) + ) + return [self._entry_dto(item) for item in cast(list[GiveawayEntry], result.all())] + + async def get_winners(self, campaign_id: int) -> list[GiveawayEntryDto]: + result = await self.session.scalars( + select(GiveawayEntry) + .where( + GiveawayEntry.campaign_id == campaign_id, + GiveawayEntry.status == GiveawayEntryStatus.WINNER, + ) + .order_by(GiveawayEntry.winner_rank) + ) + return [self._entry_dto(item) for item in cast(list[GiveawayEntry], result.all())] + + async def count_entries(self, campaign_id: int) -> int: + return int( + await self.session.scalar( + select(func.count()) + .select_from(GiveawayEntry) + .where( + GiveawayEntry.campaign_id == campaign_id, + GiveawayEntry.status != GiveawayEntryStatus.ARCHIVED, + ) + ) + or 0 + ) + + async def select_winner( + self, + campaign_id: int, + winner_rank: int, + selected_at: datetime, + ) -> Optional[GiveawayEntryDto]: + winning_users = select(GiveawayEntry.user_telegram_id).where( + GiveawayEntry.campaign_id == campaign_id, + GiveawayEntry.status == GiveawayEntryStatus.WINNER, + GiveawayEntry.user_telegram_id.is_not(None), + ) + candidate = await self.session.scalar( + select(GiveawayEntry) + .where( + GiveawayEntry.campaign_id == campaign_id, + GiveawayEntry.status == GiveawayEntryStatus.ELIGIBLE, + or_( + GiveawayEntry.user_telegram_id.is_(None), + GiveawayEntry.user_telegram_id.not_in(winning_users), + ), + ) + .order_by(func.random()) + .with_for_update(skip_locked=True) + .limit(1) + ) + if not candidate: + return None + candidate.status = GiveawayEntryStatus.WINNER + candidate.winner_rank = winner_rank + candidate.selected_at = selected_at + await self.session.flush() + await self.session.refresh(candidate) + return self._entry_dto(candidate) diff --git a/src/infrastructure/database/dao/promocode.py b/src/infrastructure/database/dao/promocode.py new file mode 100644 index 00000000..cdf2d1a8 --- /dev/null +++ b/src/infrastructure/database/dao/promocode.py @@ -0,0 +1,148 @@ +from typing import Optional, cast + +from adaptix import Retort +from adaptix.conversion import ConversionRetort +from loguru import logger +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.application.common.dao import PromocodeDao +from src.application.dto import PromocodeActivationDto, PromocodeDto +from src.infrastructure.database.models import Promocode, PromocodeActivation + + +class PromocodeDaoImpl(PromocodeDao): + def __init__( + self, + session: AsyncSession, + retort: Retort, + conversion_retort: ConversionRetort, + ) -> None: + self.session = session + self.retort = retort + self.conversion_retort = conversion_retort + + self._to_activation_dto = self.conversion_retort.get_converter( + PromocodeActivation, PromocodeActivationDto + ) + + @staticmethod + def _to_promocode_dto(model: Promocode) -> PromocodeDto: + return PromocodeDto( + id=model.id, + code=model.code, + discount_percent=model.discount_percent, + plan_id=model.plan_id, + audience=model.audience, + max_activations=model.max_activations, + expires_at=model.expires_at, + is_active=model.is_active, + created_at=model.created_at, + updated_at=model.updated_at, + plan=None, + ) + + async def create(self, promocode: PromocodeDto) -> PromocodeDto: + db_obj = Promocode( + code=promocode.code, + discount_percent=promocode.discount_percent, + plan_id=promocode.plan_id, + audience=promocode.audience, + max_activations=promocode.max_activations, + expires_at=promocode.expires_at, + is_active=promocode.is_active, + ) + self.session.add(db_obj) + await self.session.flush() + await self.session.refresh(db_obj, attribute_names=["plan"]) + + logger.debug(f"Created promocode '{db_obj.code}' (id={db_obj.id})") + return self._to_promocode_dto(db_obj) + + async def get_by_id(self, promocode_id: int) -> Optional[PromocodeDto]: + stmt = select(Promocode).where(Promocode.id == promocode_id) + db_obj = await self.session.scalar(stmt) + + if db_obj: + logger.debug(f"Promocode id='{promocode_id}' found") + return self._to_promocode_dto(db_obj) + + logger.debug(f"Promocode id='{promocode_id}' not found") + return None + + async def get_by_code(self, code: str) -> Optional[PromocodeDto]: + stmt = select(Promocode).where(Promocode.code == code) + db_obj = await self.session.scalar(stmt) + + if db_obj: + logger.debug(f"Promocode '{code}' found") + return self._to_promocode_dto(db_obj) + + logger.debug(f"Promocode '{code}' not found") + return None + + async def deactivate(self, promocode_id: int) -> None: + stmt = ( + update(Promocode) + .where(Promocode.id == promocode_id) + .values(is_active=False) + ) + await self.session.execute(stmt) + logger.debug(f"Promocode id='{promocode_id}' deactivated") + + async def count_activations(self, promocode_id: int) -> int: + stmt = ( + select(func.count()) + .select_from(PromocodeActivation) + .where(PromocodeActivation.promocode_id == promocode_id) + ) + count = await self.session.scalar(stmt) or 0 + logger.debug(f"Promocode id='{promocode_id}' has '{count}' activations") + return int(count) + + async def has_user_activated(self, promocode_id: int, user_telegram_id: int) -> bool: + stmt = ( + select(func.count()) + .select_from(PromocodeActivation) + .where( + PromocodeActivation.promocode_id == promocode_id, + PromocodeActivation.user_telegram_id == user_telegram_id, + ) + ) + count = await self.session.scalar(stmt) or 0 + return count > 0 + + async def record_activation( + self, + activation: PromocodeActivationDto, + ) -> PromocodeActivationDto: + db_obj = PromocodeActivation( + promocode_id=activation.promocode_id, + user_telegram_id=activation.user_telegram_id, + transaction_payment_id=activation.transaction_payment_id, + ) + self.session.add(db_obj) + await self.session.flush() + + logger.debug( + f"Recorded activation: promocode_id='{activation.promocode_id}' " + f"user='{activation.user_telegram_id}'" + ) + return self._to_activation_dto(db_obj) + + async def get_all( + self, + limit: int = 100, + offset: int = 0, + ) -> list[PromocodeDto]: + stmt = ( + select(Promocode) + .order_by(Promocode.created_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await self.session.scalars(stmt) + db_list = cast(list, result.all()) + + logger.debug(f"Retrieved '{len(db_list)}' promocodes") + return [self._to_promocode_dto(item) for item in db_list] diff --git a/src/infrastructure/database/dao/transaction.py b/src/infrastructure/database/dao/transaction.py index b0704800..4e44aff9 100644 --- a/src/infrastructure/database/dao/transaction.py +++ b/src/infrastructure/database/dao/transaction.py @@ -57,6 +57,18 @@ async def get_by_payment_id(self, payment_id: UUID) -> Optional[TransactionDto]: logger.debug(f"Transaction '{payment_id}' not found") return None + async def get_by_payment_id_for_update( + self, + payment_id: UUID, + ) -> Optional[TransactionDto]: + stmt = ( + select(Transaction) + .where(Transaction.payment_id == payment_id) + .with_for_update() + ) + db_transaction = await self.session.scalar(stmt) + return self._convert_to_dto(db_transaction) if db_transaction else None + async def get_by_user(self, telegram_id: int) -> list[TransactionDto]: stmt = ( select(Transaction) diff --git a/src/infrastructure/database/migrations/versions/0021_create_promocodes.py b/src/infrastructure/database/migrations/versions/0021_create_promocodes.py new file mode 100644 index 00000000..7c90fa5d --- /dev/null +++ b/src/infrastructure/database/migrations/versions/0021_create_promocodes.py @@ -0,0 +1,83 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0021" +down_revision: Union[str, None] = "0020" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + promo_audience = postgresql.ENUM( + "ALL", + "WITH_ACTIVE_SUBSCRIPTION", + name="promo_audience", + create_type=False, + ) + promo_audience.create(op.get_bind(), checkfirst=True) + + op.create_table( + "promocodes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("discount_percent", sa.Integer(), nullable=False), + sa.Column("plan_id", sa.Integer(), nullable=False), + sa.Column("audience", promo_audience, nullable=False), + sa.Column("max_activations", sa.Integer(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('UTC', now())"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('UTC', now())"), nullable=False), + sa.ForeignKeyConstraint(["plan_id"], ["plans.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_promocodes_code"), "promocodes", ["code"], unique=True) + op.create_index(op.f("ix_promocodes_plan_id"), "promocodes", ["plan_id"], unique=False) + + op.create_table( + "promocode_activations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("promocode_id", sa.Integer(), nullable=False), + sa.Column("user_telegram_id", sa.BigInteger(), nullable=False), + sa.Column("transaction_payment_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("activated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('UTC', now())"), nullable=False), + sa.ForeignKeyConstraint(["promocode_id"], ["promocodes.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_telegram_id"], ["users.telegram_id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("promocode_id", "user_telegram_id", name="uq_promo_activation_user"), + ) + op.create_index(op.f("ix_promocode_activations_promocode_id"), "promocode_activations", ["promocode_id"], unique=False) + op.create_index(op.f("ix_promocode_activations_user_telegram_id"), "promocode_activations", ["user_telegram_id"], unique=False) + + op.add_column( + "transactions", + sa.Column("promocode_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_transactions_promocode_id", + "transactions", + "promocodes", + ["promocode_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index("ix_transactions_promocode_id", "transactions", ["promocode_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_transactions_promocode_id", table_name="transactions") + op.drop_constraint("fk_transactions_promocode_id", "transactions", type_="foreignkey") + op.drop_column("transactions", "promocode_id") + + op.drop_index(op.f("ix_promocode_activations_user_telegram_id"), table_name="promocode_activations") + op.drop_index(op.f("ix_promocode_activations_promocode_id"), table_name="promocode_activations") + op.drop_table("promocode_activations") + + op.drop_index(op.f("ix_promocodes_plan_id"), table_name="promocodes") + op.drop_index(op.f("ix_promocodes_code"), table_name="promocodes") + op.drop_table("promocodes") + + op.execute("DROP TYPE IF EXISTS promo_audience") diff --git a/src/infrastructure/database/migrations/versions/0022_add_without_active_subscription_audience.py b/src/infrastructure/database/migrations/versions/0022_add_without_active_subscription_audience.py new file mode 100644 index 00000000..4082f030 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/0022_add_without_active_subscription_audience.py @@ -0,0 +1,18 @@ +from typing import Sequence, Union + +from alembic import op + +revision: str = "0022" +down_revision: Union[str, None] = "0021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TYPE promo_audience ADD VALUE IF NOT EXISTS 'WITHOUT_ACTIVE_SUBSCRIPTION'") + + +def downgrade() -> None: + # Removing a value from a PostgreSQL enum requires recreating the type, + # which is unsafe and out of scope for a simple rollback. + pass diff --git a/src/infrastructure/database/migrations/versions/0023_create_giveaways.py b/src/infrastructure/database/migrations/versions/0023_create_giveaways.py new file mode 100644 index 00000000..47e8a805 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/0023_create_giveaways.py @@ -0,0 +1,208 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0023" +down_revision: Union[str, None] = "0022" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + campaign_status = postgresql.ENUM( + "DRAFT", + "ACTIVE", + "COMPLETED", + "ARCHIVED", + name="giveaway_campaign_status", + create_type=False, + ) + entry_status = postgresql.ENUM( + "ELIGIBLE", + "WINNER", + "ARCHIVED", + "EXCLUDED", + name="giveaway_entry_status", + create_type=False, + ) + purchase_type = postgresql.ENUM( + "NEW", + "RENEW", + "CHANGE", + name="purchase_type", + create_type=False, + ) + campaign_status.create(op.get_bind(), checkfirst=True) + entry_status.create(op.get_bind(), checkfirst=True) + + op.create_table( + "giveaway_campaigns", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("status", campaign_status, nullable=False), + sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ends_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("winner_count", sa.Integer(), nullable=False), + sa.Column("prize_amount", sa.Numeric(12, 2), nullable=False), + sa.Column("eligible_plan_id", sa.Integer(), nullable=False), + sa.Column("eligible_duration_days", sa.Integer(), nullable=False), + sa.Column("eligible_purchase_types", postgresql.JSONB(), nullable=False), + sa.Column("code_prefix", sa.String(length=8), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("timezone('UTC', now())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("timezone('UTC', now())"), + nullable=False, + ), + sa.CheckConstraint( + "ends_at > starts_at", + name="ck_giveaway_campaign_period", + ), + sa.CheckConstraint( + "prize_amount >= 0", + name="ck_giveaway_campaign_prize_amount", + ), + sa.CheckConstraint( + "winner_count > 0", + name="ck_giveaway_campaign_winner_count", + ), + sa.ForeignKeyConstraint( + ["eligible_plan_id"], + ["plans.id"], + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_giveaway_campaigns_status", + "giveaway_campaigns", + ["status"], + ) + op.create_index( + "ix_giveaway_campaigns_starts_at", + "giveaway_campaigns", + ["starts_at"], + ) + op.create_index( + "ix_giveaway_campaigns_ends_at", + "giveaway_campaigns", + ["ends_at"], + ) + op.create_index( + "ix_giveaway_campaigns_eligible_plan_id", + "giveaway_campaigns", + ["eligible_plan_id"], + ) + + op.create_table( + "giveaway_entries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("campaign_id", sa.Integer(), nullable=False), + sa.Column("user_telegram_id", sa.BigInteger(), nullable=False), + sa.Column("telegram_username", sa.String(length=32), nullable=True), + sa.Column("participant_code", sa.String(length=32), nullable=False), + sa.Column("transaction_payment_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("plan_id", sa.Integer(), nullable=False), + sa.Column("plan_name", sa.String(length=128), nullable=False), + sa.Column("duration_days", sa.Integer(), nullable=False), + sa.Column("purchase_type", purchase_type, nullable=False), + sa.Column("phone", sa.String(length=15), nullable=True), + sa.Column("status", entry_status, nullable=False), + sa.Column("winner_rank", sa.Integer(), nullable=True), + sa.Column("selected_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("timezone('UTC', now())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("timezone('UTC', now())"), + nullable=False, + ), + sa.CheckConstraint( + "winner_rank IS NULL OR winner_rank > 0", + name="ck_giveaway_entry_winner_rank", + ), + sa.ForeignKeyConstraint( + ["campaign_id"], + ["giveaway_campaigns.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_telegram_id"], + ["users.telegram_id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "campaign_id", + "participant_code", + name="uq_giveaway_entry_campaign_code", + ), + sa.UniqueConstraint( + "campaign_id", + "winner_rank", + name="uq_giveaway_entry_campaign_winner_rank", + ), + sa.UniqueConstraint( + "transaction_payment_id", + name="uq_giveaway_entry_transaction_payment", + ), + ) + op.create_index( + "ix_giveaway_entries_campaign_id", + "giveaway_entries", + ["campaign_id"], + ) + op.create_index( + "ix_giveaway_entries_user_telegram_id", + "giveaway_entries", + ["user_telegram_id"], + ) + op.create_index( + "ix_giveaway_entries_status", + "giveaway_entries", + ["status"], + ) + op.create_index( + "ix_giveaway_entries_transaction_payment_id", + "giveaway_entries", + ["transaction_payment_id"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_giveaway_entries_transaction_payment_id", + table_name="giveaway_entries", + ) + op.drop_index("ix_giveaway_entries_status", table_name="giveaway_entries") + op.drop_index("ix_giveaway_entries_user_telegram_id", table_name="giveaway_entries") + op.drop_index("ix_giveaway_entries_campaign_id", table_name="giveaway_entries") + op.drop_table("giveaway_entries") + + op.drop_index( + "ix_giveaway_campaigns_eligible_plan_id", + table_name="giveaway_campaigns", + ) + op.drop_index("ix_giveaway_campaigns_ends_at", table_name="giveaway_campaigns") + op.drop_index("ix_giveaway_campaigns_starts_at", table_name="giveaway_campaigns") + op.drop_index("ix_giveaway_campaigns_status", table_name="giveaway_campaigns") + op.drop_table("giveaway_campaigns") + + op.execute("DROP TYPE IF EXISTS giveaway_entry_status") + op.execute("DROP TYPE IF EXISTS giveaway_campaign_status") diff --git a/src/infrastructure/database/migrations/versions/0024_add_giveaway_rules_text.py b/src/infrastructure/database/migrations/versions/0024_add_giveaway_rules_text.py new file mode 100644 index 00000000..14c3df9a --- /dev/null +++ b/src/infrastructure/database/migrations/versions/0024_add_giveaway_rules_text.py @@ -0,0 +1,20 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0024" +down_revision: Union[str, None] = "0023" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "giveaway_campaigns", + sa.Column("rules_text", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("giveaway_campaigns", "rules_text") diff --git a/src/infrastructure/database/migrations/versions/0025_add_manual_giveaway_entries.py b/src/infrastructure/database/migrations/versions/0025_add_manual_giveaway_entries.py new file mode 100644 index 00000000..4586dfc1 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/0025_add_manual_giveaway_entries.py @@ -0,0 +1,105 @@ +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0025" +down_revision: Union[str, None] = "0024" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + entry_source = postgresql.ENUM( + "AUTO_PURCHASE", + "MANUAL", + name="giveaway_entry_source", + create_type=False, + ) + entry_source.create(op.get_bind(), checkfirst=True) + + op.add_column( + "giveaway_entries", + sa.Column( + "entry_source", + entry_source, + server_default=sa.text("'AUTO_PURCHASE'::giveaway_entry_source"), + nullable=False, + ), + ) + op.alter_column( + "giveaway_entries", + "user_telegram_id", + existing_type=sa.BigInteger(), + nullable=True, + ) + op.alter_column( + "giveaway_entries", + "transaction_payment_id", + existing_type=postgresql.UUID(as_uuid=True), + nullable=True, + ) + op.alter_column( + "giveaway_entries", + "purchase_type", + existing_type=postgresql.ENUM( + "NEW", + "RENEW", + "CHANGE", + name="purchase_type", + create_type=False, + ), + nullable=True, + ) + op.create_check_constraint( + "ck_giveaway_entry_source_fields", + "giveaway_entries", + "(" + "entry_source = 'AUTO_PURCHASE' " + "AND user_telegram_id IS NOT NULL " + "AND transaction_payment_id IS NOT NULL " + "AND purchase_type IS NOT NULL" + ") OR (" + "entry_source = 'MANUAL' " + "AND user_telegram_id IS NULL " + "AND transaction_payment_id IS NULL " + "AND purchase_type IS NULL " + "AND phone IS NOT NULL" + ")", + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_giveaway_entry_source_fields", + "giveaway_entries", + type_="check", + ) + op.execute("DELETE FROM giveaway_entries WHERE entry_source = 'MANUAL'") + op.alter_column( + "giveaway_entries", + "purchase_type", + existing_type=postgresql.ENUM( + "NEW", + "RENEW", + "CHANGE", + name="purchase_type", + create_type=False, + ), + nullable=False, + ) + op.alter_column( + "giveaway_entries", + "transaction_payment_id", + existing_type=postgresql.UUID(as_uuid=True), + nullable=False, + ) + op.alter_column( + "giveaway_entries", + "user_telegram_id", + existing_type=sa.BigInteger(), + nullable=False, + ) + op.drop_column("giveaway_entries", "entry_source") + op.execute("DROP TYPE IF EXISTS giveaway_entry_source") diff --git a/src/infrastructure/database/models/__init__.py b/src/infrastructure/database/models/__init__.py index 87a948e3..3262be12 100644 --- a/src/infrastructure/database/models/__init__.py +++ b/src/infrastructure/database/models/__init__.py @@ -1,7 +1,9 @@ from .base import BaseSql from .broadcast import Broadcast, BroadcastMessage +from .giveaway import GiveawayCampaign, GiveawayEntry from .payment_gateway import PaymentGateway from .plan import Plan, PlanDuration, PlanPrice +from .promocode import Promocode, PromocodeActivation from .referral import Referral, ReferralReward from .settings import Settings from .subscription import Subscription @@ -12,10 +14,14 @@ "BaseSql", "Broadcast", "BroadcastMessage", + "GiveawayCampaign", + "GiveawayEntry", "PaymentGateway", "Plan", "PlanDuration", "PlanPrice", + "Promocode", + "PromocodeActivation", "Referral", "ReferralReward", "Settings", diff --git a/src/infrastructure/database/models/base.py b/src/infrastructure/database/models/base.py index a7877e2d..e66636c7 100644 --- a/src/infrastructure/database/models/base.py +++ b/src/infrastructure/database/models/base.py @@ -13,10 +13,14 @@ BroadcastMessageStatus, BroadcastStatus, Currency, + GiveawayCampaignStatus, + GiveawayEntrySource, + GiveawayEntryStatus, Locale, PaymentGatewayType, PlanAvailability, PlanType, + PromoAudience, PurchaseType, ReferralAccrualStrategy, ReferralLevel, @@ -52,6 +56,19 @@ ReferralLevel: Enum(ReferralLevel, name="referral_level"), ReferralRewardStrategy: Enum(ReferralRewardStrategy, name="referral_reward_strategy"), ReferralRewardType: Enum(ReferralRewardType, name="referral_reward_type"), + PromoAudience: Enum(PromoAudience, name="promo_audience"), + GiveawayCampaignStatus: Enum( + GiveawayCampaignStatus, + name="giveaway_campaign_status", + ), + GiveawayEntryStatus: Enum( + GiveawayEntryStatus, + name="giveaway_entry_status", + ), + GiveawayEntrySource: Enum( + GiveawayEntrySource, + name="giveaway_entry_source", + ), } ) diff --git a/src/infrastructure/database/models/giveaway.py b/src/infrastructure/database/models/giveaway.py new file mode 100644 index 00000000..0f8eaae8 --- /dev/null +++ b/src/infrastructure/database/models/giveaway.py @@ -0,0 +1,127 @@ +from datetime import datetime +from decimal import Decimal +from typing import Optional +from uuid import UUID + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.core.enums import ( + GiveawayCampaignStatus, + GiveawayEntrySource, + GiveawayEntryStatus, + PurchaseType, +) + +from .base import BaseSql +from .timestamp import TimestampMixin +from .user import User + + +class GiveawayCampaign(BaseSql, TimestampMixin): + __tablename__ = "giveaway_campaigns" + __table_args__ = ( + CheckConstraint("winner_count > 0", name="ck_giveaway_campaign_winner_count"), + CheckConstraint("prize_amount >= 0", name="ck_giveaway_campaign_prize_amount"), + CheckConstraint("ends_at > starts_at", name="ck_giveaway_campaign_period"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128)) + status: Mapped[GiveawayCampaignStatus] = mapped_column(index=True) + starts_at: Mapped[datetime] = mapped_column(index=True) + ends_at: Mapped[datetime] = mapped_column(index=True) + winner_count: Mapped[int] + prize_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + eligible_plan_id: Mapped[int] = mapped_column( + ForeignKey("plans.id", ondelete="RESTRICT"), + index=True, + ) + eligible_duration_days: Mapped[int] + eligible_purchase_types: Mapped[list[str]] = mapped_column(JSONB) + code_prefix: Mapped[str] = mapped_column(String(8), default="VAY") + rules_text: Mapped[Optional[str]] = mapped_column(Text) + completed_at: Mapped[Optional[datetime]] + archived_at: Mapped[Optional[datetime]] + + entries: Mapped[list["GiveawayEntry"]] = relationship( + back_populates="campaign", + cascade="all, delete-orphan", + ) + + +class GiveawayEntry(BaseSql, TimestampMixin): + __tablename__ = "giveaway_entries" + __table_args__ = ( + UniqueConstraint( + "campaign_id", + "participant_code", + name="uq_giveaway_entry_campaign_code", + ), + UniqueConstraint( + "campaign_id", + "winner_rank", + name="uq_giveaway_entry_campaign_winner_rank", + ), + UniqueConstraint( + "transaction_payment_id", + name="uq_giveaway_entry_transaction_payment", + ), + CheckConstraint( + "winner_rank IS NULL OR winner_rank > 0", + name="ck_giveaway_entry_winner_rank", + ), + CheckConstraint( + "(" + "entry_source = 'AUTO_PURCHASE' " + "AND user_telegram_id IS NOT NULL " + "AND transaction_payment_id IS NOT NULL " + "AND purchase_type IS NOT NULL" + ") OR (" + "entry_source = 'MANUAL' " + "AND user_telegram_id IS NULL " + "AND transaction_payment_id IS NULL " + "AND purchase_type IS NULL " + "AND phone IS NOT NULL" + ")", + name="ck_giveaway_entry_source_fields", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + campaign_id: Mapped[int] = mapped_column( + ForeignKey("giveaway_campaigns.id", ondelete="CASCADE"), + index=True, + ) + user_telegram_id: Mapped[Optional[int]] = mapped_column( + BigInteger, + ForeignKey("users.telegram_id", ondelete="CASCADE"), + index=True, + ) + telegram_username: Mapped[Optional[str]] = mapped_column(String(32)) + participant_code: Mapped[str] = mapped_column(String(32)) + transaction_payment_id: Mapped[Optional[UUID]] = mapped_column(index=True) + plan_id: Mapped[int] + plan_name: Mapped[str] = mapped_column(String(128)) + duration_days: Mapped[int] + purchase_type: Mapped[Optional[PurchaseType]] + entry_source: Mapped[GiveawayEntrySource] = mapped_column( + default=GiveawayEntrySource.AUTO_PURCHASE, + ) + phone: Mapped[Optional[str]] = mapped_column(String(15)) + status: Mapped[GiveawayEntryStatus] = mapped_column(index=True) + winner_rank: Mapped[Optional[int]] + selected_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + campaign: Mapped["GiveawayCampaign"] = relationship(back_populates="entries") + user: Mapped[Optional["User"]] = relationship(foreign_keys=[user_telegram_id]) diff --git a/src/infrastructure/database/models/promocode.py b/src/infrastructure/database/models/promocode.py new file mode 100644 index 00000000..de7a7acb --- /dev/null +++ b/src/infrastructure/database/models/promocode.py @@ -0,0 +1,58 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.core.enums import PromoAudience + +from .base import BaseSql +from .plan import Plan +from .timestamp import NOW_FUNC, TimestampMixin + + +class Promocode(BaseSql, TimestampMixin): + __tablename__ = "promocodes" + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + discount_percent: Mapped[int] + plan_id: Mapped[int] = mapped_column( + ForeignKey("plans.id", ondelete="RESTRICT"), + index=True, + ) + audience: Mapped[PromoAudience] + max_activations: Mapped[int] + expires_at: Mapped[datetime] + is_active: Mapped[bool] + + plan: Mapped["Plan"] = relationship(lazy="selectin") + + +class PromocodeActivation(BaseSql): + __tablename__ = "promocode_activations" + + __table_args__ = ( + UniqueConstraint( + "promocode_id", + "user_telegram_id", + name="uq_promo_activation_user", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + promocode_id: Mapped[int] = mapped_column( + ForeignKey("promocodes.id", ondelete="CASCADE"), + index=True, + ) + user_telegram_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("users.telegram_id"), + index=True, + ) + # Stored without FK — transactions.payment_id has only a unique index, not a constraint + transaction_payment_id: Mapped[UUID] + activated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=NOW_FUNC, + ) diff --git a/src/infrastructure/database/models/transaction.py b/src/infrastructure/database/models/transaction.py index b4cfcf83..32e3740f 100644 --- a/src/infrastructure/database/models/transaction.py +++ b/src/infrastructure/database/models/transaction.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Optional from uuid import UUID from sqlalchemy import BigInteger, ForeignKey @@ -32,4 +32,10 @@ class Transaction(BaseSql, TimestampMixin): currency: Mapped[Currency] plan_snapshot: Mapped[dict[str, Any]] + promocode_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("promocodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + user: Mapped["User"] = relationship(foreign_keys=[user_telegram_id]) diff --git a/src/infrastructure/di/providers/dao.py b/src/infrastructure/di/providers/dao.py index 59d97193..7650c184 100644 --- a/src/infrastructure/di/providers/dao.py +++ b/src/infrastructure/di/providers/dao.py @@ -2,8 +2,10 @@ from src.application.common.dao import ( BroadcastDao, + GiveawayDao, PaymentGatewayDao, PlanDao, + PromocodeDao, ReferralDao, SettingsDao, SubscriptionDao, @@ -14,8 +16,10 @@ ) from src.infrastructure.database.dao import ( BroadcastDaoImpl, + GiveawayDaoImpl, PaymentGatewayDaoImpl, PlanDaoImpl, + PromocodeDaoImpl, ReferralDaoImpl, SettingsDaoImpl, SubscriptionDaoImpl, @@ -30,8 +34,10 @@ class DaoProvider(Provider): scope = Scope.REQUEST broadcast = provide(source=BroadcastDaoImpl, provides=BroadcastDao) + giveaway = provide(source=GiveawayDaoImpl, provides=GiveawayDao) payment_gateway = provide(source=PaymentGatewayDaoImpl, provides=PaymentGatewayDao) plan = provide(source=PlanDaoImpl, provides=PlanDao) + promocode = provide(source=PromocodeDaoImpl, provides=PromocodeDao) referral = provide(source=ReferralDaoImpl, provides=ReferralDao) settings = provide(source=SettingsDaoImpl, provides=SettingsDao) subscription = provide(source=SubscriptionDaoImpl, provides=SubscriptionDao) diff --git a/src/infrastructure/di/providers/use_cases.py b/src/infrastructure/di/providers/use_cases.py index 43d969f8..4b7a180b 100644 --- a/src/infrastructure/di/providers/use_cases.py +++ b/src/infrastructure/di/providers/use_cases.py @@ -3,9 +3,11 @@ from src.application.use_cases.access import ACCESS_USE_CASES from src.application.use_cases.broadcast import BROADCAST_USE_CASES from src.application.use_cases.gateways import GATEWAYS_USE_CASES +from src.application.use_cases.giveaway import GIVEAWAY_USE_CASES from src.application.use_cases.importer import IMPORTER_USE_CASES from src.application.use_cases.misc import MISC_USE_CASES from src.application.use_cases.plan import PLAN_USE_CASES +from src.application.use_cases.promocode import PROMOCODE_USE_CASES from src.application.use_cases.referral import REFERRAL_USE_CASES from src.application.use_cases.remnawave import REMNAWAVE_USE_CASES from src.application.use_cases.settings import SETTINGS_USE_CASES @@ -21,9 +23,11 @@ class UseCasesProvider(Provider): *ACCESS_USE_CASES, *BROADCAST_USE_CASES, *GATEWAYS_USE_CASES, + *GIVEAWAY_USE_CASES, *IMPORTER_USE_CASES, *MISC_USE_CASES, *PLAN_USE_CASES, + *PROMOCODE_USE_CASES, *REFERRAL_USE_CASES, *REMNAWAVE_USE_CASES, *SETTINGS_USE_CASES, diff --git a/src/infrastructure/services/remnawave.py b/src/infrastructure/services/remnawave.py index 71184693..02b2f9ef 100644 --- a/src/infrastructure/services/remnawave.py +++ b/src/infrastructure/services/remnawave.py @@ -6,7 +6,7 @@ from loguru import logger from packaging.version import Version from remnapy import RemnawaveSDK -from remnapy.exceptions import AuthenticationError, ConflictError, NotFoundError +from remnapy.exceptions import AuthenticationError, ConflictError, ForbiddenError, NotFoundError from remnapy.models import ( CreateUserRequestDto, DeleteUserHwidDeviceRequestDto, @@ -21,6 +21,7 @@ from src.application.dto import PlanSnapshotDto, RemnaSubscriptionDto, SubscriptionDto, UserDto from src.core.constants import REMNAWAVE_MIN_VERSION from src.core.enums import SubscriptionStatus +from src.core.exceptions import RemnawaveDevicesUnavailableError from src.core.utils.converters import days_to_datetime, gb_to_bytes @@ -143,7 +144,27 @@ async def get_user_by_telegram_id(self, telegram_id: int) -> list[UserResponseDt return response.root async def get_devices(self, user_uuid: UUID) -> list[HwidDeviceDto]: - response = await self.sdk.hwid.get_hwid_user(user_uuid) + try: + response = await self.sdk.hwid.get_hwid_user(user_uuid) + except AuthenticationError as e: + logger.error( + f"Remnawave rejected HWID devices request for user '{user_uuid}': " + "API authentication failed (HTTP 401)" + ) + raise RemnawaveDevicesUnavailableError from e + except ForbiddenError as e: + logger.error( + f"Remnawave rejected HWID devices request for user '{user_uuid}': " + "access forbidden (HTTP 403); check API token and proxy permissions" + ) + raise RemnawaveDevicesUnavailableError from e + except NotFoundError as e: + logger.warning( + f"Remnawave user '{user_uuid}' was not found while fetching HWID devices " + "(HTTP 404)" + ) + raise RemnawaveDevicesUnavailableError from e + logger.debug(f"Fetched {response.total} devices for RemnaUser '{user_uuid}'") return response.devices if response.total else [] @@ -156,9 +177,24 @@ async def delete_device(self, user_uuid: UUID, hwid_uuid: str) -> Optional[int]: f"Deleted HWID device '{hwid_uuid}' for RemnaUser '{user_uuid}'. " f"Total devices now: {response.total}" ) - except NotFoundError: - logger.debug(f"RemnaUser '{user_uuid}' not found in panel") - return None + except AuthenticationError as e: + logger.error( + f"Remnawave rejected HWID device deletion for user '{user_uuid}': " + "API authentication failed (HTTP 401)" + ) + raise RemnawaveDevicesUnavailableError from e + except ForbiddenError as e: + logger.error( + f"Remnawave rejected HWID device deletion for user '{user_uuid}': " + "access forbidden (HTTP 403); check API token and proxy permissions" + ) + raise RemnawaveDevicesUnavailableError from e + except NotFoundError as e: + logger.warning( + f"Remnawave user or HWID device for user '{user_uuid}' was not found " + "during deletion (HTTP 404)" + ) + raise RemnawaveDevicesUnavailableError from e return int(response.total) diff --git a/src/telegram/routers/__init__.py b/src/telegram/routers/__init__.py index 20e05819..a0b353d4 100644 --- a/src/telegram/routers/__init__.py +++ b/src/telegram/routers/__init__.py @@ -1,12 +1,13 @@ from aiogram import Router -from . import dashboard, extra, menu, subscription +from . import client_giveaways, dashboard, extra, menu, subscription def setup_routers(router: Router) -> None: # WARNING: The order of router registration matters! routers = [ extra.payment.router, + extra.giveaway.router, extra.notification.router, extra.test.router, extra.commands.router, @@ -16,10 +17,13 @@ def setup_routers(router: Router) -> None: # menu.handlers.router, menu.dialog.router, + client_giveaways.dialog.router, # subscription.dialog.router, # dashboard.dialog.router, + dashboard.promocodes.dialog.router, + dashboard.giveaways.dialog.router, dashboard.statistics.dialog.router, dashboard.access.dialog.router, dashboard.broadcast.dialog.router, diff --git a/src/telegram/routers/client_giveaways/__init__.py b/src/telegram/routers/client_giveaways/__init__.py new file mode 100644 index 00000000..e58d4bdb --- /dev/null +++ b/src/telegram/routers/client_giveaways/__init__.py @@ -0,0 +1,3 @@ +from . import dialog + +__all__ = ["dialog"] diff --git a/src/telegram/routers/client_giveaways/dialog.py b/src/telegram/routers/client_giveaways/dialog.py new file mode 100644 index 00000000..634c11e2 --- /dev/null +++ b/src/telegram/routers/client_giveaways/dialog.py @@ -0,0 +1,138 @@ +from aiogram_dialog import Dialog, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, ListGroup, Row, Start, SwitchTo +from aiogram_dialog.widgets.text import Format +from magic_filter import F + +from src.core.enums import BannerName +from src.telegram.states import ClientGiveaways, MainMenu +from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate + +from .getters import ( + client_giveaway_conditions_getter, + client_giveaway_getter, + client_giveaway_results_getter, + client_giveaways_getter, +) +from .handlers import ( + on_buy_for_giveaway, + on_client_campaign_select, + on_client_phone_input, + on_client_phone_request, +) + +giveaways = Window( + Banner(BannerName.MENU), + I18nFormat("msg-client-giveaways"), + I18nFormat("msg-client-giveaways-empty", when=~F["has_giveaways"]), + ListGroup( + Button( + text=Format("🎁 {item[name]}"), + id="campaign", + on_click=on_client_campaign_select, + ), + id="campaigns", + item_id_getter=lambda item: item["id"], + items="giveaways", + when=F["has_giveaways"], + ), + Row( + Start( + text=I18nFormat("btn-back.menu"), + id="back", + state=MainMenu.MAIN, + mode=StartMode.RESET_STACK, + ) + ), + IgnoreUpdate(), + getter=client_giveaways_getter, + state=ClientGiveaways.LIST, +) + +view = Window( + Banner(BannerName.MENU), + I18nFormat("msg-client-giveaway-view"), + Row( + Button( + text=I18nFormat("btn-client-giveaway.buy"), + id="buy", + on_click=on_buy_for_giveaway, + when=F["can_buy"], + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-client-giveaway.conditions"), + id="conditions", + state=ClientGiveaways.CONDITIONS, + ), + Button( + text=I18nFormat("btn-client-giveaway.phone"), + id="phone", + on_click=on_client_phone_request, + when=F["can_edit_phone"], + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-client-giveaway.results"), + id="results", + state=ClientGiveaways.RESULTS, + when=F["show_results"], + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=ClientGiveaways.LIST, + ), + ), + IgnoreUpdate(), + getter=client_giveaway_getter, + state=ClientGiveaways.VIEW, +) + +conditions = Window( + Banner(BannerName.MENU), + I18nFormat("msg-client-giveaway-conditions"), + Format("{conditions}"), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=ClientGiveaways.VIEW, + ), + IgnoreUpdate(), + getter=client_giveaway_conditions_getter, + state=ClientGiveaways.CONDITIONS, +) + +phone = Window( + Banner(BannerName.MENU), + I18nFormat("msg-client-giveaway-phone"), + MessageInput(func=on_client_phone_input), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=ClientGiveaways.VIEW, + ), + IgnoreUpdate(), + getter=client_giveaway_getter, + state=ClientGiveaways.PHONE, +) + +results = Window( + Banner(BannerName.MENU), + I18nFormat("msg-client-giveaway-results"), + Format("{results}"), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=ClientGiveaways.VIEW, + ), + IgnoreUpdate(), + getter=client_giveaway_results_getter, + state=ClientGiveaways.RESULTS, +) + +router = Dialog(giveaways, view, conditions, phone, results) diff --git a/src/telegram/routers/client_giveaways/getters.py b/src/telegram/routers/client_giveaways/getters.py new file mode 100644 index 00000000..36503266 --- /dev/null +++ b/src/telegram/routers/client_giveaways/getters.py @@ -0,0 +1,117 @@ +from typing import Any + +from aiogram_dialog import DialogManager +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from src.application.common import TranslatorRunner +from src.application.common.dao import GiveawayDao +from src.application.dto import UserDto +from src.application.use_cases.giveaway.queries import ( + GetClientGiveawayConditions, + GetClientGiveawayDetails, + ListClientGiveaways, +) +from src.core.enums import GiveawayCampaignStatus, GiveawayEntryStatus +from src.core.utils.time import datetime_now + + +def _mask_phone(phone: str | None) -> str: + if not phone: + return "не указан" + return f"{phone[:4]}****{phone[-3:]}" + + +@inject +async def client_giveaways_getter( + dialog_manager: DialogManager, + user: UserDto, + list_giveaways: FromDishka[ListClientGiveaways], + **kwargs: Any, +) -> dict[str, Any]: + giveaways = await list_giveaways(user) + items = [ + { + "id": item.campaign.id, + "name": item.campaign.name, + "status": item.campaign.status, + "is_participant": item.entry is not None, + } + for item in giveaways + ] + return {"giveaways": items, "has_giveaways": bool(items)} + + +@inject +async def client_giveaway_getter( + dialog_manager: DialogManager, + user: UserDto, + i18n: FromDishka[TranslatorRunner], + get_details: FromDishka[GetClientGiveawayDetails], + **kwargs: Any, +) -> dict[str, Any]: + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + details = await get_details(user, campaign_id) + campaign = details.campaign + entry = details.entry + is_winner = bool(entry and entry.status == GiveawayEntryStatus.WINNER) + is_completed = campaign.status == GiveawayCampaignStatus.COMPLETED + now = datetime_now() + is_active = ( + campaign.status == GiveawayCampaignStatus.ACTIVE + and campaign.starts_at <= now <= campaign.ends_at + ) + + if is_winner: + status_text = i18n.get("msg-client-giveaway-status.winner") + elif entry: + status_text = i18n.get("msg-client-giveaway-status.participating") + elif is_completed: + status_text = i18n.get("msg-client-giveaway-status.completed") + else: + status_text = i18n.get("msg-client-giveaway-status.not-participating") + + dialog_manager.dialog_data["entry_id"] = entry.id if entry else None + return { + "campaign_id": campaign_id, + "name": campaign.name, + "winner_count": campaign.winner_count, + "prize_amount": campaign.prize_amount, + "starts_at": campaign.starts_at.strftime("%d.%m.%Y"), + "ends_at": campaign.ends_at.strftime("%d.%m.%Y"), + "plan_name": i18n.get(details.plan_name), + "duration_days": campaign.eligible_duration_days, + "status_text": status_text, + "has_entry": entry is not None, + "participant_code": entry.participant_code if entry else "—", + "phone": _mask_phone(entry.phone if entry else None), + "is_winner": is_winner, + "winner_rank": entry.winner_rank if entry else 0, + "is_completed": is_completed, + "can_buy": is_active and entry is None, + "can_edit_phone": entry is not None and not is_completed, + "show_results": is_completed, + } + + +@inject +async def client_giveaway_conditions_getter( + dialog_manager: DialogManager, + user: UserDto, + get_conditions: FromDishka[GetClientGiveawayConditions], + **kwargs: Any, +) -> dict[str, Any]: + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + return {"conditions": await get_conditions(user, campaign_id)} + + +@inject +async def client_giveaway_results_getter( + dialog_manager: DialogManager, + giveaway_dao: FromDishka[GiveawayDao], + **kwargs: Any, +) -> dict[str, Any]: + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + winners = await giveaway_dao.get_winners(campaign_id) + lines = [f"#{winner.winner_rank} · {winner.participant_code}" for winner in winners] + return {"results": "\n".join(lines) if lines else "Розыгрыш ещё не проведён."} diff --git a/src/telegram/routers/client_giveaways/handlers.py b/src/telegram/routers/client_giveaways/handlers.py new file mode 100644 index 00000000..7d30b1db --- /dev/null +++ b/src/telegram/routers/client_giveaways/handlers.py @@ -0,0 +1,89 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import DialogManager, StartMode +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from src.application.common import Notifier +from src.application.dto import MessagePayloadDto, UserDto +from src.application.use_cases.giveaway.commands import ( + SaveGiveawayPhone, + SaveGiveawayPhoneDto, +) +from src.application.use_cases.giveaway.queries import GetClientGiveawayDetails +from src.core.constants import USER_KEY +from src.telegram.states import ClientGiveaways, Subscription + + +async def on_client_campaign_select( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + dialog_manager.dialog_data["campaign_id"] = int(dialog_manager.item_id) # type: ignore[attr-defined] + await dialog_manager.switch_to(ClientGiveaways.VIEW) + + +@inject +async def on_buy_for_giveaway( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + get_details: FromDishka[GetClientGiveawayDetails], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + details = await get_details(user, int(dialog_manager.dialog_data["campaign_id"])) + await notifier.notify_user( + user, + payload=MessagePayloadDto( + i18n_key="ntf-giveaway.buy-instruction", + i18n_kwargs={ + "plan_name": details.plan_name, + "duration_days": details.campaign.eligible_duration_days, + }, + ), + ) + await dialog_manager.start( + Subscription.PLAN, + data={"plan_id": details.campaign.eligible_plan_id}, + mode=StartMode.RESET_STACK, + ) + + +async def on_client_phone_request( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(ClientGiveaways.PHONE) + + +@inject +async def on_client_phone_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + save_phone: FromDishka[SaveGiveawayPhone], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + phone = (message.text or "").strip() + if not phone.isdigit() or not 10 <= len(phone) <= 15: + await notifier.notify_user(user, i18n_key="ntf-giveaway.phone-invalid") + return + entry_id = dialog_manager.dialog_data.get("entry_id") + if not isinstance(entry_id, int): + await notifier.notify_user(user, i18n_key="ntf-error.unknown") + return + await save_phone( + user, + SaveGiveawayPhoneDto( + entry_id=entry_id, + user_telegram_id=user.telegram_id, + phone=phone, + ), + ) + await notifier.notify_user(user, i18n_key="ntf-giveaway.phone-saved") + await dialog_manager.switch_to(ClientGiveaways.VIEW) diff --git a/src/telegram/routers/dashboard/__init__.py b/src/telegram/routers/dashboard/__init__.py index 78774f4e..20c0733e 100644 --- a/src/telegram/routers/dashboard/__init__.py +++ b/src/telegram/routers/dashboard/__init__.py @@ -1,7 +1,19 @@ -from . import access, broadcast, dialog, importer, remnashop, remnawave, statistics, users +from . import ( + access, + broadcast, + dialog, + giveaways, + importer, + promocodes, + remnashop, + remnawave, + statistics, + users, +) __all__ = [ "dialog", + "giveaways", "access", "broadcast", "importer", diff --git a/src/telegram/routers/dashboard/dialog.py b/src/telegram/routers/dashboard/dialog.py index 10e1ca6a..bd742a83 100644 --- a/src/telegram/routers/dashboard/dialog.py +++ b/src/telegram/routers/dashboard/dialog.py @@ -1,5 +1,5 @@ from aiogram_dialog import Dialog, StartMode, Window -from aiogram_dialog.widgets.kbd import Button, Row, Start +from aiogram_dialog.widgets.kbd import Row, Start from src.application.common.policy import Permission from src.core.enums import BannerName @@ -8,7 +8,9 @@ Dashboard, DashboardAccess, DashboardBroadcast, + DashboardGiveaways, DashboardImporter, + DashboardPromocodes, DashboardRemnashop, DashboardRemnawave, DashboardStatistics, @@ -17,8 +19,6 @@ from src.telegram.utils import require_permission from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate -from .handlers import show_dev_promocode - dashboard = Window( Banner(BannerName.DASHBOARD), I18nFormat("msg-dashboard-main"), @@ -45,15 +45,23 @@ mode=StartMode.RESET_STACK, when=require_permission(Permission.VIEW_BROADCAST), ), - Button( + Start( text=I18nFormat("btn-dashboard.promocodes"), id="promocodes", - on_click=show_dev_promocode, - # state=DashboardPromocodes.MAIN, - # mode=StartMode.RESET_STACK, + state=DashboardPromocodes.MAIN, + mode=StartMode.RESET_STACK, when=require_permission(Permission.VIEW_PROMOCODE), ), ), + Row( + Start( + text=I18nFormat("btn-dashboard.giveaways"), + id="giveaways", + state=DashboardGiveaways.MAIN, + mode=StartMode.RESET_STACK, + when=require_permission(Permission.VIEW_GIVEAWAY), + ), + ), Row( Start( text=I18nFormat("btn-dashboard.access"), diff --git a/src/telegram/routers/dashboard/giveaways/__init__.py b/src/telegram/routers/dashboard/giveaways/__init__.py new file mode 100644 index 00000000..e58d4bdb --- /dev/null +++ b/src/telegram/routers/dashboard/giveaways/__init__.py @@ -0,0 +1,3 @@ +from . import dialog + +__all__ = ["dialog"] diff --git a/src/telegram/routers/dashboard/giveaways/dialog.py b/src/telegram/routers/dashboard/giveaways/dialog.py new file mode 100644 index 00000000..c08c7b18 --- /dev/null +++ b/src/telegram/routers/dashboard/giveaways/dialog.py @@ -0,0 +1,499 @@ +from aiogram_dialog import Dialog, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import ( + Button, + Column, + ListGroup, + Row, + Select, + Start, + SwitchTo, +) +from aiogram_dialog.widgets.text import Format +from magic_filter import F + +from src.core.enums import BannerName, PurchaseType +from src.telegram.keyboards import main_menu_button +from src.telegram.states import Dashboard, DashboardGiveaways +from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate + +from .getters import ( + campaign_getter, + campaigns_getter, + configurator_getter, + durations_getter, + entries_getter, + manual_entry_added_getter, + plans_getter, + purchase_types_getter, + winners_getter, +) +from .handlers import ( + on_activity_select, + on_archive_confirm, + on_archive_request, + on_campaign_select, + on_complete, + on_confirm, + on_create, + on_delete_confirm, + on_delete_request, + on_duration_select, + on_end_input, + on_manual_entry_phone_input, + on_manual_entry_request, + on_name_input, + on_plan_select, + on_prize_input, + on_purchase_type_toggle, + on_purchase_types_continue, + on_rules_edit_input, + on_rules_edit_request, + on_rules_input, + on_select_winner, + on_start_input, + on_toggle_status, + on_winner_count_input, +) + +main = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaways-main"), + Row( + SwitchTo( + text=I18nFormat("btn-giveaway.list"), + id="list", + state=DashboardGiveaways.LIST, + ), + Button( + text=I18nFormat("btn-giveaway.create"), + id="create", + on_click=on_create, + ), + ), + Row( + Start( + text=I18nFormat("btn-back.general"), + id="back", + state=Dashboard.MAIN, + mode=StartMode.RESET_STACK, + ), + *main_menu_button, + ), + IgnoreUpdate(), + state=DashboardGiveaways.MAIN, +) + +campaigns = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaways-list"), + ListGroup( + Button( + text=Format("{item[name]} · {item[status]}"), + id="campaign", + on_click=on_campaign_select, + ), + id="campaigns", + item_id_getter=lambda item: item["id"], + items="campaigns", + ), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardGiveaways.MAIN, + ) + ), + IgnoreUpdate(), + getter=campaigns_getter, + state=DashboardGiveaways.LIST, +) + +name = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-name"), + MessageInput(func=on_name_input), + IgnoreUpdate(), + state=DashboardGiveaways.NAME, +) + +starts_at = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-start"), + MessageInput(func=on_start_input), + IgnoreUpdate(), + state=DashboardGiveaways.STARTS_AT, +) + +ends_at = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-end"), + MessageInput(func=on_end_input), + IgnoreUpdate(), + state=DashboardGiveaways.ENDS_AT, +) + +winner_count = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-winner-count"), + MessageInput(func=on_winner_count_input), + IgnoreUpdate(), + state=DashboardGiveaways.WINNER_COUNT, +) + +prize_amount = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-prize"), + MessageInput(func=on_prize_input), + IgnoreUpdate(), + state=DashboardGiveaways.PRIZE_AMOUNT, +) + +plan = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-plan"), + Column( + Select( + text=Format("{item[name]}"), + id="plan", + item_id_getter=lambda item: item["id"], + items="plans", + type_factory=int, + on_click=on_plan_select, + ) + ), + IgnoreUpdate(), + getter=plans_getter, + state=DashboardGiveaways.PLAN, +) + +duration = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-duration"), + Column( + Select( + text=Format("{item} дней"), + id="duration", + item_id_getter=lambda item: item, + items="durations", + type_factory=int, + on_click=on_duration_select, + ) + ), + IgnoreUpdate(), + getter=durations_getter, + state=DashboardGiveaways.DURATION, +) + +purchase_types = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-purchase-types"), + Column( + Select( + text=I18nFormat( + "btn-giveaway.purchase-type", + selected=F["item"]["selected"], + purchase_type=F["item"]["value"], + ), + id="purchase_type", + item_id_getter=lambda item: item["value"].value, + items="purchase_types", + type_factory=PurchaseType, + on_click=on_purchase_type_toggle, + ) + ), + Button( + text=I18nFormat("btn-giveaway.continue"), + id="continue", + on_click=on_purchase_types_continue, + ), + IgnoreUpdate(), + getter=purchase_types_getter, + state=DashboardGiveaways.PURCHASE_TYPES, +) + +activity = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-activity"), + Row( + Button( + text=I18nFormat("btn-giveaway.enable"), + id="enable", + on_click=on_activity_select, + ), + Button( + text=I18nFormat("btn-giveaway.keep-disabled"), + id="keep_disabled", + on_click=on_activity_select, + ), + ), + IgnoreUpdate(), + state=DashboardGiveaways.ACTIVITY, +) + +rules = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-rules"), + MessageInput(func=on_rules_input), + IgnoreUpdate(), + state=DashboardGiveaways.RULES, +) + +configurator = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-configurator"), + Row( + Button( + text=I18nFormat("btn-giveaway.confirm"), + id="confirm", + on_click=on_confirm, + ) + ), + IgnoreUpdate(), + getter=configurator_getter, + state=DashboardGiveaways.CONFIGURATOR, +) + +view = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-view"), + I18nFormat("msg-giveaway-participants-shortage", when=F["participants_shortage"]), + Row( + SwitchTo( + text=I18nFormat("btn-giveaway.entries"), + id="entries", + state=DashboardGiveaways.ENTRIES, + ), + SwitchTo( + text=I18nFormat("btn-giveaway.winners"), + id="winners", + state=DashboardGiveaways.WINNERS, + ), + ), + Row( + Button( + text=I18nFormat("btn-giveaway.select-winner"), + id="select_winner", + on_click=on_select_winner, + when=F["can_select_winner"], + ) + ), + Row( + Button( + text=I18nFormat("btn-giveaway.add-participant"), + id="add_participant", + on_click=on_manual_entry_request, + when=F["can_add_entry"], + ) + ), + Row( + Button( + text=I18nFormat("btn-giveaway.disable"), + id="disable", + on_click=on_toggle_status, + when=F["is_active"] & F["can_manage"], + ), + Button( + text=I18nFormat("btn-giveaway.enable"), + id="enable", + on_click=on_toggle_status, + when=F["is_draft"] & F["can_manage"], + ), + ), + Row( + Button( + text=I18nFormat("btn-giveaway.complete"), + id="complete", + on_click=on_complete, + when=F["can_manage"], + ), + Button( + text=I18nFormat("btn-giveaway.archive"), + id="archive", + on_click=on_archive_request, + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-giveaway.rules"), + id="rules", + state=DashboardGiveaways.RULES_VIEW, + ), + ), + Row( + Button( + text=I18nFormat("btn-giveaway.delete"), + id="delete", + on_click=on_delete_request, + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardGiveaways.LIST, + ) + ), + IgnoreUpdate(), + getter=campaign_getter, + state=DashboardGiveaways.VIEW, +) + +entries = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-entries"), + Format("{entries_text}"), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardGiveaways.VIEW, + ), + IgnoreUpdate(), + getter=entries_getter, + state=DashboardGiveaways.ENTRIES, +) + +winners = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-winners"), + Format("{winners_text}"), + Row( + Button( + text=I18nFormat("btn-giveaway.select-next-winner"), + id="select_next", + on_click=on_select_winner, + ) + ), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardGiveaways.VIEW, + ), + IgnoreUpdate(), + getter=winners_getter, + state=DashboardGiveaways.WINNERS, +) + +archive_confirm = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-archive-confirm"), + Row( + Button( + text=I18nFormat("btn-giveaway.archive-confirm"), + id="archive_confirm", + on_click=on_archive_confirm, + ), + SwitchTo( + text=I18nFormat("btn-giveaway.cancel"), + id="cancel", + state=DashboardGiveaways.VIEW, + ), + ), + IgnoreUpdate(), + state=DashboardGiveaways.ARCHIVE_CONFIRM, +) + +delete_confirm = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-delete-confirm"), + Row( + Button( + text=I18nFormat("btn-giveaway.delete-confirm"), + id="delete_confirm", + on_click=on_delete_confirm, + ), + SwitchTo( + text=I18nFormat("btn-giveaway.cancel"), + id="cancel", + state=DashboardGiveaways.VIEW, + ), + ), + IgnoreUpdate(), + state=DashboardGiveaways.DELETE_CONFIRM, +) + +rules_edit = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-rules-edit"), + MessageInput(func=on_rules_edit_input), + SwitchTo( + text=I18nFormat("btn-giveaway.cancel"), + id="cancel", + state=DashboardGiveaways.VIEW, + ), + IgnoreUpdate(), + getter=campaign_getter, + state=DashboardGiveaways.RULES_EDIT, +) + +rules_view = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-rules-view"), + Format("{rules_text}"), + Row( + Button( + text=I18nFormat("btn-giveaway.rules-edit"), + id="edit", + on_click=on_rules_edit_request, + ), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardGiveaways.VIEW, + ), + ), + IgnoreUpdate(), + getter=campaign_getter, + state=DashboardGiveaways.RULES_VIEW, +) + +manual_entry_phone = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-manual-entry-phone"), + MessageInput(func=on_manual_entry_phone_input), + SwitchTo( + text=I18nFormat("btn-giveaway.cancel"), + id="cancel", + state=DashboardGiveaways.VIEW, + ), + IgnoreUpdate(), + state=DashboardGiveaways.MANUAL_ENTRY_PHONE, +) + +manual_entry_added = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-giveaway-manual-entry-added"), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardGiveaways.VIEW, + ), + IgnoreUpdate(), + getter=manual_entry_added_getter, + state=DashboardGiveaways.MANUAL_ENTRY_ADDED, +) + +router = Dialog( + main, + campaigns, + name, + starts_at, + ends_at, + winner_count, + prize_amount, + plan, + duration, + purchase_types, + rules, + activity, + configurator, + view, + entries, + winners, + archive_confirm, + delete_confirm, + rules_view, + rules_edit, + manual_entry_phone, + manual_entry_added, +) diff --git a/src/telegram/routers/dashboard/giveaways/getters.py b/src/telegram/routers/dashboard/giveaways/getters.py new file mode 100644 index 00000000..c4822c50 --- /dev/null +++ b/src/telegram/routers/dashboard/giveaways/getters.py @@ -0,0 +1,240 @@ +from typing import Any + +from aiogram_dialog import DialogManager +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from src.application.common import TranslatorRunner +from src.application.common.dao import GiveawayDao, PlanDao +from src.application.use_cases.giveaway.queries import generate_giveaway_conditions +from src.core.enums import GiveawayCampaignStatus, GiveawayEntrySource, PurchaseType + + +def _mask_phone(phone: str | None) -> str: + if not phone: + return "не указан" + return f"{phone[:4]}****{phone[-3:]}" + + +def _entry_source_label(source: GiveawayEntrySource) -> str: + if source == GiveawayEntrySource.MANUAL: + return "добавлен вручную" + return "покупка в боте" + + +@inject +async def campaigns_getter( + dialog_manager: DialogManager, + giveaway_dao: FromDishka[GiveawayDao], + **kwargs: Any, +) -> dict[str, Any]: + campaigns = await giveaway_dao.get_campaigns() + items = [ + {"id": item.id, "name": item.name, "status": item.status} + for item in campaigns + ] + return {"campaigns": items, "has_campaigns": bool(items)} + + +@inject +async def plans_getter( + dialog_manager: DialogManager, + plan_dao: FromDishka[PlanDao], + i18n: FromDishka[TranslatorRunner], + **kwargs: Any, +) -> dict[str, Any]: + plans = [plan for plan in await plan_dao.get_all() if plan.is_active] + items = [{"id": plan.id, "name": i18n.get(plan.name)} for plan in plans] + dialog_manager.dialog_data["_giveaway_plans"] = { + str(plan.id): { + "name": i18n.get(plan.name), + "durations": [duration.days for duration in plan.durations], + } + for plan in plans + if plan.id is not None + } + return {"plans": items} + + +async def durations_getter( + dialog_manager: DialogManager, + **kwargs: Any, +) -> dict[str, Any]: + plan_id = str(dialog_manager.dialog_data.get("eligible_plan_id")) + plan = dialog_manager.dialog_data.get("_giveaway_plans", {}).get(plan_id, {}) + return {"durations": plan.get("durations", [])} + + +async def purchase_types_getter( + dialog_manager: DialogManager, + **kwargs: Any, +) -> dict[str, Any]: + selected = set(dialog_manager.dialog_data.get("eligible_purchase_types", [])) + return { + "purchase_types": [ + {"value": item, "selected": item.value in selected} for item in PurchaseType + ], + "has_purchase_types": bool(selected), + } + + +async def configurator_getter( + dialog_manager: DialogManager, + **kwargs: Any, +) -> dict[str, Any]: + data = dialog_manager.dialog_data + return { + "name": data.get("name", "—"), + "starts_at": data.get("starts_at_display", "—"), + "ends_at": data.get("ends_at_display", "—"), + "winner_count": data.get("winner_count", "—"), + "prize_amount": data.get("prize_amount", "—"), + "plan_name": data.get("plan_name", "—"), + "duration_days": data.get("eligible_duration_days", "—"), + "purchase_types": ", ".join(data.get("eligible_purchase_types", [])), + "rules_text": ( + "Заданы вручную" if data.get("rules_text") else "Автоматические условия" + ), + "is_active": data.get("is_active", False), + } + + +@inject +async def campaign_getter( + dialog_manager: DialogManager, + giveaway_dao: FromDishka[GiveawayDao], + plan_dao: FromDishka[PlanDao], + i18n: FromDishka[TranslatorRunner], + **kwargs: Any, +) -> dict[str, Any]: + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + campaign = await giveaway_dao.get_campaign(campaign_id) + if not campaign: + return {} + plan = await plan_dao.get_by_id(campaign.eligible_plan_id) + entries = await giveaway_dao.get_entries(campaign_id) + winners = await giveaway_dao.get_winners(campaign_id) + return { + "campaign_id": campaign_id, + "name": campaign.name, + "status": campaign.status, + "starts_at": campaign.starts_at.strftime("%d.%m.%Y %H:%M UTC"), + "ends_at": campaign.ends_at.strftime("%d.%m.%Y %H:%M UTC"), + "winner_count": campaign.winner_count, + "prize_amount": campaign.prize_amount, + "plan_name": i18n.get(plan.name) if plan else str(campaign.eligible_plan_id), + "duration_days": campaign.eligible_duration_days, + "purchase_types": ", ".join(item.value for item in campaign.eligible_purchase_types), + "rules_text": campaign.rules_text + or generate_giveaway_conditions( + campaign, + i18n.get(plan.name) if plan else str(campaign.eligible_plan_id), + ), + "rules_mode": ( + "Заданы вручную" if campaign.rules_text else "Автоматические условия" + ), + "entries_count": len(entries), + "winners_count": len(winners), + "is_active": campaign.status == GiveawayCampaignStatus.ACTIVE, + "is_draft": campaign.status == GiveawayCampaignStatus.DRAFT, + "can_manage": campaign.status + not in {GiveawayCampaignStatus.COMPLETED, GiveawayCampaignStatus.ARCHIVED}, + "can_add_entry": campaign.status + in {GiveawayCampaignStatus.DRAFT, GiveawayCampaignStatus.ACTIVE}, + "can_select_winner": ( + campaign.status != GiveawayCampaignStatus.ARCHIVED + and len(winners) < campaign.winner_count + ), + "participants_shortage": len( + { + ( + "telegram", + entry.user_telegram_id, + ) + if entry.user_telegram_id is not None + else ("manual", entry.id) + for entry in entries + } + ) + < campaign.winner_count, + } + + +@inject +async def entries_getter( + dialog_manager: DialogManager, + giveaway_dao: FromDishka[GiveawayDao], + **kwargs: Any, +) -> dict[str, Any]: + entries = await giveaway_dao.get_entries(int(dialog_manager.dialog_data["campaign_id"])) + lines = [] + for entry in entries: + telegram_id = ( + str(entry.user_telegram_id) + if entry.user_telegram_id is not None + else "не указан" + ) + username = f"@{entry.telegram_username}" if entry.telegram_username else "не указан" + lines.append( + "\n".join( + [ + f"Код: {entry.participant_code}", + f"Telegram ID: {telegram_id}", + f"Username: {username}", + f"Телефон: {_mask_phone(entry.phone)}", + f"Источник: {_entry_source_label(entry.entry_source)}", + f"Статус: {entry.status.value}", + ] + ) + ) + return {"entries_text": "\n\n".join(lines) if lines else "Участников пока нет."} + + +@inject +async def winners_getter( + dialog_manager: DialogManager, + giveaway_dao: FromDishka[GiveawayDao], + **kwargs: Any, +) -> dict[str, Any]: + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + winners = await giveaway_dao.get_winners(campaign_id) + campaign = await giveaway_dao.get_campaign(campaign_id) + lines = [] + for entry in winners: + telegram_id = ( + str(entry.user_telegram_id) + if entry.user_telegram_id is not None + else "не указан" + ) + username = f"@{entry.telegram_username}" if entry.telegram_username else "не указан" + purchase_date = entry.created_at.strftime("%d.%m.%Y") if entry.created_at else "—" + date_label = ( + "Дата добавления" + if entry.entry_source == GiveawayEntrySource.MANUAL + else "Дата покупки" + ) + lines.append( + "\n".join( + [ + f"#{entry.winner_rank} · {entry.participant_code}", + f"Telegram ID: {telegram_id}", + f"Username: {username}", + f"Телефон: {entry.phone or 'не указан'}", + f"Источник: {_entry_source_label(entry.entry_source)}", + f"Тариф: {entry.plan_name}, {entry.duration_days} дней", + f"{date_label}: {purchase_date}", + f"Сумма приза: {campaign.prize_amount if campaign else 0} ₽", + ] + ) + ) + return {"winners_text": "\n\n".join(lines) if lines else "Победители пока не выбраны."} + + +async def manual_entry_added_getter( + dialog_manager: DialogManager, + **kwargs: Any, +) -> dict[str, Any]: + return { + "phone": dialog_manager.dialog_data.get("manual_entry_phone", "—"), + "participant_code": dialog_manager.dialog_data.get("manual_entry_code", "—"), + } diff --git a/src/telegram/routers/dashboard/giveaways/handlers.py b/src/telegram/routers/dashboard/giveaways/handlers.py new file mode 100644 index 00000000..2ac85d7e --- /dev/null +++ b/src/telegram/routers/dashboard/giveaways/handlers.py @@ -0,0 +1,487 @@ +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation +from typing import Optional + +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import DialogManager, StartMode +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Select +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from loguru import logger + +from src.application.common import Notifier +from src.application.common.dao import GiveawayDao +from src.application.dto import UserDto +from src.application.use_cases.giveaway.commands import ( + AddManualGiveawayEntry, + AddManualGiveawayEntryDto, + ArchiveGiveawayCampaign, + CreateGiveawayCampaign, + CreateGiveawayCampaignDto, + DeleteGiveawayCampaign, + SelectGiveawayWinner, + SetGiveawayStatus, + SetGiveawayStatusDto, + UpdateGiveawayRules, + UpdateGiveawayRulesDto, +) +from src.core.constants import USER_KEY +from src.core.enums import GiveawayCampaignStatus, PurchaseType +from src.telegram.states import DashboardGiveaways + + +def _parse_date(text: str, end_of_day: bool = False) -> Optional[datetime]: + for fmt in ("%d.%m.%Y %H:%M", "%d.%m.%Y"): + try: + value = datetime.strptime(text, fmt).replace(tzinfo=timezone.utc) + if fmt == "%d.%m.%Y" and end_of_day: + value = value.replace(hour=23, minute=59, second=59) + return value + except ValueError: + continue + return None + + +async def on_create( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + dialog_manager.dialog_data.clear() + dialog_manager.dialog_data["eligible_purchase_types"] = [ + PurchaseType.NEW.value, + PurchaseType.RENEW.value, + ] + await dialog_manager.switch_to(DashboardGiveaways.NAME) + + +@inject +async def on_name_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + name = (message.text or "").strip() + if not name or len(name) > 128: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + dialog_manager.dialog_data["name"] = name + await dialog_manager.switch_to(DashboardGiveaways.STARTS_AT) + + +@inject +async def on_start_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + value = _parse_date((message.text or "").strip()) + if not value: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + dialog_manager.dialog_data["starts_at_iso"] = value.isoformat() + dialog_manager.dialog_data["starts_at_display"] = value.strftime("%d.%m.%Y %H:%M UTC") + await dialog_manager.switch_to(DashboardGiveaways.ENDS_AT) + + +@inject +async def on_end_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + value = _parse_date((message.text or "").strip(), end_of_day=True) + starts_at = datetime.fromisoformat(dialog_manager.dialog_data["starts_at_iso"]) + if not value or value <= starts_at: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + dialog_manager.dialog_data["ends_at_iso"] = value.isoformat() + dialog_manager.dialog_data["ends_at_display"] = value.strftime("%d.%m.%Y %H:%M UTC") + await dialog_manager.switch_to(DashboardGiveaways.WINNER_COUNT) + + +@inject +async def on_winner_count_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + try: + value = int((message.text or "").strip()) + if value < 1: + raise ValueError + except ValueError: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + dialog_manager.dialog_data["winner_count"] = value + await dialog_manager.switch_to(DashboardGiveaways.PRIZE_AMOUNT) + + +@inject +async def on_prize_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + try: + value = Decimal((message.text or "").strip().replace(",", ".")) + if value < 0: + raise ValueError + except (InvalidOperation, ValueError): + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + dialog_manager.dialog_data["prize_amount"] = str(value) + await dialog_manager.switch_to(DashboardGiveaways.PLAN) + + +async def on_plan_select( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + plan_id: int, +) -> None: + plans = dialog_manager.dialog_data.get("_giveaway_plans", {}) + dialog_manager.dialog_data["eligible_plan_id"] = plan_id + dialog_manager.dialog_data["plan_name"] = plans.get(str(plan_id), {}).get("name", str(plan_id)) + await dialog_manager.switch_to(DashboardGiveaways.DURATION) + + +async def on_duration_select( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + duration_days: int, +) -> None: + dialog_manager.dialog_data["eligible_duration_days"] = duration_days + await dialog_manager.switch_to(DashboardGiveaways.PURCHASE_TYPES) + + +async def on_purchase_type_toggle( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + purchase_type: PurchaseType, +) -> None: + selected = set(dialog_manager.dialog_data.get("eligible_purchase_types", [])) + if purchase_type.value in selected: + selected.remove(purchase_type.value) + else: + selected.add(purchase_type.value) + dialog_manager.dialog_data["eligible_purchase_types"] = sorted(selected) + + +@inject +async def on_purchase_types_continue( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + if not dialog_manager.dialog_data.get("eligible_purchase_types"): + await notifier.notify_user(user, i18n_key="ntf-giveaway.purchase-type-required") + return + await dialog_manager.switch_to(DashboardGiveaways.RULES) + + +@inject +async def on_rules_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + value = (message.text or "").strip() + if len(value) > 4000: + await notifier.notify_user(user, i18n_key="ntf-giveaway.rules-too-long") + return + dialog_manager.dialog_data["rules_text"] = None if value in {"", "-"} else value + await dialog_manager.switch_to(DashboardGiveaways.ACTIVITY) + + +async def on_activity_select( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + dialog_manager.dialog_data["is_active"] = widget.widget_id == "enable" + await dialog_manager.switch_to(DashboardGiveaways.CONFIGURATOR) + + +@inject +async def on_confirm( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + create_campaign: FromDishka[CreateGiveawayCampaign], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + data = dialog_manager.dialog_data + try: + campaign = await create_campaign( + user, + CreateGiveawayCampaignDto( + name=data["name"], + starts_at=datetime.fromisoformat(data["starts_at_iso"]), + ends_at=datetime.fromisoformat(data["ends_at_iso"]), + winner_count=int(data["winner_count"]), + prize_amount=Decimal(data["prize_amount"]), + eligible_plan_id=int(data["eligible_plan_id"]), + eligible_duration_days=int(data["eligible_duration_days"]), + eligible_purchase_types=[ + PurchaseType(value) for value in data["eligible_purchase_types"] + ], + rules_text=data.get("rules_text"), + is_active=bool(data["is_active"]), + ), + ) + except Exception: + logger.exception(f"{user.log} Failed to create giveaway campaign") + await notifier.notify_user(user, i18n_key="ntf-error.unknown") + return + await notifier.notify_user(user, i18n_key="ntf-giveaway.admin-created") + dialog_manager.dialog_data.clear() + dialog_manager.dialog_data["campaign_id"] = campaign.id + await dialog_manager.switch_to(DashboardGiveaways.VIEW) + + +async def on_campaign_select( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + dialog_manager.dialog_data["campaign_id"] = int(dialog_manager.item_id) # type: ignore[attr-defined] + await dialog_manager.switch_to(DashboardGiveaways.VIEW) + + +@inject +async def on_manual_entry_request( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + giveaway_dao: FromDishka[GiveawayDao], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + campaign = await giveaway_dao.get_campaign(campaign_id) + if not campaign or campaign.status not in { + GiveawayCampaignStatus.DRAFT, + GiveawayCampaignStatus.ACTIVE, + }: + await notifier.notify_user( + user, + i18n_key="ntf-giveaway.manual-entry-unavailable", + ) + return + logger.info(f"{user.log} Started manual entry creation for campaign='{campaign_id}'") + await dialog_manager.switch_to(DashboardGiveaways.MANUAL_ENTRY_PHONE) + + +@inject +async def on_manual_entry_phone_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + add_manual_entry: FromDishka[AddManualGiveawayEntry], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + phone = (message.text or "").strip() + if not phone.isdigit() or not 10 <= len(phone) <= 15: + await notifier.notify_user( + user, + i18n_key="ntf-giveaway.manual-phone-invalid", + ) + return + + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + try: + entry = await add_manual_entry( + user, + AddManualGiveawayEntryDto( + campaign_id=campaign_id, + phone=phone, + ), + ) + except ValueError as error: + logger.warning( + f"{user.log} Manual giveaway entry rejected for campaign='{campaign_id}': {error}" + ) + await notifier.notify_user( + user, + i18n_key="ntf-giveaway.manual-entry-unavailable", + ) + await dialog_manager.switch_to(DashboardGiveaways.VIEW) + return + except Exception: + logger.exception( + f"{user.log} Failed to add manual giveaway entry campaign='{campaign_id}'" + ) + await notifier.notify_user(user, i18n_key="ntf-error.unknown") + return + + dialog_manager.dialog_data["manual_entry_phone"] = phone + dialog_manager.dialog_data["manual_entry_code"] = entry.participant_code + await dialog_manager.switch_to(DashboardGiveaways.MANUAL_ENTRY_ADDED) + + +@inject +async def on_toggle_status( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + set_status: FromDishka[SetGiveawayStatus], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + new_status = ( + GiveawayCampaignStatus.DRAFT + if widget.widget_id == "disable" + else GiveawayCampaignStatus.ACTIVE + ) + await set_status( + user, + SetGiveawayStatusDto( + campaign_id=int(dialog_manager.dialog_data["campaign_id"]), + status=new_status, + ), + ) + await notifier.notify_user(user, i18n_key="ntf-giveaway.status-updated") + + +@inject +async def on_complete( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + set_status: FromDishka[SetGiveawayStatus], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + await set_status( + user, + SetGiveawayStatusDto( + campaign_id=int(dialog_manager.dialog_data["campaign_id"]), + status=GiveawayCampaignStatus.COMPLETED, + ), + ) + await notifier.notify_user(user, i18n_key="ntf-giveaway.completed") + + +@inject +async def on_select_winner( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + select_winner: FromDishka[SelectGiveawayWinner], + giveaway_dao: FromDishka[GiveawayDao], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + campaign_id = int(dialog_manager.dialog_data["campaign_id"]) + try: + winner = await select_winner(user, campaign_id) + campaign = await giveaway_dao.get_campaign(campaign_id) + await notifier.notify_user( + user, + payload=None, + i18n_key="ntf-giveaway.winner-selected", + ) + logger.info( + f"{user.log} Winner rank='{winner.winner_rank}' selected " + f"for campaign='{campaign_id}', prize='{campaign.prize_amount if campaign else 0}'" + ) + await dialog_manager.switch_to(DashboardGiveaways.WINNERS) + except ValueError as error: + logger.warning(f"{user.log} Giveaway winner selection rejected: {error}") + await notifier.notify_user(user, i18n_key="ntf-giveaway.winner-unavailable") + + +async def on_archive_request( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(DashboardGiveaways.ARCHIVE_CONFIRM) + + +@inject +async def on_archive_confirm( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + archive_campaign: FromDishka[ArchiveGiveawayCampaign], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + await archive_campaign(user, int(dialog_manager.dialog_data["campaign_id"])) + await notifier.notify_user(user, i18n_key="ntf-giveaway.archived") + await dialog_manager.start(DashboardGiveaways.LIST, mode=StartMode.RESET_STACK) + + +async def on_delete_request( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(DashboardGiveaways.DELETE_CONFIRM) + + +async def on_rules_edit_request( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(DashboardGiveaways.RULES_EDIT) + + +@inject +async def on_rules_edit_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + update_rules: FromDishka[UpdateGiveawayRules], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + value = (message.text or "").strip() + if len(value) > 4000: + await notifier.notify_user(user, i18n_key="ntf-giveaway.rules-too-long") + return + await update_rules( + user, + UpdateGiveawayRulesDto( + campaign_id=int(dialog_manager.dialog_data["campaign_id"]), + rules_text=None if value in {"", "-"} else value, + ), + ) + await notifier.notify_user(user, i18n_key="ntf-giveaway.rules-updated") + await dialog_manager.switch_to(DashboardGiveaways.VIEW) + + +@inject +async def on_delete_confirm( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + delete_campaign: FromDishka[DeleteGiveawayCampaign], + notifier: FromDishka[Notifier], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + await delete_campaign(user, int(dialog_manager.dialog_data["campaign_id"])) + await notifier.notify_user(user, i18n_key="ntf-giveaway.deleted") + await dialog_manager.start(DashboardGiveaways.LIST, mode=StartMode.RESET_STACK) diff --git a/src/telegram/routers/dashboard/handlers.py b/src/telegram/routers/dashboard/handlers.py deleted file mode 100644 index 165d0cc1..00000000 --- a/src/telegram/routers/dashboard/handlers.py +++ /dev/null @@ -1,30 +0,0 @@ -from aiogram.types import CallbackQuery -from aiogram_dialog import DialogManager -from aiogram_dialog.widgets.kbd import Button -from dishka import FromDishka -from dishka.integrations.aiogram_dialog import inject - -from src.application.common import Notifier -from src.application.dto import MessagePayloadDto, UserDto -from src.core.constants import USER_KEY -from src.telegram.keyboards import get_boosty_keyboard - - -@inject -async def show_dev_promocode( - callback: CallbackQuery, - widget: Button, - dialog_manager: DialogManager, - notifier: FromDishka[Notifier], -) -> None: - user: UserDto = dialog_manager.middleware_data[USER_KEY] - - await notifier.notify_user( - user, - MessagePayloadDto( - i18n_key="development-promocode", - reply_markup=get_boosty_keyboard(), - disable_default_markup=False, - delete_after=None, - ), - ) diff --git a/src/telegram/routers/dashboard/promocodes/__init__.py b/src/telegram/routers/dashboard/promocodes/__init__.py new file mode 100644 index 00000000..7dd7c6ef --- /dev/null +++ b/src/telegram/routers/dashboard/promocodes/__init__.py @@ -0,0 +1,5 @@ +from . import dialog + +__all__ = [ + "dialog", +] diff --git a/src/telegram/routers/dashboard/promocodes/dialog.py b/src/telegram/routers/dashboard/promocodes/dialog.py new file mode 100644 index 00000000..2ccc116a --- /dev/null +++ b/src/telegram/routers/dashboard/promocodes/dialog.py @@ -0,0 +1,244 @@ +from aiogram_dialog import Dialog, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, ListGroup, Row, Select, Start, SwitchTo +from magic_filter import F + +from src.core.enums import BannerName, PromoAudience +from src.telegram.keyboards import main_menu_button +from src.telegram.states import Dashboard, DashboardPromocodes +from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate + +from .getters import audience_getter, configurator_getter, list_getter, plans_getter +from .handlers import ( + on_audience_select, + on_code_input, + on_confirm, + on_create, + on_deactivate, + on_lifetime_input, + on_max_activations_input, + on_plan_select, + on_promocode_select, + on_reward_input, +) + +promocodes_main = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocodes-main"), + Row( + SwitchTo( + text=I18nFormat("btn-promocodes.list"), + id="list", + state=DashboardPromocodes.LIST, + ), + Button( + text=I18nFormat("btn-promocodes.create"), + id="create", + on_click=on_create, + ), + ), + Row( + Start( + text=I18nFormat("btn-back.general"), + id="back", + state=Dashboard.MAIN, + mode=StartMode.RESET_STACK, + ), + *main_menu_button, + ), + IgnoreUpdate(), + state=DashboardPromocodes.MAIN, +) + +promocodes_list = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocodes-list"), + ListGroup( + Row( + Button( + text=I18nFormat( + "btn-promocode.item", + is_active=F["item"]["is_active"], + code=F["item"]["code"], + discount_percent=F["item"]["discount_percent"], + ), + id="promocode_select", + on_click=on_promocode_select, + ), + ), + id="promocodes_list", + item_id_getter=lambda item: item["id"], + items="promocodes", + ), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardPromocodes.MAIN, + ), + ), + IgnoreUpdate(), + state=DashboardPromocodes.LIST, + getter=list_getter, +) + +configurator = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocode-configurator", when=~F["is_edit"]), + I18nFormat("msg-promocode-view", when=F["is_edit"]), + Row( + Button( + text=I18nFormat("btn-promocode.confirm"), + id="confirm", + on_click=on_confirm, + ), + when=~F["is_edit"], + ), + Row( + Button( + text=I18nFormat("btn-promocode.deactivate"), + id="deactivate", + on_click=on_deactivate, + ), + when=F["is_edit"] & F["is_active"], + ), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back_to_lifetime", + state=DashboardPromocodes.LIFETIME, + when=~F["is_edit"], + ), + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back_to_list", + state=DashboardPromocodes.LIST, + when=F["is_edit"], + ), + ), + IgnoreUpdate(), + state=DashboardPromocodes.CONFIGURATOR, + getter=configurator_getter, +) + +code_input = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocode-code"), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardPromocodes.MAIN, + ), + ), + MessageInput(func=on_code_input), + IgnoreUpdate(), + state=DashboardPromocodes.CODE, +) + +reward_input = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocode-reward"), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardPromocodes.CODE, + ), + ), + MessageInput(func=on_reward_input), + IgnoreUpdate(), + state=DashboardPromocodes.REWARD, +) + +allowed_plan = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocode-allowed"), + Column( + Select( + text=I18nFormat("btn-promocode.plan-choice", name=F["item"]["name"]), + id="plan_select", + item_id_getter=lambda item: item["id"], + items="plans", + type_factory=int, + on_click=on_plan_select, + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardPromocodes.REWARD, + ), + ), + IgnoreUpdate(), + state=DashboardPromocodes.ALLOWED, + getter=plans_getter, +) + +audience = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocode-availability"), + Column( + Select( + text=I18nFormat("btn-promocode.audience-choice", audience=F["item"]), + id="audience_select", + item_id_getter=lambda item: item.value, + items="audiences", + type_factory=PromoAudience, + on_click=on_audience_select, + ), + ), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardPromocodes.ALLOWED, + ), + ), + IgnoreUpdate(), + state=DashboardPromocodes.AVAILABILITY, + getter=audience_getter, +) + +max_activations = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocode-type"), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardPromocodes.AVAILABILITY, + ), + ), + MessageInput(func=on_max_activations_input), + IgnoreUpdate(), + state=DashboardPromocodes.TYPE, +) + +lifetime_input = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-promocode-lifetime"), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id="back", + state=DashboardPromocodes.TYPE, + ), + ), + MessageInput(func=on_lifetime_input), + IgnoreUpdate(), + state=DashboardPromocodes.LIFETIME, +) + +router = Dialog( + promocodes_main, + promocodes_list, + configurator, + code_input, + reward_input, + allowed_plan, + audience, + max_activations, + lifetime_input, +) diff --git a/src/telegram/routers/dashboard/promocodes/getters.py b/src/telegram/routers/dashboard/promocodes/getters.py new file mode 100644 index 00000000..e2adfaef --- /dev/null +++ b/src/telegram/routers/dashboard/promocodes/getters.py @@ -0,0 +1,95 @@ +from typing import Any, Optional + +from aiogram_dialog import DialogManager +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from src.application.common import TranslatorRunner +from src.application.common.dao import PlanDao, PromocodeDao +from src.core.enums import PromoAudience + + +@inject +async def list_getter( + dialog_manager: DialogManager, + promocode_dao: FromDishka[PromocodeDao], + **kwargs: Any, +) -> dict[str, Any]: + promocodes = await promocode_dao.get_all() + + items = [ + { + "id": p.id, + "code": p.code, + "discount_percent": p.discount_percent, + "is_active": p.is_active, + } + for p in promocodes + ] + + return { + "promocodes": items, + "has_promocodes": bool(items), + } + + +@inject +async def plans_getter( + dialog_manager: DialogManager, + plan_dao: FromDishka[PlanDao], + i18n: FromDishka[TranslatorRunner], + **kwargs: Any, +) -> dict[str, Any]: + all_plans = await plan_dao.get_all() + active_plans = [p for p in all_plans if p.is_active] + + items = [ + { + "id": plan.id, + "name": i18n.get(plan.name), + } + for plan in active_plans + ] + + dialog_manager.dialog_data["_plans_cache"] = items + + return { + "plans": items, + "has_plans": bool(items), + } + + +async def audience_getter( + dialog_manager: DialogManager, + **kwargs: Any, +) -> dict[str, Any]: + return {"audiences": list(PromoAudience)} + + +@inject +async def configurator_getter( + dialog_manager: DialogManager, + promocode_dao: FromDishka[PromocodeDao], + **kwargs: Any, +) -> dict[str, Any]: + data = dialog_manager.dialog_data + is_edit: bool = data.get("is_edit", False) + + activations_count: Optional[int] = None + if is_edit: + promo_id = data.get("promocode_id") + if promo_id is not None: + activations_count = await promocode_dao.count_activations(int(promo_id)) + + return { + "is_edit": is_edit, + "code": data.get("code", "—"), + "discount_percent": data.get("discount_percent", "—"), + "plan_name": data.get("plan_name", "—"), + "audience": data.get("audience", ""), + "max_activations": data.get("max_activations", "—"), + "expires_at_str": data.get("expires_at_str", "—"), + "is_active": data.get("is_active", True), + "activations_count": activations_count if activations_count is not None else 0, + "promocode_id": data.get("promocode_id"), + } diff --git a/src/telegram/routers/dashboard/promocodes/handlers.py b/src/telegram/routers/dashboard/promocodes/handlers.py new file mode 100644 index 00000000..8a32119d --- /dev/null +++ b/src/telegram/routers/dashboard/promocodes/handlers.py @@ -0,0 +1,317 @@ +from datetime import datetime, timezone +from typing import Optional + +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import DialogManager, ShowMode, StartMode +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Select +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from loguru import logger + +from src.application.common import Notifier +from src.application.common.dao import PlanDao, PromocodeDao +from src.application.dto import UserDto +from src.application.use_cases.promocode.commands.management import ( + CreatePromocode, + CreatePromocodeDto, + DeactivatePromocode, + DeactivatePromocodeDto, +) +from src.core.constants import USER_KEY +from src.core.enums import PromoAudience +from src.core.exceptions import ( + PromocodeInvalidDiscountError, + PromocodeInvalidMaxActivationsError, + PromocodeNotFoundError, +) +from src.telegram.states import DashboardPromocodes +from src.telegram.utils import is_double_click + +_WIZARD_KEYS = [ + "code", + "discount_percent", + "plan_id", + "plan_name", + "audience", + "max_activations", + "expires_at_str", + "expires_at_iso", + "is_edit", + "is_active", + "activations_count", + "promocode_id", +] + + +async def on_create( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + for key in _WIZARD_KEYS: + dialog_manager.dialog_data.pop(key, None) + dialog_manager.dialog_data["is_edit"] = False + await dialog_manager.switch_to(DashboardPromocodes.CODE) + + +@inject +async def on_code_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], + promocode_dao: FromDishka[PromocodeDao], +) -> None: + dialog_manager.show_mode = ShowMode.EDIT + user: UserDto = dialog_manager.middleware_data[USER_KEY] + + if not message.text: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + code = message.text.strip().upper() + if not code or len(code) > 64: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + existing = await promocode_dao.get_by_code(code) + if existing: + await notifier.notify_user(user, i18n_key="ntf-promocode.code-already-exists") + return + + dialog_manager.dialog_data["code"] = code + logger.info(f"{user.log} Set promocode code='{code}'") + await dialog_manager.switch_to(DashboardPromocodes.REWARD) + + +@inject +async def on_reward_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + dialog_manager.show_mode = ShowMode.EDIT + user: UserDto = dialog_manager.middleware_data[USER_KEY] + + if not message.text: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + try: + percent = int(message.text.strip()) + if not (1 <= percent <= 99): + raise ValueError("out of range") + except ValueError: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + dialog_manager.dialog_data["discount_percent"] = percent + logger.info(f"{user.log} Set promocode discount_percent={percent}") + await dialog_manager.switch_to(DashboardPromocodes.ALLOWED) + + +async def on_plan_select( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + selected_plan_id: int, +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + + cached_plans: list = dialog_manager.dialog_data.get("_plans_cache", []) + plan_name = next( + (p["name"] for p in cached_plans if p["id"] == selected_plan_id), + str(selected_plan_id), + ) + + dialog_manager.dialog_data["plan_id"] = selected_plan_id + dialog_manager.dialog_data["plan_name"] = plan_name + logger.info(f"{user.log} Selected plan_id={selected_plan_id} for promocode") + await dialog_manager.switch_to(DashboardPromocodes.AVAILABILITY) + + +async def on_audience_select( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + selected_audience: PromoAudience, +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + dialog_manager.dialog_data["audience"] = selected_audience.value + logger.info(f"{user.log} Selected audience={selected_audience.value} for promocode") + await dialog_manager.switch_to(DashboardPromocodes.TYPE) + + +@inject +async def on_max_activations_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + dialog_manager.show_mode = ShowMode.EDIT + user: UserDto = dialog_manager.middleware_data[USER_KEY] + + if not message.text: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + try: + count = int(message.text.strip()) + if count < 1: + raise ValueError("must be >= 1") + except ValueError: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + dialog_manager.dialog_data["max_activations"] = count + logger.info(f"{user.log} Set promocode max_activations={count}") + await dialog_manager.switch_to(DashboardPromocodes.LIFETIME) + + +@inject +async def on_lifetime_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], +) -> None: + dialog_manager.show_mode = ShowMode.EDIT + user: UserDto = dialog_manager.middleware_data[USER_KEY] + + if not message.text: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + dt = _parse_date(message.text.strip()) + if dt is None: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return + + dialog_manager.dialog_data["expires_at_iso"] = dt.isoformat() + dialog_manager.dialog_data["expires_at_str"] = dt.strftime("%d.%m.%Y %H:%M UTC") + logger.info(f"{user.log} Set promocode expires_at={dt.isoformat()}") + await dialog_manager.switch_to(DashboardPromocodes.CONFIGURATOR) + + +def _parse_date(text: str) -> Optional[datetime]: + for fmt in ("%d.%m.%Y %H:%M", "%d.%m.%Y"): + try: + dt = datetime.strptime(text, fmt) + if fmt == "%d.%m.%Y": + dt = dt.replace(hour=23, minute=59, second=59) + return dt.replace(tzinfo=timezone.utc) + except ValueError: + continue + return None + + +@inject +async def on_confirm( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], + create_promocode: FromDishka[CreatePromocode], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + data = dialog_manager.dialog_data + + try: + expires_at = datetime.fromisoformat(data["expires_at_iso"]) + dto = CreatePromocodeDto( + code=data["code"], + discount_percent=int(data["discount_percent"]), + plan_id=int(data["plan_id"]), + audience=PromoAudience(data["audience"]), + max_activations=int(data["max_activations"]), + expires_at=expires_at, + ) + await create_promocode(user, dto) + + logger.info(f"{user.log} Created promocode '{data['code']}'") + await notifier.notify_user(user, i18n_key="ntf-promocode.admin-created") + await dialog_manager.start(DashboardPromocodes.MAIN, mode=StartMode.RESET_STACK) + + except PromocodeInvalidDiscountError: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + except PromocodeInvalidMaxActivationsError: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + except Exception as e: + logger.warning(f"{user.log} Promocode creation failed: {e}") + await notifier.notify_user(user, i18n_key="ntf-error.unknown") + + +@inject +async def on_promocode_select( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], + promocode_dao: FromDishka[PromocodeDao], + plan_dao: FromDishka[PlanDao], +) -> None: + promo_id = int(dialog_manager.item_id) # type: ignore[attr-defined] + user: UserDto = dialog_manager.middleware_data[USER_KEY] + + promocode = await promocode_dao.get_by_id(promo_id) + if not promocode: + await notifier.notify_user(user, i18n_key="ntf-promocode.not-found") + return + + plan_name: str = str(promocode.plan_id) + plan = await plan_dao.get_by_id(promocode.plan_id) + if plan: + plan_name = plan.name + + expires_str = promocode.expires_at.strftime("%d.%m.%Y %H:%M UTC") + + dialog_manager.dialog_data.update( + { + "is_edit": True, + "promocode_id": promocode.id, + "code": promocode.code, + "discount_percent": promocode.discount_percent, + "plan_id": promocode.plan_id, + "plan_name": plan_name, + "audience": promocode.audience.value, + "max_activations": promocode.max_activations, + "expires_at_str": expires_str, + "is_active": promocode.is_active, + } + ) + + logger.info(f"{user.log} Viewing promocode id={promo_id}") + await dialog_manager.switch_to(DashboardPromocodes.CONFIGURATOR) + + +@inject +async def on_deactivate( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + notifier: FromDishka[Notifier], + deactivate_promocode: FromDishka[DeactivatePromocode], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + promo_id = dialog_manager.dialog_data.get("promocode_id") + + if promo_id is None: + await notifier.notify_user(user, i18n_key="ntf-error.unknown") + return + + if is_double_click(dialog_manager, key=f"deactivate_confirm_{promo_id}", cooldown=10): + try: + await deactivate_promocode(user, DeactivatePromocodeDto(promocode_id=int(promo_id))) + dialog_manager.dialog_data["is_active"] = False + logger.info(f"{user.log} Deactivated promocode id={promo_id}") + await notifier.notify_user(user, i18n_key="ntf-promocode.admin-deactivated") + await dialog_manager.switch_to(DashboardPromocodes.LIST) + except PromocodeNotFoundError: + await notifier.notify_user(user, i18n_key="ntf-promocode.not-found") + return + + await notifier.notify_user(user, i18n_key="ntf-common.double-click-confirm") + logger.debug(f"{user.log} Clicked deactivate for promocode id={promo_id} (awaiting confirmation)") diff --git a/src/telegram/routers/dashboard/users/user/getters.py b/src/telegram/routers/dashboard/users/user/getters.py index 46ff7ca5..83823566 100644 --- a/src/telegram/routers/dashboard/users/user/getters.py +++ b/src/telegram/routers/dashboard/users/user/getters.py @@ -165,6 +165,7 @@ async def devices_getter( "current_count": data.current_count, "max_count": i18n_format_device_limit(data.max_count), "devices": formatted_devices, + "devices_unavailable": data.devices_unavailable, } diff --git a/src/telegram/routers/dashboard/users/user/handlers.py b/src/telegram/routers/dashboard/users/user/handlers.py index 50895416..e0a895ac 100644 --- a/src/telegram/routers/dashboard/users/user/handlers.py +++ b/src/telegram/routers/dashboard/users/user/handlers.py @@ -64,6 +64,7 @@ from src.application.use_cases.user.queries.profile import GetUserDevices from src.core.constants import TARGET_TELEGRAM_ID, USER_KEY from src.core.enums import Role +from src.core.exceptions import RemnawaveDevicesUnavailableError from src.core.utils.validators import parse_int from src.telegram.states import DashboardUser from src.telegram.utils import is_double_click @@ -199,6 +200,10 @@ async def on_devices( target_telegram_id = dialog_manager.dialog_data[TARGET_TELEGRAM_ID] user_devices = await get_user_devices(user, target_telegram_id) + if user_devices.devices_unavailable: + await notifier.notify_user(user, i18n_key="ntf-devices.unavailable") + return + if not user_devices.current_count: await notifier.notify_user(user, i18n_key="ntf-user.devices-empty") return @@ -212,6 +217,7 @@ async def on_device_delete( widget: Button, dialog_manager: DialogManager, delete_user_device: FromDishka[DeleteUserDevice], + notifier: FromDishka[Notifier], ) -> None: selected_short_hwid = dialog_manager.item_id # type: ignore[attr-defined] hwid_map: list[dict] = dialog_manager.dialog_data.get("hwid_map") # type: ignore[assignment] @@ -223,7 +229,15 @@ async def on_device_delete( user: UserDto = dialog_manager.middleware_data[USER_KEY] target_telegram_id = dialog_manager.dialog_data[TARGET_TELEGRAM_ID] - has_devices = await delete_user_device(user, DeleteUserDeviceDto(target_telegram_id, full_hwid)) + try: + has_devices = await delete_user_device( + user, + DeleteUserDeviceDto(target_telegram_id, full_hwid), + ) + except RemnawaveDevicesUnavailableError: + await notifier.notify_user(user, i18n_key="ntf-devices.unavailable") + await dialog_manager.switch_to(state=DashboardUser.SUBSCRIPTION) + return if not has_devices: await dialog_manager.switch_to(state=DashboardUser.SUBSCRIPTION) diff --git a/src/telegram/routers/extra/__init__.py b/src/telegram/routers/extra/__init__.py index 26a99c41..c7057f37 100644 --- a/src/telegram/routers/extra/__init__.py +++ b/src/telegram/routers/extra/__init__.py @@ -1,7 +1,8 @@ -from . import commands, goto, inline, member, notification, payment, test +from . import commands, giveaway, goto, inline, member, notification, payment, test __all__ = [ "commands", + "giveaway", "goto", "inline", "member", diff --git a/src/telegram/routers/extra/giveaway.py b/src/telegram/routers/extra/giveaway.py new file mode 100644 index 00000000..ec936b6e --- /dev/null +++ b/src/telegram/routers/extra/giveaway.py @@ -0,0 +1,92 @@ +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message +from dishka import FromDishka +from loguru import logger + +from src.application.common import Notifier +from src.application.dto import MessagePayloadDto, UserDto +from src.application.use_cases.giveaway.commands import ( + SaveGiveawayPhone, + SaveGiveawayPhoneDto, +) +from src.telegram.states import GiveawayPhone + +router = Router(name=__name__) + + +@router.callback_query(F.data.startswith("giveaway_phone:")) +async def on_leave_phone( + callback: CallbackQuery, + state: FSMContext, + user: UserDto, + notifier: FromDishka[Notifier], +) -> None: + try: + entry_id = int((callback.data or "").split(":", maxsplit=1)[1]) + except (IndexError, ValueError): + await callback.answer() + return + + await state.set_state(GiveawayPhone.INPUT) + await state.update_data(giveaway_entry_id=entry_id) + await notifier.notify_user( + user, + payload=MessagePayloadDto( + i18n_key="ntf-giveaway.phone-request", + delete_after=None, + ), + ) + await callback.answer() + + +@router.callback_query(F.data == "giveaway_skip") +async def on_skip_phone(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + if callback.message: + await callback.message.edit_reply_markup(reply_markup=None) + await callback.answer() + + +@router.message(GiveawayPhone.INPUT) +async def on_phone_input( + message: Message, + state: FSMContext, + user: UserDto, + notifier: FromDishka[Notifier], + save_phone: FromDishka[SaveGiveawayPhone], +) -> None: + phone = (message.text or "").strip() + if not phone.isdigit() or not 10 <= len(phone) <= 15: + await notifier.notify_user( + user, + payload=MessagePayloadDto( + i18n_key="ntf-giveaway.phone-invalid", + delete_after=None, + ), + ) + return + + data = await state.get_data() + entry_id = data.get("giveaway_entry_id") + if not isinstance(entry_id, int): + await state.clear() + await notifier.notify_user(user, i18n_key="ntf-error.unknown") + return + + try: + await save_phone( + user, + SaveGiveawayPhoneDto( + entry_id=entry_id, + user_telegram_id=user.telegram_id, + phone=phone, + ), + ) + except Exception: + logger.exception(f"{user.log} Failed to save giveaway phone for entry '{entry_id}'") + await notifier.notify_user(user, i18n_key="ntf-error.unknown") + return + + await state.clear() + await notifier.notify_user(user, i18n_key="ntf-giveaway.phone-saved") diff --git a/src/telegram/routers/menu/dialog.py b/src/telegram/routers/menu/dialog.py index 31042650..f407901d 100644 --- a/src/telegram/routers/menu/dialog.py +++ b/src/telegram/routers/menu/dialog.py @@ -7,7 +7,6 @@ ListGroup, Row, Start, - SwitchInlineQueryChosenChatButton, SwitchTo, Url, ) @@ -16,11 +15,11 @@ from magic_filter import F from src.application.common.policy import Permission -from src.core.constants import INLINE_QUERY_INVITE, PAYMENT_PREFIX +from src.core.constants import PAYMENT_PREFIX from src.core.enums import BannerName from src.telegram.keyboards import connect_buttons, custom_buttons from src.telegram.routers.dashboard.users.handlers import on_user_search -from src.telegram.states import Dashboard, MainMenu, Subscription +from src.telegram.states import ClientGiveaways, Dashboard, MainMenu, Subscription from src.telegram.utils import require_permission from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate from src.telegram.window import Window @@ -47,6 +46,7 @@ menu = Window( Banner(BannerName.MENU), I18nFormat("msg-main-menu"), + I18nFormat("msg-main-menu-how-to-connect"), Row( *connect_buttons, Button( @@ -57,6 +57,15 @@ ), when=F["has_subscription"], ), + Row( + Start( + text=I18nFormat("btn-menu.giveaways"), + id="giveaways", + state=ClientGiveaways.LIST, + mode=StartMode.RESET_STACK, + when=F["show_giveaways"], + ), + ), Row( Button( text=I18nFormat("btn-menu.trial"), @@ -86,13 +95,10 @@ on_click=on_invite, when=F["referral_enabled"], ), - SwitchInlineQueryChosenChatButton( + Url( text=I18nFormat("btn-menu.invite"), - query=Format(INLINE_QUERY_INVITE), - allow_user_chats=True, - allow_group_chats=True, - allow_channel_chats=True, id="send", + url=Format("{referral_share_url}"), when=~F["referral_enabled"], ), Url( @@ -124,7 +130,7 @@ Button( text=I18nFormat("btn-common.devices-empty"), id="devices_empty", - when=~F["has_devices"], + when=F["show_devices_empty"], ), ), ListGroup( @@ -233,13 +239,10 @@ id="qr", on_click=on_show_qr, ), - SwitchInlineQueryChosenChatButton( + Url( text=I18nFormat("btn-invite.send"), - query=Format(INLINE_QUERY_INVITE), - allow_user_chats=True, - allow_group_chats=True, - allow_channel_chats=True, id="send", + url=Format("{referral_share_url}"), ), ), Row( diff --git a/src/telegram/routers/menu/getters.py b/src/telegram/routers/menu/getters.py index 5fa2ca9f..5c25aff6 100644 --- a/src/telegram/routers/menu/getters.py +++ b/src/telegram/routers/menu/getters.py @@ -1,4 +1,5 @@ from typing import Any +from urllib.parse import quote from aiogram_dialog import DialogManager from dishka import FromDishka @@ -9,9 +10,10 @@ from src.application.common.dao import ReferralDao, SettingsDao, SubscriptionDao from src.application.dto import UserDto from src.application.services import BotService +from src.application.use_cases.giveaway.queries import ListClientGiveaways from src.application.use_cases.misc.queries.menu import GetMenuData from src.core.config import AppConfig -from src.core.exceptions import MenuRenderError +from src.core.exceptions import MenuRenderError, RemnawaveDevicesUnavailableError from src.core.utils.i18n_helpers import ( i18n_format_device_limit, i18n_format_expire_time, @@ -28,11 +30,16 @@ async def menu_getter( bot_service: FromDishka[BotService], i18n: FromDishka[TranslatorRunner], get_menu_data: FromDishka[GetMenuData], + list_client_giveaways: FromDishka[ListClientGiveaways], **kwargs: Any, ) -> dict[str, Any]: try: menu_data = await get_menu_data(user) support_url = bot_service.get_support_url(text=i18n.get("message.help")) + referral_share_url = ( + f"https://t.me/share/url?url={quote(menu_data.referral_url, safe='')}" + ) + giveaways = await list_client_giveaways(user) purchase_discount = user.purchase_discount or 0 personal_discount = user.personal_discount or 0 @@ -52,6 +59,8 @@ async def menu_getter( "support_url": support_url, # referral "referral_enabled": menu_data.is_referral_enabled, + "referral_share_url": referral_share_url, + "show_giveaways": bool(giveaways), # defaults "has_subscription": False, "connectable": False, @@ -141,7 +150,16 @@ async def devices_getter( if not current_subscription: raise ValueError(f"Current subscription for user '{user.telegram_id}' not found") - devices = await remnawave.get_devices(current_subscription.user_remna_id) + devices_unavailable = False + try: + devices = await remnawave.get_devices(current_subscription.user_remna_id) + except RemnawaveDevicesUnavailableError: + logger.warning( + f"Unable to render devices for Telegram user '{user.telegram_id}', " + f"Remnawave user '{current_subscription.user_remna_id}' is unavailable" + ) + devices = [] + devices_unavailable = True formatted_devices = [ { @@ -164,6 +182,8 @@ async def devices_getter( "devices": formatted_devices, "devices_empty": len(devices) == 0, "has_devices": len(devices) > 0, + "show_devices_empty": not devices_unavailable and len(devices) == 0, + "devices_unavailable": devices_unavailable, } @@ -191,6 +211,7 @@ async def invite_getter( referrals = await referral_dao.get_referrals_count(user.telegram_id) payments = await referral_dao.get_referrals_with_payment_count(user.telegram_id) referral_url = await bot_service.get_referral_url(user.referral_code) + referral_share_url = f"https://t.me/share/url?url={quote(referral_url, safe='')}" support_url = bot_service.get_support_url(text=i18n.get("message.withdraw-points")) return { @@ -201,6 +222,7 @@ async def invite_getter( "is_points_reward": settings.referral.reward.is_points, "has_points": True if user.points > 0 else False, "referral_url": referral_url, + "referral_share_url": referral_share_url, "withdraw": support_url, } diff --git a/src/telegram/routers/menu/handlers.py b/src/telegram/routers/menu/handlers.py index 49621c17..b68caac3 100644 --- a/src/telegram/routers/menu/handlers.py +++ b/src/telegram/routers/menu/handlers.py @@ -25,6 +25,7 @@ from src.application.use_cases.user.queries.plans import GetAvailableTrial from src.core.constants import USER_KEY from src.core.enums import MediaType +from src.core.exceptions import RemnawaveDevicesUnavailableError from src.core.utils.i18n_helpers import i18n_format_expire_time from src.core.utils.time import get_traffic_reset_delta from src.telegram.keyboards import CALLBACK_CHANNEL_CONFIRM, CALLBACK_RULES_ACCEPT @@ -122,9 +123,15 @@ async def on_device_delete_confirm( if not full_hwid: raise ValueError(f"Full HWID not found for '{selected_short_hwid}'") - await delete_user_device( - user, DeleteUserDeviceDto(telegram_id=user.telegram_id, hwid=full_hwid) - ) + try: + await delete_user_device( + user, DeleteUserDeviceDto(telegram_id=user.telegram_id, hwid=full_hwid) + ) + except RemnawaveDevicesUnavailableError: + await notifier.notify_user(user=user, i18n_key="ntf-devices.unavailable") + await dialog_manager.switch_to(state=MainMenu.DEVICES) + return + await notifier.notify_user(user=user, i18n_key="ntf-devices.deleted") await dialog_manager.switch_to(state=MainMenu.DEVICES) @@ -138,7 +145,13 @@ async def on_device_delete_all_confirm( notifier: FromDishka[Notifier], ) -> None: user: UserDto = dialog_manager.middleware_data[USER_KEY] - await delete_user_all_devices(user) + try: + await delete_user_all_devices(user) + except RemnawaveDevicesUnavailableError: + await notifier.notify_user(user=user, i18n_key="ntf-devices.unavailable") + await dialog_manager.switch_to(state=MainMenu.DEVICES) + return + await notifier.notify_user(user=user, i18n_key="ntf-devices.all-deleted") await dialog_manager.switch_to(state=MainMenu.DEVICES) diff --git a/src/telegram/routers/subscription/dialog.py b/src/telegram/routers/subscription/dialog.py index df7f46ea..dc181030 100644 --- a/src/telegram/routers/subscription/dialog.py +++ b/src/telegram/routers/subscription/dialog.py @@ -1,5 +1,6 @@ from aiogram.enums import ButtonStyle from aiogram_dialog import Dialog, Window +from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, Group, Row, Select, SwitchTo, Url from aiogram_dialog.widgets.style import Style from aiogram_dialog.widgets.text import Format @@ -26,6 +27,7 @@ on_get_subscription, on_payment_method_select, on_plan_select, + on_promocode_input, on_subscription_plans, ) @@ -52,20 +54,34 @@ when=F["has_active_subscription"], ), ), - # Row( - # Button( - # text=I18nFormat("btn-subscription.promocode"), - # id=f"{PAYMENT_PREFIX}promocode", - # on_click=show_dev_popup, - # # state=Subscription.PROMOCODE, - # ), - # ), + Row( + SwitchTo( + text=I18nFormat("btn-subscription.promocode"), + id=f"{PAYMENT_PREFIX}promocode", + state=Subscription.PROMOCODE, + ), + ), *back_main_menu_button, IgnoreUpdate(), state=Subscription.MAIN, getter=subscription_getter, ) +promocode = Window( + Banner(BannerName.SUBSCRIPTION), + I18nFormat("msg-subscription-promocode"), + MessageInput(func=on_promocode_input), + Row( + SwitchTo( + text=I18nFormat("btn-back.general"), + id=f"{PAYMENT_PREFIX}back_promo", + state=Subscription.MAIN, + ), + ), + IgnoreUpdate(), + state=Subscription.PROMOCODE, +) + plan = Window( Banner(BannerName.SUBSCRIPTION), I18nFormat("msg-subscription-plan"), @@ -269,6 +285,7 @@ router = Dialog( subscription, + promocode, plan, plans, duration, diff --git a/src/telegram/routers/subscription/getters.py b/src/telegram/routers/subscription/getters.py index 82e7445f..98d088aa 100644 --- a/src/telegram/routers/subscription/getters.py +++ b/src/telegram/routers/subscription/getters.py @@ -22,6 +22,8 @@ ) from src.telegram.states import Subscription +from .handlers import PROMO_CODE_KEY, PROMO_PLAN_ID_KEY + @inject async def subscription_getter( @@ -254,6 +256,10 @@ async def confirm_getter( key, kw = i18n_format_days(duration.days) gateways = await payment_gateway_dao.get_active() + promo_code = dialog_manager.dialog_data.get(PROMO_CODE_KEY) + promo_plan_id = dialog_manager.dialog_data.get(PROMO_PLAN_ID_KEY) + effective_promo_code = promo_code if (promo_plan_id == plan.id and promo_code) else None + return { "purchase_type": purchase_type, "plan": i18n.get(plan.name), @@ -272,6 +278,7 @@ async def confirm_getter( "only_single_gateway": len(gateways) == 1, "only_single_duration": only_single_duration, "is_free": is_free, + "promo_code": effective_promo_code or "0", } diff --git a/src/telegram/routers/subscription/handlers.py b/src/telegram/routers/subscription/handlers.py index 819deab5..6cd531cb 100644 --- a/src/telegram/routers/subscription/handlers.py +++ b/src/telegram/routers/subscription/handlers.py @@ -1,15 +1,17 @@ +from datetime import datetime, timezone from typing import Optional, TypedDict, cast from adaptix import Retort -from aiogram.types import CallbackQuery +from aiogram.types import CallbackQuery, Message from aiogram_dialog import DialogManager +from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Select from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from loguru import logger -from src.application.common import Notifier -from src.application.common.dao import PaymentGatewayDao, PlanDao, SettingsDao, SubscriptionDao +from src.application.common import Notifier, TranslatorRunner +from src.application.common.dao import PaymentGatewayDao, PlanDao, PromocodeDao, SettingsDao, SubscriptionDao from src.application.dto import PlanDto, PlanSnapshotDto, UserDto from src.application.services import PricingService from src.application.use_cases.gateways.commands.payment import ( @@ -21,13 +23,18 @@ from src.application.use_cases.plan.queries.match import MatchPlan, MatchPlanDto from src.application.use_cases.user.queries.plans import GetAvailablePlans from src.core.constants import PAYMENT_PREFIX, USER_KEY -from src.core.enums import PaymentGatewayType, PurchaseType, TransactionStatus +from src.core.enums import PaymentGatewayType, PromoAudience, PurchaseType, TransactionStatus from src.telegram.states import Subscription PAYMENT_CACHE_KEY = "payment_cache" CURRENT_DURATION_KEY = "selected_duration" CURRENT_METHOD_KEY = "selected_payment_method" +PROMO_ID_KEY = "promo_id" +PROMO_CODE_KEY = "promo_code" +PROMO_DISCOUNT_KEY = "promo_discount_percent" +PROMO_PLAN_ID_KEY = "promo_plan_id" + class CachedPaymentData(TypedDict): payment_id: str @@ -39,6 +46,11 @@ def _get_cache_key(duration: int, gateway_type: PaymentGatewayType) -> str: return f"{duration}:{gateway_type.value}" +def _clear_promo_data(dialog_manager: DialogManager) -> None: + for key in (PROMO_ID_KEY, PROMO_CODE_KEY, PROMO_DISCOUNT_KEY, PROMO_PLAN_ID_KEY): + dialog_manager.dialog_data.pop(key, None) + + def _load_payment_data(dialog_manager: DialogManager) -> dict[str, CachedPaymentData]: if PAYMENT_CACHE_KEY not in dialog_manager.dialog_data: dialog_manager.dialog_data[PAYMENT_CACHE_KEY] = {} @@ -73,7 +85,17 @@ async def _create_payment_and_get_data( transaction_plan = PlanSnapshotDto.from_plan(plan, duration.days) price = duration.get_price(payment_gateway.currency) - pricing = pricing_service.calculate(user, price, payment_gateway.currency) + + promo_discount: int = dialog_manager.dialog_data.get(PROMO_DISCOUNT_KEY, 0) + promo_plan_id: Optional[int] = dialog_manager.dialog_data.get(PROMO_PLAN_ID_KEY) + promocode_id: Optional[int] = dialog_manager.dialog_data.get(PROMO_ID_KEY) + + if promo_discount and promo_plan_id == plan.id: + pricing = pricing_service.calculate_with_promo(user, price, payment_gateway.currency, promo_discount) + effective_promocode_id = promocode_id + else: + pricing = pricing_service.calculate(user, price, payment_gateway.currency) + effective_promocode_id = None try: result = await create_payment( @@ -83,6 +105,7 @@ async def _create_payment_and_get_data( pricing=pricing, purchase_type=purchase_type, gateway_type=gateway_type, + promocode_id=effective_promocode_id, ), ) @@ -138,9 +161,8 @@ async def on_purchase_type_select( await dialog_manager.switch_to(state=Subscription.DURATION) return else: - logger.warning(f"{user.log} Tried to renew, but no matching plan found") - await notifier.notify_user(user, i18n_key="ntf-subscription.renew-plan-unavailable") - return + logger.warning(f"{user.log} Tried to renew, but no matching plan found - showing available plans") + await notifier.notify_user(user, i18n_key="ntf-subscription.renew-plan-changed") if len(plans) == 1: logger.info(f"{user.log} Auto-selected single plan '{plans[0].id}'") @@ -204,9 +226,8 @@ async def on_subscription_plans( # noqa: C901 await dialog_manager.switch_to(state=Subscription.DURATION) return else: - logger.warning(f"{user.log} Tried to renew, but no matching plan found") - await notifier.notify_user(user, i18n_key="ntf-subscription.renew-plan-unavailable") - return + logger.warning(f"{user.log} Tried to renew, but no matching plan found - showing available plans") + await notifier.notify_user(user, i18n_key="ntf-subscription.renew-plan-changed") if len(plans) == 1: logger.info(f"{user.log} Auto-selected single plan '{plans[0].id}'") @@ -260,6 +281,7 @@ async def on_plan_select( selected_plan: int, retort: FromDishka[Retort], plan_dao: FromDishka[PlanDao], + notifier: FromDishka[Notifier], ) -> None: user: UserDto = dialog_manager.middleware_data[USER_KEY] plan = await plan_dao.get_by_id(plan_id=selected_plan) @@ -271,6 +293,16 @@ async def on_plan_select( logger.info(f"{user.log} Selected plan '{plan.id}'") + stored_promo_plan_id: Optional[int] = dialog_manager.dialog_data.get(PROMO_PLAN_ID_KEY) + if stored_promo_plan_id is not None and stored_promo_plan_id != plan.id: + promo_code = dialog_manager.dialog_data.get(PROMO_CODE_KEY, "") + logger.info( + f"{user.log} Clearing promocode '{promo_code}' — " + f"plan_id mismatch (promo={stored_promo_plan_id}, selected={plan.id})" + ) + _clear_promo_data(dialog_manager) + await notifier.notify_user(user, i18n_key="ntf-promocode.plan-mismatch") + dialog_manager.dialog_data[PlanDto.__name__] = retort.dump(plan) dialog_manager.dialog_data.pop(PAYMENT_CACHE_KEY, None) dialog_manager.dialog_data.pop(CURRENT_DURATION_KEY, None) @@ -425,3 +457,72 @@ async def on_get_subscription( payment_id = dialog_manager.dialog_data["payment_id"] logger.info(f"{user.log} Getted free subscription '{payment_id}'") await process_payment.system(ProcessPaymentDto(payment_id, TransactionStatus.COMPLETED)) + + +@inject +async def on_promocode_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + promocode_dao: FromDishka[PromocodeDao], + subscription_dao: FromDishka[SubscriptionDao], + retort: FromDishka[Retort], + i18n: FromDishka[TranslatorRunner], +) -> None: + user: UserDto = dialog_manager.middleware_data[USER_KEY] + code = (message.text or "").strip().upper() + + if not code: + return + + promocode = await promocode_dao.get_by_code(code) + + if not promocode or promocode.id is None: + await message.answer(i18n.get("ntf-promocode.not-found")) + return + + if not promocode.is_active: + await message.answer(i18n.get("ntf-promocode.inactive")) + return + + now = datetime.now(tz=timezone.utc) + if promocode.expires_at.replace(tzinfo=timezone.utc) < now: + await message.answer(i18n.get("ntf-promocode.expired")) + return + + activation_count = await promocode_dao.count_activations(promocode.id) + if activation_count >= promocode.max_activations: + await message.answer(i18n.get("ntf-promocode.limit-exceeded")) + return + + already_used = await promocode_dao.has_user_activated(promocode.id, user.telegram_id) + if already_used: + await message.answer(i18n.get("ntf-promocode.already-used")) + return + + if promocode.audience == PromoAudience.WITH_ACTIVE_SUBSCRIPTION: + subscription = await subscription_dao.get_current(user.telegram_id) + if not subscription: + await message.answer(i18n.get("ntf-promocode.audience-mismatch")) + return + + raw_plan = dialog_manager.dialog_data.get(PlanDto.__name__) + if raw_plan: + plan = retort.load(raw_plan, PlanDto) + if promocode.plan_id != plan.id: + await message.answer(i18n.get("ntf-promocode.plan-mismatch")) + return + + dialog_manager.dialog_data[PROMO_ID_KEY] = promocode.id + dialog_manager.dialog_data[PROMO_CODE_KEY] = code + dialog_manager.dialog_data[PROMO_DISCOUNT_KEY] = promocode.discount_percent + dialog_manager.dialog_data[PROMO_PLAN_ID_KEY] = promocode.plan_id + dialog_manager.dialog_data.pop(PAYMENT_CACHE_KEY, None) + + logger.info( + f"{user.log} Applied promocode '{code}' " + f"(discount={promocode.discount_percent}%, plan_id={promocode.plan_id})" + ) + + await message.answer(i18n.get("ntf-promocode.applied", discount=promocode.discount_percent)) + await dialog_manager.switch_to(state=Subscription.MAIN) diff --git a/src/telegram/states.py b/src/telegram/states.py index 36ba92d7..83e45091 100644 --- a/src/telegram/states.py +++ b/src/telegram/states.py @@ -17,6 +17,18 @@ class Notification(StatesGroup): CLOSE = State() +class GiveawayPhone(StatesGroup): + INPUT = State() + + +class ClientGiveaways(StatesGroup): + LIST = State() + VIEW = State() + CONDITIONS = State() + PHONE = State() + RESULTS = State() + + class Subscription(StatesGroup): MAIN = State() PROMOCODE = State() @@ -64,6 +76,31 @@ class DashboardPromocodes(StatesGroup): ALLOWED = State() +class DashboardGiveaways(StatesGroup): + MAIN = State() + LIST = State() + NAME = State() + STARTS_AT = State() + ENDS_AT = State() + WINNER_COUNT = State() + PRIZE_AMOUNT = State() + PLAN = State() + DURATION = State() + PURCHASE_TYPES = State() + RULES = State() + ACTIVITY = State() + CONFIGURATOR = State() + VIEW = State() + ENTRIES = State() + WINNERS = State() + ARCHIVE_CONFIRM = State() + DELETE_CONFIRM = State() + RULES_VIEW = State() + RULES_EDIT = State() + MANUAL_ENTRY_PHONE = State() + MANUAL_ENTRY_ADDED = State() + + class DashboardAccess(StatesGroup): MAIN = State() CONDITIONS = State() diff --git a/src/web/endpoints/remnawave.py b/src/web/endpoints/remnawave.py index 001477d7..4167b580 100644 --- a/src/web/endpoints/remnawave.py +++ b/src/web/endpoints/remnawave.py @@ -26,8 +26,6 @@ async def remnawave_webhook( ) -> Response: try: raw_body = await request.body() - data = await request.json() - logger.debug(f"Received Remnawave webhook payload: '{data}'") payload = WebhookUtility.parse_webhook( body=raw_body.decode("utf-8"), headers=dict(request.headers), @@ -42,6 +40,8 @@ async def remnawave_webhook( logger.warning("Payload is empty after validation") raise HTTPException(status_code=401, detail="Unauthorized") + logger.debug(f"Received validated Remnawave webhook event '{payload.event}'") + try: if WebhookUtility.is_user_event(payload.event): user = cast(UserDto, WebhookUtility.get_typed_data(payload))