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..16005875 100644 --- a/assets/translations/ru/buttons.ftl +++ b/assets/translations/ru/buttons.ftl @@ -67,6 +67,7 @@ btn-dashboard = .users = 👥 Пользователи .broadcast = 📢 Рассылка .promocodes = 🎟 Промокоды + .giveaways = 🎁 Акции .access = 🔓 Режим доступа .remnawave = 🌊 RemnaWave .remnashop = 🛍 RemnaShop @@ -139,6 +140,9 @@ btn-user = .give-subscription = 🎁 Выдать подписку .subscription-internal-squads = ⏺️ Внутренние сквады .subscription-external-squads = ⏹️ Внешний сквад + .delete = 🗑 Удалить пользователя + .delete-confirm = Да, удалить пользователя + .delete-cancel = Отмена .allowed-plan-choice = { $selected -> [1] 🔘 @@ -478,8 +482,48 @@ 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 = 🎲 Выбрать следующего + .enable = 🟢 Включить + .disable = 🔴 Выключить + .keep-disabled = ⚪ Оставить выключенной + .complete = ✅ Завершить + .archive = 🗄 Очистить / архивировать + .archive-confirm = ⚠️ Да, очистить + .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..634533db 100644 --- a/assets/translations/ru/messages.ftl +++ b/assets/translations/ru/messages.ftl @@ -48,6 +48,13 @@ msg-main-menu = } +msg-main-menu-how-to-connect = +
📲 Как подключиться: + • 1. Скачайте приложение Happ + • 2. Нажмите «Подключиться» + • 3. Пролистайте немного вниз + • 4. Нажмите «Добавить подписку»
+ msg-menu-devices = 📱 Управление устройствами @@ -435,6 +442,43 @@ msg-user-main = } +msg-user-delete-confirm = + Вы уверены, что хотите полностью удалить пользователя? + +
+ Telegram ID: { NUMBER($telegram_id, useGrouping: 0) } + Username: { $username -> + [0] не указан + *[HAS] @{ $username } + } + Подписка: { $subscription_status -> + [0] отсутствует + *[HAS] { $subscription_status } + } +
+ + Будут удалены: + — локальный пользователь; + — локальные подписки; + — участие в акциях; + — активации промокодов; + — реферальные связи и награды; + — история сообщений рассылок; + — персональный доступ к тарифам; + — данные, связанные с VPN-подпиской. + + Финансовые транзакции будут сохранены без привязки к Telegram ID. + + Если пользователь есть в Remnawave, он будет удалён там тоже. + + Это действие нельзя отменить. + +msg-user-delete-input = + Последнее подтверждение + + Введите Telegram ID { NUMBER($telegram_id, useGrouping: 0) }, + чтобы полностью удалить пользователя. + msg-user-statistics = 📊 Статистика пользователя @@ -1087,6 +1131,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 +1211,11 @@ msg-subscription-confirm = { msg-subscription-details } + { $promo_code -> + [0] { empty } + *[HAS]
🎟 Промокод { $promo_code } применён
+ } + { $purchase_type -> [RENEW] ⚠️ Текущая подписка будет продлена на выбранный срок. [CHANGE] ⚠️ Текущая подписка будет заменена выбранной без пересчета оставшегося срока. @@ -1252,31 +1308,170 @@ 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-activity = + Включить акцию сразу после создания? + +msg-giveaway-configurator = + 🎁 Новая акция — подтверждение + +
+ Название: { $name } + Период: { $starts_at } — { $ends_at } + Победителей: { $winner_count } + Приз одному: { $prize_amount } ₽ + Тариф: { $plan_name } + Срок: { $duration_days } дней + Типы покупки: { $purchase_types } + Активна: { $is_active -> + [1] да + *[0] нет + }
- Выберите пункт для изменения. \ No newline at end of file +msg-giveaway-view = + 🎁 { $name } + +
+ Статус: { $status } + Период: { $starts_at } — { $ends_at } + Тариф: { $plan_name } + Срок: { $duration_days } дней + Типы покупки: { $purchase_types } + Участников: { $entries_count } + Победителей: { $winners_count } из { $winner_count } + Приз одному: { $prize_amount } ₽ +
+ +msg-giveaway-entries = 👥 Участники акции + +msg-giveaway-winners = 🏆 Победители акции + +msg-giveaway-participants-shortage = + ⚠️ Уникальных участников меньше, чем запланированных победителей. + +msg-giveaway-archive-confirm = + Вы уверены, что хотите очистить участников и победителей этой акции? + + Записи будут архивированы. Это действие нельзя отменить через интерфейс. + +msg-giveaway-delete-confirm = + Вы уверены, что хотите полностью удалить эту акцию? + + Будут удалены: + — сама акция; + — участники этой акции; + — выбранные победители этой акции. + + Платежи, пользователи и подписки удалены не будут. + + Это действие нельзя отменить. diff --git a/assets/translations/ru/notifications.ftl b/assets/translations/ru/notifications.ftl index f295944b..2a69d0f6 100644 --- a/assets/translations/ru/notifications.ftl +++ b/assets/translations/ru/notifications.ftl @@ -63,6 +63,9 @@ ntf-user = .allowed-plans-empty = ❌ Нет доступных планов для предоставления доступа. .message-success = ✅ Сообщение успешно отправлено. .message-failed = ❌ Не удалось отправить сообщение. + .deleted = Пользователь удалён ✅ + .delete-failed = ❌ Не удалось удалить пользователя. Подробности записаны в лог. + .delete-id-mismatch = ❌ Telegram ID не совпадает. Удаление не выполнено. .sync-already = ✅ Данные подписки идентичны. .sync-missing-data = ⚠️ Синхронизация невозможна. Данные подписки отсутствуют в панели и в боте. @@ -113,8 +116,59 @@ 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 = Акция удалена ✅ + ntf-broadcast = .message = { $content } .text-too-long = ❌ Превышено максимальное кол-во символов ({ $max_limit }). 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..f8bbb2b2 --- /dev/null +++ b/src/application/common/dao/giveaway.py @@ -0,0 +1,59 @@ +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_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 create_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 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/dao/user.py b/src/application/common/dao/user.py index 239da404..6a62457d 100644 --- a/src/application/common/dao/user.py +++ b/src/application/common/dao/user.py @@ -1,6 +1,6 @@ from typing import Optional, Protocol, runtime_checkable -from src.application.dto import UserDto +from src.application.dto import UserDeletionSummaryDto, UserDto from src.core.enums import Role @@ -22,6 +22,8 @@ async def update(self, user: UserDto) -> Optional[UserDto]: ... async def delete(self, telegram_id: int) -> bool: ... + async def delete_user_completely(self, telegram_id: int) -> UserDeletionSummaryDto: ... + async def count(self) -> int: ... async def exists(self, telegram_id: int) -> bool: ... diff --git a/src/application/common/policy.py b/src/application/common/policy.py index 360a887e..64ee96bf 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,11 +54,14 @@ class Permission(UpperStrEnum): # REMNASHOP_GATEWAYS = auto() REMNASHOP_PLAN_EDITOR = auto() + REMNASHOP_PROMOCODE_EDITOR = auto() + REMNASHOP_GIVEAWAY_EDITOR = auto() REMNASHOP_LOGS = auto() # USER_EDITOR = auto() USER_SUBSCRIPTION_EDITOR = auto() USER_SYNC = auto() + USER_DELETE = auto() # IMPORTER = auto() ASSIGN_ROLE = auto() @@ -72,6 +76,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..65ef8296 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 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, @@ -32,7 +34,7 @@ ) from .subscription import RemnaSubscriptionDto, SubscriptionDto from .transaction import PriceDetailsDto, TransactionDto -from .user import TempUserDto, UserDto +from .user import TempUserDto, UserDeletionSummaryDto, UserDto __all__ = [ "BaseDto", @@ -41,6 +43,8 @@ "BroadcastDto", "BroadcastMessageDto", "BuildInfoDto", + "GiveawayCampaignDto", + "GiveawayEntryDto", "MediaDescriptorDto", "MessagePayloadDto", "NotificationTaskDto", @@ -59,6 +63,8 @@ "PlanDurationDto", "PlanPriceDto", "PlanSnapshotDto", + "PromocodeDto", + "PromocodeActivationDto", "ReferralDto", "ReferralRewardDto", "AccessSettingsDto", @@ -74,5 +80,6 @@ "PriceDetailsDto", "TransactionDto", "TempUserDto", + "UserDeletionSummaryDto", "UserDto", ] diff --git a/src/application/dto/giveaway.py b/src/application/dto/giveaway.py new file mode 100644 index 00000000..d6a3cd78 --- /dev/null +++ b/src/application/dto/giveaway.py @@ -0,0 +1,46 @@ +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, + 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" + 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: int + telegram_username: Optional[str] + participant_code: str + transaction_payment_id: UUID + plan_id: int + plan_name: str + duration_days: int + purchase_type: PurchaseType + phone: Optional[str] = None + status: GiveawayEntryStatus = GiveawayEntryStatus.ELIGIBLE + winner_rank: Optional[int] = None + selected_at: Optional[datetime] = 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..40eef2fb 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 @@ -31,7 +31,7 @@ def test(cls) -> Self: @dataclass(kw_only=True) class TransactionDto(BaseDto, TrackableMixin, TimestampMixin): payment_id: UUID - user_telegram_id: int + user_telegram_id: Optional[int] status: TransactionStatus is_test: bool = False @@ -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/dto/user.py b/src/application/dto/user.py index f9c72104..0d8f66fc 100644 --- a/src/application/dto/user.py +++ b/src/application/dto/user.py @@ -10,6 +10,18 @@ from .base import BaseDto, TimestampMixin, TrackableMixin +@dataclass(frozen=True) +class UserDeletionSummaryDto: + subscriptions: int = 0 + transactions_anonymized: int = 0 + giveaway_entries: int = 0 + promocode_activations: int = 0 + referral_edges: int = 0 + referral_rewards: int = 0 + broadcast_messages: int = 0 + plan_access_entries: int = 0 + + @dataclass(kw_only=True) class TempUserDto: telegram_id: int 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..802a78be 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,46 +255,56 @@ 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}'") return - user = await self.user_dao.get_by_telegram_id(transaction.user_telegram_id) - - if not user: - logger.critical(f"User not found for transaction '{payment_id}'") + if transaction.is_completed: + logger.warning(f"Transaction '{payment_id}' already completed") return - if transaction.is_completed: + if transaction.user_telegram_id is None: logger.warning( - f"Transaction '{payment_id}' for user '{user.telegram_id}' already completed" + f"Transaction '{payment_id}' belongs to a deleted user; processing skipped" ) return + user = await self.user_dao.get_by_telegram_id(transaction.user_telegram_id) + + if not user: + logger.critical(f"User not found for transaction '{payment_id}'") + return + if new_status == TransactionStatus.CANCELED: await self.transaction_dao.update_status(payment_id, TransactionStatus.CANCELED) await self.uow.commit() @@ -345,5 +370,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..154e6ef5 --- /dev/null +++ b/src/application/use_cases/giveaway/__init__.py @@ -0,0 +1,23 @@ +from typing import Final + +from src.application.common import Interactor + +from .commands import ( + ArchiveGiveawayCampaign, + CreateGiveawayCampaign, + DeleteGiveawayCampaign, + RegisterGiveawayEntry, + SaveGiveawayPhone, + SelectGiveawayWinner, + SetGiveawayStatus, +) + +GIVEAWAY_USE_CASES: Final[tuple[type[Interactor], ...]] = ( + CreateGiveawayCampaign, + SetGiveawayStatus, + ArchiveGiveawayCampaign, + DeleteGiveawayCampaign, + RegisterGiveawayEntry, + SaveGiveawayPhone, + SelectGiveawayWinner, +) diff --git a/src/application/use_cases/giveaway/commands.py b/src/application/use_cases/giveaway/commands.py new file mode 100644 index 00000000..44fd2c65 --- /dev/null +++ b/src/application/use_cases/giveaway/commands.py @@ -0,0 +1,281 @@ +import secrets +import string +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + +from loguru import logger + +from src.application.common import Interactor +from src.application.common.dao import GiveawayDao +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, PurchaseType +from src.core.utils.time import datetime_now + + +@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 + + +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", + ) + ) + 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 RegisterGiveawayEntryDto: + user: UserDto + transaction: TransactionDto + + +class RegisterGiveawayEntry( + Interactor[RegisterGiveawayEntryDto, list[GiveawayEntryDto]] +): + required_permission = None + _ALPHABET = string.ascii_uppercase + string.digits + + 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 = self._generate_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 + + def _generate_code(self, prefix: str) -> str: + left = "".join(secrets.choice(self._ALPHABET) for _ in range(4)) + right = "".join(secrets.choice(self._ALPHABET) for _ in range(4)) + return f"{prefix}-{left}-{right}" + + +@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 = f"{data.phone[:4]}****{data.phone[-3:]}" + 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/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/user/__init__.py b/src/application/use_cases/user/__init__.py index 751e8054..218ca5e9 100644 --- a/src/application/use_cases/user/__init__.py +++ b/src/application/use_cases/user/__init__.py @@ -3,6 +3,7 @@ from src.application.common import Interactor from .commands.blocking import SetBotBlockedStatus, ToggleUserBlockedStatus, UnblockAllUsers +from .commands.deletion import DeleteUserCompletely from .commands.messaging import SendMessageToUser from .commands.profile_edit import ( ChangeUserPoints, @@ -19,6 +20,7 @@ USER_USE_CASES: Final[tuple[type[Interactor], ...]] = ( GetAdmins, GetOrCreateUser, + DeleteUserCompletely, SetBotBlockedStatus, ToggleUserBlockedStatus, RevokeRole, diff --git a/src/application/use_cases/user/commands/deletion.py b/src/application/use_cases/user/commands/deletion.py new file mode 100644 index 00000000..d3b5a20d --- /dev/null +++ b/src/application/use_cases/user/commands/deletion.py @@ -0,0 +1,65 @@ +from loguru import logger + +from src.application.common import Interactor, Remnawave +from src.application.common.dao import SubscriptionDao, UserDao +from src.application.common.policy import Permission +from src.application.common.uow import UnitOfWork +from src.application.dto import UserDeletionSummaryDto, UserDto + + +class DeleteUserCompletely(Interactor[int, UserDeletionSummaryDto]): + required_permission = Permission.USER_DELETE + + def __init__( + self, + uow: UnitOfWork, + user_dao: UserDao, + subscription_dao: SubscriptionDao, + remnawave: Remnawave, + ) -> None: + self.uow = uow + self.user_dao = user_dao + self.subscription_dao = subscription_dao + self.remnawave = remnawave + + async def _execute( + self, + actor: UserDto, + telegram_id: int, + ) -> UserDeletionSummaryDto: + target = await self.user_dao.get_by_telegram_id(telegram_id) + if not target: + raise ValueError(f"User '{telegram_id}' not found") + if actor.telegram_id == telegram_id: + raise PermissionError("Administrators cannot delete themselves") + if actor.role <= target.role: + raise PermissionError("Cannot delete user with equal or higher role") + + subscriptions = await self.subscription_dao.get_all_by_user(telegram_id) + remna_ids = {subscription.user_remna_id for subscription in subscriptions} + logger.warning( + f"{actor.log} Started complete deletion of user '{telegram_id}'; " + f"local_subscriptions='{len(subscriptions)}', remnawave_users='{len(remna_ids)}'" + ) + + deleted_remna_users = 0 + for remna_id in remna_ids: + if await self.remnawave.delete_user(remna_id): + deleted_remna_users += 1 + + try: + async with self.uow: + summary = await self.user_dao.delete_user_completely(telegram_id) + await self.uow.commit() + except Exception: + logger.exception( + f"{actor.log} Local deletion failed for user '{telegram_id}' after " + f"Remnawave cleanup; retry is safe" + ) + raise + + logger.warning( + f"{actor.log} Completely deleted user '{telegram_id}'; " + f"remnawave_deleted='{deleted_remna_users}', summary='{summary}'" + ) + return summary diff --git a/src/core/enums.py b/src/core/enums.py index 70bce16a..7735c0f9 100644 --- a/src/core/enums.py +++ b/src/core/enums.py @@ -78,6 +78,26 @@ 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 PaymentGatewayType(UpperStrEnum): TELEGRAM_STARS = auto() YOOKASSA = auto() diff --git a/src/core/exceptions.py b/src/core/exceptions.py index 066b4222..4b3122c4 100644 --- a/src/core/exceptions.py +++ b/src/core/exceptions.py @@ -51,3 +51,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..c34ef391 --- /dev/null +++ b/src/infrastructure/database/dao/giveaway.py @@ -0,0 +1,270 @@ +from datetime import datetime +from typing import Optional, cast +from uuid import UUID + +from sqlalchemy import delete, func, 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, + 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, + 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, + 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_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 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, + "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 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 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, + ) + candidate = await self.session.scalar( + select(GiveawayEntry) + .where( + GiveawayEntry.campaign_id == campaign_id, + GiveawayEntry.status == GiveawayEntryStatus.ELIGIBLE, + 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/dao/user.py b/src/infrastructure/database/dao/user.py index a52bb601..14d70c2e 100644 --- a/src/infrastructure/database/dao/user.py +++ b/src/infrastructure/database/dao/user.py @@ -5,20 +5,22 @@ from adaptix.conversion import ConversionRetort from loguru import logger from redis.asyncio import Redis -from sqlalchemy import Integer, delete, func, or_, select, update +from sqlalchemy import any_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.application.common.dao import UserDao -from src.application.dto import UserDto -from src.core.constants import TTL_1H, TTL_6H +from src.application.dto import UserDeletionSummaryDto, UserDto from src.core.enums import Role, SubscriptionStatus -from src.infrastructure.database.models import Referral, Subscription, Transaction, User -from src.infrastructure.redis.cache import invalidate_cache, provide_cache -from src.infrastructure.redis.keys import ( - USER_COUNT_PREFIX, - USER_LIST_PREFIX, - RoleKey, - UserCacheKey, +from src.infrastructure.database.models import ( + BroadcastMessage, + GiveawayEntry, + Plan, + PromocodeActivation, + Referral, + ReferralReward, + Subscription, + Transaction, + User, ) @@ -148,6 +150,75 @@ async def delete(self, telegram_id: int) -> bool: logger.debug(f"User '{telegram_id}' not found for deletion") return False + async def delete_user_completely(self, telegram_id: int) -> UserDeletionSummaryDto: + related_referrals = select(Referral.id).where( + or_( + Referral.referrer_telegram_id == telegram_id, + Referral.referred_telegram_id == telegram_id, + ) + ) + + rewards_result = await self.session.execute( + delete(ReferralReward).where( + or_( + ReferralReward.user_telegram_id == telegram_id, + ReferralReward.referral_id.in_(related_referrals), + ) + ) + ) + referrals_result = await self.session.execute( + delete(Referral).where( + or_( + Referral.referrer_telegram_id == telegram_id, + Referral.referred_telegram_id == telegram_id, + ) + ) + ) + promocodes_result = await self.session.execute( + delete(PromocodeActivation).where( + PromocodeActivation.user_telegram_id == telegram_id + ) + ) + giveaways_result = await self.session.execute( + delete(GiveawayEntry).where(GiveawayEntry.user_telegram_id == telegram_id) + ) + broadcasts_result = await self.session.execute( + delete(BroadcastMessage).where(BroadcastMessage.user_telegram_id == telegram_id) + ) + transactions_result = await self.session.execute( + update(Transaction) + .where(Transaction.user_telegram_id == telegram_id) + .values(user_telegram_id=None) + ) + plans_result = await self.session.execute( + update(Plan) + .where(telegram_id == any_(Plan.allowed_user_ids)) + .values(allowed_user_ids=func.array_remove(Plan.allowed_user_ids, telegram_id)) + ) + + await self.clear_current_subscription(telegram_id) + subscriptions_result = await self.session.execute( + delete(Subscription).where(Subscription.user_telegram_id == telegram_id) + ) + user_result = await self.session.execute( + delete(User).where(User.telegram_id == telegram_id).returning(User.id) + ) + if user_result.scalar_one_or_none() is None: + raise ValueError(f"User '{telegram_id}' not found for deletion") + + summary = UserDeletionSummaryDto( + subscriptions=subscriptions_result.rowcount, + transactions_anonymized=transactions_result.rowcount, + giveaway_entries=giveaways_result.rowcount, + promocode_activations=promocodes_result.rowcount, + referral_edges=referrals_result.rowcount, + referral_rewards=rewards_result.rowcount, + broadcast_messages=broadcasts_result.rowcount, + plan_access_entries=plans_result.rowcount, + ) + logger.debug(f"Prepared complete local deletion for user '{telegram_id}': {summary}") + return summary + async def exists(self, telegram_id: int) -> bool: stmt = select(select(User).where(User.telegram_id == telegram_id).exists()) is_exists = await self.session.scalar(stmt) or False 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_anonymize_deleted_user_transactions.py b/src/infrastructure/database/migrations/versions/0024_anonymize_deleted_user_transactions.py new file mode 100644 index 00000000..61e29ddc --- /dev/null +++ b/src/infrastructure/database/migrations/versions/0024_anonymize_deleted_user_transactions.py @@ -0,0 +1,66 @@ +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.drop_constraint( + "transactions_user_telegram_id_fkey", + "transactions", + type_="foreignkey", + ) + op.alter_column( + "transactions", + "user_telegram_id", + existing_type=sa.BigInteger(), + nullable=True, + ) + op.create_foreign_key( + "transactions_user_telegram_id_fkey", + "transactions", + "users", + ["user_telegram_id"], + ["telegram_id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + if ( + op.get_bind() + .execute( + sa.text( + "SELECT 1 FROM transactions " + "WHERE user_telegram_id IS NULL LIMIT 1" + ) + ) + .first() + ): + raise RuntimeError( + "Cannot downgrade while anonymized transactions with NULL user_telegram_id exist" + ) + + op.drop_constraint( + "transactions_user_telegram_id_fkey", + "transactions", + type_="foreignkey", + ) + op.alter_column( + "transactions", + "user_telegram_id", + existing_type=sa.BigInteger(), + nullable=False, + ) + op.create_foreign_key( + "transactions_user_telegram_id_fkey", + "transactions", + "users", + ["user_telegram_id"], + ["telegram_id"], + ) 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..db9cd4d6 100644 --- a/src/infrastructure/database/models/base.py +++ b/src/infrastructure/database/models/base.py @@ -13,10 +13,13 @@ BroadcastMessageStatus, BroadcastStatus, Currency, + GiveawayCampaignStatus, + GiveawayEntryStatus, Locale, PaymentGatewayType, PlanAvailability, PlanType, + PromoAudience, PurchaseType, ReferralAccrualStrategy, ReferralLevel, @@ -52,6 +55,15 @@ 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", + ), } ) diff --git a/src/infrastructure/database/models/giveaway.py b/src/infrastructure/database/models/giveaway.py new file mode 100644 index 00000000..97b47b83 --- /dev/null +++ b/src/infrastructure/database/models/giveaway.py @@ -0,0 +1,102 @@ +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, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.core.enums import GiveawayCampaignStatus, 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") + 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", + ), + ) + + 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[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[UUID] = mapped_column(index=True) + plan_id: Mapped[int] + plan_name: Mapped[str] = mapped_column(String(128)) + duration_days: Mapped[int] + purchase_type: Mapped[PurchaseType] + 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["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..eac95e37 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 @@ -16,10 +16,11 @@ class Transaction(BaseSql, TimestampMixin): id: Mapped[int] = mapped_column(primary_key=True) payment_id: Mapped[UUID] = mapped_column(index=True, unique=True) - user_telegram_id: Mapped[int] = mapped_column( + user_telegram_id: Mapped[Optional[int]] = mapped_column( BigInteger, - ForeignKey("users.telegram_id"), + ForeignKey("users.telegram_id", ondelete="SET NULL"), index=True, + nullable=True, ) status: Mapped[TransactionStatus] = mapped_column(index=True) @@ -32,4 +33,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/telegram/routers/__init__.py b/src/telegram/routers/__init__.py index 20e05819..47e30163 100644 --- a/src/telegram/routers/__init__.py +++ b/src/telegram/routers/__init__.py @@ -7,6 +7,7 @@ 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, @@ -20,6 +21,8 @@ def setup_routers(router: Router) -> None: 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/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..36ffb4ca --- /dev/null +++ b/src/telegram/routers/dashboard/giveaways/dialog.py @@ -0,0 +1,404 @@ +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, + 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_name_input, + on_plan_select, + on_prize_input, + on_purchase_type_toggle, + on_purchase_types_continue, + 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, +) + +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.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( + 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, +) + +router = Dialog( + main, + campaigns, + name, + starts_at, + ends_at, + winner_count, + prize_amount, + plan, + duration, + purchase_types, + activity, + configurator, + view, + entries, + winners, + archive_confirm, + delete_confirm, +) diff --git a/src/telegram/routers/dashboard/giveaways/getters.py b/src/telegram/routers/dashboard/giveaways/getters.py new file mode 100644 index 00000000..81b767ea --- /dev/null +++ b/src/telegram/routers/dashboard/giveaways/getters.py @@ -0,0 +1,171 @@ +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.core.enums import GiveawayCampaignStatus, PurchaseType + + +@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", [])), + "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), + "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_select_winner": ( + campaign.status != GiveawayCampaignStatus.ARCHIVED + and len(winners) < campaign.winner_count + ), + "participants_shortage": len({entry.user_telegram_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 = [ + ( + f"{entry.participant_code} · {entry.user_telegram_id} · " + f"{'@' + entry.telegram_username if entry.telegram_username else 'не указан'} · " + f"{entry.status.value}" + ) + for entry in entries + ] + return {"entries_text": "\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: + username = f"@{entry.telegram_username}" if entry.telegram_username else "не указан" + purchase_date = entry.created_at.strftime("%d.%m.%Y") if entry.created_at else "—" + lines.append( + "\n".join( + [ + f"#{entry.winner_rank} · {entry.participant_code}", + f"ID: {entry.user_telegram_id}", + f"Username: {username}", + f"Телефон: {entry.phone or 'не указан'}", + f"Тариф: {entry.plan_name}, {entry.duration_days} дней", + f"Дата покупки: {purchase_date}", + f"Сумма приза: {campaign.prize_amount if campaign else 0} ₽", + ] + ) + ) + return {"winners_text": "\n\n".join(lines) if lines else "Победители пока не выбраны."} diff --git a/src/telegram/routers/dashboard/giveaways/handlers.py b/src/telegram/routers/dashboard/giveaways/handlers.py new file mode 100644 index 00000000..29863215 --- /dev/null +++ b/src/telegram/routers/dashboard/giveaways/handlers.py @@ -0,0 +1,362 @@ +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 ( + ArchiveGiveawayCampaign, + CreateGiveawayCampaign, + CreateGiveawayCampaignDto, + DeleteGiveawayCampaign, + SelectGiveawayWinner, + SetGiveawayStatus, + SetGiveawayStatusDto, +) +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.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"] + ], + 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_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) + + +@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/dialog.py b/src/telegram/routers/dashboard/users/user/dialog.py index 2ec9aabe..780cd392 100644 --- a/src/telegram/routers/dashboard/users/user/dialog.py +++ b/src/telegram/routers/dashboard/users/user/dialog.py @@ -24,6 +24,7 @@ from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate from .getters import ( + delete_user_getter, device_limit_getter, devices_getter, discount_getter, @@ -80,6 +81,9 @@ on_transaction_select, on_transactions, on_trial_toggle, + on_user_delete_confirm, + on_user_delete_input, + on_user_delete_request, on_user_select, ) @@ -164,6 +168,14 @@ when=F["is_not_self"] & F["can_edit"], ), ), + Row( + Button( + text=I18nFormat("btn-user.delete"), + id="delete_user", + on_click=on_user_delete_request, + when=F["can_delete"], + ), + ), Row( Start( text=I18nFormat("btn-back.dashboard"), @@ -836,6 +848,42 @@ getter=role_getter, ) +delete_confirm = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-user-delete-confirm"), + Row( + Button( + text=I18nFormat("btn-user.delete-confirm"), + id="delete_confirm", + on_click=on_user_delete_confirm, + ), + SwitchTo( + text=I18nFormat("btn-user.delete-cancel"), + id="cancel", + state=DashboardUser.MAIN, + ), + ), + IgnoreUpdate(), + state=DashboardUser.DELETE_CONFIRM, + getter=delete_user_getter, +) + +delete_input = Window( + Banner(BannerName.DASHBOARD), + I18nFormat("msg-user-delete-input"), + MessageInput(func=on_user_delete_input), + Row( + SwitchTo( + text=I18nFormat("btn-user.delete-cancel"), + id="cancel", + state=DashboardUser.MAIN, + ), + ), + IgnoreUpdate(), + state=DashboardUser.DELETE_INPUT, + getter=delete_user_getter, +) + router = Dialog( user, subscription, @@ -861,4 +909,6 @@ points, give_access, role, + delete_confirm, + delete_input, ) diff --git a/src/telegram/routers/dashboard/users/user/getters.py b/src/telegram/routers/dashboard/users/user/getters.py index 46ff7ca5..2e933ff6 100644 --- a/src/telegram/routers/dashboard/users/user/getters.py +++ b/src/telegram/routers/dashboard/users/user/getters.py @@ -16,6 +16,7 @@ TransactionDao, UserDao, ) +from src.application.common.policy import Permission, PermissionPolicy from src.application.dto import PlanDurationDto, RemnaSubscriptionDto, SubscriptionDto, UserDto from src.application.use_cases.statistics.queries.users import GetUserStatistics from src.application.use_cases.user.queries.plans import GetAvailablePlans @@ -64,6 +65,11 @@ async def user_getter( "is_trial_available": profile.target_user.is_trial_available, "is_not_self": profile.target_user.telegram_id != user.telegram_id, "can_edit": profile.can_edit, + "can_delete": ( + profile.can_edit + and profile.target_user.telegram_id != user.telegram_id + and PermissionPolicy.has_permission(user, Permission.USER_DELETE) + ), "status": None, "is_trial": False, "has_subscription": profile.subscription is not None, @@ -83,6 +89,29 @@ async def user_getter( return data +@inject +async def delete_user_getter( + dialog_manager: DialogManager, + user: UserDto, + get_user_profile: FromDishka[GetUserProfile], + **kwargs: Any, +) -> dict[str, Any]: + target_telegram_id = dialog_manager.dialog_data[TARGET_TELEGRAM_ID] + profile = await get_user_profile(user, target_telegram_id) + return { + "telegram_id": profile.target_user.telegram_id, + "username": profile.target_user.username or False, + "subscription_status": ( + profile.subscription.current_status if profile.subscription else False + ), + "can_delete": ( + profile.can_edit + and profile.target_user.telegram_id != user.telegram_id + and PermissionPolicy.has_permission(user, Permission.USER_DELETE) + ), + } + + @inject async def subscription_getter( dialog_manager: DialogManager, diff --git a/src/telegram/routers/dashboard/users/user/handlers.py b/src/telegram/routers/dashboard/users/user/handlers.py index 50895416..44649203 100644 --- a/src/telegram/routers/dashboard/users/user/handlers.py +++ b/src/telegram/routers/dashboard/users/user/handlers.py @@ -46,6 +46,7 @@ SyncSubscriptionFromRemnawave, ) from src.application.use_cases.user.commands.blocking import ToggleUserBlockedStatus +from src.application.use_cases.user.commands.deletion import DeleteUserCompletely from src.application.use_cases.user.commands.messaging import ( SendMessageToUser, SendMessageToUserDto, @@ -65,7 +66,7 @@ from src.core.constants import TARGET_TELEGRAM_ID, USER_KEY from src.core.enums import Role from src.core.utils.validators import parse_int -from src.telegram.states import DashboardUser +from src.telegram.states import DashboardUser, DashboardUsers from src.telegram.utils import is_double_click @@ -105,6 +106,50 @@ async def on_block_toggle( await redirect.to_main_menu(target_telegram_id) +async def on_user_delete_request( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(DashboardUser.DELETE_CONFIRM) + + +async def on_user_delete_confirm( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(DashboardUser.DELETE_INPUT) + + +@inject +async def on_user_delete_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, + delete_user_completely: FromDishka[DeleteUserCompletely], + notifier: FromDishka[Notifier], +) -> None: + actor: UserDto = dialog_manager.middleware_data[USER_KEY] + target_telegram_id = int(dialog_manager.dialog_data[TARGET_TELEGRAM_ID]) + confirmation = (message.text or "").strip() + if confirmation != str(target_telegram_id): + await notifier.notify_user(actor, i18n_key="ntf-user.delete-id-mismatch") + return + + try: + await delete_user_completely(actor, target_telegram_id) + except Exception: + logger.exception( + f"{actor.log} Failed to completely delete user '{target_telegram_id}'" + ) + await notifier.notify_user(actor, i18n_key="ntf-user.delete-failed") + return + + await notifier.notify_user(actor, i18n_key="ntf-user.deleted") + await dialog_manager.start(DashboardUsers.MAIN, mode=StartMode.RESET_STACK) + + @inject async def on_trial_toggle( callback: CallbackQuery, 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..cd97d99c 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,7 +15,7 @@ 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 @@ -47,6 +46,7 @@ menu = Window( Banner(BannerName.MENU), I18nFormat("msg-main-menu"), + I18nFormat("msg-main-menu-how-to-connect"), Row( *connect_buttons, Button( @@ -86,13 +86,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( @@ -233,13 +230,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..2a725486 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 @@ -33,6 +34,9 @@ async def menu_getter( 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='')}" + ) purchase_discount = user.purchase_discount or 0 personal_discount = user.personal_discount or 0 @@ -52,6 +56,7 @@ async def menu_getter( "support_url": support_url, # referral "referral_enabled": menu_data.is_referral_enabled, + "referral_share_url": referral_share_url, # defaults "has_subscription": False, "connectable": False, @@ -191,6 +196,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 +207,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/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..5d154c71 100644 --- a/src/telegram/states.py +++ b/src/telegram/states.py @@ -17,6 +17,10 @@ class Notification(StatesGroup): CLOSE = State() +class GiveawayPhone(StatesGroup): + INPUT = State() + + class Subscription(StatesGroup): MAIN = State() PROMOCODE = State() @@ -64,6 +68,26 @@ 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() + ACTIVITY = State() + CONFIGURATOR = State() + VIEW = State() + ENTRIES = State() + WINNERS = State() + ARCHIVE_CONFIRM = State() + DELETE_CONFIRM = State() + + class DashboardAccess(StatesGroup): MAIN = State() CONDITIONS = State() @@ -105,6 +129,8 @@ class DashboardUser(StatesGroup): SYNC_WAITING = State() GIVE_SUBSCRIPTION = State() SUBSCRIPTION_DURATION = State() + DELETE_CONFIRM = State() + DELETE_INPUT = State() class DashboardRemnashop(StatesGroup): 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))