diff --git a/README.md b/README.md index 5685019..c2b4828 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ ID пользователя ведёт в его карточку. ClickHouse ч Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц скрываются на узких экранах, формы складываются в одну колонку. +Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же +редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка +(успешная или нет) пишется в `bot.admin_messages`. + ## Аналитика (ClickHouse) Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. @@ -161,7 +165,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.3`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.4`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/app.py b/admin/app.py index f46c4f3..cfc5ba4 100644 --- a/admin/app.py +++ b/admin/app.py @@ -14,7 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware from admin.media import ensure_media from admin.middleware import AdminAuthMiddleware -from admin.routes import admins, auth, health, logs, messages, pages, schedules, sequences, users +from admin.routes import admins, auth, health, logs, messages, pages, schedules, send, sequences, users from common import __version__ from common.analytics import read as analytics_read from common.config import AdminSettings, get_admin_settings @@ -95,4 +95,5 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - app.include_router(users.router) app.include_router(admins.router) app.include_router(logs.router) + app.include_router(send.router) return app diff --git a/admin/buttons.py b/admin/buttons.py new file mode 100644 index 0000000..3bc7207 --- /dev/null +++ b/admin/buttons.py @@ -0,0 +1,33 @@ +"""Разбор URL-кнопок из textarea: по одной в строке ``Текст | https://…``.""" + +from __future__ import annotations + +MAX_BUTTONS = 8 + + +def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]: + buttons: list[tuple[str, str]] = [] + errors: list[str] = [] + + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" not in line: + errors.append(f"Кнопка без разделителя «|»: {line}") + continue + text, url = (part.strip() for part in line.split("|", 1)) + if not text: + errors.append("У кнопки пустой текст") + continue + if not url.startswith(("https://", "http://")): + errors.append(f"Ссылка кнопки должна начинаться с http(s): {url}") + continue + if len(text) > 64: + errors.append("Текст кнопки длиннее 64 символов") + continue + buttons.append((text, url)) + + if len(buttons) > MAX_BUTTONS: + errors.append(f"Не больше {MAX_BUTTONS} кнопок на сообщение") + return buttons, errors diff --git a/admin/media.py b/admin/media.py index 2a3826e..e07c68f 100644 --- a/admin/media.py +++ b/admin/media.py @@ -6,6 +6,8 @@ import logging import shutil from pathlib import Path +from starlette.datastructures import UploadFile + from common.config import AdminSettings logger = logging.getLogger(__name__) @@ -16,6 +18,23 @@ BUILTIN_WELCOME = Path("images/welcome_image.jpg") #: Имя файла в volume, которое ждёт сид последовательности `welcome`. WELCOME_IMAGE_NAME = "welcome_image.jpg" +#: Допустимые типы и максимальный размер загружаемых картинок. +ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} +MAX_IMAGE_BYTES = 5 * 1024 * 1024 + + +async def read_image(upload: UploadFile) -> tuple[bytes, str, str]: + """Прочитать картинку из формы. Возвращает (данные, mime, расширение).""" + content_type = (upload.content_type or "").lower() + extension = ALLOWED_IMAGE_TYPES.get(content_type) + if extension is None: + raise ValueError("Картинка должна быть JPEG, PNG или WebP") + + data = await upload.read() + if len(data) > MAX_IMAGE_BYTES: + raise ValueError("Картинка больше 5 МБ") + return data, content_type, extension + def ensure_media(settings: AdminSettings) -> Path: """Создать каталог и положить туда встроенное приветствие, если его нет.""" diff --git a/admin/routes/pages.py b/admin/routes/pages.py index 9d6331b..e5f1364 100644 --- a/admin/routes/pages.py +++ b/admin/routes/pages.py @@ -41,6 +41,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( "description": "События из ClickHouse с фильтрами", "url": "/logs", }, + { + "title": "Отправить", + "description": "Сообщение в чат от имени бота", + "url": "/send", + }, ) diff --git a/admin/routes/send.py b/admin/routes/send.py new file mode 100644 index 0000000..7632cd6 --- /dev/null +++ b/admin/routes/send.py @@ -0,0 +1,150 @@ +"""Ручная отправка сообщения в основной чат от имени бота.""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse +from starlette.datastructures import UploadFile + +from admin import telegram +from admin.buttons import MAX_BUTTONS, parse_buttons +from admin.media import read_image +from admin.security import check_csrf +from common import __version__ +from common.repositories import admin_messages_repo +from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/send", tags=["send"]) + +#: Лимит подписи к фото. +MAX_CAPTION = 1024 + + +async def _recent() -> list[dict[str, Any]]: + try: + return await admin_messages_repo.recent(limit=5) + except Exception: + logger.warning("Не удалось прочитать журнал отправок", exc_info=True) + return [] + + +async def _render( + request: Request, + *, + text: str = "", + buttons_text: str = "", + errors: list[str] | None = None, + saved: bool = False, + status_code: int = 200, +) -> HTMLResponse: + settings = request.app.state.settings + return request.app.state.templates.TemplateResponse( + request=request, + name="send/form.html", + context={ + "version": __version__, + "text": text, + "buttons_text": buttons_text, + "errors": errors or [], + "saved": saved, + "chat_id": settings.chat_id, + "max_message": MAX_MESSAGE_LENGTH, + "max_caption": MAX_CAPTION, + "max_buttons": MAX_BUTTONS, + "tags": TAG_REGISTRY, + "recent": await _recent(), + }, + status_code=status_code, + ) + + +@router.get("", response_class=HTMLResponse) +async def send_form(request: Request, saved: bool = False) -> HTMLResponse: + return await _render(request, saved=saved) + + +@router.post("") +async def send_message(request: Request) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + text = str(form.get("text", "")) + raw_buttons = str(form.get("buttons", "")) + buttons, errors = parse_buttons(raw_buttons) + + unknown = validate(text) + if unknown: + errors.append("Неизвестные теги: " + ", ".join(unknown)) + + settings = request.app.state.settings + photo: tuple[bytes, str, str] | None = None + upload = form.get("image") + if isinstance(upload, UploadFile) and upload.filename: + try: + data, mime, extension = await read_image(upload) + photo = (data, mime, f"upload{extension}") + except ValueError as exc: + errors.append(str(exc)) + + limit = MAX_CAPTION if photo else MAX_MESSAGE_LENGTH + if len(text) > limit: + errors.append(f"С фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}") + if not text.strip() and photo is None: + errors.append("Нужен текст или картинка") + if not settings.chat_id: + errors.append("CHAT_ID не задан") + + if errors: + return await _render( + request, + text=text, + buttons_text=raw_buttons, + errors=errors, + status_code=400, + ) + + success = True + error_text: str | None = None + try: + await telegram.send_to_chat( + settings, + text=text, + buttons=buttons, + photo=photo, + ) + except telegram.TelegramError as exc: + success = False + error_text = str(exc) + except Exception: + success = False + error_text = "Не удалось отправить сообщение" + logger.exception("Ошибка отправки сообщения из админки") + + try: + await admin_messages_repo.log_message( + editor_id=int(request.state.admin["telegram_id"]), + chat_id=settings.chat_id, + text=text, + buttons=buttons, + has_image=photo is not None, + success=success, + error=error_text, + ) + except Exception: + logger.warning("Не удалось записать журнал отправки", exc_info=True) + + if not success: + return await _render( + request, + text=text, + buttons_text=raw_buttons, + errors=[f"Telegram: {error_text}"], + status_code=400, + ) + + return RedirectResponse(url="/send?saved=true", status_code=303) diff --git a/admin/routes/sequences.py b/admin/routes/sequences.py index 72b7e05..4834bbd 100644 --- a/admin/routes/sequences.py +++ b/admin/routes/sequences.py @@ -11,7 +11,8 @@ from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from starlette.datastructures import UploadFile -from admin.media import media_path +from admin.buttons import MAX_BUTTONS, parse_buttons +from admin.media import media_path, read_image from admin.security import check_csrf from common import __version__ from common.repositories import sequences_repo @@ -26,12 +27,6 @@ KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$") #: Лимит подписи к фото. MAX_CAPTION = 1024 -#: Сколько кнопок можно повесить на одно сообщение. -MAX_BUTTONS = 8 -#: Допустимые типы картинок. -ALLOWED_IMAGES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} -#: Максимальный размер картинки. -MAX_IMAGE_BYTES = 5 * 1024 * 1024 def _editor_id(request: Request) -> int: @@ -44,52 +39,10 @@ def _prepare_items(items: list[dict[str, Any]]) -> None: item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"]) -def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]: - """Разобрать кнопки: по одной в строке, формат ``Текст | https://…``.""" - buttons: list[tuple[str, str]] = [] - errors: list[str] = [] - - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - if "|" not in line: - errors.append(f"Кнопка без разделителя «|»: {line}") - continue - text, url = (part.strip() for part in line.split("|", 1)) - if not text: - errors.append("У кнопки пустой текст") - continue - if not url.startswith(("https://", "http://")): - errors.append(f"Ссылка кнопки должна начинаться с http(s): {url}") - continue - if len(text) > 64: - errors.append("Текст кнопки длиннее 64 символов") - continue - buttons.append((text, url)) - - if len(buttons) > MAX_BUTTONS: - errors.append(f"Не больше {MAX_BUTTONS} кнопок на сообщение") - return buttons, errors - - -def _image_error(upload: UploadFile) -> str | None: - content_type = (upload.content_type or "").lower() - if content_type not in ALLOWED_IMAGES: - return "Картинка должна быть JPEG, PNG или WebP" - return None - - async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str: - error = _image_error(upload) - if error: - raise ValueError(error) + data, _, extension = await read_image(upload) - data = await upload.read() - if len(data) > MAX_IMAGE_BYTES: - raise ValueError("Картинка больше 5 МБ") - - name = f"{key}_{item_id}_{uuid.uuid4().hex}{ALLOWED_IMAGES[(upload.content_type or '').lower()]}" + name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}" path = media_path(settings, name) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) diff --git a/admin/telegram.py b/admin/telegram.py index b3a711f..98cfc4c 100644 --- a/admin/telegram.py +++ b/admin/telegram.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json import logging from typing import Any @@ -22,6 +23,16 @@ class TelegramError(RuntimeError): """Telegram отклонил запрос или недоступен.""" +def _check(response: httpx.Response) -> Any: + try: + data = response.json() + except ValueError as exc: + raise TelegramError("Telegram вернул некорректный ответ") from exc + if not data.get("ok"): + raise TelegramError(str(data.get("description") or "Telegram отклонил запрос")) + return data.get("result") + + async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any: if not settings.bot_token: raise TelegramError("BOT_TOKEN не задан") @@ -32,15 +43,25 @@ async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) - response = await client.post(url, json=payload) except httpx.HTTPError as exc: raise TelegramError(f"Telegram недоступен: {exc}") from exc + return _check(response) + +async def _call_multipart( + settings: AdminSettings, + method: str, + data: dict[str, str], + files: dict[str, tuple[str, bytes, str]], +) -> Any: + if not settings.bot_token: + raise TelegramError("BOT_TOKEN не задан") + + url = _API.format(token=settings.bot_token, method=method) try: - data = response.json() - except ValueError as exc: - raise TelegramError("Telegram вернул некорректный ответ") from exc - - if not data.get("ok"): - raise TelegramError(str(data.get("description") or "Telegram отклонил запрос")) - return data.get("result") + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, data=data, files=files) + except httpx.HTTPError as exc: + raise TelegramError(f"Telegram недоступен: {exc}") from exc + return _check(response) async def kick_member(settings: AdminSettings, user_id: int) -> None: @@ -81,3 +102,57 @@ async def send_alert(settings: AdminSettings, text: str) -> None: ) except TelegramError: logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True) + + +async def send_to_chat( + settings: AdminSettings, + *, + text: str, + buttons: list[tuple[str, str]], + photo: tuple[bytes, str, str] | None = None, +) -> None: + """Отправить сообщение в основной чат от имени бота. + + ``photo`` — (данные, mime, имя файла). При фото длинный текст уходит + отдельным сообщением (лимит подписи 1024). + """ + if not settings.chat_id: + raise TelegramError("CHAT_ID не задан") + + rows = [[{"text": title, "url": url}] for title, url in buttons] + markup = {"inline_keyboard": rows} if rows else None + + if photo is None: + await _call( + settings, + "sendMessage", + { + "chat_id": settings.chat_id, + "text": text, + "parse_mode": "HTML", + "reply_markup": markup, + }, + ) + return + + data, mime, filename = photo + fields: dict[str, str] = {"chat_id": str(settings.chat_id), "parse_mode": "HTML"} + caption = text if text and len(text) <= 1024 else "" + if caption: + fields["caption"] = caption + if markup: + fields["reply_markup"] = json.dumps(markup) + + await _call_multipart(settings, "sendPhoto", fields, {"photo": (filename, data, mime)}) + + if text and not caption: + await _call( + settings, + "sendMessage", + { + "chat_id": settings.chat_id, + "text": text, + "parse_mode": "HTML", + "reply_markup": markup, + }, + ) diff --git a/admin/templates/base.html b/admin/templates/base.html index 0fe05db..cab36bb 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -31,6 +31,7 @@ Пользователи Админы Логи + Отправить {% if request.session.get('admin_id') %} {{ request.session.get('admin_username') or 'админ' }}
diff --git a/admin/templates/send/form.html b/admin/templates/send/form.html new file mode 100644 index 0000000..c76a46b --- /dev/null +++ b/admin/templates/send/form.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} + +{% block title %}Отправить сообщение{% endblock %} + +{% block content %} +

