diff --git a/README.md b/README.md index 81a1438..de70c27 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ERPNext is not required. - **Encrypted off-site snapshots:** Restic encrypts and deduplicates; credentials stay in environment variables and are never shown in the desk. - **Schedules:** pick weekdays and times in the site's timezone, or run a backup by hand. -- **Backup Control page:** backup history, off-site snapshot state, settings and actions in +- **Backup Control page:** backup history, off-site snapshot state and actions in one place, restricted to System Managers. - **Retention:** local backup sets and off-site snapshots are pruned automatically; the repository is checked weekly. @@ -63,11 +63,11 @@ server and the background workers): Use a separate repository path per environment. The repository is initialised on the first upload. -Then open **Restic Backup Settings**: +Then open **Restic Backup → Backup Settings**: - **Enable Scheduled Backups** and add schedule rows. - **Local Backup Sets** (default 4) are kept on the server after a successful upload. -- **Remote Retention (Days)** (default 14) keeps snapshots in that window, and always the newest. +- **Off-site Retention (Days)** (default 14) keeps snapshots in that window, and always the newest. - **Email on Success** also emails System Managers about successful runs. They are always told about failures, in the notification bell and by email. diff --git a/frappe_restic/public/scss/restic_backups.bundle.scss b/frappe_restic/public/scss/restic_backups.bundle.scss index b75c24f..7b9b8c8 100644 --- a/frappe_restic/public/scss/restic_backups.bundle.scss +++ b/frappe_restic/public/scss/restic_backups.bundle.scss @@ -1,17 +1,13 @@ // Frappe themes its own .frappe-card, but Bootstrap's .card stays white in dark mode. .restic-backup-control, -.restic-backup-settings-dialog { +.restic-backup-settings { .card { border-color: var(--border-color); background-color: var(--card-bg); } } -.restic-backup-settings-dialog { - .modal-dialog { - max-width: 880px; - } - +.restic-backup-settings { .restic-backup-schedule-editor { display: grid; gap: 0.75rem; diff --git a/frappe_restic/restic_backup/backup_control.py b/frappe_restic/restic_backup/backup_control.py index 2946b3f..bc818c5 100644 --- a/frappe_restic/restic_backup/backup_control.py +++ b/frappe_restic/restic_backup/backup_control.py @@ -17,7 +17,6 @@ from frappe.utils import cint, get_time, now_datetime from frappe.utils.background_jobs import enqueue from frappe.utils.backups import BackupGenerator, get_backup_path -from frappe.utils.user import get_users_with_role from redis.exceptions import LockError from frappe_restic.config import namespace @@ -475,10 +474,12 @@ def _send_alert(subject: str, message: str, *, succeeded: bool = False, run_name if succeeded and not _settings_int("email_on_success", 0): return "Not requested" try: + # Frappe's own role lookups leave out Administrator, the only manager on a small site. + names = frappe.get_all( + "Has Role", filters={"role": SYSTEM_MANAGER_ROLE, "parenttype": "User"}, pluck="parent" + ) managers = frappe.get_all( - "User", - filters={"name": ["in", get_users_with_role(SYSTEM_MANAGER_ROLE)]}, - fields=["name", "email"], + "User", filters={"name": ["in", names], "enabled": 1}, fields=["name", "email"] ) except Exception: # notification failure must not replace the operation outcome frappe.logger("restic_backup").exception("Could not find the System Managers to notify") diff --git a/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json b/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json index 72ad1a7..3f64904 100644 --- a/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json +++ b/frappe_restic/restic_backup/doctype/restic_backup_schedule_entry/restic_backup_schedule_entry.json @@ -10,7 +10,7 @@ ], "fields": [ { - "description": "Comma-separated weekdays. Backup Control presents these as selectable day pills.", + "description": "Comma-separated weekdays. Backup Settings presents these as selectable day pills.", "fieldname": "days", "fieldtype": "Data", "in_list_view": 1, diff --git a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js index c40f3ea..2cbbd55 100644 --- a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js +++ b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.js @@ -1,8 +1,197 @@ +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + frappe.ui.form.on("Restic Backup Settings", { refresh(frm) { frm.page.set_title(__("Backup Settings")); frm.add_custom_button(__("Back to Backup Control"), () => { frappe.set_route("restic-backup-control"); }); + bind_schedule_editor(frm); + render_schedule_editor(frm); + render_storage_configuration(frm); + }, + + validate(frm) { + const problem = schedule_problem(frm.doc.backup_schedule || []); + if (problem) { + frappe.msgprint(problem); + frappe.validated = false; + } }, }); + +function selected_days(row) { + return (row.days || "") + .split(",") + .map((day) => day.trim()) + .filter(Boolean); +} + +// A time loaded from the database has no leading zero ("9:00:00"), which a time input rejects. +function clock_time(value) { + if (!value) return ""; + return String(value) + .split(":") + .slice(0, 2) + .map((part) => part.padStart(2, "0")) + .join(":"); +} + +// The schedule is stored in the hidden table; this editor only reads and writes its rows. +function render_schedule_editor(frm) { + const rows = (frm.doc.backup_schedule || []) + .map((row, index) => { + const days = selected_days(row); + const day_buttons = WEEKDAYS.map((day) => { + const selected = days.includes(day); + return ``; + }).join(""); + return `
+
+
${__("Days")}
+
${day_buttons}
+
+ + +
`; + }) + .join(""); + + frm.fields_dict.schedule_editor.$wrapper.addClass("restic-backup-settings") + .html(`
+

${__( + "Each row runs once at the selected time on every selected day." + )}

+ ${rows || `
${__("No schedule rows yet.")}
`} + +
`); +} + +function bind_schedule_editor(frm) { + const $wrapper = frm.fields_dict.schedule_editor.$wrapper; + const row_at = (element) => frm.doc.backup_schedule[Number(element.dataset.index)]; + + $wrapper.off(".resticBackupSchedule"); + $wrapper.on("click.resticBackupSchedule", ".restic-backup-day", (event) => { + const row = row_at(event.currentTarget); + const day = event.currentTarget.dataset.day; + const days = selected_days(row); + row.days = WEEKDAYS.filter((value) => + value === day ? !days.includes(day) : days.includes(value) + ).join(","); + frm.dirty(); + render_schedule_editor(frm); + }); + $wrapper.on("change.resticBackupSchedule", 'input[type="time"]', (event) => { + row_at(event.currentTarget).backup_time = event.currentTarget.value; + frm.dirty(); + }); + $wrapper.on("click.resticBackupSchedule", ".restic-backup-remove", (event) => { + const row = row_at(event.currentTarget); + frappe.model.clear_doc(row.doctype, row.name); + frm.dirty(); + render_schedule_editor(frm); + }); + $wrapper.on("click.resticBackupSchedule", ".restic-backup-add", () => { + frm.add_child("backup_schedule", { days: "", backup_time: "" }); + frm.dirty(); + render_schedule_editor(frm); + }); +} + +function schedule_problem(rows) { + if (!rows.length) { + return __("Add at least one backup schedule."); + } + const scheduled_slots = new Map(); + for (const [index, row] of rows.entries()) { + const days = selected_days(row); + const time = clock_time(row.backup_time); + if (!days.length || !time) { + return __("Choose at least one day and a time in schedule row {0}.", [index + 1]); + } + for (const day of days) { + const slot = `${day}|${time}`; + if (scheduled_slots.has(slot)) { + return __("Schedule rows {0} and {1} both include {2} at {3}.", [ + scheduled_slots.get(slot), + index + 1, + __(day), + time, + ]); + } + scheduled_slots.set(slot, index + 1); + } + } + return null; +} + +async function render_storage_configuration(frm) { + const configuration = await frappe.xcall( + "frappe_restic.restic_backup.page.restic_backup_control.restic_backup_control.get_configuration" + ); + frm.fields_dict.offsite_configuration.$wrapper + .addClass("restic-backup-settings") + .html(storage_configuration_html(configuration)); +} + +function storage_configuration_html(configuration) { + const status_rows = configuration.required_variables + .map((variable) => { + const indicator = variable.configured ? "green" : "red"; + const status = variable.configured ? __("Configured") : __("Missing"); + return `
+ ${frappe.utils.escape_html(variable.name)} + ${status} +
`; + }) + .join(""); + const deployment_status = configuration.offsite_enabled + ? `${__("Enabled")}` + : `${__("Disabled")}`; + const storage_status = configuration.restic_ready + ? `${__("Configured")}` + : `${__("Credentials missing")}`; + return `

${ + configuration.offsite_enabled + ? __("Off-site uploads are enabled by deployment configuration.") + : __( + "Off-site uploads are disabled by deployment configuration. Scheduled and manual backups stay local." + ) + }

+
+ + ${__("Storage configuration")} + ${storage_status} + +
+

${__( + "Set these in the deployment environment, not in Frappe, then restart or redeploy." + )}

+

${__( + "Coolify: application Environment Variables. Docker Compose: the .env file supplied to Compose." + )}

+ ${status_rows} +
+ RESTIC_OFFSITE_BACKUP_ENABLED (${__( + "all backup uploads" + )}) + ${deployment_status} +
+

${__( + "RESTIC_REPOSITORY format: s3:https://s3..backblazeb2.com//restic. Use a bucket-restricted Backblaze application key." + )}

+
+
`; +} diff --git a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.json b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.json index c6de139..aa5e1c5 100644 --- a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.json +++ b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.json @@ -6,7 +6,10 @@ "field_order": [ "enabled", "schedule_section", + "schedule_editor", "backup_schedule", + "storage_section", + "offsite_configuration", "retention_section", "local_backup_limit", "retention_column", @@ -27,12 +30,25 @@ "fieldtype": "Section Break", "label": "Schedule" }, + { + "fieldname": "schedule_editor", + "fieldtype": "HTML" + }, { "fieldname": "backup_schedule", "fieldtype": "Table", + "hidden": 1, "label": "Schedule", - "options": "Restic Backup Schedule Entry", - "reqd": 1 + "options": "Restic Backup Schedule Entry" + }, + { + "fieldname": "storage_section", + "fieldtype": "Section Break", + "label": "Off-site Storage" + }, + { + "fieldname": "offsite_configuration", + "fieldtype": "HTML" }, { "fieldname": "retention_section", @@ -41,7 +57,7 @@ }, { "default": "4", - "description": "Complete default-path backup sets retained locally after successful off-site upload.", + "description": "Complete backup sets kept on this server.", "fieldname": "local_backup_limit", "fieldtype": "Int", "label": "Local Backup Sets", @@ -53,10 +69,10 @@ }, { "default": "14", - "description": "Restic keeps snapshots within this window and always keeps the newest snapshot.", + "description": "The newest off-site snapshot is always retained.", "fieldname": "remote_keep_days", "fieldtype": "Int", - "label": "Remote Retention (Days)", + "label": "Off-site Retention (Days)", "reqd": 1 }, { @@ -74,7 +90,7 @@ ], "issingle": 1, "links": [], - "modified": "2026-10-07 19:00:00.000000", + "modified": "2026-10-07 20:00:00.000000", "modified_by": "Administrator", "module": "Restic Backup", "name": "Restic Backup Settings", diff --git a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.py b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.py index 8f8c6d8..58ee679 100644 --- a/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.py +++ b/frappe_restic/restic_backup/doctype/restic_backup_settings/restic_backup_settings.py @@ -47,7 +47,7 @@ def validate(self) -> None: if not 1 <= cint(self.local_backup_limit) <= 30: frappe.throw(_("Local Backup Sets must be between 1 and 30.")) if not 1 <= cint(self.remote_keep_days) <= 3650: - frappe.throw(_("Remote Retention must be between 1 and 3650 days.")) + frappe.throw(_("Off-site Retention must be between 1 and 3650 days.")) frappe.db.set_single_value("System Settings", "backup_limit", cint(self.local_backup_limit)) diff --git a/frappe_restic/restic_backup/doctype/restic_backup_settings/test_restic_backup_settings.py b/frappe_restic/restic_backup/doctype/restic_backup_settings/test_restic_backup_settings.py index 165adbe..fc43b4a 100644 --- a/frappe_restic/restic_backup/doctype/restic_backup_settings/test_restic_backup_settings.py +++ b/frappe_restic/restic_backup/doctype/restic_backup_settings/test_restic_backup_settings.py @@ -16,25 +16,31 @@ parse_backup_schedule, ) from frappe_restic.restic_backup.page.restic_backup_control.restic_backup_control import ( - save_settings, + get_configuration, ) class TestResticBackupSettings(FrappeTestCase): - def test_settings_use_compact_schedule_editor_instead_of_frappe_grid(self) -> None: + def test_settings_form_edits_the_schedule_with_day_pills_instead_of_the_grid(self) -> None: app_path = Path(frappe.get_app_path("frappe_restic")) - page_script = ( - app_path / "restic_backup" / "page" / "restic_backup_control" / "restic_backup_control.js" + module_path = app_path / "restic_backup" + form_script = ( + module_path / "doctype" / "restic_backup_settings" / "restic_backup_settings.js" ).read_text(encoding="utf-8") + page_script = (module_path / "page" / "restic_backup_control" / "restic_backup_control.js").read_text( + encoding="utf-8" + ) stylesheet = (app_path / "public" / "scss" / "restic_backups.bundle.scss").read_text(encoding="utf-8") - - self.assertNotIn('fieldtype: "Table"', page_script) - self.assertNotIn('fieldtype: "MultiSelectPills"', page_script) - self.assertIn("renderScheduleEditor", page_script) - self.assertIn("setScheduleEditorVisibility", page_script) - self.assertIn("Boolean(Number(values.enabled))", page_script) - self.assertIn('
', page_script) - self.assertIn("restic-backup-day", page_script) + meta = frappe.get_meta("Restic Backup Settings") + + self.assertTrue(meta.get_field("backup_schedule").hidden) + self.assertEqual("HTML", meta.get_field("schedule_editor").fieldtype) + self.assertEqual("HTML", meta.get_field("offsite_configuration").fieldtype) + self.assertIn("restic-backup-day", form_script) + self.assertIn('
', form_script) + self.assertIn('frappe.set_route("Form", "Restic Backup Settings")', page_script) + self.assertNotIn("openSettings", page_script) + self.assertIn(".restic-backup-settings {", stylesheet) self.assertIn("restic-backup-offsite-summary", stylesheet) self.assertIn("restic-backup-schedule-row", stylesheet) self.assertIn("overflow-wrap: anywhere", stylesheet) @@ -79,51 +85,37 @@ def test_system_manager_can_call_backup_endpoints(self) -> None: with patch("frappe.get_roles", return_value=["System Manager"]): _require_system_manager() - def test_system_manager_can_save_settings_from_backup_control(self) -> None: - values = { - "enabled": 1, - "backup_schedule": [ - { - "days": [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ], - "backup_time": "12:30:00", - }, - {"days": ["Sunday"], "backup_time": "02:00:00"}, + def test_saving_settings_sorts_the_schedule_by_time(self) -> None: + workdays = "Monday,Tuesday,Wednesday,Thursday,Friday,Saturday" + settings = frappe.get_single("Restic Backup Settings") + settings.update( + {"enabled": 1, "local_backup_limit": 5, "remote_keep_days": 21, "email_on_success": 1} + ) + settings.set( + "backup_schedule", + [ + {"days": workdays, "backup_time": "12:30"}, + {"days": "Sunday", "backup_time": "02:00:00"}, ], - "local_backup_limit": 5, - "remote_keep_days": 21, - "email_on_success": 1, - } - - with patch("frappe.get_roles", return_value=["System Manager"]): - result = save_settings(values) + ) + settings.save() self.assertEqual( - result["schedule"], - [ - {"days": ["Sunday"], "backup_time": "02:00:00"}, - { - "days": [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ], - "backup_time": "12:30:00", - }, - ], + [(row.days, str(row.backup_time)) for row in settings.backup_schedule], + [("Sunday", "02:00:00"), (workdays, "12:30:00")], + ) + self.assertEqual(5, frappe.db.get_single_value("System Settings", "backup_limit")) + + def test_only_system_managers_can_read_the_storage_configuration(self) -> None: + with patch("frappe.get_roles", return_value=["Sales User"]): + with self.assertRaises(frappe.PermissionError): + get_configuration() + with patch("frappe.get_roles", return_value=["System Manager"]): + configuration = get_configuration() + self.assertIn("RESTIC_REPOSITORY", [row["name"] for row in configuration["required_variables"]]) + self.assertTrue( + all(set(row) == {"name", "configured"} for row in configuration["required_variables"]) ) - self.assertEqual(result["local_backup_limit"], 5) - self.assertEqual(result["remote_keep_days"], 21) - self.assertEqual(result["email_on_success"], 1) def test_schedule_rejects_duplicate_day_and_time(self) -> None: rows = [ diff --git a/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.js b/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.js index 67bf98d..fd7e964 100644 --- a/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.js +++ b/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.js @@ -6,14 +6,18 @@ frappe.pages["restic-backup-control"].on_page_load = function (wrapper) { }); page.add_inner_button(__("Native Backups"), () => frappe.set_route("backups")); page.add_inner_button(__("Backup Runs"), () => frappe.set_route("List", "Restic Backup Run")); - const backupControl = new ResticBackupControl(page, wrapper); - page.add_inner_button(__("Settings"), () => backupControl.openSettings()); + page.add_inner_button(__("Settings"), () => + frappe.set_route("Form", "Restic Backup Settings") + ); + wrapper.backup_control = new ResticBackupControl(page, wrapper); }; // Frappe v16 picks the sidebar for a page from the module of the last doctype opened, so opening // this page from another app's screen keeps that app's sidebar. Pick again from this page's route. -frappe.pages["restic-backup-control"].on_page_show = function () { +frappe.pages["restic-backup-control"].on_page_show = function (wrapper) { frappe.app.sidebar?.set_workspace_sidebar?.(); + // Settings are edited on their own form, so reload what this page shows of them on return. + wrapper.backup_control?.refresh(); }; class ResticBackupControl { @@ -31,7 +35,6 @@ class ResticBackupControl { this.$main.on("click", ".restic-backup-details", (event) => { this.showDetails(this.data.items[Number(event.currentTarget.dataset.index)]); }); - this.refresh(); } async refresh() { @@ -105,274 +108,6 @@ class ResticBackupControl { `); } - async openSettings() { - if (!this.data) await this.refresh(); - const settings = this.data.settings; - const weekdays = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", - ]; - const schedule = settings.schedule.map((entry) => ({ - ...entry, - days: [...entry.days], - })); - const dialog = new frappe.ui.Dialog({ - title: __("Backup Settings"), - size: "large", - fields: [ - { fieldtype: "Section Break", label: __("Schedule") }, - { - fieldtype: "Check", - fieldname: "enabled", - label: __("Enable scheduled backups"), - default: settings.enabled, - description: __( - "Manual backups remain available when the schedule is disabled." - ), - }, - { - fieldtype: "HTML", - options: `

${ - this.data.configuration.offsite_enabled - ? __("Off-site uploads are enabled by deployment configuration.") - : __( - "Off-site uploads are disabled by deployment configuration. Scheduled and manual backups stay local." - ) - }

`, - }, - { - fieldtype: "HTML", - fieldname: "backup_schedule", - }, - { fieldtype: "Section Break", label: __("Off-site storage") }, - { - fieldtype: "HTML", - fieldname: "offsite_configuration", - options: this.offsiteConfigurationHtml(this.data.configuration), - }, - { fieldtype: "Section Break", label: __("Retention") }, - { - fieldtype: "Int", - fieldname: "local_backup_limit", - label: __("Local backup sets"), - reqd: true, - default: settings.local_backup_limit, - description: __("Complete backup sets kept on this server."), - }, - { fieldtype: "Column Break" }, - { - fieldtype: "Int", - fieldname: "remote_keep_days", - label: __("Off-site retention (days)"), - reqd: true, - default: settings.remote_keep_days, - description: __("The newest off-site snapshot is always retained."), - }, - { fieldtype: "Section Break", label: __("Email notifications") }, - { - fieldtype: "Check", - fieldname: "email_on_success", - label: __("Email successful runs"), - default: settings.email_on_success, - description: __( - "System Managers are always told about failures, in the notification bell and by email." - ), - }, - ], - primary_action_label: __("Save"), - primary_action: async (values) => { - const enabled = Boolean(Number(values.enabled)); - if (enabled && !this.validateSchedule(schedule)) return; - const primaryButton = dialog.get_primary_btn(); - primaryButton.prop("disabled", true); - try { - this.data.settings = await frappe.xcall( - "frappe_restic.restic_backup.page.restic_backup_control.restic_backup_control.save_settings", - { - values: { - ...values, - backup_schedule: enabled ? schedule : settings.schedule, - }, - } - ); - dialog.hide(); - this.render(); - frappe.show_alert({ - message: __("Backup settings saved"), - indicator: "green", - }); - } finally { - primaryButton.prop("disabled", false); - } - }, - }); - dialog.show(); - dialog.$wrapper.addClass("restic-backup-settings-dialog"); - dialog.fields_dict.enabled.$input.on("change.resticBackupSchedule", () => { - this.setScheduleEditorVisibility(dialog); - }); - this.renderScheduleEditor(dialog.fields_dict.backup_schedule.$wrapper, schedule, weekdays); - this.setScheduleEditorVisibility(dialog); - } - - setScheduleEditorVisibility(dialog) { - const enabled = Boolean(Number(dialog.get_value("enabled"))); - dialog.fields_dict.backup_schedule.$wrapper.toggle(enabled); - } - - renderScheduleEditor($wrapper, schedule, weekdays) { - const render = () => { - const rows = schedule - .map((entry, index) => { - const dayButtons = weekdays - .map((day) => { - const selected = entry.days.includes(day); - return ``; - }) - .join(""); - return `
-
-
${__("Days")}
-
${dayButtons}
-
- - -
`; - }) - .join(""); - - $wrapper.html(`
-

${__( - "Each row runs once at the selected time on every selected day." - )}

- ${rows || `
${__("No schedule rows yet.")}
`} - -
`); - }; - - $wrapper.off(".resticBackupSchedule"); - $wrapper.on("click.resticBackupSchedule", ".restic-backup-day", (event) => { - const button = event.currentTarget; - const index = Number(button.dataset.index); - const day = button.dataset.day; - const selectedDays = schedule[index].days; - if (selectedDays.includes(day)) { - schedule[index].days = selectedDays.filter((value) => value !== day); - } else { - schedule[index].days = weekdays.filter( - (value) => selectedDays.includes(value) || value === day - ); - } - render(); - }); - $wrapper.on("change.resticBackupSchedule", 'input[type="time"]', (event) => { - const index = Number(event.currentTarget.dataset.index); - schedule[index].backup_time = event.currentTarget.value; - }); - $wrapper.on("click.resticBackupSchedule", ".restic-backup-remove", (event) => { - schedule.splice(Number(event.currentTarget.dataset.index), 1); - render(); - }); - $wrapper.on("click.resticBackupSchedule", ".restic-backup-add", () => { - schedule.push({ days: [], backup_time: "" }); - render(); - }); - render(); - } - - validateSchedule(schedule) { - if (!schedule.length) { - frappe.msgprint(__("Add at least one backup schedule.")); - return false; - } - const invalidIndex = schedule.findIndex( - (entry) => !entry.days.length || !entry.backup_time - ); - if (invalidIndex !== -1) { - frappe.msgprint( - __("Choose at least one day and a time in schedule row {0}.", [invalidIndex + 1]) - ); - return false; - } - const scheduledSlots = new Map(); - for (const [index, entry] of schedule.entries()) { - for (const day of entry.days) { - const slot = `${day}|${entry.backup_time}`; - if (scheduledSlots.has(slot)) { - frappe.msgprint( - __("Schedule rows {0} and {1} both include {2} at {3}.", [ - scheduledSlots.get(slot), - index + 1, - __(day), - entry.backup_time, - ]) - ); - return false; - } - scheduledSlots.set(slot, index + 1); - } - } - return true; - } - - offsiteConfigurationHtml(configuration) { - const statusRows = configuration.required_variables - .map((variable) => { - const indicator = variable.configured ? "green" : "red"; - const status = variable.configured ? __("Configured") : __("Missing"); - return `
- ${frappe.utils.escape_html(variable.name)} - ${status} -
`; - }) - .join(""); - const deploymentStatus = configuration.offsite_enabled - ? `${__("Enabled")}` - : `${__("Disabled")}`; - const storageStatus = configuration.restic_ready - ? `${__("Configured")}` - : `${__("Credentials missing")}`; - return `
- - ${__("Storage configuration")} - ${storageStatus} - -
-

${__( - "Set these in the deployment environment, not in Frappe, then restart or redeploy." - )}

-

${__( - "Coolify: application Environment Variables. Docker Compose: the .env file supplied to Compose." - )}

- ${statusRows} -
- RESTIC_OFFSITE_BACKUP_ENABLED (${__( - "all backup uploads" - )}) - ${deploymentStatus} -
-

${__( - "RESTIC_REPOSITORY format: s3:https://s3..backblazeb2.com//restic. Use a bucket-restricted Backblaze application key." - )}

-
`; - } - card(title, value, indicator) { return `
${frappe.utils.escape_html(title)}
diff --git a/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.py b/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.py index cd5e221..f1e0efc 100644 --- a/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.py +++ b/frappe_restic/restic_backup/page/restic_backup_control/restic_backup_control.py @@ -15,23 +15,12 @@ from frappe.utils.backups import get_backup_path from frappe_restic.restic_backup.backup_control import _require_system_manager -from frappe_restic.restic_backup.doctype.restic_backup_settings.restic_backup_settings import ( - parse_working_days, -) from frappe_restic.restic_backup.restic_transport import ( REQUIRED_RESTIC_ENV, _run, restic_is_configured, ) -EDITABLE_SETTING_FIELDS = { - "enabled", - "backup_schedule", - "local_backup_limit", - "remote_keep_days", - "email_on_success", -} - @frappe.whitelist() def get_recovery_details(run_name: str | None = None, snapshot_id: str | None = None) -> dict[str, str]: @@ -93,15 +82,8 @@ def get_dashboard(page: int = 1) -> dict[str, Any]: items = _backup_inventory(runs, _native_backups(), _deployment_backups()) page = min(max(1, cint(page)), max(1, (len(items) + 9) // 10)) return { - "settings": _settings_payload(settings), - "configuration": { - "restic_ready": restic_is_configured(), - "required_variables": [ - {"name": name, "configured": bool(os.environ.get(name))} for name in REQUIRED_RESTIC_ENV - ], - "offsite_enabled": _env_flag("RESTIC_OFFSITE_BACKUP_ENABLED"), - "uptime_kuma_ready": bool(os.environ.get("RESTIC_BACKUP_UPTIME_KUMA_URL")), - }, + "settings": {"enabled": cint(settings.enabled)}, + "configuration": _configuration(), "items": items[(page - 1) * 10 : page * 10], "page": page, "total": len(items), @@ -181,35 +163,21 @@ def _site_timestamp(timestamp: float) -> str: ) -@frappe.whitelist(methods=["POST"]) -def save_settings(values: str | dict[str, Any]) -> dict[str, Any]: - """Save operator-editable backup policy without exposing the internal singleton form.""" +@frappe.whitelist() +def get_configuration() -> dict[str, Any]: + """Report which deployment variables are set, never their values.""" _require_system_manager() - payload = frappe.parse_json(values) if isinstance(values, str) else values - if not isinstance(payload, dict): - frappe.throw(_("Backup settings must be an object.")) - - settings = frappe.get_single("Restic Backup Settings") - for fieldname in EDITABLE_SETTING_FIELDS: - if fieldname in payload: - settings.set(fieldname, payload[fieldname]) - settings.save() - return _settings_payload(settings) + return _configuration() -def _settings_payload(settings) -> dict[str, Any]: +def _configuration() -> dict[str, Any]: return { - "enabled": cint(settings.enabled), - "schedule": [ - { - "days": parse_working_days(row.days), - "backup_time": str(row.backup_time), - } - for row in settings.backup_schedule + "restic_ready": restic_is_configured(), + "required_variables": [ + {"name": name, "configured": bool(os.environ.get(name))} for name in REQUIRED_RESTIC_ENV ], - "local_backup_limit": cint(settings.local_backup_limit) or 4, - "remote_keep_days": cint(settings.remote_keep_days) or 14, - "email_on_success": cint(settings.email_on_success), + "offsite_enabled": _env_flag("RESTIC_OFFSITE_BACKUP_ENABLED"), + "uptime_kuma_ready": bool(os.environ.get("RESTIC_BACKUP_UPTIME_KUMA_URL")), } diff --git a/frappe_restic/restic_backup/test_backup_control.py b/frappe_restic/restic_backup/test_backup_control.py index 385a5a3..1f55d51 100644 --- a/frappe_restic/restic_backup/test_backup_control.py +++ b/frappe_restic/restic_backup/test_backup_control.py @@ -170,8 +170,7 @@ def _alert(self, *, succeeded: bool, email_on_success: int = 0, desk=None, email ] with ( patch.object(control, "_settings_int", return_value=email_on_success), - patch.object(control, "get_users_with_role", return_value=["Administrator", "ops@example.com"]), - patch("frappe.get_all", return_value=managers), + patch("frappe.get_all", side_effect=[["Administrator", "ops@example.com"], managers]) as get_all, patch("frappe.logger"), patch.object(control, "enqueue_create_notification", side_effect=desk) as notify, patch("frappe.sendmail", side_effect=email) as sendmail, @@ -179,6 +178,11 @@ def _alert(self, *, succeeded: bool, email_on_success: int = 0, desk=None, email status = control._send_alert( "FAILED: backup", "disk full", succeeded=succeeded, run_name="run123" ) + if get_all.call_count: + self.assertEqual( + get_all.call_args.kwargs["filters"], + {"name": ["in", ["Administrator", "ops@example.com"]], "enabled": 1}, + ) return status, notify, sendmail def test_failure_reaches_every_system_manager_in_the_desk_and_by_email(self) -> None: