Compare commits

..

10 Commits

Author SHA1 Message Date
MpakobecEx 6b15d8ae04 Яички снова стали пасхальным событием, а не круглогодичной валютой
CI / lint-test (push) Failing after 11s Details
CI / build-push (push) Has been skipped Details
Окно теперь считается само: с 1 апреля по православную Пасху (в 2026 году — 12 апреля) вместо зашитых дат 2025 года, из-за которых команда не работала вовсе. Вне окна /яички отвечает «рано» или «событие завершилось», а строка про яички не показывается ни в /инфо, ни в карточке админки. Если понадобится другое окно, даты фиксируются через EGG_EVENT_START/EGG_EVENT_END.
2026-10-19 04:03:22 +05:00
MpakobecEx 107a79d05e Логи стали видны из админки, а не только через SQL
Раздел «Логи» читает analytics.events из ClickHouse: фильтры по типу события, пользователю (ID или username) и датам, по 50 строк на страницу, время в часовом поясе админки. Payload раскрывается как JSON, ID ведёт в карточку игрока. Чтение идёт отдельным ленивым клиентом: если ClickHouse лежит, страница сообщает об этом и не роняет админку. Боту для этого ничего не меняли.
2026-10-19 04:00:30 +05:00
MpakobecEx 92791f1ba9 Админ может править игроков, не заходя в базу
В карточке появились ник, звание, подписка на рассылки и корректировка баланса: баланс меняется обычной записью в журнал money с действием «Корректировка админом», поэтому /инфо сразу показывает новую сумму. Все четыре действия пишутся в bot.user_changes — кто, когда, было → стало и причина; история видна в той же карточке. Бот читает эти таблицы напрямую, так что изменения действуют без перезапуска.
2026-10-19 03:58:25 +05:00
MpakobecEx 82fa21da3d В админке появился список игроков с поиском и карточкой
Искать можно по нику, username, имени и Telegram-ID; в карточке видно уровень, опыт с серией, баланс (SUM(money.amount)), яички, сообщения, очищения, подписку и последние операции по балансу. Страница пока только читает PostgreSQL: правку ника, звания и подписки включим следующим шагом, когда посмотрим на реальные данные.
2026-10-19 03:56:08 +05:00
MpakobecEx f345844dfe Админка закрылась входом через Telegram
Подпись Telegram-логина проверяется токеном бота, а доступ есть только у записей в bot.admin_users: роль перечитывается из БД на каждом запросе, поэтому выключение пользователя сразу закрывает доступ. Сессия — подписанная HttpOnly-cookie на 12 часов, все формы получили CSRF-токен, первые администраторы засеваются из ADMIN_IDS при старте. Домены в BotFather уже заданы, но NPM и публикацию не включаем — снаружи админка по-прежнему только на loopback.
2026-10-19 03:53:23 +05:00
MpakobecEx d695f1991f У админки теперь постоянные порты, которые не спорят с Portainer
Тестовый контур слушает 8100, прод — 8101 на хосте VPS (8000 занят Portainer). Адрес привязки вынесен в ADMIN_BIND_IP: по умолчанию только loopback, а для nginx-proxy-manager ставится 172.17.0.1, чтобы контейнер NPM мог достучаться до админки, но порт не оказался в интернете. Примеры окружений и DEPLOY обновлены.
2026-10-19 03:46:24 +05:00
MpakobecEx 8fb7fe5dd9 Расписания рассылок переехали из кода в админку
В админке появился раздел «Расписания»: время, дни недели, таймзона, шаблон и вкл/выкл, с историей изменений и удалением. Бот раз в минуту читает активные строки из БД, поэтому правки применяются без перезапуска; при сбое базы он переживает его на последнем прочитанном наборе или на расписании из кода. Стартовые рассылки перенесены миграцией V16, так что поведение не меняется. Заодно в зависимости добавлен tzdata: в slim-образах без него падает ZoneInfo на Europe/Moscow.
2026-10-19 03:41:07 +05:00
MpakobecEx 1e4e905f24 Бот берёт тексты из админки, не дожидаясь перезапуска
Шаблоны из message_templates читаются через кеш на 45 секунд (TEMPLATE_CACHE_TTL), поэтому правка в админке доезжает до следующего сообщения или рассылки сама. Если база недоступна или таблицы ещё нет, бот работает на текстах из кода, а неудачный запрос не повторяется на каждом сообщении — админка не может уронить бота. Расписания рассылок теперь ссылаются на ключи шаблонов, а не на строки, так что тексты рассылок тоже редактируются.
2026-10-19 03:37:58 +05:00
MpakobecEx 6e3209a339 Админка научилась показывать и править тексты сообщений
Появились таблицы шаблонов, истории правок и расписаний: тексты по умолчанию переехали в миграцию-сид, а раздел «Сообщения» показывает список, даёт отредактировать текст с проверкой тегов и предпросмотром через тот же рендерер, что у бота. Каждое сохранение пишет старую и новую версию в историю — это аудит и заготовка для отката. Бот пока читает тексты из кода: переключение на базу — следующий шаг (4.5).
2026-10-19 03:34:41 +05:00
MpakobecEx b11676764e У админки появился собственный сервис, а не только место в плане
Каркас FastAPI живёт в admin/ и собирается отдельным образом: главная страница со списком будущих разделов и /healthz для healthcheck, пока без БД и логина. Сервис добавлен в оба compose (порт только на loopback до появления домена), CI теперь собирает и публикует два образа — бота и админку. Настройки админки наследуют общие, но не требуют BOT_TOKEN: сервис запускается, даже пока токен не заполнен.
2026-10-19 03:34:10 +05:00
74 changed files with 4938 additions and 80 deletions

