Hooks developer documentation
- Simon Coggins
- Former user (Deleted)
- Dave Wallace
Hooks are a new piece of functionality available from Totara LMS 9 onwards. Hooks provide a way for plugins to extend core functionality without needing to modify core code.
Hooks have some structural similarities to Events but they are intended for quite different use cases. There are two main differences:
- Events are intended to allow components to react to core activities, but they are purely a one-way interaction. The observing component has no way to influence the outcome of the core action. Hooks are intended to be more powerful and involve two-way interaction between the watcher and the core action.
- Events must conform to an interface that requires the class structure to meet certain requirements. Hooks are much more flexible, with very few constraints on how the hook code is structured.
Implementation
Consist of two parts: the hook itself and a hook watcher. These are tied together by the db/hooks.php file.
Hook
Hook is a class that extends \totara_core\hook\base. The hook class should be stored in the component being hooked into.
Each hook should pass in and store (via the constructor) any data that may be of relevance to hook watchers. If the hook wants to allow the watcher to modify the data, it must be passed either as an object or explicitly passed by reference.
// /hookcomponent/path/classes/examplehook1.php namespace \hookcomponent\hook; /* * This is an example only. In practice hooks can do whatever * they want, but storing the data for use by watchers will be * a common pattern. */ class examplehook1 extends \totara_core\hook\base { public $arg1; public $arg2; public function __construct($arg1, $arg2) { $this->arg1 = $arg1; $this->arg2 = $arg2; } }
In the proper location the hook should be executed as follows:
// /hookcomponent/path/somefile.php // ... $hook = new \hookcomponent\hook\examplehook1($arg1, $arg2); $hook->execute(); // ...
When that code is reached the hook will be executed, which means that any watchers for that hook will have their code run, and they will have access to the hook object to allow them and modify the data.
Hook watcher
The hook watcher takes the form of any callable function which accepts the hook object as a single argument. The argument must be type-hinted to use the hook class above. It is best practice to store the function as a method in an autoloaded class in the component that is making use of the hook. If several hooks are being used to achieve a specific effect they should all be methods on the same class. The documentation for the class should describe which hooks are being made use of and what they are being used for.
A typical hook watcher would look like this:
// /watchercomponent/path/classes/watcher/examplewatcherclass.php namespace watchercomponent\watcher; use \hookcomponent\hook\examplehook1; use \hookcomponent\hook\examplehook2; class examplewatcherclass { public static function hook1watcher(examplehook1 $hook) { // Do something with $hook here, optionally modifying its properties. } public static function hook2watcher(examplehook2 $hook) { // Do something with $hook here, optionally modifying its properties. } }
db/hooks.php file
The db/hooks.php file is used to declare that a particular method should watch a specific hook. It can be used to set a priority which influences the order that hooks are executed (watchers with higher numbers in the 'priority' option are executed first). The default priority if none is specified is 100. The file is located within a plugin's directory in the db/hooks.php file. Hook watcher mappings are cached for performance so you need to bump the version number of your plugin if you make changes to the file to ensure they are picked up.
// /watchercomponent/path/db/hooks.php $watchers = [ [ 'hookname' => '\hookcomponent\hook\examplehook1', 'callback' => '\watchercomponent\watcher\examplewatcherclass::hook1watcher', 'includefile' => null, // Optional file to include to get access to callback - not required when using class autoloading. 'priority' => 100, ], [ 'hookname' => '\hookcomponent\hook\examplehook2', 'callback' => '\watchercomponent\watcher\examplewatcherclass::hook1watcher', 'includefile' => null, // Optional file to include to get access to callback - not required when using class autoloading. 'priority' => 100, ], ];
Example
Below is a simple example showing how hooks might be used. Imagine a situation where a user is being created:
// create_user.php $user = new \stdClass(); $user->firstname = $firstname; $user->lastname = $lastname; $user->email = $email; $user->suspended = 0; $userisvalid = validate_user($user); if ($userisvalid) { $userid = create_user($user); if ($userid) { // trigger an event that the user was created. $eventdata = ...; $event = \core\event\user_created::create($eventdata); $event->add_record_snapshot('user', $user); $event->trigger(); } }
You can see that there is a user_created event recording that the event occurred which could be observed, but it does not provide any way to influence the creation of the user. Now let's see how a hook could be used to modify the behaviour.
First let's create our hook:
// classes/hook/create_user.php class create_user extends \totara_core\hook\base { public $user; public $userisvalid; public function __construct($user, &$userisvalid) { $this->user = $user; $this->userisvalid =& $userisvalid; } }
In this case we pass the $user object and $userisvalid. The user object is passed by reference automatically but note how we need to explicitly pass $userisvalid by reference (because it is not an object) so we can modify it in the watcher.
Let's insert the hook into the code. We want to put it late enough that the existing data can be accessed, but early enough so that the hook watcher can impact the result:
// create_user.php $user = new \stdClass(); $user->firstname = $firstname; $user->lastname = $lastname; $user->email = $email; $user->suspended = 0; $userisvalid = validate_user($user); // Start hook code $hook = new \hookcomponent\hook\create_user($user, $userisvalid); $hook->execute(); // End hook code if ($userisvalid) { $userid = create_user($user); if ($userid) { // trigger an event that the user was created. $eventdata = ...; $event = \core\event\user_created::create($eventdata); $event->add_record_snapshot('user', $user); $event->trigger(); } }
As you can see the core code customisation required to add a new hook is minimal. Once in place it can be used by any component. A watcher might behave as follows:
// /watchercomponent/path/classes/watcher/watchercomponentclass.php namespace watchercomponent\watcher; use \hookcomponent\hook\create_user; class watchercomponentclass { public static function validate_user_by_email(create_user $hook) { $user = $hook->user; $emaildomain = substr($user->email, strpos($user->email, '@') + 1); if ($emaildomain == 'acme.com') { // We want to allow users from this domain. So simply return without // changing the $hook object and the user will be created as normal. return; } if ($emaildomain == 'foo.com') { // Let's say we want to review users from the foo.com domain before // giving them access. We can change the $user->suspended property so // the user is suspended when they are created. $hook->user->suspended = 1; return; } // If email domain is not recognised at all, block creation of the // user by overwriting $userisvalid. Since this was passed by reference // it will modify the original variable from create_user.php $hook->userisvalid = false; } }
Finally we must add the watcher to db/hooks.php and remember to bump version.php to trigger recaching of the watcher list:
// /watchercomponent/path/db/hooks.php $watchers = [ [ 'hookname' => '\hookcomponent\hook\create_user', 'callback' => '\watchercomponent\watcher\watchercomponentclass::validate_user_by_email', 'priority' => 100, ] ];
Authoring guidelines
We would like to encourage partners to create and submit hooks that they need as code contributions, since it will help to make the product more flexible and ensure there is a genuine need for the hooks that are added. Below are some tips on how to create hooks and hook watchers. As well as the example above you can checkout the code commit "8b2f27f2c63f7fd130d64614e2121238049f132d" which shows the conversion of the course editing form to remove some customisations and replace them with hooks. The diff from that commit is included below for easy reference:
commit 8b2f27f2c63f7fd130d64614e2121238049f132d Author: Sam Hemelryk <sam.hemelryk@totaralearning.com> Date: Wed May 4 11:02:40 2016 +1200 TL-8968 course: converted edit form hacks to use hooks Change-Id: I45152a7ceb5feaa322e1a3c4095908500c2f6e36 Reviewed-on: https://review.totaralms.com/11324 Reviewed-by: Brian Barnes <brian.barnes@totaralms.com> Reviewed-by: Petr Skoda <petr.skoda@totaralms.com> Tested-by: Jenkins Automation <jenkins@totaralms.com> diff --git a/course/classes/hook/edit_form_definition_complete.php b/course/classes/hook/edit_form_definition_complete.php new file mode 100644 index 0000000..2def36e --- /dev/null +++ b/course/classes/hook/edit_form_definition_complete.php @@ -0,0 +1,58 @@ +<?php +/* + * This file is part of Totara LMS + * + * Copyright (C) 2016 onwards Totara Learning Solutions LTD + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @author Sam Hemelryk <sam.hemelryk@totaralearning.com> + * @package core_course + */ + +namespace core_course\hook; + +/** + * Course edit form definition complete hook. + * + * This hook is called at the end of the course edit form definition, prior to data being set. + * + * @package core_course\hook + */ +class edit_form_definition_complete extends \totara_core\hook\base { + + /** + * The course edit form instance. + * @var \course_edit_form + */ + public $form; + + /** + * Custom data belonging to the form. + * This is protected on the form thus needs to be provided. + * @var mixed[] + */ + public $customdata; + + /** + * The edit_form_definition_complete constructor. + * + * @param \course_edit_form $form + * @param mixed[] $customdata + */ + public function __construct(\course_edit_form $form, array $customdata) { + $this->form = $form; + $this->customdata = $customdata; + } +} \ No newline at end of file diff --git a/course/classes/hook/edit_form_display.php b/course/classes/hook/edit_form_display.php new file mode 100644 index 0000000..db42392 --- /dev/null +++ b/course/classes/hook/edit_form_display.php @@ -0,0 +1,57 @@ +<?php +/* + * This file is part of Totara LMS + * + * Copyright (C) 2016 onwards Totara Learning Solutions LTD + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @author Sam Hemelryk <sam.hemelryk@totaralearning.com> + * @package core_course + */ + +namespace core_course\hook; + +/** + * Course edit form display hook. + * + * This hook is called immediately before the course edit form is displayed. + * + * @package core_course\hook + */ +class edit_form_display extends \totara_core\hook\base { + + /** + * The form instance that is about to be display. + * @var \course_edit_form + */ + public $form; + + /** + * Customdata from the form instance, which is otherwise private. + * @var mixed[] + */ + public $customdata; + + /** + * The edit_form_display constructor. + * + * @param \course_edit_form $form + * @param mixed[] $customdata + */ + public function __construct(\course_edit_form $form, array $customdata) { + $this->form = $form; + $this->customdata = $customdata; + } +} \ No newline at end of file diff --git a/course/classes/hook/edit_form_save_changes.php b/course/classes/hook/edit_form_save_changes.php new file mode 100644 index 0000000..e3d0f73 --- /dev/null +++ b/course/classes/hook/edit_form_save_changes.php @@ -0,0 +1,74 @@ +<?php +/* + * This file is part of Totara LMS + * + * Copyright (C) 2016 onwards Totara Learning Solutions LTD + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @author Sam Hemelryk <sam.hemelryk@totaralearning.com> + * @package core_course + */ + +namespace core_course\hook; + +/** + * Course edit form save changes hook. + * + * This hook is called after the course data has been saved, before the user is redirected. + * + * @package core_course\hook + */ +class edit_form_save_changes extends \totara_core\hook\base { + + /** + * True if a new course is being created, false if an existing course is being updated. + * @var bool + */ + public $iscreating = true; + + /** + * The course id. + * During creation this hook is called after the course has been created so we always have an ID. + * @var int + */ + public $courseid; + + /** + * The course context. + * During creation this hook is called after the course has been created so we always have a context. + * @var \context_course + */ + public $context; + + /** + * Data submit by the user, retrieved via the form. + * @var \stdClass + */ + public $data; + + /** + * The edit_form_save_changes constructor. + * + * @param bool $iscreating + * @param int $courseid + * @param \stdClass $data Data from the form, via {@see \course_edit_form::get_data()} + */ + public function __construct($iscreating, $courseid, \stdClass $data) { + $this->iscreating = (bool)$iscreating; + $this->courseid = $courseid; + $this->context = \context_course::instance($courseid, MUST_EXIST); + $this->data = $data; + } +} \ No newline at end of file diff --git a/course/edit.php b/course/edit.php index 50a6276..feda054 100644 --- a/course/edit.php +++ b/course/edit.php @@ -27,11 +27,7 @@ require_once('lib.php'); require_once('edit_form.php'); // Totara: extra includes -require_once($CFG->dirroot.'/totara/core/js/lib/setup.php'); require_once($CFG->dirroot.'/totara/customfield/fieldlib.php'); -require_once($CFG->dirroot.'/cohort/lib.php'); -require_once($CFG->dirroot.'/totara/cohort/lib.php'); -require_once($CFG->dirroot.'/totara/program/lib.php'); $id = optional_param('id', 0, PARAM_INT); // Course id. $categoryid = optional_param('category', 0, PARAM_INT); // Course category - can be changed in edit form. @@ -98,8 +94,6 @@ if ($id) { require_capability('moodle/course:update', $coursecontext); customfield_load_data($course, 'course', 'course'); - $instancetype = COHORT_ASSN_ITEMTYPE_COURSE; - $instanceid = $course->id; } else if ($categoryid) { // Creating new course in this category. $course = null; @@ -108,69 +102,12 @@ if ($id) { $catcontext = context_coursecat::instance($category->id); require_capability('moodle/course:create', $catcontext); $PAGE->set_context($catcontext); - $instancetype = COHORT_ASSN_ITEMTYPE_CATEGORY; - $instanceid = $categoryid; } else { require_login(); - print_error('needcoursecategoryid'); + print_error('needcoursecategroyid'); } -// Set up JS -local_js(array( - TOTARA_JS_UI, - TOTARA_JS_ICON_PREVIEW, - TOTARA_JS_DIALOG, - TOTARA_JS_TREEVIEW - )); - -// Enrolled audiences. -if (empty($course->id)) { - $enrolledselected = ''; -} else { - $enrolledselected = totara_cohort_get_course_cohorts($course->id, null, 'c.id'); - $enrolledselected = !empty($enrolledselected) ? implode(',', array_keys($enrolledselected)) : ''; -} -$PAGE->requires->strings_for_js(array('coursecohortsenrolled'), 'totara_cohort'); -$jsmodule = array( - 'name' => 'totara_cohortdialog', - 'fullpath' => '/totara/cohort/dialog/coursecohort.js', - 'requires' => array('json')); -$args = array('args'=>'{"enrolledselected":"' . $enrolledselected . '",'. - '"COHORT_ASSN_VALUE_ENROLLED":' . COHORT_ASSN_VALUE_ENROLLED . - ', "instancetype":"' . $instancetype . '", "instanceid":"' . $instanceid . '"}'); -$PAGE->requires->js_init_call('M.totara_coursecohort.init', $args, true, $jsmodule); -unset($enrolledselected); - -// Visible audiences. -if (!empty($CFG->audiencevisibility)) { - if(empty($course->id)) { - $visibleselected = ''; - } else { - $visibleselected = totara_cohort_get_visible_learning($course->id); - $visibleselected = !empty($visibleselected) ? implode(',', array_keys($visibleselected)) : ''; - } - $PAGE->requires->strings_for_js(array('coursecohortsvisible'), 'totara_cohort'); - $jsmodule = array( - 'name' => 'totara_visiblecohort', - 'fullpath' => '/totara/cohort/dialog/visiblecohort.js', - 'requires' => array('json')); - $args = array('args'=>'{"visibleselected":"' . $visibleselected . - '", "type":"course", "instancetype":"' . $instancetype . '", "instanceid":"' . $instanceid . '"}'); - $PAGE->requires->js_init_call('M.totara_visiblecohort.init', $args, true, $jsmodule); - unset($visibleselected); -} - -// Icon picker. -$PAGE->requires->string_for_js('chooseicon', 'totara_program'); -$iconjsmodule = array( - 'name' => 'totara_iconpicker', - 'fullpath' => '/totara/core/js/icon.picker.js', - 'requires' => array('json')); -$currenticon = isset($course->icon) ? $course->icon : 'default'; -$iconargs = array('args' => '{"selected_icon":"' . $currenticon . '","type":"course"}'); -$PAGE->requires->js_init_call('M.totara_iconpicker.init', $iconargs, false, $iconjsmodule); - // Prepare course and the editor. $editoroptions = array('maxfiles' => EDITOR_UNLIMITED_FILES, 'maxbytes'=>$CFG->maxbytes, 'trusttext'=>false, 'noclean'=>true); $overviewfilesoptions = course_overviewfiles_options($course); @@ -220,6 +157,9 @@ if ($editform->is_cancelled()) { } else if ($data = $editform->get_data()) { // Process data if submitted. if (empty($course->id)) { + // Totara: needed for the the edit_form_save_changes hook. + $iscreating = true; + // In creating the course. $course = create_course($data, $editoroptions); @@ -250,88 +190,17 @@ if ($editform->is_cancelled()) { } } } else { + // Totara: needed for the the edit_form_save_changes hook. + $iscreating = false; + // Save any changes to the files used in the editor. update_course($data, $editoroptions); // Set the URL to take them too if they choose save and display. $courseurl = new moodle_url('/course/view.php', array('id' => $course->id)); - - // Totara: Ensure all completion records are created. - completion_start_user_bulk($course->id); - } - - // Totara: Get context for capability checks. - $context = context_course::instance($course->id, MUST_EXIST); - - /// - /// Update course cohorts if user has permissions - /// - $runcohortsync = false; - if (enrol_is_enabled('cohort') and has_capability('moodle/course:enrolconfig', $context) and has_capability('enrol/cohort:config', $context)) { - // Enrolled audiences. - $currentcohorts = totara_cohort_get_course_cohorts($course->id, null, 'c.id, e.id AS associd'); - $currentcohorts = !empty($currentcohorts) ? $currentcohorts : array(); - $newcohorts = !empty($data->cohortsenrolled) ? explode(',', $data->cohortsenrolled) : array(); - - if ($todelete = array_diff(array_keys($currentcohorts), $newcohorts)) { - // Delete removed cohorts - foreach ($todelete as $cohortid) { - totara_cohort_delete_association($cohortid, $currentcohorts[$cohortid]->associd, COHORT_ASSN_ITEMTYPE_COURSE); - } - $runcohortsync = true; - } - - if ($newcohorts = array_diff($newcohorts, array_keys($currentcohorts))) { - // Add new cohort associations - foreach ($newcohorts as $cohortid) { - $cohort = $DB->get_record('cohort', array('id' => $cohortid)); - if (!$cohort) { - continue; - } - $context = context::instance_by_id($cohort->contextid); - if (!has_capability('moodle/cohort:view', $context)) { - continue; - } - totara_cohort_add_association($cohortid, $course->id, COHORT_ASSN_ITEMTYPE_COURSE); - } - $runcohortsync = true; - } - cache_helper::purge_by_event('changesincourse'); } - // Visible audiences. - if (!empty($CFG->audiencevisibility) && has_capability('totara/coursecatalog:manageaudiencevisibility', $context)) { - $visiblecohorts = totara_cohort_get_visible_learning($course->id); - $visiblecohorts = !empty($visiblecohorts) ? $visiblecohorts : array(); - $newvisible = !empty($data->cohortsvisible) ? explode(',', $data->cohortsvisible) : array(); - if ($todelete = array_diff(array_keys($visiblecohorts), $newvisible)) { - // Delete removed cohorts. - foreach ($todelete as $cohortid) { - totara_cohort_delete_association($cohortid, $visiblecohorts[$cohortid]->associd, - COHORT_ASSN_ITEMTYPE_COURSE, COHORT_ASSN_VALUE_VISIBLE); - } - } - - if ($newvisible = array_diff($newvisible, array_keys($visiblecohorts))) { - // Add new cohort associations. - foreach ($newvisible as $cohortid) { - $cohort = $DB->get_record('cohort', array('id' => $cohortid)); - if (!$cohort) { - continue; - } - $context = context::instance_by_id($cohort->contextid); - if (!has_capability('moodle/cohort:view', $context)) { - continue; - } - totara_cohort_add_association($cohortid, $course->id, COHORT_ASSN_ITEMTYPE_COURSE, COHORT_ASSN_VALUE_VISIBLE); - } - } - cache_helper::purge_by_event('changesincourse'); - - if ($runcohortsync) { - require_once("$CFG->dirroot/enrol/cohort/locallib.php"); - enrol_cohort_sync(new null_progress_trace(), $course->id); - } - } + $hook = new core_course\hook\edit_form_save_changes($iscreating, $course->id, $data); + $hook->execute(); if (isset($data->saveanddisplay)) { // Redirect user to newly created/updated course. diff --git a/course/edit_form.php b/course/edit_form.php index 6100172..ac489ce 100644 --- a/course/edit_form.php +++ b/course/edit_form.php @@ -6,9 +6,6 @@ require_once($CFG->libdir.'/formslib.php'); require_once($CFG->libdir.'/completionlib.php'); require_once($CFG->libdir. '/coursecatlib.php'); -// Totara: extra includes -require_once($CFG->dirroot.'/totara/cohort/lib.php'); - /** * The form for handling editing a course. */ @@ -20,7 +17,7 @@ class course_edit_form extends moodleform { * Form definition. */ function definition() { - global $USER, $CFG, $DB, $PAGE, $TOTARA_COURSE_TYPES, $COHORT_VISIBILITY; + global $CFG, $PAGE; $mform = $this->_form; $PAGE->requires->yui_module('moodle-course-formatchooser', 'M.course.init_formatchooser', @@ -31,7 +28,6 @@ class course_edit_form extends moodleform { $editoroptions = $this->_customdata['editoroptions']; $returnto = $this->_customdata['returnto']; $returnurl = $this->_customdata['returnurl']; - $nojs = (isset($this->_customdata['nojs'])) ? $this->_customdata['nojs'] : 0 ; $systemcontext = context_system::instance(); $categorycontext = context_coursecat::instance($category->id); @@ -107,31 +103,23 @@ class course_edit_form extends moodleform { } } - if (empty($CFG->audiencevisibility)) { - $choices = array(); - $choices['0'] = get_string('hide'); - $choices['1'] = get_string('show'); - $mform->addElement('select', 'visible', get_string('visible'), $choices); - $mform->addHelpButton('visible', 'visible'); - $mform->setDefault('visible', $courseconfig->visible); - if (!empty($course->id)) { - if (!has_capability('moodle/course:visibility', $coursecontext)) { - $mform->hardFreeze('visible'); - $mform->setConstant('visible', $course->visible); - } - } else { - if (!guess_if_creator_will_have_course_capability('moodle/course:visibility', $categorycontext)) { - $mform->hardFreeze('visible'); - $mform->setConstant('visible', $courseconfig->visible); - } + $choices = array(); + $choices['0'] = get_string('hide'); + $choices['1'] = get_string('show'); + $mform->addElement('select', 'visible', get_string('visible'), $choices); + $mform->addHelpButton('visible', 'visible'); + $mform->setDefault('visible', $courseconfig->visible); + if (!empty($course->id)) { + if (!has_capability('moodle/course:visibility', $coursecontext)) { + $mform->hardFreeze('visible'); + $mform->setConstant('visible', $course->visible); + } + } else { + if (!guess_if_creator_will_have_course_capability('moodle/course:visibility', $categorycontext)) { + $mform->hardFreeze('visible'); + $mform->setConstant('visible', $courseconfig->visible); } } - //Course type - $coursetypeoptions = array(); - foreach($TOTARA_COURSE_TYPES as $k => $v) { - $coursetypeoptions[$v] = get_string($k, 'totara_core'); - } - $mform->addElement('select', 'coursetype', get_string('coursetype', 'totara_core'), $coursetypeoptions); $mform->addElement('date_selector', 'startdate', get_string('startdate')); $mform->addHelpButton('startdate', 'startdate'); @@ -273,90 +261,14 @@ class course_edit_form extends moodleform { $mform->addElement('selectyesno', 'enablecompletion', get_string('enablecompletion', 'completion')); $mform->setDefault('enablecompletion', $courseconfig->enablecompletion); $mform->addHelpButton('enablecompletion', 'enablecompletion', 'completion'); - - $mform->addElement('advcheckbox', 'completionstartonenrol', get_string('completionstartonenrol', 'completion')); - $mform->setDefault('completionstartonenrol', $courseconfig->completionstartonenrol); - $mform->disabledIf('completionstartonenrol', 'enablecompletion', 'eq', 0); - - $mform->addElement('advcheckbox', 'completionprogressonview', get_string('completionprogressonview', 'completion')); - $mform->setDefault('completionprogressonview', $courseconfig->completionprogressonview); - $mform->disabledIf('completionprogressonview', 'enablecompletion', 'eq', 0); - $mform->addHelpButton('completionprogressonview', 'completionprogressonview', 'completion'); } else { $mform->addElement('hidden', 'enablecompletion'); $mform->setType('enablecompletion', PARAM_INT); $mform->setDefault('enablecompletion', 0); - - $mform->addElement('hidden', 'completionstartonenrol'); - $mform->setType('completionstartonenrol', PARAM_INT); - $mform->setDefault('completionstartonenrol',0); - - $mform->addElement('hidden', 'completionprogressonview'); - $mform->setType('completionprogressonview', PARAM_INT); - $mform->setDefault('completionprogressonview', 0); } - // Course Icons - if (empty($course->id)) { - $action = 'add'; - } else { - $action = 'edit'; - } - $course->icon = isset($course->icon) ? $course->icon : 'default'; - totara_add_icon_picker($mform, $action, 'course', $course->icon, $nojs); - // END Course Icons - -//-------------------------------------------------------------------------------- enrol_course_edit_form($mform, $course, $context); - // Only show the Enrolled Audiences functionality to users with the appropriate permissions to alter cohort enrol methods. - if (enrol_is_enabled('cohort') and has_capability('moodle/course:enrolconfig', $context) and has_capability('enrol/cohort:config', $context)) { - $mform->addElement('header','enrolledcohortshdr', get_string('enrolledcohorts', 'totara_cohort')); - - if (empty($course->id)) { - $cohorts = ''; - } else { - $cohorts = totara_cohort_get_course_cohorts($course->id, null, 'c.id'); - $cohorts = !empty($cohorts) ? implode(',', array_keys($cohorts)) : ''; - } - - $mform->addElement('hidden', 'cohortsenrolled', $cohorts); - $mform->setType('cohortsenrolled', PARAM_SEQUENCE); - $cohortsclass = new totara_cohort_course_cohorts(COHORT_ASSN_VALUE_ENROLLED); - $cohortsclass->build_table(!empty($course->id) ? $course->id : 0); - $mform->addElement('html', $cohortsclass->display(true)); - - $mform->addElement('button', 'cohortsaddenrolled', get_string('cohortsaddenrolled', 'totara_cohort')); - $mform->setExpanded('enrolledcohortshdr'); - } - - // Only show the Audiences Visibility functionality to users with the appropriate permissions. - if (!empty($CFG->audiencevisibility) && has_capability('totara/coursecatalog:manageaudiencevisibility', $context)) { - $mform->addElement('header', 'visiblecohortshdr', get_string('audiencevisibility', 'totara_cohort')); - $mform->addElement('select', 'audiencevisible', get_string('visibility', 'totara_cohort'), $COHORT_VISIBILITY); - $mform->addHelpButton('audiencevisible', 'visiblelearning', 'totara_cohort'); - - if (empty($course->id)) { - $mform->setDefault('audiencevisible', $courseconfig->visiblelearning); - $cohorts = ''; - } else { - $cohorts = totara_cohort_get_visible_learning($course->id); - $cohorts = !empty($cohorts) ? implode(',', array_keys($cohorts)) : ''; - } - - $mform->addElement('hidden', 'cohortsvisible', $cohorts); - $mform->setType('cohortsvisible', PARAM_SEQUENCE); - $cohortsclass = new totara_cohort_visible_learning_cohorts(); - $instanceid = !empty($course->id) ? $course->id : 0; - $instancetype = COHORT_ASSN_ITEMTYPE_COURSE; - $cohortsclass->build_visible_learning_table($instanceid, $instancetype); - $mform->addElement('html', $cohortsclass->display(true, 'visible')); - - $mform->addElement('button', 'cohortsaddvisible', get_string('cohortsaddvisible', 'totara_cohort')); - $mform->setExpanded('visiblecohortshdr'); - } - -//-------------------------------------------------------------------------------- $mform->addElement('header','groups', get_string('groupsettingsheader', 'group')); $choices = array(); @@ -396,7 +308,7 @@ class course_edit_form extends moodleform { customfield_definition($mform, $course, 'course', 0, 'course'); if (!empty($CFG->usetags) && - ((empty($course->id) && guess_if_creator_will_have_course_capability('moodle/course:tag', $categorycontext)) + ((empty($course->id) && guess_if_creator_will_have_course_capability('moodle/course:tag', $categorycontext)) || (!empty($course->id) && has_capability('moodle/course:tag', $coursecontext)))) { $mform->addElement('header', 'tagshdr', get_string('tags', 'tag')); $mform->addElement('tags', 'tags', get_string('tags')); @@ -416,6 +328,10 @@ class course_edit_form extends moodleform { $mform->addElement('hidden', 'id', null); $mform->setType('id', PARAM_INT); + // Called at the end of the definition, prior to data being set. + $hook = new core_course\hook\edit_form_definition_complete($this, $this->_customdata); + $hook->execute(); + // Finally set the current form data $this->set_data($course); } @@ -499,5 +415,16 @@ class course_edit_form extends moodleform { return $errors; } + + /** + * Overridden display method so that we can call our edit_form_display hook. + */ + public function display() { + + $hook = new core_course\hook\edit_form_display($this, $this->_customdata); + $hook->execute(); + + parent::display(); + } } diff --git a/course/tests/behat/totara_audience_visibility.feature b/course/tests/behat/totara_audience_visibility.feature new file mode 100644 index 0000000..e09c4b0 --- /dev/null +++ b/course/tests/behat/totara_audience_visibility.feature @@ -0,0 +1,109 @@ +@totara @core @core_course +Feature: Set audience visibility when defining a course + In order to test audience visibility + As an admin + I will enable it and then configure multiple courses to use it + + Scenario: Audience visibility controls are not shown when it is disabled + Given I am on a totara site + And I log in as "admin" + And I set the following administration settings values: + | Enable audience-based visibility | 0 | + And I create a course with: + | Course full name | Course 1 | + | Course short name | C1 | + When I navigate to "Edit settings" node in "Course administration" + Then I should not see "Audience-based visibility" + And I should not see "Visibility" + And I should see "Visible" + + @javascript + Scenario: Create courses with various audience visibility settings + Given I am on a totara site + And the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + | student2 | Student | 2 | student2@example.com | + | student3 | Student | 3 | student3@example.com | + And the following "cohorts" exist: + | name | idnumber | + | Audience 1 | a1 | + | Audience 2 | a2 | + And I log in as "admin" + And I set the following administration settings values: + | Enable audience-based visibility | 1 | + And I add "Student 1 (student1@example.com)" user to "a1" cohort members + And I add "Student 2 (student2@example.com)" user to "a2" cohort members + + And I create a course with: + | Course full name | Course 1 | + | Course short name | C1 | + | Visibility | All users | + And I enrol "Teacher 1" user as "Editing Trainer" + And I enrol "Student 3" user as "Learner" + + And I create a course with: + | Course full name | Course 2 | + | Course short name | C2 | + | Visibility | Enrolled users only | + And I enrol "Teacher 1" user as "Editing Trainer" + And I enrol "Student 3" user as "Learner" + + And I create a course with: + | Course full name | Course 3 | + | Course short name | C3 | + | Visibility | Enrolled users and members of the selected audiences | + And I enrol "Teacher 1" user as "Editing Trainer" + And I enrol "Student 3" user as "Learner" + And I navigate to "Edit settings" node in "Course administration" + And I should not see "Visible" + And I should see "Visibility" + And I should see "Audience-based visibility" + And I press "Add visible audiences" + And I should see "Course audiences (visible)" + And I should see "Audience 1" + And I click on "Audience 1" "link" + And I click on "OK" "link_or_button" in the "div[aria-describedby='course-cohorts-visible-dialog']" "css_element" + And I wait "1" seconds + And I should not see "Course audiences (visible)" + And I should see "Audience 1" + And I press "Save and display" + + And I create a course with: + | Course full name | Course 4 | + | Course short name | C4 | + | Visibility | No users | + And I enrol "Teacher 1" user as "Editing Trainer" + And I enrol "Student 3" user as "Learner" + + When I follow "Find Learning" + Then I should see "Course 1" + And I should see "Course 2" + And I should see "Course 3" + And I should see "Course 4" + And I log out + + When I log in as "student1" + And I follow "Find Learning" + Then I should see "Course 1" + And I should not see "Course 2" + And I should see "Course 3" + And I should not see "Course 4" + And I log out + + When I log in as "student2" + And I follow "Find Learning" + Then I should see "Course 1" + And I should not see "Course 2" + And I should not see "Course 3" + And I should not see "Course 4" + And I log out + + When I log in as "student3" + And I follow "Find Learning" + Then I should see "Course 1" + And I should see "Course 2" + And I should see "Course 3" + And I should not see "Course 4" + And I log out diff --git a/course/tests/behat/totara_course_icons.feature b/course/tests/behat/totara_course_icons.feature new file mode 100644 index 0000000..e5b1ef2 --- /dev/null +++ b/course/tests/behat/totara_course_icons.feature @@ -0,0 +1,37 @@ +@totara @core @core_course +Feature: An icon can be selected for a course + In order to test I can give a course an icon + As an admin I will set and icon and then change it + + @javascript + Scenario: I can select an icon for a course and then change it + Given I am on a totara site + And I log in as "admin" + And I create a course with: + | Course full name | Course 1 | + | Course short name | C1 | + When I navigate to "Edit settings" node in "Course administration" + Then I should see "Course icon" + And I should see "Current icon" + + When I click on "Choose icon" "button" + And I click on "img[title='Event Management']" "css_element" in the "#icon-selectable" "css_element" + And I click on "OK" "link_or_button" in the "div[aria-describedby='icon-dialog']" "css_element" + And I wait "1" seconds + Then I should see the "Event Management" image in the "#fitem_id_currenticon" "css_element" + + When I press "Save and display" + And I navigate to "Edit settings" node in "Course administration" + Then I should see the "Event Management" image in the "#fitem_id_currenticon" "css_element" + And I should not see the "Emotional Intelligence" image in the "#fitem_id_currenticon" "css_element" + + When I click on "Choose icon" "button" + And I click on "img[title='Emotional Intelligence']" "css_element" in the "#icon-selectable" "css_element" + And I click on "OK" "link_or_button" in the "div[aria-describedby='icon-dialog']" "css_element" + And I wait "1" seconds + And I should see the "Emotional Intelligence" image in the "#fitem_id_currenticon" "css_element" + + When I press "Save and display" + And I navigate to "Edit settings" node in "Course administration" + Then I should not see the "Event Management" image in the "#fitem_id_currenticon" "css_element" + And I should see the "Emotional Intelligence" image in the "#fitem_id_currenticon" "css_element" diff --git a/course/tests/behat/totara_course_type.feature b/course/tests/behat/totara_course_type.feature new file mode 100644 index 0000000..cd27fee --- /dev/null +++ b/course/tests/behat/totara_course_type.feature @@ -0,0 +1,30 @@ +@totara @core @core_course +Feature: A course type can be selected for a course + To test course types + As an admin + I will create a course and change its type + + @javascript + Scenario: I can select a type for a course and then change it + Given I am on a totara site + And I log in as "admin" + And I create a course with: + | Course full name | Course 1 | + | Course short name | C1 | + When I navigate to "Edit settings" node in "Course administration" + Then I should see "Course Type" + When I set the field "Course Type" to "E-learning" + Then the following fields match these values: + | Course Type | E-learning | + + When I set the field "Course Type" to "Blended" + And I press "Save and display" + And I navigate to "Edit settings" node in "Course administration" + Then the following fields match these values: + | Course Type | Blended | + + When I set the field "Course Type" to "Seminar" + And I press "Save and display" + And I navigate to "Edit settings" node in "Course administration" + Then the following fields match these values: + | Course Type | Seminar | \ No newline at end of file diff --git a/course/tests/behat/totara_enrolled_learning.feature b/course/tests/behat/totara_enrolled_learning.feature new file mode 100644 index 0000000..1be24ea --- /dev/null +++ b/course/tests/behat/totara_enrolled_learning.feature @@ -0,0 +1,47 @@ +@totara @core @core_course +Feature: Set enrolled learning for a course + In order to test I can set enrolled learning for a course + As an admin I edit the course + And add audiences to the Enrolled Learning controls. + + @javascript + Scenario: Create a course and enrol audiences + Given I am on a totara site + And the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + | student2 | Student | 2 | student2@example.com | + | student3 | Student | 3 | student3@example.com | + And the following "cohorts" exist: + | name | idnumber | + | Audience 1 | a1 | + | Audience 2 | a2 | + And I log in as "admin" + And I set the following administration settings values: + | Enable audience-based visibility | 1 | + And I add "Student 1 (student1@example.com)" user to "a1" cohort members + And I add "Student 2 (student2@example.com)" user to "a2" cohort members + + And I create a course with: + | Course full name | Course 1 | + | Course short name | C1 | + | Visibility | Enrolled users only | + When I navigate to "Edit settings" node in "Course administration" + Then I should see "Enrolled audiences" + + When I press "Add enrolled audiences" + Then I should see "Course audiences (enrolled)" + And I should see "Audience 1" + When I click on "Audience 1" "link" + And I click on "OK" "link_or_button" in the "div[aria-describedby='course-cohorts-enrolled-dialog']" "css_element" + And I wait "1" seconds + Then I should not see "Course audiences (enrolled)" + And I should see "Audience 1" + + When I press "Save and display" + And I navigate to "Enrolment methods" node in "Course administration > Users" + Then I should see "Audience sync (Audience 1 - Learner)" + + When I navigate to "Enrolled users" node in "Course administration > Users" + Then I should see "Student 1" diff --git a/totara/core/classes/watcher/core_course_edit_form.php b/totara/core/classes/watcher/core_course_edit_form.php new file mode 100644 index 0000000..5de6953 --- /dev/null +++ b/totara/core/classes/watcher/core_course_edit_form.php @@ -0,0 +1,659 @@ +<?php +/* + * This file is part of Totara LMS + * + * Copyright (C) 2016 onwards Totara Learning Solutions LTD + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @author Sam Hemelryk <sam.hemelryk@totaralearning.com> + * @package totara_core + */ + +namespace totara_core\watcher; + +use \core_course\hook\edit_form_definition_complete; +use \core_course\hook\edit_form_save_changes; +use \core_course\hook\edit_form_display; + +/** + * Class for managing Course edit form hooks. + * + * This class manages watchers for three hooks: + * + * 1. \core_course\hook\edit_form_definition_complete + * Gets called at the end of the course_edit_form definition. + * Through this watcher we can make any adjustments to the form definition we want, including adding + * Totara specific elements. + * + * 2. \core_course\hook\edit_form_save_changes + * Gets called after the form has been submit and the initial saving has been done, before the user is redirected. + * Through this watcher we can save any custom element data we need to. + * + * 3. \core_course\hook\edit_form_display + * Gets called immediately before the form is displayed and is used to initialise any required JS. + * + * @package totara_core\watcher + */ +class core_course_edit_form { + + /** + * Hook watcher that extends the course edit form with Totara specific elements. + * + * @param edit_form_definition_complete $hook + */ + public static function extend_form(edit_form_definition_complete $hook) { + global $CFG; + + // Totara: extra includes + require_once($CFG->dirroot.'/totara/core/js/lib/setup.php'); + require_once($CFG->dirroot.'/cohort/lib.php'); + require_once($CFG->dirroot.'/totara/cohort/lib.php'); + require_once($CFG->dirroot.'/totara/program/lib.php'); + + $mform = $hook->form->_form; + + // First up visibility. + // If audience visibility is enabled then we don't want to show the traditional visibility select. + if (!empty($CFG->audiencevisibility)) { + $mform->removeElement('visible'); + } + + // Add the Totara course type before startdate. + self::add_course_type_controls_to_form($hook); + + // Add the course completion modifications to the form. + self::add_course_completion_controls_to_form($hook); + + // Add course icons to the form. + self::add_course_icons_controls_to_form($hook); + + // Add enrolled audiences controls to the form. + self::add_enrolled_learning_controls_to_form($hook); + + // Add audience based visibility controls to form. + self::add_audience_visibility_controls_to_form($hook); + + // When custom fields gets converted to use hooks this is where they would go for courses. + } + + /** + * Course edit for hook watcher that is called immediately before the edit form is display. + * + * This watcher is used to load any JS required by the form modifications made in the {@see self::extend_form()} watcher. + * + * @param edit_form_display $hook + */ + public static function display_form(edit_form_display $hook) { + // Set up JS + local_js(array( + TOTARA_JS_UI, + TOTARA_JS_ICON_PREVIEW, + TOTARA_JS_DIALOG, + TOTARA_JS_TREEVIEW + )); + + self::initialise_enrolled_learning_js($hook); + self::initialise_audience_visibility_js($hook); + self::initialise_course_icons_js($hook); + } + + /** + * Course edit form save watcher. + * + * This watcher is called when saving data from the form, allowing us to process any custom elements that need processing. + * + * @param edit_form_save_changes $hook + */ + public static function save_form(edit_form_save_changes $hook) { + global $CFG; + + require_once($CFG->dirroot.'/cohort/lib.php'); + require_once($CFG->dirroot.'/totara/cohort/lib.php'); + require_once($CFG->dirroot.'/totara/program/lib.php'); + + if (!$hook->iscreating) { + // Ensure all completion records are created. + completion_start_user_bulk($hook->courseid); + } + + $changedenrolledlearning = self::save_enrolled_learning_changes($hook); + + if (!empty($CFG->audiencevisibility) && has_capability('totara/coursecatalog:manageaudiencevisibility', $hook->context)) { + // Update audience visibility. + self::save_audience_visibility_changes($hook); + + // If enrolled learning changed and audience visibility is on and can be managed then update the audiences. + if ($changedenrolledlearning) { + require_once("$CFG->dirroot/enrol/cohort/locallib.php"); + enrol_cohort_sync(new \null_progress_trace(), $hook->courseid); + } + } + } + + /** + * Adds course type controls to the course definition. + * + * Adds the following controls: + * - coursetype (select) + * + * Coursetype is a column on the course table so there is no corresponding save code. + * + * @param edit_form_definition_complete $hook + */ + protected static function add_course_type_controls_to_form(edit_form_definition_complete $hook) { + global $TOTARA_COURSE_TYPES; + + $mform = $hook->form->_form; + $coursetypeoptions = array(); + foreach($TOTARA_COURSE_TYPES as $k => $v) { + $coursetypeoptions[$v] = get_string($k, 'totara_core'); + } + $mform->insertElementBefore( + $mform->createElement('select', 'coursetype', get_string('coursetype', 'totara_core'), $coursetypeoptions), + 'startdate' + ); + } + + /** + * Add course completion controls to the form definition. + * + * Adds the following two fields: + * - completionstartonenrol (advcheckbox) + * - completionprogressonview (advcheckbox) + * + * Both completionstartonenrol, and completionprogressonview are columns on the course table so there is no + * corresponding save code. + * + * @param edit_form_definition_complete $hook + */ + protected static function add_course_completion_controls_to_form(edit_form_definition_complete $hook) { + + $mform = $hook->form->_form; + $courseconfig = get_config('moodlecourse'); + + // For the next part we need the element AFTER 'Enable completion'. + $beforename = null; + $next = false; + foreach (array_keys($mform->_elementIndex) as $elname) { + if ($elname === 'enablecompletion') { + $next = true; + } else if ($next) { + $beforename = $elname; + break; + } + } + + // Now completion starts on enrol option, and the progress view option. + if (\completion_info::is_enabled_for_site()) { + // Ok we know where to insert our new elements, now create and insert them. + $mform->insertElementBefore( + $mform->createElement('advcheckbox', 'completionstartonenrol', get_string('completionstartonenrol', 'completion')), + $beforename + ); + $mform->setDefault('completionstartonenrol', $courseconfig->completionstartonenrol); + $mform->disabledIf('completionstartonenrol', 'enablecompletion', 'eq', 0); + + $mform->insertElementBefore( + $mform->createElement('advcheckbox', 'completionprogressonview', get_string('completionprogressonview', 'completion')), + $beforename + ); + $mform->setDefault('completionprogressonview', $courseconfig->completionprogressonview); + $mform->disabledIf('completionprogressonview', 'enablecompletion', 'eq', 0); + $mform->addHelpButton('completionprogressonview', 'completionprogressonview', 'completion'); + } else { + // We're not worried about where we insert these, just do it at the end. + $mform->addElement('hidden', 'completionstartonenrol'); + $mform->setType('completionstartonenrol', PARAM_INT); + $mform->setDefault('completionstartonenrol',0); + + $mform->addElement('hidden', 'completionprogressonview'); + $mform->setType('completionprogressonview', PARAM_INT); + $mform->setDefault('completionprogressonview', 0); + } + } + + /** + * Add course icon selection controls to the course definition. + * + * Adds the following fields to the form: + * - iconheader (iconheader) + * - icon (hidden) + * - currenticon (static) + * + * JavaScript is required for this element and is loaded by (@see self::initialise_course_icons_js()} + * Icon is a column on the course table so there is no corresponding save code. + * + * @param edit_form_definition_complete $hook + */ + protected static function add_course_icons_controls_to_form(edit_form_definition_complete $hook) { + global $CFG; + + $mform = $hook->form->_form; + $course = $hook->customdata['course']; + $nojs = (isset($hook->customdata['nojs'])) ? $hook->customdata['nojs'] : 0 ; + + // For the next part we need the element AFTER 'Enable completion'. + $beforename = null; + $next = false; + foreach (array_keys($mform->_elementIndex) as $elname) { + if ($elname === 'enablecompletion') { + $next = true; + } else if ($next) { + $beforename = $elname; + break; + } + } + + $courseicon = isset($course->icon) ? $course->icon : 'default'; + $iconhtml = totara_icon_picker_preview('course', $courseicon); + + $mform->insertElementBefore( + $mform->createElement('header', 'iconheader', get_string('courseicon', 'totara_core')), + $beforename + ); + if ($nojs == 1) { + $mform->insertElementBefore( + $mform->createElement('static', 'currenticon', get_string('currenticon', 'totara_core'), $iconhtml), + $beforename + ); + $path = $CFG->dirroot . '/totara/core/pix/courseicons'; + $replace = array( + '.png' => '', + '_' => ' ', + '-' => ' ' + ); + $icons = array(); + foreach (scandir($path) as $icon) { + if ($icon == '.' || $icon == '..') { continue;} + $iconfile = str_replace('.png', '', $icon); + $iconname = strtr($icon, $replace); + $icons[$iconfile] = ucwords($iconname); + } + $mform->insertElementBefore( + $mform->createElement('select', 'icon', get_string('icon', 'totara_core'), $icons), + $beforename + ); + $mform->setDefault('icon', $courseicon); + $mform->setType('icon', PARAM_TEXT); + } else { + $buttonhtml = \html_writer::empty_tag('input', array( + 'type' => 'button', + 'value' => get_string('chooseicon', 'totara_program'), + 'id' => 'show-icon-dialog' + )); + // Hidden inputs can be safely added at the end. + $mform->addElement('hidden', 'icon'); + $mform->setType('icon', PARAM_TEXT); + $mform->insertElementBefore( + $mform->createElement('static', 'currenticon', get_string('currenticon', 'totara_core'), $iconhtml . $buttonhtml), + $beforename + ); + } + $mform->setExpanded('iconheader'); + } + + /** + * Adds the enrolled learning controls to the edit form. + * + * These controls allow the user to select one or more cohorts to enrol in the course automatically. + * + * Adds the following elements: + * - enrolledcohortshdr (header) + * - cohortsenrolled (hidden) + * - cohortsaddenrolled (button) + * + * JavaScript for these elements is loaded via {@see self::initialise_enrolled_learning_js()} + * Data from the elements is saved via {@see self::save_enrolled_learning_changes()} + * + * @param edit_form_definition_complete $hook + * @throws \coding_exception + */ + protected static function add_enrolled_learning_controls_to_form(edit_form_definition_complete $hook) { + + if (!enrol_is_enabled('cohort')) { + // Nothing to do here, cohort enrolment is not available. + return; + } + + $mform = $hook->form->_form; + $course = $hook->customdata['course']; + if (!empty($course->id)) { + $coursecontext = \context_course::instance($course->id); + $context = $coursecontext; + } else { + $coursecontext = null; + $context = \context_coursecat::instance($hook->customdata['category']->id);; + } + + if (!has_all_capabilities(['moodle/course:enrolconfig', 'enrol/cohort:config'], $context)) { + // Nothing to do here, the user cannot manage cohort enrolments. + return; + } + + $beforename = 'groups'; + $mform->insertElementBefore( + $mform->createElement('header','enrolledcohortshdr', get_string('enrolledcohorts', 'totara_cohort')), + $beforename + ); + + if (empty($course->id)) { + $cohorts = ''; + } else { + $cohorts = totara_cohort_get_course_cohorts($course->id, null, 'c.id'); + $cohorts = !empty($cohorts) ? implode(',', array_keys($cohorts)) : ''; + } + + $mform->addElement('hidden', 'cohortsenrolled', $cohorts); + $mform->setType('cohortsenrolled', PARAM_SEQUENCE); + $cohortsclass = new \totara_cohort_course_cohorts(COHORT_ASSN_VALUE_ENROLLED); + $cohortsclass->build_table(!empty($course->id) ? $course->id : 0); + $mform->insertElementBefore( + $mform->createElement('html', $cohortsclass->display(true)), + $beforename + ); + + $mform->insertElementBefore( + $mform->createElement('button', 'cohortsaddenrolled', get_string('cohortsaddenrolled', 'totara_cohort')), + $beforename + ); + $mform->setExpanded('enrolledcohortshdr'); + } + + /** + * Adds audience visibility controls to the form if audience visibility has been enabled. + * + * Adds the following elements: + * - visiblecohortshdr (header) + * - audiencevisible (select) + * - cohortsvisible (hidden) + * - cohortsaddvisible (button) + * + * JavaScript for these elements is loaded via {@see self::initialise_audience_visibility_js()} + * Data from the elements is saved via {@see self::save_audience_visibility_changes()} + * + * @param edit_form_definition_complete $hook + */ + protected static function add_audience_visibility_controls_to_form(edit_form_definition_complete $hook) { + global $CFG, $COHORT_VISIBILITY; + + if (empty($CFG->audiencevisibility)) { + // Nothing to do here, audience visibility is not enabled. + return; + } + + $courseconfig = get_config('moodlecourse'); + $mform = $hook->form->_form; + $course = $hook->customdata['course']; + if (!empty($course->id)) { + $coursecontext = \context_course::instance($course->id); + $context = $coursecontext; + } else { + $coursecontext = null; + $context = \context_coursecat::instance($hook->customdata['category']->id);; + } + + if (!has_capability('totara/coursecatalog:manageaudiencevisibility', $context)) { + // Nothing to do here the user cannot manage visibility in this context. + return; + } + + // Only show the Audiences Visibility functionality to users with the appropriate permissions. + $beforename = 'groups'; + + $mform->insertElementBefore( + $mform->createElement('header', 'visiblecohortshdr', get_string('audiencevisibility', 'totara_cohort')), + $beforename + ); + $mform->insertElementBefore( + $mform->createElement('select', 'audiencevisible', get_string('visibility', 'totara_cohort'), $COHORT_VISIBILITY), + $beforename + ); + $mform->addHelpButton('audiencevisible', 'visiblelearning', 'totara_cohort'); + + if (empty($course->id)) { + $mform->setDefault('audiencevisible', $courseconfig->visiblelearning); + $cohorts = ''; + } else { + $cohorts = totara_cohort_get_visible_learning($course->id); + $cohorts = !empty($cohorts) ? implode(',', array_keys($cohorts)) : ''; + } + + $mform->addElement('hidden', 'cohortsvisible', $cohorts); + $mform->setType('cohortsvisible', PARAM_SEQUENCE); + $cohortsclass = new \totara_cohort_visible_learning_cohorts(); + $instanceid = !empty($course->id) ? $course->id : 0; + $instancetype = COHORT_ASSN_ITEMTYPE_COURSE; + $cohortsclass->build_visible_learning_table($instanceid, $instancetype); + $mform->insertElementBefore( + $mform->createElement('html', $cohortsclass->display(true, 'visible')), + $beforename + ); + + $mform->insertElementBefore( + $mform->createElement('button', 'cohortsaddvisible', get_string('cohortsaddvisible', 'totara_cohort')), + $beforename + ); + $mform->setExpanded('visiblecohortshdr'); + } + + /** + * Initialise JS for the enrolled learning elements. + * + * Elements are initialised by {@see self::add_enrolled_learning_controls_to_form()}. + * Data is saved by {@see self::save_enrolled_learning_changes()}. + * + * @param edit_form_display $hook + */ + protected static function initialise_enrolled_learning_js(edit_form_display $hook) { + global $PAGE; + + $course = $hook->customdata['course']; + if (empty($course->id)) { + $instancetype = COHORT_ASSN_ITEMTYPE_CATEGORY; + $instanceid = $hook->customdata['category']->id; + $enrolledselected = ''; + } else { + $instancetype = COHORT_ASSN_ITEMTYPE_COURSE; + $instanceid = $course->id; + $enrolledselected = totara_cohort_get_course_cohorts($course->id, null, 'c.id'); + $enrolledselected = !empty($enrolledselected) ? implode(',', array_keys($enrolledselected)) : ''; + } + + $PAGE->requires->strings_for_js(array('coursecohortsenrolled'), 'totara_cohort'); + $jsmodule = array( + 'name' => 'totara_cohortdialog', + 'fullpath' => '/totara/cohort/dialog/coursecohort.js', + 'requires' => array('json')); + $args = array( + 'args'=>'{"enrolledselected":"' . $enrolledselected . '",'. + '"COHORT_ASSN_VALUE_ENROLLED":' . COHORT_ASSN_VALUE_ENROLLED . + ', "instancetype":"' . $instancetype . '", "instanceid":"' . $instanceid . '"}' + ); + $PAGE->requires->js_init_call('M.totara_coursecohort.init', $args, true, $jsmodule); + } + + /** + * Initialise JS for audience visibility controls. + * + * Elements are initialised by {@see self::add_audience_visibility_controls_to_form()}. + * Data is saved by {@see self::save_audience_visibility_changes()}. + * + * @param edit_form_display $hook\ + */ + protected static function initialise_audience_visibility_js(edit_form_display $hook) { + global $CFG, $PAGE; + + if (empty($CFG->audiencevisibility)) { + // Audience visibility is not enabled - nothing to do. + return; + } + + $course = $hook->customdata['course']; + + if (empty($course->id)) { + $instancetype = COHORT_ASSN_ITEMTYPE_CATEGORY; + $instanceid = $hook->customdata['category']->id; + $visibleselected = ''; + } else { + $instancetype = COHORT_ASSN_ITEMTYPE_COURSE; + $instanceid = $course->id; + $visibleselected = totara_cohort_get_visible_learning($course->id); + $visibleselected = !empty($visibleselected) ? implode(',', array_keys($visibleselected)) : ''; + } + + $PAGE->requires->strings_for_js(array('coursecohortsvisible'), 'totara_cohort'); + $jsmodule = array( + 'name' => 'totara_visiblecohort', + 'fullpath' => '/totara/cohort/dialog/visiblecohort.js', + 'requires' => array('json')); + $args = array( + 'args'=>'{"visibleselected":"' . $visibleselected . + '", "type":"course", "instancetype":"' . $instancetype . + '", "instanceid":"' . $instanceid . '"}' + ); + $PAGE->requires->js_init_call('M.totara_visiblecohort.init', $args, true, $jsmodule); + } + + /** + * Initialises JS for course icons. + * + * Elements are initialised by {@see self::add_course_icons_controls_to_form()}. + * Data is saved automatically. + * + * @param edit_form_display $hook + */ + protected static function initialise_course_icons_js(edit_form_display $hook) { + global $PAGE; + + $course = $hook->customdata['course']; + + // Icon picker. + $PAGE->requires->string_for_js('chooseicon', 'totara_program'); + $iconjsmodule = array( + 'name' => 'totara_iconpicker', + 'fullpath' => '/totara/core/js/icon.picker.js', + 'requires' => array('json')); + $currenticon = isset($course->icon) ? $course->icon : 'default'; + $iconargs = array( + 'args' => '{"selected_icon":"' . $currenticon . '","type":"course"}' + ); + $PAGE->requires->js_init_call('M.totara_iconpicker.init', $iconargs, false, $iconjsmodule); + } + + /** + * Saves changes to enrolled learning. + * + * @param edit_form_save_changes $hook + * @return bool + */ + protected static function save_enrolled_learning_changes(edit_form_save_changes $hook) { + global $DB; + + if (!enrol_is_enabled('cohort')) { + // Nothing to do here, we can't use cohort enrolment. + return false; + } + + if (!has_all_capabilities(['moodle/course:enrolconfig', 'enrol/cohort:config'], $hook->context)) { + // Nothing to do here, the user can't config enrolments. + return false; + } + + $data = $hook->data; + $courseid = $hook->courseid; + $changesmade = false; + + $currentcohorts = totara_cohort_get_course_cohorts($courseid, null, 'c.id, e.id AS associd'); + $currentcohorts = !empty($currentcohorts) ? $currentcohorts : array(); + $newcohorts = !empty($data->cohortsenrolled) ? explode(',', $data->cohortsenrolled) : array(); + + if ($todelete = array_diff(array_keys($currentcohorts), $newcohorts)) { + // Delete removed cohorts + foreach ($todelete as $cohortid) { + totara_cohort_delete_association($cohortid, $currentcohorts[$cohortid]->associd, COHORT_ASSN_ITEMTYPE_COURSE); + } + $changesmade = true; + } + + if ($newcohorts = array_diff($newcohorts, array_keys($currentcohorts))) { + // Add new cohort associations + foreach ($newcohorts as $cohortid) { + $cohort = $DB->get_record('cohort', array('id' => $cohortid)); + if (!$cohort) { + continue; + } + if (!has_capability('moodle/cohort:view', \context::instance_by_id($cohort->contextid))) { + continue; + } + totara_cohort_add_association($cohortid, $courseid, COHORT_ASSN_ITEMTYPE_COURSE); + } + $changesmade = true; + } + \cache_helper::purge_by_event('changesincourse'); + return $changesmade; + } + + /** + * Saves changes to audience visibility. + * + * @param edit_form_save_changes $hook + * @return bool + */ + protected static function save_audience_visibility_changes(edit_form_save_changes $hook) { + global $CFG, $DB; + + if (empty($CFG->audiencevisibility)) { + // Nothing to do here, audience visibility is not enabled. + return false; + } + + if (!has_capability('totara/coursecatalog:manageaudiencevisibility', $hook->context)) { + // Nothing to do here, the user does not have permission to change this. + } + + $data = $hook->data; + $courseid = $hook->courseid; + $changesmade = false; + + $visiblecohorts = totara_cohort_get_visible_learning($courseid); + $visiblecohorts = !empty($visiblecohorts) ? $visiblecohorts : array(); + $newvisible = !empty($data->cohortsvisible) ? explode(',', $data->cohortsvisible) : array(); + if ($todelete = array_diff(array_keys($visiblecohorts), $newvisible)) { + // Delete removed cohorts. + foreach ($todelete as $cohortid) { + totara_cohort_delete_association($cohortid, $visiblecohorts[$cohortid]->associd, + COHORT_ASSN_ITEMTYPE_COURSE, COHORT_ASSN_VALUE_VISIBLE); + } + $changesmade = true; + } + + if ($newvisible = array_diff($newvisible, array_keys($visiblecohorts))) { + // Add new cohort associations. + foreach ($newvisible as $cohortid) { + $cohort = $DB->get_record('cohort', array('id' => $cohortid)); + if (!$cohort) { + continue; + } + if (!has_capability('moodle/cohort:view', \context::instance_by_id($cohort->contextid))) { + continue; + } + totara_cohort_add_association($cohortid, $courseid, COHORT_ASSN_ITEMTYPE_COURSE, COHORT_ASSN_VALUE_VISIBLE); + } + $changesmade = true; + } + \cache_helper::purge_by_event('changesincourse'); + return $changesmade; + } +} \ No newline at end of file diff --git a/totara/core/db/hooks.php b/totara/core/db/hooks.php new file mode 100644 index 0000000..ec71db2 --- /dev/null +++ b/totara/core/db/hooks.php @@ -0,0 +1,46 @@ +<?php +/* + * This file is part of Totara LMS + * + * Copyright (C) 2016 onwards Totara Learning Solutions LTD + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @author Sam Hemelryk <sam.hemelryk@totaralearning.com> + * @package totara_core + */ + +$watchers = [ + [ + // Called at the end of course_edit_form::definition. + // Used by Totara to add Totara specific elements to the course definition. + 'hookname' => '\core_course\hook\edit_form_definition_complete', + 'callback' => 'totara_core\watcher\core_course_edit_form::extend_form', + 'priority' => 100, + ], + [ + // Called immediately before the course_edit_form instance is displayed. + // Used by Totara to add any required JS for the custom elements we've added. + 'hookname' => '\core_course\hook\edit_form_display', + 'callback' => 'totara_core\watcher\core_course_edit_form::display_form', + 'priority' => 100, + ], + [ + // Called after the initial form data has been saved, before redirect. + // Used by Totara to save data from our custom elements. + 'hookname' => '\core_course\hook\edit_form_save_changes', + 'callback' => 'totara_core\watcher\core_course_edit_form::save_form', + 'priority' => 100, + ], +]; \ No newline at end of file diff --git a/totara/core/tests/behat/behat_totara_core.php b/totara/core/tests/behat/behat_totara_core.php index 09d1fa9..2baefd9 100644 --- a/totara/core/tests/behat/behat_totara_core.php +++ b/totara/core/tests/behat/behat_totara_core.php @@ -25,7 +25,9 @@ require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); use Behat\Behat\Context\Step\Given as Given, - Behat\Gherkin\Node\TableNode as TableNode; + Behat\Gherkin\Node\TableNode as TableNode, + Behat\Mink\Exception\ElementNotFoundException, + Behat\Mink\Exception\ExpectationException; /** * The Totara core definitions class. @@ -309,4 +311,64 @@ class behat_totara_core extends behat_base { return true; } + + /** + * Expect to see a specific image (by alt or title) within the given thing. + * + * @Then /^I should see the "([^"]*)" image in the "([^"]*)" "([^"]*)"$/ + */ + public function i_should_see_the_x_image_in_the_y_element($titleoralt, $containerelement, $containerselectortype) { + // Get the container node; here we throw an exception + // if the container node does not exist. + $containernode = $this->get_selected_node($containerselectortype, $containerelement); + + $xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($titleoralt); + $locator = "//img[@alt={$xpathliteral} or @title={$xpathliteral}]"; + + // Will throw an ElementNotFoundException if it does not exist, but, actually + // it should not exist, so we try & catch it. + try { + // Would be better to use a 1 second sleep because the element should not be there, + // but we would need to duplicate the whole find_all() logic to do it, the benefit of + // changing to 1 second sleep is not significant. + $this->find('xpath', $locator, false, $containernode, self::REDUCED_TIMEOUT); + } catch (ElementNotFoundException $e) { + throw new ExpectationException('The "' . $titleoralt . '" image was not found exists in the "' . + $containerelement . '" "' . $containerselectortype . '"', $this->getSession()); + } + + } + + /** + * Expect to not see a specific image (by alt or title) within the given thing. + * + * @Then /^I should not see the "([^"]*)" image in the "([^"]*)" "([^"]*)"$/ + */ + public function i_should_not_see_the_x_image_in_the_y_element($titleoralt, $containerelement, $containerselectortype) { + // Get the container node; here we throw an exception + // if the container node does not exist. + $containernode = $this->get_selected_node($containerselectortype, $containerelement); + + $xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($titleoralt); + $locator = "//img[@alt={$xpathliteral} or @title={$xpathliteral}]"; + + // Will throw an ElementNotFoundException if it does not exist, but, actually + // it should not exist, so we try & catch it. + try { + // Would be better to use a 1 second sleep because the element should not be there, + // but we would need to duplicate the whole find_all() logic to do it, the benefit of + // changing to 1 second sleep is not significant. + $node = $this->find('xpath', $locator, false, $containernode, self::REDUCED_TIMEOUT); + if ($this->running_javascript() && !$node->isVisible()) { + // It passes it is there but is not visible. + return; + } + } catch (ElementNotFoundException $e) { + // It passes. + return; + } + throw new ExpectationException('The "' . $titleoralt . '" image was found in the "' . + $containerelement . '" "' . $containerselectortype . '"', $this->getSession()); + } + }
General guidelines
Please provide good documentation of your hooks, indicating in detail what data is passed, the impact the watcher has and any other pertinent details.
Please include PHPUnit tests with any contributed hooks to test their functionality.
Hook authoring guidelines
The hook API is intentionally flexible, but once a hook is added to Totara it is hard for us to change it without running the risk of breaking any existing watchers. Therefore we need to ensure we get the design of each hook correct. Please give careful consideration to the best location to execute the hook as well as which data should be passed to it. The aim is to provide enough flexibility that it will support a range of use cases, but at the same time we don't want to just pass through every bit of data that is available (since that could make it harder to refactor code without modifying the hook definition).
Watcher authoring guidelines
Watchers have a lot of power to substantially change the data passed via the hook. Watchers must use that power carefully to avoid leaving the data in a state that will cause bugs, either in the core code that is dependent on it or in other watchers.
If another watcher is modifying the data prior to your watcher in a destructive way you can set a higher priority in db/hooks.php to ensure your watcher is executed first. We recommend leaving the default priority of 100 unless you really need this functionality.