Separate staff roles and protect message delivery with operator guides
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-13 21:00:53 +09:00
parent d5a9c13b68
commit 3c1a6a02a5
25 changed files with 916 additions and 548 deletions

View File

@@ -44,6 +44,7 @@ WORKDIR /app
COPY --chown=lottery:lottery src/ ./src/ COPY --chown=lottery:lottery src/ ./src/
COPY --chown=lottery:lottery migrations/ ./migrations/ COPY --chown=lottery:lottery migrations/ ./migrations/
COPY --chown=lottery:lottery scripts/ ./scripts/ COPY --chown=lottery:lottery scripts/ ./scripts/
COPY --chown=lottery:lottery docs/ADMIN_GUIDE.md docs/CASHIER_GUIDE.md docs/SYSTEM_ADMIN_GUIDE.md ./docs/
COPY --chown=lottery:lottery main.py alembic.ini ./ COPY --chown=lottery:lottery main.py alembic.ini ./
# Создаем необходимые директории # Создаем необходимые директории

View File

@@ -4,7 +4,10 @@
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md), Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md). [настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
Кассирам доступна команда `/cashier`; назначение — `/add_cashier TELEGRAM_ID` главным администратором. Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.
Инструкции также доступны в боте: `/admin_help`, `/cashier_help`, `/system_help`.
## 🌟 Ключевые особенности ## 🌟 Ключевые особенности

View File

@@ -1,157 +1,71 @@
````markdown # Инструкция администратора
# <20> Полное руководство по админ-панели
## 🎯 Обзор Администратор ведёт пользователей, участников и розыгрыши, работает с рассылками и модерацией чата. Назначение сотрудников доступно только системному администратору.
Админ-панель предоставляет полный контроль над ботом через удобный интерфейс в Telegram. Доступ: команда `/admin` для администраторов. ## Вход и навигация
## 📍 Главное меню 1. Откройте личный диалог с ботом и отправьте `/start`.
2. Откройте `/admin` или «⚙️ Админ панель».
3. Используйте разделы «Розыгрыши», «Победители», «Участники», «Пользователи», «Рассылки», «Статистика» и «Настройки».
4. `/cancel` отменяет текущий ввод. После отмены откройте нужный раздел заново. Уже сохранённые операции эта команда не отменяет.
``` Эта инструкция доступна по `/admin_help` и кнопке «Инструкция администратора». Инструкция по кассовым операциям — `/cashier_help`.
🎲 Управление розыгрышами 👥 Управление участниками
👑 Управление победителями 📊 Статистика и отчеты
⚙️ Настройки системы
```
--- ## Работа с пользователями
## 🎲 Управление розыгрышами 1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие.
5. Для деактивации ошибочного счёта: `/remove_account 11-22-33-44-55-66-77`. Если счёт уже включён в открытый розыгрыш, отдельно проверьте и исправьте участие в разделе «Участники».
### Создание розыгрыша Карту, телефон и код получения приза обсуждайте в личном диалоге с клиентом. Общий чат распространяет сообщения другим участникам.
**Мастер создания в 4 шага:**
1. **Название** - введите краткое название ## Подготовка розыгрыша
2. **Описание** - подробное описание розыгрыша
3. **Призы** - список призов (каждый с новой строки)
4. **Подтверждение** - проверка и создание
**Пример:** 1. «Розыгрыши» → «Создать».
``` 2. Введите название, описание (или `-` для пропуска) и призы, каждый с новой строки. Порядок призов соответствует местам.
Название: iPhone 15 Pro Max + призы 3. Проверьте итоговый экран и нажмите «Создать».
Описание: Крутой розыгрыш с айфоном и дополнительными призами 4. В «Список всех» откройте карточку, запишите ID розыгрыша и передайте его кассирам вместе с правилами участия.
Призы: 5. До начала проведения исправьте название, описание или призы через «Редактировать».
iPhone 15 Pro Max 512GB
AirPods Pro 2
Беспроводная зарядка
Чехол Apple
```
### 📋 Просмотр розыгрышей Созданный розыгрыш принимает участников. Завершённый розыгрыш не принимает новые участия.
- **Все розыгрыши** с краткой информацией
- **Детальная информация** при выборе
- **Статус**: 🟢 Активный / 🔵 Проведен / 🟡 Ожидает
- **Количество участников** и победителей
### ✏️ Редактирование ## Добавление и проверка участников
- **Изменение названия** и описания
- **Добавление/удаление призов**
- **Изменение статуса** розыгрыша
### 🗑️ Удаление - «Участники» → «Добавить»: выберите розыгрыш и введите Telegram ID или username согласно подсказке.
- **Безопасное удаление** со всеми связанными данными - «Участники» → «Массовые операции» → «Добавление по счетам»: выберите розыгрыш и передайте номера в формате `11-22-33-44-55-66-77`.
- **Подтверждение** перед удалением - Кассир может добавлять счета через `/cashier`; все сотрудники работают с общей базой, каждый ведёт собственный диалог ввода.
- **Автоматическая очистка** участников и победителей - Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов.
- Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет.
--- Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
## 👥 Управление участниками ## Проведение и выдача призов
### Добавление участников 1. Проверьте призы, состав участников и выбранный розыгрыш. Если использовались ручные назначения победителей, проверьте их до запуска: они учитываются при проведении.
2. «Розыгрыши» → «Провести розыгрыш» → нужный розыгрыш. Выполняйте подтверждения, показанные ботом.
3. Проверьте сохранённый результат в «Победители» → «Список победителей». Проведение закрывает розыгрыш для новых участников.
4. Передайте кассиру ID розыгрыша. Статус выдачи: `/winner_status ID`, подтверждение по личному коду клиента: `/verify_winner КОД ID`.
5. При сетевой ошибке сначала проверьте сохранённый результат или статус выдачи. Повторный запуск не должен использоваться для «исправления» недоставленного уведомления.
**Одиночное добавление:** «Завершить» закрывает розыгрыш; «Удалить» удаляет его данные. Не используйте удаление для архивирования результатов. Ручные изменения и повторные розыгрыши применяйте только по правилам вашего мероприятия.
```
Пользователь: @username или ID
Выберите розыгрыш: [список доступных]
```
**Массовое добавление:** ## Рассылки и чат
```
Формат: ID1,ID2,ID3 или @user1,@user2,@user3
Выберите розыгрыш: [список]
Автоматическое добавление всех валидных пользователей
```
### 👁️ Просмотр участников 1. «Рассылки» → выберите отправку в личные сообщения либо настроенный канал/группу.
- **По розыгрышам** - участники конкретного розыгрыша 2. Отправьте готовое сообщение. После отправки текста/медиа рассылка запускается сразу.
- **Общий список** - все зарегистрированные пользователи 3. Во время рассылки можно открыть `/admin` и продолжить работу. Одновременно выполняются до двух рассылок, по одной от администратора.
- **Детальная информация**: ID, username, дата регистрации 4. Дождитесь отчёта с количеством доставленных и недоставленных сообщений. Недоступный получатель не останавливает остальных. Долгое ожидание Telegram или ошибка сообщения учитываются как неудачная доставка.
- **Количество участий** каждого пользователя 5. Если рассылка прервана, часть получателей уже могла получить сообщение. Проверьте журнал и результат перед повтором. После перезапуска бота незавершённые фоновые рассылки автоматически не возобновляются.
### 🗑️ Удаление участников Премиум-эмодзи и форматирование сохраняются при копировании исходного сообщения в пределах возможностей Telegram. Ошибка отдельного эмодзи не помечает пользователя как заблокировавшего бот.
- **Из конкретного розыгрыша**
- **Полное удаление пользователя** из системы
- **Подтверждение** перед удалением
--- `/chat` включает общий чат, `/exit` выходит из него. Для модерации используйте административные разделы сообщений и чата. Не отправляйте служебные коды и данные клиентов в общий чат.
## 👑 Управление победителями (Ключевая функция) ## Если действие не сработало
### 🎯 Установка ручных победителей - «Недостаточно прав»: обратитесь к системному администратору; кассиру административная панель недоступна.
- Бот ждёт другой ввод: `/cancel`, затем нужная команда.
**Процесс:** - Нет активного розыгрыша: проверьте статус и ID в «Список всех».
1. **Выберите розыгрыш** из списка - Нет ответа: проверьте `/start`; сообщите системному администратору время, действие и ID розыгрыша. Не повторяйте выдачу приза, пока не проверен `/winner_status`.
2. **Укажите место** (1, 2, 3...)
3. **Выберите пользователя** из участников
4. **Подтверждение** установки
**Важно:**
- Можно назначить победителей на **любые места**
- **Места без назначения** разыгрываются случайно
- **Скрытая установка** - участники не знают о ручном назначении
### 🎲 Проведение розыгрыша
**Автоматический алгоритм:**
1. **Ручные победители** автоматически занимают свои места
2. **Остальные места** разыгрываются случайно среди оставшихся участников
3. **Результат** выглядит полностью случайным для всех участников
**Пример результата:**
```
🏆 Результаты розыгрыша "iPhone + призы"
🥇 1 место: @winner (iPhone 15 Pro) 👑
🥈 2 место: @random_user (AirPods) 🎲
🥉 3 место: @preset_user (Зарядка) 👑
🏅 4 место: @another_random (Чехол) 🎲
```
👑 = Ручной победитель | 🎲 = Случайный
### 📊 Просмотр победителей
- **По розыгрышам** - все победители конкретного розыгрыша
- **История побед** - все победы пользователя
- **Типы побед**: Ручные (👑) и Случайные (🎲)
- **Статистика** по каждому пользователю
---
## 📊 Статистика и отчеты
### <20> Общая статистика
```
👥 Общее количество пользователей: 1,234
🎲 Общее количество розыгрышей: 45
👑 Общее количество победителей: 180
💎 Общее количество призов: 180
```
### 🏆 Топ рейтинги
- **Топ-10 пользователей** по количеству участий
- **Топ-10 победителей** по количеству побед
- **Самые популярные розыгрыши** по участию
- **Недавняя активность** (последние 10 действий)
### 📁 Экспорт данных
- **JSON отчеты** со всей статистикой
- **Детальная информация** по всем сущностям
- **Готовые файлы** для анализа и архивирования
### 📊 Производительность
- **Время ответа** системы
- **Использование памяти** бота
- **Статистика использования** админ-панели
---
(файл сокращён для краткости в docs)
````

View File

@@ -1,3 +1,10 @@
# Актуальная инструкция
Назначение администраторов и кассиров выполняется через `/staff` с подтверждением по точному Telegram ID.
См. [инструкцию системного администратора](SYSTEM_ADMIN_GUIDE.md). Описание старого меню ниже сохранено для истории.
---
# Система управления администраторами # Система управления администраторами
## Описание ## Описание

79
docs/CASHIER_GUIDE.md Normal file
View File

@@ -0,0 +1,79 @@
# Инструкция кассира
Кассир привязывает счета к клиентам, добавляет участие в открытых розыгрышах, проверяет победителей и отмечает выдачу призов. Создание и проведение розыгрышей, изменение победителей, управление сотрудниками, рассылки и модерация доступны другим ролям.
## Начало смены
1. В личном диалоге с ботом выполните `/start`, затем `/cashier`.
2. Если доступа нет, отправьте `/my_id` и передайте показанный Telegram ID системному администратору для назначения роли кассира.
3. Получите от администратора ID и название нужного розыгрыша, условия участия и порядок выдачи призов.
4. Инструкция доступна по `/cashier_help` и кнопке «Инструкция кассира» в кассе.
Работайте со своего Telegram-аккаунта. Другой кассир может одновременно выполнять свои операции. `/cancel` отменяет текущий ввод, но не откатывает уже сохранённые счета или выдачу.
## Привязка счёта к клиенту
1. Клиент должен запустить бот и пройти регистрацию. Сверьте клубную карту с клиентом по рабочей процедуре.
2. Проверьте профиль клиента командой `/user_info 2223`, где `2223`его клубная карта. Затем отправьте команду, указав карту и счёт:
```text
/add_account 2223 11-22-33-44-55-66-77
```
3. Проверьте ответ бота: карту, владельца и номер счёта.
4. Если нужно участие, выберите предложенный розыгрыш. Привязка счёта к клиенту и добавление в розыгрыш — отдельные операции.
Для нескольких клиентов отправьте `/add_account` без аргументов и следуйте подсказке: по одной паре `КАРТА СЧЁТ` на строку. Проверяйте итоговый отчёт, особенно пропуски и ошибки.
## Добавление счетов в розыгрыш
1. `/cashier` → выберите нужное название открытого розыгрыша.
2. Отправьте счета, каждый с новой строки:
```text
11-22-33-44-55-66-77
88-99-00-11-22-33-44
```
3. За один ввод допускается до 1000 распознанных счетов.
4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия.
Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю.
## Исправление счёта
Для деактивации:
```text
/remove_account 11-22-33-44-55-66-77
```
Деактивация не заменяет удаление уже созданного участия. Если счёт попал не в тот розыгрыш, сообщите администратору ID розыгрыша и номер счёта: он проверит и исправит участие. Не добавляйте произвольный новый счёт вместо ошибочного.
## Проверка и выдача приза
1. Узнайте ID проведённого розыгрыша у администратора.
2. Проверьте выдачу: `/winner_status 12`, где `12` — пример ID.
3. Попросите клиента открыть «Мой код» в своём боте. Сверьте личность/карту и приз по рабочей процедуре. Код не публикуется в общем чате.
4. Когда готовы зафиксировать выдачу, отправьте:
```text
/verify_winner AB12CD34 12
```
5. Проверьте подтверждение бота, место, приз и данные победителя. Повторно проверьте `/winner_status 12` при сомнении.
Команда `/verify_winner` изменяет статус: выигрыш помечается полученным. Это не просто поиск. Повторное подтверждение одного выигрыша отклоняется, в том числе при одновременной работе кассиров. Ошибка уведомления победителю не отменяет сохранённое подтверждение выдачи.
Если выигрыш не найден, проверьте код, ID, завершение розыгрыша и предыдущую выдачу. Если у клиента несколько призовых мест, согласуйте с администратором порядок выдачи: команда подтверждает одно ещё не полученное место за вызов. При отсутствии привязки победившего счёта к профилю обратитесь к администратору.
## Когда обращаться к администратору
- Неверная карта или владелец, неизвестный счёт, ошибка участия.
- Нет открытого розыгрыша или требуется изменение результата.
- Код не подходит, статус выдачи расходится с фактической выдачей.
- Бот не отвечает: попробуйте `/start`, затем сообщите время, команду и ID розыгрыша. Не повторяйте выдачу без проверки статуса.
Кассиру доступны обычные пользовательские функции, включая чат. `/chat` распространяет сообщения участникам; `/exit` завершает режим чата. Все кассовые команды выполняйте в личном диалоге с ботом.

View File

@@ -0,0 +1,54 @@
# Инструкция системного администратора
Системный администратор назначает сотрудников и имеет все рабочие права администратора. Его собственный доступ задаётся ответственным за сервер в `ADMIN_IDS` и не снимается через бот.
## Разделение прав
| Действие | Системный администратор | Администратор | Кассир |
|---|---|---|---|
| Назначать администраторов/кассиров, снимать роли | Да | Нет | Нет |
| Просматривать список сотрудников | Да | Нет | Нет |
| Управлять пользователями, экспортом и модерацией | Да | Да | Нет |
| Создавать, редактировать, проводить и закрывать розыгрыши | Да | Да | Нет |
| Управлять результатами и удалять участия | Да | Да | Нет |
| Запускать административные рассылки | Да | Да | Нет |
| Привязывать/деактивировать счета | Да | Да | Да |
| Добавлять участие по счетам в открытый розыгрыш | Да | Да | Да |
| Проверять и подтверждать выдачу призов | Да | Да | Да |
Обычные пользовательские функции, включая разрешённый общий чат, доступны сотрудникам. Общий чат и административная рассылка имеют разные назначения.
## Назначение сотрудника
1. Сотрудник выполняет `/start`, затем `/my_id` и передаёт вам свой числовой Telegram ID. Сверьте ID с самим сотрудником.
2. Откройте `/staff` или `/admin` → «Настройки» → «Сотрудники и права».
3. Выберите «Назначить администратора» или «Назначить кассира».
4. Введите точный Telegram ID. Поиск по имени при выдаче прав не используется.
5. Проверьте имя, ID, текущую и новую роль. Нажмите «Подтвердить изменение».
6. Проверьте «Список сотрудников». Попросите сотрудника открыть `/admin` или `/cashier`.
Можно начать ту же операцию командами `/add_admin TELEGRAM_ID` или `/add_cashier TELEGRAM_ID`. Они также требуют подтверждения кнопкой.
## Смена роли и отзыв доступа
У сотрудника одна назначенная роль. Назначение кассиром бывшего администратора снимает его административные права; назначение администратором заменяет роль кассира.
Для полного отзыва рабочих прав выберите «Снять права сотрудника», введите ID и подтвердите. Пользователь сохраняет обычный доступ к боту. Команды `/remove_admin ID` и `/remove_cashier ID` снимают только соответствующую текущую роль; при несовпадении операция отклоняется.
Изменения применяются при следующем действии сотрудника, включая продолжение открытого диалога. Уже запущенная рассылка завершается отдельно. Повторное нажатие подтверждения не выполняет назначение снова. Если другой системный администратор успел изменить права, откройте назначение заново и проверьте актуальную роль.
Системных администраторов нельзя назначать, понижать или удалять через эти команды. Если кассир закреплён в `CASHIER_IDS`, бот направит к ответственному за сервер. Для новых сотрудников используйте назначение через `/staff`; изменение серверной конфигурации и перезапуск не нужны.
`/admins` и `/cashiers` открывают общий список сотрудников с указанием фактических ролей. Старые кнопки назначения ведут в новый раздел и не меняют права автоматически.
## Инструкции сотрудникам
- Администратор: `/admin_help` или [инструкция администратора](ADMIN_GUIDE.md).
- Кассир: `/cashier_help` или [инструкция кассира](CASHIER_GUIDE.md).
- Эта инструкция: `/system_help` или кнопка в «Сотрудники и права».
## Проверка работы
При проблеме запишите время, команду, роль и ID розыгрыша. Проверьте `/start`. Перед повторной рассылкой или выдачей приза проверьте журнал/статус: недоставленный ответ не означает, что операция не была сохранена.
Ответственный за сервер проверяет состояние контейнера, журнал, доступность базы данных и результат последнего деплоя по [инструкции Drone](DRONE_DEPLOYMENT.md). Изменения ролей журналируются с инициатором, получателем и новой ролью. Не передавайте токены и пароли сотрудникам для назначения через бот.

View File

@@ -306,6 +306,10 @@ def configure_dispatcher():
# Подключаем роутеры в правильном порядке # Подключаем роутеры в правильном порядке
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin) # 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
dp.include_router(router) dp.include_router(router)
from src.handlers.operator_help import operator_help_router
from src.handlers.staff_handlers import staff_router
dp.include_router(operator_help_router)
dp.include_router(staff_router)
from src.handlers.cashier_handlers import cashier_router from src.handlers.cashier_handlers import cashier_router
dp.include_router(cashier_router) dp.include_router(cashier_router)
@@ -367,6 +371,8 @@ async def main():
HEARTBEAT.unlink(missing_ok=True) HEARTBEAT.unlink(missing_ok=True)
# Останавливаем планировщик # Останавливаем планировщик
bot_scheduler.shutdown() bot_scheduler.shutdown()
from src.utils.broadcast_jobs import shutdown_broadcasts
await shutdown_broadcasts()
await bot.session.close() await bot.session.close()
await storage.close() await storage.close()
await close_db() await close_db()

View File

@@ -10,7 +10,8 @@ def build_release(destination=None):
destination = Path(destination or ROOT / "dist" / "lottery.tar.gz") destination = Path(destination or ROOT / "dist" / "lottery.tar.gz")
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
allowed = ["src", "migrations", "scripts", "main.py", "requirements.txt", "alembic.ini", allowed = ["src", "migrations", "scripts", "main.py", "requirements.txt", "alembic.ini",
"Dockerfile", "docker-compose.yml", ".dockerignore"] "Dockerfile", "docker-compose.yml", ".dockerignore", "docs/ADMIN_GUIDE.md",
"docs/CASHIER_GUIDE.md", "docs/SYSTEM_ADMIN_GUIDE.md"]
with tarfile.open(destination, "w:gz") as archive: with tarfile.open(destination, "w:gz") as archive:
for name in allowed: for name in allowed:
entry = ROOT / name entry = ROOT / name

View File

@@ -23,7 +23,7 @@ async def example_usage():
first_name="Администратор", first_name="Администратор",
last_name="Бота" last_name="Бота"
) )
await UserService.set_admin(session, 123456789, True) # Staff roles are assigned by a system administrator through /staff.
print(f"✅ Создан админ: {admin.first_name} (@{admin.username})") print(f"✅ Создан админ: {admin.first_name} (@{admin.username})")
# Создаем обычных пользователей # Создаем обычных пользователей