View File

@ -2,6 +2,10 @@
.gitignore
.gitattributes
# секреты локального окружения (в образ не попадают)
.env
.env.*
# venv / кеши
.venv
.venv11

View File

@ -5,6 +5,12 @@ APP_ENV=dev
# Токен бота (получается у @BotFather)
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
DB_HOST=localhost
@ -42,3 +48,16 @@ CLICKHOUSE_PASSWORD=
ANALYTICS_BATCH_SIZE=200
ANALYTICS_FLUSH_INTERVAL=5.0
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

View File

@ -13,7 +13,8 @@ concurrency:
env:
REGISTRY: git.mpakosrv.ru
IMAGE_NAME: mpakobecex/dispelevilbot-bot
IMAGE_NAME_BOT: mpakobecex/dispelevilbot-bot
IMAGE_NAME_ADMIN: mpakobecex/dispelevilbot-admin
jobs:
lint-test:
@ -40,16 +41,22 @@ jobs:
- uses: actions/checkout@v4
- name: Логин в Gitea Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Сборка и публикация образа
- name: Сборка и публикация образов
run: |
set -e
IMAGE="$REGISTRY/$IMAGE_NAME"
SHA="${GITHUB_SHA:-unknown}"
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
SHORT_SHA="$(echo "${GITHUB_SHA:-unknown}" | cut -c1-12)"
VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)"
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
if [ -n "$VERSION" ]; then TAGS="$TAGS $IMAGE:$VERSION"; fi
ARGS=""
for t in $TAGS; do ARGS="$ARGS -t $t"; done
docker build $ARGS .
for t in $TAGS; do docker push "$t"; done
build_push() {
image="$1"
dockerfile="$2"
tags="$image:$SHORT_SHA $image:latest"
if [ -n "$VERSION" ]; then tags="$tags $image:$VERSION"; fi
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

View File

@ -17,10 +17,15 @@ cd DispelEvilBot
cp env/test.env.example env/test.env
# заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.),
# ADMIN_IDS (опц.), CLICKHOUSE_PASSWORD (аналитика событий)
# плюс для админки: BOT_USERNAME, ADMIN_PUBLIC_URL (HTTPS-домен),
# ADMIN_SESSION_SECRET (openssl rand -hex 32)
```
Порт ClickHouse на хосте (`CLICKHOUSE_HTTP_PORT`) у контуров должен различаться:
`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 (если пакет приватный)
@ -52,7 +57,8 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml logs -
2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля;
3. `clickhouse` поднимается и применяет `clickhouse/init/001_events.sql` —
**только при первой инициализации** пустого data-каталога;
4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же.
4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же;
5. `admin` стартует и отвечает на `GET /healthz` (порт на loopback хоста).
Проверка контура (сервисы, миграции, Postgres, ClickHouse):
@ -79,6 +85,40 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml exec -
Изменения схемы: `clickhouse/init/*.sql` применяются лишь на пустом data-каталоге,
поэтому на существующем контуре 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 ограниченное время: раз в сутки домашний сервер **сам забирает**

View File

@ -8,6 +8,7 @@ Telegram-бот клана Dispel Evil (aiogram 3 + Postgres). Монорепо
```
common/ # общий пакет: config, logging, db, repositories, models, texts, analytics
bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications
admin/ # сервис админки: FastAPI + Jinja2/HTMX (Блок 4), свой Dockerfile
sql/ # миграции Flyway (V__*.sql, R__*.sql)
clickhouse/ # init-скрипты ClickHouse (схема аналитики)
docs/ # документация (docs/events.md — события аналитики)
@ -43,11 +44,46 @@ APP_ENV=prod python -m bot # prod
или через `./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).
При 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 в единую таблицу `analytics.events`.
@ -66,13 +102,18 @@ APP_ENV=prod python -m bot # prod
## Docker
Локальный запуск (postgres + migrate + clickhouse + bot):
Локальный запуск (postgres + migrate + clickhouse + bot + admin):
```bash
cp .env.example .env # заполнить
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-файлом:
```bash
@ -104,7 +145,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.9`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.9`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
@ -133,6 +174,14 @@ git tag -a vX.Y -m "Release vX.Y"
git push origin master --tags
```
## Пасхальное событие «Яички»
`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху**
(в 2026 году — 12 апреля). Вне окна бот отвечает, что ещё рано, а строка про яички
не показывается ни в `/инфо`, ни в карточке пользователя в админке.
Даты можно зафиксировать переменными `EGG_EVENT_START`/`EGG_EVENT_END` (ISO,
например `2027-04-01` и `2027-05-02`).
## Команды бота
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,

