From 03c530cc9bbccd9904da5409cf8a5f0c59b5a797 Mon Sep 17 00:00:00 2001 From: ferran Date: Wed, 30 Oct 2024 11:55:31 +0100 Subject: [PATCH] MDL-82767 core_courseformat: new update.php to execute actions in course Since Moodle 4.0, most course edit actions can be executed using the core_courseformat_course_edit webservice using only four parameters (courseid, ids, targetsectionid and targetcmid). However, some actions logic is still replicated and embed in course/view.php and course/mod.php files since the beginning of time. Now the course/update.php offers a non-ajax way of executing the same actions and replace the old replicated ways of doing the same. --- .upgradenotes/MDL-82767-2024103011040897.yml | 11 + course/format/classes/base.php | 112 ++++++++-- .../format/classes/external/update_course.php | 27 +-- .../classes/output/local/courseupdate.php | 206 ++++++++++++++++++ course/format/tests/base_test.php | 5 +- course/format/update.php | 127 +++++++++++ 6 files changed, 450 insertions(+), 38 deletions(-) create mode 100644 .upgradenotes/MDL-82767-2024103011040897.yml create mode 100644 course/format/classes/output/local/courseupdate.php create mode 100644 course/format/update.php diff --git a/.upgradenotes/MDL-82767-2024103011040897.yml b/.upgradenotes/MDL-82767-2024103011040897.yml new file mode 100644 index 00000000000..18b0ae71692 --- /dev/null +++ b/.upgradenotes/MDL-82767-2024103011040897.yml @@ -0,0 +1,11 @@ +issueNumber: MDL-82767 +notes: + core_courseformat: + - message: >- + A new course/format/update.php url is added as a non-ajax alternative to + the core_courseformat_course_update webservice + type: improved + - message: >- + The core_courseformat\base::get_non_ajax_cm_action_url is now + deprecated. Use get_update_url instead. + type: deprecated diff --git a/course/format/classes/base.php b/course/format/classes/base.php index bfb71e6010e..9c57fc0c94a 100644 --- a/course/format/classes/base.php +++ b/course/format/classes/base.php @@ -33,8 +33,8 @@ use html_writer; use section_info; use context_course; use editsection_form; -use moodle_exception; -use coding_exception; +use core\exception\moodle_exception; +use core\exception\coding_exception; use moodle_url; use lang_string; use core_external\external_api; @@ -387,6 +387,37 @@ abstract class base { return $this->modinfo; } + /** + * Return a format state updates instance. + */ + public function get_stateupdates_instance(): \core_courseformat\stateupdates { + $defaultupdatesclass = 'core_courseformat\\stateupdates'; + $updatesclass = 'format_' . $this->format . '\\courseformat\\stateupdates'; + if (!class_exists($updatesclass)) { + $updatesclass = $defaultupdatesclass; + } + + $updates = new $updatesclass($this); + if (!is_a($updates, $defaultupdatesclass)) { + throw new coding_exception("The \"$updatesclass\" class must extend \"$defaultupdatesclass\""); + } + + return $updates; + } + + /** + * Return a format state actions instance. + * @return \core_courseformat\stateactions + */ + public function get_stateactions_instance(): \core_courseformat\stateactions { + // Get the actions class from the course format. + $actionsclass = 'format_'. $this->format.'\\courseformat\\stateactions'; + if (!class_exists($actionsclass)) { + $actionsclass = 'core_courseformat\\stateactions'; + } + return new $actionsclass(); + } + /** * Method used in the rendered and during backup instead of legacy 'numsections' * @@ -886,7 +917,7 @@ abstract class base { * of the view script, it is not enough to change just this function. Do not forget * to add proper redirection. * - * @param int|stdClass|section_info $section Section object from database or just field course_sections.section + * @param int|stdClass|section_info|null $section Section object from database or just field course_sections.section * if null the course view page is returned * @param array $options options for view URL. At the moment core uses: * 'navigation' (bool) if true and section not empty, the function returns section page; otherwise, it returns course page. @@ -925,6 +956,51 @@ abstract class base { return $url; } + /** + * The URL to update the course format. + * + * If no section is specified, the update will redirect to the general course page. + * + * @param string $action action name the reactive action + * @param array $ids list of ids to update + * @param int|null $targetsectionid optional target section id + * @param int|null $targetcmid optional target cm id + * @param moodle_url|null $returnurl optional custom return url + * @return moodle_url + */ + public function get_update_url( + string $action, + array $ids = [], + ?int $targetsectionid = null, + ?int $targetcmid = null, + ?moodle_url $returnurl = null + ): moodle_url { + $params = [ + 'courseid' => $this->get_courseid(), + 'sesskey' => sesskey(), + 'action' => $action, + ]; + + if (count($ids) === 1) { + $params['id'] = reset($ids); + } else { + foreach ($ids as $key => $id) { + $params["ids[]"] = $id; + } + } + + if ($targetsectionid) { + $params['sectionid'] = $targetsectionid; + } + if ($targetcmid) { + $params['cmid'] = $targetcmid; + } + if ($returnurl) { + $params['returnurl'] = $returnurl->out_as_local_url(); + } + return new moodle_url('/course/format/update.php', $params); + } + /** * Return the old non-ajax activity action url. * @@ -932,30 +1008,36 @@ abstract class base { * so we must translate to an old non-ajax url while non-ajax * course editing is still supported. * + * @deprecated since Moodle 5.0 + * @todo Remove this method in Moodle 6.0 (MDL-83530). + * * @param string $action action name the reactive action * @param cm_info $cm course module * @return moodle_url */ + #[\core\attribute\deprecated( + replacement: 'core_courseformat\base::get_update_url', + since: '5.0', + mdl: 'MDL-82767', + )] public function get_non_ajax_cm_action_url(string $action, cm_info $cm): moodle_url { $nonajaxactions = [ - 'cmDelete' => 'delete', - 'cmDuplicate' => 'duplicate', - 'cmHide' => 'hide', - 'cmShow' => 'show', - 'cmStealth' => 'stealth', + 'cmDelete' => 'cm_delete', + 'cmDuplicate' => 'cm_duplicate', + 'cmHide' => 'cm_hide', + 'cmShow' => 'cm_show', + 'cmStealth' => 'cm_stealth', ]; if (!isset($nonajaxactions[$action])) { throw new coding_exception('Unknown activity action: ' . $action); } + \core\deprecation::emit_deprecation_if_present([$this, __FUNCTION__]); $nonajaxaction = $nonajaxactions[$action]; - $nonajaxurl = new moodle_url( - '/course/mod.php', - ['sesskey' => sesskey(), $nonajaxaction => $cm->id] + return $this->get_update_url( + action: $nonajaxaction, + ids: [$cm->id], + returnurl: $this->get_view_url($this->get_sectionnum(), ['navigation' => true]), ); - if (!is_null($this->get_sectionid())) { - $nonajaxurl->param('sr', $this->get_sectionnum()); - } - return $nonajaxurl; } /** diff --git a/course/format/classes/external/update_course.php b/course/format/classes/external/update_course.php index 7552484774f..134c050f129 100644 --- a/course/format/classes/external/update_course.php +++ b/course/format/classes/external/update_course.php @@ -16,12 +16,11 @@ namespace core_courseformat\external; +use core\exception\moodle_exception; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_multiple_structure; use core_external\external_value; -use moodle_exception; -use coding_exception; use context_course; use core_courseformat\base as course_format; @@ -106,32 +105,16 @@ class update_course extends external_api { self::validate_context(context_course::instance($courseid)); - $courseformat = course_get_format($courseid); + $format = course_get_format($courseid); - // Create a course changes tracker object. - $defaultupdatesclass = 'core_courseformat\\stateupdates'; - $updatesclass = 'format_' . $courseformat->get_format() . '\\courseformat\\stateupdates'; - if (!class_exists($updatesclass)) { - $updatesclass = $defaultupdatesclass; - } - $updates = new $updatesclass($courseformat); - - if (!is_a($updates, $defaultupdatesclass)) { - throw new coding_exception("The \"$updatesclass\" class must extend \"$defaultupdatesclass\""); - } - - // Get the actions class from the course format. - $actionsclass = 'format_'. $courseformat->get_format().'\\courseformat\\stateactions'; - if (!class_exists($actionsclass)) { - $actionsclass = 'core_courseformat\\stateactions'; - } - $actions = new $actionsclass(); + $updates = $format->get_stateupdates_instance(); + $actions = $format->get_stateactions_instance(); if (!is_callable([$actions, $action])) { throw new moodle_exception("Invalid course state action $action in ".get_class($actions)); } - $course = $courseformat->get_course(); + $course = $format->get_course(); // Execute the action. $actions->$action($updates, $course, $ids, $targetsectionid, $targetcmid); diff --git a/course/format/classes/output/local/courseupdate.php b/course/format/classes/output/local/courseupdate.php new file mode 100644 index 00000000000..e5d932f5271 --- /dev/null +++ b/course/format/classes/output/local/courseupdate.php @@ -0,0 +1,206 @@ +. + +namespace core_courseformat\output\local; + +use core_courseformat\base as course_format; +use core\output\renderer_base; +use core\output\single_button; +use core\url; +use stdClass; + +/** + * Support UIs for non-ajax course updates alternatives. + * + * This class is used from course/format/update.php to provide confirmation + * dialogs for specific actions that require user confirmation. + * + * All protected methods has the same parameters as the core_courseformat\stateactions + * even if they are not used for a specific action. + * + * @package core_courseformat + * @copyright 2024 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class courseupdate { + use courseformat_named_templatable; + + /** + * Constructor. + * + * @param course_format $format the course format class. + * @param url $actionurl the current action url. + * @param url $returnurl the return url if the user cancel the action. + */ + public function __construct( + /** @var course_format the course format class */ + protected course_format $format, + /** @var url the current action url */ + protected url $actionurl, + /** @var url the return url if the user cancel the action */ + protected url $returnurl, + ) { + } + + /** + * Check if a specific action requires confirmation. + * + * Format plugins can override this method to provide confirmation + * dialogs for specific actions. + * + * @param string $action the action name + * @return bool + */ + public function is_confirmation_required( + string $action, + ): bool { + $methodname = $action . '_confirmation_dialog'; + return method_exists($this, $methodname); + } + + /** + * Get the confirmation dialog for a specific action. + * + * Format plugins can override this method to provide confirmation + * dialogs for specific actions. + * + * @param renderer_base $output the course renderer + * @param stdClass $course + * @param string $action the state action name to execute + * @param array $ids the section or cm ids. + * @param int|null $targetsectionid the optional target section id + * @param int|null $targetcmid the optional target cm id + * @return string the HTML output + */ + public function get_confirmation_dialog( + renderer_base $output, + stdClass $course, + string $action, + array $ids = [], + ?int $targetsectionid = null, + ?int $targetcmid = null, + ): string { + $methodname = $action . '_confirmation_dialog'; + if (method_exists($this, $methodname)) { + return $this->$methodname( + output: $output, + course: $course, + ids: $ids, + targetsectionid: $targetsectionid, + targetcmid: $targetcmid, + ); + } + return ''; + } + + /** + * Render the section delete confirmation dialog. + * + * @param renderer_base $output the course renderer + * @param stdClass $course + * @param array $ids the action ids. + * @param int|null $targetsectionid the target section id (not used) + * @param int|null $targetcmid the target cm id (not used) + * @return string the HTML output + */ + protected function section_delete_confirmation_dialog( + renderer_base $output, + stdClass $course, + array $ids = [], + ?int $targetsectionid = null, + ?int $targetcmid = null, + ): string { + if (count($ids) == 1) { + $modinfo = $this->format->get_modinfo(); + $section = $modinfo->get_section_info_by_id($ids[0]); + $title = get_string('sectiondelete_title', 'core_courseformat'); + $message = get_string( + 'sectiondelete_info', + 'core_courseformat', + ['name' => $this->format->get_section_name($section)] + ); + } else { + $title = get_string('sectionsdelete_title', 'core_courseformat'); + $message = get_string('sectionsdelete_info', 'core_courseformat', ['count' => count($ids)]); + } + + return $output->confirm( + message: $message, + cancel: $this->returnurl, + continue: new url($this->actionurl, ['confirm' => 1]), + displayoptions: [ + 'confirmtitle' => $title, + 'type' => single_button::BUTTON_DANGER, + 'continuestr' => get_string('delete'), + ] + ); + } + + /** + * Render the cm delete confirmation dialog. + * + * @param renderer_base $output the course renderer + * @param stdClass $course + * @param array $ids the action ids. + * @param int|null $targetsectionid the target section id (not used) + * @param int|null $targetcmid the target cm id (not used) + * @return string the HTML output + */ + protected function cm_delete_confirmation_dialog( + renderer_base $output, + stdClass $course, + array $ids = [], + ?int $targetsectionid = null, + ?int $targetcmid = null, + ): string { + + if (count($ids) == 1) { + $modinfo = $this->format->get_modinfo(); + $cm = $modinfo->get_cm($ids[0]); + + if ($cm->get_delegated_section_info()) { + $title = get_string('cmdelete_subsectiontitle', 'core_courseformat'); + $meesagestr = 'sectiondelete_info'; + } else { + $title = get_string('cmdelete_title', 'core_courseformat'); + $meesagestr = 'cmdelete_info'; + } + + $message = get_string( + $meesagestr, + 'core_courseformat', + (object) [ + 'type' => get_string('pluginname', 'mod_' . $cm->modname), + 'name' => $cm->name, + ], + ); + } else { + $title = get_string('cmsdelete_title', 'core_courseformat'); + $message = get_string('cmsdelete_info', 'core_courseformat', ['count' => count($ids)]); + } + + return $output->confirm( + message: $message, + cancel: $this->returnurl, + continue: new url($this->actionurl, ['confirm' => 1]), + displayoptions: [ + 'confirmtitle' => $title, + 'type' => single_button::BUTTON_DANGER, + 'continuestr' => get_string('delete'), + ] + ); + } +} diff --git a/course/format/tests/base_test.php b/course/format/tests/base_test.php index 1b4a835e889..2911ad3d1bd 100644 --- a/course/format/tests/base_test.php +++ b/course/format/tests/base_test.php @@ -790,7 +790,10 @@ final class base_test extends advanced_testcase { $this->expectException(\coding_exception::class); } $result = $format->get_non_ajax_cm_action_url($action, $cminfo); - $this->assertEquals($assign0->cmid, $result->param($expectedparam)); + if (!$exception) { + $this->assertDebuggingCalled(); + } + $this->assertEquals($assign0->cmid, $result->param('id')); } /** diff --git a/course/format/update.php b/course/format/update.php new file mode 100644 index 00000000000..0de542bf962 --- /dev/null +++ b/course/format/update.php @@ -0,0 +1,127 @@ +. + +/** + * Execute an update action on a course format and structure. + * + * @package core_courseformat + * @copyright 2024 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../../config.php'); +require_once($CFG->dirroot . '/course/lib.php'); + +use core\url; +use core\exception\moodle_exception; +use core_courseformat\base as course_format; + +$action = required_param('action', PARAM_ALPHANUMEXT); +$courseid = required_param('courseid', PARAM_INT); +$targetsectionid = optional_param('targetsectionid', null, PARAM_INT); +$targetcmid = optional_param('targetcmid', null, PARAM_INT); +$confirm = optional_param('confirm', false, PARAM_BOOL); + +$returnurl = optional_param('returnurl', null, PARAM_LOCALURL); + + +// All state updates are designed to be batch compatible. However, we also +// accept single id values for simplicity. +$ids = optional_param_array('ids', [], PARAM_INT); +if (empty($ids)) { + $ids = [required_param('id', PARAM_INT)]; +} +if (empty($ids)) { + throw new moodle_exception('missingparam', '', '', 'ids'); +} + +$format = course_get_format($courseid); +$course = $format->get_course(); + +if ($returnurl === null) { + $returnurl = new url('/course/view.php', ['id' => $course->id]); +} + +// Normalize the return URL. +$returnurl = new moodle_url($returnurl); + +$currenturl = new moodle_url( + '/course/format/update.php', + [ + 'action' => $action, + 'courseid' => $courseid, + 'targetsectionid' => $targetsectionid, + 'targetcmid' => $targetcmid, + 'returnurl' => $returnurl, + 'sesskey' => sesskey(), + ] +); +foreach ($ids as $key => $id) { + $currenturl->param("ids[]", $id); +} + +require_sesskey(); + +$PAGE->set_url($currenturl); +$PAGE->set_context($format->get_context()); +$PAGE->set_pagelayout('course'); +$PAGE->add_body_class('limitedwidth'); +$PAGE->set_heading($course->fullname); + +require_login($course); +require_all_capabilities( + ['moodle/course:update', 'moodle/course:sectionvisibility', 'moodle/course:activityvisibility'], + $format->get_context(), +); + +// Some actions may require a confirmation dialog. +$actionuiclass = $format->get_output_classname('courseupdate'); +/** @var core_courseformat\output\local\courseupdate $actionui */ +$actionui = new $actionuiclass($format, $currenturl, $returnurl); + +if ( + !$confirm + && $actionui->is_confirmation_required($action) +) { + /** @var \core_course_renderer $renderer */ + $renderer = $format->get_renderer($PAGE); + echo $renderer->header(); + echo $actionui->get_confirmation_dialog( + output: $renderer, + course: $course, + action: $action, + ids: $ids, + targetsectionid: $targetsectionid, + targetcmid: $targetcmid, + ); + echo $renderer->footer(); + die; +} + +$updates = $format->get_stateupdates_instance(); +$actions = $format->get_stateactions_instance(); + +if (!is_callable([$actions, $action])) { + throw new moodle_exception("Invalid course state action $action in ".get_class($actions)); +} + +// Execute the action. +$actions->$action($updates, $course, $ids, $targetsectionid, $targetcmid); + +// Any state action mark the state cache as dirty. +course_format::session_cache_reset($course); + +redirect($returnurl);