From ee4bbaf2cdec2a63062e0dda80859221eeaf763c Mon Sep 17 00:00:00 2001 From: Matthew Hilton Date: Tue, 4 Nov 2025 11:45:11 +1000 Subject: [PATCH] MDL-84440 backup: Add configurable default backup filename format --- public/admin/settings/courses.php | 57 ++ .../tests/default_backup_filename_test.php | 620 ++++++++++++++++++ .../util/dbops/backup_plan_dbops.class.php | 249 +++++-- public/lang/en/backup.php | 37 ++ public/lib/adminlib.php | 20 + public/version.php | 2 +- 6 files changed, 919 insertions(+), 66 deletions(-) create mode 100644 public/backup/tests/default_backup_filename_test.php diff --git a/public/admin/settings/courses.php b/public/admin/settings/courses.php index a50ab752173..73488a67f46 100644 --- a/public/admin/settings/courses.php +++ b/public/admin/settings/courses.php @@ -416,6 +416,63 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_legacyfiles', new lang_string('generallegacyfiles', 'backup'), new lang_string('configlegacyfiles', 'backup'), array('value' => 1, 'locked' => 0))); + + // Backup filename defaults. + // Until MDL-83618 is fixed, this must be required as it will not be autoloaded. + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + $temp->add( + new admin_setting_heading( + 'defaultbackupfilenamesettings', + new lang_string('defaultbackupfilenamesettings', 'backup'), + '' + ) + ); + + $temp->add( + new admin_setting_description( + 'defaultbackupfilenamesettings_help', + '', + new lang_string('defaultbackupfilenamesettings_help', 'backup'), + '' + ) + ); + + $temp->add( + new admin_setting_configbackupfilenamemustachetemplate( + 'backup/backup_default_filename_template_course', + new lang_string('defaultbackupfilenamecourse', 'backup'), + new lang_string('defaultbackupfilenamecourse_desc', 'backup'), + backup_plan_dbops::DEFAULT_FILENAME_TEMPLATE_COURSE, + PARAM_TEXT, + '60', + '3' + ) + ); + + $temp->add( + new admin_setting_configbackupfilenamemustachetemplate( + 'backup/backup_default_filename_template_section', + new lang_string('defaultbackupfilenamesection', 'backup'), + new lang_string('defaultbackupfilenamesection_desc', 'backup'), + backup_plan_dbops::DEFAULT_FILENAME_TEMPLATE_SECTION, + PARAM_TEXT, + '60', + '3' + ) + ); + + $temp->add( + new admin_setting_configbackupfilenamemustachetemplate( + 'backup/backup_default_filename_template_activity', + new lang_string('defaultbackupfilenameactivity', 'backup'), + new lang_string('defaultbackupfilenameactivity_desc', 'backup'), + backup_plan_dbops::DEFAULT_FILENAME_TEMPLATE_ACTIVITY, + PARAM_TEXT, + '60', + '3' + ) + ); + $ADMIN->add('backups', $temp); // Create a page for general import configuration and defaults. diff --git a/public/backup/tests/default_backup_filename_test.php b/public/backup/tests/default_backup_filename_test.php new file mode 100644 index 00000000000..3b4668b14a1 --- /dev/null +++ b/public/backup/tests/default_backup_filename_test.php @@ -0,0 +1,620 @@ +. + +namespace core_backup; + +use advanced_testcase; +use backup; +use backup_plan_dbops; +use core\exception\coding_exception; +use core_courseformat\local\sectionactions; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + +/** + * Tests related to the default backup filename feature. + * + * @package core_backup + * @copyright 2025 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class default_backup_filename_test extends advanced_testcase { + /** + * Provides backup filename scenarios. + * @return array + */ + public static function get_default_backup_filename_provider(): array { + return [ + // This first block of tests are tests that confirm the new mustache + // rendering method internally produces the same output as the original function. + 'moodle format, course, with users, anonymised, use id only, with files' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-19700101-0800-an.mbz', + ], + 'moodle format, course, without users, anonymised, use id only, with files' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => false, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-19700101-0800-nu.mbz', + ], + 'moodle format, course, ith users, not anonymised, use id only, with files' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => false, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-19700101-0800.mbz', + ], + 'moodle format, course, with users, anonymised, use id only, without files' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => false, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-19700101-0800-an-nf.mbz', + ], + 'moodle format, course, with users, anonymised, not id only, with files' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => false, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-abc123-19700101-0800-an.mbz', + ], + 'moodle format, section, with users, anonymised, use id only, with files' => [ + 'generate' => [ + 'section' => [ + 'name' => 'abc123', + 'section' => 9, + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1SECTION, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-section-{{sectionid}}-19700101-0800-an.mbz', + ], + 'moodle format, section, without users, anonymised, use id only, with files' => [ + 'generate' => [ + 'section' => [ + 'name' => 'abc123', + 'section' => 9, + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1SECTION, + 'users' => false, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-section-{{sectionid}}-19700101-0800-nu.mbz', + ], + 'moodle format, section, with users, not anonymised, use id only, with files' => [ + 'generate' => [ + 'section' => [ + 'name' => 'abc123', + 'section' => 9, + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1SECTION, + 'users' => true, + 'anonymised' => false, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-section-{{sectionid}}-19700101-0800.mbz', + ], + 'moodle format, section, with users, anonymised, without use id only (with section name), with files' => [ + 'generate' => [ + 'section' => [ + 'name' => 'abc123', + 'section' => 9, + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1SECTION, + 'users' => true, + 'anonymised' => true, + 'useidonly' => false, + 'files' => true, + ], + 'customtemplates' => [], + // Where section has name, it uses the name. + 'expectedfilename' => 'backup-moodle2-section-{{sectionid}}-abc123-19700101-0800-an.mbz', + ], + 'moodle format, section, with users, anonymised, without use id only (no section name), with files' => [ + 'generate' => [ + 'section' => [ + 'section' => 9, + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1SECTION, + 'users' => true, + 'anonymised' => true, + 'useidonly' => false, + 'files' => true, + ], + 'customtemplates' => [], + // Section has no name, it uses the number instead. + 'expectedfilename' => 'backup-moodle2-section-{{sectionid}}-9-19700101-0800-an.mbz', + ], + 'moodle format, section, with users, anonymised, with use id only, without files' => [ + 'generate' => [ + 'section' => [ + 'name' => 'abc123', + 'section' => 9, + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1SECTION, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => false, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-section-{{sectionid}}-19700101-0800-an-nf.mbz', + ], + 'moodle format, activity, with users, anonymised, use id only, with files' => [ + 'generate' => [ + 'activity' => [ + 'name' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1ACTIVITY, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-activity-{{activitycmid}}-19700101-0800-an.mbz', + ], + 'moodle format, activity, without users, anonymised, use id only, with files' => [ + 'generate' => [ + 'activity' => [ + 'name' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1ACTIVITY, + 'users' => false, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-activity-{{activitycmid}}-19700101-0800-nu.mbz', + ], + 'moodle format, activity, with users, not anonymised, use id only, with files' => [ + 'generate' => [ + 'activity' => [ + 'name' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1ACTIVITY, + 'users' => true, + 'anonymised' => false, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-activity-{{activitycmid}}-19700101-0800.mbz', + ], + 'moodle format, activity, with users, anonymised, without use id only, with files' => [ + 'generate' => [ + 'activity' => [ + 'name' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1ACTIVITY, + 'users' => true, + 'anonymised' => true, + 'useidonly' => false, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-activity-{{activitycmid}}-page{{activitycmid}}-19700101-0800-an.mbz', + ], + 'moodle format, activity, with users, anonymised, with id only, without files' => [ + 'generate' => [ + 'activity' => [ + 'name' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1ACTIVITY, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => false, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-activity-{{activitycmid}}-19700101-0800-an-nf.mbz', + ], + + // This second block tests custom template functions being used. + 'custom template - course' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'shortcourse', + 'fullname' => 'fullcourse', + 'startdate' => 5000, + 'enddate' => 10000, + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_course' => + '{{course.shortname}}-{{course.fullname}}-{{id}}-{{date}}-{{course.startdate}}-{{course.enddate}}', + ], + 'expectedfilename' => 'shortcourse-fullcourse-{{courseid}}-19700101-0800-19700101-0923-19700101-1046.mbz', + ], + 'custom template - section' => [ + 'generate' => [ + 'section' => [ + 'name' => 'section123', + 'section' => '1', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1SECTION, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_section' => '{{section.name}}-{{section.section}}-{{id}}-{{date}}', + ], + 'expectedfilename' => 'section123-1-{{sectionid}}-19700101-0800.mbz', + ], + 'custom template - activity' => [ + 'generate' => [ + 'activity' => [ + 'name' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1ACTIVITY, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_activity' => '{{activity.modname}}-{{activity.name}}-{{id}}-{{date}}', + ], + 'expectedfilename' => 'page-abc123-{{activitycmid}}-19700101-0800.mbz', + ], + + // This third block tests various edge cases. + 'spaces in context values and custom template are replaced' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'a b c', + 'fullname' => 'x y z', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_course' => '{{course.shortname}} {{course.fullname}}', + ], + 'expectedfilename' => 'a_b_c___x_y_z.mbz', + ], + 'spaces in context values without custom template are replaced' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'a b c', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => false, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-a_b_c-19700101-0800-an.mbz', + ], + 'whitespace trimmed from context values with custom template' => [ + 'generate' => [ + 'course' => [ + 'shortname' => " abc \n", + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_course' => '{{course.shortname}}', + ], + 'expectedfilename' => 'abc.mbz', + ], + 'whitespace trimmed from context values without custom template' => [ + 'generate' => [ + 'course' => [ + 'shortname' => " abc \n", + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => false, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-abc-19700101-0800-an.mbz', + ], + 'format string applied to context values with custom template' => [ + 'generate' => [ + 'course' => [ + // Format_string will remove the link and newline before using. + 'shortname' => "\nabc", + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_course' => '{{course.shortname}}', + ], + 'expectedfilename' => 'abc.mbz', + ], + 'format string applied to context values without custom template' => [ + 'generate' => [ + 'course' => [ + // Format_string will remove the link and newline before using. + 'shortname' => "\nabc", + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => false, + 'files' => true, + ], + 'customtemplates' => [], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-abc-19700101-0800-an.mbz', + ], + 'generated name > 251 chars is truncated' => [ + 'generate' => [ + 'course' => [ + 'shortname' => "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " . + "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis " . + "nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor.", + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_course' => '{{course.shortname}}', + ], + 'expectedfilename' => 'Lorem_ipsum_dolor_sit_amet,_consectetur_adipiscing_elit,_sed_do_eiusmod_' . + 'tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_nostrud_' . + 'exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._Duis_aute_irure_dol.mbz', + ], + 'course custom template is invalid, falls back to default' => [ + 'generate' => [ + 'course' => [ + 'shortname' => 'abc123', + ], + ], + 'params' => [ + 'format' => backup::FORMAT_MOODLE, + 'type' => backup::TYPE_1COURSE, + 'users' => true, + 'anonymised' => true, + 'useidonly' => true, + 'files' => true, + ], + 'customtemplates' => [ + 'backup_default_filename_template_course' => '{{', + ], + 'expectedfilename' => 'backup-moodle2-course-{{courseid}}-19700101-0800-an.mbz', + ], + ]; + } + + /** + * Tests get_default_backup_filename. + * + * @param array $generate array of resources to generate (courses, sections, activities). + * @param array $params parameters to pass into get_default_backup_filename. + * @param array $customtemplates array of key value pairs of config values, for setting the custom template config. + * @param string $expectedfilename the filename expected to be generated. + * @dataProvider get_default_backup_filename_provider + * @covers \backup_plan_dbops::get_default_backup_filename + */ + public function test_get_default_backup_filename( + array $generate, + array $params, + array $customtemplates, + string $expectedfilename + ): void { + $this->resetAfterTest(true); + + foreach ($customtemplates as $config => $value) { + set_config($config, $value, 'backup'); + } + + // All types need a course. + $course = $this->getDataGenerator()->create_course($generate['course'] ?? null); + + switch ($params['type']) { + case backup::TYPE_1COURSE: + $params['id'] = $course->id; + $expectedfilename = str_replace('{{courseid}}', $course->id, $expectedfilename); + break; + case backup::TYPE_1SECTION: + $sectioninfo = $this->getDataGenerator()->create_course_section(['course' => $course, 'section' => + $generate['section']['section']]); + $actions = new sectionactions($course); + $actions->update($sectioninfo, $generate['section'] ?? []); + $params['id'] = $sectioninfo->id; + $sectioninfo = get_fast_modinfo($course)->get_section_info($sectioninfo->sectionnum); + $expectedfilename = str_replace('{{sectionid}}', $sectioninfo->id, $expectedfilename); + break; + case backup::TYPE_1ACTIVITY: + $activity = $this->getDataGenerator()->create_module( + 'page', + array_merge(['course' => $course->id], $generate['activity']) + ); + $params['id'] = $activity->cmid; + $expectedfilename = str_replace('{{activitycmid}}', $activity->cmid, $expectedfilename); + break; + default: + throw new coding_exception("Unhandled backup type " . $params['type']); + } + + $defaultfilename = backup_plan_dbops::get_default_backup_filename( + $params['format'], + $params['type'], + $params['id'], + $params['users'], + $params['anonymised'], + $params['useidonly'], + $params['files'], + 0 + ); + $this->assertEquals($expectedfilename, $defaultfilename); + } + + /** + * Tests getting syntax errors in template. + * + * @covers \backup_plan_dbops::get_default_backup_filename_template_syntax_errors + */ + public function test_get_default_backup_filename_syntax_errors(): void { + $this->assertEmpty(backup_plan_dbops::get_default_backup_filename_template_syntax_errors("this is ok {{test}}")); + $this->assertNotEmpty(backup_plan_dbops::get_default_backup_filename_template_syntax_errors("this is invalid {{")); + } +} diff --git a/public/backup/util/dbops/backup_plan_dbops.class.php b/public/backup/util/dbops/backup_plan_dbops.class.php index 4dae3e5d563..bb002c44127 100644 --- a/public/backup/util/dbops/backup_plan_dbops.class.php +++ b/public/backup/util/dbops/backup_plan_dbops.class.php @@ -22,6 +22,12 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\context\course; +use core\context\module; +use core\exception\coding_exception; +use core\output\mustache_engine; +use core\output\mustache_string_helper; + /** * Non instantiable helper class providing DB support to the @backup_plan class * @@ -31,6 +37,26 @@ * TODO: Finish phpdocs */ abstract class backup_plan_dbops extends backup_dbops { + /** + * @var string Default template for course backups + */ + public const DEFAULT_FILENAME_TEMPLATE_COURSE = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}-' . + '{{course.shortname}}{{/useidonly}}-{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}' . + '{{^files}}-nf{{/files}}'; + + /** + * @var string Default template for section backups + */ + public const DEFAULT_FILENAME_TEMPLATE_SECTION = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}' . + '{{#section.name}}-{{section.name}}{{/section.name}}{{^section.name}}-{{section.section}}{{/section.name}}{{/useidonly}}-' . + '{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}{{^files}}-nf{{/files}}'; + + /** + * @var string Default template for activity backups + */ + public const DEFAULT_FILENAME_TEMPLATE_ACTIVITY = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}' . + '-{{activity.modname}}{{id}}{{/useidonly}}-{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}' . + '{{/anonymised}}{{^files}}-nf{{/files}}'; /** * Given one course module id, return one array with all the block intances that belong to it @@ -194,81 +220,174 @@ abstract class backup_plan_dbops extends backup_dbops { } /** - * Returns the default backup filename, based in passed params. - * - * Default format is (see MDL-22145) - * backup word - format - type - name - date - info . mbz - * where name is variable (course shortname, section name/id, activity modulename + cmid) - * and info can be (nu = no user info, an = anonymized). The last param $useidasname, - * defaulting to false, allows to replace the course shortname by the course id (used - * by automated backups, to avoid non-ascii chars in OS filesystem) - * - * @param string $format One of backup::FORMAT_ - * @param string $type One of backup::TYPE_ - * @param int $courseid/$sectionid/$cmid - * @param bool $users Should be true is users were included in the backup - * @param bool $anonymised Should be true is user information was anonymized. - * @param bool $useidonly only use the ID in the file name - * @return string The filename to use - */ - public static function get_default_backup_filename($format, $type, $id, $users, $anonymised, - $useidonly = false, $files = true) { + * Returns the default backup filename, based in passed params. + * + * Default format is (see MDL-22145) + * backup word - format - type - name - date - info . mbz + * where name is variable (course shortname, section name/id, activity modulename + cmid) + * and info can be (nu = no user info, an = anonymized). The last param $useidonly, + * defaulting to false, allows to replace the course shortname by the course id (used + * by automated backups, to avoid non-ascii chars in OS filesystem) + * + * @param string $format One of backup::FORMAT_ + * @param string $type One of backup::TYPE_ + * @param int $id course id, section id, or course module id + * @param bool $users Should be true is users were included in the backup + * @param bool $anonymised Should be true is user information was anonymized + * @param bool $useidonly only use the ID in the file name + * @param bool $files if files are included + * @param int|null $time time to use in any dates, if not given uses current time + * @return string The filename to use + */ + public static function get_default_backup_filename( + string $format, + string $type, + int $id, + bool $users, + bool $anonymised, + bool $useidonly = false, + bool $files = true, + ?int $time = null + ): string { global $DB; - // Calculate backup word - $backupword = str_replace(' ', '_', core_text::strtolower(get_string('backupfilename'))); - $backupword = trim(clean_filename($backupword), '_'); - - // Not $useidonly, lets fetch the name - $shortname = ''; - if (!$useidonly) { - // Calculate proper name element (based on type) - switch ($type) { - case backup::TYPE_1COURSE: - $shortname = $DB->get_field('course', 'shortname', array('id' => $id)); - $context = context_course::instance($id); - $shortname = format_string($shortname, true, array('context' => $context)); - break; - case backup::TYPE_1SECTION: - if (!$shortname = $DB->get_field('course_sections', 'name', array('id' => $id))) { - $shortname = $DB->get_field('course_sections', 'section', array('id' => $id)); - } - break; - case backup::TYPE_1ACTIVITY: - $cm = get_coursemodule_from_id(null, $id); - $shortname = $cm->modname . $id; - break; - } - $shortname = str_replace(' ', '_', $shortname); - $shortname = core_text::strtolower(trim(clean_filename($shortname), '_')); + if ($time === null) { + $time = time(); } - // The name will always contain the ID, but we append the course short name if requested. - $name = $id; - if (!$useidonly && $shortname != '') { - $name .= '-' . $shortname; - } - - // Calculate date $backupdateformat = str_replace(' ', '_', get_string('backupnameformat', 'langconfig')); - $date = userdate(time(), $backupdateformat, 99, false); - $date = core_text::strtolower(trim(clean_filename($date), '_')); + $formatdate = function (int $date) use ($backupdateformat): string { + $date = userdate($date, $backupdateformat, 99, false); + return core_text::strtolower(trim(clean_filename($date), '_')); + }; - // Calculate info - $info = ''; - if (!$users) { - $info = '-nu'; - } else if ($anonymised) { - $info = '-an'; + $mustachecontext = [ + 'format' => $format, + 'type' => $type, + 'id' => $id, + 'users' => $users, + 'anonymised' => $anonymised, + 'files' => $files, + 'useidonly' => $useidonly, + 'time' => $time, + 'date' => $formatdate($time), + ]; + + // Add extra context based on the type of backup. + // It is important to use array and not stdClass here, otherwise array_walk_recursive will not work. + // Additionally get the moodle context of an item, which is used for format_string. + $itemcontext = null; + switch ($type) { + case backup::TYPE_1COURSE: + $mustachecontext['course'] = (array) $DB->get_record( + 'course', + ['id' => $id], + 'shortname,fullname,startdate,enddate', + MUST_EXIST + ); + $mustachecontext['course']['startdate'] = $formatdate($mustachecontext['course']['startdate']); + $mustachecontext['course']['enddate'] = $formatdate($mustachecontext['course']['enddate']); + + $itemcontext = course::instance($id); + break; + case backup::TYPE_1SECTION: + $mustachecontext['section'] = (array) $DB->get_record('course_sections', ['id' => $id], 'name,section', MUST_EXIST); + + // A section is still course context, but needs an extra step to find the course id. + $courseid = $DB->get_field('course_sections', 'course', ['id' => $id], MUST_EXIST); + $itemcontext = course::instance($courseid); + break; + case backup::TYPE_1ACTIVITY: + $cm = get_coursemodule_from_id(null, $id, 0, false, MUST_EXIST); + $mustachecontext['activity'] = [ + 'modname' => $cm->modname, + 'name' => $cm->name, + ]; + + $itemcontext = module::instance($id); + break; + default: + throw new coding_exception('Unknown backup type ' . $type); } - // Indicate if backup doesn't contain files. - if (!$files) { - $info .= '-nf'; + // Recursively format all the strings and trim any extra whitespace. + array_walk_recursive($mustachecontext, function (&$item) use ($itemcontext) { + if (is_string($item)) { + // Update by reference. + $item = trim(format_string($item, true, ['context' => $itemcontext])); + } + }); + + // List of templates in order (if one fails, go to next) for each type. + $templates = [ + backup::TYPE_1COURSE => [ + get_config('backup', 'backup_default_filename_template_course'), + self::DEFAULT_FILENAME_TEMPLATE_COURSE, + ], + backup::TYPE_1SECTION => [ + get_config('backup', 'backup_default_filename_template_section'), + self::DEFAULT_FILENAME_TEMPLATE_SECTION, + ], + backup::TYPE_1ACTIVITY => [ + get_config('backup', 'backup_default_filename_template_activity'), + self::DEFAULT_FILENAME_TEMPLATE_ACTIVITY, + ], + ]; + + $mustache = self::get_mustache_for_filename_generation(); + + // Render the templates until one succeeds. + foreach ($templates[$type] as $possibletemplate) { + try { + $new = @$mustache->render($possibletemplate, $mustachecontext); + + // Clean as filename, remove spaces, and trim to max 251 chars (filename limit, 255 including .mbz extension). + $cleaned = substr(str_replace(' ', '_', clean_filename($new)), 0, 251); + + // Success - this template rendered - return it. + return $cleaned . '.mbz'; + } catch (Throwable $e) { + // Skip and try the next. + continue; + } } - return $backupword . '-' . $format . '-' . $type . '-' . - $name . '-' . $date . $info . '.mbz'; + // At a minumum the fallback default filenames should have rendered correctly. + // If we reached here it means this did not happen and that something is very wrong. + throw new coding_exception("No backup filename templates rendered correctly"); + } + + /** + * Get mustache engine instance to be used in filename generation. + * @return mustache_engine + */ + private static function get_mustache_for_filename_generation(): mustache_engine { + return new mustache_engine([ + 'helpers' => [ + 'str' => [new mustache_string_helper(), 'str'], + ], + ]); + } + + /** + * Validates the given backup filename template is syntatically valid. + * + * Used mainly for form validation. + * @param string $template mustache template + * @return array array of string error messages, if empty then there are no errors and it is valid + */ + public static function get_default_backup_filename_template_syntax_errors(string $template): array { + try { + // Render without any context, if it is syntatically invalid, + // this will throw an exception. + // This also outputs warnings if invalid, so we just ignore them using '@'. + @self::get_mustache_for_filename_generation()->render($template); + + // No exceptions thrown - is valid! + return []; + } catch (Throwable $e) { + return [$e->getMessage()]; + } } /** diff --git a/public/lang/en/backup.php b/public/lang/en/backup.php index 57d622281eb..55b7977002b 100644 --- a/public/lang/en/backup.php +++ b/public/lang/en/backup.php @@ -194,6 +194,43 @@ $string['currentstage2'] = 'Schema settings'; $string['currentstage4'] = 'Confirmation and review'; $string['currentstage8'] = 'Perform backup'; $string['currentstage16'] = 'Complete'; +$string['defaultbackupfilenameactivity'] = 'Activity backup default filename'; +$string['defaultbackupfilenameactivity_desc'] = 'Additional context for activity backups: +
    +
  • activity.name - string - activity name
  • +
  • activity.modname - integer - module name
  • +
+'; +$string['defaultbackupfilenamecourse'] = 'Course backup default filename'; +$string['defaultbackupfilenamecourse_desc'] = 'Additional context for course backups: +
    +
  • course.shortname - string - course shortname
  • +
  • course.fullname - string - course fullname
  • +
  • course.startdate - string - course start date in format specified by the backupnameformat lang string
  • +
  • course.endddate - string - course end date in format specified by the backupnameformat lang string
  • +
+'; +$string['defaultbackupfilenamesection'] = 'Section backup default filename'; +$string['defaultbackupfilenamesection_desc'] = 'Additional context for section backups: +
    +
  • section.name - string - section name
  • +
  • section.section - integer - section number
  • +
+'; +$string['defaultbackupfilenamesettings'] = 'Default backup filenames'; +$string['defaultbackupfilenamesettings_help'] = 'Mustache templates that are evaluated to provide a default filename for a backup. +Every backup template has the following context available: +
    +
  • format - string - backup format, usually moodle2
  • +
  • type - string - one of course, section or activity
  • +
  • id - integer - the item\'s database ID
  • +
  • useidonly - bool - if the backup was created without the backup_shortname config enabled
  • +
  • date - string - the date in format specified by the backupnameformat lang string
  • +
  • users - bool - if user data is included
  • +
  • anonymised - bool - if user data is anonymised
  • +
  • files - bool - if files are included
  • +
+In addition to each of the placeholder values specified, you may also use the {{#str}} helper to add lang strings. The .mbz extension is always added. The filename is truncated to 251 chars.'; $string['enableasyncbackup'] = 'Enable asynchronous backups'; $string['enableasyncbackup_help'] = 'If enabled, backup and restore operations will be done asynchronously. This provides a better user experience allowing users to do other operations while a backup or restore is in progress. This does not affect imports and exports.'; $string['enterasearch'] = 'Enter a search'; diff --git a/public/lib/adminlib.php b/public/lib/adminlib.php index 043bd6ce3f3..406d38de7e4 100644 --- a/public/lib/adminlib.php +++ b/public/lib/adminlib.php @@ -2704,6 +2704,26 @@ class admin_setting_configtextarea extends admin_setting_configtext { } } +/** + * Text area for entering backup filename mustache templates, which are validated after submission. + */ +class admin_setting_configbackupfilenamemustachetemplate extends admin_setting_configtextarea { + /** + * Validates submitted data. + * @param string $data + * @return string|true string if error, else true if ok + */ + public function validate($data) { + $errors = backup_plan_dbops::get_default_backup_filename_template_syntax_errors($data); + + if (!empty($errors)) { + return get_string('validateerror', 'admin'); + } + + return parent::validate($data); + } +} + /** * General text area with html editor. */ diff --git a/public/version.php b/public/version.php index 1ab1e58f334..cf2faa5a326 100644 --- a/public/version.php +++ b/public/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025103000.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2025103000.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.2dev (Build: 20251030)'; // Human-friendly version name