diff --git a/client/package-lock.json b/client/package-lock.json index def452da..f6a3892d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.29.0", + "version": "0.29.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.29.0", + "version": "0.29.1", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", diff --git a/client/package.json b/client/package.json index 56aaf492..146751ed 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "0.29.0", + "version": "0.29.1", "description": "DigiScript front end", "author": "DreamTeamProd", "private": true, diff --git a/client/src/assets/styles/dark.scss b/client/src/assets/styles/dark.scss index d8d2f392..9be3641f 100644 --- a/client/src/assets/styles/dark.scss +++ b/client/src/assets/styles/dark.scss @@ -5,6 +5,10 @@ @import '../../../node_modules/bootswatch/dist/darkly/variables'; @import 'bootstrap/scss/bootstrap'; @import '../../../node_modules/bootswatch/dist/darkly/bootswatch'; +// Use white-fill sort icons so they're visible on the dark background +$b-table-sort-icon-bg-not-sorted: url("data:image/svg+xml,"); +$b-table-sort-icon-bg-ascending: url("data:image/svg+xml,"); +$b-table-sort-icon-bg-descending: url("data:image/svg+xml,"); @import 'bootstrap-vue/src/index.scss'; :root { diff --git a/client/src/types/api/backup.ts b/client/src/types/api/backup.ts new file mode 100644 index 00000000..9d1dbd54 --- /dev/null +++ b/client/src/types/api/backup.ts @@ -0,0 +1,11 @@ +export interface BackupFile { + filename: string; + size_bytes: number; + created_at: number; +} + +export interface BackupsResponse { + backups: BackupFile[]; + count: number; + total_size_bytes: number; +} diff --git a/client/src/views/config/ConfigView.vue b/client/src/views/config/ConfigView.vue index 16996aa5..34401088 100644 --- a/client/src/views/config/ConfigView.vue +++ b/client/src/views/config/ConfigView.vue @@ -19,6 +19,9 @@ + + + @@ -32,9 +35,10 @@ import ConfigSettings from '@/vue_components/config/ConfigSettings.vue'; import ConfigUsers from '@/vue_components/config/ConfigUsers.vue'; import ConfigShows from '@/vue_components/config/ConfigShows.vue'; import ConfigLogs from '@/vue_components/config/ConfigLogs.vue'; +import ConfigBackups from '@/vue_components/config/ConfigBackups.vue'; export default defineComponent({ name: 'ConfigView', - components: { ConfigShows, ConfigUsers, ConfigSettings, ConfigSystem, ConfigLogs }, + components: { ConfigShows, ConfigUsers, ConfigSettings, ConfigSystem, ConfigLogs, ConfigBackups }, }); diff --git a/client/src/vue_components/config/ConfigBackups.vue b/client/src/vue_components/config/ConfigBackups.vue new file mode 100644 index 00000000..43206b2f --- /dev/null +++ b/client/src/vue_components/config/ConfigBackups.vue @@ -0,0 +1,124 @@ + + + + + {{ count }} {{ count === 1 ? 'backup' : 'backups' }} · + {{ formatBytes(totalSizeBytes) }} total + + + + {{ formatBytes(data.item.size_bytes) }} + + + {{ formatDate(data.item.created_at) }} + + + + Delete + + + + + + No backup files found. Backups are created automatically before database migrations. + + + + + + + + diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md index 7d734696..ee2e82f4 100644 --- a/docs/pages/user_config.md +++ b/docs/pages/user_config.md @@ -66,6 +66,18 @@ Once users have been created, their permissions can be configured by clicking th RBAC configuration determines what shows a user can access and what actions they can perform within those shows. +### Backup Management + +The **Backups** tab allows admin users to view and manage database backup files. DigiScript automatically creates a timestamped copy of the database file before running any database migration, ensuring you can recover data if a migration causes issues. + +The tab displays: +- A summary line showing the total number of backups and combined disk usage +- A table listing each backup file with its filename, size, and creation date + +To delete a backup, click the **Delete** button next to it and confirm the action. Deletion is permanent and cannot be undone. + +> **Note:** Backup files accumulate over time as you upgrade DigiScript. Periodically reviewing and removing old backups helps reclaim disk space once you are confident the corresponding migrations completed successfully. + ### RBAC Roles and Mappings The current RBAC mappings are as follows: diff --git a/electron/package-lock.json b/electron/package-lock.json index 3daab0ff..19458f92 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "digiscript-electron", - "version": "0.29.0", + "version": "0.29.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digiscript-electron", - "version": "0.29.0", + "version": "0.29.1", "license": "GPL-3.0", "dependencies": { "bonjour-service": "^1.3.0", diff --git a/electron/package.json b/electron/package.json index 3a44ac12..354eb792 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "digiscript-electron", - "version": "0.29.0", + "version": "0.29.1", "description": "DigiScript Electron Desktop Application", "author": "DreamTeamProd", "license": "GPL-3.0", diff --git a/server/controllers/api/db_backups.py b/server/controllers/api/db_backups.py new file mode 100644 index 00000000..4e1a5e4b --- /dev/null +++ b/server/controllers/api/db_backups.py @@ -0,0 +1,92 @@ +import os + +from utils.web.base_controller import BaseAPIController +from utils.web.route import ApiRoute, ApiVersion +from utils.web.web_decorators import api_authenticated, require_admin + + +def _get_db_file_path(application) -> str: + """ + :return: Absolute filesystem path to the SQLite database file. + :rtype: str + """ + db_path: str = application.digi_settings.settings.get("db_path").get_value() + if db_path.startswith("sqlite:///"): + db_path = db_path.replace("sqlite:///", "") + return db_path + + +def _list_backups(db_path: str) -> list[dict]: + """ + :param db_path: Filesystem path to the main database file. + :type db_path: str + :return: List of backup file dicts, sorted newest first. + :rtype: list[dict] + """ + db_dir = os.path.dirname(db_path) or "." + db_basename = os.path.basename(db_path) + prefix = db_basename + "." + + backups = [] + if os.path.isdir(db_dir): + for fname in os.listdir(db_dir): + if not fname.startswith(prefix): + continue + suffix = fname[len(prefix) :] + if not suffix.isdigit(): + continue + full_path = os.path.join(db_dir, fname) + stat = os.stat(full_path) + backups.append( + { + "filename": fname, + "size_bytes": stat.st_size, + "created_at": int(suffix), + } + ) + + backups.sort(key=lambda x: x["created_at"], reverse=True) + return backups + + +@ApiRoute("admin/db-backups", ApiVersion.V1) +class BackupsController(BaseAPIController): + @api_authenticated + @require_admin + async def get(self): + db_path = _get_db_file_path(self.application) + backups = _list_backups(db_path) + total_size = sum(b["size_bytes"] for b in backups) + self.set_status(200) + await self.finish( + { + "backups": backups, + "count": len(backups), + "total_size_bytes": total_size, + } + ) + + @api_authenticated + @require_admin + async def delete(self): + timestamp = self.get_argument("timestamp", None) + if not timestamp: + self.set_status(400) + await self.finish({"message": "timestamp query argument is required"}) + return + if not timestamp.isdigit(): + self.set_status(400) + await self.finish({"message": "timestamp must be a positive integer"}) + return + + db_path = _get_db_file_path(self.application) + backup_path = f"{db_path}.{timestamp}" + + if not os.path.isfile(backup_path): + self.set_status(404) + await self.finish({"message": "Backup file not found"}) + return + + os.remove(backup_path) + self.set_status(200) + await self.finish({"message": "Backup deleted"}) diff --git a/server/pyproject.toml b/server/pyproject.toml index 3355095e..cef65f48 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "digiscript-server" -version = "0.29.0" +version = "0.29.1" description = "DigiScript server - Digital script management for theatrical shows" readme = "../README.md" requires-python = ">=3.13" diff --git a/server/test/controllers/api/test_db_backups.py b/server/test/controllers/api/test_db_backups.py new file mode 100644 index 00000000..8f7424be --- /dev/null +++ b/server/test/controllers/api/test_db_backups.py @@ -0,0 +1,219 @@ +"""Integration tests for GET /api/v1/admin/db-backups and DELETE /api/v1/admin/db-backups.""" + +import os +import shutil +import tempfile + +from tornado import escape + +from test.conftest import DigiScriptTestCase + + +class TestDbBackupsController(DigiScriptTestCase): + def setUp(self): + super().setUp() + self._tmp_dir = tempfile.mkdtemp() + self._db_file = os.path.join(self._tmp_dir, "digiscript.sqlite") + open(self._db_file, "wb").close() + # Point the db_path setting at our temp file so the controller can find it + self._app.digi_settings.settings.get("db_path").set_value( + f"sqlite:///{self._db_file}" + ) + + def tearDown(self): + shutil.rmtree(self._tmp_dir, ignore_errors=True) + super().tearDown() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _create_backup(self, timestamp: int) -> str: + backup_path = f"{self._db_file}.{timestamp}" + with open(backup_path, "wb") as f: + f.write(b"x" * 1024) + return backup_path + + def _create_and_login_admin(self, username="admin", password="adminpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": True} + ), + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] + + def _create_and_login_user(self, admin_token, username="user", password="userpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": False} + ), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] + + def _fetch_backups(self, token=None): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return self.fetch( + "/api/v1/admin/db-backups", headers=headers, raise_error=False + ) + + def _delete_backup(self, timestamp, token=None): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return self.fetch( + f"/api/v1/admin/db-backups?timestamp={timestamp}", + method="DELETE", + headers=headers, + raise_error=False, + ) + + # ------------------------------------------------------------------ + # GET — authentication + # ------------------------------------------------------------------ + + def test_get_unauthenticated_returns_401(self): + resp = self._fetch_backups() + self.assertEqual(401, resp.code) + + def test_get_non_admin_returns_401(self): + admin_token = self._create_and_login_admin() + user_token = self._create_and_login_user(admin_token) + resp = self._fetch_backups(token=user_token) + self.assertEqual(401, resp.code) + + # ------------------------------------------------------------------ + # GET — response shape + # ------------------------------------------------------------------ + + def test_get_admin_no_backups_returns_empty_list(self): + token = self._create_and_login_admin() + resp = self._fetch_backups(token=token) + self.assertEqual(200, resp.code) + body = escape.json_decode(resp.body) + self.assertEqual([], body["backups"]) + self.assertEqual(0, body["count"]) + self.assertEqual(0, body["total_size_bytes"]) + + def test_get_admin_lists_backup_files(self): + token = self._create_and_login_admin() + ts_old = 1700000000 + ts_new = 1700001000 + self._create_backup(ts_old) + self._create_backup(ts_new) + + resp = self._fetch_backups(token=token) + self.assertEqual(200, resp.code) + body = escape.json_decode(resp.body) + self.assertEqual(2, body["count"]) + self.assertGreater(body["total_size_bytes"], 0) + + timestamps = [b["created_at"] for b in body["backups"]] + self.assertEqual([ts_new, ts_old], timestamps, "Backups should be newest-first") + + def test_get_backup_metadata_fields(self): + token = self._create_and_login_admin() + ts = 1700000000 + self._create_backup(ts) + + resp = self._fetch_backups(token=token) + body = escape.json_decode(resp.body) + backup = body["backups"][0] + self.assertIn("filename", backup) + self.assertIn("size_bytes", backup) + self.assertIn("created_at", backup) + self.assertEqual(ts, backup["created_at"]) + self.assertEqual(1024, backup["size_bytes"]) + + def test_get_ignores_non_backup_files(self): + """Files without a digits-only suffix must not appear in the list.""" + token = self._create_and_login_admin() + # Create a file that matches the prefix but has a non-digits suffix + open(f"{self._db_file}.notabackup", "wb").close() + open(f"{self._db_file}.123abc", "wb").close() + + resp = self._fetch_backups(token=token) + body = escape.json_decode(resp.body) + self.assertEqual(0, body["count"]) + + # ------------------------------------------------------------------ + # DELETE — authentication + # ------------------------------------------------------------------ + + def test_delete_unauthenticated_returns_401(self): + resp = self._delete_backup(1700000000) + self.assertEqual(401, resp.code) + + def test_delete_non_admin_returns_401(self): + admin_token = self._create_and_login_admin() + user_token = self._create_and_login_user(admin_token) + resp = self._delete_backup(1700000000, token=user_token) + self.assertEqual(401, resp.code) + + # ------------------------------------------------------------------ + # DELETE — validation + # ------------------------------------------------------------------ + + def test_delete_missing_timestamp_returns_400(self): + token = self._create_and_login_admin() + resp = self.fetch( + "/api/v1/admin/db-backups", + method="DELETE", + headers={"Authorization": f"Bearer {token}"}, + raise_error=False, + ) + self.assertEqual(400, resp.code) + + def test_delete_non_digits_timestamp_returns_400(self): + token = self._create_and_login_admin() + resp = self._delete_backup("abc123", token=token) + self.assertEqual(400, resp.code) + + def test_delete_nonexistent_timestamp_returns_404(self): + token = self._create_and_login_admin() + resp = self._delete_backup(9999999999, token=token) + self.assertEqual(404, resp.code) + + # ------------------------------------------------------------------ + # DELETE — success + # ------------------------------------------------------------------ + + def test_delete_valid_backup_removes_file(self): + token = self._create_and_login_admin() + ts = 1700000000 + backup_path = self._create_backup(ts) + self.assertTrue(os.path.isfile(backup_path)) + + resp = self._delete_backup(ts, token=token) + self.assertEqual(200, resp.code) + self.assertFalse(os.path.isfile(backup_path)) + + def test_delete_reduces_count_in_subsequent_get(self): + token = self._create_and_login_admin() + ts1 = 1700000000 + ts2 = 1700001000 + self._create_backup(ts1) + self._create_backup(ts2) + + self._delete_backup(ts1, token=token) + + resp = self._fetch_backups(token=token) + body = escape.json_decode(resp.body) + self.assertEqual(1, body["count"]) + self.assertEqual(ts2, body["backups"][0]["created_at"])
+ {{ count }} {{ count === 1 ? 'backup' : 'backups' }} · + {{ formatBytes(totalSizeBytes) }} total +