29
admin/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
# 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"]

4
admin/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""Сервис админки DispelEvilBot (FastAPI + HTMX).
Отдельный процесс/образ; общий код — пакет ``common``. Запуск: ``python -m admin``.
"""

31
admin/__main__.py Normal file
View File

@ -0,0 +1,31 @@
"""Точка входа админки: 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 Normal file
View File

@ -0,0 +1,94 @@
"""Создание 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

98
admin/forms.py Normal file
View File

@ -0,0 +1,98 @@
"""Проверка и разбор форм админки (чистые функции — без 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(),
},
[],
)

47
admin/middleware.py Normal file
View File

@ -0,0 +1,47 @@
"""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)

38
admin/preview.py Normal file
View File

@ -0,0 +1,38 @@
"""Предпросмотр шаблонов: тестовые значения для всех тегов.
Контекст общий для админки и тестов: он должен покрывать ``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
admin/routes/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Роутеры админки."""

98
admin/routes/auth.py Normal file
View File

@ -0,0 +1,98 @@
"""Вход в админку через 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)

16
admin/routes/health.py Normal file
View File

@ -0,0 +1,16 @@
"""Служебные эндпоинты: 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}

118
admin/routes/logs.py Normal file
View File

@ -0,0 +1,118 @@
"""Раздел «Логи»: события из 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,
},
)

132
admin/routes/messages.py Normal file
View File

@ -0,0 +1,132 @@
"""Раздел «Сообщения»: список шаблонов, редактор и предпросмотр (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),
)

50
admin/routes/pages.py Normal file
View File

@ -0,0 +1,50 @@
"""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,
},
)

204
admin/routes/schedules.py Normal file
View File

@ -0,0 +1,204 @@
"""Раздел «Расписания»: 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)

183
admin/routes/users.py Normal file
View File

@ -0,0 +1,183 @@
"""Раздел «Пользователи»: список с поиском, карточка и управление."""
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)

69
admin/security.py Normal file
View File

@ -0,0 +1,69 @@
"""Безопасность админки: проверка 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 "/"

View File

@ -0,0 +1,13 @@
{% 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 %}

View File

@ -0,0 +1,28 @@
{% 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 %}

38
admin/templates/base.html Normal file
View File

@ -0,0 +1,38 @@
<!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>

View File

@ -0,0 +1,25 @@
{% 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 %}

View File

@ -0,0 +1,133 @@
{% 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 %}

View File

@ -0,0 +1,102 @@
{% 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 %}

View File

@ -0,0 +1,45 @@
{% 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 %}

View File

@ -0,0 +1,143 @@
{% 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 %}

View File

@ -0,0 +1,71 @@
{% 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 %}