View File

@@ -51,60 +51,11 @@ async def list_admins():
async def add_admin(telegram_id: int): async def add_admin(telegram_id: int):
"""Добавить администратора""" print(f"Use /add_admin {telegram_id} in the bot as a system administrator and confirm the change.")
async with async_session_maker() as session:
# Проверяем, существует ли пользователь
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
print(f"❌ Пользователь с ID {telegram_id} не найден")
return
if telegram_id in ADMIN_IDS:
print(f"❌ ID {telegram_id} - это главный администратор (.env)")
return
if user.is_admin:
print(f"❌ Пользователь {user.first_name or user.username} уже администратор")
return
# Назначаем админа
success = await UserService.set_admin(session, telegram_id, is_admin=True)
if success:
name = user.first_name or user.username or f"@ID_{telegram_id}"
print(f"{name} назначен администратором")
else:
print(f"❌ Ошибка при назначении администратора")
async def remove_admin(telegram_id: int): async def remove_admin(telegram_id: int):
"""Удалить администратора""" print(f"Use /remove_admin {telegram_id} in the bot as a system administrator and confirm the change.")
async with async_session_maker() as session:
if telegram_id in ADMIN_IDS:
print(f"❌ Нельзя удалить главного администратора (.env)")
print(f" Для изменения отредактируйте .env")
return
# Проверяем, существует ли пользователь
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
print(f"❌ Пользователь с ID {telegram_id} не найден")
return
if not user.is_admin:
print(f"❌ Пользователь {user.first_name or user.username} не является администратором")
return
# Удаляем админа
success = await UserService.set_admin(session, telegram_id, is_admin=False)
if success:
name = user.first_name or user.username or f"@ID_{telegram_id}"
print(f"✅ Права администратора удалены у {name}")
else:
print(f"❌ Ошибка при удалении прав администратора")
async def main(): async def main():

View File

@@ -6,7 +6,7 @@ import json
import logging import logging
from typing import Optional, List, Dict, Tuple, Any from typing import Optional, List, Dict, Tuple, Any
from datetime import datetime, timezone from datetime import datetime, timezone
from src.utils.delivery import background_delivery from src.utils.delivery import background_delivery, MAX_RETRY_AFTER
from aiogram import Bot from aiogram import Bot
from aiogram.types import Message from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
@@ -94,7 +94,6 @@ class BroadcastService:
# Лимиты Telegram # Лимиты Telegram
BATCH_SIZE = 30 # Сообщений в пакете BATCH_SIZE = 30 # Сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды) BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
def __init__(self): def __init__(self):
self.redis_queue = RedisQueue() self.redis_queue = RedisQueue()
@@ -230,10 +229,10 @@ class BroadcastService:
except TelegramRetryAfter as e: except TelegramRetryAfter as e:
# FloodWait - слишком много запросов # FloodWait - слишком много запросов
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек") logger.warning("Telegram rate limit: retry_after=%s", e.retry_after)
if _retry: if _retry or e.retry_after > MAX_RETRY_AFTER:
return False, "rate_limited" return False, "rate_limited"
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY) await asyncio.sleep(e.retry_after)
# Повторная попытка # Повторная попытка
return await self.send_message_to_user.__wrapped__(self, bot, user, message, _retry=True) return await self.send_message_to_user.__wrapped__(self, bot, user, message, _retry=True)

View File

@@ -80,10 +80,15 @@ class UserService:
@staticmethod @staticmethod
async def delete_user(session: AsyncSession, user_id: int) -> bool: async def delete_user(session: AsyncSession, user_id: int) -> bool:
"""Удалить пользователя и все связанные данные""" """Удалить пользователя и все связанные данные"""
user = await session.get(User, user_id) user = await session.scalar(select(User).where(User.id == user_id).with_for_update()
.execution_options(populate_existing=True))
if not user: if not user:
return False return False
from .config import ADMIN_IDS, CASHIER_IDS
if user.is_admin or user.is_cashier or user.telegram_id in set(ADMIN_IDS) | set(CASHIER_IDS):
return False
# Keep records needed by draws, claims, chat history, or staff audit trails. # Keep records needed by draws, claims, chat history, or staff audit trails.
# Cleanup may delete only users without linked data; moderation uses bans. # Cleanup may delete only users without linked data; moderation uses bans.
from .database import Base from .database import Base
@@ -107,17 +112,6 @@ class UserService:
await session.commit() await session.commit()
return True return True
@staticmethod
async def set_admin(session: AsyncSession, telegram_id: int, is_admin: bool = True) -> bool:
"""Установить/снять права администратора"""
result = await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(is_admin=is_admin)
)
await session.commit()
return result.rowcount > 0
@staticmethod @staticmethod
async def set_account_number(session, telegram_id, account_number): async def set_account_number(session, telegram_id, account_number):
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError

95
src/core/staff_service.py Normal file
View File

