Compare commits
No commits in common. "6b15d8ae0466974d232cb0524eb7f1fd3e20019d" and "16bd8c06b52de1702a8c958dbc50dae387042baf" have entirely different histories.
6b15d8ae04
...
16bd8c06b5
|
|
@ -2,10 +2,6 @@
|
||||||
.gitignore
|
.gitignore
|
||||||
.gitattributes
|
.gitattributes
|
||||||
|
|
||||||
# секреты локального окружения (в образ не попадают)
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# venv / кеши
|
# venv / кеши
|
||||||
.venv
|
.venv
|
||||||
.venv11
|
.venv11
|
||||||
|
|
|
||||||
19
.env.example
19
.env.example
|
|
@ -5,12 +5,6 @@ APP_ENV=dev
|
||||||
|
|
||||||
# Токен бота (получается у @BotFather)
|
# Токен бота (получается у @BotFather)
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
# Username бота без @ (для кнопки Telegram-входа в админку)
|
|
||||||
BOT_USERNAME=
|
|
||||||
# Публичный HTTPS-адрес админки (для Telegram Login Widget)
|
|
||||||
ADMIN_PUBLIC_URL=http://localhost:8000
|
|
||||||
# Секрет подписи cookie-сессии: openssl rand -hex 32 (в проде обязателен)
|
|
||||||
ADMIN_SESSION_SECRET=
|
|
||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
|
|
@ -48,16 +42,3 @@ CLICKHOUSE_PASSWORD=
|
||||||
ANALYTICS_BATCH_SIZE=200
|
ANALYTICS_BATCH_SIZE=200
|
||||||
ANALYTICS_FLUSH_INTERVAL=5.0
|
ANALYTICS_FLUSH_INTERVAL=5.0
|
||||||
ANALYTICS_BUFFER_MAX=5000
|
ANALYTICS_BUFFER_MAX=5000
|
||||||
|
|
||||||
# Шаблоны сообщений: сколько секунд бот держит тексты из БД в памяти.
|
|
||||||
# Правка в админке применяется без перезапуска в течение этого времени.
|
|
||||||
TEMPLATE_CACHE_TTL=45
|
|
||||||
|
|
||||||
# Пасхальное событие «Яички». Пусто — автоматически 1 апреля → Православная Пасха.
|
|
||||||
# Можно задать вручную (ISO): EGG_EVENT_START=2027-04-01, EGG_EVENT_END=2027-05-02.
|
|
||||||
EGG_EVENT_START=
|
|
||||||
EGG_EVENT_END=
|
|
||||||
|
|
||||||
# Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose.
|
|
||||||
ADMIN_HTTP_PORT=8000
|
|
||||||
ADMIN_BIND_IP=127.0.0.1
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ concurrency:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.mpakosrv.ru
|
REGISTRY: git.mpakosrv.ru
|
||||||
IMAGE_NAME_BOT: mpakobecex/dispelevilbot-bot
|
IMAGE_NAME: mpakobecex/dispelevilbot-bot
|
||||||
IMAGE_NAME_ADMIN: mpakobecex/dispelevilbot-admin
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-test:
|
lint-test:
|
||||||
|
|
@ -41,22 +40,16 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Логин в Gitea Registry
|
- name: Логин в Gitea Registry
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
- name: Сборка и публикация образов
|
- name: Сборка и публикация образа
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
SHORT_SHA="$(echo "${GITHUB_SHA:-unknown}" | cut -c1-12)"
|
IMAGE="$REGISTRY/$IMAGE_NAME"
|
||||||
|
SHA="${GITHUB_SHA:-unknown}"
|
||||||
|
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
|
||||||
VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)"
|
VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)"
|
||||||
|
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
|
||||||
build_push() {
|
if [ -n "$VERSION" ]; then TAGS="$TAGS $IMAGE:$VERSION"; fi
|
||||||
image="$1"
|
ARGS=""
|
||||||
dockerfile="$2"
|
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
||||||
tags="$image:$SHORT_SHA $image:latest"
|
docker build $ARGS .
|
||||||
if [ -n "$VERSION" ]; then tags="$tags $image:$VERSION"; fi
|
for t in $TAGS; do docker push "$t"; done
|
||||||
args=""
|
|
||||||
for t in $tags; do args="$args -t $t"; done
|
|
||||||
docker build $args -f "$dockerfile" .
|
|
||||||
for t in $tags; do docker push "$t"; done
|
|
||||||
}
|
|
||||||
|
|
||||||
build_push "$REGISTRY/$IMAGE_NAME_BOT" Dockerfile
|
|
||||||
build_push "$REGISTRY/$IMAGE_NAME_ADMIN" admin/Dockerfile
|
|
||||||
|
|
|
||||||
42
DEPLOY.md
42
DEPLOY.md
|
|
@ -17,15 +17,10 @@ cd DispelEvilBot
|
||||||
cp env/test.env.example env/test.env
|
cp env/test.env.example env/test.env
|
||||||
# заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.),
|
# заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.),
|
||||||
# ADMIN_IDS (опц.), CLICKHOUSE_PASSWORD (аналитика событий)
|
# ADMIN_IDS (опц.), CLICKHOUSE_PASSWORD (аналитика событий)
|
||||||
# плюс для админки: BOT_USERNAME, ADMIN_PUBLIC_URL (HTTPS-домен),
|
|
||||||
# ADMIN_SESSION_SECRET (openssl rand -hex 32)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Порт ClickHouse на хосте (`CLICKHOUSE_HTTP_PORT`) у контуров должен различаться:
|
Порт ClickHouse на хосте (`CLICKHOUSE_HTTP_PORT`) у контуров должен различаться:
|
||||||
`prod` — `8123`, `test` — `8124` (иначе контейнеры конфликтуют на одной VPS).
|
`prod` — `8123`, `test` — `8124` (иначе контейнеры конфликтуют на одной VPS).
|
||||||
Порт админки (`ADMIN_HTTP_PORT`) — так же: `prod` — `8101`, `test` — `8100`
|
|
||||||
(8000 на VPS занят Portainer). `ADMIN_BIND_IP` по умолчанию `127.0.0.1`; чтобы
|
|
||||||
nginx-proxy-manager (контейнер) видел админку, ставьте `172.17.0.1`.
|
|
||||||
|
|
||||||
## 3. Логин в registry (если пакет приватный)
|
## 3. Логин в registry (если пакет приватный)
|
||||||
|
|
||||||
|
|
@ -57,8 +52,7 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml logs -
|
||||||
2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля;
|
2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля;
|
||||||
3. `clickhouse` поднимается и применяет `clickhouse/init/001_events.sql` —
|
3. `clickhouse` поднимается и применяет `clickhouse/init/001_events.sql` —
|
||||||
**только при первой инициализации** пустого data-каталога;
|
**только при первой инициализации** пустого data-каталога;
|
||||||
4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же;
|
4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же.
|
||||||
5. `admin` стартует и отвечает на `GET /healthz` (порт на loopback хоста).
|
|
||||||
|
|
||||||
Проверка контура (сервисы, миграции, Postgres, ClickHouse):
|
Проверка контура (сервисы, миграции, Postgres, ClickHouse):
|
||||||
|
|
||||||
|
|
@ -85,40 +79,6 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml exec -
|
||||||
Изменения схемы: `clickhouse/init/*.sql` применяются лишь на пустом data-каталоге,
|
Изменения схемы: `clickhouse/init/*.sql` применяются лишь на пустом data-каталоге,
|
||||||
поэтому на существующем контуре DDL нужно применить вручную (`make ch E=...`).
|
поэтому на существующем контуре DDL нужно применить вручную (`make ch E=...`).
|
||||||
|
|
||||||
## Админка (FastAPI)
|
|
||||||
|
|
||||||
Сервис `admin` — отдельный образ `mpakobecex/dispelevilbot-admin`, общий пакет `common`.
|
|
||||||
Наружу пока **не публикуется**: по умолчанию только loopback хоста
|
|
||||||
(`127.0.0.1:${ADMIN_HTTP_PORT}`). Для nginx-proxy-manager (он в контейнере) задайте
|
|
||||||
`ADMIN_BIND_IP=172.17.0.1` и указывайте в NPM цель `172.17.0.1:8100` (test) /
|
|
||||||
`172.17.0.1:8101` (prod). Домен и TLS — Блок 4.2.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# проверка живости
|
|
||||||
curl -s http://127.0.0.1:8100/healthz # test
|
|
||||||
curl -s http://127.0.0.1:8101/healthz # prod
|
|
||||||
```
|
|
||||||
|
|
||||||
Локально без Docker: `pip install -r requirements-admin.txt && python -m admin`
|
|
||||||
(по умолчанию `0.0.0.0:8000`, переопределяется `ADMIN_HOST`/`ADMIN_PORT`).
|
|
||||||
|
|
||||||
### Вход через Telegram
|
|
||||||
|
|
||||||
Админка закрыта Telegram Login Widget. Что нужно на контуре:
|
|
||||||
|
|
||||||
- `BOT_TOKEN` — токен бота этого контура (им же проверяется подпись `hash`);
|
|
||||||
- `BOT_USERNAME` — username бота без `@` (виджет рисует кнопку);
|
|
||||||
- `ADMIN_PUBLIC_URL` — публичный HTTPS-адрес (`https://debot-test.mpakosrv.ru`);
|
|
||||||
- `ADMIN_SESSION_SECRET` — `openssl rand -hex 32`;
|
|
||||||
- `ADMIN_IDS` — Telegram-ID администраторов: ими засевается `bot.admin_users` при старте
|
|
||||||
(`ON CONFLICT DO NOTHING`, существующих не трогает).
|
|
||||||
|
|
||||||
Домен должен быть привязан к боту в `@BotFather` (`/setdomain`), а TLS — настроен на
|
|
||||||
reverse proxy. Сессия живёт 12 часов (`ADMIN_SESSION_MAX_AGE`), cookie подписана и
|
|
||||||
`HttpOnly`; в проде ставится флаг `Secure`. Все POST-формы проверяют CSRF-токен.
|
|
||||||
Роль администратора перечитывается из БД на каждом запросе — отключение пользователя
|
|
||||||
(`enabled = false`) или удаление записи сразу закрывает доступ.
|
|
||||||
|
|
||||||
## Бэкап событий (забирает домашний сервер)
|
## Бэкап событий (забирает домашний сервер)
|
||||||
|
|
||||||
Сырые события живут на VPS ограниченное время: раз в сутки домашний сервер **сам забирает**
|
Сырые события живут на VPS ограниченное время: раз в сутки домашний сервер **сам забирает**
|
||||||
|
|
|
||||||
53
README.md
53
README.md
|
|
@ -8,7 +8,6 @@ Telegram-бот клана Dispel Evil (aiogram 3 + Postgres). Монорепо
|
||||||
```
|
```
|
||||||
common/ # общий пакет: config, logging, db, repositories, models, texts, analytics
|
common/ # общий пакет: config, logging, db, repositories, models, texts, analytics
|
||||||
bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications
|
bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications
|
||||||
admin/ # сервис админки: FastAPI + Jinja2/HTMX (Блок 4), свой Dockerfile
|
|
||||||
sql/ # миграции Flyway (V__*.sql, R__*.sql)
|
sql/ # миграции Flyway (V__*.sql, R__*.sql)
|
||||||
clickhouse/ # init-скрипты ClickHouse (схема аналитики)
|
clickhouse/ # init-скрипты ClickHouse (схема аналитики)
|
||||||
docs/ # документация (docs/events.md — события аналитики)
|
docs/ # документация (docs/events.md — события аналитики)
|
||||||
|
|
@ -44,46 +43,11 @@ APP_ENV=prod python -m bot # prod
|
||||||
|
|
||||||
или через `./start.sh` (режим берётся из `APP_ENV`).
|
или через `./start.sh` (режим берётся из `APP_ENV`).
|
||||||
|
|
||||||
Админка (FastAPI) запускается отдельным процессом: `python -m admin` (зависимости —
|
|
||||||
`requirements-admin.txt`), по умолчанию слушает `0.0.0.0:8000`, проверка живучести — `GET /healthz`.
|
|
||||||
|
|
||||||
## База данных
|
## База данных
|
||||||
|
|
||||||
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
|
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
|
||||||
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
|
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
|
||||||
|
|
||||||
## Админка (Блок 4)
|
|
||||||
|
|
||||||
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
|
|
||||||
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение
|
|
||||||
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты —
|
|
||||||
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их).
|
|
||||||
Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому
|
|
||||||
правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
|
|
||||||
Предпросмотр использует тот же `render()`, что и бот.
|
|
||||||
|
|
||||||
**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ
|
|
||||||
есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе).
|
|
||||||
Первые администраторы засеваются из `ADMIN_IDS` при старте. Сессия — подписанная
|
|
||||||
HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны
|
|
||||||
`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`.
|
|
||||||
|
|
||||||
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
|
|
||||||
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
|
|
||||||
база недоступна — использует расписание из кода (совпадает с сидом `V16`).
|
|
||||||
|
|
||||||
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
|
|
||||||
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
|
|
||||||
подписка на рассылки, дата вступления и последние операции по балансу. В карточке можно
|
|
||||||
сменить ник и звание, включить/выключить подписку и скорректировать баланс (запись в
|
|
||||||
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
|
||||||
`bot.user_changes`: кто, когда, было → стало и причина.
|
|
||||||
|
|
||||||
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username)
|
|
||||||
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON,
|
|
||||||
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
|
|
||||||
если он недоступен, страница показывает ошибку, а не падает.
|
|
||||||
|
|
||||||
## Аналитика (ClickHouse)
|
## Аналитика (ClickHouse)
|
||||||
|
|
||||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
||||||
|
|
@ -102,18 +66,13 @@ ID пользователя ведёт в его карточку. ClickHouse ч
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Локальный запуск (postgres + migrate + clickhouse + bot + admin):
|
Локальный запуск (postgres + migrate + clickhouse + bot):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # заполнить
|
cp .env.example .env # заполнить
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose
|
|
||||||
по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 /
|
|
||||||
prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет
|
|
||||||
вместе с доменом и TLS (Блок 4.2).
|
|
||||||
|
|
||||||
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
|
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -145,7 +104,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
|
|
||||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.9`.
|
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.9`.
|
||||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
@ -174,14 +133,6 @@ git tag -a vX.Y -m "Release vX.Y"
|
||||||
git push origin master --tags
|
git push origin master --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
## Пасхальное событие «Яички»
|
|
||||||
|
|
||||||
`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху**
|
|
||||||
(в 2026 году — 12 апреля). Вне окна бот отвечает, что ещё рано, а строка про яички
|
|
||||||
не показывается ни в `/инфо`, ни в карточке пользователя в админке.
|
|
||||||
Даты можно зафиксировать переменными `EGG_EVENT_START`/`EGG_EVENT_END` (ISO,
|
|
||||||
например `2027-04-01` и `2027-05-02`).
|
|
||||||
|
|
||||||
## Команды бота
|
## Команды бота
|
||||||
|
|
||||||
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,
|
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
# syntax=docker/dockerfile:1
|
|
||||||
# Образ сервиса админки (FastAPI + Jinja2/HTMX).
|
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Непривилегированный пользователь
|
|
||||||
RUN useradd --create-home --uid 1000 app
|
|
||||||
|
|
||||||
COPY requirements-admin.txt ./
|
|
||||||
RUN pip install --no-cache-dir -r requirements-admin.txt
|
|
||||||
|
|
||||||
COPY common ./common
|
|
||||||
COPY admin ./admin
|
|
||||||
|
|
||||||
USER app
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Живость процесса: HTTP-проверка /healthz (порт берётся из ADMIN_PORT).
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
||||||
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('ADMIN_PORT','8000')+'/healthz', timeout=3)"
|
|
||||||
|
|
||||||
CMD ["python", "-m", "admin"]
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
"""Сервис админки DispelEvilBot (FastAPI + HTMX).
|
|
||||||
|
|
||||||
Отдельный процесс/образ; общий код — пакет ``common``. Запуск: ``python -m admin``.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"""Точка входа админки: python -m admin"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
from common.config import get_admin_settings
|
|
||||||
from common.logging_config import setup_logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Запустить uvicorn с приложением из ``admin.app``."""
|
|
||||||
setup_logging(logging.INFO)
|
|
||||||
settings = get_admin_settings()
|
|
||||||
logger.info("Запуск админки (APP_ENV=%s) на %s:%s", settings.app_env, settings.admin_host, settings.admin_port)
|
|
||||||
# log_config=None — чтобы uvicorn не перетирал единый формат логов.
|
|
||||||
uvicorn.run(
|
|
||||||
"admin.app:create_app",
|
|
||||||
factory=True,
|
|
||||||
host=settings.admin_host,
|
|
||||||
port=settings.admin_port,
|
|
||||||
log_config=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
94
admin/app.py
94
admin/app.py
|
|
@ -1,94 +0,0 @@
|
||||||
"""Создание FastAPI-приложения админки."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
|
||||||
|
|
||||||
from admin.middleware import AdminAuthMiddleware
|
|
||||||
from admin.routes import auth, health, logs, messages, pages, schedules, users
|
|
||||||
from common import __version__
|
|
||||||
from common.analytics import read as analytics_read
|
|
||||||
from common.config import AdminSettings, get_admin_settings
|
|
||||||
from common.db.pool import close_pool, init_pool
|
|
||||||
from common.logging_config import setup_logging
|
|
||||||
from common.repositories import admins_repo
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
|
||||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -> FastAPI:
|
|
||||||
"""Собрать приложение: настройки, шаблоны, роутеры.
|
|
||||||
|
|
||||||
Настройки можно передать явно (тесты); по умолчанию читаются из окружения.
|
|
||||||
``init_db=False`` — не открывать пул Postgres (юнит-тесты без БД).
|
|
||||||
"""
|
|
||||||
setup_logging()
|
|
||||||
settings = settings or get_admin_settings()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
||||||
if init_db:
|
|
||||||
await init_pool(app.state.settings)
|
|
||||||
try:
|
|
||||||
inserted = await admins_repo.ensure_admins(app.state.settings.admin_ids)
|
|
||||||
if inserted:
|
|
||||||
logger.info("Добавлены администраторы из ADMIN_IDS: %s", inserted)
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"Не удалось засеять администраторов (применены ли миграции?)",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
if init_db:
|
|
||||||
await close_pool()
|
|
||||||
await analytics_read.close_client()
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="DispelEvilBot Admin",
|
|
||||||
version=__version__,
|
|
||||||
# Swagger/OpenAPI наружу пока не нужны: админка закрыта логином (Блок 4.2).
|
|
||||||
docs_url=None,
|
|
||||||
redoc_url=None,
|
|
||||||
openapi_url=None,
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
app.state.settings = settings
|
|
||||||
app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
||||||
|
|
||||||
# Порядок важен: SessionMiddleware добавляется последним и оборачивает auth,
|
|
||||||
# поэтому request.session доступен в проверке администратора.
|
|
||||||
app.add_middleware(AdminAuthMiddleware)
|
|
||||||
session_secret = settings.admin_session_secret
|
|
||||||
if not session_secret:
|
|
||||||
session_secret = secrets.token_urlsafe(32)
|
|
||||||
logger.warning("ADMIN_SESSION_SECRET не задан — сессии слетят при перезапуске")
|
|
||||||
app.add_middleware(
|
|
||||||
SessionMiddleware,
|
|
||||||
secret_key=session_secret,
|
|
||||||
session_cookie="debot_admin",
|
|
||||||
max_age=settings.admin_session_max_age,
|
|
||||||
same_site="lax",
|
|
||||||
https_only=settings.is_prod,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
|
||||||
app.include_router(auth.router)
|
|
||||||
app.include_router(pages.router)
|
|
||||||
app.include_router(messages.router)
|
|
||||||
app.include_router(schedules.router)
|
|
||||||
app.include_router(users.router)
|
|
||||||
app.include_router(logs.router)
|
|
||||||
return app
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"""Проверка и разбор форм админки (чистые функции — без HTTP и БД)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
WEEKDAY_NAMES: dict[int, str] = {
|
|
||||||
1: "Пн",
|
|
||||||
2: "Вт",
|
|
||||||
3: "Ср",
|
|
||||||
4: "Чт",
|
|
||||||
5: "Пт",
|
|
||||||
6: "Сб",
|
|
||||||
7: "Вс",
|
|
||||||
}
|
|
||||||
|
|
||||||
MAX_DESCRIPTION_LENGTH = 200
|
|
||||||
|
|
||||||
|
|
||||||
def format_weekdays(days: list[int] | tuple[int, ...] | None) -> str:
|
|
||||||
"""Человеческое описание дней ISO: ``1,3,5`` -> ``Пн, Ср, Пт``."""
|
|
||||||
if not days:
|
|
||||||
return "каждый день"
|
|
||||||
return ", ".join(WEEKDAY_NAMES[day] for day in sorted(set(days)) if day in WEEKDAY_NAMES)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_schedule_fields(
|
|
||||||
*,
|
|
||||||
template_key: str,
|
|
||||||
hour: str,
|
|
||||||
minute: str,
|
|
||||||
weekdays: list[str],
|
|
||||||
timezone: str,
|
|
||||||
description: str,
|
|
||||||
enabled: bool,
|
|
||||||
) -> tuple[dict[str, Any] | None, list[str]]:
|
|
||||||
"""Собрать данные расписания из полей формы.
|
|
||||||
|
|
||||||
Возвращает ``(data, errors)``: при ошибках ``data is None`` и заполнен ``errors``.
|
|
||||||
"""
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
hour_value = int(hour)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
hour_value = -1
|
|
||||||
errors.append("Час должен быть числом")
|
|
||||||
try:
|
|
||||||
minute_value = int(minute)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
minute_value = -1
|
|
||||||
errors.append("Минуты должны быть числом")
|
|
||||||
|
|
||||||
if not 0 <= hour_value <= 23 and "Час должен быть числом" not in errors:
|
|
||||||
errors.append("Час должен быть от 0 до 23")
|
|
||||||
if not 0 <= minute_value <= 59 and "Минуты должны быть числом" not in errors:
|
|
||||||
errors.append("Минуты должны быть от 0 до 59")
|
|
||||||
|
|
||||||
days: list[int] = []
|
|
||||||
for raw in weekdays:
|
|
||||||
try:
|
|
||||||
day = int(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
errors.append(f"Некорректный день недели: {raw}")
|
|
||||||
continue
|
|
||||||
if 1 <= day <= 7:
|
|
||||||
days.append(day)
|
|
||||||
else:
|
|
||||||
errors.append("День недели должен быть от 1 (Пн) до 7 (Вс)")
|
|
||||||
|
|
||||||
tz_name = (timezone or "").strip() or "Europe/Moscow"
|
|
||||||
try:
|
|
||||||
ZoneInfo(tz_name)
|
|
||||||
except (ZoneInfoNotFoundError, ValueError):
|
|
||||||
errors.append(f"Неизвестная таймзона: {tz_name}")
|
|
||||||
|
|
||||||
if not template_key.strip():
|
|
||||||
errors.append("Выберите шаблон сообщения")
|
|
||||||
|
|
||||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
|
||||||
errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
return None, errors
|
|
||||||
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"template_key": template_key,
|
|
||||||
"hour": hour_value,
|
|
||||||
"minute": minute_value,
|
|
||||||
"weekdays": sorted(set(days)),
|
|
||||||
"timezone": tz_name,
|
|
||||||
"enabled": enabled,
|
|
||||||
"description": description.strip(),
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"""Middleware админки: пускает дальше только вошедших администраторов."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import RedirectResponse, Response
|
|
||||||
|
|
||||||
from common.repositories import admins_repo
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Эндпоинты без авторизации.
|
|
||||||
PUBLIC_PATHS = frozenset({"/healthz", "/login", "/auth/telegram"})
|
|
||||||
|
|
||||||
|
|
||||||
class AdminAuthMiddleware(BaseHTTPMiddleware):
|
|
||||||
"""Сессия + роль из БД на каждом запросе (кроме служебных и логина)."""
|
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
||||||
if request.url.path in PUBLIC_PATHS:
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
admin_id = request.session.get("admin_id") if "session" in request.scope else None
|
|
||||||
if not admin_id:
|
|
||||||
target = quote(request.url.path)
|
|
||||||
return RedirectResponse(url=f"/login?next={target}", status_code=303)
|
|
||||||
|
|
||||||
try:
|
|
||||||
admin = await admins_repo.get_admin(int(admin_id))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Не удалось проверить администратора")
|
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
|
||||||
|
|
||||||
if admin is None or not admin["enabled"]:
|
|
||||||
request.session.clear()
|
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
|
||||||
|
|
||||||
request.state.admin = admin
|
|
||||||
# CSRF-токен живёт в сессии; если её создали раньше — добираем.
|
|
||||||
if not request.session.get("csrf"):
|
|
||||||
request.session["csrf"] = secrets.token_urlsafe(32)
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""Предпросмотр шаблонов: тестовые значения для всех тегов.
|
|
||||||
|
|
||||||
Контекст общий для админки и тестов: он должен покрывать ``TAG_REGISTRY``,
|
|
||||||
иначе в предпросмотре часть тегов останется пустой.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from common import __version__
|
|
||||||
|
|
||||||
SAMPLE_CONTEXT: dict[str, Any] = {
|
|
||||||
"username": "@test_user",
|
|
||||||
"nick": "ТестовыйНик",
|
|
||||||
"title": "Ветеран",
|
|
||||||
"lvl": 3,
|
|
||||||
"exp": 120,
|
|
||||||
"exp_max": 300,
|
|
||||||
"money": 1500,
|
|
||||||
"dt": "01.01.2026",
|
|
||||||
"msg_count": 42,
|
|
||||||
"eggs": 2,
|
|
||||||
"eggs_line": "<b>🥚 Количество яичек:</b> 2",
|
|
||||||
"h": "12",
|
|
||||||
"m": "05",
|
|
||||||
"s": "09",
|
|
||||||
"exp_add": 10,
|
|
||||||
"money_add": 50,
|
|
||||||
"usernames": "@test_user @second_user",
|
|
||||||
"users_list": "1: @test_user - ТестовыйНик\n2: @second_user - Второй",
|
|
||||||
"users_list_count": 2,
|
|
||||||
"tops": "1) ТестовыйНик - 42\n2) Второй - 17",
|
|
||||||
"promo": "PROMO-2026",
|
|
||||||
"chat_title": "Dispel Evil",
|
|
||||||
"version": __version__,
|
|
||||||
"uptime": "2ч 15м",
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Роутеры админки."""
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"""Вход в админку через Telegram Login Widget."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
|
|
||||||
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
|
|
||||||
from common import __version__
|
|
||||||
from common.repositories import admins_repo
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
|
||||||
|
|
||||||
#: Поля, которые Telegram включает в подпись (остальные в query игнорируем).
|
|
||||||
TELEGRAM_FIELDS = frozenset({"id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"})
|
|
||||||
|
|
||||||
|
|
||||||
def _deny(request: Request, message: str, status_code: int = 403) -> HTMLResponse:
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="auth/denied.html",
|
|
||||||
context={"version": __version__, "message": message},
|
|
||||||
status_code=status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
|
||||||
async def login_page(request: Request, next: str = "/") -> Response:
|
|
||||||
"""Страница входа с кнопкой Telegram."""
|
|
||||||
target = safe_redirect_target(next)
|
|
||||||
if request.session.get("admin_id"):
|
|
||||||
return RedirectResponse(url=target, status_code=303)
|
|
||||||
|
|
||||||
settings = request.app.state.settings
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="auth/login.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"bot_username": settings.bot_username,
|
|
||||||
"public_url": settings.admin_public_url.rstrip("/"),
|
|
||||||
"configured": bool(settings.bot_token and settings.bot_username),
|
|
||||||
"next": target,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth/telegram")
|
|
||||||
async def telegram_callback(request: Request) -> Response:
|
|
||||||
"""Проверить подпись Telegram, роль в БД и открыть сессию."""
|
|
||||||
settings = request.app.state.settings
|
|
||||||
data = {key: value for key, value in request.query_params.items() if key in TELEGRAM_FIELDS}
|
|
||||||
|
|
||||||
if not verify_telegram_auth(data, settings.bot_token):
|
|
||||||
logger.warning("Отклонён Telegram-логин: неверная подпись или устаревший auth_date")
|
|
||||||
return _deny(request, "Не удалось проверить вход через Telegram. Попробуйте ещё раз.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
telegram_id = int(str(data.get("id")))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return _deny(request, "Telegram не передал идентификатор пользователя.", status_code=400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
admin = await admins_repo.get_admin(telegram_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Ошибка проверки администратора при входе")
|
|
||||||
return _deny(request, "Не удалось проверить доступ. Попробуйте позже.", status_code=503)
|
|
||||||
|
|
||||||
if admin is None or not admin["enabled"]:
|
|
||||||
logger.info("Отказ во входе: %s нет среди администраторов", telegram_id)
|
|
||||||
return _deny(request, "У вас нет доступа к админке. Обратитесь к владельцу.")
|
|
||||||
|
|
||||||
request.session.clear()
|
|
||||||
request.session["admin_id"] = telegram_id
|
|
||||||
request.session["admin_username"] = admin["username"] or data.get("username") or data.get("first_name") or ""
|
|
||||||
request.session["csrf"] = secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await admins_repo.touch_login(telegram_id)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Не удалось обновить last_login_at", exc_info=True)
|
|
||||||
|
|
||||||
logger.info("Вход в админку: %s", telegram_id)
|
|
||||||
return RedirectResponse(url=safe_redirect_target(request.query_params.get("next")), status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
async def logout(request: Request) -> Response:
|
|
||||||
"""Выйти: очистить сессию."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
request.session.clear()
|
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
"""Служебные эндпоинты: healthcheck для Docker и мониторинга."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
|
|
||||||
from common import __version__
|
|
||||||
|
|
||||||
router = APIRouter(tags=["service"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/healthz")
|
|
||||||
async def healthz(request: Request) -> dict[str, str]:
|
|
||||||
"""Вернуть состояние сервиса (используется healthcheck'ом контейнера)."""
|
|
||||||
settings = request.app.state.settings
|
|
||||||
return {"status": "ok", "version": __version__, "app_env": settings.app_env}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
"""Раздел «Логи»: события из ClickHouse с фильтрами и пагинацией."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
from common import __version__
|
|
||||||
from common.analytics import ALL_EVENT_TYPES
|
|
||||||
from common.analytics import read as analytics_read
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
|
||||||
|
|
||||||
#: Сколько событий показывать на странице.
|
|
||||||
PAGE_SIZE = 50
|
|
||||||
|
|
||||||
|
|
||||||
def _pretty_payload(payload: str) -> str:
|
|
||||||
"""Отформатировать JSON-payload для просмотра; битый JSON — как есть."""
|
|
||||||
try:
|
|
||||||
return json.dumps(json.loads(payload), ensure_ascii=False, indent=2)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return payload or ""
|
|
||||||
|
|
||||||
|
|
||||||
def _decorate(row: dict[str, Any], settings: Any) -> dict[str, Any]:
|
|
||||||
"""Добавить локальное время и читаемый payload."""
|
|
||||||
moment = row.get("event_time")
|
|
||||||
if isinstance(moment, datetime):
|
|
||||||
if moment.tzinfo is None:
|
|
||||||
moment = moment.replace(tzinfo=UTC)
|
|
||||||
time_local = moment.astimezone(settings.tzinfo).strftime("%d.%m.%Y %H:%M:%S")
|
|
||||||
else:
|
|
||||||
time_local = "—"
|
|
||||||
|
|
||||||
return {
|
|
||||||
**row,
|
|
||||||
"time_local": time_local,
|
|
||||||
"payload_pretty": _pretty_payload(str(row.get("payload") or "")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
|
||||||
async def logs_page(
|
|
||||||
request: Request,
|
|
||||||
event_type: str = "",
|
|
||||||
user: str = "",
|
|
||||||
date_from: str = "",
|
|
||||||
date_to: str = "",
|
|
||||||
page: int = 1,
|
|
||||||
) -> HTMLResponse:
|
|
||||||
"""Показать события с фильтрами по типу, пользователю и датам."""
|
|
||||||
settings = request.app.state.settings
|
|
||||||
today = datetime.now(settings.tzinfo).date()
|
|
||||||
date_from = date_from or (today - timedelta(days=6)).isoformat()
|
|
||||||
date_to = date_to or today.isoformat()
|
|
||||||
page = max(page, 1)
|
|
||||||
|
|
||||||
events: list[dict[str, Any]] = []
|
|
||||||
total = 0
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
events, total = await analytics_read.fetch_events(
|
|
||||||
settings,
|
|
||||||
event_type=event_type,
|
|
||||||
user=user,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
limit=PAGE_SIZE,
|
|
||||||
offset=(page - 1) * PAGE_SIZE,
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
error = "Даты должны быть в формате ГГГГ-ММ-ДД."
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Не удалось прочитать события из ClickHouse")
|
|
||||||
error = "ClickHouse недоступен — логи временно не читаются."
|
|
||||||
|
|
||||||
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
|
|
||||||
query_base = urlencode(
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in {
|
|
||||||
"event_type": event_type,
|
|
||||||
"user": user,
|
|
||||||
"date_from": date_from,
|
|
||||||
"date_to": date_to,
|
|
||||||
}.items()
|
|
||||||
if value
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="logs/list.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"events": [_decorate(row, settings) for row in events],
|
|
||||||
"event_types": sorted(ALL_EVENT_TYPES),
|
|
||||||
"event_type": event_type,
|
|
||||||
"user": user,
|
|
||||||
"date_from": date_from,
|
|
||||||
"date_to": date_to,
|
|
||||||
"page": page,
|
|
||||||
"pages": pages,
|
|
||||||
"total": total,
|
|
||||||
"error": error,
|
|
||||||
"query_base": query_base,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
"""Раздел «Сообщения»: список шаблонов, редактор и предпросмотр (HTMX)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import html
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
|
|
||||||
from admin.preview import SAMPLE_CONTEXT
|
|
||||||
from admin.security import check_csrf
|
|
||||||
from common import __version__
|
|
||||||
from common.repositories import templates_repo
|
|
||||||
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, TemplateError, render, validate
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
|
||||||
|
|
||||||
|
|
||||||
def _editor_context(
|
|
||||||
request: Request,
|
|
||||||
template: dict[str, object],
|
|
||||||
*,
|
|
||||||
text: str | None = None,
|
|
||||||
errors: list[str] | None = None,
|
|
||||||
saved: bool = False,
|
|
||||||
history: list[dict[str, object]] | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Контекст страницы редактора (общий для GET и повторного показа после ошибки)."""
|
|
||||||
return {
|
|
||||||
"version": __version__,
|
|
||||||
"template": template,
|
|
||||||
"text": text if text is not None else template["text"],
|
|
||||||
"errors": errors or [],
|
|
||||||
"saved": saved,
|
|
||||||
"history": history or [],
|
|
||||||
"tags": TAG_REGISTRY,
|
|
||||||
"max_length": MAX_MESSAGE_LENGTH,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
|
||||||
async def list_messages(request: Request) -> HTMLResponse:
|
|
||||||
"""Список шаблонов: ключ, описание, длина и время последней правки."""
|
|
||||||
templates = request.app.state.templates
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="messages/list.html",
|
|
||||||
context={"version": __version__, "templates": await templates_repo.list_templates()},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/preview", response_class=HTMLResponse)
|
|
||||||
async def preview_message(request: Request) -> HTMLResponse:
|
|
||||||
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).
|
|
||||||
|
|
||||||
Результат кладётся в ``iframe sandbox``: разметка Telegram видна «как в чате»,
|
|
||||||
но скрипты из введённого текста не выполняются.
|
|
||||||
"""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
text = str(form.get("text", ""))
|
|
||||||
|
|
||||||
unknown = validate(text)
|
|
||||||
if unknown:
|
|
||||||
return HTMLResponse(
|
|
||||||
'<p class="text-sm text-red-600">Неизвестные теги: ' + html.escape(", ".join(unknown)) + "</p>"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
rendered = render(text, SAMPLE_CONTEXT)
|
|
||||||
except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
|
|
||||||
return HTMLResponse(f'<p class="text-sm text-red-600">{html.escape(str(exc))}</p>')
|
|
||||||
|
|
||||||
if len(rendered) > MAX_MESSAGE_LENGTH:
|
|
||||||
return HTMLResponse(
|
|
||||||
f'<p class="text-sm text-red-600">Текст длиннее лимита Telegram: '
|
|
||||||
f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.</p>"
|
|
||||||
)
|
|
||||||
|
|
||||||
return HTMLResponse(
|
|
||||||
'<iframe sandbox class="h-64 w-full rounded border border-slate-200 bg-white" '
|
|
||||||
f'srcdoc="{html.escape(rendered, quote=True)}"></iframe>'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{key}")
|
|
||||||
async def save_message(request: Request, key: str) -> Response:
|
|
||||||
"""Сохранить текст шаблона; при ошибках вернуть редактор с подсветкой."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
text = str(form.get("text", ""))
|
|
||||||
|
|
||||||
template = await templates_repo.get_template(key)
|
|
||||||
if template is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
unknown = validate(text)
|
|
||||||
if unknown:
|
|
||||||
errors.append("Неизвестные теги: " + ", ".join(unknown))
|
|
||||||
if not text.strip():
|
|
||||||
errors.append("Текст не может быть пустым")
|
|
||||||
if len(text) > MAX_MESSAGE_LENGTH:
|
|
||||||
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
history = await templates_repo.template_history(key)
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="messages/edit.html",
|
|
||||||
context=_editor_context(request, template, text=text, errors=errors, history=history),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
editor_id = int(request.state.admin["telegram_id"])
|
|
||||||
await templates_repo.update_template(key, text, editor_id=editor_id)
|
|
||||||
return RedirectResponse(url=f"/messages/{key}?saved=1", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key}", response_class=HTMLResponse)
|
|
||||||
async def edit_message(request: Request, key: str, saved: bool = False) -> HTMLResponse:
|
|
||||||
"""Редактор шаблона: текст, подсказка по тегам, предпросмотр и история."""
|
|
||||||
template = await templates_repo.get_template(key)
|
|
||||||
if template is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
|
||||||
|
|
||||||
history = await templates_repo.template_history(key)
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="messages/edit.html",
|
|
||||||
context=_editor_context(request, template, saved=saved, history=history),
|
|
||||||
)
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
"""HTML-страницы админки (серверный рендер Jinja2 + HTMX)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
from common import __version__
|
|
||||||
|
|
||||||
router = APIRouter(tags=["pages"])
|
|
||||||
|
|
||||||
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
|
|
||||||
SECTIONS: tuple[dict[str, str | None], ...] = (
|
|
||||||
{
|
|
||||||
"title": "Сообщения",
|
|
||||||
"description": "Тексты бота и предпросмотр с тегами {tag}",
|
|
||||||
"url": "/messages",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Расписания",
|
|
||||||
"description": "Время рассылок, дни недели и шаблон",
|
|
||||||
"url": "/schedules",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Пользователи",
|
|
||||||
"description": "Ники, звания, баланс и подписка",
|
|
||||||
"url": "/users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Логи",
|
|
||||||
"description": "События из ClickHouse с фильтрами",
|
|
||||||
"url": "/logs",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
|
||||||
async def index(request: Request) -> HTMLResponse:
|
|
||||||
"""Главная страница: версия, окружение и список будущих разделов."""
|
|
||||||
templates = request.app.state.templates
|
|
||||||
settings = request.app.state.settings
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="index.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"app_env": settings.app_env,
|
|
||||||
"sections": SECTIONS,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
"""Раздел «Расписания»: CRUD рассылок, которые исполняет бот."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
|
|
||||||
from admin.forms import format_weekdays, parse_schedule_fields
|
|
||||||
from admin.security import check_csrf
|
|
||||||
from common import __version__
|
|
||||||
from common.repositories import schedules_repo, templates_repo
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/schedules", tags=["schedules"])
|
|
||||||
|
|
||||||
|
|
||||||
def _editor_id(request: Request) -> int:
|
|
||||||
"""Telegram-ID текущего администратора (ставит middleware)."""
|
|
||||||
return int(request.state.admin["telegram_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def _raw_from_form(form: Any) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"template_key": str(form.get("template_key", "")),
|
|
||||||
"hour": str(form.get("hour", "")),
|
|
||||||
"minute": str(form.get("minute", "")),
|
|
||||||
"weekdays": [str(day) for day in form.getlist("weekdays")],
|
|
||||||
"timezone": str(form.get("timezone", "")),
|
|
||||||
"description": str(form.get("description", "")),
|
|
||||||
"enabled": form.get("enabled") is not None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _view(values: Mapping[str, Any], *, is_new: bool) -> dict[str, Any]:
|
|
||||||
"""Привести значения (из БД или формы) к виду, удобному шаблону."""
|
|
||||||
days: list[int] = []
|
|
||||||
for day in values.get("weekdays") or ():
|
|
||||||
try:
|
|
||||||
days.append(int(day))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return {
|
|
||||||
"template_key": values.get("template_key") or "",
|
|
||||||
"hour": values.get("hour", 16),
|
|
||||||
"minute": values.get("minute", 0),
|
|
||||||
"weekdays": sorted(set(days)),
|
|
||||||
"timezone": values.get("timezone") or "Europe/Moscow",
|
|
||||||
"description": values.get("description") or "",
|
|
||||||
"enabled": bool(values.get("enabled", True)),
|
|
||||||
"id": values.get("id"),
|
|
||||||
"is_new": is_new,
|
|
||||||
"action": "/schedules" if is_new else f"/schedules/{values.get('id')}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _form_response(
|
|
||||||
request: Request,
|
|
||||||
schedule: dict[str, Any],
|
|
||||||
message_templates: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
errors: list[str] | None = None,
|
|
||||||
history: list[dict[str, Any]] | None = None,
|
|
||||||
status_code: int = 200,
|
|
||||||
) -> Response:
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="schedules/form.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"schedule": schedule,
|
|
||||||
"message_templates": message_templates,
|
|
||||||
"errors": errors or [],
|
|
||||||
"history": history or [],
|
|
||||||
"weekday_names": {1: "Пн", 2: "Вт", 3: "Ср", 4: "Чт", 5: "Пт", 6: "Сб", 7: "Вс"},
|
|
||||||
},
|
|
||||||
status_code=status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
|
||||||
async def list_schedules(request: Request) -> HTMLResponse:
|
|
||||||
"""Список расписаний: время, дни, шаблон, таймзона и статус."""
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="schedules/list.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"schedules": await schedules_repo.list_schedules(),
|
|
||||||
"format_weekdays": format_weekdays,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/new", response_class=HTMLResponse)
|
|
||||||
async def new_schedule(request: Request) -> HTMLResponse:
|
|
||||||
"""Форма создания расписания."""
|
|
||||||
message_templates = await templates_repo.list_templates()
|
|
||||||
defaults = {
|
|
||||||
"template_key": message_templates[0]["key"] if message_templates else "",
|
|
||||||
"hour": 16,
|
|
||||||
"minute": 0,
|
|
||||||
"weekdays": [],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"description": "",
|
|
||||||
"enabled": True,
|
|
||||||
}
|
|
||||||
return _form_response(request, _view(defaults, is_new=True), message_templates)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def create_schedule(request: Request) -> Response:
|
|
||||||
"""Создать расписание (с проверкой полей и существования шаблона)."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
raw = _raw_from_form(form)
|
|
||||||
data, errors = parse_schedule_fields(**raw)
|
|
||||||
message_templates = await templates_repo.list_templates()
|
|
||||||
|
|
||||||
if data is not None and await templates_repo.get_template(data["template_key"]) is None:
|
|
||||||
errors.append("Шаблон не найден")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
return _form_response(
|
|
||||||
request,
|
|
||||||
_view(raw, is_new=True),
|
|
||||||
message_templates,
|
|
||||||
errors=errors,
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
await schedules_repo.create_schedule(data, editor_id=_editor_id(request))
|
|
||||||
return RedirectResponse(url="/schedules", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{schedule_id}", response_class=HTMLResponse)
|
|
||||||
async def edit_schedule(request: Request, schedule_id: int) -> HTMLResponse:
|
|
||||||
"""Форма редактирования и история изменений."""
|
|
||||||
schedule = await schedules_repo.get_schedule(schedule_id)
|
|
||||||
if schedule is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
|
||||||
|
|
||||||
return _form_response(
|
|
||||||
request,
|
|
||||||
_view(schedule, is_new=False),
|
|
||||||
await templates_repo.list_templates(),
|
|
||||||
history=await schedules_repo.schedule_history(schedule_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{schedule_id}")
|
|
||||||
async def update_schedule(request: Request, schedule_id: int) -> Response:
|
|
||||||
"""Сохранить изменения расписания."""
|
|
||||||
if await schedules_repo.get_schedule(schedule_id) is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
|
||||||
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
raw = _raw_from_form(form)
|
|
||||||
data, errors = parse_schedule_fields(**raw)
|
|
||||||
message_templates = await templates_repo.list_templates()
|
|
||||||
|
|
||||||
if data is not None and await templates_repo.get_template(data["template_key"]) is None:
|
|
||||||
errors.append("Шаблон не найден")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
view = _view({**raw, "id": schedule_id}, is_new=False)
|
|
||||||
return _form_response(
|
|
||||||
request,
|
|
||||||
view,
|
|
||||||
message_templates,
|
|
||||||
errors=errors,
|
|
||||||
history=await schedules_repo.schedule_history(schedule_id),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
await schedules_repo.update_schedule(schedule_id, data, editor_id=_editor_id(request))
|
|
||||||
return RedirectResponse(url="/schedules", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{schedule_id}/toggle")
|
|
||||||
async def toggle_schedule(request: Request, schedule_id: int) -> Response:
|
|
||||||
"""Включить или выключить расписание."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
|
|
||||||
schedule = await schedules_repo.get_schedule(schedule_id)
|
|
||||||
if schedule is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
|
||||||
|
|
||||||
await schedules_repo.set_enabled(schedule_id, not schedule["enabled"], editor_id=_editor_id(request))
|
|
||||||
return RedirectResponse(url="/schedules", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{schedule_id}/delete")
|
|
||||||
async def delete_schedule(request: Request, schedule_id: int) -> Response:
|
|
||||||
"""Удалить расписание (история при этом сохраняется)."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
|
|
||||||
if not await schedules_repo.delete_schedule(schedule_id, editor_id=_editor_id(request)):
|
|
||||||
raise HTTPException(status_code=404, detail="Расписание не найдено")
|
|
||||||
return RedirectResponse(url="/schedules", status_code=303)
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
"""Раздел «Пользователи»: список с поиском, карточка и управление."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
|
|
||||||
from admin.security import check_csrf
|
|
||||||
from common import __version__
|
|
||||||
from common.eggs import is_egg_event_active
|
|
||||||
from common.repositories import users_admin_repo
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
|
||||||
|
|
||||||
#: Сколько пользователей показывать на странице.
|
|
||||||
PAGE_SIZE = 25
|
|
||||||
|
|
||||||
|
|
||||||
def _editor_id(request: Request) -> int:
|
|
||||||
"""Telegram-ID текущего администратора (ставит middleware)."""
|
|
||||||
return int(request.state.admin["telegram_id"])
|
|
||||||
|
|
||||||
|
|
||||||
async def _card_response(
|
|
||||||
request: Request,
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
errors: list[str] | None = None,
|
|
||||||
saved: str | None = None,
|
|
||||||
status_code: int = 200,
|
|
||||||
) -> HTMLResponse:
|
|
||||||
"""Отрисовать карточку (общая точка для GET и ошибок валидации)."""
|
|
||||||
user = await users_admin_repo.get_user(user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="users/card.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"user": user,
|
|
||||||
"money": await users_admin_repo.money_history(user_id),
|
|
||||||
"titles": await users_admin_repo.list_titles(),
|
|
||||||
"history": await users_admin_repo.user_history(user_id),
|
|
||||||
"errors": errors or [],
|
|
||||||
"saved": saved,
|
|
||||||
"eggs_visible": is_egg_event_active(request.app.state.settings),
|
|
||||||
"max_nick_length": users_admin_repo.MAX_NICK_LENGTH,
|
|
||||||
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
|
|
||||||
},
|
|
||||||
status_code=status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
|
||||||
async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse:
|
|
||||||
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
|
|
||||||
total = await users_admin_repo.count_users(q)
|
|
||||||
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
|
|
||||||
page = min(max(page, 1), pages)
|
|
||||||
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="users/list.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"users": await users_admin_repo.list_users(
|
|
||||||
q,
|
|
||||||
limit=PAGE_SIZE,
|
|
||||||
offset=(page - 1) * PAGE_SIZE,
|
|
||||||
),
|
|
||||||
"q": q,
|
|
||||||
"page": page,
|
|
||||||
"pages": pages,
|
|
||||||
"total": total,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_class=HTMLResponse)
|
|
||||||
async def user_card(request: Request, user_id: int, saved: str | None = None) -> HTMLResponse:
|
|
||||||
"""Карточка пользователя: сводка, управление, история и операции."""
|
|
||||||
return await _card_response(request, user_id, saved=saved)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/nick")
|
|
||||||
async def update_nick(request: Request, user_id: int) -> Response:
|
|
||||||
"""Сменить ник игрока."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
nick = str(form.get("nick", "")).strip()
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
if not nick:
|
|
||||||
errors.append("Ник не может быть пустым")
|
|
||||||
elif len(nick) > users_admin_repo.MAX_NICK_LENGTH:
|
|
||||||
errors.append(f"Ник длиннее {users_admin_repo.MAX_NICK_LENGTH} символов")
|
|
||||||
elif any(char.isspace() for char in nick):
|
|
||||||
errors.append("Ник не должен содержать пробелы")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
return await _card_response(request, user_id, errors=errors, status_code=400)
|
|
||||||
|
|
||||||
if not await users_admin_repo.set_nick(user_id, nick, editor_id=_editor_id(request)):
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
return RedirectResponse(url=f"/users/{user_id}?saved=nick", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/title")
|
|
||||||
async def update_title(request: Request, user_id: int) -> Response:
|
|
||||||
"""Сменить звание игрока."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
|
|
||||||
try:
|
|
||||||
title_id = int(str(form.get("title_id", "")))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return await _card_response(request, user_id, errors=["Выберите звание"], status_code=400)
|
|
||||||
|
|
||||||
titles = await users_admin_repo.list_titles()
|
|
||||||
if not any(int(title["id_title"]) == title_id for title in titles):
|
|
||||||
return await _card_response(request, user_id, errors=["Звание не найдено"], status_code=400)
|
|
||||||
|
|
||||||
if not await users_admin_repo.set_title(user_id, title_id, editor_id=_editor_id(request)):
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
return RedirectResponse(url=f"/users/{user_id}?saved=title", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/subscription")
|
|
||||||
async def update_subscription(request: Request, user_id: int) -> Response:
|
|
||||||
"""Подписать или отписать от рассылок."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
|
|
||||||
value = str(form.get("subscribed", ""))
|
|
||||||
if value not in {"0", "1"}:
|
|
||||||
return await _card_response(request, user_id, errors=["Некорректное значение подписки"], status_code=400)
|
|
||||||
|
|
||||||
if not await users_admin_repo.set_subscription(
|
|
||||||
user_id,
|
|
||||||
value == "1",
|
|
||||||
editor_id=_editor_id(request),
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
return RedirectResponse(url=f"/users/{user_id}?saved=subscription", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/balance")
|
|
||||||
async def update_balance(request: Request, user_id: int) -> Response:
|
|
||||||
"""Скорректировать баланс записью в журнал money (действие «Корректировка админом»)."""
|
|
||||||
form = await request.form()
|
|
||||||
check_csrf(request, str(form.get("csrf_token", "")))
|
|
||||||
|
|
||||||
raw_amount = str(form.get("amount", "")).strip()
|
|
||||||
reason = str(form.get("reason", "")).strip()
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
try:
|
|
||||||
amount = int(raw_amount)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
amount = 0
|
|
||||||
errors.append("Сумма должна быть целым числом")
|
|
||||||
|
|
||||||
if amount == 0 and "Сумма должна быть целым числом" not in errors:
|
|
||||||
errors.append("Сумма не может быть нулевой")
|
|
||||||
if not reason:
|
|
||||||
errors.append("Укажите причину корректировки")
|
|
||||||
elif len(reason) > users_admin_repo.MAX_COMMENT_LENGTH:
|
|
||||||
errors.append(f"Причина длиннее {users_admin_repo.MAX_COMMENT_LENGTH} символов")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
return await _card_response(request, user_id, errors=errors, status_code=400)
|
|
||||||
|
|
||||||
balance = await users_admin_repo.adjust_balance(
|
|
||||||
user_id,
|
|
||||||
amount,
|
|
||||||
reason,
|
|
||||||
editor_id=_editor_id(request),
|
|
||||||
)
|
|
||||||
if balance is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303)
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
"""Безопасность админки: проверка Telegram-логина, сессия и CSRF."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from collections.abc import Mapping
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
|
|
||||||
#: Сколько живёт подпись Telegram-логина (секунды).
|
|
||||||
TELEGRAM_AUTH_MAX_AGE = 24 * 60 * 60
|
|
||||||
|
|
||||||
#: Допустимое расхождение часов с Telegram (секунды).
|
|
||||||
FUTURE_SKEW = 300
|
|
||||||
|
|
||||||
|
|
||||||
def telegram_check_string(data: Mapping[str, object]) -> str:
|
|
||||||
"""Строка для проверки подписи: отсортированные ``key=value`` без ``hash``."""
|
|
||||||
pairs = sorted((key, str(value)) for key, value in data.items() if key != "hash")
|
|
||||||
return "\n".join(f"{key}={value}" for key, value in pairs)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_telegram_auth(
|
|
||||||
data: Mapping[str, object],
|
|
||||||
bot_token: str,
|
|
||||||
*,
|
|
||||||
max_age: int = TELEGRAM_AUTH_MAX_AGE,
|
|
||||||
now: float | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Проверить подпись Telegram Login Widget и свежесть ``auth_date``.
|
|
||||||
|
|
||||||
Секрет — ``SHA256(bot_token)``, подпись — HMAC-SHA256 от check-string.
|
|
||||||
"""
|
|
||||||
received = str(data.get("hash") or "")
|
|
||||||
if not received or not bot_token:
|
|
||||||
return False
|
|
||||||
|
|
||||||
expected = hmac.new(
|
|
||||||
hashlib.sha256(bot_token.encode()).digest(),
|
|
||||||
telegram_check_string(data).encode(),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
if not hmac.compare_digest(expected, received):
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
auth_date = int(str(data.get("auth_date") or ""))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
moment = time.time() if now is None else now
|
|
||||||
return -FUTURE_SKEW <= moment - auth_date <= max_age
|
|
||||||
|
|
||||||
|
|
||||||
def check_csrf(request: Request, token: str) -> None:
|
|
||||||
"""Проверить CSRF-токен формы; при несовпадении — 400."""
|
|
||||||
expected = request.session.get("csrf")
|
|
||||||
if not expected or not token or not secrets.compare_digest(str(expected), str(token)):
|
|
||||||
raise HTTPException(status_code=400, detail="CSRF-токен неверен или устарел")
|
|
||||||
|
|
||||||
|
|
||||||
def safe_redirect_target(value: str | None) -> str:
|
|
||||||
"""Разрешить редирект только на внутренний путь (защита от open redirect)."""
|
|
||||||
if value and value.startswith("/") and not value.startswith("//"):
|
|
||||||
return value
|
|
||||||
return "/"
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Доступ закрыт{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mx-auto mt-10 max-w-md rounded-lg border border-red-200 bg-white p-8 text-center">
|
|
||||||
<h1 class="text-2xl font-semibold text-red-700">Доступ закрыт</h1>
|
|
||||||
<p class="mt-3 text-sm text-slate-700">{{ message }}</p>
|
|
||||||
<p class="mt-6">
|
|
||||||
<a href="/login" class="text-sky-700 hover:underline">Вернуться к входу</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Вход{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mx-auto mt-10 max-w-md rounded-lg border border-slate-200 bg-white p-8 text-center">
|
|
||||||
<h1 class="text-2xl font-semibold">Вход в админку</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p>
|
|
||||||
|
|
||||||
{% if configured %}
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<script
|
|
||||||
async
|
|
||||||
src="https://telegram.org/js/telegram-widget.js?22"
|
|
||||||
data-telegram-login="{{ bot_username }}"
|
|
||||||
data-size="large"
|
|
||||||
data-auth-url="{{ public_url }}/auth/telegram?next={{ next | urlencode }}"
|
|
||||||
data-request-access="write"
|
|
||||||
></script>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
||||||
Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>,
|
|
||||||
<span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>{% block title %}Админка{% endblock %} — DispelEvilBot</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-slate-100 text-slate-900">
|
|
||||||
<header class="border-b border-slate-200 bg-white">
|
|
||||||
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
|
||||||
<a href="/" class="text-lg font-semibold">DispelEvilBot</a>
|
|
||||||
<nav class="flex items-center gap-4 text-sm">
|
|
||||||
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a>
|
|
||||||
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
|
|
||||||
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
|
|
||||||
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
|
|
||||||
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a>
|
|
||||||
{% if request.session.get('admin_id') %}
|
|
||||||
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
|
||||||
<form method="post" action="/logout">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<button type="submit" class="text-slate-600 hover:text-slate-900">Выйти</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
<span class="text-slate-500">админка · v{{ version }}</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="mx-auto max-w-5xl px-4 py-8">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Главная{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1 class="text-2xl font-semibold">Панель администрирования</h1>
|
|
||||||
<p class="mt-2 text-slate-600">
|
|
||||||
Окружение: <span class="font-mono">{{ app_env }}</span>. Это каркас админки: разделы
|
|
||||||
появятся по мере готовности (Блок 4.1–4.3).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
|
||||||
{% for section in sections %}
|
|
||||||
<section class="rounded-lg border border-slate-200 bg-white p-5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-medium">
|
|
||||||
{% if section.url %}<a href="{{ section.url }}" class="hover:underline">{{ section.title }}</a>{% else %}{{ section.title }}{% endif %}
|
|
||||||
</h2>
|
|
||||||
{% if not section.url %}<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">скоро</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ section.description }}</p>
|
|
||||||
</section>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Логи{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-semibold">Логи</h1>
|
|
||||||
<p class="mt-1 text-sm text-slate-600">События из ClickHouse · найдено: {{ total }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="get" action="/logs" class="mt-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div>
|
|
||||||
<label for="event_type" class="text-xs text-slate-500">Тип</label>
|
|
||||||
<select
|
|
||||||
id="event_type"
|
|
||||||
name="event_type"
|
|
||||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Все типы</option>
|
|
||||||
{% for type in event_types %}
|
|
||||||
<option value="{{ type }}" {% if type == event_type %}selected{% endif %}>{{ type }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="user" class="text-xs text-slate-500">Пользователь</label>
|
|
||||||
<input
|
|
||||||
id="user"
|
|
||||||
name="user"
|
|
||||||
type="text"
|
|
||||||
value="{{ user }}"
|
|
||||||
placeholder="ID или @username"
|
|
||||||
class="mt-1 block w-48 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="date_from" class="text-xs text-slate-500">С</label>
|
|
||||||
<input
|
|
||||||
id="date_from"
|
|
||||||
name="date_from"
|
|
||||||
type="date"
|
|
||||||
value="{{ date_from }}"
|
|
||||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="date_to" class="text-xs text-slate-500">По</label>
|
|
||||||
<input
|
|
||||||
id="date_to"
|
|
||||||
name="date_to"
|
|
||||||
type="date"
|
|
||||||
value="{{ date_to }}"
|
|
||||||
class="mt-1 block rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Показать
|
|
||||||
</button>
|
|
||||||
<a href="/logs" class="text-sm text-slate-600 hover:underline">Сбросить</a>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|
||||||
<thead class="bg-slate-50 text-left text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Время</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Событие</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Пользователь</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Чат</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Версия</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Payload</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-100">
|
|
||||||
{% for event in events %}
|
|
||||||
<tr class="align-top hover:bg-slate-50">
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ event.time_local }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span class="rounded bg-slate-100 px-2 py-0.5 font-mono text-xs">{{ event.event_type }}</span>
|
|
||||||
{% if event.prod %}<span class="ml-1 rounded bg-indigo-100 px-2 py-0.5 text-xs text-indigo-800">prod</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
{% if event.user_id %}
|
|
||||||
{% if event.username %}<div>@{{ event.username }}</div>{% endif %}
|
|
||||||
<a href="/users/{{ event.user_id }}" class="font-mono text-xs text-sky-700 hover:underline">{{ event.user_id }}</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-slate-400">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ event.chat_id or "—" }}</td>
|
|
||||||
<td class="px-4 py-3 text-xs text-slate-500">{{ event.bot_version }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
{% if event.payload_pretty %}
|
|
||||||
<details>
|
|
||||||
<summary class="cursor-pointer text-xs text-slate-500">JSON</summary>
|
|
||||||
<pre class="mt-1 max-h-64 max-w-xl overflow-auto rounded bg-slate-50 p-2 text-xs">{{ event.payload_pretty }}</pre>
|
|
||||||
</details>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-slate-400">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
|
||||||
{% if error %}Данные не загрузились.{% else %}За выбранный период событий нет.{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if pages > 1 %}
|
|
||||||
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
|
|
||||||
<div>Страница {{ page }} из {{ pages }}</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
{% if page > 1 %}
|
|
||||||
<a href="/logs?{{ query_base }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if page < pages %}
|
|
||||||
<a href="/logs?{{ query_base }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ template.key }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
|
|
||||||
<h1 class="mt-1 font-mono text-xl font-semibold">{{ template.key }}</h1>
|
|
||||||
<p class="text-sm text-slate-600">{{ template.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if saved %}
|
|
||||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
|
||||||
Текст сохранён.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if errors %}
|
|
||||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
|
||||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/messages/{{ template.key }}" class="mt-6 space-y-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<div>
|
|
||||||
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
|
||||||
<textarea
|
|
||||||
id="text"
|
|
||||||
name="text"
|
|
||||||
rows="18"
|
|
||||||
class="mt-1 w-full rounded-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none"
|
|
||||||
>{{ text }}</textarea>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
|
||||||
hx-post="/messages/preview"
|
|
||||||
hx-include="closest form"
|
|
||||||
hx-target="#preview"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
>
|
|
||||||
Предпросмотр
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-lg font-medium">Предпросмотр</h2>
|
|
||||||
<p class="text-sm text-slate-600">Тестовые значения: @test_user, Ветеран, 3 уровень, 1500 DE.</p>
|
|
||||||
<div id="preview" class="mt-2">
|
|
||||||
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с подставленными тегами.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="mt-8 rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
|
|
||||||
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
|
|
||||||
{% for name, description in tags.items() %}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
|
|
||||||
<dd class="text-slate-600">{{ description }}</dd>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</dl>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-lg font-medium">История изменений</h2>
|
|
||||||
{% if history %}
|
|
||||||
<ul class="mt-3 space-y-2">
|
|
||||||
{% for item in history %}
|
|
||||||
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
|
||||||
<div class="flex items-center justify-between text-slate-500">
|
|
||||||
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
|
||||||
<span>редактор: {{ item.editor_id or "—" }}</span>
|
|
||||||
</div>
|
|
||||||
<details class="mt-2">
|
|
||||||
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
|
|
||||||
<div class="mt-2 grid gap-3 sm:grid-cols-2">
|
|
||||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.old_text or "" }}</pre>
|
|
||||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.new_text }}</pre>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Сообщения{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-2xl font-semibold">Сообщения</h1>
|
|
||||||
<span class="text-sm text-slate-500">{{ templates | length }} шаблонов</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-2 text-slate-600">
|
|
||||||
Тексты хранятся в Postgres. В редакторе можно проверить подстановку тегов
|
|
||||||
<span class="font-mono">{tag}</span> на тестовых данных.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
|
||||||
Изменения применяются ботом без перезапуска — в течение <span class="font-mono">TEMPLATE_CACHE_TTL</span>
|
|
||||||
секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|
||||||
<thead class="bg-slate-50 text-left text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Ключ</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Описание</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Длина</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Изменён</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-100">
|
|
||||||
{% for template in templates %}
|
|
||||||
<tr class="hover:bg-slate-50">
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<a href="/messages/{{ template.key }}" class="font-mono text-sky-700 hover:underline">{{ template.key }}</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-slate-700">{{ template.description }}</td>
|
|
||||||
<td class="px-4 py-3 text-slate-500">{{ template.text_length }}</td>
|
|
||||||
<td class="px-4 py-3 text-slate-500">{{ template.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="text-sm text-slate-500"><a href="/schedules" class="hover:underline">← Расписания</a></p>
|
|
||||||
<h1 class="mt-1 text-2xl font-semibold">
|
|
||||||
{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{% if errors %}
|
|
||||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
|
||||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{{ schedule.action }}" class="mt-6 max-w-3xl space-y-5">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<div>
|
|
||||||
<label for="template_key" class="text-sm font-medium text-slate-700">Шаблон сообщения</label>
|
|
||||||
<select
|
|
||||||
id="template_key"
|
|
||||||
name="template_key"
|
|
||||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
{% for template in message_templates %}
|
|
||||||
<option value="{{ template.key }}" {% if template.key == schedule.template_key %}selected{% endif %}>
|
|
||||||
{{ template.key }} — {{ template.description }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label for="hour" class="text-sm font-medium text-slate-700">Час (0–23)</label>
|
|
||||||
<input
|
|
||||||
id="hour"
|
|
||||||
name="hour"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="23"
|
|
||||||
value="{{ schedule.hour }}"
|
|
||||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="minute" class="text-sm font-medium text-slate-700">Минуты (0–59)</label>
|
|
||||||
<input
|
|
||||||
id="minute"
|
|
||||||
name="minute"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
value="{{ schedule.minute }}"
|
|
||||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend class="text-sm font-medium text-slate-700">Дни недели</legend>
|
|
||||||
<p class="text-xs text-slate-500">Ничего не выбрано — каждый день.</p>
|
|
||||||
<div class="mt-2 flex flex-wrap gap-4">
|
|
||||||
{% for day, name in weekday_names.items() %}
|
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input type="checkbox" name="weekdays" value="{{ day }}" {% if day in schedule.weekdays %}checked{% endif %} />
|
|
||||||
{{ name }}
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="timezone" class="text-sm font-medium text-slate-700">Таймзона</label>
|
|
||||||
<input
|
|
||||||
id="timezone"
|
|
||||||
name="timezone"
|
|
||||||
type="text"
|
|
||||||
value="{{ schedule.timezone }}"
|
|
||||||
list="timezones"
|
|
||||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<datalist id="timezones">
|
|
||||||
<option value="Europe/Moscow"></option>
|
|
||||||
<option value="Europe/Berlin"></option>
|
|
||||||
<option value="UTC"></option>
|
|
||||||
</datalist>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">Время указывается в этой таймзоне (IANA).</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="description" class="text-sm font-medium text-slate-700">Описание (для себя)</label>
|
|
||||||
<input
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
type="text"
|
|
||||||
value="{{ schedule.description }}"
|
|
||||||
maxlength="200"
|
|
||||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input type="checkbox" name="enabled" {% if schedule.enabled %}checked{% endif %} />
|
|
||||||
Включено
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<a href="/schedules" class="text-sm text-slate-600 hover:underline">Отмена</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if not schedule.is_new %}
|
|
||||||
<div class="mt-10">
|
|
||||||
<h2 class="text-lg font-medium">История изменений</h2>
|
|
||||||
{% if history %}
|
|
||||||
<ul class="mt-3 space-y-2">
|
|
||||||
{% for item in history %}
|
|
||||||
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
|
||||||
<div class="flex items-center justify-between text-slate-500">
|
|
||||||
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
|
||||||
<span>редактор: {{ item.editor_id or "—" }}</span>
|
|
||||||
</div>
|
|
||||||
<details class="mt-2">
|
|
||||||
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
|
|
||||||
<div class="mt-2 grid gap-3 sm:grid-cols-2">
|
|
||||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.old_data | tojson(indent=2) if item.old_data else "—" }}</pre>
|
|
||||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.new_data | tojson(indent=2) if item.new_data else "—" }}</pre>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Расписания{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-2xl font-semibold">Расписания</h1>
|
|
||||||
<a href="/schedules/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Новое расписание
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-2 text-slate-600">
|
|
||||||
Рассылки исполняет бот: он читает активные расписания из БД раз в минуту,
|
|
||||||
поэтому правки применяются без перезапуска.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|
||||||
<thead class="bg-slate-50 text-left text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Время</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Дни</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Шаблон</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Таймзона</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Статус</th>
|
|
||||||
<th class="px-4 py-3 font-medium"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-100">
|
|
||||||
{% for schedule in schedules %}
|
|
||||||
<tr class="hover:bg-slate-50">
|
|
||||||
<td class="px-4 py-3 font-mono">{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}</td>
|
|
||||||
<td class="px-4 py-3">{{ format_weekdays(schedule.weekdays) }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="font-mono text-sky-700">{{ schedule.template_key }}</div>
|
|
||||||
<div class="text-xs text-slate-500">{{ schedule.description or schedule.template_description }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-slate-600">{{ schedule.timezone }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
{% if schedule.enabled %}
|
|
||||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<a href="/schedules/{{ schedule.id }}" class="text-sky-700 hover:underline">Изменить</a>
|
|
||||||
<form method="post" action="/schedules/{{ schedule.id }}/toggle">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<button type="submit" class="text-slate-600 hover:underline">
|
|
||||||
{% if schedule.enabled %}Выключить{% else %}Включить{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/schedules/{{ schedule.id }}/delete" onsubmit="return confirm('Удалить расписание?');">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">Расписаний пока нет.</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ user.nick or user.id_user }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="text-sm text-slate-500"><a href="/users" class="hover:underline">← Пользователи</a></p>
|
|
||||||
|
|
||||||
<div class="mt-2 flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-semibold">{{ user.nick or "Без ника" }}</h1>
|
|
||||||
<p class="mt-1 text-slate-600">
|
|
||||||
{% if user.username %}<span class="font-mono">@{{ user.username }}</span> · {% endif %}
|
|
||||||
<span class="font-mono">{{ user.id_user }}</span>
|
|
||||||
{% if user.first_name or user.last_name %}
|
|
||||||
· {{ user.first_name or "" }} {{ user.last_name or "" }}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{% if user.title %}
|
|
||||||
<span class="rounded bg-indigo-100 px-3 py-1 text-sm text-indigo-800">{{ user.title }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if user.subscribed %}
|
|
||||||
<span class="rounded bg-emerald-100 px-3 py-1 text-sm text-emerald-800">подписан на рассылки</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="rounded bg-slate-100 px-3 py-1 text-sm text-slate-500">без рассылок</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if saved %}
|
|
||||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
|
||||||
{% if saved == 'nick' %}Ник обновлён.
|
|
||||||
{% elif saved == 'title' %}Звание обновлено.
|
|
||||||
{% elif saved == 'subscription' %}Подписка обновлена.
|
|
||||||
{% elif saved == 'balance' %}Баланс скорректирован.
|
|
||||||
{% else %}Изменения сохранены.{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if errors %}
|
|
||||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
|
||||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Уровень</div>
|
|
||||||
<div class="mt-1 text-xl font-semibold">{{ user.lvl }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Опыт</div>
|
|
||||||
<div class="mt-1 text-xl font-semibold">{{ user.expr }}</div>
|
|
||||||
<div class="text-xs text-slate-500">серия: {{ user.expr_streak }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Баланс DE</div>
|
|
||||||
<div class="mt-1 text-xl font-semibold">{{ user.balance }}</div>
|
|
||||||
</div>
|
|
||||||
{% if eggs_visible %}
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Яички</div>
|
|
||||||
<div class="mt-1 text-xl font-semibold">{{ user.eggs }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Сообщения</div>
|
|
||||||
<div class="mt-1 text-xl font-semibold">{{ user.msg_count }}</div>
|
|
||||||
<div class="text-xs text-slate-500">
|
|
||||||
последнее: {{ user.last_message_at.strftime("%d.%m.%Y %H:%M") if user.last_message_at else "—" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Очищения</div>
|
|
||||||
<div class="mt-1 text-xl font-semibold">{{ user.purification_count }}</div>
|
|
||||||
<div class="text-xs text-slate-500">
|
|
||||||
последнее: {{ user.last_purification_at.strftime("%d.%m.%Y %H:%M") if user.last_purification_at else "—" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">В клане с</div>
|
|
||||||
<div class="mt-1 text-xl font-semibold">
|
|
||||||
{{ user.dt_create.strftime("%d.%m.%Y") if user.dt_create else "—" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Telegram-ID</div>
|
|
||||||
<div class="mt-1 font-mono text-sm">{{ user.id_user }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-lg font-medium">Управление</h2>
|
|
||||||
<div class="mt-3 grid gap-4 lg:grid-cols-2">
|
|
||||||
<form method="post" action="/users/{{ user.id_user }}/nick" class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<label for="nick" class="text-sm font-medium text-slate-700">Ник</label>
|
|
||||||
<div class="mt-2 flex gap-2">
|
|
||||||
<input
|
|
||||||
id="nick"
|
|
||||||
name="nick"
|
|
||||||
type="text"
|
|
||||||
value="{{ user.nick or '' }}"
|
|
||||||
maxlength="{{ max_nick_length }}"
|
|
||||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="post" action="/users/{{ user.id_user }}/title" class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<label for="title_id" class="text-sm font-medium text-slate-700">Звание</label>
|
|
||||||
<div class="mt-2 flex gap-2">
|
|
||||||
<select
|
|
||||||
id="title_id"
|
|
||||||
name="title_id"
|
|
||||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
{% for title in titles %}
|
|
||||||
<option value="{{ title.id_title }}" {% if title.id_title == user.id_title %}selected{% endif %}>
|
|
||||||
{{ title.title }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="post" action="/users/{{ user.id_user }}/subscription" class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<input type="hidden" name="subscribed" value="{{ '0' if user.subscribed else '1' }}" />
|
|
||||||
<div class="text-sm font-medium text-slate-700">Рассылки</div>
|
|
||||||
<p class="mt-1 text-sm text-slate-600">
|
|
||||||
{% if user.subscribed %}Сейчас подписан.{% else %}Сейчас не подписан.{% endif %}
|
|
||||||
</p>
|
|
||||||
<button type="submit" class="mt-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
|
||||||
{% if user.subscribed %}Отписать{% else %}Подписать{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="post" action="/users/{{ user.id_user }}/balance" class="rounded-lg border border-slate-200 bg-white p-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
|
||||||
<div class="text-sm font-medium text-slate-700">Корректировка баланса</div>
|
|
||||||
<div class="mt-2 flex gap-2">
|
|
||||||
<input
|
|
||||||
name="amount"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
placeholder="+50 или -20"
|
|
||||||
class="w-32 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
name="reason"
|
|
||||||
type="text"
|
|
||||||
maxlength="{{ max_comment_length }}"
|
|
||||||
placeholder="Причина"
|
|
||||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Провести
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">Запись уйдёт в журнал денег с действием «Корректировка админом».</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-lg font-medium">История изменений</h2>
|
|
||||||
{% if history %}
|
|
||||||
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|
||||||
<thead class="bg-slate-50 text-left text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Когда</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Что</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Было → стало</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Комментарий</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Админ</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-100">
|
|
||||||
{% set field_labels = {"nick": "Ник", "title": "Звание", "subscription": "Подписка", "balance": "Баланс"} %}
|
|
||||||
{% for item in history %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3 text-slate-600">{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
|
||||||
<td class="px-4 py-3">{{ field_labels.get(item.field, item.field) }}</td>
|
|
||||||
<td class="px-4 py-3 text-slate-700">{{ item.old_value or "—" }} → {{ item.new_value or "—" }}</td>
|
|
||||||
<td class="px-4 py-3 text-slate-600">{{ item.comment or "—" }}</td>
|
|
||||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Ручных изменений ещё не было.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-lg font-medium">Последние операции по балансу</h2>
|
|
||||||
{% if money %}
|
|
||||||
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|
||||||
<thead class="bg-slate-50 text-left text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Когда</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Сумма</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Действие</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-100">
|
|
||||||
{% for item in money %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3 text-slate-600">
|
|
||||||
{{ item.dt_action.strftime("%d.%m.%Y %H:%M") if item.dt_action else "—" }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 font-medium {% if item.amount < 0 %}text-red-600{% else %}text-emerald-700{% endif %}">
|
|
||||||
{{ "%+d" | format(item.amount) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-slate-700">{{ item.action or "—" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Операций по балансу не было.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Пользователи{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-semibold">Пользователи</h1>
|
|
||||||
<p class="mt-1 text-sm text-slate-600">Всего: {{ total }}</p>
|
|
||||||
</div>
|
|
||||||
<form method="get" action="/users" class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
name="q"
|
|
||||||
value="{{ q }}"
|
|
||||||
placeholder="Ник, @username, имя или Telegram-ID"
|
|
||||||
class="w-80 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
|
||||||
Найти
|
|
||||||
</button>
|
|
||||||
{% if q %}<a href="/users" class="text-sm text-slate-600 hover:underline">Сбросить</a>{% endif %}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|
||||||
<thead class="bg-slate-50 text-left text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Ник</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Telegram</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Уровень</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Баланс</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Сообщения</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Подписка</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-100">
|
|
||||||
{% for user in users %}
|
|
||||||
<tr class="hover:bg-slate-50">
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<a href="/users/{{ user.id_user }}" class="font-medium text-sky-700 hover:underline">
|
|
||||||
{{ user.nick or "—" }}
|
|
||||||
</a>
|
|
||||||
<div class="text-xs text-slate-500">{{ user.title or "" }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
{% if user.username %}<div>@{{ user.username }}</div>{% endif %}
|
|
||||||
<div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">{{ user.lvl }}</td>
|
|
||||||
<td class="px-4 py-3">{{ user.balance }}</td>
|
|
||||||
<td class="px-4 py-3">{{ user.msg_count }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
{% if user.subscribed %}
|
|
||||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">нет</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
|
||||||
{% if q %}Никого не нашли по запросу «{{ q }}».{% else %}Пользователей пока нет.{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if pages > 1 %}
|
|
||||||
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
|
|
||||||
<div>Страница {{ page }} из {{ pages }}</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
{% if page > 1 %}
|
|
||||||
<a href="/users?q={{ q | urlencode }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if page < pages %}
|
|
||||||
<a href="/users?q={{ q | urlencode }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -12,10 +12,8 @@ from aiogram.types import Message
|
||||||
from bot.services import access, stats, subscriptions
|
from bot.services import access, stats, subscriptions
|
||||||
from bot.services import users as users_service
|
from bot.services import users as users_service
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.eggs import is_egg_event_active
|
from common.texts import economy, info, system
|
||||||
from common.texts import system
|
from common.texts.render import format_tops, format_user_list, render
|
||||||
from common.texts.render import escape, format_tops, format_user_list, render
|
|
||||||
from common.texts.store import get_text
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router(name="commands")
|
router = Router(name="commands")
|
||||||
|
|
@ -63,7 +61,7 @@ async def list_nicks_handler(message: Message) -> None:
|
||||||
"users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
|
"users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
|
||||||
"users_list_count": len(nicks),
|
"users_list_count": len(nicks),
|
||||||
}
|
}
|
||||||
await message.answer(render(await get_text("economy.nick_list"), context))
|
await message.answer(render(economy.MSG_NICK_LIST, context))
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("кто", ignore_case=True))
|
@router.message(Command("кто", ignore_case=True))
|
||||||
|
|
@ -85,7 +83,7 @@ async def get_nick_handler(message: Message) -> None:
|
||||||
async def help_handler(message: Message) -> None:
|
async def help_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
await message.answer(await get_text("info.help"))
|
await message.answer(info.MSG_HELP)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("инфо", ignore_case=True))
|
@router.message(Command("инфо", ignore_case=True))
|
||||||
|
|
@ -109,11 +107,8 @@ async def info_handler(message: Message) -> None:
|
||||||
"dt": user_info.dt,
|
"dt": user_info.dt,
|
||||||
"msg_count": user_info.msg_count,
|
"msg_count": user_info.msg_count,
|
||||||
"eggs": user_info.eggs,
|
"eggs": user_info.eggs,
|
||||||
"eggs_line": (
|
|
||||||
f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
await message.answer(render(await get_text("info.profile"), context))
|
await message.answer(render(info.MSG_INFO, context))
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("рейтинг", ignore_case=True))
|
@router.message(Command("рейтинг", ignore_case=True))
|
||||||
|
|
@ -122,7 +117,7 @@ async def top_handler(message: Message) -> None:
|
||||||
return
|
return
|
||||||
tops = await stats.top_users()
|
tops = await stats.top_users()
|
||||||
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
|
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
|
||||||
await message.answer(render(await get_text("economy.top"), context))
|
await message.answer(render(economy.MSG_TOP, context))
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("отписка", ignore_case=True))
|
@router.message(Command("отписка", ignore_case=True))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
|
|
@ -12,13 +12,15 @@ from aiogram.types import Message
|
||||||
from bot.services import access, alerts
|
from bot.services import access, alerts
|
||||||
from bot.services import economy as economy_service
|
from bot.services import economy as economy_service
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.eggs import egg_event_period, is_egg_event_active
|
from common.texts import economy, system
|
||||||
from common.texts import system
|
|
||||||
from common.texts.render import render
|
from common.texts.render import render
|
||||||
from common.texts.store import get_text
|
|
||||||
|
|
||||||
router = Router(name="economy")
|
router = Router(name="economy")
|
||||||
|
|
||||||
|
#: Окно сезонного события «яички».
|
||||||
|
EGG_EVENT_START = date(2025, 4, 1)
|
||||||
|
EGG_EVENT_END = date(2025, 4, 20)
|
||||||
|
|
||||||
#: Шанс выпадения монетки на сообщение.
|
#: Шанс выпадения монетки на сообщение.
|
||||||
COIN_CHANCE = 27 # из 100
|
COIN_CHANCE = 27 # из 100
|
||||||
|
|
||||||
|
|
@ -31,10 +33,10 @@ async def purification_handler(message: Message) -> None:
|
||||||
result = await economy_service.purify(message.from_user.id)
|
result = await economy_service.purify(message.from_user.id)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
||||||
await message.answer(render(await get_text("economy.no_purification"), context))
|
await message.answer(render(economy.MSG_NO_PURIFICATION, context))
|
||||||
else:
|
else:
|
||||||
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
||||||
await message.answer(render(await get_text("economy.purification"), context))
|
await message.answer(render(economy.MSG_PURIFICATION, context))
|
||||||
await alerts.notify_purification(
|
await alerts.notify_purification(
|
||||||
message.bot,
|
message.bot,
|
||||||
username=message.from_user.username,
|
username=message.from_user.username,
|
||||||
|
|
@ -45,11 +47,9 @@ async def purification_handler(message: Message) -> None:
|
||||||
|
|
||||||
@router.message(Command("яички", ignore_case=True))
|
@router.message(Command("яички", ignore_case=True))
|
||||||
async def eggs_handler(message: Message) -> None:
|
async def eggs_handler(message: Message) -> None:
|
||||||
settings = get_settings()
|
today = datetime.now(get_settings().tzinfo).date()
|
||||||
if not is_egg_event_active(settings):
|
if not (EGG_EVENT_START <= today <= EGG_EVENT_END):
|
||||||
today = datetime.now(settings.tzinfo).date()
|
await message.answer(system.MSG_EGGS_TOO_EARLY)
|
||||||
start, _ = egg_event_period(settings, today)
|
|
||||||
await message.answer(system.MSG_EGGS_TOO_EARLY if today < start else system.MSG_EGGS_EVENT_OVER)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
|
|
@ -58,9 +58,9 @@ async def eggs_handler(message: Message) -> None:
|
||||||
result = await economy_service.collect_eggs(message.from_user.id)
|
result = await economy_service.collect_eggs(message.from_user.id)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
|
||||||
await message.answer(render(await get_text("economy.no_eggs"), context))
|
await message.answer(render(economy.MSG_NO_EGGS, context))
|
||||||
else:
|
else:
|
||||||
await message.answer(render(await get_text("economy.eggs"), {"eggs": result.eggs}))
|
await message.answer(render(economy.MSG_EGGS, {"eggs": result.eggs}))
|
||||||
|
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from common.analytics import EventType, record
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.texts import system, welcome
|
from common.texts import system, welcome
|
||||||
from common.texts.render import render
|
from common.texts.render import render
|
||||||
from common.texts.store import get_text
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router(name="member_events")
|
router = Router(name="member_events")
|
||||||
|
|
@ -55,14 +54,14 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
|
||||||
await event.bot.send_photo(
|
await event.bot.send_photo(
|
||||||
chat_id=event.chat.id,
|
chat_id=event.chat.id,
|
||||||
photo=FSInputFile("images/welcome_image.jpg"),
|
photo=FSInputFile("images/welcome_image.jpg"),
|
||||||
caption=render(await get_text("welcome.text"), {"username": user.username}),
|
caption=render(welcome.MSG_WELCOME_TEXT, {"username": user.username}),
|
||||||
)
|
)
|
||||||
await event.bot.send_message(
|
await event.bot.send_message(
|
||||||
chat_id=event.chat.id,
|
chat_id=event.chat.id,
|
||||||
text=await get_text("welcome.text2"),
|
text=welcome.MSG_WELCOME_TEXT2,
|
||||||
reply_markup=builder.as_markup(),
|
reply_markup=builder.as_markup(),
|
||||||
)
|
)
|
||||||
await event.bot.send_message(chat_id=event.chat.id, text=await get_text("welcome.text3"))
|
await event.bot.send_message(chat_id=event.chat.id, text=welcome.MSG_WELCOME_TEXT3)
|
||||||
|
|
||||||
|
|
||||||
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ from aiogram.types import CallbackQuery, Message
|
||||||
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
||||||
from bot.services import access
|
from bot.services import access
|
||||||
from bot.services import shop as shop_service
|
from bot.services import shop as shop_service
|
||||||
|
from common.texts import shop as shop_texts
|
||||||
from common.texts import system
|
from common.texts import system
|
||||||
from common.texts.render import render
|
from common.texts.render import render
|
||||||
from common.texts.store import get_text
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router(name="shop")
|
router = Router(name="shop")
|
||||||
|
|
@ -26,16 +26,16 @@ async def shop_handler(message: Message) -> None:
|
||||||
if message.chat.type != "private":
|
if message.chat.type != "private":
|
||||||
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
|
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
|
||||||
return
|
return
|
||||||
await message.answer(await get_text("shop.main"), reply_markup=get_keyboard_main())
|
await message.answer(shop_texts.MSG_SHOP_MAIN, reply_markup=get_keyboard_main())
|
||||||
|
|
||||||
|
|
||||||
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
|
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
|
||||||
if chapter == "main":
|
if chapter == "main":
|
||||||
return await get_text("shop.main"), get_keyboard_main()
|
return shop_texts.MSG_SHOP_MAIN, get_keyboard_main()
|
||||||
if chapter == "title":
|
if chapter == "title":
|
||||||
return await get_text("shop.title"), await get_keyboard_title()
|
return shop_texts.MSG_SHOP_TITLE, await get_keyboard_title()
|
||||||
if chapter == "donat":
|
if chapter == "donat":
|
||||||
return await get_text("shop.donat"), await get_keyboard_donat()
|
return shop_texts.MSG_SHOP_DONAT, await get_keyboard_donat()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,57 @@
|
||||||
"""Планировщик рассылок.
|
"""Планировщик рассылок.
|
||||||
|
|
||||||
Расписания читаются из БД (таблица ``bot.schedules``, редактируется в админке).
|
Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
|
||||||
Если база недоступна, используется расписание по умолчанию из кода и последний
|
магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
|
||||||
успешно прочитанный набор. Тексты берутся из ``bot.message_templates`` через
|
редактироваться через админку.
|
||||||
``common.texts.store`` (TTL-кеш, правки без перезапуска).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
from dataclasses import dataclass, field
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
|
||||||
from bot.services import users as users_service
|
from bot.services import users as users_service
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.repositories import schedules_repo
|
|
||||||
from common.texts import format_usernames, render
|
from common.texts import format_usernames, render
|
||||||
from common.texts.store import get_text
|
from common.texts import notifications as notify_texts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
#: Размер порции упоминаний в одном сообщении.
|
#: Размер порции упоминаний в одном сообщении.
|
||||||
BATCH_SIZE = 10
|
BATCH_SIZE = 10
|
||||||
|
|
||||||
#: Как часто писать в лог, если БД с расписаниями недоступна (секунды).
|
|
||||||
WARNING_INTERVAL = 300.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ScheduledMessage:
|
class ScheduledMessage:
|
||||||
"""Одна запланированная рассылка из БД (или из значений по умолчанию).
|
"""Одна запланированная рассылка.
|
||||||
|
|
||||||
:param weekdays: дни недели по ISO (1=Пн … 7=Вс). Пусто — ежедневно.
|
:param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно.
|
||||||
:param timezone: таймзона расписания (IANA), время в ней.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hour: int
|
hour: int
|
||||||
minute: int
|
minute: int
|
||||||
template_key: str
|
template: str
|
||||||
weekdays: tuple[int, ...] = ()
|
weekdays: tuple[int, ...] | None = field(default=None)
|
||||||
timezone: str = "Europe/Moscow"
|
|
||||||
|
|
||||||
def matches(self, now: datetime) -> bool:
|
def matches(self, now: datetime) -> bool:
|
||||||
"""Совпадает ли момент ``now`` (aware) с этим расписанием."""
|
if self.hour != now.hour or self.minute != now.minute:
|
||||||
local = now.astimezone(ZoneInfo(self.timezone))
|
|
||||||
if self.hour != local.hour or self.minute != local.minute:
|
|
||||||
return False
|
return False
|
||||||
return not self.weekdays or local.isoweekday() in self.weekdays
|
return self.weekdays is None or now.weekday() in self.weekdays
|
||||||
|
|
||||||
|
|
||||||
#: Расписание по умолчанию — на случай недоступной БД (совпадает с V16-сидом).
|
SCHEDULES: tuple[ScheduledMessage, ...] = (
|
||||||
DEFAULT_SCHEDULES: tuple[ScheduledMessage, ...] = (
|
ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
|
||||||
ScheduledMessage(16, 30, "notify.blago_30"),
|
ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
|
||||||
ScheduledMessage(16, 45, "notify.blago_45"),
|
ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
|
||||||
ScheduledMessage(16, 55, "notify.blago_55"),
|
ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
|
||||||
ScheduledMessage(17, 0, "notify.blago"),
|
ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
|
||||||
ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)), # пятница
|
ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
|
||||||
ScheduledMessage(14, 0, "notify.vklad", weekdays=(7,)), # воскресенье
|
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Последний успешно прочитанный набор — чтобы пережить временный сбой БД.
|
|
||||||
_last_good: tuple[ScheduledMessage, ...] | None = None
|
|
||||||
_last_warning_at = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _from_row(row: dict) -> ScheduledMessage:
|
|
||||||
return ScheduledMessage(
|
|
||||||
hour=int(row["hour"]),
|
|
||||||
minute=int(row["minute"]),
|
|
||||||
template_key=str(row["template_key"]),
|
|
||||||
weekdays=tuple(int(day) for day in (row["weekdays"] or ())),
|
|
||||||
timezone=str(row["timezone"] or "Europe/Moscow"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_schedules() -> tuple[ScheduledMessage, ...]:
|
|
||||||
"""Прочитать активные расписания; при сбое — последние известные или дефолт."""
|
|
||||||
global _last_good, _last_warning_at
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = await schedules_repo.list_active()
|
|
||||||
except Exception:
|
|
||||||
now = time.monotonic()
|
|
||||||
if now - _last_warning_at > WARNING_INTERVAL:
|
|
||||||
logger.warning(
|
|
||||||
"Не удалось прочитать расписания из БД — использую последние известные",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
_last_warning_at = now
|
|
||||||
return _last_good if _last_good is not None else DEFAULT_SCHEDULES
|
|
||||||
|
|
||||||
_last_good = tuple(_from_row(row) for row in rows)
|
|
||||||
return _last_good
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
|
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
|
||||||
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
|
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
|
||||||
|
|
@ -107,15 +61,10 @@ async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> N
|
||||||
|
|
||||||
|
|
||||||
async def _tick(bot: Bot, now: datetime) -> None:
|
async def _tick(bot: Bot, now: datetime) -> None:
|
||||||
for schedule in await _load_schedules():
|
for schedule in SCHEDULES:
|
||||||
if not schedule.matches(now):
|
if schedule.matches(now):
|
||||||
continue
|
subscribers = await users_service.list_subscribers()
|
||||||
template = await get_text(schedule.template_key)
|
await _send_broadcast(bot, schedule.template, subscribers)
|
||||||
if not template:
|
|
||||||
logger.warning("Шаблон %s пуст или не найден — рассылка пропущена", schedule.template_key)
|
|
||||||
continue
|
|
||||||
subscribers = await users_service.list_subscribers()
|
|
||||||
await _send_broadcast(bot, template, subscribers)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_notifications(bot: Bot) -> None:
|
async def run_notifications(bot: Bot) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""Чтение событий ClickHouse для админки (Блок 4.3).
|
|
||||||
|
|
||||||
Клиент создаётся лениво и только на чтение; недоступность ClickHouse не роняет
|
|
||||||
админку — страница покажет ошибку. Запись событий живёт в ``client.py``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime, time, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from common.analytics.events import ALL_EVENT_TYPES
|
|
||||||
from common.config import Settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Колонки, которые показываем в логах.
|
|
||||||
EVENT_FIELDS = (
|
|
||||||
"event_time",
|
|
||||||
"event_type",
|
|
||||||
"user_id",
|
|
||||||
"username",
|
|
||||||
"chat_id",
|
|
||||||
"payload",
|
|
||||||
"bot_version",
|
|
||||||
"prod",
|
|
||||||
)
|
|
||||||
|
|
||||||
_client: Any | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_client(settings: Settings) -> Any:
|
|
||||||
"""Создать (один раз) асинхронный клиент ClickHouse."""
|
|
||||||
global _client
|
|
||||||
if _client is not None:
|
|
||||||
return _client
|
|
||||||
|
|
||||||
import clickhouse_connect
|
|
||||||
|
|
||||||
_client = await clickhouse_connect.get_async_client(
|
|
||||||
host=settings.clickhouse_host,
|
|
||||||
port=settings.clickhouse_port,
|
|
||||||
username=settings.clickhouse_user,
|
|
||||||
password=settings.clickhouse_password,
|
|
||||||
database=settings.clickhouse_db,
|
|
||||||
)
|
|
||||||
return _client
|
|
||||||
|
|
||||||
|
|
||||||
async def close_client() -> None:
|
|
||||||
"""Закрыть клиент при остановке админки."""
|
|
||||||
global _client
|
|
||||||
if _client is not None:
|
|
||||||
client, _client = _client, None
|
|
||||||
try:
|
|
||||||
await client.close()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Не удалось закрыть клиент ClickHouse", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _where(
|
|
||||||
*,
|
|
||||||
event_type: str,
|
|
||||||
user: str,
|
|
||||||
start: datetime,
|
|
||||||
end: datetime,
|
|
||||||
) -> tuple[str, dict[str, Any]]:
|
|
||||||
clauses = ["event_time >= {start:DateTime}", "event_time < {end:DateTime}"]
|
|
||||||
params: dict[str, Any] = {"start": start, "end": end}
|
|
||||||
|
|
||||||
if event_type and event_type in ALL_EVENT_TYPES:
|
|
||||||
clauses.append("event_type = {event_type:String}")
|
|
||||||
params["event_type"] = event_type
|
|
||||||
|
|
||||||
user = user.strip()
|
|
||||||
if user:
|
|
||||||
if user.isdigit():
|
|
||||||
clauses.append("user_id = {user_id:Int64}")
|
|
||||||
params["user_id"] = int(user)
|
|
||||||
else:
|
|
||||||
clauses.append("positionCaseInsensitive(username, {user_text:String}) > 0")
|
|
||||||
params["user_text"] = user
|
|
||||||
|
|
||||||
return " WHERE " + " AND ".join(clauses), params
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_events(
|
|
||||||
settings: Settings,
|
|
||||||
*,
|
|
||||||
event_type: str = "",
|
|
||||||
user: str = "",
|
|
||||||
date_from: str = "",
|
|
||||||
date_to: str = "",
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> tuple[list[dict[str, Any]], int]:
|
|
||||||
"""Вернуть события и общее количество по фильтрам.
|
|
||||||
|
|
||||||
Даты — ``YYYY-MM-DD`` в таймзоне админки; внутри переводятся в UTC.
|
|
||||||
"""
|
|
||||||
today = datetime.now(settings.tzinfo).date()
|
|
||||||
start_date = datetime.strptime(date_from, "%Y-%m-%d").date() if date_from else today - timedelta(days=6)
|
|
||||||
end_date = datetime.strptime(date_to, "%Y-%m-%d").date() if date_to else today
|
|
||||||
|
|
||||||
start = datetime.combine(start_date, time.min, tzinfo=settings.tzinfo).astimezone(UTC).replace(tzinfo=None)
|
|
||||||
end = (
|
|
||||||
datetime.combine(end_date + timedelta(days=1), time.min, tzinfo=settings.tzinfo)
|
|
||||||
.astimezone(UTC)
|
|
||||||
.replace(tzinfo=None)
|
|
||||||
)
|
|
||||||
|
|
||||||
where, params = _where(event_type=event_type, user=user, start=start, end=end)
|
|
||||||
client = await _ensure_client(settings)
|
|
||||||
|
|
||||||
count_result = await client.query(f"SELECT count() FROM analytics.events{where}", parameters=params)
|
|
||||||
total = int(count_result.result_rows[0][0]) if count_result.result_rows else 0
|
|
||||||
|
|
||||||
result = await client.query(
|
|
||||||
f"SELECT {', '.join(EVENT_FIELDS)} FROM analytics.events{where}"
|
|
||||||
" ORDER BY event_time DESC LIMIT {limit:UInt32} OFFSET {offset:UInt32}",
|
|
||||||
parameters={**params, "limit": limit, "offset": offset},
|
|
||||||
)
|
|
||||||
rows = [dict(zip(result.column_names, row, strict=False)) for row in result.result_rows]
|
|
||||||
return rows, total
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
@ -67,14 +66,6 @@ class Settings(BaseSettings):
|
||||||
analytics_flush_interval: float = 5.0
|
analytics_flush_interval: float = 5.0
|
||||||
analytics_buffer_max: int = 5000
|
analytics_buffer_max: int = 5000
|
||||||
|
|
||||||
#: Сколько секунд бот держит шаблоны сообщений в памяти (правки из админки
|
|
||||||
#: применяются без перезапуска). 0 — читать БД при каждом обращении.
|
|
||||||
template_cache_ttl: float = 45.0
|
|
||||||
|
|
||||||
#: Окно пасхального события «Яички». Пусто — автоматически 1 апреля → Православная Пасха.
|
|
||||||
egg_event_start: date | None = None
|
|
||||||
egg_event_end: date | None = None
|
|
||||||
|
|
||||||
@field_validator("chat_id", "alert_chat_id", mode="before")
|
@field_validator("chat_id", "alert_chat_id", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _empty_int_to_zero(cls, value: object) -> object:
|
def _empty_int_to_zero(cls, value: object) -> object:
|
||||||
|
|
@ -83,14 +74,6 @@ class Settings(BaseSettings):
|
||||||
return 0
|
return 0
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("egg_event_start", "egg_event_end", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _empty_date_to_none(cls, value: object) -> object:
|
|
||||||
"""Пустое значение в .env — «даты не заданы» (работает авто-расчёт)."""
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("admin_ids", mode="before")
|
@field_validator("admin_ids", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_admin_ids(cls, value: object) -> object:
|
def _parse_admin_ids(cls, value: object) -> object:
|
||||||
|
|
@ -122,36 +105,5 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Вернуть singleton настроек бота."""
|
"""Вернуть singleton настроек."""
|
||||||
return Settings() # type: ignore[call-arg]
|
return Settings() # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
class AdminSettings(Settings):
|
|
||||||
"""Настройки сервиса админки.
|
|
||||||
|
|
||||||
Наследует общие поля (окружение, Postgres, таймзона, ClickHouse), но не требует
|
|
||||||
``BOT_TOKEN``: на этапе каркаса он не нужен, а появится в проверке подписи
|
|
||||||
Telegram-логина (Блок 4.2).
|
|
||||||
"""
|
|
||||||
|
|
||||||
bot_token: str = ""
|
|
||||||
|
|
||||||
#: Адрес и порт, которые слушает uvicorn внутри контейнера.
|
|
||||||
admin_host: str = "0.0.0.0"
|
|
||||||
admin_port: int = 8000
|
|
||||||
|
|
||||||
#: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS).
|
|
||||||
admin_public_url: str = "http://localhost:8000"
|
|
||||||
#: Username бота без @ — из него виджет рисует кнопку входа.
|
|
||||||
bot_username: str = ""
|
|
||||||
#: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают
|
|
||||||
#: при перезапуске (генерируется временный).
|
|
||||||
admin_session_secret: str = ""
|
|
||||||
#: Время жизни сессии, секунды (по умолчанию 12 часов).
|
|
||||||
admin_session_max_age: int = 12 * 60 * 60
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_admin_settings() -> AdminSettings:
|
|
||||||
"""Вернуть singleton настроек админки."""
|
|
||||||
return AdminSettings() # type: ignore[call-arg]
|
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,20 @@ import logging
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg_pool import AsyncConnectionPool
|
from psycopg_pool import AsyncConnectionPool
|
||||||
|
|
||||||
from common.config import Settings, get_settings
|
from common.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_pool: AsyncConnectionPool | None = None
|
_pool: AsyncConnectionPool | None = None
|
||||||
|
|
||||||
|
|
||||||
async def init_pool(settings: Settings | None = None) -> AsyncConnectionPool:
|
async def init_pool() -> AsyncConnectionPool:
|
||||||
"""Создать и открыть пул. Идемпотентно.
|
"""Создать и открыть пул. Идемпотентно."""
|
||||||
|
|
||||||
``settings`` передаёт админка (свои настройки); по умолчанию — настройки бота.
|
|
||||||
"""
|
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is not None:
|
if _pool is not None:
|
||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
settings = settings or get_settings()
|
settings = get_settings()
|
||||||
_pool = AsyncConnectionPool(
|
_pool = AsyncConnectionPool(
|
||||||
conninfo=settings.dsn,
|
conninfo=settings.dsn,
|
||||||
min_size=settings.db_pool_min,
|
min_size=settings.db_pool_min,
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""Пасхальное событие «Яички» (Блок 4: сезонность).
|
|
||||||
|
|
||||||
По умолчанию окно — с 1 апреля по православную Пасху включительно (как было
|
|
||||||
в исходном боте: 01.04–20.04.2025). Даты можно переопределить переменными
|
|
||||||
``EGG_EVENT_START``/``EGG_EVENT_END`` (например, для другого года или правила).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
|
|
||||||
from common.config import Settings
|
|
||||||
|
|
||||||
#: Разница между юлианским и григорианским календарями для 1900–2099.
|
|
||||||
_JULIAN_TO_GREGORIAN_DAYS = 13
|
|
||||||
|
|
||||||
|
|
||||||
def orthodox_easter(year: int) -> date:
|
|
||||||
"""Дата православной Пасхи (по юлианской пасхалии, переведённая в григорианский стиль)."""
|
|
||||||
a = year % 4
|
|
||||||
b = year % 7
|
|
||||||
c = year % 19
|
|
||||||
d = (19 * c + 15) % 30
|
|
||||||
e = (2 * a + 4 * b - d + 34) % 7
|
|
||||||
month = (d + e + 114) // 31
|
|
||||||
day = ((d + e + 114) % 31) + 1
|
|
||||||
return date(year, month, day) + timedelta(days=_JULIAN_TO_GREGORIAN_DAYS)
|
|
||||||
|
|
||||||
|
|
||||||
def egg_event_period(settings: Settings, today: date) -> tuple[date, date]:
|
|
||||||
"""Начало и конец окна «яичек» (включительно).
|
|
||||||
|
|
||||||
Если в настройках заданы обе даты — берём их, иначе 1 апреля → православная
|
|
||||||
Пасха года ``today``.
|
|
||||||
"""
|
|
||||||
if settings.egg_event_start and settings.egg_event_end:
|
|
||||||
return settings.egg_event_start, settings.egg_event_end
|
|
||||||
return date(today.year, 4, 1), orthodox_easter(today.year)
|
|
||||||
|
|
||||||
|
|
||||||
def is_egg_event_active(settings: Settings, today: date | None = None) -> bool:
|
|
||||||
"""Идёт ли сейчас пасхальное событие (в таймзоне настроек)."""
|
|
||||||
if today is None:
|
|
||||||
today = datetime.now(settings.tzinfo).date()
|
|
||||||
start, end = egg_event_period(settings, today)
|
|
||||||
return start <= today <= end
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
"""Репозиторий администраторов веб-панели (Блок 4.2)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from common.db.pool import get_pool
|
|
||||||
|
|
||||||
|
|
||||||
async def get_admin(telegram_id: int) -> dict[str, Any] | None:
|
|
||||||
"""Вернуть администратора по Telegram-ID (для проверки доступа)."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT telegram_id, username, role, enabled, created_at, last_login_at
|
|
||||||
FROM bot.admin_users
|
|
||||||
WHERE telegram_id = %s
|
|
||||||
""",
|
|
||||||
(telegram_id,),
|
|
||||||
)
|
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_admins(telegram_ids: list[int]) -> int:
|
|
||||||
"""Засеять администраторов из ADMIN_IDS (существующих не трогаем).
|
|
||||||
|
|
||||||
Возвращает количество вставленных строк.
|
|
||||||
"""
|
|
||||||
if not telegram_ids:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
for telegram_id in dict.fromkeys(telegram_ids):
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bot.admin_users (telegram_id, role)
|
|
||||||
VALUES (%s, 'admin')
|
|
||||||
ON CONFLICT (telegram_id) DO NOTHING
|
|
||||||
""",
|
|
||||||
(telegram_id,),
|
|
||||||
)
|
|
||||||
inserted += cur.rowcount or 0
|
|
||||||
return inserted
|
|
||||||
|
|
||||||
|
|
||||||
async def touch_login(telegram_id: int) -> None:
|
|
||||||
"""Отметить успешный вход (не критично, ошибки не должны ломать логин)."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE bot.admin_users SET last_login_at = now() WHERE telegram_id = %s",
|
|
||||||
(telegram_id,),
|
|
||||||
)
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
"""Репозиторий расписаний рассылок (Блок 4.5).
|
|
||||||
|
|
||||||
Редактирует админка, исполняет бот: он читает активные строки раз в минуту.
|
|
||||||
Каждое изменение пишет снимок строки в ``schedules_history`` (аудит).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from psycopg.types.json import Jsonb
|
|
||||||
|
|
||||||
from common.db.pool import get_pool
|
|
||||||
|
|
||||||
#: Поля, которые попадают в историю и форму (без служебных отметок времени).
|
|
||||||
SNAPSHOT_FIELDS = ("template_key", "hour", "minute", "weekdays", "timezone", "enabled", "description")
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(row: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return {field: row.get(field) for field in SNAPSHOT_FIELDS}
|
|
||||||
|
|
||||||
|
|
||||||
async def list_schedules() -> list[dict[str, Any]]:
|
|
||||||
"""Все расписания с описанием шаблона (для таблицы в админке)."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT s.id, s.template_key, t.description AS template_description,
|
|
||||||
s.hour, s.minute, s.weekdays, s.timezone, s.enabled,
|
|
||||||
s.description, s.updated_at, s.updated_by
|
|
||||||
FROM bot.schedules AS s
|
|
||||||
JOIN bot.message_templates AS t ON t.key = s.template_key
|
|
||||||
ORDER BY s.hour, s.minute, s.id
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_active() -> list[dict[str, Any]]:
|
|
||||||
"""Активные расписания — для планировщика бота."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, template_key, hour, minute, weekdays, timezone
|
|
||||||
FROM bot.schedules
|
|
||||||
WHERE enabled
|
|
||||||
ORDER BY hour, minute, id
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_schedule(schedule_id: int) -> dict[str, Any] | None:
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, template_key, hour, minute, weekdays, timezone, enabled,
|
|
||||||
description, updated_at, updated_by
|
|
||||||
FROM bot.schedules
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(schedule_id,),
|
|
||||||
)
|
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
async def create_schedule(data: dict[str, Any], *, editor_id: int | None = None) -> int:
|
|
||||||
"""Создать расписание и записать его в историю. Возвращает id."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bot.schedules
|
|
||||||
(template_key, hour, minute, weekdays, timezone, enabled, description, updated_by)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
data["template_key"],
|
|
||||||
data["hour"],
|
|
||||||
data["minute"],
|
|
||||||
data["weekdays"],
|
|
||||||
data["timezone"],
|
|
||||||
data["enabled"],
|
|
||||||
data["description"],
|
|
||||||
editor_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
row = await cur.fetchone()
|
|
||||||
schedule_id = int(row["id"])
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
|
|
||||||
VALUES (%s, NULL, %s, %s)
|
|
||||||
""",
|
|
||||||
(schedule_id, Jsonb(data), editor_id),
|
|
||||||
)
|
|
||||||
return schedule_id
|
|
||||||
|
|
||||||
|
|
||||||
async def update_schedule(schedule_id: int, data: dict[str, Any], *, editor_id: int | None = None) -> bool:
|
|
||||||
"""Обновить расписание и записать старое/новое значение. False — если записи нет."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE",
|
|
||||||
(schedule_id,),
|
|
||||||
)
|
|
||||||
row = await cur.fetchone()
|
|
||||||
if row is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
old = _snapshot(row)
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE bot.schedules
|
|
||||||
SET template_key = %s, hour = %s, minute = %s, weekdays = %s,
|
|
||||||
timezone = %s, enabled = %s, description = %s,
|
|
||||||
updated_at = now(), updated_by = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
data["template_key"],
|
|
||||||
data["hour"],
|
|
||||||
data["minute"],
|
|
||||||
data["weekdays"],
|
|
||||||
data["timezone"],
|
|
||||||
data["enabled"],
|
|
||||||
data["description"],
|
|
||||||
editor_id,
|
|
||||||
schedule_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(schedule_id, Jsonb(old), Jsonb(data), editor_id),
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def set_enabled(schedule_id: int, enabled: bool, *, editor_id: int | None = None) -> bool:
|
|
||||||
"""Включить/выключить расписание (тоже с записью в историю)."""
|
|
||||||
schedule = await get_schedule(schedule_id)
|
|
||||||
if schedule is None:
|
|
||||||
return False
|
|
||||||
data = _snapshot(schedule)
|
|
||||||
data["enabled"] = enabled
|
|
||||||
return await update_schedule(schedule_id, data, editor_id=editor_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_schedule(schedule_id: int, *, editor_id: int | None = None) -> bool:
|
|
||||||
"""Удалить расписание, сохранив последний снимок в истории."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE", (schedule_id,))
|
|
||||||
row = await cur.fetchone()
|
|
||||||
if row is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id)
|
|
||||||
VALUES (%s, %s, NULL, %s)
|
|
||||||
""",
|
|
||||||
(schedule_id, Jsonb(_snapshot(row)), editor_id),
|
|
||||||
)
|
|
||||||
await conn.execute("DELETE FROM bot.schedules WHERE id = %s", (schedule_id,))
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def schedule_history(schedule_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, old_data, new_data, editor_id, changed_at
|
|
||||||
FROM bot.schedules_history
|
|
||||||
WHERE schedule_id = %s
|
|
||||||
ORDER BY changed_at DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(schedule_id, limit),
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"""Репозиторий шаблонов сообщений (Блок 4.1: редактирование в админке).
|
|
||||||
|
|
||||||
Каждое изменение текста пишет запись в ``message_template_history`` — это аудит
|
|
||||||
и материал для отката. Бот подключится к этим данным в Блоке 4.5.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from common.db.pool import get_pool
|
|
||||||
|
|
||||||
|
|
||||||
async def load_texts() -> dict[str, str]:
|
|
||||||
"""Актуальные тексты включённых шаблонов (для кеша бота в ``common.texts.store``)."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT key, text FROM bot.message_templates WHERE enabled ORDER BY key")
|
|
||||||
rows = await cur.fetchall()
|
|
||||||
return {row["key"]: row["text"] for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
async def list_templates() -> list[dict[str, Any]]:
|
|
||||||
"""Вернуть список шаблонов без текста (для таблицы в админке)."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT key, description, enabled, updated_at, updated_by,
|
|
||||||
length(text) AS text_length
|
|
||||||
FROM bot.message_templates
|
|
||||||
ORDER BY key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_template(key: str) -> dict[str, Any] | None:
|
|
||||||
"""Вернуть шаблон целиком (с текстом) или ``None``."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT key, text, description, enabled, updated_at, updated_by
|
|
||||||
FROM bot.message_templates
|
|
||||||
WHERE key = %s
|
|
||||||
""",
|
|
||||||
(key,),
|
|
||||||
)
|
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_template(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any] | None:
|
|
||||||
"""Обновить текст шаблона и записать историю.
|
|
||||||
|
|
||||||
Возвращает ``None``, если шаблона нет. Старое значение читается под
|
|
||||||
``FOR UPDATE``, поэтому параллельные правки не потеряют историю.
|
|
||||||
"""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT text FROM bot.message_templates WHERE key = %s FOR UPDATE", (key,))
|
|
||||||
row = await cur.fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
old_text = row["text"]
|
|
||||||
if old_text == text:
|
|
||||||
return {"key": key, "old_text": old_text, "text": text, "changed": False}
|
|
||||||
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE bot.message_templates
|
|
||||||
SET text = %s, updated_at = now(), updated_by = %s
|
|
||||||
WHERE key = %s
|
|
||||||
""",
|
|
||||||
(text, editor_id, key),
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bot.message_template_history (key, old_text, new_text, editor_id)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(key, old_text, text, editor_id),
|
|
||||||
)
|
|
||||||
return {"key": key, "old_text": old_text, "text": text, "changed": True}
|
|
||||||
|
|
||||||
|
|
||||||
async def template_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
"""Последние изменения шаблона (для блока истории в редакторе)."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, old_text, new_text, editor_id, changed_at
|
|
||||||
FROM bot.message_template_history
|
|
||||||
WHERE key = %s
|
|
||||||
ORDER BY changed_at DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(key, limit),
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
"""Чтение данных пользователей для админки (Блок 4.3).
|
|
||||||
|
|
||||||
Баланс — контракт ``SUM(bot.money.amount)``; яички — ``SUM(bot.eggs.amount)``.
|
|
||||||
Подписка на рассылки — отсутствие строки в ``bot.unsubscribe``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from common.db.pool import get_pool
|
|
||||||
|
|
||||||
#: Общая часть выборки: агрегаты по сообщениям, деньгам, яичкам и очищениям.
|
|
||||||
_FROM = """
|
|
||||||
FROM bot.tg_users AS tg
|
|
||||||
LEFT JOIN bot.de_users AS du ON du.id_user = tg.id_user
|
|
||||||
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
|
|
||||||
FROM bot.messages GROUP BY id_user
|
|
||||||
) AS msg ON msg.id_user = tg.id_user
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT id_user, sum(amount) AS balance
|
|
||||||
FROM bot.money GROUP BY id_user
|
|
||||||
) AS money ON money.id_user = tg.id_user
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT id_user, sum(amount) AS eggs
|
|
||||||
FROM bot.eggs GROUP BY id_user
|
|
||||||
) AS eggs ON eggs.id_user = tg.id_user
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
|
|
||||||
FROM bot.purifications GROUP BY id_user
|
|
||||||
) AS pur ON pur.id_user = tg.id_user
|
|
||||||
LEFT JOIN bot.unsubscribe AS unsub ON unsub.id_user = tg.id_user
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT = """
|
|
||||||
SELECT tg.id_user, tg.username, tg.first_name, tg.last_name,
|
|
||||||
du.nick, du.lvl, du.expr, du.expr_streak, du.dt_create,
|
|
||||||
du.id_title,
|
|
||||||
t.title,
|
|
||||||
coalesce(msg.cnt, 0) AS msg_count,
|
|
||||||
msg.last_dt AS last_message_at,
|
|
||||||
coalesce(money.balance, 0) AS balance,
|
|
||||||
coalesce(eggs.eggs, 0) AS eggs,
|
|
||||||
coalesce(pur.cnt, 0) AS purification_count,
|
|
||||||
pur.last_dt AS last_purification_at,
|
|
||||||
(unsub.id_user IS NULL) AS subscribed
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Колонки, по которым идёт поиск (в порядке подстановки параметров).
|
|
||||||
_SEARCH_COLUMNS = ("tg.username", "tg.first_name", "tg.last_name", "du.nick")
|
|
||||||
|
|
||||||
|
|
||||||
def _search_clause(term: str) -> tuple[str, list[Any]]:
|
|
||||||
"""WHERE по нику, username, имени и Telegram-ID."""
|
|
||||||
term = term.strip()
|
|
||||||
if not term:
|
|
||||||
return "", []
|
|
||||||
|
|
||||||
like = f"%{term}%"
|
|
||||||
clauses = [f"{column} ILIKE %s" for column in _SEARCH_COLUMNS]
|
|
||||||
params: list[Any] = [like] * len(_SEARCH_COLUMNS)
|
|
||||||
|
|
||||||
telegram_id = term.lstrip("@")
|
|
||||||
if telegram_id.isdigit():
|
|
||||||
clauses.append("tg.id_user = %s")
|
|
||||||
params.append(int(telegram_id))
|
|
||||||
|
|
||||||
return " WHERE (" + " OR ".join(clauses) + ")", params
|
|
||||||
|
|
||||||
|
|
||||||
async def count_users(search: str = "") -> int:
|
|
||||||
"""Сколько пользователей подходит под поиск."""
|
|
||||||
where, params = _search_clause(search)
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT count(*) AS total " + _FROM + where, tuple(params))
|
|
||||||
row = await cur.fetchone()
|
|
||||||
return int(row["total"]) if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
async def list_users(search: str = "", *, limit: int = 25, offset: int = 0) -> list[dict[str, Any]]:
|
|
||||||
"""Страница списка пользователей (с поиском)."""
|
|
||||||
where, params = _search_clause(search)
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
_SELECT + _FROM + where + " ORDER BY du.nick NULLS LAST, tg.id_user LIMIT %s OFFSET %s",
|
|
||||||
(*params, limit, offset),
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user(user_id: int) -> dict[str, Any] | None:
|
|
||||||
"""Карточка пользователя со сводными показателями."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(_SELECT + _FROM + " WHERE tg.id_user = %s", (user_id,))
|
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
"""Последние операции по балансу с названием действия."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT m.id_money, m.amount, m.dt_action, a.title AS action
|
|
||||||
FROM bot.money AS m
|
|
||||||
LEFT JOIN bot.actions AS a ON a.id_action = m.id_action
|
|
||||||
WHERE m.id_user = %s
|
|
||||||
ORDER BY m.dt_action DESC, m.id_money DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(user_id, limit),
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Управление пользователем (Блок 4.3) -------------------------------------
|
|
||||||
|
|
||||||
#: Действие «Корректировка админом» из V18 (ручное изменение баланса).
|
|
||||||
ADMIN_ACTION_ID = 6
|
|
||||||
|
|
||||||
#: Ограничение длины ника (как в команде /ник).
|
|
||||||
MAX_NICK_LENGTH = 32
|
|
||||||
|
|
||||||
#: Ограничение длины комментария к корректировке баланса.
|
|
||||||
MAX_COMMENT_LENGTH = 200
|
|
||||||
|
|
||||||
|
|
||||||
async def list_titles() -> list[dict[str, Any]]:
|
|
||||||
"""Доступные звания для выбора в карточке."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT id_title, title FROM bot.titles WHERE visible ORDER BY price DESC, id_title")
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
|
||||||
"""История ручных изменений пользователя."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, field, old_value, new_value, comment, editor_id, changed_at
|
|
||||||
FROM bot.user_changes
|
|
||||||
WHERE id_user = %s
|
|
||||||
ORDER BY changed_at DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(user_id, limit),
|
|
||||||
)
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def _log_change(
|
|
||||||
conn: Any,
|
|
||||||
user_id: int,
|
|
||||||
field: str,
|
|
||||||
old_value: str | None,
|
|
||||||
new_value: str | None,
|
|
||||||
editor_id: int | None,
|
|
||||||
comment: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(user_id, field, old_value, new_value, comment, editor_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool:
|
|
||||||
"""Сменить ник (создаёт запись de_users, если её ещё нет)."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (user_id,))
|
|
||||||
row = await cur.fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (user_id, nick))
|
|
||||||
old_nick = None
|
|
||||||
else:
|
|
||||||
old_nick = row["nick"]
|
|
||||||
if old_nick == nick:
|
|
||||||
return True
|
|
||||||
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, user_id))
|
|
||||||
|
|
||||||
await _log_change(conn, user_id, "nick", old_nick, nick, editor_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool:
|
|
||||||
"""Сменить звание пользователя. False — если пользователя или звания нет."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT du.id_title, coalesce(t.title, '') AS title
|
|
||||||
FROM bot.de_users AS du
|
|
||||||
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
|
|
||||||
WHERE du.id_user = %s
|
|
||||||
FOR UPDATE OF du
|
|
||||||
""",
|
|
||||||
(user_id,),
|
|
||||||
)
|
|
||||||
current = await cur.fetchone()
|
|
||||||
if current is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
cur = await conn.execute("SELECT title FROM bot.titles WHERE id_title = %s", (title_id,))
|
|
||||||
title = await cur.fetchone()
|
|
||||||
if title is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if current["id_title"] == title_id:
|
|
||||||
return True
|
|
||||||
|
|
||||||
await conn.execute("UPDATE bot.de_users SET id_title = %s WHERE id_user = %s", (title_id, user_id))
|
|
||||||
await _log_change(conn, user_id, "title", current["title"], title["title"], editor_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool:
|
|
||||||
"""Подписать или отписать пользователя от рассылок."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
|
||||||
if await cur.fetchone() is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
cur = await conn.execute("SELECT 1 FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
|
||||||
was_subscribed = await cur.fetchone() is None
|
|
||||||
if was_subscribed == subscribed:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if subscribed:
|
|
||||||
await conn.execute("DELETE FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
|
||||||
else:
|
|
||||||
await conn.execute(
|
|
||||||
"INSERT INTO bot.unsubscribe (id_user) VALUES (%s) ON CONFLICT DO NOTHING",
|
|
||||||
(user_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
await _log_change(
|
|
||||||
conn,
|
|
||||||
user_id,
|
|
||||||
"subscription",
|
|
||||||
"да" if was_subscribed else "нет",
|
|
||||||
"да" if subscribed else "нет",
|
|
||||||
editor_id,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def adjust_balance(
|
|
||||||
user_id: int,
|
|
||||||
amount: int,
|
|
||||||
reason: str,
|
|
||||||
*,
|
|
||||||
editor_id: int | None = None,
|
|
||||||
) -> int | None:
|
|
||||||
"""Изменить баланс записью в money. Возвращает новый баланс или None."""
|
|
||||||
async with get_pool().connection() as conn:
|
|
||||||
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
|
||||||
if await cur.fetchone() is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cur = await conn.execute(
|
|
||||||
"SELECT coalesce(sum(amount), 0) AS balance FROM bot.money WHERE id_user = %s", (user_id,)
|
|
||||||
)
|
|
||||||
row = await cur.fetchone()
|
|
||||||
old_balance = int(row["balance"])
|
|
||||||
|
|
||||||
await conn.execute(
|
|
||||||
"INSERT INTO bot.money (id_user, amount, id_action) VALUES (%s, %s, %s)",
|
|
||||||
(user_id, amount, ADMIN_ACTION_ID),
|
|
||||||
)
|
|
||||||
new_balance = old_balance + amount
|
|
||||||
await _log_change(
|
|
||||||
conn,
|
|
||||||
user_id,
|
|
||||||
"balance",
|
|
||||||
str(old_balance),
|
|
||||||
str(new_balance),
|
|
||||||
editor_id,
|
|
||||||
comment=reason,
|
|
||||||
)
|
|
||||||
return new_balance
|
|
||||||
|
|
@ -9,7 +9,7 @@ MSG_INFO = """<b>👤Информация о пользователе {username}
|
||||||
<b>💰 Деньги:</b> {money}
|
<b>💰 Деньги:</b> {money}
|
||||||
<b>📈 Начало активности в боте:</b> {dt}
|
<b>📈 Начало активности в боте:</b> {dt}
|
||||||
<b>📊 Количество сообщений в чате:</b> {msg_count}
|
<b>📊 Количество сообщений в чате:</b> {msg_count}
|
||||||
{eggs_line}
|
<b>🥚 Количество яичек:</b> {eggs}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MSG_HELP = """<b><u>Список команд бота</u></b>
|
MSG_HELP = """<b><u>Список команд бота</u></b>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from typing import Any
|
||||||
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
|
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
|
||||||
|
|
||||||
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
|
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
|
||||||
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops", "eggs_line"})
|
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"})
|
||||||
|
|
||||||
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
||||||
TAG_REGISTRY: dict[str, str] = {
|
TAG_REGISTRY: dict[str, str] = {
|
||||||
|
|
@ -32,7 +32,6 @@ TAG_REGISTRY: dict[str, str] = {
|
||||||
"dt": "Дата начала активности",
|
"dt": "Дата начала активности",
|
||||||
"msg_count": "Количество сообщений",
|
"msg_count": "Количество сообщений",
|
||||||
"eggs": "Количество яичек",
|
"eggs": "Количество яичек",
|
||||||
"eggs_line": "Готовая строка про яички (пусто вне пасхального события)",
|
|
||||||
"h": "Часы до следующего действия",
|
"h": "Часы до следующего действия",
|
||||||
"m": "Минуты до следующего действия",
|
"m": "Минуты до следующего действия",
|
||||||
"s": "Секунды до следующего действия",
|
"s": "Секунды до следующего действия",
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"""Шаблоны сообщений из БД с TTL-кешем (Блок 4.5).
|
|
||||||
|
|
||||||
Бот читает тексты из ``bot.message_templates``: правка в админке применяется
|
|
||||||
без перезапуска — в течение ``TEMPLATE_CACHE_TTL`` секунд. Если БД недоступна
|
|
||||||
(или таблицы ещё нет), используются тексты по умолчанию из ``common.texts.TEMPLATES``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
from common.config import get_settings
|
|
||||||
from common.texts import TEMPLATES
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Загрузчик «ключ -> текст» (по умолчанию — из Postgres).
|
|
||||||
Loader = Callable[[], Awaitable[dict[str, str]]]
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_from_db() -> dict[str, str]:
|
|
||||||
# Импорт внутри функции: не тянем слой БД на этапе импорта текстов.
|
|
||||||
from common.repositories.templates_repo import load_texts
|
|
||||||
|
|
||||||
return await load_texts()
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateStore:
|
|
||||||
"""Кеш шаблонов с временем жизни; при ошибке БД — тексты по умолчанию."""
|
|
||||||
|
|
||||||
def __init__(self, loader: Loader | None = None, ttl: float | None = None) -> None:
|
|
||||||
self._loader = loader or _load_from_db
|
|
||||||
self._ttl = ttl
|
|
||||||
self._texts: dict[str, str] = {}
|
|
||||||
self._loaded_at: float | None = None
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ttl(self) -> float:
|
|
||||||
"""TTL кеша в секундах (из настроек, если не задан явно)."""
|
|
||||||
if self._ttl is None:
|
|
||||||
self._ttl = get_settings().template_cache_ttl
|
|
||||||
return max(self._ttl, 0.0)
|
|
||||||
|
|
||||||
async def get(self, key: str) -> str:
|
|
||||||
"""Вернуть текст шаблона (из БД или из кода, если его там нет)."""
|
|
||||||
await self._ensure_fresh()
|
|
||||||
text = self._texts.get(key)
|
|
||||||
if text is None:
|
|
||||||
return TEMPLATES.get(key, "")
|
|
||||||
return text
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Сбросить кеш: следующий ``get`` перечитает БД."""
|
|
||||||
self._loaded_at = None
|
|
||||||
|
|
||||||
async def _ensure_fresh(self) -> None:
|
|
||||||
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
|
|
||||||
return
|
|
||||||
|
|
||||||
async with self._lock:
|
|
||||||
# Пока ждали лок, кеш мог обновить другой вызов.
|
|
||||||
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
texts = await self._loader()
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"Не удалось прочитать шаблоны из БД — использую тексты по умолчанию",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
# Не долбим БД каждым сообщением: следующая попытка через TTL.
|
|
||||||
self._loaded_at = time.monotonic()
|
|
||||||
return
|
|
||||||
|
|
||||||
self._texts = texts
|
|
||||||
self._loaded_at = time.monotonic()
|
|
||||||
logger.debug("Шаблоны обновлены из БД: %s", len(texts))
|
|
||||||
|
|
||||||
|
|
||||||
_store: TemplateStore | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_template_store() -> TemplateStore:
|
|
||||||
"""Вернуть общий кеш шаблонов процесса."""
|
|
||||||
global _store
|
|
||||||
if _store is None:
|
|
||||||
_store = TemplateStore()
|
|
||||||
return _store
|
|
||||||
|
|
||||||
|
|
||||||
async def get_text(key: str) -> str:
|
|
||||||
"""Короткий доступ к шаблону: ``await get_text("info.help")``."""
|
|
||||||
return await get_template_store().get(key)
|
|
||||||
|
|
@ -25,7 +25,6 @@ MSG_PURCHASE_FAIL = "Не получилось купить :с"
|
||||||
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
|
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
|
||||||
|
|
||||||
MSG_EGGS_TOO_EARLY = "Слишком рано..."
|
MSG_EGGS_TOO_EARLY = "Слишком рано..."
|
||||||
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
|
|
||||||
|
|
||||||
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"
|
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.4.9"
|
__version__ = "0.3.9"
|
||||||
|
|
|
||||||
|
|
@ -62,35 +62,6 @@ services:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# Админка (FastAPI + Jinja2/HTMX), образ из Gitea Registry.
|
|
||||||
# Наружу пока не публикуется: только loopback хоста; домен/TLS — Блок 2.4/4.2.
|
|
||||||
admin:
|
|
||||||
image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecex/dispelevilbot-admin:${IMAGE_TAG:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- ${ENV_FILE:-env/prod.env}
|
|
||||||
environment:
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: "5432"
|
|
||||||
CLICKHOUSE_HOST: clickhouse
|
|
||||||
CLICKHOUSE_PORT: "8123"
|
|
||||||
ADMIN_HOST: 0.0.0.0
|
|
||||||
ADMIN_PORT: "8000"
|
|
||||||
ports:
|
|
||||||
# По умолчанию только loopback. Для NPM (контейнер) задайте ADMIN_BIND_IP=172.17.0.1:
|
|
||||||
# порт остаётся недоступен из интернета, но виден контейнерам на bridge.
|
|
||||||
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8101}:8000"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# Аналитика событий (docs/events.md). Наружу не публикуется:
|
# Аналитика событий (docs/events.md). Наружу не публикуется:
|
||||||
# только loopback хоста для бэкапа/туннеля; бот ходит внутрь сети compose.
|
# только loopback хоста для бэкапа/туннеля; бот ходит внутрь сети compose.
|
||||||
clickhouse:
|
clickhouse:
|
||||||
|
|
|
||||||
|
|
@ -67,36 +67,6 @@ services:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# Админка (FastAPI + HTMX). Порт — только loopback: наружу выйдет через reverse proxy
|
|
||||||
# вместе с TLS и доменом (Блок 2.4/4.2).
|
|
||||||
admin:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: admin/Dockerfile
|
|
||||||
image: dispelevilbot-admin:local
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: "5432"
|
|
||||||
CLICKHOUSE_HOST: clickhouse
|
|
||||||
CLICKHOUSE_PORT: "8123"
|
|
||||||
ADMIN_HOST: 0.0.0.0
|
|
||||||
ADMIN_PORT: "8000"
|
|
||||||
ports:
|
|
||||||
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-8000}:8000"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# Аналитика событий (Блок 3). DDL — в clickhouse/init/.
|
# Аналитика событий (Блок 3). DDL — в clickhouse/init/.
|
||||||
clickhouse:
|
clickhouse:
|
||||||
image: clickhouse/clickhouse-server:25.3
|
image: clickhouse/clickhouse-server:25.3
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,6 @@ REGISTRY=git.mpakosrv.ru
|
||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
# Username бота без @ (кнопка Telegram-входа в админку)
|
|
||||||
BOT_USERNAME=
|
|
||||||
# Публичный HTTPS-адрес админки (Telegram Login Widget)
|
|
||||||
ADMIN_PUBLIC_URL=https://debot.mpakosrv.ru
|
|
||||||
# Секрет подписи cookie-сессии: openssl rand -hex 32
|
|
||||||
ADMIN_SESSION_SECRET=
|
|
||||||
|
|
||||||
# Postgres (для контейнера бота host = имя сервиса compose)
|
# Postgres (для контейнера бота host = имя сервиса compose)
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
|
|
@ -45,16 +39,3 @@ CLICKHOUSE_USER=de_bot
|
||||||
CLICKHOUSE_PASSWORD=
|
CLICKHOUSE_PASSWORD=
|
||||||
# Порт на хосте (для бэкапа). У test и prod он должен различаться.
|
# Порт на хосте (для бэкапа). У test и prod он должен различаться.
|
||||||
CLICKHOUSE_HTTP_PORT=8123
|
CLICKHOUSE_HTTP_PORT=8123
|
||||||
|
|
||||||
# Админка (FastAPI). Порт на хосте; у test и prod должен различаться
|
|
||||||
# (test 8100, prod 8101 — 8000 занят Portainer).
|
|
||||||
# ADMIN_BIND_IP=127.0.0.1 — только хост; для nginx-proxy-manager поставьте 172.17.0.1.
|
|
||||||
ADMIN_HTTP_PORT=8101
|
|
||||||
ADMIN_BIND_IP=127.0.0.1
|
|
||||||
|
|
||||||
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
|
||||||
TEMPLATE_CACHE_TTL=45
|
|
||||||
|
|
||||||
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
|
|
||||||
EGG_EVENT_START=
|
|
||||||
EGG_EVENT_END=
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@ REGISTRY=git.mpakosrv.ru
|
||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
# Username бота без @ (кнопка Telegram-входа в админку)
|
|
||||||
BOT_USERNAME=
|
|
||||||
# Публичный HTTPS-адрес админки (Telegram Login Widget)
|
|
||||||
ADMIN_PUBLIC_URL=https://debot-test.mpakosrv.ru
|
|
||||||
# Секрет подписи cookie-сессии: openssl rand -hex 32
|
|
||||||
ADMIN_SESSION_SECRET=
|
|
||||||
|
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
@ -43,16 +37,3 @@ CLICKHOUSE_USER=de_bot
|
||||||
CLICKHOUSE_PASSWORD=
|
CLICKHOUSE_PASSWORD=
|
||||||
# Порт на хосте: у test он отличается от prod (8123), иначе конфликт на одной VPS.
|
# Порт на хосте: у test он отличается от prod (8123), иначе конфликт на одной VPS.
|
||||||
CLICKHOUSE_HTTP_PORT=8124
|
CLICKHOUSE_HTTP_PORT=8124
|
||||||
|
|
||||||
# Админка (FastAPI). Порт на хосте; у test и prod должен различаться
|
|
||||||
# (test 8100, prod 8101 — 8000 занят Portainer).
|
|
||||||
# ADMIN_BIND_IP=127.0.0.1 — только хост; для nginx-proxy-manager поставьте 172.17.0.1.
|
|
||||||
ADMIN_HTTP_PORT=8100
|
|
||||||
ADMIN_BIND_IP=127.0.0.1
|
|
||||||
|
|
||||||
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
|
||||||
TEMPLATE_CACHE_TTL=45
|
|
||||||
|
|
||||||
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
|
|
||||||
EGG_EVENT_START=
|
|
||||||
EGG_EVENT_END=
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# Зависимости сервиса админки (FastAPI). Бот использует requirements.txt.
|
|
||||||
# Python 3.12
|
|
||||||
|
|
||||||
fastapi>=0.115,<1.0
|
|
||||||
uvicorn[standard]>=0.30,<1.0
|
|
||||||
jinja2>=3.1,<4.0
|
|
||||||
|
|
||||||
pydantic>=2.7,<3.0
|
|
||||||
pydantic-settings>=2.3,<3.0
|
|
||||||
|
|
||||||
# Подписанные cookie-сессии (Starlette SessionMiddleware).
|
|
||||||
itsdangerous>=2.2,<3.0
|
|
||||||
|
|
||||||
# Таймзоны для проверки расписаний (в slim-образах нет системного tzdata).
|
|
||||||
tzdata>=2024.1
|
|
||||||
|
|
||||||
# Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей.
|
|
||||||
psycopg[binary,pool]>=3.2,<4.0
|
|
||||||
|
|
||||||
# Разбор form-data (HTMX-формы).
|
|
||||||
python-multipart>=0.0.9,<1.0
|
|
||||||
|
|
@ -5,5 +5,3 @@ black>=24.4,<25.0
|
||||||
pytest>=8.2,<9.0
|
pytest>=8.2,<9.0
|
||||||
pytest-asyncio>=0.23,<1.0
|
pytest-asyncio>=0.23,<1.0
|
||||||
pytest-cov>=5.0,<6.0
|
pytest-cov>=5.0,<6.0
|
||||||
# TestClient для FastAPI-админки.
|
|
||||||
httpx>=0.27,<1.0
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@ aiogram>=3.7,<4.0
|
||||||
pydantic>=2.7,<3.0
|
pydantic>=2.7,<3.0
|
||||||
pydantic-settings>=2.3,<3.0
|
pydantic-settings>=2.3,<3.0
|
||||||
python-dotenv>=1.0,<2.0
|
python-dotenv>=1.0,<2.0
|
||||||
# База таймзон для zoneinfo: в slim-образах нет системного tzdata,
|
|
||||||
# без пакета ZoneInfo("Europe/Moscow") падает (расписания и настройка TIMEZONE).
|
|
||||||
tzdata>=2024.1
|
|
||||||
psycopg[binary,pool]>=3.2,<4.0
|
psycopg[binary,pool]>=3.2,<4.0
|
||||||
|
|
||||||
# ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент.
|
# ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент.
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
-- Таблицы админки (Блок 4.1): шаблоны сообщений, история изменений и расписания.
|
|
||||||
-- Схема bot.* уже создана миграцией V1.
|
|
||||||
|
|
||||||
-- Тексты сообщений: источник правды для админки (бот подключается в 4.5).
|
|
||||||
CREATE TABLE IF NOT EXISTS bot.message_templates (
|
|
||||||
key text PRIMARY KEY,
|
|
||||||
text text NOT NULL,
|
|
||||||
description text NOT NULL DEFAULT '',
|
|
||||||
enabled boolean NOT NULL DEFAULT true,
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_by bigint
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE bot.message_templates IS 'Шаблоны сообщений бота (редактируются в админке)';
|
|
||||||
COMMENT ON COLUMN bot.message_templates.key IS 'Стабильный ключ шаблона (например, info.help)';
|
|
||||||
COMMENT ON COLUMN bot.message_templates.updated_by IS 'Telegram-ID администратора, последним изменившего текст';
|
|
||||||
|
|
||||||
-- История правок: старые значения и автор (аудит + откат).
|
|
||||||
CREATE TABLE IF NOT EXISTS bot.message_template_history (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
key text NOT NULL REFERENCES bot.message_templates (key) ON DELETE CASCADE,
|
|
||||||
old_text text,
|
|
||||||
new_text text NOT NULL,
|
|
||||||
editor_id bigint,
|
|
||||||
changed_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_template_history_key_time
|
|
||||||
ON bot.message_template_history (key, changed_at DESC);
|
|
||||||
|
|
||||||
COMMENT ON TABLE bot.message_template_history IS 'История изменений шаблонов (кто, когда, старое и новое значение)';
|
|
||||||
|
|
||||||
-- Расписания рассылок: редактирует админка, исполняет бот (4.5).
|
|
||||||
CREATE TABLE IF NOT EXISTS bot.schedules (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
template_key text NOT NULL REFERENCES bot.message_templates (key) ON DELETE RESTRICT,
|
|
||||||
hour smallint NOT NULL CHECK (hour BETWEEN 0 AND 23),
|
|
||||||
minute smallint NOT NULL CHECK (minute BETWEEN 0 AND 59),
|
|
||||||
weekdays smallint[] NOT NULL DEFAULT '{}',
|
|
||||||
timezone text NOT NULL DEFAULT 'Europe/Moscow',
|
|
||||||
enabled boolean NOT NULL DEFAULT true,
|
|
||||||
description text NOT NULL DEFAULT '',
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_by bigint
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE bot.schedules IS 'Расписания рассылок (исполняет бот, редактирует админка)';
|
|
||||||
COMMENT ON COLUMN bot.schedules.weekdays IS 'Дни недели 1..7 (ISO, 1 = понедельник); пусто — каждый день';
|
|
||||||
|
|
||||||
-- История изменений расписаний: снимок строки до и после правки.
|
|
||||||
-- FK с SET NULL: история должна пережить удаление самого расписания.
|
|
||||||
CREATE TABLE IF NOT EXISTS bot.schedules_history (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
schedule_id bigint REFERENCES bot.schedules (id) ON DELETE SET NULL,
|
|
||||||
old_data jsonb,
|
|
||||||
new_data jsonb,
|
|
||||||
editor_id bigint,
|
|
||||||
changed_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedules_history_schedule_time
|
|
||||||
ON bot.schedules_history (schedule_id, changed_at DESC);
|
|
||||||
|
|
||||||
COMMENT ON TABLE bot.schedules_history IS 'История изменений расписаний (аудит)';
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
-- Стартовые тексты сообщений (сид для message_templates).
|
|
||||||
-- Сгенерировано из common.texts.TEMPLATES; содержимое сверяет tests/test_seed_templates.py.
|
|
||||||
INSERT INTO bot.message_templates (key, text, description)
|
|
||||||
VALUES
|
|
||||||
('welcome.text', $tpl$💠{username}, добро пожаловать в Dispel Evil!
|
|
||||||
❗️Правила клана
|
|
||||||
|
|
||||||
<i>Незнание не освобождает от ответственности.
|
|
||||||
|
|
||||||
1. <u><b>Конфликты/разборки</b></u>: сами решаете между собой в лс. Админ имеет право дать мут участникам конфликта до его решения, потом пишите в лс, что решили вопрос и мут снимут. <b>Если замечены одни и те же люди в конфликте три раза - вас исключат из клана.</b>
|
|
||||||
|
|
||||||
2. <u><b>Слушать администрацию</b></u>: если кто-то из админов просит закрыть тему - вы её закрываете, если вам делают замечание - не агрессировать и отвечать спокойно. Со стороны админов не будет претензий на пустом месте.
|
|
||||||
|
|
||||||
3. <u><b>Маты в меру</b></u>. Если используйте маты в игре - на вашей совести, по правилам игры они запрещены, за последствия отвечать будете сами.</i> $tpl$, 'Приветствие, часть 1'),
|
|
||||||
('welcome.text2', $tpl$<i>4. <u><b>Сомнительные темы</b></u>: не рекомендуем заводить разговоры про политику/национальность/религию и другие спорные темы создающие конфликты. В противном случае вам могут выдать мут.
|
|
||||||
|
|
||||||
5. <u><b>Места на благо</b></u> - становитесь на любое свободное место, не выделяйте «своё личное место». <u><b>В съёмочный день оставьте 3е место для админа</b></u>. </i>
|
|
||||||
|
|
||||||
<u>Уважительная просьба</u>: если вам не приятен человек, решайте вопросы в лс. Пожалуйста, не выливайте это в общие чаты, чтобы сохранить приятную атмосферу для остальных.
|
|
||||||
|
|
||||||
<b>💠Ниже кнопка на наш основной канал со всеми новостями, конкурсами и темы для блага.</b>$tpl$, 'Приветствие, часть 2'),
|
|
||||||
('welcome.text3', $tpl$❗️<b>По игре</b>:
|
|
||||||
|
|
||||||
💠Очки командного боя 20000+;
|
|
||||||
💠Вклад 1000+;
|
|
||||||
💠АФК 3 дня без предупреждения = Кик;
|
|
||||||
💠Чистка от бездельников по воскресеньям;
|
|
||||||
|
|
||||||
🕹Если появляются вопросы, Вы всегда можете написать админам: @Sad_Ju, @yaki_ra, @irinka87364
|
|
||||||
|
|
||||||
💡Также мы всегда ждём людей с новыми идеями и предложениями по развитию клана!
|
|
||||||
|
|
||||||
💠https://t.me/c/1800439573/397
|
|
||||||
В комментариях под постом, мы ждём твоего сообщения! Там же ты можешь посмотреть анкеты других участников и найти новых друзей!$tpl$, 'Приветствие, часть 3'),
|
|
||||||
('info.profile', $tpl$<b>👤Информация о пользователе {username} </b>
|
|
||||||
|
|
||||||
<b>✅ Ник в игре:</b> {nick}
|
|
||||||
<b>🏆 Звание:</b> {title}
|
|
||||||
<b>🧩 Уровень:</b> {lvl}
|
|
||||||
<b>🥪 Опыт:</b> {exp}/{exp_max}
|
|
||||||
<b>💰 Деньги:</b> {money}
|
|
||||||
<b>📈 Начало активности в боте:</b> {dt}
|
|
||||||
<b>📊 Количество сообщений в чате:</b> {msg_count}
|
|
||||||
{eggs_line}
|
|
||||||
$tpl$, 'Профиль (/инфо)'),
|
|
||||||
('info.help', $tpl$<b><u>Список команд бота</u></b>
|
|
||||||
|
|
||||||
<b>/ник <i>НИК</i></b> - указать свой ник в игре <u>[чат, бот]</u>
|
|
||||||
<b>/список</b> - вывести список ников <u>[бот]</u>
|
|
||||||
<b>/кто</b> - вывод ника человека в ответ на сообщение <u>[чат]</u>
|
|
||||||
<b>/инфо</b> - вывод информации о себе <u>[чат, бот]</u>
|
|
||||||
<b>/очищение</b> - получение бонусов раз в сутки <u>[чат, бот]</u>
|
|
||||||
<b>/магазин</b> - магазин бота <u>[бот]</u>
|
|
||||||
<b>/рейтинг</b> - топ-10 игроков по сообщениям <u>[чат, бот]</u>
|
|
||||||
<b>/отписка</b> - отписаться от рассылок <u>[чат, бот]</u>
|
|
||||||
<b>/подписка</b> - подписаться на рассылки <u>[чат, бот]</u>
|
|
||||||
$tpl$, 'Справка (/помощь)'),
|
|
||||||
('shop.main', $tpl$Разделы магазина$tpl$, 'Магазин: главное меню'),
|
|
||||||
('shop.title', $tpl$Доступные звания для покупки$tpl$, 'Магазин: звания'),
|
|
||||||
('shop.donat', $tpl$Доступные донаты для покупки$tpl$, 'Магазин: донаты'),
|
|
||||||
('economy.nick_list', $tpl$<b><u>📑 Список игроков[{users_list_count}] 📑</u></b>
|
|
||||||
|
|
||||||
{users_list}$tpl$, 'Список игроков (/список)'),
|
|
||||||
('economy.top', $tpl$<u><b>⭐️ТОП10 общительных участников⭐️</b></u>:
|
|
||||||
{tops}$tpl$, 'Топ по сообщениям (/рейтинг)'),
|
|
||||||
('economy.purification', $tpl$<b>💠Успешное очищение!💠</b>
|
|
||||||
<b>Получено:</b> 💰{money_add} DE и 🥪{exp_add} exp!$tpl$, 'Успешное очищение'),
|
|
||||||
('economy.no_purification', $tpl$Очищение возможно не чаще раза в сутки!
|
|
||||||
Возвращайся через {h}ч {m}м {s}с$tpl$, 'Очищение ещё недоступно'),
|
|
||||||
('economy.eggs', $tpl$<b>💠Держи свои яички!💠</b>
|
|
||||||
<b>Получено</b> {eggs}🥚!$tpl$, 'Получение яичек'),
|
|
||||||
('economy.no_eggs', $tpl$Получать яички возможно не чаще раза в сутки!
|
|
||||||
Возвращайся через {h}ч {m}м {s}с$tpl$, 'Яички ещё недоступны'),
|
|
||||||
('notify.blago_30', $tpl$ ⏰ <b>Просыпаемся, 30 минут до блага!</b> ⏰
|
|
||||||
{usernames}$tpl$, 'Рассылка за 30 минут'),
|
|
||||||
('notify.blago_45', $tpl$ 🔔 <b>15 минут до блага, ждём всех на базе!</b> 🔔
|
|
||||||
{usernames}$tpl$, 'Рассылка за 15 минут'),
|
|
||||||
('notify.blago_55', $tpl$ ☀️ <b>5 МИНУТ ДО БЛАГА! НАРЯЖАЕМ ПОПКИ!</b> ☀️
|
|
||||||
{usernames}$tpl$, 'Рассылка за 5 минут'),
|
|
||||||
('notify.blago', $tpl$💃 <b>АКТИВНО ТРЯСЁМ ПОПКАМИ НА БАЗЕ!</b>🕺
|
|
||||||
{usernames}$tpl$, 'Начало благо-часа'),
|
|
||||||
('notify.vklad', $tpl$<b>💰 НЕ ЗАБЫВАЕМ ПРОВЕРИТЬ ВКЛАД 💰</b>
|
|
||||||
{usernames}$tpl$, 'Напоминание о вкладе')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- Стартовые расписания рассылок: переносят текущее поведение из кода бота.
|
|
||||||
-- Дни недели — ISO (1 = понедельник … 7 = воскресенье), пусто — каждый день.
|
|
||||||
-- Время — в таймзоне расписания (Europe/Moscow).
|
|
||||||
|
|
||||||
INSERT INTO bot.schedules (template_key, hour, minute, weekdays, timezone, description)
|
|
||||||
VALUES
|
|
||||||
('notify.blago_30', 16, 30, '{}', 'Europe/Moscow', 'Благо: за 30 минут'),
|
|
||||||
('notify.blago_45', 16, 45, '{}', 'Europe/Moscow', 'Благо: за 15 минут'),
|
|
||||||
('notify.blago_55', 16, 55, '{}', 'Europe/Moscow', 'Благо: за 5 минут'),
|
|
||||||
('notify.blago', 17, 0, '{}', 'Europe/Moscow', 'Благо: начало'),
|
|
||||||
('notify.vklad', 15, 0, '{5}', 'Europe/Moscow', 'Вклад: пятница'),
|
|
||||||
('notify.vklad', 14, 0, '{7}', 'Europe/Moscow', 'Вклад: воскресенье');
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
-- Администраторы веб-панели (Блок 4.2): вход через Telegram Login Widget.
|
|
||||||
-- Первых админов засевает приложение из ADMIN_IDS (идемпотентно при старте).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bot.admin_users (
|
|
||||||
telegram_id bigint PRIMARY KEY,
|
|
||||||
username text,
|
|
||||||
role text NOT NULL DEFAULT 'admin' CHECK (role IN ('admin', 'moderator')),
|
|
||||||
enabled boolean NOT NULL DEFAULT true,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
last_login_at timestamptz
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE bot.admin_users IS 'Администраторы веб-панели (доступ через Telegram-логин)';
|
|
||||||
COMMENT ON COLUMN bot.admin_users.role IS 'admin — полный доступ; moderator — задел на ограниченные права';
|
|
||||||
COMMENT ON COLUMN bot.admin_users.last_login_at IS 'Время последнего успешного входа';
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-- История ручных изменений пользователей из админки (Блок 4.3).
|
|
||||||
CREATE TABLE IF NOT EXISTS bot.user_changes (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
id_user bigint NOT NULL,
|
|
||||||
field text NOT NULL CHECK (field IN ('nick', 'title', 'subscription', 'balance')),
|
|
||||||
old_value text,
|
|
||||||
new_value text,
|
|
||||||
comment text,
|
|
||||||
editor_id bigint,
|
|
||||||
changed_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_changes_user_time
|
|
||||||
ON bot.user_changes (id_user, changed_at DESC);
|
|
||||||
|
|
||||||
COMMENT ON TABLE bot.user_changes IS 'Аудит ручных изменений пользователей администратором';
|
|
||||||
COMMENT ON COLUMN bot.user_changes.field IS 'Что изменено: nick, title, subscription или balance';
|
|
||||||
|
|
||||||
-- Отдельное действие для ручных корректировок баланса (видно в истории операций).
|
|
||||||
INSERT INTO bot.actions (id_action, title)
|
|
||||||
VALUES (6, 'Корректировка админом')
|
|
||||||
ON CONFLICT (id_action) DO NOTHING;
|
|
||||||
|
|
@ -1,124 +1,20 @@
|
||||||
"""Общие приспособления тестов админки: Telegram-вход и CSRF-токен."""
|
"""Общие фикстуры тестов."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
from collections.abc import Callable
|
||||||
import hmac
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from admin.app import create_app
|
from common.config import Settings
|
||||||
from common.config import AdminSettings
|
|
||||||
from common.repositories import admins_repo
|
|
||||||
|
|
||||||
BOT_TOKEN = "123:TEST"
|
|
||||||
SESSION_SECRET = "test-secret"
|
|
||||||
ADMIN: dict[str, Any] = {
|
|
||||||
"telegram_id": 1,
|
|
||||||
"username": "admin",
|
|
||||||
"role": "admin",
|
|
||||||
"enabled": True,
|
|
||||||
"created_at": None,
|
|
||||||
"last_login_at": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
_CSRF_RE = re.compile(r'name="csrf_token" value="([^"]+)"')
|
|
||||||
|
|
||||||
|
|
||||||
def admin_settings(**overrides: Any) -> AdminSettings:
|
|
||||||
values: dict[str, Any] = {
|
|
||||||
"_env_file": None,
|
|
||||||
"bot_token": BOT_TOKEN,
|
|
||||||
"bot_username": "debot_test_bot",
|
|
||||||
"admin_public_url": "https://debot-test.mpakosrv.ru",
|
|
||||||
"admin_session_secret": SESSION_SECRET,
|
|
||||||
"db_password": "test",
|
|
||||||
"app_env": "dev",
|
|
||||||
}
|
|
||||||
values.update(overrides)
|
|
||||||
return AdminSettings(**values)
|
|
||||||
|
|
||||||
|
|
||||||
def telegram_params(
|
|
||||||
*,
|
|
||||||
token: str = BOT_TOKEN,
|
|
||||||
telegram_id: int = 1,
|
|
||||||
auth_date: int | None = None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Собрать query как от Telegram Login Widget (с валидной подписью)."""
|
|
||||||
data = {
|
|
||||||
"id": str(telegram_id),
|
|
||||||
"first_name": "Admin",
|
|
||||||
"username": "admin",
|
|
||||||
"auth_date": str(auth_date if auth_date is not None else int(time.time())),
|
|
||||||
}
|
|
||||||
check_string = "\n".join(f"{key}={value}" for key, value in sorted(data.items()))
|
|
||||||
secret = hashlib.sha256(token.encode()).digest()
|
|
||||||
data["hash"] = hmac.new(secret, check_string.encode(), hashlib.sha256).hexdigest()
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_from(html: str) -> str:
|
|
||||||
"""Достать CSRF-токен из HTML формы."""
|
|
||||||
match = _CSRF_RE.search(html)
|
|
||||||
assert match, "в странице нет csrf_token"
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def bot_token() -> str:
|
def settings_factory() -> Callable[..., Settings]:
|
||||||
"""Токен тестового бота (подпись Telegram-логина)."""
|
"""Фабрика ``Settings`` с обязательным ``BOT_TOKEN`` и без чтения ``.env``."""
|
||||||
return BOT_TOKEN
|
|
||||||
|
|
||||||
|
def _factory(**overrides: Any) -> Settings:
|
||||||
|
return Settings(_env_file=None, bot_token="1:a", **overrides)
|
||||||
|
|
||||||
@pytest.fixture
|
return _factory
|
||||||
def csrf_token():
|
|
||||||
"""Функция: достать CSRF-токен из HTML."""
|
|
||||||
return csrf_from
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def telegram_query():
|
|
||||||
"""Функция: собрать подписанный query Telegram-логина."""
|
|
||||||
return telegram_params
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def settings_factory():
|
|
||||||
"""Фабрика настроек админки для тестов."""
|
|
||||||
return admin_settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client_factory(monkeypatch: pytest.MonkeyPatch):
|
|
||||||
"""Фабрика TestClient: подменяет проверку администратора и умеет логиниться."""
|
|
||||||
|
|
||||||
async def fake_get_admin(telegram_id: int) -> dict[str, Any] | None:
|
|
||||||
return ADMIN if int(telegram_id) == ADMIN["telegram_id"] else None
|
|
||||||
|
|
||||||
async def fake_touch_login(telegram_id: int) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(admins_repo, "get_admin", fake_get_admin)
|
|
||||||
monkeypatch.setattr(admins_repo, "touch_login", fake_touch_login)
|
|
||||||
|
|
||||||
def make(
|
|
||||||
*,
|
|
||||||
authenticated: bool = True,
|
|
||||||
follow_redirects: bool = True,
|
|
||||||
settings: AdminSettings | None = None,
|
|
||||||
) -> TestClient:
|
|
||||||
client = TestClient(
|
|
||||||
create_app(settings or admin_settings(), init_db=False),
|
|
||||||
follow_redirects=follow_redirects,
|
|
||||||
)
|
|
||||||
if authenticated:
|
|
||||||
response = client.get("/auth/telegram", params=telegram_params())
|
|
||||||
assert response.status_code in {200, 303}
|
|
||||||
return client
|
|
||||||
|
|
||||||
return make
|
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
"""Тесты админки: каркас, раздел «Сообщения» (без БД — репозиторий подменяется)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from common.repositories import templates_repo
|
|
||||||
|
|
||||||
TEMPLATE: dict[str, Any] = {
|
|
||||||
"key": "info.help",
|
|
||||||
"text": "Справка по командам",
|
|
||||||
"description": "Справка (/помощь)",
|
|
||||||
"enabled": True,
|
|
||||||
"updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
|
||||||
"updated_by": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_template(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fake_get(key: str) -> dict[str, Any] | None:
|
|
||||||
return TEMPLATE if key == TEMPLATE["key"] else None
|
|
||||||
|
|
||||||
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(templates_repo, "get_template", fake_get)
|
|
||||||
monkeypatch.setattr(templates_repo, "template_history", fake_history)
|
|
||||||
|
|
||||||
|
|
||||||
def test_healthz(client_factory) -> None:
|
|
||||||
response = client_factory(authenticated=False).get("/healthz")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["status"] == "ok"
|
|
||||||
assert payload["app_env"] == "dev"
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_renders_sections(client_factory) -> None:
|
|
||||||
response = client_factory().get("/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Панель администрирования" in response.text
|
|
||||||
assert "Расписания" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_messages_list(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fake_list() -> list[dict[str, Any]]:
|
|
||||||
return [{**TEMPLATE, "text_length": len(TEMPLATE["text"])}]
|
|
||||||
|
|
||||||
monkeypatch.setattr(templates_repo, "list_templates", fake_list)
|
|
||||||
|
|
||||||
response = client_factory().get("/messages")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "info.help" in response.text
|
|
||||||
assert "Справка (/помощь)" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_message_editor_and_history(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_patch_template(monkeypatch)
|
|
||||||
|
|
||||||
response = client_factory().get("/messages/info.help")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Справка по командам" in response.text
|
|
||||||
assert "Доступные теги" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_message_editor_404(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fake_get(key: str) -> dict[str, Any] | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(templates_repo, "get_template", fake_get)
|
|
||||||
|
|
||||||
assert client_factory().get("/messages/unknown").status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_renders_tags(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
|
||||||
_patch_template(monkeypatch)
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/info.help").text)
|
|
||||||
|
|
||||||
response = client.post("/messages/preview", data={"text": "Привет, {nick}!", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "ТестовыйНик" in response.text
|
|
||||||
assert "<iframe" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_reports_unknown_tag(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
|
||||||
_patch_template(monkeypatch)
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/info.help").text)
|
|
||||||
|
|
||||||
response = client.post("/messages/preview", data={"text": "Привет, {unknown_tag}!", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Неизвестные теги" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_requires_csrf(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_patch_template(monkeypatch)
|
|
||||||
|
|
||||||
response = client_factory().post("/messages/preview", data={"text": "Привет"})
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_rejects_unknown_tag(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
|
||||||
_patch_template(monkeypatch)
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/info.help").text)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/messages/info.help",
|
|
||||||
data={"text": "текст {unknown_tag}", "csrf_token": token},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Неизвестные теги" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_updates_template(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
|
||||||
saved: dict[str, Any] = {}
|
|
||||||
|
|
||||||
async def fake_update(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any]:
|
|
||||||
saved.update(key=key, text=text, editor_id=editor_id)
|
|
||||||
return {"key": key, "text": text, "old_text": TEMPLATE["text"], "changed": True}
|
|
||||||
|
|
||||||
_patch_template(monkeypatch)
|
|
||||||
monkeypatch.setattr(templates_repo, "update_template", fake_update)
|
|
||||||
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/messages/info.help").text)
|
|
||||||
response = client.post("/messages/info.help", data={"text": "Новая справка", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Текст сохранён" in response.text
|
|
||||||
assert saved == {"key": "info.help", "text": "Новая справка", "editor_id": 1}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"""Тесты раздела «Логи» (без ClickHouse — чтение подменяется)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from common.analytics import read as analytics_read
|
|
||||||
|
|
||||||
EVENT: dict[str, Any] = {
|
|
||||||
"event_time": datetime(2026, 10, 18, 17, 0, 0), # UTC, без tz — как отдаёт clickhouse-connect
|
|
||||||
"event_type": "command",
|
|
||||||
"user_id": 42,
|
|
||||||
"username": "test_user",
|
|
||||||
"chat_id": -100123,
|
|
||||||
"payload": '{"command": "инфо", "args_count": 0}',
|
|
||||||
"bot_version": "0.4.8",
|
|
||||||
"prod": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_read(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|
||||||
state: dict[str, Any] = {"total": 1}
|
|
||||||
|
|
||||||
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
|
|
||||||
state["kwargs"] = kwargs
|
|
||||||
return [EVENT], state["total"]
|
|
||||||
|
|
||||||
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def test_logs_page(fake_read: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().get("/logs")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "command" in response.text
|
|
||||||
assert "@test_user" in response.text
|
|
||||||
assert "инфо" in response.text
|
|
||||||
assert "20:00:00" in response.text # 17:00 UTC -> 20:00 МСК
|
|
||||||
|
|
||||||
|
|
||||||
def test_logs_filters_passed(fake_read: dict[str, Any], client_factory) -> None:
|
|
||||||
client_factory().get(
|
|
||||||
"/logs",
|
|
||||||
params={
|
|
||||||
"event_type": "command",
|
|
||||||
"user": "42",
|
|
||||||
"date_from": "2026-10-01",
|
|
||||||
"date_to": "2026-10-18",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert fake_read["kwargs"] == {
|
|
||||||
"event_type": "command",
|
|
||||||
"user": "42",
|
|
||||||
"date_from": "2026-10-01",
|
|
||||||
"date_to": "2026-10-18",
|
|
||||||
"limit": 50,
|
|
||||||
"offset": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_logs_pagination(fake_read: dict[str, Any], client_factory) -> None:
|
|
||||||
fake_read["total"] = 120
|
|
||||||
|
|
||||||
response = client_factory().get("/logs", params={"page": "2"})
|
|
||||||
|
|
||||||
assert "Страница 2 из 3" in response.text
|
|
||||||
assert fake_read["kwargs"]["offset"] == 50
|
|
||||||
|
|
||||||
|
|
||||||
def test_logs_clickhouse_error(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
|
|
||||||
raise RuntimeError("ClickHouse недоступен")
|
|
||||||
|
|
||||||
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
|
|
||||||
|
|
||||||
response = client_factory().get("/logs")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "ClickHouse недоступен" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_logs_bad_date(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
async def fetch_events(settings: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], int]:
|
|
||||||
raise ValueError("bad date")
|
|
||||||
|
|
||||||
monkeypatch.setattr(analytics_read, "fetch_events", fetch_events)
|
|
||||||
|
|
||||||
response = client_factory().get("/logs", params={"date_from": "18.10.2026"})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "формате ГГГГ-ММ-ДД" in response.text
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
"""Тесты раздела «Расписания» (без БД — репозитории подменяются)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from common.repositories import schedules_repo, templates_repo
|
|
||||||
|
|
||||||
TEMPLATE: dict[str, Any] = {
|
|
||||||
"key": "notify.blago",
|
|
||||||
"text": "Благо через 5 минут {usernames}",
|
|
||||||
"description": "Благо: начало",
|
|
||||||
"enabled": True,
|
|
||||||
"updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
|
||||||
"updated_by": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
SCHEDULE: dict[str, Any] = {
|
|
||||||
"id": 1,
|
|
||||||
"template_key": "notify.blago",
|
|
||||||
"template_description": "Благо: начало",
|
|
||||||
"hour": 17,
|
|
||||||
"minute": 0,
|
|
||||||
"weekdays": [1, 5],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"enabled": True,
|
|
||||||
"description": "Благо",
|
|
||||||
"updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
|
||||||
"updated_by": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_repos(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|
||||||
state: dict[str, Any] = {}
|
|
||||||
|
|
||||||
async def list_schedules() -> list[dict[str, Any]]:
|
|
||||||
return [SCHEDULE]
|
|
||||||
|
|
||||||
async def get_schedule(schedule_id: int) -> dict[str, Any] | None:
|
|
||||||
return SCHEDULE if schedule_id == 1 else None
|
|
||||||
|
|
||||||
async def schedule_history(schedule_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def create_schedule(data: dict[str, Any], *, editor_id: int | None = None) -> int:
|
|
||||||
state["created"] = data
|
|
||||||
state["editor_id"] = editor_id
|
|
||||||
return 2
|
|
||||||
|
|
||||||
async def update_schedule(schedule_id: int, data: dict[str, Any], *, editor_id: int | None = None) -> bool:
|
|
||||||
state["updated"] = (schedule_id, data)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def delete_schedule(schedule_id: int, *, editor_id: int | None = None) -> bool:
|
|
||||||
state["deleted"] = schedule_id
|
|
||||||
return schedule_id == 1
|
|
||||||
|
|
||||||
async def set_enabled(schedule_id: int, enabled: bool, *, editor_id: int | None = None) -> bool:
|
|
||||||
state["enabled"] = (schedule_id, enabled)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def list_templates() -> list[dict[str, Any]]:
|
|
||||||
return [TEMPLATE]
|
|
||||||
|
|
||||||
async def get_template(key: str) -> dict[str, Any] | None:
|
|
||||||
return TEMPLATE if key == TEMPLATE["key"] else None
|
|
||||||
|
|
||||||
monkeypatch.setattr(schedules_repo, "list_schedules", list_schedules)
|
|
||||||
monkeypatch.setattr(schedules_repo, "get_schedule", get_schedule)
|
|
||||||
monkeypatch.setattr(schedules_repo, "schedule_history", schedule_history)
|
|
||||||
monkeypatch.setattr(schedules_repo, "create_schedule", create_schedule)
|
|
||||||
monkeypatch.setattr(schedules_repo, "update_schedule", update_schedule)
|
|
||||||
monkeypatch.setattr(schedules_repo, "delete_schedule", delete_schedule)
|
|
||||||
monkeypatch.setattr(schedules_repo, "set_enabled", set_enabled)
|
|
||||||
monkeypatch.setattr(templates_repo, "list_templates", list_templates)
|
|
||||||
monkeypatch.setattr(templates_repo, "get_template", get_template)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def test_schedules_list(fake_repos: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().get("/schedules")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "17:00" in response.text
|
|
||||||
assert "Пн, Пт" in response.text
|
|
||||||
assert "включено" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_schedule_form(fake_repos: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().get("/schedules/new")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Новое расписание" in response.text
|
|
||||||
assert "notify.blago" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_schedule(fake_repos: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/schedules/new").text)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/schedules",
|
|
||||||
data={
|
|
||||||
"template_key": "notify.blago",
|
|
||||||
"hour": "17",
|
|
||||||
"minute": "0",
|
|
||||||
"weekdays": ["1", "5"],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"description": "Благо",
|
|
||||||
"enabled": "on",
|
|
||||||
"csrf_token": token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert fake_repos["created"] == {
|
|
||||||
"template_key": "notify.blago",
|
|
||||||
"hour": 17,
|
|
||||||
"minute": 0,
|
|
||||||
"weekdays": [1, 5],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"enabled": True,
|
|
||||||
"description": "Благо",
|
|
||||||
}
|
|
||||||
assert fake_repos["editor_id"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_schedule_rejects_bad_hour(fake_repos: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/schedules/new").text)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/schedules",
|
|
||||||
data={
|
|
||||||
"template_key": "notify.blago",
|
|
||||||
"hour": "25",
|
|
||||||
"minute": "0",
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"csrf_token": token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Час должен быть от 0 до 23" in response.text
|
|
||||||
assert "created" not in fake_repos
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_schedule_rejects_unknown_template(fake_repos: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/schedules/new").text)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/schedules",
|
|
||||||
data={
|
|
||||||
"template_key": "unknown.key",
|
|
||||||
"hour": "17",
|
|
||||||
"minute": "0",
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"csrf_token": token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Шаблон не найден" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_requires_csrf(fake_repos: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().post(
|
|
||||||
"/schedules",
|
|
||||||
data={"template_key": "notify.blago", "hour": "17", "minute": "0", "timezone": "Europe/Moscow"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_edit_schedule(fake_repos: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().get("/schedules/1")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Расписание #1" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_edit_missing_schedule_404(fake_repos: dict[str, Any], client_factory) -> None:
|
|
||||||
assert client_factory().get("/schedules/99").status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_schedule(fake_repos: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/schedules/1").text)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/schedules/1",
|
|
||||||
data={
|
|
||||||
"template_key": "notify.blago",
|
|
||||||
"hour": "18",
|
|
||||||
"minute": "30",
|
|
||||||
"weekdays": [],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"description": "",
|
|
||||||
"enabled": "on",
|
|
||||||
"csrf_token": token,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
schedule_id, data = fake_repos["updated"]
|
|
||||||
assert schedule_id == 1
|
|
||||||
assert data["hour"] == 18
|
|
||||||
assert data["minute"] == 30
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_schedule(fake_repos: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/schedules").text)
|
|
||||||
|
|
||||||
client.post("/schedules/1/toggle", data={"csrf_token": token})
|
|
||||||
|
|
||||||
assert fake_repos["enabled"] == (1, False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_schedule(fake_repos: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/schedules").text)
|
|
||||||
|
|
||||||
client.post("/schedules/1/delete", data={"csrf_token": token})
|
|
||||||
|
|
||||||
assert fake_repos["deleted"] == 1
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
"""Тесты раздела «Пользователи»: список, карточка и управление (без БД)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, date, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from common.repositories import users_admin_repo
|
|
||||||
|
|
||||||
USER: dict[str, Any] = {
|
|
||||||
"id_user": 42,
|
|
||||||
"username": "test_user",
|
|
||||||
"first_name": "Test",
|
|
||||||
"last_name": None,
|
|
||||||
"nick": "ТестовыйНик",
|
|
||||||
"lvl": 3,
|
|
||||||
"expr": 120,
|
|
||||||
"expr_streak": 2,
|
|
||||||
"dt_create": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
|
||||||
"id_title": 1,
|
|
||||||
"title": "Ветеран",
|
|
||||||
"msg_count": 42,
|
|
||||||
"last_message_at": datetime(2026, 10, 18, 20, 0, tzinfo=UTC),
|
|
||||||
"balance": 1500,
|
|
||||||
"eggs": 2,
|
|
||||||
"purification_count": 5,
|
|
||||||
"last_purification_at": datetime(2026, 10, 17, 16, 0, tzinfo=UTC),
|
|
||||||
"subscribed": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
MONEY: list[dict[str, Any]] = [
|
|
||||||
{"id_money": 2, "amount": 50, "dt_action": datetime(2026, 10, 18, 16, 0, tzinfo=UTC), "action": "Очищение"},
|
|
||||||
{"id_money": 1, "amount": -20, "dt_action": datetime(2026, 10, 17, 12, 0, tzinfo=UTC), "action": "Покупка"},
|
|
||||||
]
|
|
||||||
|
|
||||||
TITLES: list[dict[str, Any]] = [
|
|
||||||
{"id_title": 1, "title": "Ветеран"},
|
|
||||||
{"id_title": 2, "title": "Новичок"},
|
|
||||||
]
|
|
||||||
|
|
||||||
HISTORY: list[dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"field": "nick",
|
|
||||||
"old_value": "Старый",
|
|
||||||
"new_value": "ТестовыйНик",
|
|
||||||
"comment": None,
|
|
||||||
"editor_id": 1,
|
|
||||||
"changed_at": datetime(2026, 10, 18, 12, 0, tzinfo=UTC),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|
||||||
state: dict[str, Any] = {"total": 1, "calls": []}
|
|
||||||
|
|
||||||
async def count_users(search: str = "") -> int:
|
|
||||||
state["search"] = search
|
|
||||||
return state["total"]
|
|
||||||
|
|
||||||
async def list_users(search: str = "", *, limit: int = 25, offset: int = 0) -> list[dict[str, Any]]:
|
|
||||||
state["calls"].append({"search": search, "limit": limit, "offset": offset})
|
|
||||||
return [USER]
|
|
||||||
|
|
||||||
async def get_user(user_id: int) -> dict[str, Any] | None:
|
|
||||||
return USER if user_id == USER["id_user"] else None
|
|
||||||
|
|
||||||
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
return MONEY if user_id == USER["id_user"] else []
|
|
||||||
|
|
||||||
async def list_titles() -> list[dict[str, Any]]:
|
|
||||||
return TITLES
|
|
||||||
|
|
||||||
async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
|
||||||
return HISTORY if user_id == USER["id_user"] else []
|
|
||||||
|
|
||||||
async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool:
|
|
||||||
state["nick"] = (user_id, nick, editor_id)
|
|
||||||
return user_id == USER["id_user"]
|
|
||||||
|
|
||||||
async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool:
|
|
||||||
state["title"] = (user_id, title_id, editor_id)
|
|
||||||
return user_id == USER["id_user"]
|
|
||||||
|
|
||||||
async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool:
|
|
||||||
state["subscription"] = (user_id, subscribed, editor_id)
|
|
||||||
return user_id == USER["id_user"]
|
|
||||||
|
|
||||||
async def adjust_balance(
|
|
||||||
user_id: int,
|
|
||||||
amount: int,
|
|
||||||
reason: str,
|
|
||||||
*,
|
|
||||||
editor_id: int | None = None,
|
|
||||||
) -> int | None:
|
|
||||||
state["balance"] = (user_id, amount, reason, editor_id)
|
|
||||||
return USER["balance"] + amount if user_id == USER["id_user"] else None
|
|
||||||
|
|
||||||
monkeypatch.setattr(users_admin_repo, "count_users", count_users)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "list_users", list_users)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "get_user", get_user)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "money_history", money_history)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "list_titles", list_titles)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "user_history", user_history)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "set_nick", set_nick)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "set_title", set_title)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "set_subscription", set_subscription)
|
|
||||||
monkeypatch.setattr(users_admin_repo, "adjust_balance", adjust_balance)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def _csrf(client: TestClient, csrf_token) -> str:
|
|
||||||
return csrf_token(client.get("/users/42").text)
|
|
||||||
|
|
||||||
|
|
||||||
def test_users_list(fake_repo: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().get("/users")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "ТестовыйНик" in response.text
|
|
||||||
assert "@test_user" in response.text
|
|
||||||
assert "1500" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_users_search_passed_to_repo(fake_repo: dict[str, Any], client_factory) -> None:
|
|
||||||
client_factory().get("/users", params={"q": "Тест"})
|
|
||||||
|
|
||||||
assert fake_repo["calls"][0]["search"] == "Тест"
|
|
||||||
|
|
||||||
|
|
||||||
def test_users_pagination(fake_repo: dict[str, Any], client_factory) -> None:
|
|
||||||
fake_repo["total"] = 30
|
|
||||||
|
|
||||||
response = client_factory().get("/users", params={"page": "2"})
|
|
||||||
|
|
||||||
assert "Страница 2 из 2" in response.text
|
|
||||||
assert fake_repo["calls"][0]["offset"] == 25
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_card(fake_repo: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().get("/users/42")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Ветеран" in response.text
|
|
||||||
assert "Управление" in response.text
|
|
||||||
assert "Очищение" in response.text
|
|
||||||
assert "+50" in response.text
|
|
||||||
assert "-20" in response.text
|
|
||||||
assert "Старый → ТестовыйНик" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_card_404(fake_repo: dict[str, Any], client_factory) -> None:
|
|
||||||
assert client_factory().get("/users/99").status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_eggs_hidden_outside_event(fake_repo: dict[str, Any], client_factory) -> None:
|
|
||||||
response = client_factory().get("/users/42")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Яички" not in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_eggs_visible_during_event(fake_repo: dict[str, Any], client_factory, settings_factory) -> None:
|
|
||||||
today = date.today()
|
|
||||||
client = client_factory(settings=settings_factory(egg_event_start=today, egg_event_end=today))
|
|
||||||
|
|
||||||
assert "Яички" in client.get("/users/42").text
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_nick(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post("/users/42/nick", data={"nick": "НовыйНик", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Ник обновлён" in response.text
|
|
||||||
assert fake_repo["nick"] == (42, "НовыйНик", 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_nick_rejects_empty(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post("/users/42/nick", data={"nick": " ", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Ник не может быть пустым" in response.text
|
|
||||||
assert "nick" not in fake_repo
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_title(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post("/users/42/title", data={"title_id": "2", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Звание обновлено" in response.text
|
|
||||||
assert fake_repo["title"] == (42, 2, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_title_rejects_unknown(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post("/users/42/title", data={"title_id": "999", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Звание не найдено" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_subscription(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post("/users/42/subscription", data={"subscribed": "0", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Подписка обновлена" in response.text
|
|
||||||
assert fake_repo["subscription"] == (42, False, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_balance(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/users/42/balance",
|
|
||||||
data={"amount": "-100", "reason": "Штраф", "csrf_token": token},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Баланс скорректирован" in response.text
|
|
||||||
assert fake_repo["balance"] == (42, -100, "Штраф", 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_balance_requires_reason(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post("/users/42/balance", data={"amount": "100", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Укажите причину" in response.text
|
|
||||||
assert "balance" not in fake_repo
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_balance_rejects_zero(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = _csrf(client, csrf_token)
|
|
||||||
|
|
||||||
response = client.post("/users/42/balance", data={"amount": "0", "reason": "Тест", "csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Сумма не может быть нулевой" in response.text
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
"""Тесты входа в админку через Telegram: подпись, сессия и CSRF."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from admin.security import verify_telegram_auth
|
|
||||||
|
|
||||||
|
|
||||||
def test_healthz_is_public(client_factory) -> None:
|
|
||||||
assert client_factory(authenticated=False).get("/healthz").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_protected_page_redirects_to_login(client_factory) -> None:
|
|
||||||
response = client_factory(authenticated=False, follow_redirects=False).get("/")
|
|
||||||
|
|
||||||
assert response.status_code == 303
|
|
||||||
assert response.headers["location"].startswith("/login")
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_page_has_widget(client_factory) -> None:
|
|
||||||
response = client_factory(authenticated=False).get("/login")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "telegram-widget.js" in response.text
|
|
||||||
assert "debot_test_bot" in response.text
|
|
||||||
assert "https://debot-test.mpakosrv.ru/auth/telegram" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_page_without_config(client_factory, settings_factory) -> None:
|
|
||||||
client = client_factory(
|
|
||||||
authenticated=False,
|
|
||||||
settings=settings_factory(bot_token="", bot_username=""),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/login")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Вход не настроен" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_with_valid_signature(client_factory, telegram_query) -> None:
|
|
||||||
client: TestClient = client_factory(authenticated=False, follow_redirects=False)
|
|
||||||
|
|
||||||
response = client.get("/auth/telegram", params=telegram_query())
|
|
||||||
|
|
||||||
assert response.status_code == 303
|
|
||||||
assert response.headers["location"] == "/"
|
|
||||||
assert client.get("/").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_rejects_bad_hash(client_factory, telegram_query) -> None:
|
|
||||||
params = telegram_query()
|
|
||||||
params["hash"] = "0" * 64
|
|
||||||
|
|
||||||
response = client_factory(authenticated=False).get("/auth/telegram", params=params)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "Не удалось проверить вход" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_rejects_stale_auth_date(client_factory, telegram_query) -> None:
|
|
||||||
params = telegram_query(auth_date=int(time.time()) - 90_000)
|
|
||||||
|
|
||||||
response = client_factory(authenticated=False).get("/auth/telegram", params=params)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_rejects_non_admin(client_factory, telegram_query) -> None:
|
|
||||||
response = client_factory(authenticated=False).get("/auth/telegram", params=telegram_query(telegram_id=2))
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "нет доступа" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_logout_requires_csrf(client_factory) -> None:
|
|
||||||
response = client_factory().post("/logout")
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_logout_clears_session(client_factory, csrf_token) -> None:
|
|
||||||
client: TestClient = client_factory()
|
|
||||||
token = csrf_token(client.get("/").text)
|
|
||||||
|
|
||||||
response = client.post("/logout", data={"csrf_token": token})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert client.get("/", follow_redirects=False).status_code == 303
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_signature(bot_token: str) -> None:
|
|
||||||
"""Юнит-проверка подписи без HTTP (данные собираем тем же алгоритмом)."""
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
|
|
||||||
data = {"id": "1", "first_name": "Admin", "auth_date": str(int(time.time()))}
|
|
||||||
check = "\n".join(f"{key}={value}" for key, value in sorted(data.items()))
|
|
||||||
secret = hashlib.sha256(bot_token.encode()).digest()
|
|
||||||
data["hash"] = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
assert verify_telegram_auth(data, bot_token)
|
|
||||||
assert not verify_telegram_auth({**data, "id": "2"}, bot_token)
|
|
||||||
assert not verify_telegram_auth(data, "999:OTHER")
|
|
||||||
assert not verify_telegram_auth(data, bot_token, now=time.time() + 90_000)
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
"""Тесты сезонности «яичек»: Пасха, окно события и переопределение дат."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from common.config import Settings
|
|
||||||
from common.eggs import egg_event_period, is_egg_event_active, orthodox_easter
|
|
||||||
|
|
||||||
|
|
||||||
def _settings(**env: str) -> Settings:
|
|
||||||
return Settings(_env_file=None, **{"BOT_TOKEN": "1:a", **env})
|
|
||||||
|
|
||||||
|
|
||||||
def test_orthodox_easter_known_dates() -> None:
|
|
||||||
assert orthodox_easter(2024) == date(2024, 5, 5)
|
|
||||||
assert orthodox_easter(2025) == date(2025, 4, 20)
|
|
||||||
assert orthodox_easter(2026) == date(2026, 4, 12)
|
|
||||||
assert orthodox_easter(2027) == date(2027, 5, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_period_is_april_first_to_easter() -> None:
|
|
||||||
settings = _settings()
|
|
||||||
|
|
||||||
assert egg_event_period(settings, date(2026, 2, 1)) == (date(2026, 4, 1), date(2026, 4, 12))
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_boundaries() -> None:
|
|
||||||
settings = _settings()
|
|
||||||
|
|
||||||
assert not is_egg_event_active(settings, date(2026, 3, 31))
|
|
||||||
assert is_egg_event_active(settings, date(2026, 4, 1))
|
|
||||||
assert is_egg_event_active(settings, date(2026, 4, 12))
|
|
||||||
assert not is_egg_event_active(settings, date(2026, 4, 13))
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_dates_can_be_overridden() -> None:
|
|
||||||
settings = _settings(EGG_EVENT_START="2026-04-05", EGG_EVENT_END="2026-04-10")
|
|
||||||
|
|
||||||
assert egg_event_period(settings, date(2026, 4, 1)) == (date(2026, 4, 5), date(2026, 4, 10))
|
|
||||||
assert not is_egg_event_active(settings, date(2026, 4, 4))
|
|
||||||
assert is_egg_event_active(settings, date(2026, 4, 6))
|
|
||||||
assert not is_egg_event_active(settings, date(2026, 4, 11))
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_dates_mean_auto() -> None:
|
|
||||||
settings = _settings(EGG_EVENT_START="", EGG_EVENT_END="")
|
|
||||||
|
|
||||||
assert settings.egg_event_start is None
|
|
||||||
assert settings.egg_event_end is None
|
|
||||||
assert is_egg_event_active(settings, date(2026, 4, 2))
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
"""Тесты расписаний бота: совпадение времени, таймзоны и сид V16."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bot.services.notifications import DEFAULT_SCHEDULES, ScheduledMessage, _from_row
|
|
||||||
|
|
||||||
SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V16__seed_schedules.sql"
|
|
||||||
_ROW_RE = re.compile(r"\('([^']+)',\s*(\d+),\s*(\d+),\s*'\{([^}]*)\}',\s*'([^']+)'")
|
|
||||||
|
|
||||||
|
|
||||||
def test_matches_daily_in_schedule_timezone() -> None:
|
|
||||||
schedule = ScheduledMessage(16, 30, "notify.blago_30")
|
|
||||||
|
|
||||||
# 13:30 UTC = 16:30 МСК
|
|
||||||
assert schedule.matches(datetime(2026, 10, 19, 13, 30, tzinfo=UTC))
|
|
||||||
assert not schedule.matches(datetime(2026, 10, 19, 13, 31, tzinfo=UTC))
|
|
||||||
|
|
||||||
|
|
||||||
def test_matches_respects_weekdays_iso() -> None:
|
|
||||||
friday = ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,))
|
|
||||||
|
|
||||||
# 2026-10-16 — пятница, 12:00 UTC = 15:00 МСК
|
|
||||||
assert friday.matches(datetime(2026, 10, 16, 12, 0, tzinfo=UTC))
|
|
||||||
# 2026-10-15 — четверг
|
|
||||||
assert not friday.matches(datetime(2026, 10, 15, 12, 0, tzinfo=UTC))
|
|
||||||
|
|
||||||
|
|
||||||
def test_matches_uses_schedule_timezone() -> None:
|
|
||||||
berlin = ScheduledMessage(16, 30, "notify.blago_30", timezone="Europe/Berlin")
|
|
||||||
|
|
||||||
# Зимой Берлин = UTC+1: 15:30 UTC = 16:30
|
|
||||||
assert berlin.matches(datetime(2026, 1, 15, 15, 30, tzinfo=UTC))
|
|
||||||
|
|
||||||
|
|
||||||
def test_from_row_converts_fields() -> None:
|
|
||||||
schedule = _from_row(
|
|
||||||
{
|
|
||||||
"hour": 14,
|
|
||||||
"minute": 5,
|
|
||||||
"template_key": "notify.vklad",
|
|
||||||
"weekdays": [7],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert schedule == ScheduledMessage(14, 5, "notify.vklad", weekdays=(7,), timezone="Europe/Moscow")
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_schedules_match_seed() -> None:
|
|
||||||
"""Расписание-фолбэк в коде не должно расходиться с сидом V16."""
|
|
||||||
seeded = []
|
|
||||||
for key, hour, minute, days, timezone in _ROW_RE.findall(SEED_FILE.read_text(encoding="utf-8")):
|
|
||||||
weekdays = tuple(int(day) for day in days.split(",") if day.strip())
|
|
||||||
seeded.append(
|
|
||||||
ScheduledMessage(
|
|
||||||
hour=int(hour),
|
|
||||||
minute=int(minute),
|
|
||||||
template_key=key,
|
|
||||||
weekdays=weekdays,
|
|
||||||
timezone=timezone,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert seeded == list(DEFAULT_SCHEDULES)
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"""Тесты разбора формы расписаний (без HTTP и БД)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from admin.forms import format_weekdays, parse_schedule_fields
|
|
||||||
|
|
||||||
|
|
||||||
def _parse(**overrides: object) -> tuple[dict | None, list[str]]:
|
|
||||||
fields: dict[str, object] = {
|
|
||||||
"template_key": "notify.blago",
|
|
||||||
"hour": "17",
|
|
||||||
"minute": "0",
|
|
||||||
"weekdays": [],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"description": "Благо",
|
|
||||||
"enabled": True,
|
|
||||||
}
|
|
||||||
fields.update(overrides)
|
|
||||||
return parse_schedule_fields(**fields) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_weekdays() -> None:
|
|
||||||
assert format_weekdays([]) == "каждый день"
|
|
||||||
assert format_weekdays((5, 1, 3, 3)) == "Пн, Ср, Пт"
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_valid_daily_schedule() -> None:
|
|
||||||
data, errors = _parse()
|
|
||||||
|
|
||||||
assert errors == []
|
|
||||||
assert data == {
|
|
||||||
"template_key": "notify.blago",
|
|
||||||
"hour": 17,
|
|
||||||
"minute": 0,
|
|
||||||
"weekdays": [],
|
|
||||||
"timezone": "Europe/Moscow",
|
|
||||||
"enabled": True,
|
|
||||||
"description": "Благо",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_weekdays_are_sorted_and_deduplicated() -> None:
|
|
||||||
data, errors = _parse(weekdays=["5", "1", "5"])
|
|
||||||
|
|
||||||
assert errors == []
|
|
||||||
assert data is not None
|
|
||||||
assert data["weekdays"] == [1, 5]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_default_timezone() -> None:
|
|
||||||
data, errors = _parse(timezone="")
|
|
||||||
|
|
||||||
assert errors == []
|
|
||||||
assert data is not None
|
|
||||||
assert data["timezone"] == "Europe/Moscow"
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rejects_bad_hour() -> None:
|
|
||||||
data, errors = _parse(hour="25")
|
|
||||||
|
|
||||||
assert data is None
|
|
||||||
assert "Час должен быть от 0 до 23" in errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rejects_non_numeric_minute() -> None:
|
|
||||||
data, errors = _parse(minute="abc")
|
|
||||||
|
|
||||||
assert data is None
|
|
||||||
assert "Минуты должны быть числом" in errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rejects_bad_weekday() -> None:
|
|
||||||
data, errors = _parse(weekdays=["9"])
|
|
||||||
|
|
||||||
assert data is None
|
|
||||||
assert any("День недели" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rejects_unknown_timezone() -> None:
|
|
||||||
data, errors = _parse(timezone="Mars/Olympus")
|
|
||||||
|
|
||||||
assert data is None
|
|
||||||
assert any("таймзона" in error for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rejects_empty_template() -> None:
|
|
||||||
data, errors = _parse(template_key="")
|
|
||||||
|
|
||||||
assert data is None
|
|
||||||
assert "Выберите шаблон сообщения" in errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rejects_long_description() -> None:
|
|
||||||
data, errors = _parse(description="x" * 201)
|
|
||||||
|
|
||||||
assert data is None
|
|
||||||
assert any("Описание длиннее" in error for error in errors)
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"""Тесты сида шаблонов и контекста предпросмотра (без БД)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from admin.preview import SAMPLE_CONTEXT
|
|
||||||
from common.texts import TEMPLATES
|
|
||||||
from common.texts.render import SUPPORTED_TAGS, validate
|
|
||||||
|
|
||||||
SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V15__seed_message_templates.sql"
|
|
||||||
_ROW_RE = re.compile(r"\('([^']+)', \$tpl\$(.*?)\$tpl\$", re.DOTALL)
|
|
||||||
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_matches_code_templates() -> None:
|
|
||||||
"""Миграция-сид не должна расходиться с реестром TEMPLATES."""
|
|
||||||
seeded = dict(_ROW_RE.findall(SEED_FILE.read_text(encoding="utf-8")))
|
|
||||||
|
|
||||||
assert seeded == TEMPLATES
|
|
||||||
|
|
||||||
|
|
||||||
def test_templates_have_known_tags_only() -> None:
|
|
||||||
for key, text in TEMPLATES.items():
|
|
||||||
assert validate(text) == [], f"неизвестные теги в {key}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_context_covers_all_tags() -> None:
|
|
||||||
"""В предпросмотре у каждого тега должно быть тестовое значение."""
|
|
||||||
assert set(SAMPLE_CONTEXT) == set(SUPPORTED_TAGS)
|
|
||||||
|
|
||||||
used: set[str] = set()
|
|
||||||
for text in TEMPLATES.values():
|
|
||||||
used.update(_TAG_RE.findall(text))
|
|
||||||
assert used <= set(SAMPLE_CONTEXT)
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"""Тесты TTL-кеша шаблонов из БД (Блок 4.5): кеш, фолбэк и ошибки БД."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from common.texts import TEMPLATES
|
|
||||||
from common.texts.store import TemplateStore
|
|
||||||
|
|
||||||
|
|
||||||
async def test_uses_db_values() -> None:
|
|
||||||
async def loader() -> dict[str, str]:
|
|
||||||
return {"info.help": "текст из БД"}
|
|
||||||
|
|
||||||
store = TemplateStore(loader=loader, ttl=60)
|
|
||||||
|
|
||||||
assert await store.get("info.help") == "текст из БД"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_falls_back_to_code_for_missing_key() -> None:
|
|
||||||
async def loader() -> dict[str, str]:
|
|
||||||
return {"info.help": "текст из БД"}
|
|
||||||
|
|
||||||
store = TemplateStore(loader=loader, ttl=60)
|
|
||||||
|
|
||||||
assert await store.get("economy.top") == TEMPLATES["economy.top"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_falls_back_when_db_unavailable() -> None:
|
|
||||||
async def loader() -> dict[str, str]:
|
|
||||||
raise RuntimeError("БД недоступна")
|
|
||||||
|
|
||||||
store = TemplateStore(loader=loader, ttl=60)
|
|
||||||
|
|
||||||
assert await store.get("info.help") == TEMPLATES["info.help"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_caches_within_ttl() -> None:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def loader() -> dict[str, str]:
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
return {"info.help": f"v{calls}"}
|
|
||||||
|
|
||||||
store = TemplateStore(loader=loader, ttl=60)
|
|
||||||
|
|
||||||
assert await store.get("info.help") == "v1"
|
|
||||||
assert await store.get("info.help") == "v1"
|
|
||||||
assert calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reloads_when_cache_expired() -> None:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def loader() -> dict[str, str]:
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
return {"info.help": f"v{calls}"}
|
|
||||||
|
|
||||||
store = TemplateStore(loader=loader, ttl=0)
|
|
||||||
|
|
||||||
assert await store.get("info.help") == "v1"
|
|
||||||
assert await store.get("info.help") == "v2"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_clear_forces_reload() -> None:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def loader() -> dict[str, str]:
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
return {"info.help": f"v{calls}"}
|
|
||||||
|
|
||||||
store = TemplateStore(loader=loader, ttl=600)
|
|
||||||
assert await store.get("info.help") == "v1"
|
|
||||||
|
|
||||||
store.clear()
|
|
||||||
|
|
||||||
assert await store.get("info.help") == "v2"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_load_is_not_retried_on_every_call() -> None:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def loader() -> dict[str, str]:
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
raise RuntimeError("БД недоступна")
|
|
||||||
|
|
||||||
store = TemplateStore(loader=loader, ttl=60)
|
|
||||||
|
|
||||||
assert await store.get("info.help") == TEMPLATES["info.help"]
|
|
||||||
assert await store.get("info.help") == TEMPLATES["info.help"]
|
|
||||||
assert calls == 1
|
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.4.9"
|
assert __version__ == "0.3.9"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue