chore: backup warning details

This commit is contained in:
3252a8
2026-05-27 19:08:49 +03:00
parent 75586d8883
commit bce78c4f28
5 changed files with 101 additions and 18 deletions
+2 -6
View File
@@ -699,12 +699,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"+{count}", "title": f"+{count}",
"subtitle": tariff.name(lang), "subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime( "valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
(rub_quote or stars_quote).get("valid_from") "valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
),
"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), "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: if stars_quote and int(stars_quote.get("price") or 0) > 0:
+10 -6
View File
@@ -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.""" """Resolve purchased units from a payment record for webhook activation."""
base = sale_mode_base(sale_mode) base = sale_mode_base(sale_mode)
if sale_mode_is_traffic(base): if sale_mode_is_traffic(base):
return getattr(payment, "purchased_gb", None) or getattr( return (
payment, "subscription_duration_months", None getattr(payment, "purchased_gb", None)
) or 1 or getattr(payment, "subscription_duration_months", None)
or 1
)
if sale_mode_is_hwid_devices(base): if sale_mode_is_hwid_devices(base):
return getattr(payment, "purchased_hwid_devices", None) or getattr( return (
payment, "subscription_duration_months", None getattr(payment, "purchased_hwid_devices", None)
) or 1 or getattr(payment, "subscription_duration_months", None)
or 1
)
return getattr(payment, "subscription_duration_months", None) or 1 return getattr(payment, "subscription_duration_months", None) or 1
+41 -5
View File
@@ -55,6 +55,9 @@ BACKUP_RUNTIME_SETTING_KEYS = {
"BACKUP_COMPOSE_SOURCE_DIR", "BACKUP_COMPOSE_SOURCE_DIR",
"BACKUP_COMPOSE_EXCLUDE_DIRS", "BACKUP_COMPOSE_EXCLUDE_DIRS",
} }
TELEGRAM_DOCUMENT_CAPTION_LIMIT = 1024
TELEGRAM_WARNING_DETAIL_LIMIT = 6
TELEGRAM_WARNING_LINE_LIMIT = 220
@dataclass @dataclass
@@ -263,16 +266,26 @@ class BackupWorker:
def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int: def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int:
source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip() source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip()
if not source_raw: 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 return 0
source_dir = Path(source_raw).expanduser() source_dir = Path(source_raw).expanduser()
if not source_dir.exists() or not source_dir.is_dir(): 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 return 0
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES): 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() excluded_dirs = self._compose_excluded_dirs()
files_count = 0 files_count = 0
@@ -358,8 +371,31 @@ class BackupWorker:
f"Archive size: {self._human_size(result.size_bytes)}", f"Archive size: {self._human_size(result.size_bytes)}",
] ]
if result.warnings: if result.warnings:
lines.append(f"Warnings: {len(result.warnings)}") lines.append(f"Warnings ({len(result.warnings)}):")
return "\n".join(lines) 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 @staticmethod
def _human_size(size_bytes: int) -> str: def _human_size(size_bytes: int) -> str:
+7
View File
@@ -54,6 +54,13 @@ BACKUP_THREAD_ID=123
`COMPOSE_BACKUP_SOURCE=.` означает папку рядом с текущим `docker-compose.yml`. Если compose лежит в другом месте, укажите абсолютный host-путь. `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-файлов из контейнера, задайте: Если нужно запретить восстановление compose-файлов из контейнера, задайте:
```env ```env
+41 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import json import json
import os import os
import zipfile import zipfile
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock 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 send_kwargs = bot.send_document.await_args.kwargs
assert send_kwargs["chat_id"] == 123 assert send_kwargs["chat_id"] == 123
assert "Database dump: yes" in send_kwargs["caption"] assert "Database dump: yes" in send_kwargs["caption"]
assert "Warnings" not in send_kwargs["caption"]
with zipfile.ZipFile(result.archive_path) as archive: with zipfile.ZipFile(result.archive_path) as archive:
names = set(archive.namelist()) 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.archive_path.is_file()
assert result.db_dump_included is True assert result.db_dump_included is True
assert result.compose_files_count == 0 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: with zipfile.ZipFile(result.archive_path) as archive:
names = set(archive.namelist()) names = set(archive.namelist())
assert "database/shop.dump" in names 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): def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch):
from bot.services import settings_override_service from bot.services import settings_override_service