diff --git a/course/format/classes/base.php b/course/format/classes/base.php index 9330bc3ad7d..cd273397758 100644 --- a/course/format/classes/base.php +++ b/course/format/classes/base.php @@ -1735,4 +1735,36 @@ abstract class base { // By default, formats store some most display specifics in a user preference. $DB->delete_records('user_preferences', ['name' => 'coursesectionspreferences_' . $course->id]); } + + /** + * Duplicate a section + * + * @param section_info $originalsection The section to be duplicated + * @return section_info The new duplicated section + * @since Moodle 4.2 + */ + public function duplicate_section(section_info $originalsection): section_info { + if (!$this->uses_sections()) { + throw new moodle_exception('sectionsnotsupported', 'core_courseformat'); + } + + $course = $this->get_course(); + $oldsectioninfo = get_fast_modinfo($course)->get_section_info($originalsection->section); + $newsection = course_create_section($course, $oldsectioninfo->section + 1); // Place new section after existing one. + + $newsection->name = $originalsection->name; + $newsection->summary = $originalsection->summary; + $newsection->summaryformat = $originalsection->summaryformat; + $newsection->visible = $originalsection->visible; + $newsection->availability = $originalsection->availability; + course_update_section($course, $newsection, $newsection); + + $modinfo = $this->get_modinfo(); + foreach ($modinfo->sections[$originalsection->section] as $modnumber) { + $originalcm = $modinfo->cms[$modnumber]; + duplicate_module($course, $originalcm, $newsection->id, false); + } + + return get_fast_modinfo($course)->get_section_info_by_id($newsection->id); + } } diff --git a/course/format/classes/output/local/content/section/controlmenu.php b/course/format/classes/output/local/content/section/controlmenu.php index 4bba866902d..a9bab4f813c 100644 --- a/course/format/classes/output/local/content/section/controlmenu.php +++ b/course/format/classes/output/local/content/section/controlmenu.php @@ -152,6 +152,17 @@ class controlmenu implements named_templatable, renderable { 'pixattr' => ['class' => ''], 'attr' => ['class' => 'icon edit'], ]; + + $duplicatesectionurl = clone($baseurl); + $duplicatesectionurl->param('section', $section->section); + $duplicatesectionurl->param('duplicatesection', $section->section); + $controls['duplicate'] = [ + 'url' => $duplicatesectionurl, + 'icon' => 't/copy', + 'name' => get_string('duplicate'), + 'pixattr' => ['class' => ''], + 'attr' => ['class' => 'icon duplicate'], + ]; } if ($section->section) { diff --git a/course/format/tests/base_test.php b/course/format/tests/base_test.php index 55ca4d7c602..d22422895c2 100644 --- a/course/format/tests/base_test.php +++ b/course/format/tests/base_test.php @@ -416,6 +416,45 @@ class base_test extends advanced_testcase { ] ]; } + + /** + * Test duplicate_section() + * @covers ::duplicate_section + */ + public function test_duplicate_section() { + global $DB; + + $this->setAdminUser(); + $this->resetAfterTest(); + + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $format = course_get_format($course); + + $originalsection = $DB->get_record('course_sections', ['course' => $course->id, 'section' => 1], '*', MUST_EXIST); + $generator->create_module('page', ['course' => $course, 'section' => $originalsection->section]); + $generator->create_module('page', ['course' => $course, 'section' => $originalsection->section]); + $generator->create_module('page', ['course' => $course, 'section' => $originalsection->section]); + + $originalmodcount = $DB->count_records('course_modules', ['course' => $course->id, 'section' => $originalsection->id]); + $this->assertEquals(3, $originalmodcount); + + $modinfo = get_fast_modinfo($course); + $sectioninfo = $modinfo->get_section_info($originalsection->section, MUST_EXIST); + + $newsection = $format->duplicate_section($sectioninfo); + + // Verify properties are the same. + foreach ($originalsection as $prop => $value) { + if ($prop == 'id' || $prop == 'sequence' || $prop == 'section' || $prop == 'timemodified') { + continue; + } + $this->assertEquals($value, $newsection->$prop); + } + + $newmodcount = $DB->count_records('course_modules', ['course' => $course->id, 'section' => $newsection->id]); + $this->assertEquals($originalmodcount, $newmodcount); + } } /** diff --git a/course/format/tests/behat/duplicate_section.feature b/course/format/tests/behat/duplicate_section.feature new file mode 100644 index 00000000000..65babf2e544 --- /dev/null +++ b/course/format/tests/behat/duplicate_section.feature @@ -0,0 +1,26 @@ +@core @core_course @core_courseformat +Feature: Duplicate a section + In order to set up my course contents quickly + As a teacher + I need to duplicate sections inside the same course + + Background: + Given the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | category | 0 | + | enablecompletion | 1 | + | numsections | 4 | + And the following "activities" exist: + | activity | name | intro | course | idnumber | section | + | assign | Activity sample 1 | Test assignment description | C1 | sample1 | 1 | + | book | Activity sample 2 | Test book description | C1 | sample2 | 1 | + | choice | Activity sample 3 | Test choice description | C1 | sample3 | 2 | + And I log in as "admin" + And I am on "Course 1" course homepage with editing mode on + + @javascript + Scenario: Duplicate section + Given I open section "1" edit menu + And I click on "Duplicate" "link" in the "Topic 1" "section" + Then I should see "Activity sample 2" in the "Topic 2" "section" diff --git a/course/format/upgrade.txt b/course/format/upgrade.txt index d3b30556675..77d4ef7dd8a 100644 --- a/course/format/upgrade.txt +++ b/course/format/upgrade.txt @@ -16,6 +16,7 @@ Overview of this plugin type at http://docs.moodle.org/dev/Course_formats - Renderer method: core_courseformat\output\section_renderer::bulk_editing_button - New overridable checkboxes: content/cm/bulkselect.mustache and content/section/bulkselect.mustache * Plugins can use the CSS class "bulk-hidden" to hide elements when the bulk editing is enabled. +* New core_courseformat\base::duplicate_section method to duplicate course sections and their modules within a course. === 4.1 === * New \core_courseformat\stateupdates methods add_section_remove() and add_cm_remove() have been added to replace diff --git a/course/lib.php b/course/lib.php index 0a57d990575..639409a13d9 100644 --- a/course/lib.php +++ b/course/lib.php @@ -3357,6 +3357,8 @@ function mod_duplicate_activity($course, $cm, $sr = null) { * * @param object $course course object. * @param object $cm course module object to be duplicated. + * @param int $sectionid section ID new course module will be placed in. + * @param bool $changename updates module name with text from duplicatedmodule lang string. * @since Moodle 2.8 * * @throws Exception @@ -3366,7 +3368,7 @@ function mod_duplicate_activity($course, $cm, $sr = null) { * * @return cm_info|null cminfo object if we sucessfully duplicated the mod and found the new cm. */ -function duplicate_module($course, $cm) { +function duplicate_module($course, $cm, int $sectionid = null, bool $changename = true): ?cm_info { global $CFG, $DB, $USER; require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); @@ -3442,16 +3444,22 @@ function duplicate_module($course, $cm) { // Proceed with activity renaming before everything else. We don't use APIs here to avoid // triggering a lot of create/update duplicated events. $newcm = get_coursemodule_from_id($cm->modname, $newcmid, $cm->course); - // Add ' (copy)' to duplicates. Note we don't cleanup or validate lengths here. It comes - // from original name that was valid, so the copy should be too. - $newname = get_string('duplicatedmodule', 'moodle', $newcm->name); - $DB->set_field($cm->modname, 'name', $newname, ['id' => $newcm->instance]); + if ($changename) { + // Add ' (copy)' to duplicates. Note we don't cleanup or validate lengths here. It comes + // from original name that was valid, so the copy should be too. + $newname = get_string('duplicatedmodule', 'moodle', $newcm->name); + $DB->set_field($cm->modname, 'name', $newname, ['id' => $newcm->instance]); + } - $section = $DB->get_record('course_sections', array('id' => $cm->section, 'course' => $cm->course)); - $modarray = explode(",", trim($section->sequence)); - $cmindex = array_search($cm->id, $modarray); - if ($cmindex !== false && $cmindex < count($modarray) - 1) { - moveto_module($newcm, $section, $modarray[$cmindex + 1]); + $section = $DB->get_record('course_sections', ['id' => $sectionid ?? $cm->section, 'course' => $cm->course]); + if (isset($sectionid)) { + moveto_module($newcm, $section); + } else { + $modarray = explode(",", trim($section->sequence)); + $cmindex = array_search($cm->id, $modarray); + if ($cmindex !== false && $cmindex < count($modarray) - 1) { + moveto_module($newcm, $section, $modarray[$cmindex + 1]); + } } // Update calendar events with the duplicated module. diff --git a/course/view.php b/course/view.php index 594e5ffd489..49890b39a88 100644 --- a/course/view.php +++ b/course/view.php @@ -13,6 +13,7 @@ $edit = optional_param('edit', -1, PARAM_BOOL); $hide = optional_param('hide', 0, PARAM_INT); $show = optional_param('show', 0, PARAM_INT); + $duplicatesection = optional_param('duplicatesection', 0, PARAM_INT); $idnumber = optional_param('idnumber', '', PARAM_RAW); $sectionid = optional_param('sectionid', 0, PARAM_INT); $section = optional_param('section', 0, PARAM_INT); @@ -187,6 +188,12 @@ } } + if (!empty($section) && !empty($coursesections) && !empty($duplicatesection) + && has_capability('moodle/course:update', $context) && confirm_sesskey()) { + $newsection = $format->duplicate_section($coursesections); + redirect(course_get_url($course, $newsection->section)); + } + if (!empty($section) && !empty($move) && has_capability('moodle/course:movesections', $context) && confirm_sesskey()) { $destsection = $section + $move;