View File

@ -0,0 +1,238 @@
{% 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 %}

View File

@ -0,0 +1,86 @@
{% 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 %}

View File

@ -12,8 +12,10 @@ from aiogram.types import Message
from bot.services import access, stats, subscriptions
from bot.services import users as users_service
from common.config import get_settings
from common.texts import economy, info, system
from common.texts.render import format_tops, format_user_list, render
from common.eggs import is_egg_event_active
from common.texts import system
from common.texts.render import escape, format_tops, format_user_list, render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="commands")
@ -61,7 +63,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_count": len(nicks),
}
await message.answer(render(economy.MSG_NICK_LIST, context))
await message.answer(render(await get_text("economy.nick_list"), context))
@router.message(Command("кто", ignore_case=True))
@ -83,7 +85,7 @@ async def get_nick_handler(message: Message) -> None:
async def help_handler(message: Message) -> None:
if not await access.check_user(message):
return
await message.answer(info.MSG_HELP)
await message.answer(await get_text("info.help"))
@router.message(Command("инфо", ignore_case=True))
@ -107,8 +109,11 @@ async def info_handler(message: Message) -> None:
"dt": user_info.dt,
"msg_count": user_info.msg_count,
"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(info.MSG_INFO, context))
await message.answer(render(await get_text("info.profile"), context))
@router.message(Command("рейтинг", ignore_case=True))
@ -117,7 +122,7 @@ async def top_handler(message: Message) -> None:
return
tops = await stats.top_users()
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
await message.answer(render(economy.MSG_TOP, context))
await message.answer(render(await get_text("economy.top"), context))
@router.message(Command("отписка", ignore_case=True))

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import random
from datetime import date, datetime
from datetime import datetime
from aiogram import Router
from aiogram.filters import Command
@ -12,15 +12,13 @@ from aiogram.types import Message
from bot.services import access, alerts
from bot.services import economy as economy_service
from common.config import get_settings
from common.texts import economy, system
from common.eggs import egg_event_period, is_egg_event_active
from common.texts import system
from common.texts.render import render
from common.texts.store import get_text
router = Router(name="economy")
#: Окно сезонного события «яички».
EGG_EVENT_START = date(2025, 4, 1)
EGG_EVENT_END = date(2025, 4, 20)
#: Шанс выпадения монетки на сообщение.
COIN_CHANCE = 27 # из 100
@ -33,10 +31,10 @@ async def purification_handler(message: Message) -> None:
result = await economy_service.purify(message.from_user.id)
if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(economy.MSG_NO_PURIFICATION, context))
await message.answer(render(await get_text("economy.no_purification"), context))
else:
context = {"money_add": result.money_add, "exp_add": result.exp_add}
await message.answer(render(economy.MSG_PURIFICATION, context))
await message.answer(render(await get_text("economy.purification"), context))
await alerts.notify_purification(
message.bot,
username=message.from_user.username,
@ -47,9 +45,11 @@ async def purification_handler(message: Message) -> None:
@router.message(Command("яички", ignore_case=True))
async def eggs_handler(message: Message) -> None:
today = datetime.now(get_settings().tzinfo).date()
if not (EGG_EVENT_START <= today <= EGG_EVENT_END):
await message.answer(system.MSG_EGGS_TOO_EARLY)
settings = get_settings()
if not is_egg_event_active(settings):
today = datetime.now(settings.tzinfo).date()
start, _ = egg_event_period(settings, today)
await message.answer(system.MSG_EGGS_TOO_EARLY if today < start else system.MSG_EGGS_EVENT_OVER)
return
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)
if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
await message.answer(render(economy.MSG_NO_EGGS, context))
await message.answer(render(await get_text("economy.no_eggs"), context))
else:
await message.answer(render(economy.MSG_EGGS, {"eggs": result.eggs}))
await message.answer(render(await get_text("economy.eggs"), {"eggs": result.eggs}))
@router.message()

View File

