From 3b1edea8af704e114e670e6b609203356fbfaf5e Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 15 Sep 2026 20:57:58 -0700 Subject: [PATCH] fix(web): run manual backups in standalone viewer (#280) --- README.md | 26 +++++++--------------- modules/web_viewer/app.py | 20 ++++++++++++----- tests/test_web_viewer.py | 46 +++++++++++---------------------------- 3 files changed, 35 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 3bad9c5..c563017 100644 --- a/README.md +++ b/README.md @@ -276,16 +276,13 @@ log_max_bytes = 5242880 # 5 MB per file log_backup_count = 3 # number of rotated files to keep ``` -**Database Backup** — schedule automatic backups from the Config tab: +**Database Backup**: schedule automatic backups from the Config tab. The viewer stores these settings in the database, not in a `[Maintenance]` section of `config.ini`: -```ini -[Maintenance] -db_backup_enabled = true -db_backup_schedule = daily # daily | weekly | manual -db_backup_time = 02:00 # HH:MM local time -db_backup_retention_count = 7 -db_backup_dir = /data/backups -``` +- Enabled +- Schedule: daily, weekly, or manual +- Time: local `HH:MM` +- Retention count +- Backup directory The **Maintenance Status** card in the Config tab shows the last backup time, next scheduled run, and log rotation status. @@ -453,15 +450,8 @@ When `json_logging = true` each log line is a JSON object: ``` ### Maintenance -```ini -[Maintenance] -db_backup_enabled = false -db_backup_schedule = daily # daily | weekly | manual -db_backup_time = 02:00 # HH:MM local time -db_backup_retention_count = 7 -db_backup_dir = /data/backups -email_attach_log = false # attach current log file (≤ 5 MB) to nightly email before rotation -``` + +Configure database backups and nightly-email log attachments in the Web Viewer Config tab. These settings are stored in the SQLite `bot_metadata` table and do not use a `[Maintenance]` section in `config.ini`. ### Notifications ```ini diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index bed4da6..9034ad2 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -54,6 +54,7 @@ from modules.db_retention import ( retention_delete_settings, ) from modules.ini_writer import IniValueError, update_ini_values +from modules.maintenance import MaintenanceRunner from modules.scheduled_message_admin import ( SECTION as SCHEDULED_MESSAGES_SECTION, ) @@ -634,6 +635,14 @@ class BotDataViewer: # Now set db_manager on the minimal bot for RepeaterManager minimal_bot.db_manager = self.db_manager + # The viewer runs as a separate process, so it cannot call the bot's + # MessageScheduler directly. MaintenanceRunner only needs this small + # bot facade for manual database backups. + self._maintenance_runner = MaintenanceRunner( + minimal_bot, + get_current_time=datetime.now, + ) + # The viewer only needs RepeaterManager for the manual geocode # endpoint, so defer its setup until that endpoint is actually used. self._repeater_manager_bot = minimal_bot @@ -2200,12 +2209,11 @@ class BotDataViewer: def api_maintenance_backup_now(): """Trigger an immediate DB backup outside the normal schedule.""" try: - bot = getattr(self, 'bot', None) - scheduler = getattr(bot, 'scheduler', None) if bot else None - if scheduler is None or not hasattr(scheduler, 'run_db_backup'): - return jsonify({'success': False, 'error': 'Scheduler not available'}), 503 - scheduler.run_db_backup() - # Read outcome written by _run_db_backup + runner = getattr(self, '_maintenance_runner', None) + if runner is None: + return jsonify({'success': False, 'error': 'Maintenance runner not available'}), 503 + runner.run_db_backup() + # Read the outcome written by MaintenanceRunner. path = self.db_manager.get_metadata('maint.status.db_backup_path') or '' outcome = self.db_manager.get_metadata('maint.status.db_backup_outcome') or '' if outcome.startswith('error'): diff --git a/tests/test_web_viewer.py b/tests/test_web_viewer.py index 5345817..64b1356 100644 --- a/tests/test_web_viewer.py +++ b/tests/test_web_viewer.py @@ -2773,59 +2773,39 @@ class TestDbBackupDirValidation: class TestBackupNowRoute: """Tests for POST /api/maintenance/backup_now endpoint.""" - def test_returns_503_when_no_scheduler(self, viewer): - """Returns 503 when bot/scheduler is not attached.""" - # viewer fixture has no bot attached - with viewer.app.test_client() as c: - resp = c.post("/api/maintenance/backup_now") + def test_returns_503_when_no_maintenance_runner(self, viewer): + """Returns 503 when the viewer's maintenance runner is unavailable.""" + with patch.object(viewer, '_maintenance_runner', None): + with viewer.app.test_client() as c: + resp = c.post("/api/maintenance/backup_now") assert resp.status_code == 503 data = resp.get_json() assert data["success"] is False assert "error" in data # Error details sanitized in 5xx responses def test_returns_200_on_successful_backup(self, viewer, tmp_path): - """Returns 200 with success=True and path when backup succeeds.""" - from unittest.mock import MagicMock, patch + """The standalone viewer performs a real SQLite backup.""" + backup_dir = tmp_path / "backups" + viewer.db_manager.set_metadata('maint.db_backup_dir', str(backup_dir)) - mock_scheduler = MagicMock() - - def fake_run_db_backup(): - # Simulate what run_db_backup writes to metadata - viewer.db_manager.set_metadata( - 'maint.status.db_backup_path', str(tmp_path / "test.db") - ) - viewer.db_manager.set_metadata('maint.status.db_backup_outcome', 'ok') - - mock_scheduler.run_db_backup = fake_run_db_backup - mock_bot = MagicMock() - mock_bot.scheduler = mock_scheduler - - with patch.object(viewer, 'bot', mock_bot, create=True): - with viewer.app.test_client() as c: - resp = c.post("/api/maintenance/backup_now") + with viewer.app.test_client() as c: + resp = c.post("/api/maintenance/backup_now") assert resp.status_code == 200 data = resp.get_json() assert data["success"] is True - assert "test.db" in data["path"] + assert Path(data["path"]).is_file() + assert Path(data["path"]).parent == backup_dir def test_returns_500_on_backup_error(self, viewer): """Returns 500 with success=False when backup writes an error outcome.""" - from unittest.mock import MagicMock, patch - - mock_scheduler = MagicMock() - def fake_run_db_backup(): viewer.db_manager.set_metadata('maint.status.db_backup_path', '') viewer.db_manager.set_metadata( 'maint.status.db_backup_outcome', 'error: cannot create dir' ) - mock_scheduler.run_db_backup = fake_run_db_backup - mock_bot = MagicMock() - mock_bot.scheduler = mock_scheduler - - with patch.object(viewer, 'bot', mock_bot, create=True): + with patch.object(viewer._maintenance_runner, 'run_db_backup', fake_run_db_backup): with viewer.app.test_client() as c: resp = c.post("/api/maintenance/backup_now")