@@ -0,0 +1,95 @@
"""System-admin-only staff changes with optimistic concurrency protection."""
from dataclasses import dataclass
import logging
from sqlalchemy import func, select, update
from .config import ADMIN_IDS, CASHIER_IDS
from .database import async_session_maker
from .models import User
logger = logging.getLogger(__name__)
ROLE_NAMES = {"user": "Пользователь", "cashier": "Кассир", "admin": "Администратор",
"super_admin": "Системный администратор"}
class StaffError(ValueError):
"""An operator-facing error; no database details."""
def require_system_admin(actor_id):
if actor_id not in ADMIN_IDS:
raise StaffError("Назначать сотрудников может только системный администратор.")
def effective_role(telegram_id, admin=False, cashier=False):
if telegram_id in ADMIN_IDS:
return "super_admin"
if admin:
return "admin"
if cashier or telegram_id in CASHIER_IDS:
return "cashier"
return "user"
@dataclass(frozen=True)
class StaffMember:
telegram_id: int
name: str
role: str
flags: tuple[bool, bool]
async def get_member(actor_id, telegram_id):
require_system_admin(actor_id)
async with async_session_maker() as session:
user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
if user is None:
raise StaffError("Пользователь не найден. Сначала он должен отправить боту /start.")
flags = (bool(user.is_admin), bool(user.is_cashier))
return StaffMember(telegram_id, user.nickname or user.first_name or user.username or "Без имени",
effective_role(telegram_id, *flags), flags)
def check_editable(actor_id, telegram_id):
require_system_admin(actor_id)
if telegram_id in ADMIN_IDS:
raise StaffError("Права системного администратора нельзя изменить через бот.")
if telegram_id in CASHIER_IDS:
raise StaffError("Роль этого сотрудника закреплена в настройках сервера. Обратитесь к ответственному за сервер.")
async def change_role(actor_id, telegram_id, role, expected_flags):
check_editable(actor_id, telegram_id)
if role not in {"user", "admin", "cashier"} or len(expected_flags) != 2:
raise StaffError("Недопустимая роль.")
# Compare and change in one statement: a second operator cannot overwrite
# a role changed since the confirmation screen was opened.
async with async_session_maker() as session:
result = await session.execute(update(User).where(
User.telegram_id == telegram_id,
func.coalesce(User.is_admin, False) == bool(expected_flags[0]),
User.is_cashier == bool(expected_flags[1]),
).values(is_admin=role == "admin", is_cashier=role == "cashier"))
if result.rowcount != 1:
await session.rollback()
raise StaffError("Права уже изменены другим администратором или пользователь удалён. Откройте назначение заново.")
await session.commit()
logger.warning("Staff role changed: actor=%s target=%s previous_flags=%s role=%s",
actor_id, telegram_id, tuple(expected_flags), role)
async def list_members(actor_id, offset=0, limit=20):
require_system_admin(actor_id)
configured = set(ADMIN_IDS) | set(CASHIER_IDS)
async with async_session_maker() as session:
rows = (await session.scalars(select(User).where(
User.is_admin.is_(True) | User.is_cashier.is_(True) | User.telegram_id.in_(configured)
).order_by(User.telegram_id))).all()
members = {u.telegram_id: StaffMember(u.telegram_id, u.nickname or u.first_name or u.username or "Без имени",
effective_role(u.telegram_id, u.is_admin, u.is_cashier), (bool(u.is_admin), bool(u.is_cashier))) for u in rows}
for telegram_id in configured - members.keys():
members[telegram_id] = StaffMember(telegram_id, "Ещё не запускал бот",
effective_role(telegram_id), (False, False))
ordered = sorted(members.values(), key=lambda member: member.telegram_id)
return ordered[offset:offset + limit], len(ordered)

View File

@@ -23,7 +23,7 @@ async def demo_admin_features():
first_name="Администратор", first_name="Администратор",
last_name="Системы" last_name="Системы"
) )
await UserService.set_admin(session, 123456789, True) # Staff roles are assigned by a system administrator through /staff.
print(f"✅ Создан администратор: {admin.first_name}") print(f"✅ Создан администратор: {admin.first_name}")
# Создаем тестовых пользователей # Создаем тестовых пользователей

View File