@ -15,6 +15,7 @@ from common.analytics import EventType, record
from common.config import get_settings
from common.texts import system, welcome
from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="member_events")
@ -54,14 +55,14 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
await event.bot.send_photo(
chat_id=event.chat.id,
photo=FSInputFile("images/welcome_image.jpg"),
caption=render(welcome.MSG_WELCOME_TEXT, {"username": user.username}),
caption=render(await get_text("welcome.text"), {"username": user.username}),
)
await event.bot.send_message(
chat_id=event.chat.id,
text=welcome.MSG_WELCOME_TEXT2,
text=await get_text("welcome.text2"),
reply_markup=builder.as_markup(),
)
await event.bot.send_message(chat_id=event.chat.id, text=welcome.MSG_WELCOME_TEXT3)
await event.bot.send_message(chat_id=event.chat.id, text=await get_text("welcome.text3"))
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))

View File

@ -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.services import access
from bot.services import shop as shop_service
from common.texts import shop as shop_texts
from common.texts import system
from common.texts.render import render
from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="shop")
@ -26,16 +26,16 @@ async def shop_handler(message: Message) -> None:
if message.chat.type != "private":
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
return
await message.answer(shop_texts.MSG_SHOP_MAIN, reply_markup=get_keyboard_main())
await message.answer(await get_text("shop.main"), reply_markup=get_keyboard_main())
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
if chapter == "main":
return shop_texts.MSG_SHOP_MAIN, get_keyboard_main()
return await get_text("shop.main"), get_keyboard_main()
if chapter == "title":
return shop_texts.MSG_SHOP_TITLE, await get_keyboard_title()
return await get_text("shop.title"), await get_keyboard_title()
if chapter == "donat":
return shop_texts.MSG_SHOP_DONAT, await get_keyboard_donat()
return await get_text("shop.donat"), await get_keyboard_donat()
return None

View File

@ -1,57 +1,103 @@
"""Планировщик рассылок.
Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
редактироваться через админку.
Расписания читаются из БД (таблица ``bot.schedules``, редактируется в админке).
Если база недоступна, используется расписание по умолчанию из кода и последний
успешно прочитанный набор. Тексты берутся из ``bot.message_templates`` через
``common.texts.store`` (TTL-кеш, правки без перезапуска).
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
import time
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from aiogram import Bot
from bot.services import users as users_service
from common.config import get_settings
from common.repositories import schedules_repo
from common.texts import format_usernames, render
from common.texts import notifications as notify_texts
from common.texts.store import get_text
logger = logging.getLogger(__name__)
#: Размер порции упоминаний в одном сообщении.
BATCH_SIZE = 10
#: Как часто писать в лог, если БД с расписаниями недоступна (секунды).
WARNING_INTERVAL = 300.0
@dataclass(frozen=True, slots=True)
class ScheduledMessage:
"""Одна запланированная рассылка.
"""Одна запланированная рассылка из БД (или из значений по умолчанию).
:param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно.
:param weekdays: дни недели по ISO (1=Пн … 7=Вс). Пусто — ежедневно.
:param timezone: таймзона расписания (IANA), время в ней.
"""
hour: int
minute: int
template: str
weekdays: tuple[int, ...] | None = field(default=None)
template_key: str
weekdays: tuple[int, ...] = ()
timezone: str = "Europe/Moscow"
def matches(self, now: datetime) -> bool:
if self.hour != now.hour or self.minute != now.minute:
"""Совпадает ли момент ``now`` (aware) с этим расписанием."""
local = now.astimezone(ZoneInfo(self.timezone))
if self.hour != local.hour or self.minute != local.minute:
return False
return self.weekdays is None or now.weekday() in self.weekdays
return not self.weekdays or local.isoweekday() in self.weekdays
SCHEDULES: tuple[ScheduledMessage, ...] = (
ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
#: Расписание по умолчанию — на случай недоступной БД (совпадает с V16-сидом).
DEFAULT_SCHEDULES: tuple[ScheduledMessage, ...] = (
ScheduledMessage(16, 30, "notify.blago_30"),
ScheduledMessage(16, 45, "notify.blago_45"),
ScheduledMessage(16, 55, "notify.blago_55"),
ScheduledMessage(17, 0, "notify.blago"),
ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)), # пятница
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:
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
@ -61,10 +107,15 @@ async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> N
async def _tick(bot: Bot, now: datetime) -> None:
for schedule in SCHEDULES:
if schedule.matches(now):
subscribers = await users_service.list_subscribers()
await _send_broadcast(bot, schedule.template, subscribers)
for schedule in await _load_schedules():
if not schedule.matches(now):
continue
template = await get_text(schedule.template_key)
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:

125
common/analytics/read.py Normal file
View File

