Доработки контуров, тесты конфига и CI
This commit is contained in:
parent
05169b9b39
commit
aafb9c9e3b
|
|
@ -26,6 +26,8 @@ deploy
|
||||||
.gitea
|
.gitea
|
||||||
tests
|
tests
|
||||||
docs
|
docs
|
||||||
|
scripts
|
||||||
|
Makefile
|
||||||
*.md
|
*.md
|
||||||
Dockerfile
|
Dockerfile
|
||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ on:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Отменяем предыдущий незавершённый прогон при новом пуше.
|
||||||
|
concurrency:
|
||||||
|
group: ci
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.mpakosrv.ru
|
REGISTRY: git.mpakosrv.ru
|
||||||
IMAGE_NAME: mpakobecex/dispelevilbot-bot
|
IMAGE_NAME: mpakobecex/dispelevilbot-bot
|
||||||
|
|
@ -20,12 +25,10 @@ jobs:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Установка зависимостей
|
- name: Установка зависимостей
|
||||||
run: pip install -r requirements-dev.txt
|
run: pip install -r requirements-dev.txt
|
||||||
# В Forgejo 1.21 `continue-on-error` игнорируется, поэтому неблокирующий
|
|
||||||
# режим для black сделан через shell. ruff обязателен.
|
|
||||||
- name: Ruff
|
- name: Ruff
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
- name: Black
|
- name: Black
|
||||||
run: black --check . || echo "::warning::black нашёл отличия в форматировании (пока не блокирует)"
|
run: black --check .
|
||||||
- name: Pytest
|
- name: Pytest
|
||||||
run: pytest -q
|
run: pytest -q
|
||||||
|
|
||||||
|
|
@ -43,7 +46,9 @@ jobs:
|
||||||
IMAGE="$REGISTRY/$IMAGE_NAME"
|
IMAGE="$REGISTRY/$IMAGE_NAME"
|
||||||
SHA="${GITHUB_SHA:-unknown}"
|
SHA="${GITHUB_SHA:-unknown}"
|
||||||
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
|
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
|
||||||
|
VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)"
|
||||||
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
|
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
|
||||||
|
if [ -n "$VERSION" ]; then TAGS="$TAGS $IMAGE:$VERSION"; fi
|
||||||
ARGS=""
|
ARGS=""
|
||||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
||||||
docker build $ARGS .
|
docker build $ARGS .
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Управление контурами test/prod.
|
||||||
|
# Пример: make up E=test
|
||||||
|
#
|
||||||
|
# Используем .RECIPEPREFIX, чтобы не зависеть от табов (рецепты начинаются с ">").
|
||||||
|
E ?= test
|
||||||
|
FILE := env/$(E).env
|
||||||
|
COMPOSE := docker compose -p $(E) --env-file $(FILE) -f docker-compose.prod.yml
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.RECIPEPREFIX := >
|
||||||
|
|
||||||
|
.PHONY: help up pull down restart logs ps images check psql
|
||||||
|
|
||||||
|
help:
|
||||||
|
> @echo "Контур задаётся через E=test|prod (по умолчанию test):"
|
||||||
|
> @echo " make up E=test поднять контур"
|
||||||
|
> @echo " make pull E=test подтянуть образ из registry"
|
||||||
|
> @echo " make down E=test остановить (данные сохраняются)"
|
||||||
|
> @echo " make restart E=test пересоздать контейнеры"
|
||||||
|
> @echo " make logs E=test стримить логи"
|
||||||
|
> @echo " make ps E=test статус сервисов"
|
||||||
|
> @echo " make images E=test образы контура"
|
||||||
|
> @echo " make check E=test проверка контура (скрипт)"
|
||||||
|
> @echo " make psql E=test psql в БД контура"
|
||||||
|
|
||||||
|
up:
|
||||||
|
> $(COMPOSE) up -d
|
||||||
|
|
||||||
|
pull:
|
||||||
|
> $(COMPOSE) pull
|
||||||
|
|
||||||
|
down:
|
||||||
|
> $(COMPOSE) down
|
||||||
|
|
||||||
|
restart:
|
||||||
|
> $(COMPOSE) up -d --force-recreate
|
||||||
|
|
||||||
|
logs:
|
||||||
|
> $(COMPOSE) logs -f
|
||||||
|
|
||||||
|
ps:
|
||||||
|
> $(COMPOSE) ps
|
||||||
|
|
||||||
|
images:
|
||||||
|
> $(COMPOSE) images
|
||||||
|
|
||||||
|
check:
|
||||||
|
> ./scripts/check_contour.sh $(E)
|
||||||
|
|
||||||
|
psql:
|
||||||
|
> $(COMPOSE) exec postgres sh -lc 'psql -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'
|
||||||
|
|
@ -73,7 +73,7 @@ pytest
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
|
|
||||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2`.
|
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2.5`.
|
||||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ async def set_nick_handler(message: Message) -> None:
|
||||||
nick = parts[1]
|
nick = parts[1]
|
||||||
try:
|
try:
|
||||||
await users_service.set_nick(message.from_user.id, nick)
|
await users_service.set_nick(message.from_user.id, nick)
|
||||||
await message.answer(
|
await message.answer(render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick}))
|
||||||
render(system.MSG_NICK_SET, {"username": message.from_user.username, "nick": nick})
|
|
||||||
)
|
|
||||||
await message.delete()
|
await message.delete()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Не удалось установить ник")
|
logger.exception("Не удалось установить ник")
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,7 @@ SCHEDULES: tuple[ScheduledMessage, ...] = (
|
||||||
|
|
||||||
|
|
||||||
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
|
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
|
||||||
chunks = [
|
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
|
||||||
subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)
|
|
||||||
] or [[]]
|
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
text = render(template, {"usernames": format_usernames(chunk)})
|
text = render(template, {"usernames": format_usernames(chunk)})
|
||||||
await bot.send_message(chat_id=get_settings().chat_id, text=text)
|
await bot.send_message(chat_id=get_settings().chat_id, text=text)
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,12 @@ from common.repositories import shop_repo
|
||||||
|
|
||||||
async def list_titles() -> list[Title]:
|
async def list_titles() -> list[Title]:
|
||||||
rows = await shop_repo.get_titles_list()
|
rows = await shop_repo.get_titles_list()
|
||||||
return [
|
return [Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows]
|
||||||
Title(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0))
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def list_donats() -> list[Donat]:
|
async def list_donats() -> list[Donat]:
|
||||||
rows = await shop_repo.get_donats_list()
|
rows = await shop_repo.get_donats_list()
|
||||||
return [
|
return [Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0)) for row in rows]
|
||||||
Donat(id=int(row["i"]), title=row["n"] or "", price=int(row["p"] or 0))
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def buy_title(id_user: int, id_title: int) -> bool:
|
async def buy_title(id_user: int, id_title: int) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,5 @@ from common.repositories import stats_repo
|
||||||
async def top_users() -> list[TopEntry]:
|
async def top_users() -> list[TopEntry]:
|
||||||
rows = await stats_repo.get_top_list()
|
rows = await stats_repo.get_top_list()
|
||||||
return [
|
return [
|
||||||
TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0))
|
TopEntry(username=row.get("username"), nick=row.get("nick"), count=int(row.get("count") or 0)) for row in rows
|
||||||
for row in rows
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,7 @@ class Settings(BaseSettings):
|
||||||
"""Строка подключения psycopg для async-пула (креды URL-экранируются)."""
|
"""Строка подключения psycopg для async-пула (креды URL-экранируются)."""
|
||||||
user = quote_plus(self.db_user)
|
user = quote_plus(self.db_user)
|
||||||
password = quote_plus(self.db_password)
|
password = quote_plus(self.db_password)
|
||||||
return (
|
return f"postgresql://{user}:{password}" f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
f"postgresql://{user}:{password}"
|
|
||||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|
|
||||||
|
|
@ -9,40 +9,35 @@ from common.db.pool import get_pool
|
||||||
|
|
||||||
async def money_action(id_user: int, amount: int, id_action: int = 1) -> None:
|
async def money_action(id_user: int, amount: int, id_action: int = 1) -> None:
|
||||||
"""Начислить/списать деньги (запись в журнал money)."""
|
"""Начислить/списать деньги (запись в журнал money)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("CALL bot.money_action(%s, %s, %s)", (id_user, amount, id_action))
|
||||||
await cur.execute("CALL bot.money_action(%s, %s, %s)", (id_user, amount, id_action))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_money(id_user: int) -> int:
|
async def get_money(id_user: int) -> int:
|
||||||
"""Текущий баланс пользователя (SUM по журналу)."""
|
"""Текущий баланс пользователя (SUM по журналу)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT bot.get_money(%s) AS money", (id_user,))
|
||||||
await cur.execute("SELECT bot.get_money(%s) AS money", (id_user,))
|
row = await cur.fetchone()
|
||||||
row = await cur.fetchone()
|
return int(row["money"] or 0) if row else 0
|
||||||
return int(row["money"] or 0) if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
async def get_eggs(id_user: int) -> int:
|
async def get_eggs(id_user: int) -> int:
|
||||||
"""Количество яичек пользователя."""
|
"""Количество яичек пользователя."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT bot.get_eggs(%s) AS eggs", (id_user,))
|
||||||
await cur.execute("SELECT bot.get_eggs(%s) AS eggs", (id_user,))
|
row = await cur.fetchone()
|
||||||
row = await cur.fetchone()
|
return int(row["eggs"] or 0) if row else 0
|
||||||
return int(row["eggs"] or 0) if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
async def purification(id_user: int) -> dict[str, Any] | None:
|
async def purification(id_user: int) -> dict[str, Any] | None:
|
||||||
"""Выполнить ежедневное очищение."""
|
"""Выполнить ежедневное очищение."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT * FROM bot.purification(%s)", (id_user,))
|
||||||
await cur.execute("SELECT * FROM bot.purification(%s)", (id_user,))
|
return await cur.fetchone()
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
async def add_eggs(id_user: int) -> dict[str, Any] | None:
|
async def add_eggs(id_user: int) -> dict[str, Any] | None:
|
||||||
"""Получить яички (раз в сутки)."""
|
"""Получить яички (раз в сутки)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT * FROM bot.add_eggs(%s)", (id_user,))
|
||||||
await cur.execute("SELECT * FROM bot.add_eggs(%s)", (id_user,))
|
return await cur.fetchone()
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
|
||||||
|
|
@ -9,33 +9,29 @@ from common.db.pool import get_pool
|
||||||
|
|
||||||
async def get_titles_list() -> list[dict[str, Any]]:
|
async def get_titles_list() -> list[dict[str, Any]]:
|
||||||
"""Доступные к покупке звания (id_title, title, price)."""
|
"""Доступные к покупке звания (id_title, title, price)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT * FROM bot.get_titles_list()")
|
||||||
await cur.execute("SELECT * FROM bot.get_titles_list()")
|
return await cur.fetchall()
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_donats_list() -> list[dict[str, Any]]:
|
async def get_donats_list() -> list[dict[str, Any]]:
|
||||||
"""Доступные к покупке донаты (id_donat, title, price)."""
|
"""Доступные к покупке донаты (id_donat, title, price)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT * FROM bot.get_donats_list()")
|
||||||
await cur.execute("SELECT * FROM bot.get_donats_list()")
|
return await cur.fetchall()
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def buy_title(id_user: int, id_title: int) -> int:
|
async def buy_title(id_user: int, id_title: int) -> int:
|
||||||
"""Купить звание. Возвращает 1 при успехе, -1 если не хватает денег."""
|
"""Купить звание. Возвращает 1 при успехе, -1 если не хватает денег."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT bot.buy_title(%s, %s) AS res", (id_user, id_title))
|
||||||
await cur.execute("SELECT bot.buy_title(%s, %s) AS res", (id_user, id_title))
|
row = await cur.fetchone()
|
||||||
row = await cur.fetchone()
|
return int(row["res"]) if row else -1
|
||||||
return int(row["res"]) if row else -1
|
|
||||||
|
|
||||||
|
|
||||||
async def buy_donat(id_user: int, id_donat: int) -> str:
|
async def buy_donat(id_user: int, id_donat: int) -> str:
|
||||||
"""Купить донат. Возвращает промокод или '-1' при неуспехе."""
|
"""Купить донат. Возвращает промокод или '-1' при неуспехе."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT bot.buy_donat(%s, %s) AS res", (id_user, id_donat))
|
||||||
await cur.execute("SELECT bot.buy_donat(%s, %s) AS res", (id_user, id_donat))
|
row = await cur.fetchone()
|
||||||
row = await cur.fetchone()
|
return str(row["res"]) if row else "-1"
|
||||||
return str(row["res"]) if row else "-1"
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from common.db.pool import get_pool
|
||||||
|
|
||||||
async def get_top_list() -> list[dict[str, Any]]:
|
async def get_top_list() -> list[dict[str, Any]]:
|
||||||
"""Топ-10 пользователей по количеству сообщений (username, nick, count)."""
|
"""Топ-10 пользователей по количеству сообщений (username, nick, count)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT u AS username, n AS nick, c AS count FROM bot.get_top_list()")
|
||||||
await cur.execute("SELECT u AS username, n AS nick, c AS count FROM bot.get_top_list()")
|
return await cur.fetchall()
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,11 @@ from common.db.pool import get_pool
|
||||||
|
|
||||||
async def subscribe(id_user: int) -> None:
|
async def subscribe(id_user: int) -> None:
|
||||||
"""Подписать пользователя на рассылки."""
|
"""Подписать пользователя на рассылки."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("CALL bot.subscribe(%s)", (id_user,))
|
||||||
await cur.execute("CALL bot.subscribe(%s)", (id_user,))
|
|
||||||
|
|
||||||
|
|
||||||
async def unsubscribe(id_user: int) -> None:
|
async def unsubscribe(id_user: int) -> None:
|
||||||
"""Отписать пользователя от рассылок."""
|
"""Отписать пользователя от рассылок."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("CALL bot.unsubscribe(%s)", (id_user,))
|
||||||
await cur.execute("CALL bot.unsubscribe(%s)", (id_user,))
|
|
||||||
|
|
|
||||||
|
|
@ -9,47 +9,41 @@ from common.db.pool import get_pool
|
||||||
|
|
||||||
async def add_user(id_user: int, username: str, first_name: str, last_name: str) -> None:
|
async def add_user(id_user: int, username: str, first_name: str, last_name: str) -> None:
|
||||||
"""Создать/обновить пользователя и добавить запись о сообщении."""
|
"""Создать/обновить пользователя и добавить запись о сообщении."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute(
|
||||||
await cur.execute(
|
"CALL bot.add_user(%s, %s, %s, %s)",
|
||||||
"CALL bot.add_user(%s, %s, %s, %s)",
|
(id_user, username, first_name, last_name),
|
||||||
(id_user, username, first_name, last_name),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def add_nick(id_user: int, nick: str) -> None:
|
async def add_nick(id_user: int, nick: str) -> None:
|
||||||
"""Установить ник пользователя."""
|
"""Установить ник пользователя."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick))
|
||||||
await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_nick_list() -> list[dict[str, Any]]:
|
async def get_nick_list() -> list[dict[str, Any]]:
|
||||||
"""Список всех игроков (username, nick), отсортированный по нику."""
|
"""Список всех игроков (username, nick), отсортированный по нику."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT username, nick FROM bot.get_nick_list()")
|
||||||
await cur.execute("SELECT username, nick FROM bot.get_nick_list()")
|
return await cur.fetchall()
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_nick_list_subscribe() -> list[dict[str, Any]]:
|
async def get_nick_list_subscribe() -> list[dict[str, Any]]:
|
||||||
"""Список подписанных на рассылки (username, nick)."""
|
"""Список подписанных на рассылки (username, nick)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT username, nick FROM bot.get_nick_list_subscribe()")
|
||||||
await cur.execute("SELECT username, nick FROM bot.get_nick_list_subscribe()")
|
return await cur.fetchall()
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_info_by_id(id_user: int) -> dict[str, Any] | None:
|
async def get_info_by_id(id_user: int) -> dict[str, Any] | None:
|
||||||
"""Сводная информация о пользователе."""
|
"""Сводная информация о пользователе."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("SELECT * FROM bot.get_info_by_id(%s)", (id_user,))
|
||||||
await cur.execute("SELECT * FROM bot.get_info_by_id(%s)", (id_user,))
|
return await cur.fetchone()
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
async def del_user_by_id(id_user: int) -> None:
|
async def del_user_by_id(id_user: int) -> None:
|
||||||
"""Удалить пользователя (при выходе из чата)."""
|
"""Удалить пользователя (при выходе из чата)."""
|
||||||
async with get_pool().connection() as conn:
|
async with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
async with conn.cursor() as cur:
|
await cur.execute("CALL bot.del_user_by_id(%s)", (id_user,))
|
||||||
await cur.execute("CALL bot.del_user_by_id(%s)", (id_user,))
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,7 @@ from typing import Any
|
||||||
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
|
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
|
||||||
|
|
||||||
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
|
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже).
|
||||||
RAW_TAGS: frozenset[str] = frozenset(
|
RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"})
|
||||||
{"usernames", "users_list", "users_list_count", "tops"}
|
|
||||||
)
|
|
||||||
|
|
||||||
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
||||||
TAG_REGISTRY: dict[str, str] = {
|
TAG_REGISTRY: dict[str, str] = {
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.2"
|
__version__ = "0.2.5"
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ ENV_FILE=env/prod.env
|
||||||
|
|
||||||
APP_ENV=prod
|
APP_ENV=prod
|
||||||
|
|
||||||
# Образ в реестре
|
# Образ в реестре (latest = последняя сборка из master; можно закрепить <sha> или версию, напр. 0.2.5)
|
||||||
REGISTRY=git.mpakosrv.ru
|
REGISTRY=git.mpakosrv.ru
|
||||||
IMAGE_TAG=v0.2
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ ENV_FILE=env/test.env
|
||||||
|
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
|
|
||||||
# Образ в реестре
|
# Образ в реестре (latest = последняя сборка из master; можно закрепить <sha> или версию, напр. 0.2.5)
|
||||||
REGISTRY=git.mpakosrv.ru
|
REGISTRY=git.mpakosrv.ru
|
||||||
IMAGE_TAG=v0.2
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ line-length = 120
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B", "W"]
|
select = ["E", "F", "I", "UP", "B", "W", "C4", "SIM", "PIE"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Проверка контура: сервисы запущены, образ бота — ожидаемый, миграции применены.
|
||||||
|
# Использование: ./scripts/check_contour.sh [test|prod]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
E="${1:-test}"
|
||||||
|
FILE="env/${E}.env"
|
||||||
|
|
||||||
|
if [ ! -f "$FILE" ]; then
|
||||||
|
echo "Нет файла окружения: $FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE="docker compose -p ${E} --env-file ${FILE} -f docker-compose.prod.yml"
|
||||||
|
|
||||||
|
echo "== контур: ${E} =="
|
||||||
|
$COMPOSE ps
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== образы =="
|
||||||
|
$COMPOSE images
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== проверки =="
|
||||||
|
|
||||||
|
running="$($COMPOSE ps --services --status running || true)"
|
||||||
|
fail=0
|
||||||
|
for svc in postgres bot; do
|
||||||
|
if echo "$running" | grep -qx "$svc"; then
|
||||||
|
echo "OK ${svc}: running"
|
||||||
|
else
|
||||||
|
echo "FAIL ${svc}: не запущен" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# миграции: сервис migrate должен завершиться успешно (Exited (0))
|
||||||
|
if $COMPOSE ps -a --format '{{.Service}} {{.Status}}' | grep -q '^migrate Exited (0)'; then
|
||||||
|
echo "OK migrate: завершён успешно"
|
||||||
|
else
|
||||||
|
echo "WARN migrate: нет подтверждённого успешного завершения (см. 'logs migrate')" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# БД отвечает
|
||||||
|
if $COMPOSE exec -T postgres sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
|
||||||
|
echo "OK postgres: принимает соединения"
|
||||||
|
else
|
||||||
|
echo "FAIL postgres: не отвечает" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $fail
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Тесты разбора переменных окружения (без БД и сети).
|
||||||
|
|
||||||
|
Проверяем граничные случаи, которые уже ломали запуск:
|
||||||
|
- ``ADMIN_IDS`` пустой / через запятую / как JSON-массив;
|
||||||
|
- пустые целочисленные поля ``CHAT_ID``/``ALERT_CHAT_ID``;
|
||||||
|
- обязательный ``BOT_TOKEN``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from common.config import Settings
|
||||||
|
|
||||||
|
#: Переменные, которые чистим перед каждым тестом, чтобы окружение не влияло.
|
||||||
|
_ENV_KEYS = ("BOT_TOKEN", "APP_ENV", "ADMIN_IDS", "CHAT_ID", "ALERT_CHAT_ID")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
for key in _ENV_KEYS:
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> Settings:
|
||||||
|
for key, value in env.items():
|
||||||
|
monkeypatch.setenv(key, value)
|
||||||
|
return Settings(_env_file=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_ids_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="").admin_ids == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_ids_not_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
assert _settings(monkeypatch, BOT_TOKEN="1:a").admin_ids == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_ids_comma(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="1,2, 3").admin_ids == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_ids_json_like(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
assert _settings(monkeypatch, BOT_TOKEN="1:a", ADMIN_IDS="[7, 8]").admin_ids == [7, 8]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_ids_empty_are_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
settings = _settings(monkeypatch, BOT_TOKEN="1:a", CHAT_ID="", ALERT_CHAT_ID="")
|
||||||
|
assert settings.chat_id == 0
|
||||||
|
assert settings.alert_chat_id == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_id_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
assert _settings(monkeypatch, BOT_TOKEN="1:a", CHAT_ID="-1001234567890").chat_id == -1001234567890
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_token_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Settings(_env_file=None)
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.2"
|
assert __version__ == "0.2.5"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue