Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 5e56781

Browse files
committedMar 11, 2025··
MDL-84440 backup: Add configurable default backup filename format
1 parent 8cb5d00 commit 5e56781

12 files changed

+988
-85
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
issueNumber: MDL-84440
2+
notes:
3+
core_backup:
4+
- message: >-
5+
backup_plan_dbops::get_default_backup_filename has been deprecated.
6+
Please use backup_filename_helper::get_default_backup_filename
7+
type: deprecated

‎admin/cli/backup.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2424
*/
2525

26+
use core\backup\backup_filename_helper;
27+
2628
define('CLI_SCRIPT', 1);
2729

2830
require(__DIR__.'/../../config.php');
@@ -104,7 +106,7 @@
104106
$id = $bc->get_id();
105107
$users = $bc->get_plan()->get_setting('users')->get_value();
106108
$anonymised = $bc->get_plan()->get_setting('anonymize')->get_value();
107-
$filename = backup_plan_dbops::get_default_backup_filename($format, $type, $id, $users, $anonymised);
109+
$filename = (new backup_filename_helper())->get_default_backup_filename($format, $type, $id, $users, $anonymised);
108110
$bc->get_plan()->get_setting('filename')->set_value($filename);
109111

110112
// Execution.

‎admin/settings/courses.php

+38
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626

2727
require_once($CFG->libdir . '/pdflib.php');
2828

29+
// Until MDL-83618 is fixed, this must be required as it will not be autoloaded.
30+
require_once($CFG->dirroot . '/backup/classes/backup_filename_helper.php');
31+
32+
use core\backup\backup_filename_helper;
2933
use core_admin\local\settings\filesize;
3034

3135
$capabilities = array(
@@ -439,6 +443,40 @@
439443
$temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_legacyfiles',
440444
new lang_string('generallegacyfiles', 'backup'),
441445
new lang_string('configlegacyfiles', 'backup'), array('value' => 1, 'locked' => 0)));
446+
447+
// Filename defaults.
448+
$temp->add(new admin_setting_heading('defaultbackupfilenamesettings',
449+
new lang_string('defaultbackupfilenamesettings', 'backup'), ''));
450+
$temp->add(new admin_setting_description('defaultbackupfilenamesettings_help', '',
451+
new lang_string('defaultbackupfilenamesettings_help', 'backup'), ''));
452+
453+
$temp->add(new admin_setting_configmustachetemplate('backup/backup_default_filename_template_course',
454+
new lang_string('defaultbackupfilenamecourse', 'backup'),
455+
new lang_string('defaultbackupfilenamecourse_desc', 'backup'),
456+
backup_filename_helper::DEFAULT_FILENAME_TEMPLATE_COURSE,
457+
PARAM_TEXT,
458+
'60',
459+
'3'
460+
));
461+
462+
$temp->add(new admin_setting_configmustachetemplate('backup/backup_default_filename_template_section',
463+
new lang_string('defaultbackupfilenamesection', 'backup'),
464+
new lang_string('defaultbackupfilenamesection_desc', 'backup'),
465+
backup_filename_helper::DEFAULT_FILENAME_TEMPLATE_SECTION,
466+
PARAM_TEXT,
467+
'60',
468+
'3'
469+
));
470+
471+
$temp->add(new admin_setting_configtextarea('backup/backup_default_filename_template_activity',
472+
new lang_string('defaultbackupfilenameactivity', 'backup'),
473+
new lang_string('defaultbackupfilenameactivity_desc', 'backup'),
474+
backup_filename_helper::DEFAULT_FILENAME_TEMPLATE_ACTIVITY,
475+
PARAM_TEXT,
476+
'60',
477+
'3'
478+
));
479+
442480
$ADMIN->add('backups', $temp);
443481

