From 8fa50fdd3434e27d7a237d86420417c7a59dfd0d Mon Sep 17 00:00:00 2001 From: Mark Nielsen Date: Tue, 1 Apr 2014 15:07:54 -0700 Subject: [PATCH] MDL-44902: Several additions to External Tool (LTI) * LTI service related changes: ** Fixing exceptions in OAuth library. ** Added new launch option, Existing window: replaces entire page with the LTI object. ** The LTI tool ID used to perform the launch is now sent with the LTI launch parameters. This is sent back to Moodle on subsequent requests. ** Added $CFG->mod_lti_forcessl to force SSL on all LTI launches. ** Added new LTI launch parameter: tool_consumer_instance_name. Default value is site full name, but can be customized with $CFG->mod_lti_institution_name. ** The LTI grade service endpoints now set the affected user to the session. This was required for event listeners. ** Fix the grade deletion service. Was deleting the grade item instead of just the grade. ** Send error response when LTI instance does not accept grades and grades are being sent. ** Added a method for writing incoming LTI requests to disk for debugging. Disabled by default. * Changes for ltisource plugins: ** Can now to plug into backup/restore. ** Can now have settings.php files. ** Can now hook into the LTI launch and edit parameters. * Several grade changes: ** Added standard_grading_coursemodule_elements to LTI instance edit form. This means LTI instances can be configured with a grade. ** No longer assumes that grade is out of 100. ** Replaced modl/lti:grade capability with mod/lti:view. * JS on mod/lti/view.php for resizing the content object has been converted to YUI3. * Fixed misspellings in language file. * Added hooks for log post and view actions. * Bug fix for lti_get_url_thumbprint() when the URL is missing a schema. --- mod/lti/OAuth.php | 4 +- mod/lti/OAuthBody.php | 2 +- .../backup/moodle2/backup_lti_stepslib.php | 3 + .../restore_lti_activity_task.class.php | 15 +- .../backup/moodle2/restore_lti_stepslib.php | 12 +- mod/lti/classes/factory.php | 118 ++++++++++++ .../classes/observer/before_launch_event.php | 69 +++++++ mod/lti/classes/observer/dispatcher.php | 87 +++++++++ mod/lti/classes/observer/events.php | 44 +++++ .../classes/observer/listener_interface.php | 47 +++++ mod/lti/db/access.php | 14 -- mod/lti/edit_form.php | 10 +- mod/lti/grade.php | 5 +- mod/lti/lang/en/lti.php | 9 +- mod/lti/lib.php | 39 +++- mod/lti/locallib.php | 169 +++++++++++++++++- mod/lti/mod_form.js | 27 ++- mod/lti/mod_form.php | 4 + mod/lti/service.php | 18 ++ mod/lti/servicelib.php | 55 +++++- mod/lti/settings.php | 35 ++++ mod/lti/tests/locallib_test.php | 15 ++ mod/lti/view.php | 32 ++-- 23 files changed, 759 insertions(+), 74 deletions(-) create mode 100644 mod/lti/classes/factory.php create mode 100644 mod/lti/classes/observer/before_launch_event.php create mode 100644 mod/lti/classes/observer/dispatcher.php create mode 100644 mod/lti/classes/observer/events.php create mode 100644 mod/lti/classes/observer/listener_interface.php diff --git a/mod/lti/OAuth.php b/mod/lti/OAuth.php index 65e9b0f138a..e0365407e0d 100644 --- a/mod/lti/OAuth.php +++ b/mod/lti/OAuth.php @@ -181,7 +181,7 @@ class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod { // (3) some sort of specific discovery code based on request // // either way should return a string representation of the certificate - throw OAuthException("fetch_public_cert not implemented"); + throw new OAuthException("fetch_public_cert not implemented"); } protected function fetch_private_cert(&$request) { @@ -189,7 +189,7 @@ class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod { // (1) do a lookup in a table of trusted certs keyed off of consumer // // either way should return a string representation of the certificate - throw OAuthException("fetch_private_cert not implemented"); + throw new OAuthException("fetch_private_cert not implemented"); } public function build_signature(&$request, $consumer, $token) { diff --git a/mod/lti/OAuthBody.php b/mod/lti/OAuthBody.php index 565cdd34163..5f43f69d39e 100644 --- a/mod/lti/OAuthBody.php +++ b/mod/lti/OAuthBody.php @@ -114,7 +114,7 @@ function handleOAuthBodyPOST($oauth_consumer_key, $oauth_consumer_secret, $body, try { $server->verify_request($request); - } catch (Exception $e) { + } catch (\Exception $e) { $message = $e->getMessage(); throw new OAuthException("OAuth signature failed: " . $message); } diff --git a/mod/lti/backup/moodle2/backup_lti_stepslib.php b/mod/lti/backup/moodle2/backup_lti_stepslib.php index 8a7ac39bb1c..f74e29245c7 100644 --- a/mod/lti/backup/moodle2/backup_lti_stepslib.php +++ b/mod/lti/backup/moodle2/backup_lti_stepslib.php @@ -99,6 +99,9 @@ class backup_lti_activity_structure_step extends backup_activity_structure_step // Define file annotations $lti->annotate_files('mod_lti', 'intro', null); // This file areas haven't itemid + // Add support for subplugin structure. + $this->add_subplugin_structure('ltisource', $lti, true); + // Return the root element (lti), wrapped into standard activity structure return $this->prepare_activity_structure($lti); } diff --git a/mod/lti/backup/moodle2/restore_lti_activity_task.class.php b/mod/lti/backup/moodle2/restore_lti_activity_task.class.php index 61113e00ed2..a1a1e92dedb 100644 --- a/mod/lti/backup/moodle2/restore_lti_activity_task.class.php +++ b/mod/lti/backup/moodle2/restore_lti_activity_task.class.php @@ -47,7 +47,7 @@ defined('MOODLE_INTERNAL') || die(); -require_once($CFG->dirroot . '/mod/lti/backup/moodle2/restore_lti_stepslib.php'); // Because it exists (must) +require_once($CFG->dirroot . '/mod/lti/backup/moodle2/restore_lti_stepslib.php'); /** * basiclti restore task that provides all the settings and steps to perform one @@ -59,14 +59,14 @@ class restore_lti_activity_task extends restore_activity_task { * Define (add) particular settings this activity can have */ protected function define_my_settings() { - // No particular settings for this activity + // No particular settings for this activity. } /** * Define (add) particular steps this activity can have */ protected function define_my_steps() { - // label only has one structure step + // Label only has one structure step. $this->add_step(new restore_lti_activity_structure_step('lti_structure', 'lti.xml')); } @@ -129,4 +129,13 @@ class restore_lti_activity_task extends restore_activity_task { return $rules; } + + /** + * Getter for ltisource plugins. + * + * @return int + */ + public function get_old_moduleid() { + return $this->oldmoduleid; + } } diff --git a/mod/lti/backup/moodle2/restore_lti_stepslib.php b/mod/lti/backup/moodle2/restore_lti_stepslib.php index 042ac9f20f0..eef676be87f 100644 --- a/mod/lti/backup/moodle2/restore_lti_stepslib.php +++ b/mod/lti/backup/moodle2/restore_lti_stepslib.php @@ -56,9 +56,13 @@ class restore_lti_activity_structure_step extends restore_activity_structure_ste protected function define_structure() { $paths = array(); - $paths[] = new restore_path_element('lti', '/activity/lti'); + $lti = new restore_path_element('lti', '/activity/lti'); + $paths[] = $lti; - // Return the paths wrapped into standard activity structure + // Add support for subplugin structure. + $this->add_subplugin_structure('ltisource', $lti); + + // Return the paths wrapped into standard activity structure. return $this->prepare_activity_structure($paths); } @@ -78,12 +82,12 @@ class restore_lti_activity_structure_step extends restore_activity_structure_ste $newitemid = $DB->insert_record('lti', $data); - // immediately after inserting "activity" record, call this + // Immediately after inserting "activity" record, call this. $this->apply_activity_instance($newitemid); } protected function after_execute() { - // Add lti related files, no need to match by itemname (just internally handled context) + // Add lti related files, no need to match by itemname (just internally handled context). $this->add_related_files('mod_lti', 'intro', null); } } diff --git a/mod/lti/classes/factory.php b/mod/lti/classes/factory.php new file mode 100644 index 00000000000..b880150fe48 --- /dev/null +++ b/mod/lti/classes/factory.php @@ -0,0 +1,118 @@ +. + +/** + * Class Builder + * + * @package mod + * @subpackage lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ + +namespace mod_lti; + +use coding_exception; +use mod_lti\observer\dispatcher; + +/** + * Builds various classes + * + * @package mod_lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ +class factory { + /** + * Given a component and class name suffix, create a full + * class name and ensure that it exists. + * + * Classes are namespace based. + * + * @param string $component The component to find the class file in + * @param string $suffix This is appended to the component name, together make the class name we want + * @return string + * @throws \coding_exception + */ + protected function build_class_name($component, $suffix) { + $classname = "\\$component\\$suffix"; + if (!class_exists($classname)) { + throw new coding_exception("Expected to find $classname in the classes directory of $component"); + } + return $classname; + } + + /** + * Instantiate a class and optionally verify parent class + * + * @param string $class Create a new instance of this class + * @param null|string $parent Ensure that this is the parent class + * @return mixed + * @throws coding_exception + */ + protected function build_generic_instance($class, $parent = null) { + if (!is_null($parent)) { + $reflection = new \ReflectionClass($class); + if (!$reflection->isSubclassOf($parent)) { + throw new coding_exception("The $class must be a subclass of $parent"); + } + } + return new $class(); + } + + /** + * Builds a single ltisource plugin listener + * + * @param string $component The component to find the class file in + * @return \mod_lti\observer\listener_interface + */ + public function build_listener($component) { + return $this->build_generic_instance( + $this->build_class_name($component, 'listener'), + '\mod_lti\observer\listener_interface' + ); + } + + /** + * Builds ltisource plugin listeners + * + * @return \mod_lti\observer\listener_interface[] + */ + public function build_listeners() { + $plugins = \core_component::get_plugin_list('ltisource'); + $listeners = array(); + foreach (array_keys($plugins) as $pluginname) { + try { + $listeners[] = $this->build_listener('ltisource_'.$pluginname); + } catch (\Exception $e) { + // Class is optional, so ignore if not found. + } + } + return $listeners; + } + + /** + * Builds an event dispatcher with listeners. + * + * @return dispatcher + */ + public function build_dispatcher() { + $dispatcher = new dispatcher(); + $dispatcher->set_listeners($this->build_listeners()); + + return $dispatcher; + } +} \ No newline at end of file diff --git a/mod/lti/classes/observer/before_launch_event.php b/mod/lti/classes/observer/before_launch_event.php new file mode 100644 index 00000000000..2ca2b35a3a9 --- /dev/null +++ b/mod/lti/classes/observer/before_launch_event.php @@ -0,0 +1,69 @@ +. + +/** + * Event for ltisource plugins. + * + * @package mod + * @subpackage lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ + +namespace mod_lti\observer; + +/** + * This event occurs prior to an LTI launch. + * + * @package mod_lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ +class before_launch_event { + /** + * LTI activity instance + * + * @var \stdClass + */ + public $instance; + + /** + * Launch URL + * + * @var string + */ + public $endpoint; + + /** + * Launch request parameters + * + * @var array + */ + public $params; + + /** + * Constructor + * + * @param \stdClass $instance + * @param string $endpoint + * @param array $params + */ + public function __construct($instance, $endpoint, array $params) { + $this->instance = $instance; + $this->endpoint = $endpoint; + $this->params = $params; + } +} \ No newline at end of file diff --git a/mod/lti/classes/observer/dispatcher.php b/mod/lti/classes/observer/dispatcher.php new file mode 100644 index 00000000000..f438022ad73 --- /dev/null +++ b/mod/lti/classes/observer/dispatcher.php @@ -0,0 +1,87 @@ +. + +/** + * Dispatches events to ltisource plugin listeners + * + * @package mod + * @subpackage lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ + +namespace mod_lti\observer; + +/** + * Event dispatcher + * + * @package mod_lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ +class dispatcher { + /** + * @var listener_interface[] + */ + protected $listeners = array(); + + /** + * Add a listener + * + * @param listener_interface $listener + */ + public function add_listener(listener_interface $listener) { + $this->listeners[] = $listener; + } + + /** + * Set a list of listeners + * + * @param listener_interface[] $listeners + */ + public function set_listeners($listeners) { + $this->listeners = array(); + foreach ($listeners as $listener) { + $this->add_listener($listener); + } + } + + /** + * Dispatches an event. + * + * Very trivial right now. + * + * @param string $name Event name + * @param mixed $event The event object + * @throws \coding_exception + */ + public function dispatch($name, $event) { + foreach ($this->listeners as $listener) { + $subscribed = $listener->get_subscribed_events(); + + if (!array_key_exists($name, $subscribed)) { + continue; + } + $callable = array($listener, $subscribed[$name]); + if (!is_callable($callable)) { + throw new \coding_exception( + sprintf('The method %s is not callable on %s class', $subscribed[$name], get_class($listener)) + ); + } + call_user_func($callable, $event); + } + } +} \ No newline at end of file diff --git a/mod/lti/classes/observer/events.php b/mod/lti/classes/observer/events.php new file mode 100644 index 00000000000..8e95dbe9c60 --- /dev/null +++ b/mod/lti/classes/observer/events.php @@ -0,0 +1,44 @@ +. + +/** + * Defines internal events that ltisource + * plugins can subscribe to. + * + * @package mod + * @subpackage lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ + +namespace mod_lti\observer; + +/** + * These are all of the event names + * + * @package mod_lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ +final class events { + /** + * This is thrown before an LTI launch + * + * The event listener will recevie an instance + * of \mod_lti\observer\before_launch_event + */ + const BEFORE_LAUNCH = 'before.launch'; +} \ No newline at end of file diff --git a/mod/lti/classes/observer/listener_interface.php b/mod/lti/classes/observer/listener_interface.php new file mode 100644 index 00000000000..72760ffa7f2 --- /dev/null +++ b/mod/lti/classes/observer/listener_interface.php @@ -0,0 +1,47 @@ +. + +/** + * Listener interface for ltisource plugins + * + * @package mod + * @subpackage lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ + +namespace mod_lti\observer; + +/** + * This interface allows a class to define the ltisource + * plugin events that the class would like to subscribe + * to + * + * @package mod_lti + * @copyright Copyright (c) 2009 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License + */ +interface listener_interface { + /** + * Register for events + * + * Example return: + * array('eventname' => 'methodToCall'); + * + * @return array + */ + public function get_subscribed_events(); +} \ No newline at end of file diff --git a/mod/lti/db/access.php b/mod/lti/db/access.php index 647b83f63d4..13a43157060 100644 --- a/mod/lti/db/access.php +++ b/mod/lti/db/access.php @@ -56,20 +56,6 @@ $capabilities = array( 'clonepermissionsfrom' => 'moodle/course:manageactivities' ), - // Controls access to the grade.php script, which shows all the submissions - // made to the external tool that have been reported back to Moodle. - 'mod/lti:grade' => array( - 'riskbitmask' => RISK_PERSONAL, - - 'captype' => 'write', - 'contextlevel' => CONTEXT_MODULE, - 'archetypes' => array( - 'teacher' => CAP_ALLOW, - 'editingteacher' => CAP_ALLOW, - 'manager' => CAP_ALLOW - ) - ), - // When the user arrives at the external tool, if they have this capability // in Moodle, then they given the Instructor role in the remote system, // otherwise they are given Learner. See the lti_get_ims_role function. diff --git a/mod/lti/edit_form.php b/mod/lti/edit_form.php index 558c1b4f76d..2b13b9e2a96 100644 --- a/mod/lti/edit_form.php +++ b/mod/lti/edit_form.php @@ -54,6 +54,8 @@ require_once($CFG->dirroot.'/mod/lti/locallib.php'); class mod_lti_edit_types_form extends moodleform{ public function definition() { + global $CFG; + $mform =& $this->_form; //------------------------------------------------------------------------------- @@ -96,6 +98,7 @@ class mod_lti_edit_types_form extends moodleform{ $launchoptions=array(); $launchoptions[LTI_LAUNCH_CONTAINER_EMBED] = get_string('embed', 'lti'); $launchoptions[LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS] = get_string('embed_no_blocks', 'lti'); + $launchoptions[LTI_LAUNCH_CONTAINER_REPLACE_MOODLE_WINDOW] = get_string('existing_window', 'lti'); $launchoptions[LTI_LAUNCH_CONTAINER_WINDOW] = get_string('new_window', 'lti'); $mform->addElement('select', 'lti_launchcontainer', get_string('default_launch_container', 'lti'), $launchoptions); @@ -137,7 +140,12 @@ class mod_lti_edit_types_form extends moodleform{ $mform->addElement('checkbox', 'lti_forcessl', ' ', ' ' . get_string('force_ssl', 'lti'), $options); $mform->setType('lti_forcessl', PARAM_BOOL); - $mform->setDefault('lti_forcessl', '0'); + if (!empty($CFG->mod_lti_forcessl)) { + $mform->setDefault('lti_forcessl', '1'); + $mform->freeze('lti_forcessl'); + } else { + $mform->setDefault('lti_forcessl', '0'); + } $mform->addHelpButton('lti_forcessl', 'force_ssl', 'lti'); if (!empty($this->_customdata->isadmin)) { diff --git a/mod/lti/grade.php b/mod/lti/grade.php index c3c063d41dc..e13a4ec4855 100644 --- a/mod/lti/grade.php +++ b/mod/lti/grade.php @@ -68,7 +68,10 @@ $course = $DB->get_record('course', array('id' => $cm->course), '*', MUST_EXIST) require_login($course, false, $cm); $context = context_module::instance($cm->id); -require_capability('mod/lti:grade', $context); +require_capability('mod/lti:view', $context); + +// Redirecting to lti view. May need to wrap this in config. +redirect(new moodle_url('/mod/lti/view.php', array('l' => $lti->id))); $url = new moodle_url('/mod/lti/grade.php', array('id' => $cm->id)); if ($mode !== 'all') { diff --git a/mod/lti/lang/en/lti.php b/mod/lti/lang/en/lti.php index 10ed43a1c94..1653fabd506 100644 --- a/mod/lti/lang/en/lti.php +++ b/mod/lti/lang/en/lti.php @@ -111,11 +111,11 @@ $string['debuglaunchon'] = 'Debug launch'; $string['default'] = 'Default'; $string['default_launch_container'] = 'Default Launch Container'; $string['default_launch_container_help'] = 'The launch container affects the display of the tool when launched from the course. Some launch containers provide more screen -real estate to the tool, and others provide a more integrated feel with the Moodle environemnt. +real estate to the tool, and others provide a more integrated feel with the Moodle environment. * **Default** - Use the launch container specified by the tool configuration. * **Embed** - The tool is displayed within the existing Moodle window, in a manner similar to most other Activity types. -* **Embed, without blocks** - The tool is displayed within the existing Moodle window, with just the neavigation controls +* **Embed, without blocks** - The tool is displayed within the existing Moodle window, with just the navigation controls at the top of the page. * **New window** - The tool opens in a new window, occupying all the available space. Depending on the browser, it will open in a new tab or a popup window. @@ -147,6 +147,7 @@ $string['embed_no_blocks'] = 'Embed, without blocks'; $string['enableemailnotification'] = 'Send notification emails'; $string['enableemailnotification_help'] = 'If enabled, students will receive email notification when their tool submissions are graded.'; $string['errormisconfig'] = 'Misconfigured tool. Please ask your Moodle administrator to fix the configuration of the tool.'; +$string['existing_window'] = 'Existing window'; $string['extensions'] = 'LTI Extension Services'; $string['external_tool_type'] = 'External tool type'; $string['external_tool_type_help'] = 'The main purpose of a tool configuration is to set up a secure communication channel between Moodle and the tool provider. @@ -205,11 +206,11 @@ If you have selected a specific tool type, you may not need to enter a Launch UR into the tool provider\'s system, and not go to a specific resource, this will likely be the case.'; $string['launchinpopup'] = 'Launch Container'; $string['launchinpopup_help'] = 'The launch container affects the display of the tool when launched from the course. Some launch containers provide more screen -real estate to the tool, and others provide a more integrated feel with the Moodle environemnt. +real estate to the tool, and others provide a more integrated feel with the Moodle environment. * **Default** - Use the launch container specified by the tool configuration. * **Embed** - The tool is displayed within the existing Moodle window, in a manner similar to most other Activity types. -* **Embed, without blocks** - The tool is displayed within the existing Moodle window, with just the neavigation controls +* **Embed, without blocks** - The tool is displayed within the existing Moodle window, with just the navigation controls at the top of the page. * **New window** - The tool opens in a new window, occupying all the available space. Depending on the browser, it will open in a new tab or a popup window. diff --git a/mod/lti/lib.php b/mod/lti/lib.php index 8903475fd52..4e7f162cae1 100644 --- a/mod/lti/lib.php +++ b/mod/lti/lib.php @@ -95,17 +95,20 @@ function lti_add_instance($lti, $mform) { $lti->timemodified = $lti->timecreated; $lti->servicesalt = uniqid('', true); + lti_force_type_config_settings($lti, lti_get_type_config_by_instance($lti)); + if (empty($lti->typeid) && isset($lti->urlmatchedtypeid)) { $lti->typeid = $lti->urlmatchedtypeid; } - if (!isset($lti->grade)) { - $lti->grade = 100; // TODO: Why is this harcoded here and default @ DB + if (!isset($lti->instructorchoiceacceptgrades) || $lti->instructorchoiceacceptgrades != LTI_SETTING_ALWAYS) { + // The instance does not accept grades back from the provider, so set to "No grade" value 0. + $lti->grade = 0; } $lti->id = $DB->insert_record('lti', $lti); - if ($lti->instructorchoiceacceptgrades == LTI_SETTING_ALWAYS) { + if (isset($lti->instructorchoiceacceptgrades) && $lti->instructorchoiceacceptgrades == LTI_SETTING_ALWAYS) { if (!isset($lti->cmidnumber)) { $lti->cmidnumber = ''; } @@ -139,13 +142,15 @@ function lti_update_instance($lti, $mform) { $lti->showdescriptionlaunch = 0; } - if (!isset($lti->grade)) { - $lti->grade = $DB->get_field('lti', 'grade', array('id' => $lti->id)); - } + lti_force_type_config_settings($lti, lti_get_type_config_by_instance($lti)); - if ($lti->instructorchoiceacceptgrades == LTI_SETTING_ALWAYS) { + if (isset($lti->instructorchoiceacceptgrades) && $lti->instructorchoiceacceptgrades == LTI_SETTING_ALWAYS) { lti_grade_item_update($lti); } else { + // Instance is no longer accepting grades from Provider, set grade to "No grade" value 0. + $lti->grade = 0; + $lti->instructorchoiceacceptgrades = 0; + lti_grade_item_delete($lti); } @@ -468,7 +473,7 @@ function lti_grade_item_delete($basiclti) { function lti_extend_settings_navigation($settings, $parentnode) { global $PAGE; - if (has_capability('mod/lti:grade', context_module::instance($PAGE->cm->id))) { + if (has_capability('mod/lti:manage', context_module::instance($PAGE->cm->id))) { $keys = $parentnode->get_children_key_list(); $node = navigation_node::create('Submissions', @@ -478,3 +483,21 @@ function lti_extend_settings_navigation($settings, $parentnode) { $parentnode->add_node($node, $keys[1]); } } + +/** + * Log post actions + * + * @return array + */ +function lti_get_post_actions() { + return array(); +} + +/** + * Log view actions + * + * @return array + */ +function lti_get_view_actions() { + return array('view all', 'view'); +} diff --git a/mod/lti/locallib.php b/mod/lti/locallib.php index 4397d3b47af..2bf89d2834e 100644 --- a/mod/lti/locallib.php +++ b/mod/lti/locallib.php @@ -150,7 +150,7 @@ function lti_view($instance) { $orgid = $typeconfig['organizationid']; $course = $PAGE->course; - $requestparams = lti_build_request($instance, $typeconfig, $course); + $requestparams = lti_build_request($instance, $typeconfig, $course, $typeid); $launchcontainer = lti_get_launch_container($instance, $typeconfig); $returnurlparams = array('course' => $course->id, 'launch_container' => $launchcontainer, 'instanceid' => $instance->id); @@ -158,7 +158,11 @@ function lti_view($instance) { if ( $orgid ) { $requestparams["tool_consumer_instance_guid"] = $orgid; } - + if (!empty($CFG->mod_lti_institution_name)) { + $requestparams['tool_consumer_instance_name'] = $CFG->mod_lti_institution_name; + } else { + $requestparams['tool_consumer_instance_name'] = get_site()->fullname; + } if (empty($key) || empty($secret)) { $returnurlparams['unsigned'] = '1'; } @@ -171,8 +175,33 @@ function lti_view($instance) { $returnurl = lti_ensure_url_is_https($returnurl); } + $target = null; + switch($launchcontainer) { + case LTI_LAUNCH_CONTAINER_EMBED: + case LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS: + $target = 'iframe'; + break; + case LTI_LAUNCH_CONTAINER_REPLACE_MOODLE_WINDOW: + $target = 'frame'; + break; + case LTI_LAUNCH_CONTAINER_WINDOW: + $target = 'window'; + break; + } + if (!is_null($target)) { + $requestparams['launch_presentation_document_target'] = $target; + } + $requestparams['launch_presentation_return_url'] = $returnurl; + $factory = new \mod_lti\factory(); + $dispatcher = $factory->build_dispatcher(); + $event = new \mod_lti\observer\before_launch_event($instance, $endpoint, $requestparams); + $dispatcher->dispatch(\mod_lti\observer\events::BEFORE_LAUNCH, $event); + + // Allow params to be updated. + $requestparams = $event->params; + if (!empty($key) && !empty($secret)) { $parms = lti_sign_parameters($requestparams, $endpoint, "POST", $key, $secret); @@ -200,11 +229,22 @@ function lti_view($instance) { echo $content; } -function lti_build_sourcedid($instanceid, $userid, $launchid = null, $servicesalt) { +/** + * Build source ID + * + * @param int $instanceid + * @param int $userid + * @param string $servicesalt + * @param null|int $typeid + * @param null|int $launchid + * @return stdClass + */ +function lti_build_sourcedid($instanceid, $userid, $servicesalt, $typeid = null, $launchid = null) { $data = new stdClass(); $data->instanceid = $instanceid; $data->userid = $userid; + $data->typeid = $typeid; if (!empty($launchid)) { $data->launchid = $launchid; } else { @@ -228,10 +268,11 @@ function lti_build_sourcedid($instanceid, $userid, $launchid = null, $servicesal * @param object $instance Basic LTI instance object * @param object $typeconfig Basic LTI tool configuration * @param object $course Course object + * @param int|null $typeid Basic LTI tool ID * * @return array $request Request details */ -function lti_build_request($instance, $typeconfig, $course) { +function lti_build_request($instance, $typeconfig, $course, $typeid = null) { global $USER, $CFG; if (empty($instance->cmid)) { @@ -252,22 +293,30 @@ function lti_build_request($instance, $typeconfig, $course) { 'launch_presentation_locale' => current_language() ); + if (property_exists($instance, 'resource_link_id') and !empty($instance->resource_link_id)) { + $requestparams['resource_link_id'] = $instance->resource_link_id; + } $placementsecret = $instance->servicesalt; if ( isset($placementsecret) ) { - $sourcedid = json_encode(lti_build_sourcedid($instance->id, $USER->id, null, $placementsecret)); + $sourcedid = json_encode(lti_build_sourcedid($instance->id, $USER->id, $placementsecret, $typeid)); + $requestparams['lis_result_sourcedid'] = $sourcedid; } if ( isset($placementsecret) && ( $typeconfig['acceptgrades'] == LTI_SETTING_ALWAYS || ( $typeconfig['acceptgrades'] == LTI_SETTING_DELEGATE && $instance->instructorchoiceacceptgrades == LTI_SETTING_ALWAYS ) ) ) { - $requestparams['lis_result_sourcedid'] = $sourcedid; //Add outcome service URL $serviceurl = new moodle_url('/mod/lti/service.php'); $serviceurl = $serviceurl->out(); - if ($typeconfig['forcessl'] == '1') { + $forcessl = false; + if (!empty($CFG->mod_lti_forcessl)) { + $forcessl = true; + } + + if ($typeconfig['forcessl'] == '1' or $forcessl) { $serviceurl = lti_ensure_url_is_https($serviceurl); } @@ -497,7 +546,7 @@ function lti_get_ims_role($user, $cmid, $courseid) { } if (is_siteadmin($user)) { - array_push($roles, 'urn:lti:sysrole:ims/lis/Administrator'); + array_push($roles, 'urn:lti:sysrole:ims/lis/Administrator', 'urn:lti:instrole:ims/lis/Administrator'); } return join(',', $roles); @@ -638,6 +687,10 @@ function lti_get_tool_by_url_match($url, $courseid = null, $state = LTI_TOOL_STA } function lti_get_url_thumbprint($url) { + // Parse URL requires a schema otherwise everything goes into 'path'. Fixed 5.4.7 or later. + if (preg_match('/https?:\/\//', $url) !== 1) { + $url = 'http://'.$url; + } $urlparts = parse_url(strtolower($url)); if (!isset($urlparts['path'])) { $urlparts['path'] = ''; @@ -1173,3 +1226,103 @@ function lti_ensure_url_is_https($url) { return $url; } + +/** + * Determines if we should try to log the request + * + * @param string $rawbody + * @return bool + */ +function lti_should_log_request($rawbody) { + global $CFG; + + if (empty($CFG->mod_lti_log_users)) { + return false; + } + + $logusers = explode(',', $CFG->mod_lti_log_users); + if (empty($logusers)) { + return false; + } + + try { + $xml = new SimpleXMLElement($rawbody); + $ns = $xml->getNamespaces(); + $ns = array_shift($ns); + $xml->registerXPathNamespace('lti', $ns); + $requestuserid = ''; + if ($node = $xml->xpath('//lti:userId')) { + $node = $node[0]; + $requestuserid = clean_param((string) $node, PARAM_INT); + } else if ($node = $xml->xpath('//lti:sourcedId')) { + $node = $node[0]; + $resultjson = json_decode((string) $node); + $requestuserid = clean_param($resultjson->data->userid, PARAM_INT); + } + } catch (Exception $e) { + return false; + } + + if (empty($requestuserid) or !in_array($requestuserid, $logusers)) { + return false; + } + + return true; +} + +/** + * Logs the request to a file in temp dir + * + * @param string $rawbody + */ +function lti_log_request($rawbody) { + if ($tempdir = make_temp_directory('mod_lti', false)) { + if ($tempfile = tempnam($tempdir, 'mod_lti_request'.date('YmdHis'))) { + file_put_contents($tempfile, $rawbody); + chmod($tempfile, 0644); + } + } +} + +/** + * Fetches LTI type configuration for an LTI instance + * + * @param stdClass $instance + * @return array Can be empty if no type is found + */ +function lti_get_type_config_by_instance($instance) { + $typeid = null; + if (empty($instance->typeid)) { + $tool = lti_get_tool_by_url_match($instance->toolurl, $instance->course); + if ($tool) { + $typeid = $tool->id; + } + } else { + $typeid = $instance->typeid; + } + if (!empty($typeid)) { + return lti_get_type_config($typeid); + } + return array(); +} + +/** + * Enforce type config settings onto the LTI instance + * + * @param stdClass $instance + * @param array $typeconfig + */ +function lti_force_type_config_settings($instance, array $typeconfig) { + $forced = array( + 'instructorchoicesendname' => 'sendname', + 'instructorchoicesendemailaddr' => 'sendemailaddr', + 'instructorchoiceacceptgrades' => 'acceptgrades', + ); + + foreach ($forced as $instanceparam => $typeconfigparam) { + if (array_key_exists($typeconfigparam, $typeconfig) && $typeconfig[$typeconfigparam] != LTI_SETTING_DELEGATE) { + $instance->$instanceparam = $typeconfig[$typeconfigparam]; + } + } +} + diff --git a/mod/lti/mod_form.js b/mod/lti/mod_form.js index aabce00bc93..6dc0e14f906 100644 --- a/mod/lti/mod_form.js +++ b/mod/lti/mod_form.js @@ -77,9 +77,25 @@ }, 2000); }); + var allowgrades = Y.one('#id_instructorchoiceacceptgrades'); + allowgrades.on('change', this.toggleGradeSection, this); + updateToolMatches(); }, + toggleGradeSection: function(e) { + if (e) { + e.preventDefault(); + } + var allowgrades = Y.one('#id_instructorchoiceacceptgrades'); + var gradefieldset = Y.one('#modstandardgrade'); + if (!allowgrades.get('checked')) { + gradefieldset.hide(); + } else { + gradefieldset.show(); + } + }, + clearToolCache: function(){ this.urlCache = {}; this.toolTypeCache = {}; @@ -138,9 +154,10 @@ automatchToolDisplay.set('innerHTML', '' + M.str.lti.custom_config); } - var continuation = function(toolInfo){ - self.updatePrivacySettings(toolInfo); - + var continuation = function(toolInfo, inputfield){ + if (inputfield === undefined || (inputfield.get('id') != 'id_securetoolurl' || inputfield.get('value'))) { + self.updatePrivacySettings(toolInfo); + } if(toolInfo.toolname){ automatchToolDisplay.set('innerHTML', '' + M.str.lti.using_tool_configuration + toolInfo.toolname); } else if(!selectedToolType) { @@ -159,7 +176,7 @@ return continuation(self.urlCache[url]); } else if(!selectedToolType && !url) { // No tool type or url set - return continuation({}); + return continuation({}, field); } else { self.findToolByUrl(url, selectedToolType, function(toolInfo){ if(toolInfo){ @@ -237,6 +254,8 @@ } } } + + this.toggleGradeSection(); }, getSelectedToolTypeOption: function(){ diff --git a/mod/lti/mod_form.php b/mod/lti/mod_form.php index c2322681275..e34eb239a0d 100644 --- a/mod/lti/mod_form.php +++ b/mod/lti/mod_form.php @@ -125,6 +125,7 @@ class mod_lti_mod_form extends moodleform_mod { $launchoptions[LTI_LAUNCH_CONTAINER_DEFAULT] = get_string('default', 'lti'); $launchoptions[LTI_LAUNCH_CONTAINER_EMBED] = get_string('embed', 'lti'); $launchoptions[LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS] = get_string('embed_no_blocks', 'lti'); + $launchoptions[LTI_LAUNCH_CONTAINER_REPLACE_MOODLE_WINDOW] = get_string('existing_window', 'lti'); $launchoptions[LTI_LAUNCH_CONTAINER_WINDOW] = get_string('new_window', 'lti'); $mform->addElement('select', 'launchcontainer', get_string('launchinpopup', 'lti'), $launchoptions); @@ -194,6 +195,9 @@ class mod_lti_mod_form extends moodleform_mod { } */ + // Add standard course module grading elements. + $this->standard_grading_coursemodule_elements(); + //------------------------------------------------------------------------------- // add standard elements, common to all modules $this->standard_coursemodule_elements(); diff --git a/mod/lti/service.php b/mod/lti/service.php index beae870997e..7cfb4258a5a 100644 --- a/mod/lti/service.php +++ b/mod/lti/service.php @@ -34,6 +34,10 @@ use moodle\mod\lti as lti; $rawbody = file_get_contents("php://input"); +if (lti_should_log_request($rawbody)) { + lti_log_request($rawbody); +} + foreach (lti\OAuthUtil::get_headers() as $name => $value) { if ($name === 'Authorization') { // TODO: Switch to core oauthlib once implemented - MDL-30149 @@ -78,7 +82,12 @@ switch ($messagetype) { $ltiinstance = $DB->get_record('lti', array('id' => $parsed->instanceid)); + if (!lti_accepts_grades($ltiinstance)) { + throw new Exception('Tool does not accept grades'); + } + lti_verify_sourcedid($ltiinstance, $parsed); + lti_set_session_user($parsed->userid); $gradestatus = lti_update_grade($ltiinstance, $parsed->userid, $parsed->launchid, $parsed->gradeval); @@ -98,6 +107,10 @@ switch ($messagetype) { $ltiinstance = $DB->get_record('lti', array('id' => $parsed->instanceid)); + if (!lti_accepts_grades($ltiinstance)) { + throw new Exception('Tool does not accept grades'); + } + //Getting the grade requires the context is set $context = context_course::instance($ltiinstance->course); $PAGE->set_context($context); @@ -127,7 +140,12 @@ switch ($messagetype) { $ltiinstance = $DB->get_record('lti', array('id' => $parsed->instanceid)); + if (!lti_accepts_grades($ltiinstance)) { + throw new Exception('Tool does not accept grades'); + } + lti_verify_sourcedid($ltiinstance, $parsed); + lti_set_session_user($parsed->userid); $gradestatus = lti_delete_grade($ltiinstance, $parsed->userid); diff --git a/mod/lti/servicelib.php b/mod/lti/servicelib.php index 3d7cc0eb180..c1f3f97003d 100644 --- a/mod/lti/servicelib.php +++ b/mod/lti/servicelib.php @@ -79,11 +79,12 @@ function lti_parse_grade_replace_message($xml) { } $parsed = new stdClass(); - $parsed->gradeval = $grade * 100; + $parsed->gradeval = $grade; $parsed->instanceid = $resultjson->data->instanceid; $parsed->userid = $resultjson->data->userid; $parsed->launchid = $resultjson->data->launchid; + $parsed->typeid = $resultjson->data->typeid; $parsed->sourcedidhash = $resultjson->hash; $parsed->messageid = lti_parse_message_id($xml); @@ -99,6 +100,7 @@ function lti_parse_grade_read_message($xml) { $parsed->instanceid = $resultjson->data->instanceid; $parsed->userid = $resultjson->data->userid; $parsed->launchid = $resultjson->data->launchid; + $parsed->typeid = $resultjson->data->typeid; $parsed->sourcedidhash = $resultjson->hash; $parsed->messageid = lti_parse_message_id($xml); @@ -114,6 +116,7 @@ function lti_parse_grade_delete_message($xml) { $parsed->instanceid = $resultjson->data->instanceid; $parsed->userid = $resultjson->data->userid; $parsed->launchid = $resultjson->data->launchid; + $parsed->typeid = $resultjson->data->typeid; $parsed->sourcedidhash = $resultjson->hash; $parsed->messageid = lti_parse_message_id($xml); @@ -121,6 +124,33 @@ function lti_parse_grade_delete_message($xml) { return $parsed; } +function lti_accepts_grades($ltiinstance) { + $acceptsgrades = true; + $typeconfig = lti_get_config($ltiinstance); + + $typeacceptgrades = isset($typeconfig['acceptgrades']) ? $typeconfig['acceptgrades'] : LTI_SETTING_DELEGATE; + + if (!($typeacceptgrades == LTI_SETTING_ALWAYS || + ($typeacceptgrades == LTI_SETTING_DELEGATE && $ltiinstance->instructorchoiceacceptgrades == LTI_SETTING_ALWAYS))) { + $acceptsgrades = false; + } + + return $acceptsgrades; +} + +/** + * Set the passed user ID to the session user. + * + * @param int $userid + */ +function lti_set_session_user($userid) { + global $DB; + + if ($user = $DB->get_record('user', array('id' => $userid))) { + \core\session\manager::set_user($user); + } +} + function lti_update_grade($ltiinstance, $userid, $launchid, $gradeval) { global $CFG, $DB; require_once($CFG->libdir . '/gradelib.php'); @@ -128,6 +158,8 @@ function lti_update_grade($ltiinstance, $userid, $launchid, $gradeval) { $params = array(); $params['itemname'] = $ltiinstance->name; + $gradeval = $gradeval * floatval($ltiinstance->grade); + $grade = new stdClass(); $grade->userid = $userid; $grade->rawgrade = $gradeval; @@ -170,10 +202,12 @@ function lti_read_grade($ltiinstance, $userid) { $grades = grade_get_grades($ltiinstance->course, LTI_ITEM_TYPE, LTI_ITEM_MODULE, $ltiinstance->id, $userid); - if (isset($grades) && isset($grades->items[0]) && is_array($grades->items[0]->grades)) { + $ltigrade = floatval($ltiinstance->grade); + + if (!empty($ltigrade) && isset($grades) && isset($grades->items[0]) && is_array($grades->items[0]->grades)) { foreach ($grades->items[0]->grades as $agrade) { $grade = $agrade->grade; - $grade = $grade / 100.0; + $grade = $grade / $ltigrade; break; } } @@ -191,7 +225,7 @@ function lti_delete_grade($ltiinstance, $userid) { $grade->userid = $userid; $grade->rawgrade = null; - $status = grade_update(LTI_SOURCE, $ltiinstance->course, LTI_ITEM_TYPE, LTI_ITEM_MODULE, $ltiinstance->id, 0, $grade, array('deleted'=>1)); + $status = grade_update(LTI_SOURCE, $ltiinstance->course, LTI_ITEM_TYPE, LTI_ITEM_MODULE, $ltiinstance->id, 0, $grade); return $status == GRADE_UPDATE_OK; } @@ -215,8 +249,16 @@ function lti_verify_message($key, $sharedsecrets, $body, $headers = null) { return false; } +/** + * Validate source ID from external request + * + * @param object $ltiinstance + * @param object $parsed + * @throws Exception + */ function lti_verify_sourcedid($ltiinstance, $parsed) { - $sourceid = lti_build_sourcedid($parsed->instanceid, $parsed->userid, $parsed->launchid, $ltiinstance->servicesalt); + $sourceid = lti_build_sourcedid($parsed->instanceid, $parsed->userid, + $ltiinstance->servicesalt, $parsed->typeid, $parsed->launchid); if ($sourceid->hash != $parsed->sourcedidhash) { throw new Exception('SourcedId hash not valid'); @@ -238,6 +280,7 @@ function lti_extend_lti_services($data) { if (count($plugins) > 1) { throw new coding_exception('More than one ltisource plugin handler found'); } + $data->xml = new SimpleXMLElement($data->body); $callback = current($plugins); call_user_func($callback, $data); } catch (moodle_exception $e) { @@ -258,4 +301,4 @@ function lti_extend_lti_services($data) { return true; } return false; -} \ No newline at end of file +} diff --git a/mod/lti/settings.php b/mod/lti/settings.php index ad754376956..3055e79f60d 100644 --- a/mod/lti/settings.php +++ b/mod/lti/settings.php @@ -173,4 +173,39 @@ if ($ADMIN->fulltree) { "; $settings->add(new admin_setting_heading('lti_types', get_string('external_tool_types', 'lti') . $OUTPUT->help_icon('main_admin', 'lti'), $template)); + + if (!during_initial_install()) { + // Process subplugin settings pages if any. + // Every subplugin that wishes to have settings page should provide it's own + // settings.php assuming it will be added as a custom settings page. + // A type will be passed through subtype parameter. + // All such links will be placed in separate category called LTI. + $plugins = get_plugin_list('ltisource'); + if (!empty($plugins)) { + $toadd = array(); + foreach ($plugins as $name => $path) { + if (file_exists($path.DIRECTORY_SEPARATOR.'settings.php')) { + $toadd[] = $name; + } + } + + if (!empty($toadd)) { + $ADMIN->add('modules', + new admin_category('ltisource', + new lang_string('lti', 'lti'), + $module->is_enabled() === false) + ); + + foreach ($toadd as $name) { + $component = 'ltisource_'.$name; + $ADMIN->add($component, + new admin_externalpage($name, + new lang_string('pluginname', $component), + new moodle_url("/mod/lti/source/{$name}/settings.php", + array('subtype' => $component))) + ); + } + } + } + } } diff --git a/mod/lti/tests/locallib_test.php b/mod/lti/tests/locallib_test.php index 33a21878bb7..b0a3b16f091 100644 --- a/mod/lti/tests/locallib_test.php +++ b/mod/lti/tests/locallib_test.php @@ -145,4 +145,19 @@ class mod_lti_locallib_testcase extends basic_testcase { $this->assertEquals('https://moodle.org', lti_ensure_url_is_https('moodle.org')); $this->assertEquals('https://moodle.org', lti_ensure_url_is_https('https://moodle.org')); } + + /** + * Test lti_get_url_thumbprint against various URLs + */ + public function test_lti_get_url_thumbprint() { + // Note: trailing and double slash are expected right now. Must evaluate if it must be removed at some point. + $this->assertEquals('moodle.org/', lti_get_url_thumbprint('http://MOODLE.ORG')); + $this->assertEquals('moodle.org/', lti_get_url_thumbprint('http://www.moodle.org')); + $this->assertEquals('moodle.org/', lti_get_url_thumbprint('https://www.moodle.org')); + $this->assertEquals('moodle.org/', lti_get_url_thumbprint('moodle.org')); + $this->assertEquals('moodle.org//this/is/moodle', lti_get_url_thumbprint('http://moodle.org/this/is/moodle')); + $this->assertEquals('moodle.org//this/is/moodle', lti_get_url_thumbprint('https://moodle.org/this/is/moodle')); + $this->assertEquals('moodle.org//this/is/moodle', lti_get_url_thumbprint('moodle.org/this/is/moodle')); + $this->assertEquals('moodle.org//this/is/moodle', lti_get_url_thumbprint('moodle.org/this/is/moodle?foo=bar')); + } } diff --git a/mod/lti/view.php b/mod/lti/view.php index e31adc7f052..d6e7f6eb61e 100644 --- a/mod/lti/view.php +++ b/mod/lti/view.php @@ -47,6 +47,7 @@ */ require_once('../../config.php'); +require_once($CFG->libdir.'/completionlib.php'); require_once($CFG->dirroot.'/mod/lti/lib.php'); require_once($CFG->dirroot.'/mod/lti/locallib.php'); @@ -75,6 +76,9 @@ $PAGE->set_cm($cm, $course); // set's up global $COURSE $context = context_module::instance($cm->id); $PAGE->set_context($context); +require_login($course, true, $cm); +require_capability('mod/lti:view', $context); + $url = new moodle_url('/mod/lti/view.php', array('id'=>$cm->id)); $PAGE->set_url($url); @@ -89,8 +93,6 @@ if ($launchcontainer == LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS) { $PAGE->set_pagelayout('incourse'); } -require_login($course); - // Mark viewed by user (if required). $completion = new completion_info($course); $completion->set_module_viewed($cm); @@ -135,31 +137,25 @@ if ( $launchcontainer == LTI_LAUNCH_CONTAINER_WINDOW ) { $resize = '