@ -0,0 +1,125 @@
"""Чтение событий 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

View File

@ -6,6 +6,7 @@
from __future__ import annotations
from datetime import date
from functools import lru_cache
from typing import Annotated
from urllib.parse import quote_plus
@ -66,6 +67,14 @@ class Settings(BaseSettings):
analytics_flush_interval: float = 5.0
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")
@classmethod
def _empty_int_to_zero(cls, value: object) -> object:
@ -74,6 +83,14 @@ class Settings(BaseSettings):
return 0
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")
@classmethod
def _parse_admin_ids(cls, value: object) -> object:
@ -105,5 +122,36 @@ class Settings(BaseSettings):
@lru_cache
def get_settings() -> Settings:
"""Вернуть singleton настроек."""
"""Вернуть singleton настроек бота."""
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]

View File

@ -11,20 +11,23 @@ import logging
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from common.config import get_settings
from common.config import Settings, get_settings
logger = logging.getLogger(__name__)
_pool: AsyncConnectionPool | None = None
async def init_pool() -> AsyncConnectionPool:
"""Создать и открыть пул. Идемпотентно."""
async def init_pool(settings: Settings | None = None) -> AsyncConnectionPool:
"""Создать и открыть пул. Идемпотентно.
``settings`` передаёт админка (свои настройки); по умолчанию — настройки бота.
"""
global _pool
if _pool is not None:
return _pool
settings = get_settings()
settings = settings or get_settings()
_pool = AsyncConnectionPool(
conninfo=settings.dsn,
min_size=settings.db_pool_min,

46
common/eggs.py Normal file
View File

@ -0,0 +1,46 @@
"""Пасхальное событие «Яички» (Блок 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

View File

@ -0,0 +1,53 @@
"""Репозиторий администраторов веб-панели (Блок 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,),
)

View File

@ -0,0 +1,183 @@
"""Репозиторий расписаний рассылок (Блок 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()

View File

@ -0,0 +1,97 @@
"""Репозиторий шаблонов сообщений (Блок 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()

View File

@ -0,0 +1,283 @@
"""Чтение данных пользователей для админки (Блок 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

View File

@ -9,7 +9,7 @@ MSG_INFO = """<b>👤Информация о пользователе {username}
<b>💰 Деньги:</b> {money}
<b>📈 Начало активности в боте:</b> {dt}
<b>📊 Количество сообщений в чате:</b> {msg_count}
<b>🥚 Количество яичек:</b> {eggs}
{eggs_line}
"""
MSG_HELP = """<b><u>Список команд бота</u></b>

View File

@ -18,7 +18,7 @@ from typing import Any
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"})
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops", "eggs_line"})
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
TAG_REGISTRY: dict[str, str] = {
@ -32,6 +32,7 @@ TAG_REGISTRY: dict[str, str] = {
"dt": "Дата начала активности",
"msg_count": "Количество сообщений",
"eggs": "Количество яичек",
"eggs_line": "Готовая строка про яички (пусто вне пасхального события)",
"h": "Часы до следующего действия",
"m": "Минуты до следующего действия",
"s": "Секунды до следующего действия",

98
common/texts/store.py Normal file
View File

@ -0,0 +1,98 @@
"""Шаблоны сообщений из БД с 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)

View File

@ -25,6 +25,7 @@ MSG_PURCHASE_FAIL = "Не получилось купить :с"
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
MSG_EGGS_TOO_EARLY = "Слишком рано..."
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.3.9"
__version__ = "0.4.9"

View File

@ -62,6 +62,35 @@ services:
max-size: "10m"
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). Наружу не публикуется:
# только loopback хоста для бэкапа/туннеля; бот ходит внутрь сети compose.
clickhouse:

View File

@ -67,6 +67,36 @@ services:
max-size: "10m"
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/.
clickhouse:
image: clickhouse/clickhouse-server:25.3

19
env/prod.env.example vendored
View File

@ -12,6 +12,12 @@ REGISTRY=git.mpakosrv.ru
IMAGE_TAG=latest
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)
DB_HOST=postgres
@ -39,3 +45,16 @@ CLICKHOUSE_USER=de_bot
CLICKHOUSE_PASSWORD=
# Порт на хосте (для бэкапа). У test и prod он должен различаться.
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=

19
env/test.env.example vendored
View File