444482
// Create a page for general import configuration and defaults.
+217
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
namespace core\backup;
18+
19+
use core\context\course;
20+
use backup;
21+
use core\context\module;
22+
use core\exception\coding_exception;
23+
use core\output\mustache_engine;
24+
use core\output\mustache_string_helper;
25+
use core_text;
26+
use Throwable;
27+
28+
/**
29+
* Backup filename helper.
30+
*
31+
* @package core_backup
32+
* @subpackage backup
33+
* @copyright 2025 Matthew Hilton <matthewhilton@catalyst-au.net>
34+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35+
*/
36+
class backup_filename_helper {
37+
/**
38+
* @var string Default template for course backups
39+
*/
40+
public const DEFAULT_FILENAME_TEMPLATE_COURSE = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}-' .
41+
'{{course.shortname}}{{/useidonly}}-{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}' .
42+
'{{^files}}-nf{{/files}}';
43+
44+
/**
45+
* @var string Default template for section backups
46+
*/
47+
public const DEFAULT_FILENAME_TEMPLATE_SECTION = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}' .
48+
'{{#section.name}}-{{section.name}}{{/section.name}}{{^section.name}}-{{section.section}}{{/section.name}}{{/useidonly}}-' .
49+
'{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}{{^files}}-nf{{/files}}';
50+
51+
/**
52+
* @var string Default template for activity backups
53+
*/
54+
public const DEFAULT_FILENAME_TEMPLATE_ACTIVITY = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}' .
55+
'-{{activity.modname}}{{id}}{{/useidonly}}-{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}' .
56+
'{{/anonymised}}{{^files}}-nf{{/files}}';
57+
58+
/**
59+
* Get mustache engine instance
60+
* @return mustache_engine
61+
*/
62+
private function get_mustache(): mustache_engine {
63+
return new mustache_engine([
64+
'helpers' => [
65+
'str' => [new mustache_string_helper(), 'str'],
66+
],
67+
]);
68+
}
69+
70+
/**
71+
* Returns the default backup filename, based in passed params.
72+
*
73+
* @param string $format One of backup::FORMAT_
74+
* @param string $type One of backup::TYPE_
75+
* @param int $id course id, section id, or course module id
76+
* @param bool $users Should be true is users were included in the backup
77+
* @param bool $anonymised Should be true is user information was anonymized
78+
* @param bool $useidonly only use the ID in the file name
79+
* @param bool $files if files are included
80+
* @param int|null $time time to use in any dates, if not given uses current time
81+
* @return string The filename to use
82+
*/
83+
public function get_default_backup_filename(string $format, string $type, int $id, bool $users, bool $anonymised,
84+
bool $useidonly = false, bool $files = true, ?int $time = null): string {
85+
global $DB;
86+
87+
if ($time === null) {
88+
$time = time();
89+
}
90+
91+
$backupdateformat = str_replace(' ', '_', get_string('backupnameformat', 'langconfig'));
92+
$formatdate = function(int $date) use ($backupdateformat): string {
93+
$date = userdate($date, $backupdateformat, 99, false);
94+
return core_text::strtolower(trim(clean_filename($date), '_'));
95+
};
96+
97+
$mustachecontext = [
98+
'format' => $format,
99+
'type' => $type,
100+
'id' => $id,
101+
'users' => $users,
102+
'anonymised' => $anonymised,
103+
'files' => $files,
104+
'useidonly' => $useidonly,
105+
'time' => $time,
106+
'date' => $formatdate($time),
107+
];
108+
109+
// Add extra context based on the type of backup.
110+
// It is important to use array and not stdClass here, otherwise array_walk_recursive will not work.
111+
switch ($type) {
112+
case backup::TYPE_1COURSE:
113+
$mustachecontext['course'] = (array) $DB->get_record('course', ['id' => $id],
114+
'shortname,fullname,startdate,enddate', MUST_EXIST);
115+
$mustachecontext['course']['startdate'] = $formatdate($mustachecontext['course']['startdate']);
116+
$mustachecontext['course']['enddate'] = $formatdate($mustachecontext['course']['enddate']);
117+
break;
118+
case backup::TYPE_1SECTION:
119+
$mustachecontext['section'] = (array) $DB->get_record('course_sections', ['id' => $id], 'name,section', MUST_EXIST);
120+
break;
121+
case backup::TYPE_1ACTIVITY:
122+
$cm = get_coursemodule_from_id(null, $id, 0, false, MUST_EXIST);
123+
$mustachecontext['activity'] = [
124+
'modname' => $cm->modname,
125+
'name' => $cm->name,
126+
];
127+
break;
128+
default:
129+
throw new coding_exception('Unknown backup type - cannot get template context');
130+
}
131+
132+
// Get the relevant context for the item, which is used for format_string.
133+
$itemcontext = null;
134+
switch ($type) {
135+
case backup::TYPE_1COURSE:
136+
$itemcontext = course::instance($id);
137+
break;
138+
case backup::TYPE_1SECTION:
139+
// A section is still course context, but needs an extra step to find the course id.
140+
$courseid = $DB->get_field('course_sections', 'course', ['id' => $id], MUST_EXIST);
141+
$itemcontext = course::instance($courseid);
142+
break;
143+
case backup::TYPE_1ACTIVITY:
144+
$itemcontext = module::instance($id);
145+
break;
146+
default:
147+
throw new coding_exception('Unknown backup type - cannot get instance context');
148+
}
149+
150+
// Recursively format all the strings and trim any extra whitespace.
151+
array_walk_recursive($mustachecontext, function(&$item) use ($itemcontext) {
152+
if (is_string($item)) {
153+
// Update by reference.
154+
$item = trim(format_string($item, true, ['context' => $itemcontext]));
155+
}
156+
});
157+
158+
// List of templates in order (if one fails, go to next) for each type.
159+
$templates = [
160+
backup::TYPE_1COURSE => [
161+
get_config('backup', 'backup_default_filename_template_course'),
162+
self::DEFAULT_FILENAME_TEMPLATE_COURSE,
163+
],
164+
backup::TYPE_1SECTION => [
165+
get_config('backup', 'backup_default_filename_template_section'),
166+
self::DEFAULT_FILENAME_TEMPLATE_SECTION,
167+
],
168+
backup::TYPE_1ACTIVITY => [
169+
get_config('backup', 'backup_default_filename_template_activity'),
170+
self::DEFAULT_FILENAME_TEMPLATE_ACTIVITY,
171+
],
172+
];
173+
174+
$mustache = $this->get_mustache();
175+
176+
// Render the templates until one succeeds.
177+
foreach ($templates[$type] as $possibletemplate) {
178+
try {
179+
$new = @$mustache->render($possibletemplate, $mustachecontext);
180+
181+
// Clean as filename, remove spaces, and trim to max 251 chars (filename limit, 255 including .mbz extension).
182+
$cleaned = substr(str_replace(' ', '_', clean_filename($new)), 0, 251);
183+
184+
// Success - this template rendered - return it.
185+
return $cleaned . '.mbz';
186+
} catch (Throwable $e) {
187+
// Skip and try the next.
188+
continue;
189+
}
190+
}
191+
192+
// The only way to reach here would be if the constant default templates
193+
// became broken, which indicates a coding error.
194+
throw new coding_exception("No backup filename templates rendered correctly");
195+
}
196+
197+
/**
198+
* Validates the given template renders correctly.
199+
*
200+
* Used mainly for form validation.
201+
* @param string $template mustache template
202+
* @return array array of errors, if empty then there are no errors
203+
*/
204+
public function validate(string $template): array {
205+
try {
206+
// Rnder without any context, if it is syntatically invalid,
207+
// this will throw an exception.
208+
// this also outputs warnings if invalid, so we just ignore them using '@'.
209+
@$this->get_mustache()->render($template);
210+
211+
// All good - is valid!
212+
return [];
213+
} catch (Throwable $e) {
214+
return [$e->getMessage()];
215+
}
216+
}
217+
}

0 commit comments

Comments
 (0)
Please sign in to comment.