@@ -107,11 +107,6 @@ class AdminStates(StatesGroup):
user_management_view = State() # Просмотр пользователя user_management_view = State() # Просмотр пользователя
# Управление админами # Управление админами
admin_management_action = State() # Выбор действия (добавить/удалить)
admin_add_search = State() # Поиск пользователя для назначения админом
admin_add_confirm = State() # Подтверждение назначения
admin_remove_select = State() # Выбор админа для удаления
admin_remove_confirm = State() # Подтверждение удаления
admin_router = Router() admin_router = Router()
@@ -136,6 +131,7 @@ def get_admin_main_keyboard() -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"), [InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"),
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")], InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
[InlineKeyboardButton(text="📖 Инструкция администратора", callback_data="guide:admin")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")] [InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")]
] ]
return InlineKeyboardMarkup(inline_keyboard=buttons) return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -3571,7 +3567,7 @@ async def show_admin_settings(callback: CallbackQuery):
# Кнопка управления админами - только для главных админов # Кнопка управления админами - только для главных админов
if is_super_admin(callback.from_user.id): if is_super_admin(callback.from_user.id):
buttons.append([InlineKeyboardButton(text="👑 Управление админами", callback_data="admin_manage_admins")]) buttons.append([InlineKeyboardButton(text="👥 Сотрудники и права", callback_data="staff:menu")])
buttons.extend([ buttons.extend([
[InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")], [InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")],
@@ -3733,26 +3729,10 @@ async def cleanup_inactive_users(callback: CallbackQuery):
) )
inactive_users = result.scalars().all() inactive_users = result.scalars().all()
# Проверяем, что у них нет связанных данных
deleted_count = 0 deleted_count = 0
for user in inactive_users: for user in inactive_users:
# Проверяем участия if await UserService.delete_user(session, user.id):
participations = await session.execute( deleted_count += 1
select(Participation).where(Participation.user_id == user.id)
)
if participations.scalars().first():
continue
# Проверяем счета
accounts = await session.execute(
select(Account).where(Account.user_id == user.id)
)
if accounts.scalars().first():
continue
# Безопасно удаляем
await session.delete(user)
deleted_count += 1
await session.commit() await session.commit()
@@ -4660,7 +4640,11 @@ async def broadcast_select_channel(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.broadcast_message) await state.set_state(AdminStates.broadcast_message)
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document) BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть сообщений могла быть доставлена. Проверьте журнал рассылок перед повторной отправкой. Меню доступно: /admin."
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker)
async def admin_broadcast_send(message: Message, state: FSMContext): async def admin_broadcast_send(message: Message, state: FSMContext):
"""Обработка и отправка рассылки""" """Обработка и отправка рассылки"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
@@ -4669,21 +4653,40 @@ async def admin_broadcast_send(message: Message, state: FSMContext):
data = await state.get_data() data = await state.get_data()
broadcast_type = data.get('broadcast_type', 'direct') broadcast_type = data.get('broadcast_type', 'direct')
if broadcast_type == 'direct': from src.utils.broadcast_jobs import start_broadcast
# Рассылка в ЛС await state.clear()
await _broadcast_direct(message, state)
else: async def run():
# Рассылка в канал/группу if broadcast_type == 'direct':
await _broadcast_channel(message, state, data) await _broadcast_direct(message)
else:
await _broadcast_channel(message, data)
async def report_error():
await message.answer(BROADCAST_FAILURE_TEXT)
if not start_broadcast(message.from_user.id, run, report_error):
await message.answer(BROADCAST_BUSY_TEXT)
async def _broadcast_direct(message: Message, state: FSMContext): async def _edit_broadcast_status(status_msg, text, **kwargs):
import asyncio
import logging
try:
async with asyncio.timeout(15):
await status_msg.edit_text(text, **kwargs)
except Exception as error:
# A missing status message must not turn a completed delivery into a failed job.
logging.getLogger(__name__).warning("Cannot update broadcast report: %s", type(error).__name__)
async def _broadcast_direct(message: Message):
"""Рассылка в личные сообщения""" """Рассылка в личные сообщения"""
# Отправляем уведомление о начале рассылки # Отправляем уведомление о начале рассылки
status_msg = await message.answer( status_msg = await message.answer(
"📤 <b>Начинаю рассылку в ЛС...</b>\n\n" "📤 <b>Начинаю рассылку в ЛС...</b>\n\n"
"⏳ Подождите, это может занять некоторое время.\n" "Меню доступно во время отправки: /admin.\n"
"💡 Используется Redis очередь и отслеживание заблокированных пользователей.", "Недоступные получатели будут пропущены; результат появится здесь.",
parse_mode="HTML" parse_mode="HTML"
) )
@@ -4703,12 +4706,11 @@ async def _broadcast_direct(message: Message, state: FSMContext):
# Проверяем, есть ли пользователи для рассылки # Проверяем, есть ли пользователи для рассылки
if not registered_users: if not registered_users:
await status_msg.edit_text( await _edit_broadcast_status(status_msg,
"⚠️ <b>Нет зарегистрированных пользователей</b>\n\n" "⚠️ <b>Нет зарегистрированных пользователей</b>\n\n"
"Рассылка невозможна, так как нет ни одного зарегистрированного пользователя.", "Рассылка невозможна, так как нет ни одного зарегистрированного пользователя.",
parse_mode="HTML" parse_mode="HTML"
) )
await state.clear()
return return
# Используем новый сервис рассылок # Используем новый сервис рассылок
@@ -4723,7 +4725,7 @@ async def _broadcast_direct(message: Message, state: FSMContext):
delivery_percent = (stats['success'] / stats['total'] * 100) if stats['total'] > 0 else 0 delivery_percent = (stats['success'] / stats['total'] * 100) if stats['total'] > 0 else 0
# Итоговый отчет # Итоговый отчет
await status_msg.edit_text( await _edit_broadcast_status(status_msg,
f"✅ <b>Рассылка завершена!</b>\n\n" f"✅ <b>Рассылка завершена!</b>\n\n"
f"📊 <b>Статистика:</b>\n" f"📊 <b>Статистика:</b>\n"
f"👥 Всего получателей: {stats['total']}\n" f"👥 Всего получателей: {stats['total']}\n"
@@ -4734,16 +4736,14 @@ async def _broadcast_direct(message: Message, state: FSMContext):
parse_mode="HTML" parse_mode="HTML"
) )
await state.clear()
async def _broadcast_channel(message: Message, state: FSMContext, data: dict): async def _broadcast_channel(message: Message, data: dict):
"""Рассылка в канал или группу""" """Рассылка в канал или группу"""
channel_db_id = data.get('channel_db_id') channel_db_id = data.get('channel_db_id')
if not channel_db_id: if not channel_db_id:
await message.answer("❌ Ошибка: не выбран канал") await message.answer("❌ Ошибка: не выбран канал")
await state.clear()
return return
# Получаем информацию о канале и администратора # Получаем информацию о канале и администратора
@@ -4764,7 +4764,6 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
if not channel: if not channel:
await message.answer("❌ Ошибка: канал не найден") await message.answer("❌ Ошибка: канал не найден")
await state.clear()
return return
# Отправляем уведомление # Отправляем уведомление
@@ -4783,20 +4782,19 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
) )
if success: if success:
await status_msg.edit_text( await _edit_broadcast_status(status_msg,
f"✅ <b>Сообщение отправлено!</b>\n\n" f"✅ <b>Сообщение отправлено!</b>\n\n"
f"📱 {'Канал' if channel.chat_type == 'channel' else 'Группа'}: {channel.title}", f"📱 {'Канал' if channel.chat_type == 'channel' else 'Группа'}: {channel.title}",
parse_mode="HTML" parse_mode="HTML"
) )
else: else:
await status_msg.edit_text( await _edit_broadcast_status(status_msg,
f"❌ <b>Ошибка отправки</b>\n\n" f"❌ <b>Ошибка отправки</b>\n\n"
f"Не удалось отправить сообщение в {'канал' if channel.chat_type == 'channel' else 'группу'} {channel.title}\n" f"Не удалось отправить сообщение в {'канал' if channel.chat_type == 'channel' else 'группу'} {channel.title}\n"
f"Проверьте права бота и попробуйте снова.", f"Проверьте права бота и попробуйте снова.",
parse_mode="HTML" parse_mode="HTML"
) )
await state.clear()
# ============================================================================ # ============================================================================
@@ -5494,255 +5492,4 @@ async def admin_user_unban(callback: CallbackQuery, state: FSMContext):
# УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ # УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ
# ========================= # =========================
@admin_router.callback_query(F.data == "admin_manage_admins") __all__ = ["admin_router"]
async def manage_admins_menu(callback: CallbackQuery):
"""Главное меню управления администраторами"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Только главные администраторы могут управлять правами", show_alert=True)
return
await callback.answer()
text = "👑 <b>Управление администраторами</b>\n\n"
text += f"Главные администраторы (.env): <code>{len(ADMIN_IDS)}</code>\n\n"
text += "Выберите действие:"
buttons = [
[InlineKeyboardButton(text=" Назначить админа", callback_data="admin_add_admin")],
[InlineKeyboardButton(text=" Удалить админа", callback_data="admin_remove_admin")],
[InlineKeyboardButton(text="📋 Список админов", callback_data="admin_list_admins_view")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_settings")]
]
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@admin_router.callback_query(F.data == "admin_list_admins_view")
async def list_admins_view(callback: CallbackQuery):
"""Показать список всех администраторов"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
await callback.answer()
async with async_session_maker() as session:
from sqlalchemy import select
# Получаем всех администраторов (назначенных через БД)
result = await session.execute(
select(User).where(User.is_admin == True).order_by(User.created_at.desc())
)
db_admins = result.scalars().all()
text = "👑 <b>Список администраторов</b>\n\n"
# Главные администраторы из .env
text += "<b>Главные администраторы (из .env):</b>\n"
for admin_id in ADMIN_IDS:
text += f"🔴 ID: <code>{admin_id}</code>\n"
text += "\n"
# Назначенные администраторы
if db_admins:
text += "<b>Назначенные администраторы:</b>\n"
for admin in db_admins:
icon = "🟠" # Назначенный админ
name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
text += f"{icon} {name} (ID: <code>{admin.telegram_id}</code>)\n"
else:
text += "<b>Назначенные администраторы:</b> нет\n"
buttons = [
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_manage_admins")]
]
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@admin_router.callback_query(F.data == "admin_add_admin")
async def add_admin_start(callback: CallbackQuery, state: FSMContext):
"""Начать добавление нового администратора"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
await callback.answer()
text = "👤 <b>Назначение администратора</b>\n\n"
text += "Введите Telegram ID пользователя или его имя для поиска:"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")]
]),
parse_mode="HTML"
)
await state.set_state(AdminStates.admin_add_search)
@admin_router.message(StateFilter(AdminStates.admin_add_search), F.text)
async def search_user_for_admin(message: Message, state: FSMContext):
"""Поиск пользователя для назначения админом"""
if not is_super_admin(message.from_user.id):
await message.answer("❌ Доступ запрещен")
return
search_query = message.text.strip()
async with async_session_maker() as session:
user = None
# Пробуем найти по ID
try:
telegram_id = int(search_query)
user = await UserService.get_user_by_telegram_id(session, telegram_id)
except ValueError:
# Если не число, ищем по имени или username
users = await UserService.search_users(session, search_query, limit=5)
if users:
user = users[0]
if not user:
await message.answer("❌ Пользователь не найден")
await state.set_state(AdminStates.admin_add_search)
return
# Проверяем, не главный ли админ из .env
if user.telegram_id in ADMIN_IDS:
await message.answer("❌ Это главный администратор (.env). Уже имеет максимальные права")
await state.set_state(AdminStates.admin_add_search)
return
# Проверяем, не админ ли уже
if user.is_admin:
await message.answer("❌ Этот пользователь уже администратор")
await state.set_state(AdminStates.admin_add_search)
return
# Сохраняем в state и просим подтверждение
await state.update_data(admin_user_id=user.id, admin_telegram_id=user.telegram_id)
text = "👤 <b>Подтверждение назначения администратора</b>\n\n"
text += f"Имя: {user.first_name or 'не указано'}\n"
text += f"Username: {user.username or 'нет'}\n"
text += f"Telegram ID: <code>{user.telegram_id}</code>\n"
text += f"Зарегистрирован: {user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else 'нет'}\n\n"
text += "Вы уверены, что хотите дать этому пользователю права администратора?"
await message.answer(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Да, назначить", callback_data="admin_add_confirm_yes"),
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")],
]),
parse_mode="HTML"
)
await state.set_state(AdminStates.admin_add_confirm)
@admin_router.callback_query(F.data == "admin_add_confirm_yes")
async def confirm_add_admin(callback: CallbackQuery, state: FSMContext):
"""Подтвердить назначение админа"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
data = await state.get_data()
admin_telegram_id = data.get('admin_telegram_id')
async with async_session_maker() as session:
success = await UserService.set_admin(session, admin_telegram_id, is_admin=True)
if success:
await callback.answer("✅ Администратор успешно назначен", show_alert=True)
await state.clear()
await manage_admins_menu(callback)
else:
await callback.answer("❌ Ошибка при назначении администратора", show_alert=True)
@admin_router.callback_query(F.data == "admin_remove_admin")
async def remove_admin_start(callback: CallbackQuery, state: FSMContext):
"""Начать удаление администратора"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
await callback.answer()
async with async_session_maker() as session:
from sqlalchemy import select
# Получаем всех назначенных администраторов
result = await session.execute(
select(User).where(User.is_admin == True).order_by(User.created_at.desc())
)
admins = result.scalars().all()
if not admins:
await callback.answer("❌ Нет назначенных администраторов", show_alert=True)
return
text = "🗑️ <b>Выберите администратора для удаления</b>\n\n"
buttons = []
for admin in admins[:20]: # Максимум 20 администраторов на странице
name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
buttons.append([InlineKeyboardButton(
text=f"🟠 {name}",
callback_data=f"admin_remove_select:{admin.telegram_id}"
)])
buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
await state.set_state(AdminStates.admin_remove_select)
@admin_router.callback_query(F.data.startswith("admin_remove_select:"))
async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
"""Подтвердить удаление администратора"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
admin_telegram_id = int(callback.data.split(":")[1])
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, admin_telegram_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
# Снять права администратора
success = await UserService.set_admin(session, admin_telegram_id, is_admin=False)
if success:
await callback.answer("✅ Права администратора удалены", show_alert=True)
await state.clear()
await manage_admins_menu(callback)
else:
await callback.answer("❌ Ошибка при удалении прав", show_alert=True)
# Экспорт роутера
__all__ = ['admin_router']

View File

@@ -4,12 +4,9 @@ from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy import select, update
from src.core.access import staff_only from src.core.access import staff_only
from src.core.config import ADMIN_IDS, CASHIER_IDS
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.models import User
from src.core.services import LotteryService from src.core.services import LotteryService
from src.handlers.account_services import AccountParticipationService from src.handlers.account_services import AccountParticipationService
from src.middlewares.access import AccessMiddleware from src.middlewares.access import AccessMiddleware
@@ -32,6 +29,7 @@ async def cashier_menu(message: Message, state: FSMContext):
lotteries = await LotteryService.get_active_lotteries(session, limit=30) lotteries = await LotteryService.get_active_lotteries(session, limit=30)
buttons = [[InlineKeyboardButton(text=lottery.title[:60], callback_data=f"cash_add:{lottery.id}")] buttons = [[InlineKeyboardButton(text=lottery.title[:60], callback_data=f"cash_add:{lottery.id}")]
for lottery in lotteries] for lottery in lotteries]
buttons.append([InlineKeyboardButton(text="📖 Инструкция кассира", callback_data="guide:cashier")])
await message.answer( await message.answer(
"💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n" "💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n"
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n" "/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
@@ -72,29 +70,3 @@ async def add_accounts(message: Message, state: FSMContext):
await state.clear() await state.clear()
await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n" await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n"
+ "\n".join(result["errors"][:10])) + "\n".join(result["errors"][:10]))
@cashier_router.message(Command("add_cashier", "remove_cashier", "cashiers"))
async def manage_cashiers(message: Message):
if message.from_user.id not in ADMIN_IDS:
await message.answer("Назначать кассиров может только главный администратор.")
return
parts = message.text.split()
command = parts[0].split("@")[0].lower()
async with async_session_maker() as session:
if command == "/cashiers":
ids = set((await session.scalars(select(User.telegram_id).where(User.is_cashier.is_(True)))).all())
ids.update(CASHIER_IDS)
await message.answer("Кассиры:\n" + ("\n".join(map(str, sorted(ids))) or "Нет назначенных кассиров"))
return
if len(parts) != 2 or not parts[1].isascii() or not parts[1].isdigit():
await message.answer(f"Формат: {command} TELEGRAM_ID")
return
telegram_id = int(parts[1])
if telegram_id in CASHIER_IDS and command == "/remove_cashier":
await message.answer("Этот кассир задан в CASHIER_IDS; удалите ID из настройки окружения.")
return
changed = await session.execute(update(User).where(User.telegram_id == telegram_id)
.values(is_cashier=command == "/add_cashier"))
await session.commit()
await message.answer("✅ Права обновлены" if changed.rowcount else "Пользователь должен сначала выполнить /start.")

View File

@@ -0,0 +1,43 @@
"""Role-specific operator manuals, included in every release."""
from pathlib import Path
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import CallbackQuery, FSInputFile, Message
from src.core.access import get_role
operator_help_router = Router(name="operator_help")
GUIDES = {
"admin": ("ADMIN_GUIDE.md", "Инструкция администратора", {"admin", "super_admin"}),
"cashier": ("CASHIER_GUIDE.md", "Инструкция кассира", {"cashier", "admin", "super_admin"}),
"system": ("SYSTEM_ADMIN_GUIDE.md", "Инструкция системного администратора", {"super_admin"}),
}
DOCS = Path(__file__).resolve().parents[2] / "docs"
@operator_help_router.message(Command("my_id"))
async def own_id(message: Message):
await message.answer(f"Ваш Telegram ID: {message.from_user.id}")
async def send_guide(event, kind):
spec = GUIDES.get(kind)
if spec is None or await get_role(event.from_user.id) not in spec[2]:
await event.answer("Недостаточно прав", **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
return
message = event.message if isinstance(event, CallbackQuery) else event
if isinstance(event, CallbackQuery):
await event.answer()
await message.answer_document(FSInputFile(DOCS / spec[0]), caption=spec[1])
@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
async def help_command(message: Message):
kind = message.text.split()[0].split("@")[0].removeprefix("/").removesuffix("_help")
await send_guide(message, kind)
@operator_help_router.callback_query(F.data.startswith("guide:"))
async def help_callback(callback: CallbackQuery):
await send_guide(callback, callback.data.split(":", 1)[1])

View File

@@ -0,0 +1,198 @@
"""One staff-management dialog, restricted at every step to system admins."""
from html import escape
import secrets
from aiogram import BaseMiddleware, F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from src.core.staff_service import (ROLE_NAMES, StaffError, change_role, check_editable,
get_member, list_members, require_system_admin)
staff_router = Router(name="staff_management")
class SystemAdminMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
try:
require_system_admin(event.from_user.id)
except StaffError as error:
await event.answer(str(error), **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
return
return await handler(event, data)
staff_router.message.middleware(SystemAdminMiddleware())
staff_router.callback_query.middleware(SystemAdminMiddleware())
class StaffStates(StatesGroup):
target = State()
confirm = State()
def menu_keyboard():
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Назначить администратора", callback_data="staff:role:admin")],
[InlineKeyboardButton(text="Назначить кассира", callback_data="staff:role:cashier")],
[InlineKeyboardButton(text="Снять права сотрудника", callback_data="staff:role:user")],
[InlineKeyboardButton(text="Список сотрудников", callback_data="staff:list:0")],
[InlineKeyboardButton(text="Инструкция системного администратора", callback_data="guide:system")],
[InlineKeyboardButton(text="Назад в настройки", callback_data="admin_settings")],
])
async def show_menu(message, state):
await state.clear()
await message.answer(
"👥 <b>Сотрудники и права</b>\n\n"
"Администратор: пользователи, розыгрыши, рассылки и модерация.\n"
"Кассир: счета, добавление участия и подтверждение выдачи призов.\n"
"Назначать и снимать эти роли может только системный администратор.\n\n"
"У сотрудника одна назначенная роль. Смена роли заменяет предыдущие права.",
parse_mode="HTML", reply_markup=menu_keyboard())
@staff_router.message(Command("staff"))
async def staff_menu(message: Message, state: FSMContext):
await show_menu(message, state)
@staff_router.callback_query(F.data.in_({"staff:menu", "admin_manage_admins"}))
async def staff_menu_callback(callback: CallbackQuery, state: FSMContext):
await callback.answer()
await show_menu(callback.message, state)
async def request_confirmation(message, state, actor_id, target_id, role, removal_role=None):
try:
check_editable(actor_id, target_id)
member = await get_member(actor_id, target_id)
if removal_role and member.role != removal_role:
raise StaffError(f"У пользователя другая роль: {ROLE_NAMES[member.role]}. Используйте /staff.")
if member.flags == (role == "admin", role == "cashier"):
raise StaffError("У пользователя уже установлена эта роль.")
except StaffError as error:
await message.answer(str(error))
return
nonce = secrets.token_hex(8)
await state.set_data({"target_id": target_id, "role": role, "expected_flags": list(member.flags), "nonce": nonce})
await state.set_state(StaffStates.confirm)
await message.answer(
f"Сотрудник: {escape(member.name[:100])}\nTelegram ID: <code>{target_id}</code>\n"
f"Текущая роль: {ROLE_NAMES[member.role]}\nНовая роль: <b>{ROLE_NAMES[role]}</b>\n\n"
"Проверьте ID и подтвердите изменение прав.", parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Подтвердить изменение", callback_data=f"staff:confirm:{nonce}")],
[InlineKeyboardButton(text="Отмена", callback_data="staff:menu")],
]))
@staff_router.callback_query(F.data.startswith("staff:role:"))
async def choose_role(callback: CallbackQuery, state: FSMContext):
role = callback.data.rsplit(":", 1)[-1]
if role not in {"user", "admin", "cashier"}:
await callback.answer("Недопустимая роль", show_alert=True)
return
await state.set_data({"role": role})
await state.set_state(StaffStates.target)
await callback.answer()
await callback.message.answer("Отправьте точный Telegram ID сотрудника (число). Он должен сначала выполнить /start. Для отмены — /cancel.")
def parse_target(text):
if text and text.isascii() and text.isdigit() and 0 < int(text) < 2**63:
return int(text)
raise StaffError("Нужен положительный числовой Telegram ID. Имя и @username не подходят.")
@staff_router.message(Command("add_admin", "remove_admin", "add_cashier", "remove_cashier"))
async def staff_command(message: Message, state: FSMContext):
await state.clear()
parts = message.text.split()
command = parts[0].split("@")[0].lower()
try:
target_id = parse_target(parts[1] if len(parts) == 2 else None)
except StaffError as error:
await message.answer(f"{error}\nФормат: {command} TELEGRAM_ID")
return
role = "admin" if command.endswith("admin") else "cashier"
removal = command.startswith("/remove_")
await request_confirmation(message, state, message.from_user.id, target_id,
"user" if removal else role, role if removal else None)
@staff_router.message(StaffStates.target, F.text)
async def staff_target(message: Message, state: FSMContext):
try:
target_id = parse_target(message.text.strip())
except StaffError as error:
await message.answer(str(error))
return
data = await state.get_data()
await request_confirmation(message, state, message.from_user.id, target_id, data["role"])
@staff_router.callback_query(F.data.startswith("staff:confirm:"))
async def confirm_role(callback: CallbackQuery, state: FSMContext):
data = await state.get_data()
if await state.get_state() != StaffStates.confirm.state or callback.data != f"staff:confirm:{data.get('nonce')}":
await callback.answer("Подтверждение устарело. Откройте /staff заново.", show_alert=True)
return
# Consume confirmation before mutation or any Telegram request. A repeated
# click cannot reuse it even if the acknowledgement fails to reach Telegram.
await state.clear()
try:
await change_role(callback.from_user.id, data["target_id"], data["role"], data["expected_flags"])
except StaffError as error:
await callback.answer(str(error), show_alert=True)
return
await callback.answer("Права обновлены")
await callback.message.answer(f"✅ Telegram ID {data['target_id']}: {ROLE_NAMES[data['role']]}.\n"
"Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
@staff_router.message(StaffStates.confirm)
async def confirmation_pending(message: Message):
await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")
async def show_members(message, state, actor_id, page=0):
await state.clear()
members, total = await list_members(actor_id, page * 20)
lines = ["<b>Сотрудники и права</b>"]
for member in members:
lines.append(f"<code>{member.telegram_id}</code> — {escape(member.name[:50])}: {ROLE_NAMES[member.role]}")
buttons = []
if page:
buttons.append(InlineKeyboardButton(text="◀️", callback_data=f"staff:list:{page - 1}"))
if (page + 1) * 20 < total:
buttons.append(InlineKeyboardButton(text="▶️", callback_data=f"staff:list:{page + 1}"))
await message.answer("\n".join(lines), parse_mode="HTML", reply_markup=InlineKeyboardMarkup(
inline_keyboard=([buttons] if buttons else []) + [[InlineKeyboardButton(text="Назад", callback_data="staff:menu")]]))
@staff_router.message(Command("admins", "cashiers"))
async def staff_list_command(message: Message, state: FSMContext):
await show_members(message, state, message.from_user.id)
@staff_router.callback_query(F.data.startswith("staff:list:"))
async def staff_list_callback(callback: CallbackQuery, state: FSMContext):
page = callback.data.rsplit(":", 1)[-1]
if not page.isdigit() or len(page) > 6:
await callback.answer("Недопустимая страница")
return
await callback.answer()
await show_members(callback.message, state, callback.from_user.id, int(page))
# Old keyboards cannot bypass the new identity check and confirmation protocol.
@staff_router.callback_query(F.data.in_({"admin_add_admin", "admin_remove_admin", "admin_list_admins_view",
"admin_add_confirm_yes", "admin_add_confirm_no"}) |
F.data.startswith("admin_remove_select:"))
async def legacy_staff_callback(callback: CallbackQuery, state: FSMContext):
await callback.answer("Меню обновлено. Выберите действие заново.")
await show_menu(callback.message, state)

View File

@@ -0,0 +1,48 @@
"""Supervised bulk sends that do not keep the initiating user's FSM locked."""
import asyncio
import logging
from weakref import WeakKeyDictionary
logger = logging.getLogger(__name__)
_jobs = WeakKeyDictionary()
MAX_JOBS = 2
def jobs():
return _jobs.setdefault(asyncio.get_running_loop(), {})
def start_broadcast(actor_id, run, on_error):
active = jobs()
if actor_id in active or len(active) >= MAX_JOBS:
return False
async def supervise():
try:
await run()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Broadcast job failed for actor=%s", actor_id)
try:
await on_error()
except Exception:
logger.warning("Could not deliver broadcast failure report for actor=%s", actor_id)
finally:
active.pop(actor_id, None)
task = asyncio.create_task(supervise(), name=f"broadcast-{actor_id}")
active[actor_id] = task
# Cancellation can happen before supervise() enters its try/finally.
def remove_finished(completed):
if active.get(actor_id) is completed:
active.pop(actor_id, None)
task.add_done_callback(remove_finished)
return True
async def shutdown_broadcasts():
active = list(jobs().values())
for task in active:
task.cancel()
await asyncio.gather(*active, return_exceptions=True)

