diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index 886f93fdc88..a31bfda80e2 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -475,6 +475,11 @@ class backup_course_structure_step extends backup_structure_step { 'shortname', 'type', 'value', 'valueformat' )); + $courseformatoptions = new backup_nested_element('courseformatoptions'); + $courseformatoption = new backup_nested_element('courseformatoption', [], [ + 'courseid', 'format', 'sectionid', 'name', 'value' + ]); + // attach format plugin structure to $course element, only one allowed $this->add_plugin_structure('format', $course, false); @@ -512,17 +517,14 @@ class backup_course_structure_step extends backup_structure_step { $course->add_child($customfields); $customfields->add_child($customfield); + $course->add_child($courseformatoptions); + $courseformatoptions->add_child($courseformatoption); + // Set the sources $courserec = $DB->get_record('course', array('id' => $this->task->get_courseid())); $courserec->contextid = $this->task->get_contextid(); - $formatoptions = course_get_format($courserec)->get_format_options(); - $course->add_final_elements(array_keys($formatoptions)); - foreach ($formatoptions as $key => $value) { - $courserec->$key = $value; - } - // Add 'numsections' in order to be able to restore in previous versions of Moodle. // Even though Moodle does not officially support restore into older verions of Moodle from the // version where backup was made, without 'numsections' restoring will go very wrong. @@ -544,6 +546,10 @@ class backup_course_structure_step extends backup_structure_step { backup_helper::is_sqlparam('course'), backup::VAR_PARENTID)); + $courseformatoption->set_source_sql('SELECT id, format, sectionid, name, value + FROM {course_format_options} + WHERE courseid = ?', [ backup::VAR_PARENTID ]); + $handler = core_course\customfield\course_handler::create(); $fieldsforbackup = $handler->get_instance_data_for_backup($this->task->get_courseid()); $customfield->set_source_array($fieldsforbackup); diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index 1aaede00bfa..3bcf1291f95 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -1806,7 +1806,8 @@ class restore_course_structure_step extends restore_structure_step { $category = new restore_path_element('category', '/course/category'); $tag = new restore_path_element('tag', '/course/tags/tag'); $customfield = new restore_path_element('customfield', '/course/customfields/customfield'); - $allowed_module = new restore_path_element('allowed_module', '/course/allowed_modules/module'); + $courseformatoptions = new restore_path_element('course_format_option', '/course/courseformatoptions/courseformatoption'); + $allowedmodule = new restore_path_element('allowed_module', '/course/allowed_modules/module'); // Apply for 'format' plugins optional paths at course level $this->add_plugin_structure('format', $course); @@ -1829,7 +1830,7 @@ class restore_course_structure_step extends restore_structure_step { // Apply for admin tool plugins optional paths at course level. $this->add_plugin_structure('tool', $course); - return array($course, $category, $tag, $customfield, $allowed_module); + return array($course, $category, $tag, $customfield, $allowedmodule, $courseformatoptions); } /** @@ -1951,8 +1952,6 @@ class restore_course_structure_step extends restore_structure_step { // Course record ready, update it $DB->update_record('course', $data); - course_get_format($data)->update_course_format_options($data); - // Role name aliases restore_dbops::set_course_role_names($this->get_restoreid(), $this->get_courseid()); } @@ -1980,6 +1979,26 @@ class restore_course_structure_step extends restore_structure_step { $handler->restore_instance_data_from_backup($this->task, $data); } + /** + * Processes a course format option. + * + * @param array $data The record being restored. + * @throws base_step_exception + * @throws dml_exception + */ + public function process_course_format_option(array $data) : void { + global $DB; + + $courseid = $this->get_courseid(); + $record = $DB->get_record('course_format_options', [ 'courseid' => $courseid, 'name' => $data['name'] ], 'id'); + if ($record !== false) { + $DB->update_record('course_format_options', (object) [ 'id' => $record->id, 'value' => $data['value'] ]); + } else { + $data['courseid'] = $courseid; + $DB->insert_record('course_format_options', (object) $data); + } + } + public function process_allowed_module($data) { $data = (object)$data; diff --git a/course/format/classes/base.php b/course/format/classes/base.php index 9824ee90220..9523522a5f9 100644 --- a/course/format/classes/base.php +++ b/course/format/classes/base.php @@ -908,15 +908,12 @@ abstract class base { 'format' => $this->format, 'sectionid' => $sectionid ), '', 'id,name,value'); + $indexedrecords = []; foreach ($records as $record) { - if (array_key_exists($record->name, $this->formatoptions[$sectionid])) { - $value = $record->value; - if ($value !== null && isset($options[$record->name]['type'])) { - // This will convert string value to number if needed. - $value = clean_param($value, $options[$record->name]['type']); - } - $this->formatoptions[$sectionid][$record->name] = $value; - } + $indexedrecords[$record->name] = $record->value; + } + foreach ($options as $optionname => $option) { + contract_value($this->formatoptions[$sectionid], $indexedrecords, $option, $optionname); } } } @@ -1011,7 +1008,7 @@ abstract class base { $data = array_intersect_key($rawdata, $allformatoptions); foreach ($data as $key => $value) { $option = $allformatoptions[$key] + ['type' => PARAM_RAW, 'element_type' => null, 'element_attributes' => [[]]]; - $data[$key] = clean_param($value, $option['type']); + expand_value($data, $data, $option, $key); if ($option['element_type'] === 'select' && !array_key_exists($data[$key], $option['element_attributes'][0])) { // Value invalid for select element, skip. unset($data[$key]); @@ -1060,6 +1057,7 @@ abstract class base { if (array_key_exists('default', $option)) { $defaultoptions[$key] = $option['default']; } + expand_value($defaultoptions, $defaultoptions, $option, $key); $cached[$key] = ($sectionid === 0 || !empty($option['cache'])); } $records = $DB->get_records('course_format_options', diff --git a/course/format/lib.php b/course/format/lib.php index 4dbf1c42386..954895e2b7f 100644 --- a/course/format/lib.php +++ b/course/format/lib.php @@ -135,3 +135,67 @@ class format_site extends course_format { return 1; } } + +/** + * 'Converts' a value from what is stored in the database into what is used by edit forms. + * + * @param array $dest The destination array + * @param array $source The source array + * @param array $option The definition structure of the option. + * @param string $optionname The name of the option, as provided in the definition. + */ +function contract_value(array &$dest, array $source, array $option, string $optionname) : void { + if (substr($optionname, -7) == '_editor') { // Suffix '_editor' indicates that the element is an editor. + $name = substr($optionname, 0, -7); + if (isset($source[$name])) { + $dest[$optionname] = [ + 'text' => clean_param_if_not_null($source[$name], $option['type'] ?? PARAM_RAW), + 'format' => clean_param_if_not_null($source[$name . 'format'], PARAM_INT), + ]; + } + } else { + if (isset($source[$optionname])) { + $dest[$optionname] = clean_param_if_not_null($source[$optionname], $option['type'] ?? PARAM_RAW); + } + } +} + +/** + * Cleans the given param, unless it is null. + * + * @param mixed $param The variable we are cleaning. + * @param string $type Expected format of param after cleaning. + * @return mixed Null if $param is null, otherwise the cleaned value. + * @throws coding_exception + */ +function clean_param_if_not_null($param, string $type = PARAM_RAW) { + if ($param === null) { + return null; + } else { + return clean_param($param, $type); + } +} + +/** + * 'Converts' a value from what is used in edit forms into a value(s) to be stored in the database. + * + * @param array $dest The destination array + * @param array $source The source array + * @param array $option The definition structure of the option. + * @param string $optionname The name of the option, as provided in the definition. + */ +function expand_value(array &$dest, array $source, array $option, string $optionname) : void { + if (substr($optionname, -7) == '_editor') { // Suffix '_editor' indicates that the element is an editor. + $name = substr($optionname, 0, -7); + if (is_string($source[$optionname])) { + $dest[$name] = clean_param($source[$optionname], $option['type'] ?? PARAM_RAW); + $dest[$name . 'format'] = 1; + } else { + $dest[$name] = clean_param($source[$optionname]['text'], $option['type'] ?? PARAM_RAW); + $dest[$name . 'format'] = clean_param($source[$optionname]['format'], PARAM_INT); + } + unset($dest[$optionname]); + } else { + $dest[$optionname] = clean_param($source[$optionname], $option['type'] ?? PARAM_RAW); + } +} diff --git a/course/format/tests/base_test.php b/course/format/tests/base_test.php index 0a6eb84a511..55ca4d7c602 100644 --- a/course/format/tests/base_test.php +++ b/course/format/tests/base_test.php @@ -36,6 +36,35 @@ class base_test extends advanced_testcase { require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest_output_course_format_invalidoutput.php'); } + /** + * Tests the save and load functionality. + * + * @author Jason den Dulk + * @covers \core_courseformat + */ + public function test_courseformat_saveandload() { + $this->resetAfterTest(); + + $courseformatoptiondata = (object) [ + "hideoddsections" => 1, + 'summary_editor' => [ + 'text' => '
Somewhere over the rainbow
The quick brown fox jumpos over the lazy dog.
', + 'format' => 1 + ] + ]; + $generator = $this->getDataGenerator(); + $course1 = $generator->create_course(array('format' => 'theunittest')); + $this->assertEquals('theunittest', $course1->format); + course_create_sections_if_missing($course1, array(0, 1)); + + $courseformat = course_get_format($course1); + $courseformat->update_course_format_options($courseformatoptiondata); + + $savedcourseformatoptiondata = $courseformat->get_format_options(); + + $this->assertEqualsCanonicalizing($courseformatoptiondata, (object) $savedcourseformatoptiondata); + } + public function test_available_hook() { global $DB; $this->resetAfterTest(); diff --git a/course/format/tests/fixtures/format_theunittest.php b/course/format/tests/fixtures/format_theunittest.php index 986b0cbb066..b59e1b7c73a 100644 --- a/course/format/tests/fixtures/format_theunittest.php +++ b/course/format/tests/fixtures/format_theunittest.php @@ -37,6 +37,10 @@ class format_theunittest extends core_courseformat\base { 'default' => 0, 'type' => PARAM_INT, ), + 'summary_editor' => array( + 'default' => '', + 'type' => PARAM_RAW, + ), ); } if ($foreditform && !isset($courseformatoptions['hideoddsections']['label'])) { @@ -51,6 +55,10 @@ class format_theunittest extends core_courseformat\base { 'element_type' => 'select', 'element_attributes' => array($sectionmenu), ), + 'summary_editor' => array( + 'label' => 'Summary Text', + 'element_type' => 'editor', + ), ); $courseformatoptions = array_merge_recursive($courseformatoptions, $courseformatoptionsedit); } @@ -74,4 +82,4 @@ class format_theunittest extends core_courseformat\base { } } } -} \ No newline at end of file +} diff --git a/course/tests/backup/restore_test.php b/course/tests/backup/restore_test.php index 17f6000f17d..550504007a9 100644 --- a/course/tests/backup/restore_test.php +++ b/course/tests/backup/restore_test.php @@ -26,6 +26,7 @@ global $CFG; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest.php'); /** * Course restore testcase. @@ -534,4 +535,49 @@ class restore_test extends \advanced_testcase { $this->assertEquals($chat2->chattime, $restoredchat2->chattime); $this->assertEquals($c2->startdate + 1 * WEEKSECS, $restoredchat2->chattime); } + + /** + * Tests course restore with editor in course format. + * + * @author Matthew Hilton + * @covers \core_courseformat + */ + public function test_restore_editor_courseformat() { + $this->resetAfterTest(); + + // Setup user with restore permissions. + $dg = $this->getDataGenerator(); + $u1 = $dg->create_user(); + + $managers = get_archetype_roles('manager'); + $manager = array_shift($managers); + $dg->role_assign($manager->id, $u1->id); + + // Create a course with an editor item in the course format. + $courseformatoptiondata = (object) [ + "hideoddsections" => 1, + 'summary_editor' => [ + 'text' => 'Somewhere over the rainbow
The quick brown fox jumpos over the lazy dog.
', + 'format' => 1 + ] + ]; + $course1 = $dg->create_course(['format' => 'theunittest']); + $course2 = $dg->create_course(['format' => 'theunittest']); + $this->assertEquals('theunittest', $course1->format); + course_create_sections_if_missing($course1, array(0, 1)); + + // Set the course format. + $courseformat = course_get_format($course1); + $courseformat->update_course_format_options($courseformatoptiondata); + + // Backup and restore the course. + $backupid = $this->backup_course($course1->id); + $this->restore_to_existing_course($backupid, $course2->id, $u1->id); + + // Get the restored course format. + $restoredformat = course_get_format($course2); + $restoredformatoptions = $restoredformat->get_format_options(); + + $this->assertEqualsCanonicalizing($courseformatoptiondata, (object) $restoredformatoptions); + } } diff --git a/course/tests/course_format_function_test.php b/course/tests/course_format_function_test.php new file mode 100644 index 00000000000..6e89eca9786 --- /dev/null +++ b/course/tests/course_format_function_test.php @@ -0,0 +1,105 @@ +. + +namespace core_course; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/course/lib.php'); +require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest.php'); +require_once($CFG->dirroot . '/course/format/lib.php'); + +/** + * Course format function unit tests + * + * @package core_course + * @copyright 2021 Catalyst IT Pty Ltd + * @author Jason den Dulk + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_format_function_test extends \basic_testcase { + + /** + * Tests clean_param_if_not_null function + * @covers ::clean_param_if_not_null + */ + public function test_clean_param_if_not_null() { + $this->assertNull(clean_param_if_not_null(null)); + $n = '3x'; + $this->assertEquals(clean_param($n, PARAM_INT), clean_param_if_not_null($n, PARAM_INT)); + $this->assertEquals(clean_param($n, PARAM_RAW), clean_param_if_not_null($n, PARAM_RAW)); + $this->assertEquals(clean_param($n, PARAM_ALPHANUM), clean_param_if_not_null($n, PARAM_ALPHANUM)); + $this->assertEquals(clean_param($n, PARAM_ALPHA), clean_param_if_not_null($n, PARAM_ALPHA)); + $s = 'All together Now
', + 'abcformat' => '1', + 'jolly' => 'Roger' + ]; + $expected = [ + 'abc_editor' => [ 'text' => $input['abc'], 'format' => $input['abcformat'] ], + 'jolly' => $input['jolly'], + ]; + $defs = [ + 'abc_editor' => [], + 'jolly' => [ 'type' => PARAM_ALPHA ], + ]; + $dest = []; + + foreach ($defs as $name => $def) { + contract_value($dest, $input, $def, $name); + } + + $this->assertEquals($expected, $dest); + } + + /** + * Tests expand_value function + * @covers ::expand_value + */ + public function test_expand_value() { + $input = [ + 'abc_editor' => [ 'text' => 'All together Now
', 'format' => '1' ], + 'jolly' => 'Roger', + ]; + $expected = [ + 'abc' => $input['abc_editor']['text'], + 'abcformat' => $input['abc_editor']['format'], + 'jolly' => $input['jolly'], + ]; + $defs = [ + 'abc_editor' => [], + 'jolly' => [ 'type' => PARAM_ALPHA ], + ]; + $dest = []; + + foreach ($defs as $name => $def) { + expand_value($dest, $input, $def, $name); + } + + $this->assertEquals($expected, $dest); + } +}