@ -11,6 +11,12 @@ REGISTRY=git.mpakosrv.ru
IMAGE_TAG=latest
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_PORT=5432
@ -37,3 +43,16 @@ CLICKHOUSE_USER=de_bot
CLICKHOUSE_PASSWORD=
# Порт на хосте: у test он отличается от prod (8123), иначе конфликт на одной VPS.
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=

21
requirements-admin.txt Normal file
View File

@ -0,0 +1,21 @@
# Зависимости сервиса админки (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

View File

@ -5,3 +5,5 @@ black>=24.4,<25.0
pytest>=8.2,<9.0
pytest-asyncio>=0.23,<1.0
pytest-cov>=5.0,<6.0
# TestClient для FastAPI-админки.
httpx>=0.27,<1.0

View File

@ -5,6 +5,9 @@ aiogram>=3.7,<4.0
pydantic>=2.7,<3.0
pydantic-settings>=2.3,<3.0
python-dotenv>=1.0,<2.0
# База таймзон для zoneinfo: в slim-образах нет системного tzdata,
# без пакета ZoneInfo("Europe/Moscow") падает (расписания и настройка TIMEZONE).
tzdata>=2024.1
psycopg[binary,pool]>=3.2,<4.0
# ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент.

64
sql/V14__admin_tables.sql Normal file
View File

@ -0,0 +1,64 @@
-- Таблицы админки (Блок 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 'История изменений расписаний (аудит)';

View File

@ -0,0 +1,84 @@
-- Стартовые тексты сообщений (сид для 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;

View File

@ -0,0 +1,12 @@
-- Стартовые расписания рассылок: переносят текущее поведение из кода бота.
-- Дни недели — 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', 'Вклад: воскресенье');

15
sql/V17__admin_users.sql Normal file
View File

@ -0,0 +1,15 @@
-- Администраторы веб-панели (Блок 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 'Время последнего успешного входа';

22
sql/V18__user_changes.sql Normal file
View File

@ -0,0 +1,22 @@
-- История ручных изменений пользователей из админки (Блок 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;

View File

@ -1,20 +1,124 @@
"""Общие фикстуры тестов."""
"""Общие приспособления тестов админки: Telegram-вход и CSRF-токен."""
from __future__ import annotations
from collections.abc import Callable
import hashlib
import hmac
import re
import time
from typing import Any
import pytest
from fastapi.testclient import TestClient
from common.config import Settings
from admin.app import create_app
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
def settings_factory() -> Callable[..., Settings]:
"""Фабрика ``Settings`` с обязательным ``BOT_TOKEN`` и без чтения ``.env``."""
def bot_token() -> str:
"""Токен тестового бота (подпись Telegram-логина)."""
return BOT_TOKEN
def _factory(**overrides: Any) -> Settings:
return Settings(_env_file=None, bot_token="1:a", **overrides)
return _factory
@pytest.fixture
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

144
tests/test_admin.py Normal file
View File

@ -0,0 +1,144 @@
"""Тесты админки: каркас, раздел «Сообщения» (без БД — репозиторий подменяется)."""
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}

97
tests/test_admin_logs.py Normal file
View File

@ -0,0 +1,97 @@
"""Тесты раздела «Логи» (без 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

View File

@ -0,0 +1,232 @@
"""Тесты раздела «Расписания» (без БД — репозитории подменяются)."""
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

260
tests/test_admin_users.py Normal file
View File

@ -0,0 +1,260 @@
"""Тесты раздела «Пользователи»: список, карточка и управление (без БД)."""
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

108
tests/test_auth.py Normal file
View File

@ -0,0 +1,108 @@
"""Тесты входа в админку через 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)

51
tests/test_eggs.py Normal file
View File

@ -0,0 +1,51 @@
"""Тесты сезонности «яичек»: Пасха, окно события и переопределение дат."""
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))

View File

@ -0,0 +1,68 @@
"""Тесты расписаний бота: совпадение времени, таймзоны и сид 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)

View File

@ -0,0 +1,97 @@
"""Тесты разбора формы расписаний (без 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)

View File

@ -0,0 +1,36 @@
"""Тесты сида шаблонов и контекста предпросмотра (без БД)."""
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)

93
tests/test_store.py Normal file
View File

@ -0,0 +1,93 @@
"""Тесты 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

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set():
assert __version__
assert __version__ == "0.3.9"
assert __version__ == "0.4.9"