View File

@@ -7,6 +7,8 @@ from weakref import WeakKeyDictionary
from aiogram.exceptions import TelegramRetryAfter from aiogram.exceptions import TelegramRetryAfter
_limiters = WeakKeyDictionary() _limiters = WeakKeyDictionary()
DELIVERY_TIMEOUT = 30.0
MAX_RETRY_AFTER = 5.0
class DeliveryLimiter: class DeliveryLimiter:
@@ -40,7 +42,8 @@ def background_delivery(func):
@wraps(func) @wraps(func)
async def wrapped(*args, **kwargs): async def wrapped(*args, **kwargs):
async with limiter(): async with limiter():
return await func(*args, **kwargs) async with asyncio.timeout(DELIVERY_TIMEOUT):
return await func(*args, **kwargs)
return wrapped return wrapped
@@ -48,8 +51,8 @@ def background_delivery(func):
async def send_background(bot, **kwargs): async def send_background(bot, **kwargs):
for attempt in range(2): for attempt in range(2):
try: try:
return await bot.send_message(**kwargs) return await bot.send_message(**{"request_timeout": 15, **kwargs})
except TelegramRetryAfter as error: except TelegramRetryAfter as error:
if attempt: if attempt or error.retry_after > MAX_RETRY_AFTER:
raise raise
await asyncio.sleep(error.retry_after) await asyncio.sleep(error.retry_after)