Отправить сообщение

+

+ Сообщение уйдёт в основной чат {{ chat_id }} от имени бота. +

+ +{% if saved %} +
+ Сообщение отправлено. +
+{% endif %} + +{% if errors %} + +{% endif %} + + + + +
+ +
+ {% with editor_id="send-text", editor_name="text", editor_value=text, editor_max=max_message, editor_rows=12 %} + {% include "partials/editor.html" %} + {% endwith %} +
+
+ +
+
+ + +

До {{ max_buttons }} кнопок.

+
+
+ + +

С фото текст уходит подписью — не больше {{ max_caption }} символов.

+
+
+ +
+ + +
+
+ +
+

Предпросмотр

+
+

Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.

+
+
+ +
+

Последние отправки

+ {% if recent %} +
+ + + + + + + + + + + {% for item in recent %} + + + + + + + {% endfor %} + +
КогдаТекстФотоСтатус
{{ item.sent_at.strftime("%d.%m.%Y %H:%M") }}{{ item.preview }}{{ "да" if item.has_image else "—" }} + {% if item.success %} + отправлено + {% else %} + {{ item.error or "ошибка" }} + {% endif %} +
+
+ {% else %} +

Отправок ещё не было.

+ {% endif %} +
+{% endblock %} diff --git a/common/repositories/admin_messages_repo.py b/common/repositories/admin_messages_repo.py new file mode 100644 index 0000000..f0804fa --- /dev/null +++ b/common/repositories/admin_messages_repo.py @@ -0,0 +1,52 @@ +"""Журнал ручных отправок сообщений из админки.""" + +from __future__ import annotations + +from typing import Any + +from psycopg.types.json import Jsonb + +from common.db.pool import get_pool + + +async def log_message( + *, + editor_id: int | None, + chat_id: int, + text: str, + buttons: list[tuple[str, str]], + has_image: bool, + success: bool, + error: str | None = None, +) -> None: + async with get_pool().connection() as conn: + await conn.execute( + """ + INSERT INTO bot.admin_messages (editor_id, chat_id, text, buttons, has_image, success, error) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + editor_id, + chat_id, + text, + Jsonb([{"text": button_text, "url": url} for button_text, url in buttons]), + has_image, + success, + error, + ), + ) + + +async def recent(*, limit: int = 10) -> list[dict[str, Any]]: + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT id, editor_id, chat_id, left(text, 120) AS preview, buttons, + has_image, success, error, sent_at + FROM bot.admin_messages + ORDER BY sent_at DESC + LIMIT %s + """, + (limit,), + ) + return await cur.fetchall() diff --git a/common/version.py b/common/version.py index 3d1d073..a356802 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.5.3" +__version__ = "0.5.4" diff --git a/sql/V22__admin_messages.sql b/sql/V22__admin_messages.sql new file mode 100644 index 0000000..53adfe7 --- /dev/null +++ b/sql/V22__admin_messages.sql @@ -0,0 +1,17 @@ +-- Журнал сообщений, отправленных администратором из веб-панели в чат. +CREATE TABLE IF NOT EXISTS bot.admin_messages ( + id bigserial PRIMARY KEY, + editor_id bigint, + chat_id bigint NOT NULL, + text text NOT NULL DEFAULT '', + buttons jsonb NOT NULL DEFAULT '[]'::jsonb, + has_image boolean NOT NULL DEFAULT false, + success boolean NOT NULL, + error text, + sent_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_admin_messages_sent_at + ON bot.admin_messages (sent_at DESC); + +COMMENT ON TABLE bot.admin_messages IS 'Аудит ручных отправок сообщений из админки'; diff --git a/tests/test_admin_send.py b/tests/test_admin_send.py new file mode 100644 index 0000000..5275bdc --- /dev/null +++ b/tests/test_admin_send.py @@ -0,0 +1,118 @@ +"""Тесты ручной отправки сообщения из админки.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from admin import telegram +from common.repositories import admin_messages_repo + + +@pytest.fixture +def fake_send(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + state: dict[str, Any] = {} + + async def recent(*, limit: int = 10) -> list[dict[str, Any]]: + return [] + + async def log_message(**kwargs: Any) -> None: + state["audit"] = kwargs + + async def send_to_chat(settings: Any, *, text: str, buttons: list, photo: Any = None) -> None: + state["send"] = {"text": text, "buttons": buttons, "photo": photo} + + monkeypatch.setattr(admin_messages_repo, "recent", recent) + monkeypatch.setattr(admin_messages_repo, "log_message", log_message) + monkeypatch.setattr(telegram, "send_to_chat", send_to_chat) + return state + + +def _client(client_factory, settings_factory) -> TestClient: + return client_factory(settings=settings_factory(chat_id=-100123), follow_redirects=False) + + +def test_send_form(fake_send: dict[str, Any], client_factory, settings_factory) -> None: + response = _client(client_factory, settings_factory).get("/send") + + assert response.status_code == 200 + assert 'data-tag="b"' in response.text + assert "-100123" in response.text + + +def test_send_text(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None: + client = _client(client_factory, settings_factory) + token = csrf_token(client.get("/send").text) + + response = client.post( + "/send", + data={"text": "Привет, клан!", "buttons": "Канал | https://t.me/x", "csrf_token": token}, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/send?saved=true" + assert fake_send["send"]["text"] == "Привет, клан!" + assert fake_send["send"]["buttons"] == [("Канал", "https://t.me/x")] + assert fake_send["send"]["photo"] is None + assert fake_send["audit"]["success"] is True + + +def test_send_rejects_bad_buttons(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None: + client = _client(client_factory, settings_factory) + token = csrf_token(client.get("/send").text) + + response = client.post("/send", data={"text": "Текст", "buttons": "без ссылки", "csrf_token": token}) + + assert response.status_code == 400 + assert "Кнопка без разделителя" in response.text + assert "send" not in fake_send + + +def test_send_requires_text_or_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None: + client = _client(client_factory, settings_factory) + token = csrf_token(client.get("/send").text) + + response = client.post("/send", data={"text": " ", "csrf_token": token}) + + assert response.status_code == 400 + assert "Нужен текст или картинка" in response.text + + +def test_send_with_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None: + client = _client(client_factory, settings_factory) + token = csrf_token(client.get("/send").text) + + response = client.post( + "/send", + data={"text": "С картинкой", "csrf_token": token}, + files={"image": ("pic.png", b"fake-png", "image/png")}, + ) + + assert response.status_code == 303 + assert fake_send["send"]["photo"] is not None + assert fake_send["send"]["photo"][1] == "image/png" + assert fake_send["audit"]["has_image"] is True + + +def test_send_telegram_error( + fake_send: dict[str, Any], + client_factory, + settings_factory, + csrf_token, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def send_to_chat(*args: Any, **kwargs: Any) -> None: + raise telegram.TelegramError("chat not found") + + monkeypatch.setattr(telegram, "send_to_chat", send_to_chat) + client = _client(client_factory, settings_factory) + token = csrf_token(client.get("/send").text) + + response = client.post("/send", data={"text": "Текст", "csrf_token": token}) + + assert response.status_code == 400 + assert "chat not found" in response.text + assert fake_send["audit"]["success"] is False + assert fake_send["audit"]["error"] == "chat not found" diff --git a/tests/test_version.py b/tests/test_version.py index c7b28e6..6fccfd2 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.5.3" + assert __version__ == "0.5.4"