From bce78c4f286a91b22717ce8ef910288a8d799d9d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 27 May 2026 19:08:49 +0300 Subject: [PATCH] chore: backup warning details --- backend/bot/app/web/webapp/billing.py | 8 +--- .../bot/payment_providers/shared/common.py | 16 ++++--- backend/bot/services/backup_worker.py | 46 +++++++++++++++++-- docs/features/backups.md | 7 +++ tests/test_backup_worker.py | 42 ++++++++++++++++- 5 files changed, 101 insertions(+), 18 deletions(-) diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 07e3629..081501a 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -699,12 +699,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response: "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "title": f"+{count}", "subtitle": tariff.name(lang), - "valid_from": _billing_iso_datetime( - (rub_quote or stars_quote).get("valid_from") - ), - "valid_until": _billing_iso_datetime( - (rub_quote or stars_quote).get("valid_until") - ), + "valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")), + "valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")), "proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0), } if stars_quote and int(stars_quote.get("price") or 0) > 0: diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index 45ee4f8..53ac7b9 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -182,13 +182,17 @@ def payment_units_for_activation(payment: Any, sale_mode: str) -> Any: """Resolve purchased units from a payment record for webhook activation.""" base = sale_mode_base(sale_mode) if sale_mode_is_traffic(base): - return getattr(payment, "purchased_gb", None) or getattr( - payment, "subscription_duration_months", None - ) or 1 + return ( + getattr(payment, "purchased_gb", None) + or getattr(payment, "subscription_duration_months", None) + or 1 + ) if sale_mode_is_hwid_devices(base): - return getattr(payment, "purchased_hwid_devices", None) or getattr( - payment, "subscription_duration_months", None - ) or 1 + return ( + getattr(payment, "purchased_hwid_devices", None) + or getattr(payment, "subscription_duration_months", None) + or 1 + ) return getattr(payment, "subscription_duration_months", None) or 1 diff --git a/backend/bot/services/backup_worker.py b/backend/bot/services/backup_worker.py index e48b77e..d231561 100644 --- a/backend/bot/services/backup_worker.py +++ b/backend/bot/services/backup_worker.py @@ -55,6 +55,9 @@ BACKUP_RUNTIME_SETTING_KEYS = { "BACKUP_COMPOSE_SOURCE_DIR", "BACKUP_COMPOSE_EXCLUDE_DIRS", } +TELEGRAM_DOCUMENT_CAPTION_LIMIT = 1024 +TELEGRAM_WARNING_DETAIL_LIMIT = 6 +TELEGRAM_WARNING_LINE_LIMIT = 220 @dataclass @@ -263,16 +266,26 @@ class BackupWorker: def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int: source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip() if not source_raw: - warnings.append("Compose source directory is not configured.") + warnings.append( + "Compose source directory is not configured. Set " + "BACKUP_COMPOSE_SOURCE_DIR or mount the compose folder into the backup container." + ) return 0 source_dir = Path(source_raw).expanduser() if not source_dir.exists() or not source_dir.is_dir(): - warnings.append(f"Compose source directory is unavailable: {source_dir}") + warnings.append( + "If manual backup includes compose but scheduled backup does not, recreate " + "the worker service with the compose-source mount. Compose source directory " + f"is unavailable in this container: {source_dir}" + ) return 0 if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES): - warnings.append(f"Compose source directory has no compose file marker: {source_dir}") + warnings.append( + "Check that COMPOSE_BACKUP_SOURCE points to the folder with docker-compose.yml. " + f"Compose source directory has no compose file marker: {source_dir}" + ) excluded_dirs = self._compose_excluded_dirs() files_count = 0 @@ -358,8 +371,31 @@ class BackupWorker: f"Archive size: {self._human_size(result.size_bytes)}", ] if result.warnings: - lines.append(f"Warnings: {len(result.warnings)}") - return "\n".join(lines) + lines.append(f"Warnings ({len(result.warnings)}):") + for index, warning in enumerate( + result.warnings[:TELEGRAM_WARNING_DETAIL_LIMIT], + start=1, + ): + lines.append(f"{index}. {self._caption_warning(warning)}") + hidden_count = len(result.warnings) - TELEGRAM_WARNING_DETAIL_LIMIT + if hidden_count > 0: + lines.append(f"... and {hidden_count} more warning(s)") + return self._fit_caption(lines) + + @staticmethod + def _caption_warning(warning: str) -> str: + text = " ".join(str(warning or "").split()) + if len(text) <= TELEGRAM_WARNING_LINE_LIMIT: + return text + return f"{text[: TELEGRAM_WARNING_LINE_LIMIT - 1].rstrip()}..." + + @staticmethod + def _fit_caption(lines: list[str]) -> str: + caption = "\n".join(lines) + if len(caption) <= TELEGRAM_DOCUMENT_CAPTION_LIMIT: + return caption + suffix = "\n... caption truncated" + return f"{caption[: TELEGRAM_DOCUMENT_CAPTION_LIMIT - len(suffix)].rstrip()}{suffix}" @staticmethod def _human_size(size_bytes: int) -> str: diff --git a/docs/features/backups.md b/docs/features/backups.md index 8cc2bd8..7175608 100644 --- a/docs/features/backups.md +++ b/docs/features/backups.md @@ -54,6 +54,13 @@ BACKUP_THREAD_ID=123 `COMPOSE_BACKUP_SOURCE=.` означает папку рядом с текущим `docker-compose.yml`. Если compose лежит в другом месте, укажите абсолютный host-путь. +Ручное создание бэкапа из админки выполняется в `backend`-контейнере, а автоматический backup по расписанию - в `worker`-контейнере. Оба контейнера должны видеть `/app/compose-source`. Если ручной backup содержит compose-папку, а автоматический нет, пересоздайте worker после обновления compose: + +```bash +docker compose up -d --force-recreate worker +docker compose exec worker ls -la /app/compose-source +``` + Если нужно запретить восстановление compose-файлов из контейнера, задайте: ```env diff --git a/tests/test_backup_worker.py b/tests/test_backup_worker.py index 4fbd3d0..61b2c72 100644 --- a/tests/test_backup_worker.py +++ b/tests/test_backup_worker.py @@ -2,6 +2,7 @@ import asyncio import json import os import zipfile +from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -80,6 +81,7 @@ def test_backup_worker_creates_archive_with_db_dump_and_compose_snapshot(tmp_pat send_kwargs = bot.send_document.await_args.kwargs assert send_kwargs["chat_id"] == 123 assert "Database dump: yes" in send_kwargs["caption"] + assert "Warnings" not in send_kwargs["caption"] with zipfile.ZipFile(result.archive_path) as archive: names = set(archive.namelist()) @@ -168,12 +170,50 @@ def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path assert result.archive_path.is_file() assert result.db_dump_included is True assert result.compose_files_count == 0 - assert any("Compose source directory is unavailable" in item for item in result.warnings) + assert any( + "Compose source directory is unavailable in this container" in item + for item in result.warnings + ) + bot.send_document.assert_awaited_once() + caption = bot.send_document.await_args.kwargs["caption"] + assert "Warnings (1):" in caption + assert "1. If manual backup includes compose but scheduled backup does not" in caption + assert "Compose source directory is unavailable in this container" in caption + assert "recreate the worker service" in caption with zipfile.ZipFile(result.archive_path) as archive: names = set(archive.namelist()) assert "database/shop.dump" in names +def test_backup_worker_caption_lists_and_truncates_warning_details(tmp_path): + settings = _settings(tmp_path, tmp_path / "compose") + worker = _FakePgDumpBackupWorker(settings, _FakeBot()) + result = SimpleNamespace( + completed_at=datetime.now(timezone.utc), + db_dump_included=True, + compose_files_count=0, + size_bytes=1024, + warnings=[ + "first warning", + "second warning", + "third warning", + "fourth warning", + "fifth warning", + "sixth warning", + "seventh warning", + ], + ) + + caption = worker._caption(result) + + assert "Warnings (7):" in caption + assert "1. first warning" in caption + assert "6. sixth warning" in caption + assert "seventh warning" not in caption + assert "... and 1 more warning(s)" in caption + assert len(caption) <= 1024 + + def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch): from bot.services import settings_override_service