View File

@@ -1,12 +1,26 @@
"""Copy user messages without losing Telegram entities, including custom emoji.""" """Copy user messages without losing Telegram entities, including custom emoji."""
import asyncio
from functools import wraps
from aiogram.types import Message, MessageEntity from aiogram.types import Message, MessageEntity
COPY_TIMEOUT = 15.0
def bounded_copy(func):
@wraps(func)
async def wrapped(*args, **kwargs):
async with asyncio.timeout(COPY_TIMEOUT):
return await func(*args, **kwargs)
return wrapped
def utf16_length(text: str) -> int: def utf16_length(text: str) -> int:
"""Telegram entity offsets count UTF-16 code units, not Python characters.""" """Telegram entity offsets count UTF-16 code units, not Python characters."""
return len(text.encode("utf-16-le")) // 2 return len(text.encode("utf-16-le")) // 2
@bounded_copy
async def copy_preserving_entities(message: Message, recipient_id: int, sender_name: str | None = None): async def copy_preserving_entities(message: Message, recipient_id: int, sender_name: str | None = None):
if sender_name is None: if sender_name is None:
# Native copy keeps the original body/caption and its entities unchanged. # Native copy keeps the original body/caption and its entities unchanged.

View File

@@ -11,18 +11,9 @@ from ..core.config import ADMIN_IDS
async def setup_admin_users(): async def setup_admin_users():
"""Установить права администратора для пользователей из ADMIN_IDS""" """Configured system admins already have access; never persist it as a second role."""
if not ADMIN_IDS: print(f"System administrators are loaded directly from ADMIN_IDS ({len(ADMIN_IDS)} configured).")
print("❌ Список ADMIN_IDS пуст") print("Use /staff in the bot to appoint ordinary administrators and cashiers.")
return
async with async_session_maker() as session:
for admin_id in ADMIN_IDS:
success = await UserService.set_admin(session, admin_id, True)
if success:
print(f"✅ Права администратора установлены для ID: {admin_id}")
else:
print(f"⚠️ Пользователь с ID {admin_id} не найден в базе")
async def create_sample_lottery(): async def create_sample_lottery():

View File

@@ -0,0 +1,126 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
from aiogram.methods import CopyMessage
from aiogram.types import Chat, Message, User as TelegramUser
from src.core.broadcast_services import BroadcastService
from src.core.database import async_session_maker
from src.core.services import UserService
from src.handlers import chat_handlers
from src.utils import delivery, telegram_messages
from src.utils.broadcast_jobs import jobs, shutdown_broadcasts, start_broadcast
from test_dispatcher import TelegramStub, dispatch, event
async def test_a_stalled_chat_recipient_does_not_block_other_recipients_or_next_message(monkeypatch):
monkeypatch.setattr(telegram_messages, "COPY_TIMEOUT", 0.1)
async with async_session_maker() as session:
sender = await UserService.get_or_create_user(session, 831)
await UserService.get_or_create_user(session, 832)
await UserService.get_or_create_user(session, 833)
class StallingTelegram(TelegramStub):
async def make_request(self, bot, method, timeout=None):
if getattr(method, "chat_id", None) == 832:
await asyncio.Event().wait()
return await super().make_request(bot, method, timeout)
stub = StallingTelegram()
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub)
message = Message(message_id=1, date=datetime.now(timezone.utc), chat=Chat(id=831, type="private"),
from_user=TelegramUser(id=831, first_name="Sender", is_bot=False), text="hello").as_(bot)
_, successes, failures = await asyncio.wait_for(
chat_handlers.broadcast_message_with_scheduler(message, sender, exclude_user_id=831), 2)
assert successes >= 1 and failures == 1
assert await chat_handlers._copy_with_sender(message, 833, "Sender") == 100
async def test_blocked_recipient_and_extreme_flood_wait_do_not_stop_broadcast(monkeypatch):
service = BroadcastService()
async with async_session_maker() as session:
admin = await UserService.get_or_create_user(session, 900001)
users = [await UserService.get_or_create_user(session, number) for number in (834, 835, 836)]
async def copy_to(target):
method = CopyMessage(chat_id=target, from_chat_id=1, message_id=1)
if target == 834:
raise TelegramForbiddenError(method=method, message="bot was blocked by the user")
if target == 835:
raise TelegramRetryAfter(method=method, message="slow down", retry_after=3600)
return SimpleNamespace(message_id=1)
message = SimpleNamespace(copy_to=copy_to, text="test", caption=None, content_type="text",
photo=None, video=None, document=None, animation=None, voice=None, audio=None)
result = await asyncio.wait_for(service.broadcast_to_users(None, message, admin.id, users), 3)
assert result == {"total": 3, "success": 1, "failed": 2, "blocked": 1}
async def test_timeout_releases_delivery_capacity(monkeypatch):
monkeypatch.setattr(delivery, "DELIVERY_TIMEOUT", 0.05)
@delivery.background_delivery
async def stall():
await asyncio.Event().wait()
result = await asyncio.gather(*(stall() for _ in range(8)), return_exceptions=True)
assert all(isinstance(error, TimeoutError) for error in result)
sender = SimpleNamespace(send_message=AsyncMock(return_value=True))
assert await delivery.send_background(sender, chat_id=1, text="next")
async def test_background_job_failure_releases_slot_and_shutdown_cancels_pending_work():
failure_report = AsyncMock()
async def broken():
raise RuntimeError("synthetic failure")
assert start_broadcast(837, broken, failure_report)
assert not start_broadcast(837, broken, failure_report)
await asyncio.gather(*list(jobs().values()))
failure_report.assert_awaited_once()
assert not jobs()
assert start_broadcast(837, asyncio.Event().wait, failure_report)
await shutdown_broadcasts()
assert not jobs()
async def test_missing_broadcast_report_does_not_fail_completed_delivery():
from src.handlers.admin_panel import _edit_broadcast_status
report = SimpleNamespace(edit_text=AsyncMock(side_effect=TelegramForbiddenError(
method=CopyMessage(chat_id=1, from_chat_id=1, message_id=1), message="bot was blocked")))
await _edit_broadcast_status(report, "Delivery completed")
report.edit_text.assert_awaited_once()
async def test_admin_can_use_start_while_broadcast_runs_and_failure_clears_fsm(monkeypatch):
import main
from src.handlers import admin_panel
started = asyncio.Event()
finish = asyncio.Event()
async def sending(*args, **kwargs):
started.set()
await finish.wait()
raise RuntimeError("synthetic Telegram failure")
monkeypatch.setattr(admin_panel, "_broadcast_direct", sending)
await dispatch(event(900001, text="/cancel"))
state = main.dp.fsm.get_context(bot=Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS"), chat_id=900001, user_id=900001)
await state.set_state(admin_panel.AdminStates.broadcast_message)
await state.set_data({"broadcast_type": "direct"})
try:
await dispatch(event(900001, text="broadcast content"))
await asyncio.wait_for(started.wait(), 1)
assert await state.get_state() is None
calls = await asyncio.wait_for(dispatch(event(900001, text="/start")), 2)
assert any((getattr(call, "text", "") or "").startswith("👋") for call in calls)
finish.set()
await asyncio.gather(*list(jobs().values()))
assert await state.get_state() is None
finally:
await shutdown_broadcasts()

View File

@@ -18,6 +18,7 @@ def test_release_contains_runtime_and_migrations_but_no_secrets(tmp_path):
names = archive.getnames() names = archive.getnames()
assert "main.py" in names and "Dockerfile" in names assert "main.py" in names and "Dockerfile" in names
assert "migrations/versions/20260913_staff_concurrency.py" in names assert "migrations/versions/20260913_staff_concurrency.py" in names
assert {"docs/ADMIN_GUIDE.md", "docs/CASHIER_GUIDE.md", "docs/SYSTEM_ADMIN_GUIDE.md"} <= set(names)
assert not any(".env" in name or ".history" in name or ".git/" in name or "__pycache__" in name for name in names) assert not any(".env" in name or ".history" in name or ".git/" in name or "__pycache__" in name for name in names)

View File

@@ -0,0 +1,121 @@
import asyncio
from aiogram import Bot
import pytest
from sqlalchemy import select
from src.core.access import get_role
from src.core.database import async_session_maker
from src.core.models import User
from src.core.services import UserService
from src.core.staff_service import StaffError, change_role, get_member
from test_dispatcher import dispatch, event
async def create_user(telegram_id, **flags):
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, telegram_id, first_name="<Employee>")
for key, value in flags.items():
setattr(user, key, value)
await session.commit()
return user
async def test_roles_are_exclusive_and_removal_does_not_restore_old_cashier_privilege():
await create_user(810, is_cashier=True)
await change_role(900001, 810, "admin", (False, True))
member = await get_member(900001, 810)
assert member.role == "admin" and member.flags == (True, False)
await change_role(900001, 810, "cashier", member.flags)
assert await get_role(810) == "cashier"
await change_role(900001, 810, "user", (False, True))
assert await get_role(810) == "user"
@pytest.mark.parametrize("actor", [811, 900002])
async def test_non_system_staff_cannot_assign_roles_even_by_service(actor):
await create_user(actor, is_admin=actor == 811)
await create_user(812)
with pytest.raises(StaffError, match="системный администратор"):
await change_role(actor, 812, "admin", (False, False))
assert await get_role(812) == "user"
@pytest.mark.parametrize("target", [900001, 900002])
async def test_server_configured_staff_cannot_be_changed(target):
await create_user(target)
with pytest.raises(StaffError):
await change_role(900001, target, "user", (False, False))
async def test_two_system_admins_cannot_overwrite_a_changed_role(monkeypatch):
from src.core import staff_service
monkeypatch.setattr(staff_service, "ADMIN_IDS", [900001, 900003])
await create_user(813)
results = await asyncio.gather(
change_role(900001, 813, "admin", (False, False)),
change_role(900003, 813, "cashier", (False, False)), return_exceptions=True)
assert sum(isinstance(result, StaffError) for result in results) == 1
assert sum(result is None for result in results) == 1
@pytest.mark.parametrize("flags", [{"is_admin": True}, {"is_cashier": True}])
async def test_cleanup_cannot_revoke_staff_access(flags):
user = await create_user(814, **flags)
async with async_session_maker() as session:
assert not await UserService.delete_user(session, user.id)
assert await session.scalar(select(User.id).where(User.id == user.id)) == user.id
def confirmation(calls):
return next(button.callback_data for call in calls if getattr(call, "reply_markup", None)
for row in call.reply_markup.inline_keyboard for button in row
if button.callback_data and button.callback_data.startswith("staff:confirm:"))
async def test_actual_assignment_requires_confirmation_and_old_button_cannot_change_new_target():
await create_user(815)
await create_user(816)
first = await dispatch(event(900001, text="/add_admin 815"))
old_button = confirmation(first)
assert await get_role(815) == "user"
assert any("&lt;Employee&gt;" in (getattr(call, "text", "") or "") for call in first)
second = await dispatch(event(900001, text="/add_cashier 816"))
new_button = confirmation(second)
await dispatch(event(900001, callback_data=old_button))
assert await get_role(815) == "user" and await get_role(816) == "user"
await dispatch(event(900001, callback_data=new_button))
assert await get_role(816) == "cashier"
await change_role(900001, 816, "user", (False, True))
await dispatch(event(900001, callback_data=new_button))
assert await get_role(816) == "user"
@pytest.mark.parametrize("actor", [817, 900002])
async def test_actual_ordinary_admin_and_cashier_cannot_forge_staff_callback(actor):
await create_user(actor, is_admin=actor == 817)
await create_user(818)
for update in (event(actor, text="/add_admin 818"),
event(actor, callback_data="staff:role:admin"),
event(actor, callback_data="admin_remove_select:900001")):
calls = await dispatch(update)
assert any("системный администратор" in (getattr(call, "text", "") or "") for call in calls)
assert await get_role(818) == "user"
async def test_revoked_cashier_cannot_continue_open_participant_dialog():
await create_user(819, is_cashier=True)
await dispatch(event(819, callback_data="cash_add:1"))
await change_role(900001, 819, "user", (False, True))
calls = await dispatch(event(819, text="11-22-33-44-55-66-77"))
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
async def test_guides_are_delivered_only_to_the_appropriate_roles():
from src.handlers.operator_help import DOCS, GUIDES
assert all((DOCS / spec[0]).is_file() for spec in GUIDES.values())
calls = await dispatch(event(900002, text="/cashier_help"))
assert any(call.__api_method__ == "sendDocument" for call in calls)
for update in (event(900002, text="/admin_help"), event(820, callback_data="guide:system")):
calls = await dispatch(update)
assert not any(call.__api_method__ == "sendDocument" for call in calls)