Merge branch 'MDL-48773_m32v1' of https://github.com/sbourget/moodle
authorDan Poltawski <dan@moodle.com>
Tue, 23 Aug 2016 07:18:16 +0000 (08:18 +0100)
committerDan Poltawski <dan@moodle.com>
Tue, 23 Aug 2016 07:18:16 +0000 (08:18 +0100)
148 files changed:
admin/lib.php
admin/settings/appearance.php
admin/settings/security.php
admin/settings/server.php
admin/tool/generator/classes/course_backend.php
admin/tool/generator/lang/en/tool_generator.php
admin/tool/monitor/classes/eventlist.php
admin/tool/monitor/classes/rule.php
admin/tool/monitor/classes/subscription.php
admin/tool/monitor/edit.php
backup/util/dbops/restore_dbops.class.php
blocks/rss_client/templates/feed.mustache
blocks/site_main_menu/styles.css
blog/edit.php
blog/index.php
blog/preferences.php
blog/rsslib.php
composer.json
composer.lock
course/externallib.php
enrol/lti/cartridge.php [new file with mode: 0644]
enrol/lti/classes/helper.php
enrol/lti/classes/manage_table.php
enrol/lti/lang/en/enrol_lti.php
enrol/lti/styles.css [new file with mode: 0644]
enrol/lti/tests/fixtures/input.xml [new file with mode: 0644]
enrol/lti/tests/fixtures/test_ambiguous_nodes-expected.xml [new file with mode: 0644]
enrol/lti/tests/fixtures/test_correct_xpath-expected.xml [new file with mode: 0644]
enrol/lti/tests/fixtures/test_missing_node-expected.xml [new file with mode: 0644]
enrol/lti/tests/fixtures/test_nodes_removed-expected.xml [new file with mode: 0644]
enrol/lti/tests/helper_test.php
enrol/lti/version.php
enrol/lti/xml/imslticc.xml [new file with mode: 0644]
enrol/self/db/access.php
enrol/self/db/upgrade.php
enrol/self/tests/behat/key_holder.feature [new file with mode: 0644]
enrol/self/version.php
grade/grading/form/rubric/edit.php
grade/grading/form/rubric/lang/en/gradingform_rubric.php
grade/grading/form/rubric/renderer.php
grade/grading/form/rubric/rubriceditor.php
lang/en/admin.php
lang/en/moodle.php
lang/en/search.php
lib/accesslib.php
lib/classes/event/base.php
lib/classes/event/content_viewed.php
lib/classes/event/course_module_instances_list_viewed.php
lib/classes/event/mnet_access_control_updated.php
lib/classes/event/role_assigned.php
lib/classes/session/manager.php
lib/classes/task/stats_cron_task.php
lib/db/install.xml
lib/db/tasks.php
lib/db/upgrade.php
lib/filelib.php
lib/filestorage/file_storage.php
lib/filestorage/stored_file.php
lib/form/modgrade.php
lib/gdlib.php
lib/moodlelib.php
lib/outputrenderers.php
lib/sessionlib.php
lib/statslib.php
lib/templates/copy_box.mustache [new file with mode: 0644]
lib/tests/accesslib_test.php
lib/tests/gdlib_test.php
lib/tests/moodlelib_test.php
lib/tests/sessionlib_test.php
lib/tests/statslib_test.php
lib/tests/unoconv_test.php
lib/upgrade.txt
message/classes/search/base_message.php [new file with mode: 0644]
message/classes/search/message_received.php [new file with mode: 0644]
message/classes/search/message_sent.php [new file with mode: 0644]
message/tests/search_test_received.php [new file with mode: 0644]
message/tests/search_test_sent.php [new file with mode: 0644]
mod/assign/tests/behat/rescale_grades.feature
mod/choice/classes/event/answer_created.php [new file with mode: 0644]
mod/choice/classes/event/answer_deleted.php
mod/choice/classes/event/answer_submitted.php
mod/choice/classes/event/answer_updated.php
mod/choice/lang/en/choice.php
mod/choice/lib.php
mod/choice/renderer.php
mod/choice/report.php
mod/choice/styles.css
mod/choice/tests/behat/modify_choice.feature [new file with mode: 0644]
mod/choice/tests/events_test.php
mod/choice/tests/generator/lib.php
mod/choice/upgrade.txt
mod/choice/view.php
mod/data/field/latlong/field.class.php
mod/data/field/url/field.class.php
mod/data/import.php
mod/data/upgrade.txt [new file with mode: 0644]
mod/lesson/backup/moodle2/restore_lesson_activity_task.class.php
mod/lesson/backup/moodle2/restore_lesson_stepslib.php
mod/lesson/pagetypes/matching.php
mod/lesson/pagetypes/truefalse.php
mod/scorm/backup/moodle1/lib.php
mod/scorm/backup/moodle2/backup_scorm_stepslib.php
mod/scorm/backup/moodle2/restore_scorm_stepslib.php
mod/scorm/classes/external.php
mod/scorm/db/install.xml
mod/scorm/db/upgrade.php
mod/scorm/lang/en/scorm.php
mod/scorm/lib.php
mod/scorm/mod_form.php
mod/scorm/tests/behat/completion_condition_require_status.feature [new file with mode: 0644]
mod/scorm/tests/externallib_test.php
mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip [new file with mode: 0644]
mod/scorm/tests/packages/readme_moodle.txt
mod/scorm/version.php
mod/wiki/classes/external.php
mod/wiki/create_form.php
mod/wiki/lib.php
mod/wiki/locallib.php
mod/wiki/pagelib.php
mod/wiki/tests/externallib_test.php
mod/workshop/form/accumulative/assessment_form.php
mod/workshop/form/accumulative/lang/en/deprecated.txt [new file with mode: 0644]
mod/workshop/form/accumulative/lang/en/workshopform_accumulative.php
mod/workshop/form/comments/assessment_form.php
mod/workshop/form/comments/lang/en/deprecated.txt [new file with mode: 0644]
mod/workshop/form/comments/lang/en/workshopform_comments.php
mod/workshop/form/numerrors/assessment_form.php
mod/workshop/form/numerrors/lang/en/deprecated.txt [new file with mode: 0644]
mod/workshop/form/numerrors/lang/en/workshopform_numerrors.php
mod/workshop/lang/en/deprecated.txt [new file with mode: 0644]
mod/workshop/lang/en/workshop.php
question/category_class.php
question/tests/generator/lib.php
question/type/multianswer/renderer.php
report/eventlist/classes/list_generator.php
report/eventlist/eventdetail.php
report/search/version.php [deleted file]
report/security/lang/en/report_security.php
report/security/locallib.php
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/style/moodle.css
theme/clean/classes/core_renderer.php
theme/clean/lang/en/theme_clean.php
theme/clean/lib.php
theme/more/lang/en/theme_more.php
theme/more/lib.php
theme/upgrade.txt
version.php

index c9ddefa..3955084 100644 (file)
@@ -24,6 +24,8 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+defined('MOODLE_INTERNAL') || die();
+
 /**
  * Return a list of page types
  * @param string $pagetype current page type
@@ -37,3 +39,86 @@ function admin_page_type_list($pagetype, $parentcontext, $currentcontext) {
     );
     return $array;
 }
+
+/**
+ * File serving.
+ *
+ * @param stdClass $course The course object.
+ * @param stdClass $cm The cm object.
+ * @param context $context The context object.
+ * @param string $filearea The file area.
+ * @param array $args List of arguments.
+ * @param bool $forcedownload Whether or not to force the download of the file.
+ * @param array $options Array of options.
+ * @return void|false
+ */
+function core_admin_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = array()) {
+    global $CFG;
+
+    if (in_array($filearea, ['logo', 'logocompact'])) {
+        $size = array_shift($args); // The path hides the size.
+        $itemid = clean_param(array_shift($args), PARAM_INT);
+        $filename = clean_param(array_shift($args), PARAM_FILE);
+        $themerev = theme_get_revision();
+        if ($themerev <= 0) {
+            // Normalise to 0 as -1 doesn't place well with paths.
+             $themerev = 0;
+        }
+
+        // Extract the requested width and height.
+        $maxwidth = 0;
+        $maxheight = 0;
+        if (preg_match('/^\d+x\d+$/', $size)) {
+            list($maxwidth, $maxheight) = explode('x', $size);
+            $maxwidth = clean_param($maxwidth, PARAM_INT);
+            $maxheight = clean_param($maxheight, PARAM_INT);
+        }
+
+        $lifetime = 0;
+        if ($itemid > 0 && $themerev == $itemid) {
+            // The itemid is $CFG->themerev, when 0 or less no caching. Also no caching when they don't match.
+            $lifetime = DAYSECS * 60;
+        }
+
+        // Anyone, including guests and non-logged in users, can view the logos.
+        $options = ['cacheability' => 'public'];
+
+        // Check if we've got a cached file to return. When lifetime is 0 then we don't want to cached one.
+        $candidate = $CFG->localcachedir . "/core_admin/$themerev/$filearea/{$maxwidth}x{$maxheight}/$filename";
+        if (file_exists($candidate) && $lifetime > 0) {
+            send_file($candidate, $filename, $lifetime, 0, false, false, '', false, $options);
+        }
+
+        // Find the original file.
+        $fs = get_file_storage();
+        $filepath = "/{$context->id}/core_admin/{$filearea}/0/{$filename}";
+        if (!$file = $fs->get_file_by_hash(sha1($filepath))) {
+            send_file_not_found();
+        }
+
+        // No need for resizing, but if the file should be cached we save it so we can serve it fast next time.
+        if (empty($maxwidth) && empty($maxheight)) {
+            if ($lifetime) {
+                file_safe_save_content($file->get_content(), $candidate);
+            }
+            send_stored_file($file, $lifetime, 0, false, $options);
+        }
+
+        // Proceed with the resizing.
+        $filedata = $file->resize_image($maxwidth, $maxheight);
+        if (!$filedata) {
+            send_file_not_found();
+        }
+
+        // If we don't want to cached the file, serve now and quit.
+        if (!$lifetime) {
+            send_content_uncached($filedata, $filename);
+        }
+
+        // Save, serve and quit.
+        file_safe_save_content($filedata, $candidate);
+        send_file($candidate, $filename, $lifetime, 0, false, false, '', false, $options);
+    }
+
+    send_file_not_found();
+}
index a8b6c6a..d8697d4 100644 (file)
@@ -51,6 +51,26 @@ preferences,moodle|/user/preferences.php|preferences',
         }
     }
 
+    // Logos section.
+    $temp = new admin_settingpage('logos', new lang_string('logossettings', 'admin'));
+
+    // Logo file setting.
+    $title = get_string('logo', 'admin');
+    $description = get_string('logo_desc', 'admin');
+    $setting = new admin_setting_configstoredfile('core_admin/logo', $title, $description, 'logo', 0,
+        ['maxfiles' => 1, 'accepted_types' => ['.jpg', '.png']]);
+    $setting->set_updatedcallback('theme_reset_all_caches');
+    $temp->add($setting);
+
+    // Small logo file setting.
+    $title = get_string('logocompact', 'admin');
+    $description = get_string('logocompact_desc', 'admin');
+    $setting = new admin_setting_configstoredfile('core_admin/logocompact', $title, $description, 'logocompact', 0,
+        ['maxfiles' => 1, 'accepted_types' => ['.jpg', '.png']]);
+    $setting->set_updatedcallback('theme_reset_all_caches');
+    $temp->add($setting);
+
+    $ADMIN->add('appearance', $temp);
 
     // Calendar settings.
     $temp = new admin_settingpage('calendar', new lang_string('calendarsettings','admin'));
index 00c9429..946abbd 100644 (file)
@@ -109,7 +109,7 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     // "httpsecurity" settingpage
     $temp = new admin_settingpage('httpsecurity', new lang_string('httpsecurity', 'admin'));
     $temp->add(new admin_setting_configcheckbox('loginhttps', new lang_string('loginhttps', 'admin'), new lang_string('configloginhttps', 'admin'), 0));
-    $temp->add(new admin_setting_configcheckbox('cookiesecure', new lang_string('cookiesecure', 'admin'), new lang_string('configcookiesecure', 'admin'), 0));
+    $temp->add(new admin_setting_configcheckbox('cookiesecure', new lang_string('cookiesecure', 'admin'), new lang_string('configcookiesecure', 'admin'), 1));
     $temp->add(new admin_setting_configcheckbox('cookiehttponly', new lang_string('cookiehttponly', 'admin'), new lang_string('configcookiehttponly', 'admin'), 0));
     $temp->add(new admin_setting_configcheckbox('allowframembedding', new lang_string('allowframembedding', 'admin'), new lang_string('allowframembedding_help', 'admin'), 0));
     $temp->add(new admin_setting_configcheckbox('loginpasswordautocomplete', new lang_string('loginpasswordautocomplete', 'admin'), new lang_string('loginpasswordautocomplete_help', 'admin'), 0));
index 548031b..8ef94a7 100644 (file)
@@ -79,7 +79,6 @@ $temp->add(new admin_setting_configselect('statsmaxruntime', new lang_string('st
                                                                                                                                                             60*60*7 => '7 '.new lang_string('hours'),
                                                                                                                                                             60*60*8 => '8 '.new lang_string('hours') )));
 $temp->add(new admin_setting_configtext('statsruntimedays', new lang_string('statsruntimedays', 'admin'), new lang_string('configstatsruntimedays', 'admin'), 31, PARAM_INT));
-$temp->add(new admin_setting_configtime('statsruntimestarthour', 'statsruntimestartminute', new lang_string('statsruntimestart', 'admin'), new lang_string('configstatsruntimestart', 'admin'), array('h' => 0, 'm' => 0)));
 $temp->add(new admin_setting_configtext('statsuserthreshold', new lang_string('statsuserthreshold', 'admin'), new lang_string('configstatsuserthreshold', 'admin'), 0, PARAM_INT));
 $ADMIN->add('server', $temp);
 
index 0219ebe..e5e7ec4 100644 (file)
@@ -216,19 +216,19 @@ class tool_generator_course_backend extends tool_generator_backend {
 
         $entirestart = microtime(true);
 
-        // Start transaction.
-        $transaction = $DB->start_delegated_transaction();
-
         // Get generator.
         $this->generator = phpunit_util::get_data_generator();
 
         // Make course.
         $this->course = $this->create_course();
-        $this->create_users();
+
         $this->create_assignments();
         $this->create_pages();
         $this->create_small_files();
         $this->create_big_files();
+
+        // Create users as late as possible to reduce regarding in the gradebook.
+        $this->create_users();
         $this->create_forum();
 
         // Log total time.
@@ -238,8 +238,6 @@ class tool_generator_course_backend extends tool_generator_backend {
             echo html_writer::end_tag('ul');
         }
 
-        // Commit transaction and finish.
-        $transaction->allow_commit();
         return $this->course->id;
     }
 
@@ -435,7 +433,7 @@ class tool_generator_course_backend extends tool_generator_backend {
 
             // Generate random binary data (different for each file so it
             // doesn't compress unrealistically).
-            $data = self::get_random_binary($this->limit_filesize(self::$paramsmallfilesize[$this->size]));
+            $data = random_bytes_emulate($this->limit_filesize(self::$paramsmallfilesize[$this->size]));
 
             $fs->create_file_from_string($filerecord, $data);
             $this->dot($i, $count);
@@ -444,33 +442,10 @@ class tool_generator_course_backend extends tool_generator_backend {
         $this->end_log();
     }
 
-    /**
-     * Creates a string of random binary data. The start of the string includes
-     * the current time, in an attempt to avoid large-scale repetition.
-     *
-     * @param int $length Number of bytes
-     * @return Random data
-     */
-    private static function get_random_binary($length) {
-
-        $data = microtime(true);
-        if (strlen($data) > $length) {
-            // Use last digits of data.
-            return substr($data, -$length);
-        }
-        $length -= strlen($data);
-        for ($j = 0; $j < $length; $j++) {
-            $data .= chr(rand(1, 255));
-        }
-        return $data;
-    }
-
     /**
      * Creates a number of resource activities with one big file each.
      */
     private function create_big_files() {
-        global $CFG;
-
         // Work out how many files and how many blocks to use (up to 64KB).
         $count = self::$parambigfilecount[$this->size];
         $filesize = $this->limit_filesize(self::$parambigfilesize[$this->size]);
@@ -499,7 +474,7 @@ class tool_generator_course_backend extends tool_generator_backend {
                 throw new coding_exception('Failed to open temporary file');
             }
             for ($j = 0; $j < $blocks; $j++) {
-                $data = self::get_random_binary($blocksize);
+                $data = random_bytes_emulate($blocksize);
                 fwrite($handle, $data);
                 $this->dot($i * $blocks + $j, $count * $blocks);
             }
index 86247ec..8711269 100644 (file)
@@ -43,10 +43,10 @@ DEVELOPER debugging level.)';
 
 $string['coursesize_0'] = 'XS (~10KB; create in ~1 second)';
 $string['coursesize_1'] = 'S (~10MB; create in ~30 seconds)';
-$string['coursesize_2'] = 'M (~100MB; create in ~5 minutes)';
-$string['coursesize_3'] = 'L (~1GB; create in ~1 hour)';
-$string['coursesize_4'] = 'XL (~10GB; create in ~4 hours)';
-$string['coursesize_5'] = 'XXL (~20GB; create in ~8 hours)';
+$string['coursesize_2'] = 'M (~100MB; create in ~2 minutes)';
+$string['coursesize_3'] = 'L (~1GB; create in ~30 minutes)';
+$string['coursesize_4'] = 'XL (~10GB; create in ~2 hours)';
+$string['coursesize_5'] = 'XXL (~20GB; create in ~4 hours)';
 $string['coursewithoutusers'] = 'The selected course has no users';
 $string['createcourse'] = 'Create course';
 $string['createtestplan'] = 'Create test plan';
index 989bc3c..ccce626 100644 (file)
@@ -67,7 +67,7 @@ class eventlist {
                 $ref = new \ReflectionClass($classname);
                 // Ignore abstracts.
                 if (!$ref->isAbstract() && $file != 'manager') {
-                    $eventinformation[$classname] = $classname::get_name();
+                    $eventinformation[$classname] = $classname::get_name_with_info();
                 }
             }
         }
@@ -103,15 +103,16 @@ class eventlist {
             foreach ($pluginlist as $plugin => $directory) {
                 $plugindirectory = $directory . '/classes/event';
                 foreach (self::get_file_list($plugindirectory) as $eventname => $notused) {
-                    $plugineventname = '\\' . $plugintype . '_' . $plugin . '\\event\\' . $eventname;
+                    $fullpluginname = $plugintype . '_' . $plugin;
+                    $plugineventname = '\\' . $fullpluginname . '\\event\\' . $eventname;
                     // Check that this is actually an event.
-                    if (method_exists($plugineventname, 'get_static_info')  && $plugin != 'monitor') { // No selfie here.
+                    if (method_exists($plugineventname, 'get_static_info')  && $fullpluginname !== 'tool_monitor') { // No selfie here.
                         $ref = new \ReflectionClass($plugineventname);
-                        if (!$ref->isAbstract() && $plugin != 'legacy') {
+                        if (!$ref->isAbstract() && $fullpluginname !== 'logstore_legacy') {
                             if ($withoutcomponent) {
-                                $noncorepluginlist[$plugineventname] = $plugineventname::get_name();
+                                $noncorepluginlist[$plugineventname] = $plugineventname::get_name_with_info();
                             } else {
-                                $noncorepluginlist[$plugintype . '_' . $plugin][$plugineventname] = $plugineventname::get_name();
+                                $noncorepluginlist[$fullpluginname][$plugineventname] = $plugineventname::get_name_with_info();
                             }
                         }
                     }
index 2a23eb1..3c5e6d2 100644 (file)
@@ -202,7 +202,7 @@ class rule {
     public function get_event_name() {
         $eventclass = $this->eventname;
         if (class_exists($eventclass)) {
-            return $eventclass::get_name();
+            return $eventclass::get_name_with_info();
         }
         return get_string('eventnotfound', 'tool_monitor');
     }
index 98518e9..5beddbb 100644 (file)
@@ -111,7 +111,7 @@ class subscription {
     public function get_event_name() {
         $eventclass = $this->eventname;
         if (class_exists($eventclass)) {
-            return $eventclass::get_name();
+            return $eventclass::get_name_with_info();
         }
         return get_string('eventnotfound', 'tool_monitor');
     }
index 20bd3c4..1e01a32 100644 (file)
@@ -54,12 +54,6 @@ $PAGE->set_heading($coursename);
 // Get data ready for mform.
 $eventlist = tool_monitor\eventlist::get_all_eventlist(true);
 $pluginlist = tool_monitor\eventlist::get_plugin_list();
-$eventlist = array_merge(array('' => get_string('choosedots')), $eventlist);
-$pluginlist = array_merge(array('' => get_string('choosedots')), $pluginlist);
-
-// Set up the yui module.
-$PAGE->requires->yui_module('moodle-tool_monitor-dropdown', 'Y.M.tool_monitor.DropDown.init',
-        array(array('eventlist' => $eventlist)));
 
 // Site level report.
 if (empty($courseid)) {
@@ -74,11 +68,25 @@ if (!empty($ruleid)) {
     $rule = \tool_monitor\rule_manager::get_rule($ruleid)->get_mform_set_data();
     $rule->minutes = $rule->timewindow / MINSECS;
     $subscriptioncount = \tool_monitor\subscription_manager::count_rule_subscriptions($ruleid);
+
+    // Filter out events which cannot be triggered for some reason.
+    $eventlist = array_filter($eventlist, function($classname) use ($rule) {
+        // Filter out all deprecated events, except for the current one.
+        return $classname === $rule->eventname || !$classname::is_deprecated();
+    }, ARRAY_FILTER_USE_KEY);
 } else {
     $rule = new stdClass();
     $subscriptioncount = 0;
+
+    // Filter out events which cannot be triggered for some reason.
+    $eventlist = array_filter($eventlist, function($classname) {
+        return !$classname::is_deprecated();
+    }, ARRAY_FILTER_USE_KEY);
 }
 
+// Modify the lists to add the choosers.
+$eventlist = array_merge(array('' => get_string('choosedots')), $eventlist);
+$pluginlist = array_merge(array('' => get_string('choosedots')), $pluginlist);
 $mform = new tool_monitor\rule_form(null, array('eventlist' => $eventlist, 'pluginlist' => $pluginlist, 'rule' => $rule,
         'courseid' => $courseid, 'subscriptioncount' => $subscriptioncount));
 
@@ -98,6 +106,10 @@ if ($mformdata = $mform->get_data()) {
 
     redirect($manageurl);
 } else {
+    // Set up the yui module.
+    $PAGE->requires->yui_module('moodle-tool_monitor-dropdown', 'Y.M.tool_monitor.DropDown.init',
+            array(array('eventlist' => $eventlist)));
+
     echo $OUTPUT->header();
     $mform->set_data($rule);
     // If there's any subscription for this rule, display an information message.
index 6d48d36..a4c7075 100644 (file)
@@ -795,7 +795,7 @@ abstract class restore_dbops {
                      // Prepare the query
                      list($stamp_sql, $stamp_params) = $DB->get_in_or_equal($stamps);
                      list($context_sql, $context_params) = $DB->get_in_or_equal($contexts);
-                     $sql = "SELECT contextid
+                     $sql = "SELECT DISTINCT contextid
                                FROM {question_categories}
                               WHERE stamp $stamp_sql
                                 AND contextid $context_sql";
index ad9ae3e..a69f3e8 100644 (file)
@@ -66,7 +66,7 @@
 
 {{$title}}
     {{#title}}
-        <div class="title">{{feedtitle}}</div>
+        <div class="title">{{title}}</div>
     {{/title}}
 {{/title}}
 
index 6d43c31..9174440 100644 (file)
@@ -4,7 +4,13 @@
     width: 100%;
     display: table;
 }
-.block_site_main_menu li .buttons { float: right; margin: 0; }
+.block_site_main_menu li .buttons {
+    float: right;
+    margin: 0;
+    padding: 0;
+    border: 0;
+    background-color: inherit;
+}
 .dir-rtl .block_site_main_menu li .buttons { float: left; }
 .block_site_main_menu li .buttons a img{ vertical-align: text-bottom;}
 .block_site_main_menu .footer { margin-top: 1em; }
index 36a544c..bf9a166 100644 (file)
@@ -79,7 +79,7 @@ if (empty($CFG->enableblogs)) {
 }
 
 if (isguestuser()) {
-    print_error('noguestentry', 'blog');
+    print_error('noguest');
 }
 
 $returnurl = new moodle_url('/blog/index.php');
index fb1ef89..e639df3 100644 (file)
@@ -92,7 +92,7 @@ if ($CFG->bloglevel == BLOG_GLOBAL_LEVEL) {
     require_login();
     if (isguestuser()) {
         // They must have entered the url manually.
-        print_error('blogdisable', 'blog');
+        print_error('noguest');
     }
 
 } else if ($CFG->bloglevel == BLOG_USER_LEVEL) {
index 9c42bec..1871161 100644 (file)
@@ -63,6 +63,10 @@ if (empty($CFG->enableblogs)) {
     print_error('blogdisable', 'blog');
 }
 
+if (isguestuser()) {
+    print_error('noguest');
+}
+
 // The preference is site wide not blog specific. Hence user should have permissions in site level.
 require_capability('moodle/blog:view', $sitecontext);
 
index 366c5cd..760a0ea 100644 (file)
@@ -22,6 +22,8 @@
  * @copyright  2010 Andrew Davis
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+defined('MOODLE_INTERNAL') || die();
+
 require_once($CFG->dirroot.'/lib/rsslib.php');
 require_once($CFG->dirroot .'/blog/lib.php');
 
index ce55027..713e233 100644 (file)
@@ -7,6 +7,6 @@
     "require-dev": {
         "phpunit/phpunit": "5.4.*",
         "phpunit/dbUnit": "1.4.*",
-        "moodlehq/behat-extension": "3.32.1"
+        "moodlehq/behat-extension": "3.32.2"
     }
 }
index 6fe8310..5810eda 100644 (file)
@@ -4,8 +4,8 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "hash": "7f0c2a252624902575a3ed1bdc237644",
-    "content-hash": "bcc157487e77ec6ad4163f1661abac97",
+    "hash": "902ce5735f2446cf9bcc305c0d8a191b",
+    "content-hash": "b60ecd0f5b6430a10ada7e4d4c38e73b",
     "packages": [],
     "packages-dev": [
         {
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "6.2.0",
+            "version": "6.2.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "d094e337976dff9d8e2424e8485872194e768662"
+                "reference": "3f808fba627f2c5b69e2501217bf31af349c1427"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d094e337976dff9d8e2424e8485872194e768662",
-                "reference": "d094e337976dff9d8e2424e8485872194e768662",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/3f808fba627f2c5b69e2501217bf31af349c1427",
+                "reference": "3f808fba627f2c5b69e2501217bf31af349c1427",
                 "shasum": ""
             },
             "require": {
-                "guzzlehttp/promises": "~1.0",
-                "guzzlehttp/psr7": "~1.1",
-                "php": ">=5.5.0"
+                "guzzlehttp/promises": "^1.0",
+                "guzzlehttp/psr7": "^1.3.1",
+                "php": ">=5.5"
             },
             "require-dev": {
                 "ext-curl": "*",
-                "phpunit/phpunit": "~4.0",
-                "psr/log": "~1.0"
+                "phpunit/phpunit": "^4.0",
+                "psr/log": "^1.0"
             },
             "type": "library",
             "extra": {
                 "rest",
                 "web service"
             ],
-            "time": "2016-03-21 20:02:09"
+            "time": "2016-07-15 17:22:37"
         },
         {
             "name": "guzzlehttp/promises",
         },
         {
             "name": "moodlehq/behat-extension",
-            "version": "v3.32.1",
+            "version": "v3.32.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/moodlehq/moodle-behat-extension.git",
-                "reference": "f8305058ce9140864c23c9b667e3d7d487fdc006"
+                "reference": "cdf11394f55576f849db750cac9836f2efc17d4c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/f8305058ce9140864c23c9b667e3d7d487fdc006",
-                "reference": "f8305058ce9140864c23c9b667e3d7d487fdc006",
+                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/cdf11394f55576f849db750cac9836f2efc17d4c",
+                "reference": "cdf11394f55576f849db750cac9836f2efc17d4c",
                 "shasum": ""
             },
             "require": {
                 "Behat",
                 "moodle"
             ],
-            "time": "2016-06-20 07:56:08"
+            "time": "2016-08-12 01:58:24"
         },
         {
             "name": "myclabs/deep-copy",
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "4.0.0",
+            "version": "4.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "900370c81280cc0d942ffbc5912d80464eaee7e9"
+                "reference": "5f3f7e736d6319d5f1fc402aff8b026da26709a3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/900370c81280cc0d942ffbc5912d80464eaee7e9",
-                "reference": "900370c81280cc0d942ffbc5912d80464eaee7e9",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/5f3f7e736d6319d5f1fc402aff8b026da26709a3",
+                "reference": "5f3f7e736d6319d5f1fc402aff8b026da26709a3",
                 "shasum": ""
             },
             "require": {
                 "phpunit/php-text-template": "~1.2",
                 "phpunit/php-token-stream": "^1.4.2",
                 "sebastian/code-unit-reverse-lookup": "~1.0",
-                "sebastian/environment": "^1.3.2",
+                "sebastian/environment": "^1.3.2 || ^2.0",
                 "sebastian/version": "~1.0|~2.0"
             },
             "require-dev": {
                 "testing",
                 "xunit"
             ],
-            "time": "2016-06-03 05:03:56"
+            "time": "2016-07-26 14:39:29"
         },
         {
             "name": "phpunit/php-file-iterator",
         },
         {
             "name": "phpunit/phpunit",
-            "version": "5.4.6",
+            "version": "5.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "2f1fc94b77ea6418bd6a06c64a1dac0645fbce59"
+                "reference": "3132365e1430c091f208e120b8845d39c25f20e6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2f1fc94b77ea6418bd6a06c64a1dac0645fbce59",
-                "reference": "2f1fc94b77ea6418bd6a06c64a1dac0645fbce59",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3132365e1430c091f208e120b8845d39c25f20e6",
+                "reference": "3132365e1430c091f208e120b8845d39c25f20e6",
                 "shasum": ""
             },
             "require": {
                 "myclabs/deep-copy": "~1.3",
                 "php": "^5.6 || ^7.0",
                 "phpspec/prophecy": "^1.3.1",
-                "phpunit/php-code-coverage": "^4.0",
+                "phpunit/php-code-coverage": "^4.0.1",
                 "phpunit/php-file-iterator": "~1.4",
                 "phpunit/php-text-template": "~1.2",
                 "phpunit/php-timer": "^1.0.6",
                 "testing",
                 "xunit"
             ],
-            "time": "2016-06-16 06:01:15"
+            "time": "2016-07-26 14:48:00"
         },
         {
             "name": "phpunit/phpunit-mock-objects",
         },
         {
             "name": "psr/http-message",
-            "version": "1.0",
+            "version": "1.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/http-message.git",
-                "reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298"
+                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-message/zipball/85d63699f0dbedb190bbd4b0d2b9dc707ea4c298",
-                "reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298",
+                "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
+                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
                 "shasum": ""
             },
             "require": {
                 }
             ],
             "description": "Common interface for HTTP messages",
+            "homepage": "https://github.com/php-fig/http-message",
             "keywords": [
                 "http",
                 "http-message",
                 "request",
                 "response"
             ],
-            "time": "2015-05-04 20:22:00"
+            "time": "2016-08-06 14:39:51"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",
         },
         {
             "name": "symfony/browser-kit",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/browser-kit.git",
-                "reference": "dcf41ed026b0499254385b5c88f03247b2ba010b"
+                "reference": "d2a07cc11c5fa94820240b1e67592ffb18e347b9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/dcf41ed026b0499254385b5c88f03247b2ba010b",
-                "reference": "dcf41ed026b0499254385b5c88f03247b2ba010b",
+                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/d2a07cc11c5fa94820240b1e67592ffb18e347b9",
+                "reference": "d2a07cc11c5fa94820240b1e67592ffb18e347b9",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony BrowserKit Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-26 08:04:17"
         },
         {
             "name": "symfony/class-loader",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/class-loader.git",
-                "reference": "0d0ac77c336eb73f35bebdf3e1f3695ac741bbc9"
+                "reference": "817f09b4c37b7688fa4342cb4642d8f2d81c1097"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/class-loader/zipball/0d0ac77c336eb73f35bebdf3e1f3695ac741bbc9",
-                "reference": "0d0ac77c336eb73f35bebdf3e1f3695ac741bbc9",
+                "url": "https://api.github.com/repos/symfony/class-loader/zipball/817f09b4c37b7688fa4342cb4642d8f2d81c1097",
+                "reference": "817f09b4c37b7688fa4342cb4642d8f2d81c1097",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony ClassLoader Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-10 08:05:47"
         },
         {
             "name": "symfony/config",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/config.git",
-                "reference": "bcf5aebabc95b56e370e13d78565f74c7d8726dc"
+                "reference": "a7630397b91be09cdd2fe57fd13612e258700598"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/config/zipball/bcf5aebabc95b56e370e13d78565f74c7d8726dc",
-                "reference": "bcf5aebabc95b56e370e13d78565f74c7d8726dc",
+                "url": "https://api.github.com/repos/symfony/config/zipball/a7630397b91be09cdd2fe57fd13612e258700598",
+                "reference": "a7630397b91be09cdd2fe57fd13612e258700598",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-26 08:04:17"
         },
         {
             "name": "symfony/console",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "747154aa69b0f83cd02fc9aa554836dee417631a"
+                "reference": "f9e638e8149e9e41b570ff092f8007c477ef0ce5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/747154aa69b0f83cd02fc9aa554836dee417631a",
-                "reference": "747154aa69b0f83cd02fc9aa554836dee417631a",
+                "url": "https://api.github.com/repos/symfony/console/zipball/f9e638e8149e9e41b570ff092f8007c477ef0ce5",
+                "reference": "f9e638e8149e9e41b570ff092f8007c477ef0ce5",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 07:02:31"
+            "time": "2016-07-26 08:04:17"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "b7272b65f2f46cbe77def7d33916f2613669c508"
+                "reference": "6abd4952d07042d11bbb8122f3b57469691acdb5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b7272b65f2f46cbe77def7d33916f2613669c508",
-                "reference": "b7272b65f2f46cbe77def7d33916f2613669c508",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6abd4952d07042d11bbb8122f3b57469691acdb5",
+                "reference": "6abd4952d07042d11bbb8122f3b57469691acdb5",
                 "shasum": ""
             },
             "require": {
             "require-dev": {
                 "symfony/config": "~2.8|~3.0",
                 "symfony/expression-language": "~2.8|~3.0",
-                "symfony/yaml": "~2.8|~3.0"
+                "symfony/yaml": "~2.8.7|~3.0.7|~3.1.1|~3.2"
             },
             "suggest": {
                 "symfony/config": "",
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:42:25"
+            "time": "2016-07-28 11:13:48"
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "99ec4a23330fcd0c8667095f3ef7aa204ffd9dc0"
+                "reference": "c7b9b8db3a6f2bac76dcd9a9db5446f2591897f9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/99ec4a23330fcd0c8667095f3ef7aa204ffd9dc0",
-                "reference": "99ec4a23330fcd0c8667095f3ef7aa204ffd9dc0",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/c7b9b8db3a6f2bac76dcd9a9db5446f2591897f9",
+                "reference": "c7b9b8db3a6f2bac76dcd9a9db5446f2591897f9",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony DomCrawler Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-26 08:04:17"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "7f9839ede2070f53e7e2f0849b9bd14748c434c5"
+                "reference": "c0c00c80b3a69132c4e55c3e7db32b4a387615e5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7f9839ede2070f53e7e2f0849b9bd14748c434c5",
-                "reference": "7f9839ede2070f53e7e2f0849b9bd14748c434c5",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c0c00c80b3a69132c4e55c3e7db32b4a387615e5",
+                "reference": "c0c00c80b3a69132c4e55c3e7db32b4a387615e5",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony EventDispatcher Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-19 10:45:57"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "322da5f0910d8aa0b25fa65ffccaba68dbddb890"
+                "reference": "bb29adceb552d202b6416ede373529338136e84f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/322da5f0910d8aa0b25fa65ffccaba68dbddb890",
-                "reference": "322da5f0910d8aa0b25fa65ffccaba68dbddb890",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/bb29adceb552d202b6416ede373529338136e84f",
+                "reference": "bb29adceb552d202b6416ede373529338136e84f",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-20 05:44:26"
         },
         {
             "name": "symfony/polyfill-mbstring",
         },
         {
             "name": "symfony/process",
-            "version": "v2.8.8",
+            "version": "v2.8.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "89f33c16796415ccfd8bb3cf8d520cbb79899bfe"
+                "reference": "d20332e43e8774ff8870b394f3dd6020cc7f8e0c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/89f33c16796415ccfd8bb3cf8d520cbb79899bfe",
-                "reference": "89f33c16796415ccfd8bb3cf8d520cbb79899bfe",
+                "url": "https://api.github.com/repos/symfony/process/zipball/d20332e43e8774ff8870b394f3dd6020cc7f8e0c",
+                "reference": "d20332e43e8774ff8870b394f3dd6020cc7f8e0c",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Process Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:29:29"
+            "time": "2016-07-28 11:13:19"
         },
         {
             "name": "symfony/translation",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "d63a94528530c3ea5ff46924c8001cec4a398609"
+                "reference": "7713ddf81518d0823b027fe74ec390b80f6b6536"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/d63a94528530c3ea5ff46924c8001cec4a398609",
-                "reference": "d63a94528530c3ea5ff46924c8001cec4a398609",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/7713ddf81518d0823b027fe74ec390b80f6b6536",
+                "reference": "7713ddf81518d0823b027fe74ec390b80f6b6536",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Translation Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-26 08:04:17"
         },
         {
             "name": "symfony/yaml",
-            "version": "v3.1.2",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "2884c26ce4c1d61aebf423a8b912950fe7c764de"
+                "reference": "1819adf2066880c7967df7180f4f662b6f0567ac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/2884c26ce4c1d61aebf423a8b912950fe7c764de",
-                "reference": "2884c26ce4c1d61aebf423a8b912950fe7c764de",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/1819adf2066880c7967df7180f4f662b6f0567ac",
+                "reference": "1819adf2066880c7967df7180f4f662b6f0567ac",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2016-06-29 05:41:56"
+            "time": "2016-07-17 14:02:08"
         },
         {
             "name": "webmozart/assert",
-            "version": "1.0.2",
+            "version": "1.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/webmozart/assert.git",
-                "reference": "30eed06dd6bc88410a4ff7f77b6d22f3ce13dbde"
+                "reference": "bb2d123231c095735130cc8f6d31385a44c7b308"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/30eed06dd6bc88410a4ff7f77b6d22f3ce13dbde",
-                "reference": "30eed06dd6bc88410a4ff7f77b6d22f3ce13dbde",
+                "url": "https://api.github.com/repos/webmozart/assert/zipball/bb2d123231c095735130cc8f6d31385a44c7b308",
+                "reference": "bb2d123231c095735130cc8f6d31385a44c7b308",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": "^5.3.3|^7.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.6"
+                "phpunit/phpunit": "^4.6",
+                "sebastian/version": "^1.0.1"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0-dev"
+                    "dev-master": "1.2-dev"
                 }
             },
             "autoload": {
                 "check",
                 "validate"
             ],
-            "time": "2015-08-24 13:29:44"
+            "time": "2016-08-09 15:02:57"
         }
     ],
     "aliases": [],
index 7a2367d..ec6f7c9 100644 (file)
@@ -2253,8 +2253,8 @@ class core_course_external extends external_api {
             $coursecontext = context_course::instance($course->id);
 
             // Category information.
-            if (!isset($categoriescache[$course->category])) {
-                $categoriescache[$course->category] = coursecat::get($course->category);
+            if (!array_key_exists($course->category, $categoriescache)) {
+                $categoriescache[$course->category] = coursecat::get($course->category, IGNORE_MISSING);
             }
             $category = $categoriescache[$course->category];
 
@@ -2302,7 +2302,7 @@ class core_course_external extends external_api {
             $coursereturns['displayname']       = external_format_string($displayname, $coursecontext->id);
             $coursereturns['shortname']         = external_format_string($course->shortname, $coursecontext->id);
             $coursereturns['categoryid']        = $course->category;
-            $coursereturns['categoryname']      = $category->name;
+            $coursereturns['categoryname']      = $category == null ? '' : $category->name;
             $coursereturns['summary']           = $summary;
             $coursereturns['summaryformat']     = $summaryformat;
             $coursereturns['overviewfiles']     = $files;
diff --git a/enrol/lti/cartridge.php b/enrol/lti/cartridge.php
new file mode 100644 (file)
index 0000000..bf61743
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generates an XML IMS Cartridge with the details for the given tool
+ *
+ * @package    enrol_lti
+ * @copyright  2016 John Okely <john@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(dirname(__FILE__) . '/../../config.php');
+require_once($CFG->dirroot . '/lib/weblib.php');
+
+$toolid = null;
+$token = null;
+
+$filearguments = get_file_argument();
+$arguments = explode('/', trim($filearguments, '/'));
+if (count($arguments) >= 2) { // Can put cartridge.xml at the end, or anything really.
+    list($toolid, $token) = $arguments;
+}
+
+$toolid = optional_param('id', $toolid, PARAM_INT);
+$token = optional_param('token', $token, PARAM_ALPHANUM);
+
+// Only show the cartridge if the token parameter is correct.
+// If we do not compare with a shared secret, someone could very easily
+// guess an id for the enrolment.
+if (!\enrol_lti\helper::verify_tool_token($toolid, $token)) {
+    throw new \moodle_exception('incorrecttoken', 'enrol_lti');
+}
+
+$tool = \enrol_lti\helper::get_lti_tool($toolid);
+
+if (!is_enabled_auth('lti')) {
+    print_error('pluginnotenabled', 'auth', '', get_string('pluginname', 'auth_lti'));
+
+} else if (!enrol_is_enabled('lti')) {
+    print_error('enrolisdisabled', 'enrol_lti');
+
+} else if ($tool->status != ENROL_INSTANCE_ENABLED) {
+    print_error('enrolisdisabled', 'enrol_lti');
+
+} else {
+    header('Content-Type: text/xml; charset=utf-8');
+    echo \enrol_lti\helper::create_cartridge($toolid);
+}
index 83a9b13..aabd9a6 100644 (file)
@@ -380,4 +380,219 @@ class helper {
               </imsx_POXBody>
             </imsx_POXEnvelopeRequest>';
     }
+
+    /**
+     * Returns the url to launch the lti tool.
+     *
+     * @param int $toolid the id of the shared tool
+     * @return moodle_url the url to launch the tool
+     * @since Moodle 3.2
+     */
+    public static function get_launch_url($toolid) {
+        return new \moodle_url('/enrol/lti/tool.php', array('id' => $toolid));
+    }
+
+    /**
+     * Returns the name of the lti enrolment instance, or the name of the course/module being shared.
+     *
+     * @param stdClass $tool The lti tool
+     * @return string The name of the tool
+     * @since Moodle 3.2
+     */
+    public static function get_name($tool) {
+        $name = null;
+
+        if (empty($tool->name)) {
+            $toolcontext = \context::instance_by_id($tool->contextid);
+            $name = $toolcontext->get_context_name();
+        } else {
+            $name = $tool->name;
+        };
+
+        return $name;
+    }
+
+    /**
+     * Returns a description of the course or module that this lti instance points to.
+     *
+     * @param stdClass $tool The lti tool
+     * @return string A description of the tool
+     * @since Moodle 3.2
+     */
+    public static function get_description($tool) {
+        global $DB;
+        $description = '';
+        $context = \context::instance_by_id($tool->contextid);
+        if ($context->contextlevel == CONTEXT_COURSE) {
+            $course = $DB->get_record('course', array('id' => $context->instanceid));
+            $description = $course->summary;
+        } else if ($context->contextlevel == CONTEXT_MODULE) {
+            $cmid = $context->instanceid;
+            $cm = get_coursemodule_from_id(false, $context->instanceid, 0, false, MUST_EXIST);
+            $module = $DB->get_record($cm->modname, array('id' => $cm->instance));
+            $description = $module->intro;
+        }
+        return trim(html_to_text($description));
+    }
+
+    /**
+     * Returns the url to the cartridge representing the tool.
+     *
+     * If you have slash arguments enabled, this will be a nice url ending in cartridge.xml.
+     * If not it will be a php page with some parameters passed.
+     *
+     * @param stdClass $tool The lti tool
+     * @return string The url to the cartridge representing the tool
+     * @since Moodle 3.2
+     */
+    public static function get_cartridge_url($tool) {
+        global $CFG;
+        $url = null;
+
+        $id = $tool->id;
+        $token = self::generate_tool_token($tool->id);
+        if ($CFG->slasharguments) {
+            $url = new \moodle_url('/enrol/lti/cartridge.php/' . $id . '/' . $token . '/cartridge.xml');
+        } else {
+            $url = new \moodle_url('/enrol/lti/cartridge.php',
+                    array(
+                        'id' => $id,
+                        'token' => $token
+                    )
+                );
+        }
+        return $url;
+    }
+
+    /**
+     * Returns a unique hash for this site and this enrolment instance.
+     *
+     * Used to verify that the link to the cartridge has not just been guessed.
+     *
+     * @param int $toolid The id of the shared tool
+     * @return string MD5 hash of combined site ID and enrolment instance ID.
+     * @since Moodle 3.2
+     */
+    public static function generate_tool_token($toolid) {
+        $siteidentifier = get_site_identifier();
+        $checkhash = md5($siteidentifier . '_enrol_lti_' . $toolid);
+        return $checkhash;
+    }
+
+    /**
+     * Verifies that the given token matches the token of the given shared tool.
+     *
+     * @param int $toolid The id of the shared tool
+     * @param string $token hash for this site and this enrolment instance
+     * @return boolean True if the token matches, false if it does not
+     * @since Moodle 3.2
+     */
+    public static function verify_tool_token($toolid, $token) {
+        return $token == self::generate_tool_token($toolid);
+    }
+
+    /**
+     * Returns the parameters of the cartridge as an associative array of partial xpath.
+     *
+     * @param int $toolid The id of the shared tool
+     * @return array Recursive associative array with partial xpath to be concatenated into an xpath expression
+     *     before setting the value.
+     * @since Moodle 3.2
+     */
+    protected static function get_cartridge_parameters($toolid) {
+        global $OUTPUT, $PAGE, $SITE;
+        $PAGE->set_context(\context_system::instance());
+
+        // Get the tool.
+        $tool = self::get_lti_tool($toolid);
+
+        // Work out the name of the tool.
+        $title = self::get_name($tool);
+        $launchurl = self::get_launch_url($toolid);
+        $launchurl = $launchurl->out();
+        $icon = $OUTPUT->favicon();
+        $icon = $icon->out();
+        $securelaunchurl = null;
+        $secureicon = null;
+        $vendorurl = new \moodle_url('/');
+        $vendorurl = $vendorurl->out();
+        $description = self::get_description($tool);
+
+        // If we are a https site, we can add the launch url and icon urls as secure equivalents.
+        if (\is_https()) {
+            $securelaunchurl = $launchurl;
+            $secureicon = $icon;
+        }
+
+        return array(
+                "/cc:cartridge_basiclti_link" => array(
+                    "/blti:title" => $title,
+                    "/blti:description" => $description,
+                    "/blti:extensions" => array(
+                            "/lticm:property[@name='icon_url']" => $icon,
+                            "/lticm:property[@name='secure_icon_url']" => $secureicon
+                        ),
+                    "/blti:launch_url" => $launchurl,
+                    "/blti:secure_launch_url" => $securelaunchurl,
+                    "/blti:icon" => $icon,
+                    "/blti:secure_icon" => $secureicon,
+                    "/blti:vendor" => array(
+                            "/lticp:code" => $SITE->shortname,
+                            "/lticp:name" => $SITE->fullname,
+                            "/lticp:description" => trim(html_to_text($SITE->summary)),
+                            "/lticp:url" => $vendorurl
+                        )
+                )
+            );
+    }
+
+    /**
+     * Traverses a recursive associative array, setting the properties of the corresponding
+     * xpath element.
+     *
+     * @param DOMXPath $xpath The xpath with the xml to modify
+     * @param array $parameters The array of xpaths to search through
+     * @param string $prefix The current xpath prefix (gets longer the deeper into the array you go)
+     * @return void
+     * @since Moodle 3.2
+     */
+    protected static function set_xpath($xpath, $parameters, $prefix = '') {
+        foreach ($parameters as $key => $value) {
+            if (is_array($value)) {
+                self::set_xpath($xpath, $value, $prefix . $key);
+            } else {
+                $result = @$xpath->query($prefix . $key);
+                if ($result) {
+                    $node = $result->item(0);
+                    if ($node) {
+                        if (is_null($value)) {
+                            $node->parentNode->removeChild($node);
+                        } else {
+                            $node->nodeValue = $value;
+                        }
+                    }
+                } else {
+                    throw new \coding_exception('Please check your XPATH and try again.');
+                }
+            }
+        }
+    }
+
+    /**
+     * Create an IMS cartridge for the tool.
+     *
+     * @param int $toolid The id of the shared tool
+     * @return string representing the generated cartridge
+     * @since Moodle 3.2
+     */
+    public static function create_cartridge($toolid) {
+        $cartridge = new \DOMDocument();
+        $cartridge->load(realpath(__DIR__ . '/../xml/imslticc.xml'));
+        $xpath = new \DOMXpath($cartridge);
+        $xpath->registerNamespace('cc', 'http://www.imsglobal.org/xsd/imslticc_v1p0');
+        $parameters = self::get_cartridge_parameters($toolid);
+        self::set_xpath($xpath, $parameters);
+
+        return $cartridge->saveXML();
+    }
 }
index d334483..6ebec61 100644 (file)
@@ -96,12 +96,7 @@ class manage_table extends \table_sql {
      * @return string
      */
     public function col_name($tool) {
-        if (empty($tool->name)) {
-            $toolcontext = \context::instance_by_id($tool->contextid);
-            $name = $toolcontext->get_context_name();
-        } else {
-            $name = $tool->name;
-        };
+        $name = helper::get_name($tool);
 
         return $this->get_display_text($tool, $name);
     }
@@ -113,8 +108,9 @@ class manage_table extends \table_sql {
      * @return string
      */
     public function col_url($tool) {
-        $url = new \moodle_url('/enrol/lti/tool.php', array('id' => $tool->id));
-        return $this->get_display_text($tool, $url);
+        $url = helper::get_cartridge_url($tool);
+
+        return $this->get_copyable_text($tool, $url);
     }
 
     /**
@@ -124,7 +120,7 @@ class manage_table extends \table_sql {
      * @return string
      */
     public function col_secret($tool) {
-        return $this->get_display_text($tool, $tool->secret);
+        return $this->get_copyable_text($tool, $tool->secret);
     }
 
 
@@ -215,4 +211,22 @@ class manage_table extends \table_sql {
 
         return $text;
     }
+
+    /**
+     * Returns text to display in the columns.
+     *
+     * @param \stdClass $tool the tool
+     * @param string $text the text to alter
+     * @return string
+     * @since Moodle 3.2
+     */
+    protected function get_copyable_text($tool, $text) {
+        global $OUTPUT;
+        $copyable = $OUTPUT->render_from_template('core/copy_box', array('text' => $text));
+        if ($tool->status != ENROL_INSTANCE_ENABLED) {
+            return \html_writer::tag('span', $copyable, array('class' => 'dimmed_text', 'style' => 'overflow: scroll'));
+        }
+
+        return $copyable;
+    }
 }
index 8b2cd1e..0579023 100644 (file)
@@ -37,6 +37,7 @@ $string['enrolstartdate_help'] = 'If enabled, users can access from this date on
 $string['frameembeddingnotenabled'] = 'To access the tool, please follow the link below.';
 $string['gradesync'] = 'Grade synchronisation';
 $string['gradesync_help'] = 'Whether grades from the tool are sent to the remote system (LTI consumer).';
+$string['incorrecttoken'] = 'Token was incorrect please check the URL and try again, or contact the administrator of this tool.';
 $string['maxenrolled'] = 'Maximum enrolled users';
 $string['maxenrolled_help'] = 'The maximum number of remote users who can access the tool. If set to zero, the number of enrolled users is unlimited.';
 $string['maxenrolledreached'] = 'The maximum number of remote users allowed to access the tool has been reached.';
diff --git a/enrol/lti/styles.css b/enrol/lti/styles.css
new file mode 100644 (file)
index 0000000..9128815
--- /dev/null
@@ -0,0 +1,4 @@
+.copy_box {
+    width: 100%;
+    max-width: 350px;
+}
diff --git a/enrol/lti/tests/fixtures/input.xml b/enrol/lti/tests/fixtures/input.xml
new file mode 100644 (file)
index 0000000..6202832
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<root>
+    <firstnode></firstnode>
+    <parentnode>
+        <childnode></childnode>
+    </parentnode>
+    <ambiguous id="0"></ambiguous>
+    <ambiguous id="1"></ambiguous>
+</root>
diff --git a/enrol/lti/tests/fixtures/test_ambiguous_nodes-expected.xml b/enrol/lti/tests/fixtures/test_ambiguous_nodes-expected.xml
new file mode 100644 (file)
index 0000000..8ef4597
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<root>
+    <firstnode/>
+    <parentnode>
+        <childnode/>
+    </parentnode>
+    <ambiguous id="0"/>
+    <ambiguous id="1">Content 1</ambiguous>
+</root>
diff --git a/enrol/lti/tests/fixtures/test_correct_xpath-expected.xml b/enrol/lti/tests/fixtures/test_correct_xpath-expected.xml
new file mode 100644 (file)
index 0000000..88c21eb
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<root>
+    <firstnode>Content 1</firstnode>
+    <parentnode>
+        <childnode>Content 2</childnode>
+    </parentnode>
+    <ambiguous id="0"/>
+    <ambiguous id="1"/>
+</root>
diff --git a/enrol/lti/tests/fixtures/test_missing_node-expected.xml b/enrol/lti/tests/fixtures/test_missing_node-expected.xml
new file mode 100644 (file)
index 0000000..e4a5619
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<root>
+    <firstnode>Content 1</firstnode>
+    <parentnode>
+        <childnode/>
+    </parentnode>
+    <ambiguous id="0"/>
+    <ambiguous id="1"/>
+</root>
diff --git a/enrol/lti/tests/fixtures/test_nodes_removed-expected.xml b/enrol/lti/tests/fixtures/test_nodes_removed-expected.xml
new file mode 100644 (file)
index 0000000..34eb129
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<root>
+    
+    <parentnode>
+        
+    </parentnode>
+    <ambiguous id="0"/>
+    <ambiguous id="1"/>
+</root>
index 971fa50..c6c40f7 100644 (file)
@@ -247,6 +247,233 @@ class enrol_lti_helper_testcase extends advanced_testcase {
         $this->assertTrue(isset($tools[$tool3->id]));
     }
 
+    /**
+     * Test getting the launch url of a tool.
+     */
+    public function test_get_launch_url() {
+        $course1 = $this->getDataGenerator()->create_course();
+        $data = new stdClass();
+        $data->courseid = $course1->id;
+        $tool1 = $this->create_tool($data);
+
+        $id = $tool1->id;
+        $launchurl = \enrol_lti\helper::get_launch_url($id);
+        $this->assertEquals('http://www.example.com/moodle/enrol/lti/tool.php?id=' . $id, $launchurl->out());
+    }
+
+    /**
+     * Test getting the cartridge url of a tool.
+     */
+    public function test_get_cartridge_url() {
+        global $CFG;
+
+        $slasharguments = $CFG->slasharguments;
+
+        $CFG->slasharguments = false;
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $data = new stdClass();
+        $data->courseid = $course1->id;
+        $tool1 = $this->create_tool($data);
+
+        $id = $tool1->id;
+        $token = \enrol_lti\helper::generate_tool_token($id);
+        $launchurl = \enrol_lti\helper::get_cartridge_url($tool1);
+        $this->assertEquals('http://www.example.com/moodle/enrol/lti/cartridge.php?id=' . $id . '&amp;token=' . $token,
+                            $launchurl->out());
+
+        $CFG->slasharguments = true;
+
+        $launchurl = \enrol_lti\helper::get_cartridge_url($tool1);
+        $this->assertEquals('http://www.example.com/moodle/enrol/lti/cartridge.php/' . $id . '/' . $token . '/cartridge.xml',
+                            $launchurl->out());
+
+        $CFG->slasharguments = $slasharguments;
+    }
+
+    /**
+     * Test getting the name of a tool.
+     */
+    public function test_get_name() {
+        $course1 = $this->getDataGenerator()->create_course();
+        $data = new stdClass();
+        $data->courseid = $course1->id;
+        $tool1 = $this->create_tool($data);
+
+        $name = \enrol_lti\helper::get_name($tool1);
+        $this->assertEquals('Course: Test course 1', $name);
+
+        $tool1->name = 'Shared course';
+        $name = \enrol_lti\helper::get_name($tool1);
+        $this->assertEquals('Shared course', $name);
+    }
+
+    /**
+     * Test getting the description of a tool.
+     */
+    public function test_get_description() {
+        $course1 = $this->getDataGenerator()->create_course();
+        $data = new stdClass();
+        $data->courseid = $course1->id;
+        $tool1 = $this->create_tool($data);
+
+        $description = \enrol_lti\helper::get_description($tool1);
+        $this->assertContains('Test course 1 Lorem ipsum dolor sit amet', $description);
+
+        $module1 = $this->getDataGenerator()->create_module('assign', array(
+                'course' => $course1->id
+            ));
+        $data = new stdClass();
+        $data->cmid = $module1->cmid;
+        $tool2 = $this->create_tool($data);
+        $description = \enrol_lti\helper::get_description($tool2);
+        $this->assertContains('Test assign 1', $description);
+    }
+
+    /**
+     * Test verifying a tool token.
+     */
+    public function test_verify_tool_token() {
+        $course1 = $this->getDataGenerator()->create_course();
+        $data = new stdClass();
+        $data->courseid = $course1->id;
+        $tool1 = $this->create_tool($data);
+
+        $token = \enrol_lti\helper::generate_tool_token($tool1->id);
+        $this->assertTrue(\enrol_lti\helper::verify_tool_token($tool1->id, $token));
+        $this->assertFalse(\enrol_lti\helper::verify_tool_token($tool1->id, 'incorrect token!'));
+    }
+
+    /**
+     * Data provider for the set_xpath test.
+     */
+    public function set_xpath_provider() {
+        return [
+            "Correct structure" => [
+                "parameters" => [
+                    "/root" => [
+                        "/firstnode" => "Content 1",
+                        "/parentnode" => [
+                            "/childnode" => "Content 2"
+                        ]
+                    ]
+                ],
+                "expected" => "test_correct_xpath-expected.xml"
+            ],
+            "A null value, but no node to remove" => [
+                "parameters" => [
+                    "/root" => [
+                        "/nonexistant" => null,
+                        "/firstnode" => "Content 1"
+                    ]
+                ],
+                "expected" => "test_missing_node-expected.xml"
+            ],
+            "A string value, but no node existing to set" => [
+                "parameters" => [
+                    "/root" => [
+                        "/nonexistant" => "This will not be set",
+                        "/firstnode" => "Content 1"
+                    ]
+                ],
+                "expected" => "test_missing_node-expected.xml"
+            ],
+            "Array but no children exist" => [
+                "parameters" => [
+                    "/root" => [
+                        "/nonexistant" => [
+                            "/alsononexistant" => "This will not be set"
+                        ],
+                        "/firstnode" => "Content 1"
+                    ]
+                ],
+                "expected" => "test_missing_node-expected.xml"
+            ],
+            "Remove nodes" => [
+                "parameters" => [
+                    "/root" => [
+                        "/parentnode" => [
+                            "/childnode" => null
+                        ],
+                        "/firstnode" => null
+                    ]
+                ],
+                "expected" => "test_nodes_removed-expected.xml"
+            ],
+            "Get by attribute" => [
+                "parameters" => [
+                    "/root" => [
+                        "/ambiguous[@id='1']" => 'Content 1'
+                    ]
+                ],
+                "expected" => "test_ambiguous_nodes-expected.xml"
+            ]
+        ];
+    }
+
+    /**
+     * Test set_xpath.
+     * @dataProvider set_xpath_provider
+     * @param array $parameters A hash of parameters represented by a heirarchy of xpath expressions
+     * @param string $expected The name of the fixture file containing the expected result.
+     */
+    public function test_set_xpath($parameters, $expected) {
+        $helper = new ReflectionClass('enrol_lti\\helper');
+        $function = $helper->getMethod('set_xpath');
+        $function->setAccessible(true);
+
+        $document = new \DOMDocument();
+        $document->load(realpath(__DIR__ . '/fixtures/input.xml'));
+        $xpath = new \DOMXpath($document);
+        $function->invokeArgs(null, [$xpath, $parameters]);
+        $result = $document->saveXML();
+        $expected = file_get_contents(realpath(__DIR__ . '/fixtures/' . $expected));
+        $this->assertEquals($expected, $result);
+    }
+
+    /**
+     * Test set_xpath when an incorrect xpath expression is given.
+     * @expectedException coding_exception
+     */
+    public function test_set_xpath_incorrect_xpath() {
+        $parameters = [
+            "/root" => [
+                "/firstnode" => null,
+                "/parentnode*&#^*#(" => [
+                    "/childnode" => null
+                ],
+            ]
+        ];
+        $helper = new ReflectionClass('enrol_lti\\helper');
+        $function = $helper->getMethod('set_xpath');
+        $function->setAccessible(true);
+
+        $document = new \DOMDocument();
+        $document->load(realpath(__DIR__ . '/fixtures/input.xml'));
+        $xpath = new \DOMXpath($document);
+        $function->invokeArgs(null, [$xpath, $parameters]);
+        $result = $document->saveXML();
+        $expected = file_get_contents(realpath(__DIR__ . '/fixtures/' . $expected));
+        $this->assertEquals($expected, $result);
+    }
+
+    /**
+     * Test create cartridge.
+     */
+    public function test_create_cartridge() {
+        global $CFG;
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $data = new stdClass();
+        $data->courseid = $course1->id;
+        $tool1 = $this->create_tool($data);
+
+        $cartridge = \enrol_lti\helper::create_cartridge($tool1->id);
+        $this->assertContains('<blti:title>Test LTI</blti:title>', $cartridge);
+        $this->assertContains("<blti:icon>$CFG->wwwroot/theme/image.php/_s/clean/theme/1/favicon</blti:icon>", $cartridge);
+        $this->assertContains("<blti:launch_url>$CFG->wwwroot/enrol/lti/tool.php?id=$tool1->id</blti:launch_url>", $cartridge);
+    }
+
     /**
      * Helper function used to create a tool.
      *
@@ -267,6 +494,12 @@ class enrol_lti_helper_testcase extends advanced_testcase {
             $course = get_course($data->courseid);
         }
 
+        if (!empty($data->cmid)) {
+            $data->contextid = context_module::instance($data->cmid)->id;
+        } else {
+            $data->contextid = context_course::instance($data->courseid)->id;
+        }
+
         // Set it to enabled if no status was specified.
         if (!isset($data->status)) {
             $data->status = ENROL_INSTANCE_ENABLED;
@@ -274,7 +507,6 @@ class enrol_lti_helper_testcase extends advanced_testcase {
 
         // Add some extra necessary fields to the data.
         $data->name = 'Test LTI';
-        $data->contextid = context_course::instance($data->courseid)->id;
         $data->roleinstructor = $studentrole->id;
         $data->rolelearner = $teacherrole->id;
 
index 926e739..1468621 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version = 2016052300; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2016052301; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires = 2016051900; // Requires this Moodle version (3.1)
 $plugin->component = 'enrol_lti'; // Full name of the plugin (used for diagnostics).
diff --git a/enrol/lti/xml/imslticc.xml b/enrol/lti/xml/imslticc.xml
new file mode 100644 (file)
index 0000000..839c57f
--- /dev/null
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
+    xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
+    xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
+    xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
+    xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
+http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
+http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
+http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd">
+    <blti:title></blti:title>
+    <blti:description></blti:description>
+    <blti:extensions platform="org.moodle.lms">
+        <lticm:property name="icon_url"></lticm:property>
+        <lticm:property name="secure_icon_url"></lticm:property>
+    </blti:extensions>
+    <blti:launch_url></blti:launch_url>
+    <blti:secure_launch_url></blti:secure_launch_url>
+    <blti:icon></blti:icon>
+    <blti:secure_icon></blti:secure_icon>
+    <blti:vendor>
+        <lticp:code></lticp:code>
+        <lticp:name></lticp:name>
+        <lticp:description></lticp:description>
+        <lticp:url></lticp:url>
+    </blti:vendor>
+    <test></test>
+</cartridge_basiclti_link>
index d2c5972..4d34810 100644 (file)
@@ -52,9 +52,6 @@ $capabilities = array(
 
         'captype' => 'write',
         'contextlevel' => CONTEXT_COURSE,
-        'archetypes' => array(
-            'manager' => CAP_PROHIBIT,
-        )
     ),
 
     /* Voluntarily unenrol self from course - watch out for data loss. */
index 8be21ea..581de7a 100644 (file)
@@ -39,5 +39,24 @@ function xmldb_enrol_self_upgrade($oldversion) {
     // Moodle v3.1.0 release upgrade line.
     // Put any upgrade step following this.
 
+    // Moodle v3.2.0 release upgrade line.
+    // Put any upgrade step following this.
+
+    if ($oldversion < 2016052301) {
+        global $DB;
+        // Get roles with manager archetype.
+        $managerroles = get_archetype_roles('manager');
+        if (!empty($managerroles)) {
+            // Remove wrong CAP_PROHIBIT from self:holdkey.
+            foreach ($managerroles as $role) {
+                $DB->execute("DELETE
+                                FROM {role_capabilities}
+                               WHERE roleid = ? AND capability = ? AND permission = ?",
+                        array($role->id, 'enrol/self:holdkey', CAP_PROHIBIT));
+            }
+        }
+        upgrade_plugin_savepoint(true, 2016052301, 'enrol', 'self');
+    }
+
     return true;
 }
diff --git a/enrol/self/tests/behat/key_holder.feature b/enrol/self/tests/behat/key_holder.feature
new file mode 100644 (file)
index 0000000..31f4a82
--- /dev/null
@@ -0,0 +1,53 @@
+@enrol @enrol_self
+Feature: Users can be defined as key holders in courses where self enrolment is allowed
+  In order to participate in courses
+  As a user
+  I need to auto enrol me in courses
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | manager1 | Manager | 1 | manager1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And I log in as "admin"
+    And I navigate to "Define roles" node in "Site administration > Users > Permissions"
+    And I click on "Add a new role" "button"
+    And I click on "Continue" "button"
+    And I set the following fields to these values:
+      | Short name | keyholder |
+      | Custom full name | Key holder |
+      | contextlevel50 | 1 |
+      | enrol/self:holdkey | 1 |
+    And I click on "Create this role" "button"
+    And I navigate to "Courses" node in "Site administration > Appearance"
+    And I set the following fields to these values:
+      | Key holder | 1 |
+    And I press "Save changes"
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | manager1 | C1 | keyholder |
+    And I log out
+
+  @javascript
+  Scenario: The key holder name is displayed on site home page
+    Given I log in as "admin"
+    And I am on site homepage
+    And I follow "Course 1"
+    When I add "Self enrolment" enrolment method with:
+      | Custom instance name | Test student enrolment |
+      | Enrolment key | moodle_rules |
+    And I log out
+    And I log in as "student1"
+    And I am on site homepage
+    And I follow "Course 1"
+    And I should see "You should have received this enrolment key from:"
+    And I should see "Manager 1"
+    And I set the following fields to these values:
+      | Enrolment key | moodle_rules |
+    And I press "Enrol me"
+    Then I should see "Topic 1"
+    And I should not see "Enrolment options"
+    And I should not see "Enrol me in this course"
index c2d7ed9..7422312 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2016052300;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2016052301;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2016051900;        // Requires this Moodle version
 $plugin->component = 'enrol_self';      // Full name of the plugin (used for diagnostics)
 $plugin->cron      = 600;
index 2a6a33c..71f47b8 100644 (file)
@@ -50,9 +50,19 @@ $mform->set_data($data);
 if ($mform->is_cancelled()) {
     redirect($returnurl);
 } else if ($mform->is_submitted() && $mform->is_validated() && !$mform->need_confirm_regrading($controller)) {
-    // everything ok, validated, re-grading confirmed if needed. Make changes to the rubric
-    $controller->update_definition($mform->get_data());
-    redirect($returnurl);
+    // Everything ok, validated, re-grading confirmed if needed. Make changes to the rubric.
+    $data = $mform->get_data();
+    $controller->update_definition($data);
+
+    // If we do not go back to management url and the minscore warning needs to be displayed, display it during redirection.
+    $warning = null;
+    if (!empty($data->returnurl)) {
+        if (($scores = $controller->get_min_max_score()) && $scores['minscore'] <> 0) {
+            $warning = get_string('zerolevelsabsent', 'gradingform_rubric').'<br>'.
+                html_writer::link($manager->get_management_url(), get_string('back'));
+        }
+    }
+    redirect($returnurl, $warning, null, \core\output\notification::NOTIFY_ERROR);
 }
 
 echo $OUTPUT->header();
index cf9455d..dacd5dd 100644 (file)
@@ -44,6 +44,7 @@ $string['err_mintwolevels'] = 'Each criterion must have at least two levels';
 $string['err_nocriteria'] = 'Rubric must contain at least one criterion';
 $string['err_nodefinition'] = 'Level definition can not be empty';
 $string['err_nodescription'] = 'Criterion description can not be empty';
+$string['err_novariations'] = 'Criterion levels cannot all be worth the same number of points';
 $string['err_scoreformat'] = 'Number of points for each level must be a valid non-negative number';
 $string['err_totalscore'] = 'Maximum number of points possible when graded by the rubric must be more than zero';
 $string['gradingof'] = '{$a} grading';
@@ -82,4 +83,6 @@ $string['showscorestudent'] = 'Display points for each level to those being grad
 $string['showscoreteacher'] = 'Display points for each level during evaluation';
 $string['sortlevelsasc'] = 'Sort order for levels:';
 $string['sortlevelsasc0'] = 'Descending by number of points';
-$string['sortlevelsasc1'] = 'Ascending by number of points';
\ No newline at end of file
+$string['sortlevelsasc1'] = 'Ascending by number of points';
+$string['zerolevelsabsent'] = 'Warning: The minimum possible score for this rubric is not 0; this can result in unexpected grades for the activity. To avoid this, each criterion should have a level with 0 points.<br>
+This warning may be ignored if a scale is used for grading, and the minimum levels in the rubric correspond to the minimum value of the scale.';
index 72b6b60..dbaa666 100644 (file)
@@ -633,6 +633,9 @@ class gradingform_rubric_renderer extends plugin_renderer_base {
         if (!$scores) {
             return $html;
         }
+        if ($scores['minscore'] <> 0) {
+            $html .= $this->output->notification(get_string('zerolevelsabsent', 'gradingform_rubric'), 'error');
+        }
         $html .= $this->box(
                 html_writer::tag('h4', get_string('rubricmapping', 'gradingform_rubric')).
                 html_writer::tag('div', get_string('rubricmappingexplained', 'gradingform_rubric', (object)$scores))
index 54601b5..49b0193 100644 (file)
@@ -197,6 +197,7 @@ class MoodleQuickForm_rubriceditor extends HTML_QuickForm_input {
         // iterate through criteria
         $lastaction = null;
         $lastid = null;
+        $overallminscore = $overallmaxscore = 0;
         foreach ($value['criteria'] as $id => $criterion) {
             if ($id == 'addcriterion') {
                 $id = $this->get_next_id(array_keys($value['criteria']));
@@ -221,7 +222,7 @@ class MoodleQuickForm_rubriceditor extends HTML_QuickForm_input {
                 $this->nonjsbuttonpressed = true;
             }
             $levels = array();
-            $maxscore = null;
+            $minscore = $maxscore = null;
             if (array_key_exists('levels', $criterion)) {
                 foreach ($criterion['levels'] as $levelid => $level) {
                     if ($levelid == 'addlevel') {
@@ -249,6 +250,9 @@ class MoodleQuickForm_rubriceditor extends HTML_QuickForm_input {
                             }
                         }
                         $levels[$levelid] = $level;
+                        if ($minscore === null || (float)$level['score'] < $minscore) {
+                            $minscore = (float)$level['score'];
+                        }
                         if ($maxscore === null || (float)$level['score'] > $maxscore) {
                             $maxscore = (float)$level['score'];
                         }
@@ -268,6 +272,8 @@ class MoodleQuickForm_rubriceditor extends HTML_QuickForm_input {
                     $errors['err_nodescription'] = 1;
                     $criterion['error_description'] = true;
                 }
+                $overallmaxscore += $maxscore;
+                $overallminscore += $minscore;
             }
             if (array_key_exists('moveup', $criterion) || $lastaction == 'movedown') {
                 unset($criterion['moveup']);
@@ -307,6 +313,9 @@ class MoodleQuickForm_rubriceditor extends HTML_QuickForm_input {
 
         // create validation error string (if needed)
         if ($withvalidation) {
+            if ($overallminscore == $overallmaxscore) {
+                $errors['err_novariations'] = 1;
+            }
             if (count($errors)) {
                 $rv = array();
                 foreach ($errors as $error => $v) {
index 58448b6..aa197d7 100644 (file)
@@ -151,7 +151,7 @@ $string['configbloglevel'] = 'This setting allows you to restrict the level to w
 $string['configcalendarcustomexport'] = 'Enable custom date range export of calendar';
 $string['configcalendarexportsalt'] = 'This random text is used for improving of security of authentication tokens used for exporting of calendars. Please note that all current tokens are invalidated if you change this hash salt.';
 $string['configcookiehttponly'] = 'Enables new PHP 5.2.0 feature - browsers are instructed to send cookie with real http requests only, cookies should not be accessible by scripting languages. This is not supported in all browsers and it may not be fully compatible with current code. It helps to prevent some types of XSS attacks.';
-$string['configcookiesecure'] = 'If server is accepting only https connections it is recommended to enable sending of secure cookies. If enabled please make sure that web server is not accepting http:// or set up permanent redirection to https:// address. When <em>wwwroot</em> address does not start with https:// this setting is turned off automatically.';
+$string['configcookiesecure'] = 'If server is accepting only https connections it is recommended to enable sending of secure cookies. If enabled please make sure that web server is not accepting http:// or set up permanent redirection to https:// address and ideally send HSTS headers. When <em>wwwroot</em> address does not start with https:// this setting is ignored.';
 $string['configcountry'] = 'If you set a country here, then this country will be selected by default on new user accounts.  To force users to choose a country, just leave this unset.';
 $string['configcourseoverviewfilesext'] = 'A comma-separated list of allowed course summary files extensions.';
 $string['configcourseoverviewfileslimit'] = 'The maximum number of files that can be attached to a course summary.';
@@ -657,6 +657,11 @@ $string['loginpageautofocus_help'] = 'Enabling this option improves usability of
 $string['loginpasswordautocomplete'] = 'Prevent password autocompletion on login form';
 $string['loginpasswordautocomplete_help'] = 'If enabled, users are not allowed to save their account password in their browser.';
 $string['loglifetime'] = 'Keep logs for';
+$string['logo'] = 'Logo';
+$string['logo_desc'] = 'Your full logo. The image format must be PNG or JPEG.';
+$string['logocompact'] = 'Compact logo';
+$string['logocompact_desc'] = 'A compact version of your logo, usually this would be your emblem, or iconic symbol. Moodle\'s compact logo is the \'M\' by itself with the cap. The image format must be PNG or JPEG.';
+$string['logossettings'] = 'Logos';
 $string['logstorenotrequired'] = 'Log store not required';
 $string['logstoressupported'] = 'Log stores that support this report';
 $string['longtimewarning'] = '<b>Please note that this process can take a long time.</b>';
index b6e7255..7148c61 100644 (file)
@@ -2027,6 +2027,7 @@ $string['yourself'] = 'yourself';
 $string['yourteacher'] = 'your {$a}';
 $string['yourwordforx'] = 'Your word for \'{$a}\'';
 $string['zippingbackup'] = 'Zipping backup';
+$string['deprecatedeventname'] = '{$a} (no longer in use)';
 
 // Deprecated since Moodle 3.1.
 $string['filetoolarge'] = 'is too large to upload';
index a5d25d7..f48c7f4 100644 (file)
@@ -81,6 +81,8 @@ $string['runindexer'] = 'Run indexer (real)';
 $string['runindexertest'] = 'Run indexer test';
 $string['score'] = 'Score';
 $string['search'] = 'Search';
+$string['search:message_received'] = 'Messages - Received';
+$string['search:message_sent'] = 'Messages - Sent';
 $string['search:mycourse'] = 'My courses';
 $string['search:user'] = 'Users';
 $string['searcharea'] = 'Search area';
index a2c4eb8..19033df 100644 (file)
@@ -4208,15 +4208,32 @@ function get_role_users($roleid, context $context, $parent = false, $fields = ''
     // Adding the fields from $sort that are not present in $fields.
     $sortarray = preg_split('/,\s*/', $sort);
     $fieldsarray = preg_split('/,\s*/', $fields);
+
+    // Discarding aliases from the fields.
+    $fieldnames = array();
+    foreach ($fieldsarray as $key => $field) {
+        list($fieldnames[$key]) = explode(' ', $field);
+    }
+
     $addedfields = array();
     foreach ($sortarray as $sortfield) {
         // Throw away any additional arguments to the sort (e.g. ASC/DESC).
-        list ($sortfield) = explode(' ', $sortfield);
-        if (!in_array($sortfield, $fieldsarray)) {
+        list($sortfield) = explode(' ', $sortfield);
+        list($tableprefix) = explode('.', $sortfield);
+        $fieldpresent = false;
+        foreach ($fieldnames as $fieldname) {
+            if ($fieldname === $sortfield || $fieldname === $tableprefix.'.*') {
+                $fieldpresent = true;
+                break;
+            }
+        }
+
+        if (!$fieldpresent) {
             $fieldsarray[] = $sortfield;
             $addedfields[] = $sortfield;
         }
     }
+
     $fields = implode(', ', $fieldsarray);
     if (!empty($addedfields)) {
         $addedfields = implode(', ', $addedfields);
index 17d399b..8036019 100644 (file)
@@ -305,6 +305,26 @@ abstract class base implements \IteratorAggregate {
         return $parts[0].': '.str_replace('_', ' ', $parts[2]);
     }
 
+    /**
+     * Returns the event name complete with metadata information.
+     *
+     * This includes information about whether the event has been deprecated so should not be used in all situations -
+     * for example within reports themselves.
+     *
+     * If overriding this function, please ensure that you call the parent version too.
+     *
+     * @return string
+     */
+    public static function get_name_with_info() {
+        $return = static::get_name();
+
+        if (static::is_deprecated()) {
+            $return = get_string('deprecatedeventname', 'core', $return);
+        }
+
+        return $return;
+    }
+
     /**
      * Returns non-localised event description with id's for admin use only.
      *
@@ -961,4 +981,17 @@ abstract class base implements \IteratorAggregate {
     public function getIterator() {
         return new \ArrayIterator($this->data);
     }
+
+    /**
+     * Whether this event has been marked as deprecated.
+     *
+     * Events cannot be deprecated in the normal fashion as they must remain to support historical data.
+     * Once they are deprecated, there is no way to trigger the event, so it does not make sense to list it in some
+     * parts of the UI (e.g. Event Monitor).
+     *
+     * @return boolean
+     */
+    public static function is_deprecated() {
+        return false;
+    }
 }
index a1680fa..517ad11 100644 (file)
@@ -130,5 +130,13 @@ abstract class content_viewed extends base {
     public static function get_other_mapping() {
         return false;
     }
-}
 
+    /**
+     * This event has been deprected.
+     *
+     * @return boolean
+     */
+    public static function is_deprecated() {
+        return true;
+    }
+}
index 6534c5c..7594b69 100644 (file)
@@ -27,6 +27,9 @@
 namespace core\event;
 defined('MOODLE_INTERNAL') || die();
 
+debugging('core\\event\\course_module_instances_list_viewed has been deperecated. Please use
+        core\\event\\course_module_instance_list_viewed instead', DEBUG_DEVELOPER);
+
 /**
  * This class has been deprecated, please use \core\event\course_module_instance_list_viewed.
  *
@@ -37,7 +40,12 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 abstract class course_module_instances_list_viewed extends course_module_instance_list_viewed {
+    /**
+     * This event has been deprected.
+     *
+     * @return boolean
+     */
+    public static function is_deprecated() {
+        return true;
+    }
 }
-
-debugging('core\\event\\course_module_instances_list_viewed has been deperecated. Please use
-        core\\event\\course_module_instance_list_viewed instead', DEBUG_DEVELOPER);
index b2e9332..9ac0048 100644 (file)
@@ -123,4 +123,8 @@ class mnet_access_control_updated extends base {
         // Nothing to map.
         return false;
     }
+
+    public static function is_deprecated() {
+        return true;
+    }
 }
index 8396a8d..3eb3cf6 100644 (file)
@@ -105,7 +105,8 @@ class role_assigned extends base {
      */
     protected function get_legacy_logdata() {
         $roles = get_all_roles();
-        $rolenames = role_fix_names($roles, $this->get_context(), ROLENAME_ORIGINAL, true);
+        $neededrole = array($this->objectid => $roles[$this->objectid]);
+        $rolenames = role_fix_names($neededrole, $this->get_context(), ROLENAME_ORIGINAL, true);
         return array($this->courseid, 'role', 'assign', 'admin/roles/assign.php?contextid='.$this->contextid.'&roleid='.$this->objectid,
                 $rolenames[$this->objectid], '', $this->userid);
     }
index 85499a0..4d0386b 100644 (file)
@@ -191,9 +191,7 @@ class manager {
     protected static function prepare_cookies() {
         global $CFG;
 
-        if (!isset($CFG->cookiesecure) or (!is_https() and empty($CFG->sslproxy))) {
-            $CFG->cookiesecure = 0;
-        }
+        $cookiesecure = is_moodle_cookie_secure();
 
         if (!isset($CFG->cookiehttponly)) {
             $CFG->cookiehttponly = 0;
@@ -254,7 +252,7 @@ class manager {
 
         // Set configuration.
         session_name($sessionname);
-        session_set_cookie_params(0, $CFG->sessioncookiepath, $CFG->sessioncookiedomain, $CFG->cookiesecure, $CFG->cookiehttponly);
+        session_set_cookie_params(0, $CFG->sessioncookiepath, $CFG->sessioncookiedomain, $cookiesecure, $CFG->cookiehttponly);
         ini_set('session.use_trans_sid', '0');
         ini_set('session.use_only_cookies', '1');
         ini_set('session.hash_function', '0');        // For now MD5 - we do not have room for sha-1 in sessions table.
index 5ce0791..f9f5465 100644 (file)
@@ -44,28 +44,19 @@ class stats_cron_task extends scheduled_task {
     public function execute() {
         global $CFG;
 
-        $timenow = time();
         // Run stats as at the end because they are known to take very long time on large sites.
         if (!empty($CFG->enablestats) and empty($CFG->disablestatsprocessing)) {
             require_once($CFG->dirroot.'/lib/statslib.php');
-            // Check we're not before our runtime.
-            $timetocheck = stats_get_base_daily() + $CFG->statsruntimestarthour * 60 * 60 + $CFG->statsruntimestartminute * 60;
-
-            if ($timenow > $timetocheck) {
-                // Process configured number of days as max (defaulting to 31).
-                $maxdays = empty($CFG->statsruntimedays) ? 31 : abs($CFG->statsruntimedays);
-                if (stats_cron_daily($maxdays)) {
-                    if (stats_cron_weekly()) {
-                        if (stats_cron_monthly()) {
-                            stats_clean_old();
-                        }
+            // Process configured number of days as max (defaulting to 31).
+            $maxdays = empty($CFG->statsruntimedays) ? 31 : abs($CFG->statsruntimedays);
+            if (stats_cron_daily($maxdays)) {
+                if (stats_cron_weekly()) {
+                    if (stats_cron_monthly()) {
+                        stats_clean_old();
                     }
                 }
-                \core_php_time_limit::raise();
-            } else {
-                mtrace('Next stats run after:'. userdate($timetocheck));
             }
+            \core_php_time_limit::raise();
         }
     }
-
 }
index 3dbd8f4..51265b0 100644 (file)
       </KEYS>
       <INDEXES>
         <INDEX NAME="contextid" UNIQUE="false" FIELDS="contextid" COMMENT="links to context table"/>
+        <INDEX NAME="contextidstamp" UNIQUE="true" FIELDS="contextid, stamp"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="question" COMMENT="The questions themselves">
index 49863b8..f7296c5 100644 (file)
@@ -306,7 +306,7 @@ $tasks = array(
         'classname' => 'core\task\stats_cron_task',
         'blocking' => 0,
         'minute' => '0',
-        'hour' => '*',
+        'hour' => '0',
         'day' => '*',
         'dayofweek' => '*',
         'month' => '*'
index 629ab19..c413aa6 100644 (file)
@@ -2072,7 +2072,7 @@ function xmldb_main_upgrade($oldversion) {
     // Moodle v3.1.0 release upgrade line.
     // Put any upgrade step following this.
 
-    if ($oldversion < 2016070700.01) {
+    if ($oldversion < 2016081700.00) {
 
         // If someone is emotionally attached to it let's leave the config (basically the version) there.
         if (!file_exists($CFG->dirroot . '/report/search/classes/output/form.php')) {
@@ -2080,7 +2080,101 @@ function xmldb_main_upgrade($oldversion) {
         }
 
         // Savepoint reached.
-        upgrade_main_savepoint(true, 2016070700.01);
+        upgrade_main_savepoint(true, 2016081700.00);
+    }
+
+    if ($oldversion < 2016081700.02) {
+        // Default schedule values.
+        $hour = 0;
+        $minute = 0;
+
+        // Get the old settings.
+        if (isset($CFG->statsruntimestarthour)) {
+            $hour = $CFG->statsruntimestarthour;
+        }
+        if (isset($CFG->statsruntimestartminute)) {
+            $minute = $CFG->statsruntimestartminute;
+        }
+
+        // Retrieve the scheduled task record first.
+        $stattask = $DB->get_record('task_scheduled', array('component' => 'moodle', 'classname' => '\core\task\stats_cron_task'));
+
+        // Don't touch customised scheduling.
+        if ($stattask && !$stattask->customised) {
+
+            $nextruntime = mktime($hour, $minute, 0, date('m'), date('d'), date('Y'));
+            if ($nextruntime < $stattask->lastruntime) {
+                // Add 24 hours to the next run time.
+                $newtime = new DateTime();
+                $newtime->setTimestamp($nextruntime);
+                $newtime->add(new DateInterval('P1D'));
+                $nextruntime = $newtime->getTimestamp();
+            }
+            $stattask->nextruntime = $nextruntime;
+            $stattask->minute = $minute;
+            $stattask->hour = $hour;
+            $stattask->customised = 1;
+            $DB->update_record('task_scheduled', $stattask);
+        }
+        // These settings are no longer used.
+        unset_config('statsruntimestarthour');
+        unset_config('statsruntimestartminute');
+        unset_config('statslastexecution');
+
+        upgrade_main_savepoint(true, 2016081700.02);
+    }
+
+    if ($oldversion < 2016082200.00) {
+        // An upgrade step to remove any duplicate stamps, within the same context, in the question_categories table, and to
+        // add a unique index to (contextid, stamp) to avoid future stamp duplication. See MDL-54864.
+
+        // Extend the execution time limit of the script to 2 hours.
+        upgrade_set_timeout(7200);
+
+        // This SQL fetches the id of those records which have duplicate stamps within the same context.
+        // This doesn't return the original record within the context, from which the duplicate stamps were likely created.
+        $fromclause = "FROM (
+                        SELECT min(id) AS minid, contextid, stamp
+                            FROM {question_categories}
+                            GROUP BY contextid, stamp
+                        ) minid
+                        JOIN {question_categories} qc
+                            ON qc.contextid = minid.contextid AND qc.stamp = minid.stamp AND qc.id > minid.minid";
+
+        // Get the total record count - used for the progress bar.
+        $countduplicatessql = "SELECT count(qc.id) $fromclause";
+        $total = $DB->count_records_sql($countduplicatessql);
+
+        // Get the records themselves.
+        $getduplicatessql = "SELECT qc.id $fromclause ORDER BY minid";
+        $rs = $DB->get_recordset_sql($getduplicatessql);
+
+        // For each duplicate, update the stamp to a new random value.
+        $i = 0;
+        $pbar = new progress_bar('updatequestioncategorystamp', 500, true);
+        foreach ($rs as $record) {
+            // Generate a new, unique stamp and update the record.
+            do {
+                $newstamp = make_unique_id_code();
+            } while (isset($usedstamps[$newstamp]));
+            $usedstamps[$newstamp] = 1;
+            $DB->set_field('question_categories', 'stamp', $newstamp, array('id' => $record->id));
+
+            // Update progress.
+            $i++;
+            $pbar->update($i, $total, "Updating duplicate question category stamp - $i/$total.");
+        }
+        unset($usedstamps);
+
+        // The uniqueness of each (contextid, stamp) pair is now guaranteed, so add the unique index to stop future duplicates.
+        $table = new xmldb_table('question_categories');
+        $index = new xmldb_index('contextidstamp', XMLDB_INDEX_UNIQUE, array('contextid', 'stamp'));
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        // Savepoint reached.
+        upgrade_main_savepoint(true, 2016082200.00);
     }
 
     return true;
index e23467a..e2f19c9 100644 (file)
@@ -2070,6 +2070,70 @@ function send_temp_file_finished($path) {
     }
 }
 
+/**
+ * Serve content which is not meant to be cached.
+ *
+ * This is only intended to be used for volatile public files, for instance
+ * when development is enabled, or when caching is not required on a public resource.
+ *
+ * @param string $content Raw content.
+ * @param string $filename The file name.
+ * @return void
+ */
+function send_content_uncached($content, $filename) {
+    $mimetype = mimeinfo('type', $filename);
+    $charset = strpos($mimetype, 'text/') === 0 ? '; charset=utf-8' : '';
+
+    header('Content-Disposition: inline; filename="' . $filename . '"');
+    header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
+    header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 2) . ' GMT');
+    header('Pragma: ');
+    header('Accept-Ranges: none');
+    header('Content-Type: ' . $mimetype . $charset);
+    header('Content-Length: ' . strlen($content));
+
+    echo $content;
+    die();
+}
+
+/**
+ * Safely save content to a certain path.
+ *
+ * This function tries hard to be atomic by first copying the content
+ * to a separate file, and then moving the file across. It also prevents
+ * the user to abort a request to prevent half-safed files.
+ *
+ * This function is intended to be used when saving some content to cache like
+ * $CFG->localcachedir. If you're not caching a file you should use the File API.
+ *
+ * @param string $content The file content.
+ * @param string $destination The absolute path of the final file.
+ * @return void
+ */
+function file_safe_save_content($content, $destination) {
+    global $CFG;
+
+    clearstatcache();
+    if (!file_exists(dirname($destination))) {
+        @mkdir(dirname($destination), $CFG->directorypermissions, true);
+    }
+
+    // Prevent serving of incomplete file from concurrent request,
+    // the rename() should be more atomic than fwrite().
+    ignore_user_abort(true);
+    if ($fp = fopen($destination . '.tmp', 'xb')) {
+        fwrite($fp, $content);
+        fclose($fp);
+        rename($destination . '.tmp', $destination);
+        @chmod($destination, $CFG->filepermissions);
+        @unlink($destination . '.tmp'); // Just in case anything fails.
+    }
+    ignore_user_abort(false);
+    if (connection_aborted()) {
+        die();
+    }
+}
+
 /**
  * Handles the sending of file data to the user's browser, including support for
  * byteranges etc.
@@ -2086,9 +2150,12 @@ function send_temp_file_finished($path) {
  *                        if this is passed as true, ignore_user_abort is called.  if you don't want your processing to continue on cancel,
  *                        you must detect this case when control is returned using connection_aborted. Please not that session is closed
  *                        and should not be reopened.
+ * @param array $options An array of options, currently accepts:
+ *                       - (string) cacheability: public, or private.
  * @return null script execution stopped unless $dontdie is true
  */
-function send_file($path, $filename, $lifetime = null , $filter=0, $pathisstring=false, $forcedownload=false, $mimetype='', $dontdie=false) {
+function send_file($path, $filename, $lifetime = null , $filter=0, $pathisstring=false, $forcedownload=false, $mimetype='',
+                   $dontdie=false, array $options = array()) {
     global $CFG, $COURSE;
 
     if ($dontdie) {
@@ -2122,7 +2189,13 @@ function send_file($path, $filename, $lifetime = null , $filter=0, $pathisstring
 
     if ($lifetime > 0) {
         $cacheability = ' public,';
-        if (isloggedin() and !isguestuser()) {
+        if (!empty($options['cacheability']) && ($options['cacheability'] === 'public')) {
+            // This file must be cache-able by both browsers and proxies.
+            $cacheability = ' public,';
+        } else if (!empty($options['cacheability']) && ($options['cacheability'] === 'private')) {
+            // This file must be cache-able only by browsers.
+            $cacheability = ' private,';
+        } else if (isloggedin() and !isguestuser()) {
             // By default, under the conditions above, this file must be cache-able only by browsers.
             $cacheability = ' private,';
         }
index a9640e0..a5bc30c 100644 (file)
@@ -184,16 +184,17 @@ class file_storage {
      *
      * @param stored_file $file the file we want to preview
      * @param string $format The desired format - e.g. 'pdf'. Formats are specified by file extension.
+     * @param boolean $forcerefresh If true, the file will be converted every time (not cached).
      * @return stored_file|bool false if unable to create the conversion, stored file otherwise
      */
-    public function get_converted_document(stored_file $file, $format) {
+    public function get_converted_document(stored_file $file, $format, $forcerefresh = false) {
 
         $context = context_system::instance();
         $path = '/' . $format . '/';
         $conversion = $this->get_file($context->id, 'core', 'documentconversion', 0, $path, $file->get_contenthash());
 
-        if (!$conversion) {
-            $conversion = $this->create_converted_document($file, $format);
+        if (!$conversion || $forcerefresh) {
+            $conversion = $this->create_converted_document($file, $format, $forcerefresh);
             if (!$conversion) {
                 return false;
             }
@@ -259,7 +260,7 @@ class file_storage {
     }
 
     /**
-     * If the test pdf has been generated correctly and send it direct to the browser.
+     * Regenerate the test pdf and send it direct to the browser.
      */
     public static function send_test_pdf() {
         global $CFG;
@@ -286,7 +287,7 @@ class file_storage {
         }
 
         // Convert the doc file to pdf and send it direct to the browser.
-        $result = $fs->get_converted_document($testdocx, 'pdf');
+        $result = $fs->get_converted_document($testdocx, 'pdf', true);
         readfile_accel($result, 'application/pdf', true);
     }
 
@@ -334,7 +335,7 @@ class file_storage {
      * @param string $format The desired format - e.g. 'pdf'. Formats are specified by file extension.
      * @return stored_file|bool false if unable to create the conversion, stored file otherwise
      */
-    protected function create_converted_document(stored_file $file, $format) {
+    protected function create_converted_document(stored_file $file, $format, $forcerefresh = false) {
         global $CFG;
 
         if (empty($CFG->pathtounoconv) || !file_is_executable(trim($CFG->pathtounoconv))) {
@@ -365,7 +366,7 @@ class file_storage {
                 throw new file_exception('storedfileproblem', 'Could not copy file contents to temp file.');
             }
         } catch (file_exception $fe) {
-            remove_dir($uniqdir);
+            remove_dir($tmp);
             throw $fe;
         }
 
@@ -386,25 +387,34 @@ class file_storage {
         chdir($tmp);
         $result = exec($cmd, $output);
         chdir($currentdir);
-        if (!file_exists($newtmpfile)) {
-            remove_dir($uniqdir);
+        touch($newtmpfile);
+        if (filesize($newtmpfile) === 0) {
+            remove_dir($tmp);
             // Cleanup.
             return false;
         }
 
         $context = context_system::instance();
+        $path = '/' . $format . '/';
         $record = array(
             'contextid' => $context->id,
             'component' => 'core',
             'filearea'  => 'documentconversion',
             'itemid'    => 0,
-            'filepath'  => '/' . $format . '/',
+            'filepath'  => $path,
             'filename'  => $file->get_contenthash(),
         );
 
+        if ($forcerefresh) {
+            $existing = $this->get_file($context->id, 'core', 'documentconversion', 0, $path, $file->get_contenthash());
+            if ($existing) {
+                $existing->delete();
+            }
+        }
+
         $convertedfile = $this->create_file_from_pathname($record, $newtmpfile);
         // Cleanup.
-        remove_dir($uniqdir);
+        remove_dir($tmp);
         return $convertedfile;
     }
 
index b115f96..7d3b64f 100644 (file)
@@ -1063,4 +1063,28 @@ class stored_file {
         // Generate the thumbnail.
         return generate_image_thumbnail_from_image($original, $imageinfo, $width, $height);
     }
+
+    /**
+     * Generate a resized image for this stored_file.
+     *
+     * @param int|null $width The desired width, or null to only use the height.
+     * @param int|null $height The desired height, or null to only use the width.
+     * @return string|false False when a problem occurs, else the image data.
+     */
+    public function resize_image($width, $height) {
+        global $CFG;
+        require_once($CFG->libdir . '/gdlib.php');
+
+        // Fetch the image information for this image.
+        $imageinfo = @getimagesizefromstring($this->get_content());
+        if (empty($imageinfo)) {
+            return false;
+        }
+
+        // Create a new image from the file.
+        $original = @imagecreatefromstring($this->get_content());
+
+        // Generate the resized image.
+        return resize_image_from_image($original, $imageinfo, $width, $height);
+    }
 }
index b33f437..bced16a 100644 (file)
@@ -266,7 +266,8 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
         $point = (isset($vals['modgrade_point'])) ? $vals['modgrade_point'] : null;
         $scale = (isset($vals['modgrade_scale'])) ? $vals['modgrade_scale'] : null;
         $rescalegrades = (isset($vals['modgrade_rescalegrades'])) ? $vals['modgrade_rescalegrades'] : null;
-        $return = $this->process_value($type, $scale, $point);
+
+        $return = $this->process_value($type, $scale, $point, $rescalegrades);
         return array($this->getName() => $return, $this->getName() . '_rescalegrades' => $rescalegrades);
     }
 
@@ -276,11 +277,17 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
      * @param  string $type The value of the grade type select box. Can be 'none', 'scale', or 'point'
      * @param  string|int $scale The value of the scale select box.
      * @param  string|int $point The value of the point grade textbox.
+     * @param  string $rescalegrades The value of the rescalegrades select.
      * @return int The resulting value
      */
-    protected function process_value($type='none', $scale=null, $point=null) {
+    protected function process_value($type='none', $scale=null, $point=null, $rescalegrades=null) {
         global $COURSE;
         $val = 0;
+        if ($this->isupdate && $this->hasgrades && $this->canrescale && $this->currentgradetype == 'point' && empty($rescalegrades)) {
+            // If the maxgrade field is disabled with javascript, no value is sent with the form and mform assumes the default.
+            // If the user was forced to choose a rescale option - and they haven't - prevent any changes to the max grade.
+            return (string)unformat_float($this->currentgrade);
+        }
         switch ($type) {
             case 'point':
                 if ($this->validate_point($point) === true) {
index 948d783..72fdb41 100644 (file)
@@ -270,20 +270,21 @@ function process_new_icon($context, $component, $filearea, $itemid, $originalfil
     return $file1->get_id();
 }
 
+
 /**
- * Generates a thumbnail for the given image
+ * Resize an image from an image path.
  *
- * If the GD library has at least version 2 and PNG support is available, the returned data
- * is the content of a transparent PNG file containing the thumbnail. Otherwise, the function
- * returns contents of a JPEG file with black background containing the thumbnail.
+ * This maintains the aspect ratio of the image.
+ * This will not enlarge the image.
  *
- * @param string $filepath the full path to the original image file
- * @param int $width the width of the requested thumbnail
- * @param int $height the height of the requested thumbnail
- * @return string|bool false if a problem occurs, the thumbnail image data otherwise
+ * @param string $filepath The full path to the original image file.
+ * @param int|null $width The max width of the resized image, or null to only use the height.
+ * @param int|null $height The max height of the resized image, or null to only use the width.
+ * @param bool $forcecanvas Whether the final dimensions should be set to $width and $height.
+ * @return string|bool False if a problem occurs, else the resized image data.
  */
-function generate_image_thumbnail($filepath, $width, $height) {
-    if (empty($filepath) or empty($width) or empty($height)) {
+function resize_image($filepath, $width, $height, $forcecanvas = false) {
+    if (empty($filepath)) {
         return false;
     }
 
@@ -297,58 +298,33 @@ function generate_image_thumbnail($filepath, $width, $height) {
     $original = @imagecreatefromstring(file_get_contents($filepath));
 
     // Generate the thumbnail.
-    return generate_image_thumbnail_from_image($original, $imageinfo, $width, $height);
+    return resize_image_from_image($original, $imageinfo, $width, $height, $forcecanvas);
 }
 
 /**
- * Generates a thumbnail for the given image string.
- *
- * If the GD library has at least version 2 and PNG support is available, the returned data
- * is the content of a transparent PNG file containing the thumbnail. Otherwise, the function
- * returns contents of a JPEG file with black background containing the thumbnail.
+ * Resize an image from an image object.
  *
- * @param   string $filedata The image content as a string
- * @param   int $width the width of the requested thumbnail
- * @param   int $height the height of the requested thumbnail
- * @return  string|bool false if a problem occurs, the thumbnail image data otherwise
+ * @param resource $original The image to work on.
+ * @param array $imageinfo Contains [0] => originalwidth, [1] => originalheight.
+ * @param int|null $width The max width of the resized image, or null to only use the height.
+ * @param int|null $height The max height of the resized image, or null to only use the width.
+ * @param bool $forcecanvas Whether the final dimensions should be set to $width and $height.
+ * @return string|bool False if a problem occurs, else the resized image data.
  */
-function generate_image_thumbnail_from_string($filedata, $width, $height) {
-    if (empty($filedata) or empty($width) or empty($height)) {
-        return false;
-    }
+function resize_image_from_image($original, $imageinfo, $width, $height, $forcecanvas = false) {
+    global $CFG;
 
-    // Fetch the image information for this image.
-    $imageinfo = @getimagesizefromstring($filedata);
-    if (empty($imageinfo)) {
+    if (empty($width) && empty($height) || ($forcecanvas && (empty($width) || empty($height)))) {
+        // We need do not have the required ddimensions to work with.
         return false;
     }
 
-    // Create a new image from the file.
-    $original = @imagecreatefromstring($filedata);
-
-    // Generate the thumbnail.
-    return generate_image_thumbnail_from_image($original, $imageinfo, $width, $height);
-}
-
-/**
- * Generates a thumbnail for the given image string.
- *
- * If the GD library has at least version 2 and PNG support is available, the returned data
- * is the content of a transparent PNG file containing the thumbnail. Otherwise, the function
- * returns contents of a JPEG file with black background containing the thumbnail.
- *
- * @param   string $original The image content as a string
- * @return  string|bool false if a problem occurs, the thumbnail image data otherwise
- */
-function generate_image_thumbnail_from_image($original, $imageinfo, $width, $height) {
-    global $CFG;
-
     if (empty($imageinfo)) {
         return false;
     }
+
     $originalwidth  = $imageinfo[0];
     $originalheight = $imageinfo[1];
-
     if (empty($originalwidth) or empty($originalheight)) {
         return false;
     }
@@ -366,19 +342,14 @@ function generate_image_thumbnail_from_image($original, $imageinfo, $width, $hei
         return false;
     }
 
-    if (function_exists('imagecreatetruecolor')) {
-        $thumbnail = imagecreatetruecolor($width, $height);
-        if ($imagefnc === 'imagepng') {
-            imagealphablending($thumbnail, false);
-            imagefill($thumbnail, 0, 0, imagecolorallocatealpha($thumbnail, 0, 0, 0, 127));
-            imagesavealpha($thumbnail, true);
-        }
+    if (empty($height)) {
+        $ratio = $width / $originalwidth;
+    } else if (empty($width)) {
+        $ratio = $height / $originalheight;
     } else {
-        $thumbnail = imagecreate($width, $height);
+        $ratio = min($width / $originalwidth, $height / $originalheight);
     }
 
-    $ratio = min($width / $originalwidth, $height / $originalheight);
-
     if ($ratio < 1) {
         $targetwidth    = floor($originalwidth * $ratio);
         $targetheight   = floor($originalheight * $ratio);
@@ -388,20 +359,103 @@ function generate_image_thumbnail_from_image($original, $imageinfo, $width, $hei
         $targetheight   = $originalheight;
     }
 
-    $dstx = floor(($width - $targetwidth) / 2);
-    $dsty = floor(($height - $targetheight) / 2);
+    $canvaswidth = $targetwidth;
+    $canvasheight = $targetheight;
+    $dstx = 0;
+    $dsty = 0;
 
-    imagecopybicubic($thumbnail, $original, $dstx, $dsty, 0, 0, $targetwidth, $targetheight, $originalwidth, $originalheight);
+    if ($forcecanvas) {
+        $canvaswidth = $width;
+        $canvasheight = $height;
+        $dstx = floor(($width - $targetwidth) / 2);
+        $dsty = floor(($height - $targetheight) / 2);
+    }
+
+    if (function_exists('imagecreatetruecolor')) {
+        $newimage = imagecreatetruecolor($canvaswidth, $canvasheight);
+        if ($imagefnc === 'imagepng') {
+            imagealphablending($newimage, false);
+            imagefill($newimage, 0, 0, imagecolorallocatealpha($newimage, 0, 0, 0, 127));
+            imagesavealpha($newimage, true);
+        }
+    } else {
+        $newimage = imagecreate($canvaswidth, $canvasheight);
+    }
+
+    imagecopybicubic($newimage, $original, $dstx, $dsty, 0, 0, $targetwidth, $targetheight, $originalwidth, $originalheight);
 
     // Capture the image as a string object, rather than straight to file.
     ob_start();
-    if (!$imagefnc($thumbnail, null, $quality, $filters)) {
+    if (!$imagefnc($newimage, null, $quality, $filters)) {
         ob_end_clean();
         return false;
     }
     $data = ob_get_clean();
     imagedestroy($original);
-    imagedestroy($thumbnail);
+    imagedestroy($newimage);
 
     return $data;
 }
+
+/**
+ * Generates a thumbnail for the given image
+ *
+ * If the GD library has at least version 2 and PNG support is available, the returned data
+ * is the content of a transparent PNG file containing the thumbnail. Otherwise, the function
+ * returns contents of a JPEG file with black background containing the thumbnail.
+ *
+ * @param string $filepath the full path to the original image file
+ * @param int $width the width of the requested thumbnail
+ * @param int $height the height of the requested thumbnail
+ * @return string|bool false if a problem occurs, the thumbnail image data otherwise
+ */
+function generate_image_thumbnail($filepath, $width, $height) {
+    return resize_image($filepath, $width, $height, true);
+}
+
+/**
+ * Generates a thumbnail for the given image string.
+ *
+ * If the GD library has at least version 2 and PNG support is available, the returned data
+ * is the content of a transparent PNG file containing the thumbnail. Otherwise, the function
+ * returns contents of a JPEG file with black background containing the thumbnail.
+ *
+ * @param   string $filedata The image content as a string
+ * @param   int $width the width of the requested thumbnail
+ * @param   int $height the height of the requested thumbnail
+ * @return  string|bool false if a problem occurs, the thumbnail image data otherwise
+ */
+function generate_image_thumbnail_from_string($filedata, $width, $height) {
+    if (empty($filedata) or empty($width) or empty($height)) {
+        return false;
+    }
+
+    // Fetch the image information for this image.
+    $imageinfo = @getimagesizefromstring($filedata);
+    if (empty($imageinfo)) {
+        return false;
+    }
+
+    // Create a new image from the file.
+    $original = @imagecreatefromstring($filedata);
+
+    // Generate the thumbnail.
+    return generate_image_thumbnail_from_image($original, $imageinfo, $width, $height);
+}
+
+/**
+ * Generates a thumbnail for the given image string.
+ *
+ * If the GD library has at least version 2 and PNG support is available, the returned data
+ * is the content of a transparent PNG file containing the thumbnail. Otherwise, the function
+ * returns contents of a JPEG file with black background containing the thumbnail.
+ *
+ * @param   resource $original The image to work on.
+ * @param   array $imageinfo Contains [0] => originalwidth, [1] => originalheight.
+ * @param   int $width The width of the requested thumbnail.
+ * @param   int $height The height of the requested thumbnail.
+ * @return  string|bool False if a problem occurs, the thumbnail image data otherwise.
+ */
+function generate_image_thumbnail_from_image($original, $imageinfo, $width, $height) {
+    return resize_image_from_image($original, $imageinfo, $width, $height, true);
+}
index 9e57b24..230fef2 100644 (file)
@@ -7808,20 +7808,21 @@ function random_bytes_emulate($length) {
         }
     }
     if (function_exists('openssl_random_pseudo_bytes')) {
-        // For PHP 5.3 and later with openssl extension.
+        // If you have the openssl extension enabled.
         $hash = openssl_random_pseudo_bytes($length);
         if ($hash !== false) {
             return $hash;
         }
     }
 
-    // Bad luck, there is no reliable random generator, let's just hash some unique stuff that is hard to guess.
-    $hash = sha1(serialize($CFG) . serialize($_SERVER) . microtime(true) . uniqid('', true), true);
-    // NOTE: the last param in sha1() is true, this means we are getting 20 bytes, not 40 chars as usual.
-    if ($length <= 20) {
-        return substr($hash, 0, $length);
-    }
-    return $hash . random_bytes_emulate($length - 20);
+    // Bad luck, there is no reliable random generator, let's just slowly hash some unique stuff that is hard to guess.
+    $staticdata = serialize($CFG) . serialize($_SERVER);
+    $hash = '';
+    do {
+        $hash .= sha1($staticdata . microtime(true) . uniqid('', true), true);
+    } while (strlen($hash) < $length);
+
+    return substr($hash, 0, $length);
 }
 
 /**
index 63be693..97b9543 100644 (file)
@@ -261,6 +261,51 @@ class renderer_base {
     public function pix_url($imagename, $component = 'moodle') {
         return $this->page->theme->pix_url($imagename, $component);
     }
+
+    /**
+     * Return the site's logo URL, if any.
+     *
+     * @param int $maxwidth The maximum width, or null when the maximum width does not matter.
+     * @param int $maxheight The maximum height, or null when the maximum height does not matter.
+     * @return moodle_url|false
+     */
+    public function get_logo_url($maxwidth = null, $maxheight = 100) {
+        global $CFG;
+        $logo = get_config('core_admin', 'logo');
+        if (empty($logo)) {
+            return false;
+        }
+
+        // Hide the requested size in the file path.
+        $filepath = ((int) $maxwidth . 'x' . (int) $maxheight) . '/';
+
+        // Use $CFG->themerev to prevent browser caching when the file changes.
+        return moodle_url::make_pluginfile_url(context_system::instance()->id, 'core_admin', 'logo', $filepath,
+            theme_get_revision(), $logo);
+    }
+
+    /**
+     * Return the site's compact logo URL, if any.
+     *
+     * @param int $maxwidth The maximum width, or null when the maximum width does not matter.
+     * @param int $maxheight The maximum height, or null when the maximum height does not matter.
+     * @return moodle_url|false
+     */
+    public function get_compact_logo_url($maxwidth = 100, $maxheight = 100) {
+        global $CFG;
+        $logo = get_config('core_admin', 'logocompact');
+        if (empty($logo)) {
+            return false;
+        }
+
+        // Hide the requested size in the file path.
+        $filepath = ((int) $maxwidth . 'x' . (int) $maxheight) . '/';
+
+        // Use $CFG->themerev to prevent browser caching when the file changes.
+        return moodle_url::make_pluginfile_url(context_system::instance()->id, 'core_admin', 'logocompact', $filepath,
+            theme_get_revision(), $logo);
+    }
+
 }
 
 
index e60462f..03fc861 100644 (file)
@@ -86,6 +86,25 @@ function require_sesskey() {
     }
 }
 
+/**
+ * Determine wether the secure flag should be set on cookies
+ * @return bool
+ */
+function is_moodle_cookie_secure() {
+    global $CFG;
+
+    if (!isset($CFG->cookiesecure)) {
+        return false;
+    }
+    if (!empty($CFG->loginhttps)) {
+        return false;
+    }
+    if (!is_https() and empty($CFG->sslproxy)) {
+        return false;
+    }
+    return !empty($CFG->cookiesecure);
+}
+
 /**
  * Sets a moodle cookie with a weakly encrypted username
  *
@@ -111,12 +130,14 @@ function set_moodle_cookie($username) {
 
     $cookiename = 'MOODLEID1_'.$CFG->sessioncookie;
 
-    // delete old cookie
-    setcookie($cookiename, '', time() - HOURSECS, $CFG->sessioncookiepath, $CFG->sessioncookiedomain, $CFG->cookiesecure, $CFG->cookiehttponly);
+    $cookiesecure = is_moodle_cookie_secure();
+
+    // Delete old cookie.
+    setcookie($cookiename, '', time() - HOURSECS, $CFG->sessioncookiepath, $CFG->sessioncookiedomain, $cookiesecure, $CFG->cookiehttponly);
 
     if ($username !== '') {
-        // set username cookie for 60 days
-        setcookie($cookiename, rc4encrypt($username), time()+(DAYSECS*60), $CFG->sessioncookiepath, $CFG->sessioncookiedomain, $CFG->cookiesecure, $CFG->cookiehttponly);
+        // Set username cookie for 60 days.
+        setcookie($cookiename, rc4encrypt($username), time() + (DAYSECS * 60), $CFG->sessioncookiepath, $CFG->sessioncookiedomain, $cookiesecure, $CFG->cookiehttponly);
     }
 }
 
index 23fa555..a414fae 100644 (file)
@@ -138,22 +138,6 @@ function stats_cron_daily($maxdays=1) {
         set_config('statslastdaily', $timestart);
     }
 
-    // calculate scheduled time
-    $scheduledtime = stats_get_base_daily() + $CFG->statsruntimestarthour*60*60 + $CFG->statsruntimestartminute*60;
-
-    // Note: This will work fine for sites running cron each 4 hours or less (hopefully, 99.99% of sites). MDL-16709
-    // check to make sure we're due to run, at least 20 hours after last run
-    if (isset($CFG->statslastexecution) && ((time() - 20*60*60) < $CFG->statslastexecution)) {
-        mtrace("...preventing stats to run, last execution was less than 20 hours ago.");
-        return false;
-    // also check that we are a max of 4 hours after scheduled time, stats won't run after that
-    } else if (time() > $scheduledtime + 4*60*60) {
-        mtrace("...preventing stats to run, more than 4 hours since scheduled time.");
-        return false;
-    } else {
-        set_config('statslastexecution', time()); /// Grab this execution as last one
-    }
-
     $nextmidnight = stats_get_next_day_start($timestart);
 
     // are there any days that need to be processed?
@@ -161,7 +145,6 @@ function stats_cron_daily($maxdays=1) {
         return true; // everything ok and up-to-date
     }
 
-
     $timeout = empty($CFG->statsmaxruntime) ? 60*60*24 : $CFG->statsmaxruntime;
 
     if (!set_cron_lock('statsrunning', $now + $timeout)) {
@@ -1041,13 +1024,10 @@ function stats_get_base_monthly($time=0) {
  */
 function stats_get_next_day_start($time) {
     $next = stats_get_base_daily($time);
-    $next = $next + 60*60*26;
-    $next = stats_get_base_daily($next);
-    if ($next <= $time) {
-        //DST trouble - prevent infinite loops
-        $next = $next + 60*60*24;
-    }
-    return $next;
+    $nextdate = new DateTime();
+    $nextdate->setTimestamp($next);
+    $nextdate->add(new DateInterval('P1D'));
+    return $nextdate->getTimestamp();
 }
 
 /**
@@ -1057,13 +1037,10 @@ function stats_get_next_day_start($time) {
  */
 function stats_get_next_week_start($time) {
     $next = stats_get_base_weekly($time);
-    $next = $next + 60*60*24*9;
-    $next = stats_get_base_weekly($next);
-    if ($next <= $time) {
-        //DST trouble - prevent infinite loops
-        $next = $next + 60*60*24*7;
-    }
-    return $next;
+    $nextdate = new DateTime();
+    $nextdate->setTimestamp($next);
+    $nextdate->add(new DateInterval('P1W'));
+    return $nextdate->getTimestamp();
 }
 
 /**
@@ -1073,13 +1050,10 @@ function stats_get_next_week_start($time) {
  */
 function stats_get_next_month_start($time) {
     $next = stats_get_base_monthly($time);
-    $next = $next + 60*60*24*33;
-    $next = stats_get_base_monthly($next);
-    if ($next <= $time) {
-        //DST trouble - prevent infinite loops
-        $next = $next + 60*60*24*31;
-    }
-    return $next;
+    $nextdate = new DateTime();
+    $nextdate->setTimestamp($next);
+    $nextdate->add(new DateInterval('P1M'));
+    return $nextdate->getTimestamp();
 }
 
 /**
diff --git a/lib/templates/copy_box.mustache b/lib/templates/copy_box.mustache
new file mode 100644 (file)
index 0000000..b0ba295
--- /dev/null
@@ -0,0 +1,41 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle 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.
+
+    Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/copy_box
+
+    Interface element to contain text that the user should copy. Will automaticaly select when clicked.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * text The content to be displayed ready for copying
+
+    Example context (json):
+    { "text": "Copyable text"}
+}}
+<input type="text" class="copy_box" value="{{{ text }}}" readonly="readonly" id="copy_box-{{uniqid}}"/>
+{{# js }}
+require(['jquery', 'theme_bootstrapbase/bootstrap'], function($) {
+    $('#copy_box-{{uniqid}}').on('click', function() {
+        $(this).select();
+    });
+});
+{{/ js }}
index e3a2f99..04686f6 100644 (file)
@@ -1453,6 +1453,22 @@ class core_accesslib_testcase extends advanced_testcase {
         $this->assertObjectHasAttribute('lastname', $users[$user3->id]);
         $this->assertObjectHasAttribute('firstname', $users[$user3->id]);
 
+        $users = get_role_users($teacherrole->id, $coursecontext, false, 'u.*');
+        $this->assertDebuggingNotCalled();
+        $this->assertCount(2, $users);
+
+        $users = get_role_users($teacherrole->id, $coursecontext, false, 'u.id AS id_alias');
+        $this->assertDebuggingCalled('get_role_users() adding u.lastname, u.firstname to the query result because they were required by $sort but missing in $fields');
+        $this->assertCount(2, $users);
+        $this->assertArrayHasKey($user1->id, $users);
+        $this->assertObjectHasAttribute('id_alias', $users[$user1->id]);
+        $this->assertObjectHasAttribute('lastname', $users[$user1->id]);
+        $this->assertObjectHasAttribute('firstname', $users[$user1->id]);
+        $this->assertArrayHasKey($user3->id, $users);
+        $this->assertObjectHasAttribute('id_alias', $users[$user3->id]);
+        $this->assertObjectHasAttribute('lastname', $users[$user3->id]);
+        $this->assertObjectHasAttribute('firstname', $users[$user3->id]);
+
         $users = get_role_users($teacherrole->id, $coursecontext, false, 'u.id, u.email, u.idnumber', 'u.idnumber', null, $group->id);
         $this->assertCount(1, $users);
         $this->assertArrayHasKey($user3->id, $users);
index befc910..8875c3c 100644 (file)
@@ -86,4 +86,72 @@ class core_gdlib_testcase extends basic_testcase {
         $this->assertEquals(24, $imageinfo[1]);
         $this->assertEquals('image/png', $imageinfo['mime']);
     }
+
+    public function test_resize_image() {
+        global $CFG;
+        require_once($CFG->libdir . '/gdlib.php');
+
+        $pngpath = $this->fixturepath . 'gd-logo.png';
+
+        // Preferred height.
+        $newpng = resize_image($pngpath, null, 24);
+        $this->assertTrue(is_string($newpng));
+        $imageinfo = getimagesizefromstring($newpng);
+        $this->assertEquals(89, $imageinfo[0]);
+        $this->assertEquals(24, $imageinfo[1]);
+        $this->assertEquals('image/png', $imageinfo['mime']);
+
+        // Preferred width.
+        $newpng = resize_image($pngpath, 100, null);
+        $this->assertTrue(is_string($newpng));
+        $imageinfo = getimagesizefromstring($newpng);
+        $this->assertEquals(100, $imageinfo[0]);
+        $this->assertEquals(26, $imageinfo[1]);
+        $this->assertEquals('image/png', $imageinfo['mime']);
+
+        // Preferred width and height.
+        $newpng = resize_image($pngpath, 50, 50);
+        $this->assertTrue(is_string($newpng));
+        $imageinfo = getimagesizefromstring($newpng);
+        $this->assertEquals(50, $imageinfo[0]);
+        $this->assertEquals(13, $imageinfo[1]);
+        $this->assertEquals('image/png', $imageinfo['mime']);
+    }
+
+    public function test_resize_image_from_image() {
+        global $CFG;
+        require_once($CFG->libdir . '/gdlib.php');
+
+        $pngpath = $this->fixturepath . 'gd-logo.png';
+        $origimageinfo = getimagesize($pngpath);
+        $imagecontent = file_get_contents($pngpath);
+
+        // Preferred height.
+        $imageresource = imagecreatefromstring($imagecontent);
+        $newpng = resize_image_from_image($imageresource, $origimageinfo, null, 24);
+        $this->assertTrue(is_string($newpng));
+        $imageinfo = getimagesizefromstring($newpng);
+        $this->assertEquals(89, $imageinfo[0]);
+        $this->assertEquals(24, $imageinfo[1]);
+        $this->assertEquals('image/png', $imageinfo['mime']);
+
+        // Preferred width.
+        $imageresource = imagecreatefromstring($imagecontent);
+        $newpng = resize_image_from_image($imageresource, $origimageinfo, 100, null);
+        $this->assertTrue(is_string($newpng));
+        $imageinfo = getimagesizefromstring($newpng);
+        $this->assertEquals(100, $imageinfo[0]);
+        $this->assertEquals(26, $imageinfo[1]);
+        $this->assertEquals('image/png', $imageinfo['mime']);
+
+        // Preferred width and height.
+        $imageresource = imagecreatefromstring($imagecontent);
+        $newpng = resize_image_from_image($imageresource, $origimageinfo, 50, 50);
+        $this->assertTrue(is_string($newpng));
+        $imageinfo = getimagesizefromstring($newpng);
+        $this->assertEquals(50, $imageinfo[0]);
+        $this->assertEquals(13, $imageinfo[1]);
+        $this->assertEquals('image/png', $imageinfo['mime']);
+    }
+
 }
index 34e05bc..353187e 100644 (file)
@@ -3047,6 +3047,9 @@ class core_moodlelib_testcase extends advanced_testcase {
         $result = random_bytes_emulate(666);
         $this->assertSame(666, strlen($result));
 
+        $result = random_bytes_emulate(40);
+        $this->assertSame(40, strlen($result));
+
         $this->assertDebuggingNotCalled();
 
         $result = random_bytes_emulate(0);
index 6879ae5..a5e9eb5 100644 (file)
@@ -154,6 +154,110 @@ class core_sessionlib_testcase extends advanced_testcase {
         $this->assertSame($GLOBALS['USER'], $USER);
     }
 
+    /**
+     * Test provided for secure cookie
+     *
+     * @return array of config and secure result
+     */
+    public function moodle_cookie_secure_provider() {
+        return array(
+            array(
+                // Non ssl, not set.
+                'config' => array(
+                    'wwwroot'       => 'http://example.com',
+                    'httpswwwroot'  => 'http://example.com',
+                    'sslproxy'      => null,
+                    'loginhttps'    => null,
+                    'cookiesecure'  => null,
+                ),
+                'secure' => false,
+            ),
+            array(
+                // Non ssl, off and ignored.
+                'config' => array(
+                    'wwwroot'       => 'http://example.com',
+                    'httpswwwroot'  => 'http://example.com',
+                    'sslproxy'      => null,
+                    'loginhttps'    => null,
+                    'cookiesecure'  => false,
+                ),
+                'secure' => false,
+            ),
+            array(
+                // Non ssl, on and ignored.
+                'config' => array(
+                    'wwwroot'       => 'http://example.com',
+                    'httpswwwroot'  => 'http://example.com',
+                    'sslproxy'      => null,
+                    'loginhttps'    => null,
+                    'cookiesecure'  => true,
+                ),
+                'secure' => false,
+            ),
+            array(
+                // SSL via proxy, off.
+                'config' => array(
+                    'wwwroot'       => 'http://example.com',
+                    'httpswwwroot'  => 'http://example.com',
+                    'sslproxy'      => true,
+                    'loginhttps'    => null,
+                    'cookiesecure'  => false,
+                ),
+                'secure' => false,
+            ),
+            array(
+                // SSL via proxy, on.
+                'config' => array(
+                    'wwwroot'       => 'http://example.com',
+                    'httpswwwroot'  => 'http://example.com',
+                    'sslproxy'      => true,
+                    'loginhttps'    => null,
+                    'cookiesecure'  => true,
+                ),
+                'secure' => true,
+            ),
+            array(
+                // SSL and off.
+                'config' => array(
+                    'wwwroot'       => 'https://example.com',
+                    'httpswwwroot'  => 'https://example.com',
+                    'sslproxy'      => null,
+                    'loginhttps'    => null,
+                    'cookiesecure'  => false,
+                ),
+                'secure' => false,
+            ),
+            array(
+                // SSL and on.
+                'config' => array(
+                    'wwwroot'       => 'https://example.com',
+                    'httpswwwroot'  => 'https://example.com',
+                    'sslproxy'      => null,
+                    'loginhttps'    => null,
+                    'cookiesecure'  => true,
+                ),
+                'secure' => true,
+            ),
+        );
+    }
+
+    /**
+     * Test for secure cookie
+     *
+     * @dataProvider moodle_cookie_secure_provider
+     *
+     * @param array $config Array of key value config settings
+     * @param bool $secure Wether cookies should be secure or not
+     */
+    public function test_is_moodle_cookie_secure($config, $secure) {
+
+        $this->resetAfterTest();
+        foreach ($config as $key => $value) {
+            set_config($key, $value);
+        }
+        $this->assertEquals($secure, is_moodle_cookie_secure());
+    }
+
     public function test_sesskey() {
         global $USER;
         $this->resetAfterTest();
index aa7a251..66089a4 100644 (file)
@@ -61,7 +61,6 @@ class core_statslib_testcase extends advanced_testcase {
         core_date::set_default_server_timezone();
         $CFG->statsfirstrun           = 'all';
         $CFG->statslastdaily          = 0;
-        $CFG->statslastexecution      = 0;
 
         // Figure out the broken day start so I can figure out when to the start time should be.
         $time   = time();
@@ -74,9 +73,6 @@ class core_statslib_testcase extends advanced_testcase {
 
         $shour  = intval(($time - $stime) / (60*60));
 
-        $CFG->statsruntimestarthour   = $shour;
-        $CFG->statsruntimestartminute = 0;
-
         if ($DB->record_exists('user', array('username' => 'user1'))) {
             return;
         }
@@ -392,6 +388,22 @@ class core_statslib_testcase extends advanced_testcase {
     public function test_statslib_get_next_day_start() {
         $this->setTimezone(0);
         $this->assertEquals(1272758400, stats_get_next_day_start(1272686410));
+
+        // Try setting timezone to some place in the US.
+        $this->setTimezone('America/New_York', 'America/New_York');
+        // Then set the time for midnight before daylight savings.
+        // 1425790800 is midnight in New York (2015-03-08) Daylight saving will occur in 2 hours time.
+        // 1425873600 is midnight the next day.
+        $this->assertEquals(1425873600, stats_get_next_day_start(1425790800));
+        $this->assertEquals(23, ((1425873600 - 1425790800) / 60 ) / 60);
+        // Then set the time for midnight before daylight savings ends.
+        // 1446350400 is midnight in New York (2015-11-01) Daylight saving will finish in 2 hours time.
+        // 1446440400 is midnight the next day.
+        $this->assertEquals(1446440400, stats_get_next_day_start(1446350400));
+        $this->assertEquals(25, ((1446440400 - 1446350400) / 60 ) / 60);
+        // The next day should be normal.
+        $this->assertEquals(1446526800, stats_get_next_day_start(1446440400));
+        $this->assertEquals(24, ((1446526800 - 1446440400) / 60 ) / 60);
     }
 
     /**
index 19817df..9209e57 100644 (file)
@@ -34,75 +34,74 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_unoconv_testcase extends advanced_testcase {
 
-    /** @var $testfile1 */
-    private $testfile1 = null;
-    /** @var $testfile2 */
-    private $testfile2 = null;
-
-    public function setUp() {
-        $this->fixturepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR;
-
-        $fs = get_file_storage();
-        $filerecord = array(
-            'contextid' => context_system::instance()->id,
-            'component' => 'test',
-            'filearea' => 'unittest',
-            'itemid' => 0,
-            'filepath' => '/',
-            'filename' => 'test.html'
-        );
-        $teststring = file_get_contents($this->fixturepath . DIRECTORY_SEPARATOR . 'unoconv-source.html');
-        $this->testfile1 = $fs->create_file_from_string($filerecord, $teststring);
-
-        $filerecord = array(
-            'contextid' => context_system::instance()->id,
-            'component' => 'test',
-            'filearea' => 'unittest',
-            'itemid' => 0,
-            'filepath' => '/',
-            'filename' => 'test.docx'
-        );
-        $teststring = file_get_contents($this->fixturepath . DIRECTORY_SEPARATOR . 'unoconv-source.docx');
-        $this->testfile2 = $fs->create_file_from_string($filerecord, $teststring);
-
-        $this->resetAfterTest();
+    public function get_converted_document_provider() {
+        $fixturepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR;
+        return [
+            'HTML => PDF' => [
+                'source'            => $fixturepath . 'unoconv-source.html',
+                'sourcefilename'    => 'test.html',
+                'format'            => 'pdf',
+                'mimetype'          => 'application/pdf',
+            ],
+            'docx => PDF' => [
+                'source'            => $fixturepath . 'unoconv-source.docx',
+                'sourcefilename'    => 'test.docx',
+                'format'            => 'pdf',
+                'mimetype'          => 'application/pdf',
+            ],
+            'HTML => TXT' => [
+                'source'            => $fixturepath . 'unoconv-source.html',
+                'sourcefilename'    => 'test.html',
+                'format'            => 'txt',
+                'mimetype'          => 'text/plain',
+            ],
+            'docx => TXT' => [
+                'source'            => $fixturepath . 'unoconv-source.docx',
+                'sourcefilename'    => 'test.docx',
+                'format'            => 'txt',
+                'mimetype'          => 'text/plain',
+            ],
+        ];
     }
 
-    public function test_generate_pdf() {
+    /**
+     * @dataProvider get_converted_document_provider
+     */
+    public function test_get_converted_document($source, $sourcefilename, $format, $mimetype) {
         global $CFG;
 
         if (empty($CFG->pathtounoconv) || !file_is_executable(trim($CFG->pathtounoconv))) {
             // No conversions are possible, sorry.
             return $this->markTestSkipped();
         }
-        $fs = get_file_storage();
 
-        $result = $fs->get_converted_document($this->testfile1, 'pdf');
-        $this->assertNotFalse($result);
-        $this->assertSame($result->get_mimetype(), 'application/pdf');
-        $this->assertGreaterThan(0, $result->get_filesize());
-        $result = $fs->get_converted_document($this->testfile2, 'pdf');
-        $this->assertNotFalse($result);
-        $this->assertSame($result->get_mimetype(), 'application/pdf');
-        $this->assertGreaterThan(0, $result->get_filesize());
-    }
+        $this->resetAfterTest();
 
-    public function test_generate_markdown() {
-        global $CFG;
+        $filerecord = array(
+            'contextid' => context_system::instance()->id,
+            'component' => 'test',
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => $sourcefilename,
+        );
 
-        if (empty($CFG->pathtounoconv) || !file_is_executable(trim($CFG->pathtounoconv))) {
-            // No conversions are possible, sorry.
-            return $this->markTestSkipped();
-        }
         $fs = get_file_storage();
+        //$testfile = $fs->create_file_from_string($filerecord, file_get_contents($source));
+        $testfile = $fs->create_file_from_pathname($filerecord, $source);
 
-        $result = $fs->get_converted_document($this->testfile1, 'txt');
-        $this->assertNotFalse($result);
-        $this->assertSame($result->get_mimetype(), 'text/plain');
-        $this->assertGreaterThan(0, $result->get_filesize());
-        $result = $fs->get_converted_document($this->testfile2, 'txt');
+        $result = $fs->get_converted_document($testfile, $format);
         $this->assertNotFalse($result);
-        $this->assertSame($result->get_mimetype(), 'text/plain');
+        $this->assertSame($mimetype, $result->get_mimetype());
         $this->assertGreaterThan(0, $result->get_filesize());
+
+        // Repeat immediately with the file forcing re-generation.
+        $new = $fs->get_converted_document($testfile, $format, true);
+        $this->assertNotFalse($new);
+        $this->assertSame($mimetype, $new->get_mimetype());
+        $this->assertGreaterThan(0, $new->get_filesize());
+        $this->assertNotEquals($result->get_id(), $new->get_id());
+        // Note: We cannot compare contenthash for PDF because the PDF has a unique ID, and a creation timestamp
+        // imprinted in the file.
     }
 }
index 4f72273..10a5207 100644 (file)
@@ -52,6 +52,9 @@ information provided here is intended especially for developers.
 * All file_packer implementations now accept an additional parameter to allow a simple boolean return value instead of
   an array of individual file statuses.
 * "I set the field "field_string" to multiline:" now end with colon (:), as PyStrings is supposed to end with ":"
+* New functions to support deprecation of events have been added to the base event. See MDL-46214 for further details.
+* A new function `get_name_with_info` has been added to the base event. This function adds information about event
+  deprecations and should be used where this information is relevant.
 
 === 3.1 ===
 
diff --git a/message/classes/search/base_message.php b/message/classes/search/base_message.php
new file mode 100644 (file)
index 0000000..435bc54
--- /dev/null
@@ -0,0 +1,127 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Search area base class for messages.
+ *
+ * @package    core_message
+ * @copyright  2016 Devang Gaur
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_message\search;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/message/lib.php');
+
+/**
+ * Search area base class for messages.
+ *
+ * @package    core_message
+ * @copyright  2016 Devang Gaur
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class base_message extends \core_search\base {
+
+    /**
+     * The context levels the search area is working on.
+     * @var array
+     */
+    protected static $levels = [CONTEXT_USER];
+
+    /**
+     * Returns the document associated with this message record.
+     *
+     * @param stdClass $record
+     * @param array    $options
+     * @return \core_search\document
+     */
+    public function get_document($record, $options = array()) {
+        try {
+            $usercontext = \context_user::instance($options['user1id']);
+        } catch (\moodle_exception $ex) {
+            // Notify it as we run here as admin, we should see everything.
+            debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' .
+                    $ex->getMessage(), DEBUG_DEVELOPER);
+            return false;
+        }
+        // Prepare associative array with data from DB.
+        $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname);
+        $doc->set('title', content_to_text($record->subject, false));
+        $doc->set('itemid', $record->id);
+        $doc->set('content', content_to_text($record->smallmessage, false));
+        $doc->set('contextid', $usercontext->id);
+        $doc->set('courseid', SITEID);
+        $doc->set('owneruserid', $options['user1id']);
+        $doc->set('userid', $options['user2id']);
+        $doc->set('modified', $record->timecreated);
+
+        // Check if this document should be considered new.
+        if (isset($options['lastindexedtime']) && $options['lastindexedtime'] < $record->timecreated) {
+            // If the document was created after the last index time, it must be new.
+            $doc->set_is_new(true);
+        }
+
+        return $doc;
+    }
+
+    /**
+     * Link to the message.
+     *
+     * @param \core_search\document $doc
+     * @return \moodle_url
+     */
+    public function get_doc_url(\core_search\document $doc) {
+        $users = $this->get_current_other_users($doc);
+        $position = 'm'.$doc->get('itemid');
+        return new \moodle_url('/message/index.php', array('history' => MESSAGE_HISTORY_ALL,
+                'user1' => $users['currentuserid'], 'user2' => $users['otheruserid']), $position);
+    }
+
+    /**
+     * Link to the conversation.
+     *
+     * @param \core_search\document $doc
+     * @return \moodle_url
+     */
+    public function get_context_url(\core_search\document $doc) {
+        $users = $this->get_current_other_users($doc);
+        return new \moodle_url('/message/index.php', array('user1' => $users['currentuserid'], 'user2' => $users['otheruserid']));
+    }
+
+    /**
+     * Sorting the current(user1) and other(user2) user in the conversation.
+     *
+     * @param \core_search\document $doc
+     * @return array()
+     */
+    protected function get_current_other_users($doc) {
+        global $USER;
+
+        $users = array();
+        if (($USER->id == $doc->get('owneruserid')) || (get_class($this) === 'message_sent')) {
+            $users['currentuserid'] = $doc->get('owneruserid');
+            $users['otheruserid'] = $doc->get('userid');
+        } else {
+            $users['currentuserid'] = $doc->get('userid');
+            $users['otheruserid'] = $doc->get('owneruserid');
+        }
+
+        return $users;
+    }
+
+}
diff --git a/message/classes/search/message_received.php b/message/classes/search/message_received.php
new file mode 100644 (file)
index 0000000..f57dc9f
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Search area for received messages.
+ *
+ * @package    core_message
+ * @copyright  2016 Devang Gaur
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_message\search;
+
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Search area for received messages.
+ *
+ * @package    core_message
+ * @copyright  2016 Devang Gaur
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class message_received extends base_message {
+
+    /**
+     * Returns recordset containing message records.
+     *
+     * @param int $modifiedfrom timestamp
+     * @return \moodle_recordset
+     */
+    public function get_recordset_by_timestamp($modifiedfrom = 0) {
+        global $DB;
+
+        // We don't want to index messages received from noreply and support users.
+        $params = array('modifiedfrom' => $modifiedfrom, 'noreplyuser' => \core_user::NOREPLY_USER,
+            'supportuser' => \core_user::SUPPORT_USER);
+        return $DB->get_recordset_select('message_read', 'timecreated >= :modifiedfrom AND
+            useridto != :noreplyuser AND useridto != :supportuser', $params, 'timecreated ASC');
+    }
+
+    /**
+     * Returns the document associated with this message record.
+     *
+     * @param stdClass $record
+     * @param array    $options
+     * @return \core_search\document
+     */
+    public function get_document($record, $options = array()) {
+        return parent::get_document($record, array('user1id' => $record->useridto, 'user2id' => $record->useridfrom));
+    }
+
+    /**
+     * Whether the user can access the document or not.
+     *
+     * @param int $id The message instance id.
+     * @return int
+     */
+    public function check_access($id) {
+        global $CFG, $DB, $USER;
+
+        if (!$CFG->messaging) {
+            return \core_search\manager::ACCESS_DENIED;
+        }
+
+        $message = $DB->get_record('message_read', array('id' => $id));
+        if (!$message) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+
+        $userfrom = \core_user::get_user($message->useridfrom, 'id, deleted');
+        $userto = \core_user::get_user($message->useridto, 'id, deleted');
+
+        if (!$userfrom || !$userto || $userfrom->deleted || $userto->deleted) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+
+        if ($USER->id != $userto->id) {
+            return \core_search\manager::ACCESS_DENIED;
+        }
+
+        if ($message->timeusertodeleted != 0) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+
+        return \core_search\manager::ACCESS_GRANTED;
+    }
+
+}
diff --git a/message/classes/search/message_sent.php b/message/classes/search/message_sent.php
new file mode 100644 (file)
index 0000000..f0ab8d3
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Search area for sent messages.
+ *
+ * @package    core_message
+ * @copyright  2016 Devang Gaur
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_message\search;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Search area for sent messages.
+ *
+ * @package    core_message
+ * @copyright  2016 Devang Gaur
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class message_sent extends base_message {
+
+    /**
+     * Returns recordset containing message records.
+     *
+     * @param int $modifiedfrom timestamp
+     * @return \moodle_recordset
+     */
+    public function get_recordset_by_timestamp($modifiedfrom = 0) {
+        global $DB;
+
+        // We don't want to index messages sent by noreply and support users.
+        $params = array('modifiedfrom' => $modifiedfrom, 'noreplyuser' => \core_user::NOREPLY_USER,
+            'supportuser' => \core_user::SUPPORT_USER);
+        return $DB->get_recordset_select('message_read', 'timecreated >= :modifiedfrom AND
+            useridfrom != :noreplyuser AND useridfrom != :supportuser', $params, 'timecreated ASC');
+    }
+
+    /**
+     * Returns the document associated with this message record.
+     *
+     * @param stdClass $record
+     * @param array    $options
+     * @return \core_search\document
+     */
+    public function get_document($record, $options = array()) {
+        return parent::get_document($record, array('user1id' => $record->useridfrom, 'user2id' => $record->useridto));
+    }
+
+    /**
+     * Whether the user can access the document or not.
+     *
+     * @param int $id The message instance id.
+     * @return int
+     */
+    public function check_access($id) {
+        global $CFG, $DB, $USER;
+
+        if (!$CFG->messaging) {
+            return \core_search\manager::ACCESS_DENIED;
+        }
+
+        $message = $DB->get_record('message_read', array('id' => $id));
+        if (!$message) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+
+        $userfrom = \core_user::get_user($message->useridfrom, 'id, deleted');
+        $userto = \core_user::get_user($message->useridto, 'id, deleted');
+
+        if (!$userfrom || !$userto || $userfrom->deleted || $userto->deleted) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+
+        if ($USER->id != $userfrom->id) {
+            return \core_search\manager::ACCESS_DENIED;
+        }
+
+        if ($message->timeuserfromdeleted != 0) {
+            return \core_search\manager::ACCESS_DELETED;
+        }
+
+        return \core_search\manager::ACCESS_GRANTED;
+    }
+
+}
diff --git a/message/tests/search_test_received.php b/message/tests/search_test_received.php
new file mode 100644 (file)
index 0000000..7b41803
--- /dev/null
@@ -0,0 +1,226 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * received message global search unit tests.
+ *
+ * @package     core
+ * @copyright   2016 Devang Gaur
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
+
+/**
+ * Provides the unit tests for received messages global search.
+ *
+ * @package     core
+ * @copyright   2016 Devang Gaur
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class message_received_search_testcase extends advanced_testcase {
+
+    /**
+     * @var string Area id
+     */
+    protected $messagereceivedareaid = null;
+
+    /**
+     * Setting up the test environment
+     * @return void
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+        set_config('enableglobalsearch', true);
+
+        $this->messagereceivedareaid = \core_search\manager::generate_areaid('core_message', 'message_received');
+
+        // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this.
+        $search = testable_core_search::instance();
+    }
+
+    /**
+     * Indexing messages contents.
+     *
+     * @return void
+     */
+    public function test_message_received_indexing() {
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->messagereceivedareaid);
+        $this->assertInstanceOf('\core_message\search\message_received', $searcharea);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $this->preventResetByRollback();
+        $sink = $this->redirectMessages();
+
+        $message = new StdClass();
+        $message->userfrom = $user1;
+        $message->userto = $user2;
+        $message->subject = "Test Subject";
+        $message->smallmessage = "Test small messsage";
+        $message->fullmessage = "Test full messsage";
+        $message->fullmessageformat = 0;
+        $message->fullmessagehtml = null;
+        $message->notification = 0;
+        $message->component = "moodle";
+        $message->name = "instantmessage";
+
+        message_send($message);
+
+        $messages = $sink->get_messages();
+
+        $this->assertEquals(1, count($messages));
+
+        // All records.
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $this->assertTrue($recordset->valid());
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $this->assertInstanceOf('stdClass', $record);
+            $doc = $searcharea->get_document($record);
+            $this->assertInstanceOf('\core_search\document', $doc);
+            $nrecords++;
+        }
+        // If there would be an error/failure in the foreach above the recordset would be closed on shutdown.
+        $recordset->close();
+        $this->assertEquals(1, $nrecords);
+
+        // The +2 is to prevent race conditions.
+        $recordset = $searcharea->get_recordset_by_timestamp(time() + 2);
+
+        // No new records.
+        $this->assertFalse($recordset->valid());
+        $recordset->close();
+    }
+
+    /**
+     * Document contents.
+     *
+     * @return void
+     */
+    public function test_message_received_document() {
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->messagereceivedareaid);
+        $this->assertInstanceOf('\core_message\search\message_received', $searcharea);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $this->preventResetByRollback();
+        $sink = $this->redirectMessages();
+
+        $message = new StdClass();
+        $message->userfrom = $user1;
+        $message->userto = $user2;
+        $message->subject = "Test Subject";
+        $message->smallmessage = "Test small messsage";
+        $message->fullmessage = "Test full messsage";
+        $message->fullmessageformat = 0;
+        $message->fullmessagehtml = null;
+        $message->notification = 0;
+        $message->component = "moodle";
+        $message->name = "instantmessage";
+
+        message_send($message);
+
+        $messages = $sink->get_messages();
+        $message = $messages[0];
+
+        $doc = $searcharea->get_document($message);
+        $this->assertInstanceOf('\core_search\document', $doc);
+        $this->assertEquals($message->id, $doc->get('itemid'));
+        $this->assertEquals($this->messagereceivedareaid . '-' . $message->id, $doc->get('id'));
+        $this->assertEquals(SITEID, $doc->get('courseid'));
+        $this->assertEquals($message->useridfrom, $doc->get('userid'));
+        $this->assertEquals($message->useridto, $doc->get('owneruserid'));
+        $this->assertEquals(content_to_text($message->subject, false), $doc->get('title'));
+        $this->assertEquals(content_to_text($message->smallmessage, false), $doc->get('content'));
+    }
+
+    /**
+     * Document accesses.
+     *
+     * @return void
+     */
+    public function test_message_received_access() {
+        global $CFG;
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->messagereceivedareaid);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $this->preventResetByRollback();
+        $sink = $this->redirectMessages();
+
+        $message = new StdClass();
+        $message->userfrom = $user1;
+        $message->userto = $user2;
+        $message->subject = "Test Subject";
+        $message->smallmessage = "Test small messsage";
+        $message->fullmessage = "Test full messsage";
+        $message->fullmessageformat = 0;
+        $message->fullmessagehtml = null;
+        $message->notification = 0;
+        $message->component = "moodle";
+        $message->name = "instantmessage";
+
+        $messageid = message_send($message);
+
+        $messages = $sink->get_messages();
+        $message = $messages[0];
+
+        $this->setUser($user1);
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123));
+
+        $this->setUser($user2);
+        $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($messageid));
+
+        if ($CFG->messaging) {
+            $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($messageid));
+        } else {
+            $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+        }
+
+        message_delete_message($message, $user2->id);
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($messageid));
+
+        $this->setUser($user3);
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+
+        $this->setGuestUser();
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+
+        $this->setAdminUser();
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+
+        delete_user($user1);
+
+        $this->setUser($user2);
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($messageid));
+
+    }
+}
diff --git a/message/tests/search_test_sent.php b/message/tests/search_test_sent.php
new file mode 100644 (file)
index 0000000..9938ae1
--- /dev/null
@@ -0,0 +1,226 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Sent message global search unit tests.
+ *
+ * @package     core
+ * @copyright   2016 Devang Gaur
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
+
+/**
+ * Provides the unit tests for sent message global search.
+ *
+ * @package     core
+ * @copyright   2016 Devang Gaur
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class message_sent_search_testcase extends advanced_testcase {
+
+    /**
+     * @var string Area id
+     */
+    protected $messagesentareaid = null;
+
+    /**
+     * Setting up the test environment
+     * @return void
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+        set_config('enableglobalsearch', true);
+
+        $this->messagesentareaid = \core_search\manager::generate_areaid('core_message', 'message_sent');
+
+        // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this.
+        $search = testable_core_search::instance();
+    }
+
+    /**
+     * Indexing messages contents.
+     *
+     * @return void
+     */
+    public function test_message_sent_indexing() {
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->messagesentareaid);
+        $this->assertInstanceOf('\core_message\search\message_sent', $searcharea);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $this->preventResetByRollback();
+        $sink = $this->redirectMessages();
+
+        $message = new StdClass();
+        $message->userfrom = $user1;
+        $message->userto = $user2;
+        $message->subject = "Test Subject";
+        $message->smallmessage = "Test small messsage";
+        $message->fullmessage = "Test full messsage";
+        $message->fullmessageformat = 0;
+        $message->fullmessagehtml = null;
+        $message->notification = 0;
+        $message->component = 'moodle';
+        $message->name = "instantmessage";
+
+        message_send($message);
+
+        $messages = $sink->get_messages();
+
+        $this->assertEquals(1, count($messages));
+
+        // All records.
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $this->assertTrue($recordset->valid());
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $this->assertInstanceOf('stdClass', $record);
+            $doc = $searcharea->get_document($record);
+            $this->assertInstanceOf('\core_search\document', $doc);
+            $nrecords++;
+        }
+        // If there would be an error/failure in the foreach above the recordset would be closed on shutdown.
+        $recordset->close();
+        $this->assertEquals(1, $nrecords);
+
+        // The +2 is to prevent race conditions.
+        $recordset = $searcharea->get_recordset_by_timestamp(time() + 2);
+
+        // No new records.
+        $this->assertFalse($recordset->valid());
+        $recordset->close();
+    }
+
+    /**
+     * Document contents.
+     *
+     * @return void
+     */
+    public function test_message_sent_document() {
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->messagesentareaid);
+        $this->assertInstanceOf('\core_message\search\message_sent', $searcharea);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $this->preventResetByRollback();
+        $sink = $this->redirectMessages();
+
+        $message = new StdClass();
+        $message->userfrom = $user1;
+        $message->userto = $user2;
+        $message->subject = "Test Subject";
+        $message->smallmessage = "Test small messsage";
+        $message->fullmessage = "Test full messsage";
+        $message->fullmessageformat = 0;
+        $message->fullmessagehtml = null;
+        $message->notification = 0;
+        $message->component = "moodle";
+        $message->name = "instantmessage";
+
+        message_send($message);
+
+        $messages = $sink->get_messages();
+        $message = $messages[0];
+
+        $doc = $searcharea->get_document($message);
+        $this->assertInstanceOf('\core_search\document', $doc);
+        $this->assertEquals($message->id, $doc->get('itemid'));
+        $this->assertEquals($this->messagesentareaid . '-' . $message->id, $doc->get('id'));
+        $this->assertEquals(SITEID, $doc->get('courseid'));
+        $this->assertEquals($message->useridfrom, $doc->get('owneruserid'));
+        $this->assertEquals($message->useridto, $doc->get('userid'));
+        $this->assertEquals(content_to_text($message->subject, false), $doc->get('title'));
+        $this->assertEquals(content_to_text($message->smallmessage, false), $doc->get('content'));
+    }
+
+    /**
+     * Document accesses.
+     *
+     * @return void
+     */
+    public function test_message_sent_access() {
+        global $CFG;
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->messagesentareaid);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $this->preventResetByRollback();
+        $sink = $this->redirectMessages();
+
+        $message = new StdClass();
+        $message->userfrom = $user1;
+        $message->userto = $user2;
+        $message->subject = "Test Subject";
+        $message->smallmessage = "Test small messsage";
+        $message->fullmessage = "Test full messsage";
+        $message->fullmessageformat = 0;
+        $message->fullmessagehtml = null;
+        $message->notification = 0;
+        $message->component = "moodle";
+        $message->name = "instantmessage";
+
+        $messageid = message_send($message);
+
+        $messages = $sink->get_messages();
+        $message = $messages[0];
+
+        $this->setUser($user1);
+
+        if ($CFG->messaging) {
+            $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($messageid));
+        } else {
+            $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+        }
+
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access(-123));
+
+        message_delete_message($message, $user1->id);
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($messageid));
+
+        $this->setUser($user2);
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+
+        $this->setUser($user3);
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+
+        $this->setGuestUser();
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+
+        $this->setAdminUser();
+        $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($messageid));
+
+        delete_user($user2);
+
+        $this->setUser($user1);
+        $this->assertEquals(\core_search\manager::ACCESS_DELETED, $searcharea->check_access($messageid));
+
+    }
+}
\ No newline at end of file
index 8b589ac..4d861e5 100644 (file)
@@ -45,6 +45,22 @@ Feature: Check that the assignment grade can be rescaled when the max grade is c
     And I follow "View all submissions"
     Then "Student 1" row "Grade" column of "generaltable" table should contain "40.00"
 
+  Scenario: Update an assignment without touching the max grades
+    Given I follow "Edit settings"
+    And I expand all fieldsets
+    And I set the field "Rescale existing grades" to "No"
+    And I set the field "Maximum grade" to "80"
+    And I press "Save and display"
+    And I follow "Edit settings"
+    And I press "Save and display"
+    And I follow "Edit settings"
+    And I expand all fieldsets
+    And I set the field "Rescale existing grades" to "Yes"
+    And I set the field "Maximum grade" to "80"
+    When I press "Save and display"
+    And I follow "View all submissions"
+    Then "Student 1" row "Grade" column of "generaltable" table should contain "40.00"
+
   Scenario: Update the max grade for an assignment rescaling existing grades
     Given I follow "Edit settings"
     And I expand all fieldsets
diff --git a/mod/choice/classes/event/answer_created.php b/mod/choice/classes/event/answer_created.php
new file mode 100644 (file)
index 0000000..c0f0e3e
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The mod_choice answer created event.
+ *
+ * @package    mod_choice
+ * @copyright  2016 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_choice\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The mod_choice answer created event class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - int choiceid: id of choice.
+ *      - int optionid: id of the option.
+ * }
+ *
+ * @package    mod_choice
+ * @since      Moodle 3.2
+ * @copyright  2016 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class answer_created extends \core\event\base {
+
+    /**
+     * Creates an instance of the event from the records
+     *
+     * @param stdClass $choiceanswer record from 'choice_answers' table
+     * @param stdClass $choice record from 'choice' table
+     * @param stdClass $cm record from 'course_modules' table
+     * @param stdClass $course
+     * @return self
+     */
+    public static function create_from_object($choiceanswer, $choice, $cm, $course) {
+        global $USER;
+        $eventdata = array();
+        $eventdata['objectid'] = $choiceanswer->id;
+        $eventdata['context'] = \context_module::instance($cm->id);
+        $eventdata['userid'] = $USER->id;
+        $eventdata['courseid'] = $course->id;
+        $eventdata['relateduserid'] = $choiceanswer->userid;
+        $eventdata['other'] = array();
+        $eventdata['other']['choiceid'] = $choice->id;
+        $eventdata['other']['optionid'] = $choiceanswer->optionid;
+        $event = self::create($eventdata);
+        $event->add_record_snapshot('course', $course);
+        $event->add_record_snapshot('course_modules', $cm);
+        $event->add_record_snapshot('choice', $choice);
+        $event->add_record_snapshot('choice_answers', $choiceanswer);
+        return $event;
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' has added the option with id '" . $this->other['optionid'] . "' for the
+            user with id '$this->relateduserid' from the choice activity with course module id '$this->contextinstanceid'.";
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventanswercreated', 'mod_choice');
+    }
+
+    /**
+     * Get URL related to the action
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/mod/choice/view.php', array('id' => $this->contextinstanceid));
+    }
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'choice_answers';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['choiceid'])) {
+            throw new \coding_exception('The \'choiceid\' value must be set in other.');
+        }
+
+        if (!isset($this->other['optionid'])) {
+            throw new \coding_exception('The \'optionid\' value must be set in other.');
+        }
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to it's new value in the new course.
+     *
+     * @return string the name of the restore mapping the objectid links to
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'choice_answers', 'restore' => 'answer');
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the information in 'other' to it's new value in the new course.
+     *
+     * @return array an array of other values and their corresponding mapping
+     */
+    public static function get_other_mapping() {
+        $othermapped = array();
+        $othermapped['choiceid'] = array('db' => 'choice', 'restore' => 'choice');
+        $othermapped['optionid'] = array('db' => 'choice_options', 'restore' => 'choice_option');
+
+        return $othermapped;
+    }
+}
index 7b8cb18..29f38d9 100644 (file)
@@ -27,7 +27,7 @@ namespace mod_choice\event;
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * The mod_choice answer updated event class.
+ * The mod_choice answer deleted event class.
  *
  * @property-read array $other {
  *      Extra information about event.
@@ -43,6 +43,34 @@ defined('MOODLE_INTERNAL') || die();
  */
 class answer_deleted extends \core\event\base {
 
+    /**
+     * Creates an instance of the event from the records
+     *
+     * @param stdClass $choiceanswer record from 'choice_answers' table
+     * @param stdClass $choice record from 'choice' table
+     * @param stdClass $cm record from 'course_modules' table
+     * @param stdClass $course
+     * @return self
+     */
+    public static function create_from_object($choiceanswer, $choice, $cm, $course) {
+        global $USER;
+        $eventdata = array();
+        $eventdata['objectid'] = $choiceanswer->id;
+        $eventdata['context'] = \context_module::instance($cm->id);
+        $eventdata['userid'] = $USER->id;
+        $eventdata['courseid'] = $course->id;
+        $eventdata['relateduserid'] = $choiceanswer->userid;
+        $eventdata['other'] = array();
+        $eventdata['other']['choiceid'] = $choice->id;
+        $eventdata['other']['optionid'] = $choiceanswer->optionid;
+        $event = self::create($eventdata);
+        $event->add_record_snapshot('course', $course);
+        $event->add_record_snapshot('course_modules', $cm);
+        $event->add_record_snapshot('choice', $choice);
+        $event->add_record_snapshot('choice_answers', $choiceanswer);
+        return $event;
+    }
+
     /**
      * Returns description of what happened.
      *
index baa6817..e4c051d 100644 (file)
@@ -29,6 +29,13 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * The mod_choice answer submitted event class.
  *
+ * This event is deprecated in Moodle 3.2, it can no longer be triggered, do not
+ * write event observers for it. This event can only be initiated during
+ * restore from previous Moodle versions and appear in the logs.
+ *
+ * Event observers should listen to mod_choice\event\answer_created instead that
+ * will be triggered once for each option selected
+ *
  * @property-read array $other {
  *      Extra information about event.
  *
@@ -36,6 +43,7 @@ defined('MOODLE_INTERNAL') || die();
  *      - int optionid: (optional) id of option.
  * }
  *
+ * @deprecated since 3.2
  * @package    mod_choice
  * @since      Moodle 2.6
  * @copyright  2013 Adrian Greeve <adrian@moodle.com>
@@ -75,7 +83,7 @@ class answer_submitted extends \core\event\base {
      * @return string
      */
     public static function get_name() {
-        return get_string('eventanswercreated', 'mod_choice');
+        return get_string('eventanswersubmitted', 'mod_choice');
     }
 
     /**
@@ -111,6 +119,10 @@ class answer_submitted extends \core\event\base {
     protected function validate_data() {
         parent::validate_data();
 
+        debugging('Event \\mod_choice\event\\answer_submitted should not be used '
+                . 'any more for triggering new events and can only be initiated during restore. '
+                . 'For new events please use \\mod_choice\\event\\answer_created', DEBUG_DEVELOPER);
+
         if (!isset($this->other['choiceid'])) {
             throw new \coding_exception('The \'choiceid\' value must be set in other.');
         }
@@ -133,4 +145,8 @@ class answer_submitted extends \core\event\base {
 
         return $othermapped;
     }
+
+    public static function is_deprecated() {
+        return true;
+    }
 }
index e6ab08a..f332962 100644 (file)
@@ -29,6 +29,14 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * The mod_choice answer updated event class.
  *
+ * This event is deprecated in Moodle 3.2, it can no longer be triggered, do not
+ * write event observers for it. This event can only be initiated during
+ * restore from previous Moodle versions and appear in the logs.
+ *
+ * Event observers should listen to mod_choice\event\answer_created and
+ * mod_choice\event\answer_deleted instead, these events will be triggered for
+ * each option that was user has selected or unselected
+ *
  * @property-read array $other {
  *      Extra information about event.
  *
@@ -36,6 +44,7 @@ defined('MOODLE_INTERNAL') || die();
  *      - int optionid: (optional) id of option.
  * }
  *
+ * @deprecated since 3.2
  * @package    mod_choice
  * @since      Moodle 2.6
  * @copyright  2013 Adrian Greeve <adrian@moodle.com>
@@ -111,6 +120,11 @@ class answer_updated extends \core\event\base {
     protected function validate_data() {
         parent::validate_data();
 
+        debugging('Event \\mod_choice\event\\answer_updated should not be used '
+                . 'any more for triggering new events and can only be initiated during restore. '
+                . 'For new events please use \\mod_choice\\event\\answer_created '
+                . 'and  \\mod_choice\\event\\answer_deleted', DEBUG_DEVELOPER);
+
         if (!isset($this->other['choiceid'])) {
             throw new \coding_exception('The \'choiceid\' value must be set in other.');
         }
@@ -133,4 +147,8 @@ class answer_updated extends \core\event\base {
 
         return $othermapped;
     }
+
+    public static function is_deprecated() {
+        return true;
+    }
 }
index dcd01e5..e877076 100644 (file)
@@ -35,8 +35,9 @@ $string['completionsubmit'] = 'Show as complete when user makes a choice';
 $string['displayhorizontal'] = 'Display horizontally';
 $string['displaymode'] = 'Display mode for the options';
 $string['displayvertical'] = 'Display vertically';
-$string['eventanswercreated'] = 'Choice made';
+$string['eventanswercreated'] = 'Choice answer added';
 $string['eventanswerdeleted'] = 'Choice answer deleted';
+$string['eventanswersubmitted'] = 'Choice made';
 $string['eventanswerupdated'] = 'Choice updated';
 $string['eventreportdownloaded'] = 'Choice report downloaded';
 $string['eventreportviewed'] = 'Choice report viewed';
@@ -48,7 +49,7 @@ $string['choice'] = 'Choice';
 $string['choiceactivityname'] = 'Choice: {$a}';
 $string['choice:addinstance'] = 'Add a new choice';
 $string['choiceclose'] = 'Allow responses until';
-$string['choice:deleteresponses'] = 'Delete responses';
+$string['choice:deleteresponses'] = 'Modify and delete responses';
 $string['choice:downloadresponses'] = 'Download responses';
 $string['choicefull'] = 'This choice is full and there are no available places.';
 $string['choice:choose'] = 'Record a choice';
@@ -67,6 +68,7 @@ $string['choicesaved'] = 'Your choice has been saved';
 $string['choicetext'] = 'Choice text';
 $string['choice:view'] = 'View choice activity';
 $string['chooseaction'] = 'Choose an action ...';
+$string['chooseoption'] = 'Choose: {$a}';
 $string['description'] = 'Description';
 $string['includeinactive'] = 'Include responses from inactive/suspended users';
 $string['limit'] = 'Limit';
index d7a00b0..5f9ded4 100644 (file)
@@ -244,6 +244,61 @@ function choice_prepare_options($choice, $user, $coursemodule, $allresponses) {
     return $cdisplay;
 }
 
+/**
+ * Modifies responses of other users adding the option $newoptionid to them
+ *
+ * @param array $userids list of users to add option to (must be users without any answers yet)
+ * @param array $answerids list of existing attempt ids of users (will be either appended or
+ *      substituted with the newoptionid, depending on $choice->allowmultiple)
+ * @param int $newoptionid
+ * @param stdClass $choice choice object, result of {@link choice_get_choice()}
+ * @param stdClass $cm
+ * @param stdClass $course
+ */
+function choice_modify_responses($userids, $answerids, $newoptionid, $choice, $cm, $course) {
+    // Get all existing responses and the list of non-respondents.
+    $groupmode = groups_get_activity_groupmode($cm);
+    $onlyactive = $choice->includeinactive ? false : true;
+    $allresponses = choice_get_response_data($choice, $cm, $groupmode, $onlyactive);
+
+    // Check that the option value is valid.
+    if (!$newoptionid || !isset($choice->option[$newoptionid])) {
+        return;
+    }
+
+    // First add responses for users who did not make any choice yet.
+    foreach ($userids as $userid) {
+        if (isset($allresponses[0][$userid])) {
+            choice_user_submit_response($newoptionid, $choice, $userid, $course, $cm);
+        }
+    }
+
+    // Create the list of all options already selected by each user.
+    $optionsbyuser = []; // Mapping userid=>array of chosen choice options.
+    $usersbyanswer = []; // Mapping answerid=>userid (which answer belongs to each user).
+    foreach ($allresponses as $optionid => $responses) {
+        if ($optionid > 0) {
+            foreach ($responses as $userid => $userresponse) {
+                $optionsbyuser += [$userid => []];
+                $optionsbyuser[$userid][] = $optionid;
+                $usersbyanswer[$userresponse->answerid] = $userid;
+            }
+        }
+    }
+
+    // Go through the list of submitted attemptids and find which users answers need to be updated.
+    foreach ($answerids as $answerid) {
+        if (isset($usersbyanswer[$answerid])) {
+            $userid = $usersbyanswer[$answerid];
+            if (!in_array($newoptionid, $optionsbyuser[$userid])) {
+                $options = $choice->allowmultiple ?
+                        array_merge($optionsbyuser[$userid], [$newoptionid]) : $newoptionid;
+                choice_user_submit_response($options, $choice, $userid, $course, $cm);
+            }
+        }
+    }
+}
+
 /**
  * Process user submitted answers for a choice,
  * and either updating them or saving new answers.
@@ -256,7 +311,7 @@ function choice_prepare_options($choice, $user, $coursemodule, $allresponses) {
  * @return void
  */
 function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm) {
-    global $DB, $CFG;
+    global $DB, $CFG, $USER;
     require_once($CFG->libdir.'/completionlib.php');
 
     $continueurl = new moodle_url('/mod/choice/view.php', array('id' => $cm->id));
@@ -349,8 +404,9 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
     }
 
     // Check the user hasn't exceeded the maximum selections for the choice(s) they have selected.
+    $answersnapshots = array();
+    $deletedanswersnapshots = array();
     if (!($choice->limitanswers && $choicesexceeded)) {
-        $answersnapshots = array();
         if ($current) {
             // Update an existing answer.
             $existingchoices = array();
@@ -358,8 +414,8 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
                 if (in_array($c->optionid, $formanswers)) {
                     $existingchoices[] = $c->optionid;
                     $DB->set_field('choice_answers', 'timemodified', time(), array('id' => $c->id));
-                    $answersnapshots[] = $c;
                 } else {
+                    $deletedanswersnapshots[] = $c;
                     $DB->delete_records('choice_answers', array('id' => $c->id));
                 }
             }
@@ -376,9 +432,6 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
                     $answersnapshots[] = $newanswer;
                 }
             }
-
-            // Initialised as true, meaning we updated the answer.
-            $answerupdated = true;
         } else {
             // Add new answer.
             foreach ($formanswers as $answer) {
@@ -396,9 +449,6 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
             if ($completion->is_enabled($cm) && $choice->completionsubmit) {
                 $completion->update_state($cm, COMPLETION_COMPLETE);
             }
-
-            // Initalised as false, meaning we submitted a new answer.
-            $answerupdated = false;
         }
     } else {
         // Check to see if current choice already selected - if not display error.
@@ -416,30 +466,12 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
         $choicelock->release();
     }
 
-    // Now record completed event.
-    if (isset($answerupdated)) {
-        $eventdata = array();
-        $eventdata['context'] = $context;
-        $eventdata['objectid'] = $choice->id;
-        $eventdata['userid'] = $userid;
-        $eventdata['courseid'] = $course->id;
-        $eventdata['other'] = array();
-        $eventdata['other']['choiceid'] = $choice->id;
-
-        if ($answerupdated) {
-            $eventdata['other']['optionid'] = $formanswer;
-            $event = \mod_choice\event\answer_updated::create($eventdata);
-        } else {
-            $eventdata['other']['optionid'] = $formanswers;
-            $event = \mod_choice\event\answer_submitted::create($eventdata);
-        }
-        $event->add_record_snapshot('course', $course);
-        $event->add_record_snapshot('course_modules', $cm);
-        $event->add_record_snapshot('choice', $choice);
-        foreach ($answersnapshots as $record) {
-            $event->add_record_snapshot('choice_answers', $record);
-        }
-        $event->trigger();
+    // Trigger events.
+    foreach ($deletedanswersnapshots as $answer) {
+        \mod_choice\event\answer_deleted::create_from_object($answer, $choice, $cm, $course)->trigger();
+    }
+    foreach ($answersnapshots as $answer) {
+        \mod_choice\event\answer_created::create_from_object($answer, $choice, $cm, $course)->trigger();
     }
 }
 
@@ -479,6 +511,17 @@ function prepare_choice_show_results($choice, $course, $cm, $allresponses) {
     $display->coursemoduleid = $cm->id;
     $display->courseid = $course->id;
 
+    if (!empty($choice->showunanswered)) {
+        $choice->option[0] = get_string('notanswered', 'choice');
+        $choice->maxanswers[0] = 0;
+    }
+
+    // Remove from the list of non-respondents the users who do not have access to this activity.
+    if (!empty($display->showunanswered) && $allresponses[0]) {
+        $info = new \core_availability\info_module(cm_info::create($cm));
+        $allresponses[0] = $info->filter_user_list($allresponses[0]);
+    }
+
     //overwrite options value;
     $display->options = array();
     $allusers = [];
@@ -531,27 +574,11 @@ function choice_delete_responses($attemptids, $choice, $cm, $course) {
         }
     }
 
-    $context = context_module::instance($cm->id);
     $completion = new completion_info($course);
     foreach($attemptids as $attemptid) {
         if ($todelete = $DB->get_record('choice_answers', array('choiceid' => $choice->id, 'id' => $attemptid))) {
             // Trigger the event answer deleted.
-            $eventdata = array();
-            $eventdata['objectid'] = $todelete->id;
-            $eventdata['context'] = $context;
-            $eventdata['userid'] = $USER->id;
-            $eventdata['courseid'] = $course->id;
-            $eventdata['relateduserid'] = $todelete->userid;
-            $eventdata['other'] = array();
-            $eventdata['other']['choiceid'] = $choice->id;
-            $eventdata['other']['optionid'] = $todelete->optionid;
-            $event = \mod_choice\event\answer_deleted::create($eventdata);
-            $event->add_record_snapshot('course', $course);
-            $event->add_record_snapshot('course_modules', $cm);
-            $event->add_record_snapshot('choice', $choice);
-            $event->add_record_snapshot('choice_answers', $todelete);
-            $event->trigger();
-
+            \mod_choice\event\answer_deleted::create_from_object($todelete, $choice, $cm, $course)->trigger();
             $DB->delete_records('choice_answers', array('choiceid' => $choice->id, 'id' => $attemptid));
         }
     }
index f40f331..48f31ba 100644 (file)
@@ -171,7 +171,7 @@ class mod_choice_renderer extends plugin_renderer_base {
         $usernumberheader->text = get_string('numberofuser', 'choice');
         $columns['usernumber'][] = $usernumberheader;
 
-
+        $optionsnames = [];
         foreach ($choices->options as $optionid => $options) {
             $celloption = clone($celldefault);
             $cellusernumber = clone($celldefault);
@@ -179,7 +179,7 @@ class mod_choice_renderer extends plugin_renderer_base {
 
             $celltext = '';
             if ($choices->showunanswered && $optionid == 0) {
-                $celltext = format_string(get_string('notanswered', 'choice'));
+                $celltext = get_string('notanswered', 'choice');
             } else if ($optionid > 0) {
                 $celltext = format_string($choices->options[$optionid]->text);
             }
@@ -189,6 +189,7 @@ class mod_choice_renderer extends plugin_renderer_base {
             }
 
             $celloption->text = $celltext;
+            $optionsnames[$optionid] = $celltext;
             $cellusernumber->text = $numberofuser;
 
             $columns['options'][] = $celloption;
@@ -222,10 +223,17 @@ class mod_choice_renderer extends plugin_renderer_base {
                         }
 
                         $userfullname = fullname($user, $choices->fullnamecapability);
-                        if ($choices->viewresponsecapability && $choices->deleterepsonsecapability  && $optionid > 0) {
-                            $attemptaction = html_writer::label($userfullname, 'attempt-user'.$user->id, false, array('class' => 'accesshide'));
-                            $attemptaction .= html_writer::checkbox('attemptid[]', $user->answerid, '', null,
-                                    array('id' => 'attempt-user'.$user->id));
+                        if ($choices->viewresponsecapability && $choices->deleterepsonsecapability) {
+                            $checkboxid = 'attempt-user'.$user->id.'-option'.$optionid;
+                            $attemptaction = html_writer::label($userfullname . ' ' . $optionsnames[$optionid],
+                                    $checkboxid, false, array('class' => 'accesshide'));
+                            if ($optionid > 0) {
+                                $attemptaction .= html_writer::checkbox('attemptid[]', $user->answerid, '', null,
+                                    array('id' => $checkboxid));
+                            } else {
+                                $attemptaction .= html_writer::checkbox('userid[]', $user->id, '', null,
+                                    array('id' => $checkboxid));
+                            }
                             $data .= html_writer::tag('div', $attemptaction, array('class'=>'attemptaction'));
                         }
                         $userimage = $this->output->user_picture($user, array('courseid'=>$choices->courseid));
@@ -252,6 +260,7 @@ class mod_choice_renderer extends plugin_renderer_base {
         if ($choices->viewresponsecapability && $choices->deleterepsonsecapability) {
             $selecturl = new moodle_url('#');
 
+            $actiondata .= html_writer::start_div('selectallnone');
             $selectallactions = new component_action('click',"checkall");
             $selectall = new action_link($selecturl, get_string('selectall'), $selectallactions);
             $actiondata .= $this->output->render($selectall) . ' / ';
@@ -260,11 +269,18 @@ class mod_choice_renderer extends plugin_renderer_base {
             $deselectall = new action_link($selecturl, get_string('deselectall'), $deselectallactions);
             $actiondata .= $this->output->render($deselectall);
 
-            $actiondata .= html_writer::tag('label', ' ' . get_string('withselected', 'choice') . ' ', array('for'=>'menuaction'));
+            $actiondata .= html_writer::end_div();
 
             $actionurl = new moodle_url($PAGE->url, array('sesskey'=>sesskey(), 'action'=>'delete_confirmation()'));
-            $select = new single_select($actionurl, 'action', array('delete'=>get_string('delete')), null, array(''=>get_string('chooseaction', 'choice')), 'attemptsform');
-
+            $actionoptions = array('delete' => get_string('delete'));
+            foreach ($choices->options as $optionid => $option) {
+                if ($optionid > 0) {
+                    $actionoptions['choose_'.$optionid] = get_string('chooseoption', 'choice', $option->text);
+                }
+            }
+            $select = new single_select($actionurl, 'action', $actionoptions, null,
+                    array('' => get_string('chooseaction', 'choice')), 'attemptsform');
+            $select->set_label(get_string('withselected', 'choice'));
             $actiondata .= $this->output->render($select);
         }
         $html .= html_writer::tag('div', $actiondata, array('class'=>'responseaction'));
index 59ac1b6..3fcde42 100644 (file)
@@ -4,15 +4,12 @@
     require_once("lib.php");
 
     $id         = required_param('id', PARAM_INT);   //moduleid
-    $format     = optional_param('format', CHOICE_PUBLISH_NAMES, PARAM_INT);
     $download   = optional_param('download', '', PARAM_ALPHA);
-    $action     = optional_param('action', '', PARAM_ALPHA);
-    $attemptids = optional_param_array('attemptid', array(), PARAM_INT); //get array of responses to delete.
+    $action     = optional_param('action', '', PARAM_ALPHANUMEXT);
+    $attemptids = optional_param_array('attemptid', array(), PARAM_INT); // Get array of responses to delete or modify.
+    $userids    = optional_param_array('userid', array(), PARAM_INT); // Get array of users whose choices need to be modified.
 
     $url = new moodle_url('/mod/choice/report.php', array('id'=>$id));
-    if ($format !== CHOICE_PUBLISH_NAMES) {
-        $url->param('format', $format);
-    }
     if ($download !== '') {
         $url->param('download', $download);
     }
     $event = \mod_choice\event\report_viewed::create($eventdata);
     $event->trigger();
 
-    if (data_submitted() && $action == 'delete' && has_capability('mod/choice:deleteresponses',$context) && confirm_sesskey()) {
-        choice_delete_responses($attemptids, $choice, $cm, $course); //delete responses.
-        redirect("report.php?id=$cm->id");
+    if (data_submitted() && has_capability('mod/choice:deleteresponses', $context) && confirm_sesskey()) {
+        if ($action === 'delete') {
+            // Delete responses of other users.
+            choice_delete_responses($attemptids, $choice, $cm, $course);
+            redirect("report.php?id=$cm->id");
+        }
+        if (preg_match('/^choose_(\d+)$/', $action, $actionmatch)) {
+            // Modify responses of other users.
+            $newoptionid = (int)$actionmatch[1];
+            choice_modify_responses($userids, $attemptids, $newoptionid, $choice, $cm, $course);
+            redirect("report.php?id=$cm->id");
+        }
     }
 
     if (!$download) {
         }
         exit;
     }
-    // Show those who haven't answered the question.
-    if (!empty($choice->showunanswered)) {
-        $choice->option[0] = get_string('notanswered', 'choice');
-        $choice->maxanswers[0] = 0;
-    }
-
+    // Always show those who haven't answered the question.
+    $choice->showunanswered = 1;
     $results = prepare_choice_show_results($choice, $course, $cm, $users);
     $renderer = $PAGE->get_renderer('mod_choice');
-    echo $renderer->display_result($results, has_capability('mod/choice:readresponses', $context));
+    echo $renderer->display_result($results, true);
 
    //now give links for downloading spreadsheets.
     if (!empty($users) && has_capability('mod/choice:downloadresponses',$context)) {
index 2de72fa..373f27d 100644 (file)
 }
 
 .path-mod-choice .anonymous,
+
 .path-mod-choice .names {
   margin-left: auto;
   margin-right: auto;
-  width: 80%;
+  width: 100%;
 }
 
 .path-mod-choice .downloadreport {
diff --git a/mod/choice/tests/behat/modify_choice.feature b/mod/choice/tests/behat/modify_choice.feature
new file mode 100644 (file)
index 0000000..9441b8b
--- /dev/null
@@ -0,0 +1,127 @@
+@mod @mod_choice
+Feature: Teacher can modify choices of the students
+  In order to have all students choices
+  As a teacher
+  I need to be able to make choice for studnets
+
+  Background:
+    Given 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 "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | student3 | C1 | student |
+    And the following "activities" exist:
+      | activity | name        | intro                   | course | idnumber | option |
+      | choice   | Choice name | Test choice description | C1     | choice1  | Option 1, Option 2, Option 3 |
+
+  @javascript
+  Scenario: Delete students choice response as a teacher
+    When I log in as "student1"
+    And I follow "Course 1"
+    And I choose "Option 1" from "Choice name" choice activity
+    Then I should see "Your selection: Option 1"
+    And I should see "Your choice has been saved"
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    And I follow "View 1 responses"
+    And I click on "Student 1 Option 1" "checkbox"
+    And I select "Delete" from the "With selected" singleselect
+    And "Student 1 Option 1" "checkbox" should not exist
+    And "Student 1 Not answered yet" "checkbox" should exist
+    And I log out
+
+  @javascript
+  Scenario: Teacher set answers of students who did not respond or change existing answers
+    When I log in as "student1"
+    And I follow "Course 1"
+    And I choose "Option 1" from "Choice name" choice activity
+    Then I should see "Your selection: Option 1"
+    And I should see "Your choice has been saved"
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    And I follow "View 1 responses"
+    And I click on "Student 1 Option 1" "checkbox"
+    And I click on "Student 2 Not answered yet" "checkbox"
+    And I click on "Student 3 Not answered yet" "checkbox"
+    And I select "Choose: Option 2" from the "With selected" singleselect
+    And "Student 1 Option 1" "checkbox" should not exist
+    And "Student 2 Not answered yet" "checkbox" should not exist
+    And "Student 3 Not answered yet" "checkbox" should not exist
+    And "Student 1 Option 2" "checkbox" should exist
+    And "Student 2 Option 2" "checkbox" should exist
+    And "Student 3 Option 2" "checkbox" should exist
+    And I log out
+
+  @javascript
+  Scenario: Teacher can delete answers in the multiple answer choice
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    And I follow "Edit settings"
+    And I set the field "Allow more than one choice to be selected" to "Yes"
+    And I press "Save and return to course"
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I choose options "Option 1","Option 2" from "Choice name" choice activity
+    And I should see "Your selection: Option 1; Option 2"
+    And I should see "Your choice has been saved"
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    And I follow "View 1 responses"
+    And I click on "Student 1 Option 2" "checkbox"
+    And I select "Delete" from the "With selected" singleselect
+    And I click on "Student 1 Option 1" "checkbox"
+    And I select "Choose: Option 3" from the "With selected" singleselect
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    And I should see "Your selection: Option 1; Option 3"
+    And I log out
+
+  @javascript
+  Scenario: Teacher can manage answers on view page if the names are displayed
+    When I log in as "student1"
+    And I follow "Course 1"
+    And I choose "Option 1" from "Choice name" choice activity
+    Then I should see "Your selection: Option 1"
+    And I should see "Your choice has been saved"
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Choice name"
+    And I follow "Edit settings"
+    And I set the following fields to these values:
+      | Publish results | Always show results to students |
+      | Privacy of results | Publish full results, showing names and their choices |
+      | Show column for unanswered | Yes |
+    And I press "Save and display"
+    And I click on "Student 1 Option 1" "checkbox"
+    And I click on "Student 2 Not answered yet" "checkbox"
+    And I select "Choose: Option 3" from the "With selected" singleselect
+    And "Student 1 Option 1" "checkbox" should not exist
+    And "Student 1 Option 3" "checkbox" should exist
+    And "Student 2 Not answered yet" "checkbox" should not exist
+    And "Student 2 Option 3" "checkbox" should exist
+    And I click on "Student 1 Option 3" "checkbox"
+    And I select "Delete" from the "With selected" singleselect
+    And "Student 1 Option 3" "checkbox" should not exist
+    And "Student 1 Not answered yet" "checkbox" should exist
+    And I log out
index febe209..41fb362 100644 (file)
@@ -64,26 +64,56 @@ class mod_choice_events_testcase extends advanced_testcase {
     /**
      * Test to ensure that event data is being stored correctly.
      */
-    public function test_answer_submitted() {
+    public function test_answer_created() {
         global $DB;
         // Generate user data.
         $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
 
         $optionids = array_keys($DB->get_records('choice_options', array('choiceid' => $this->choice->id)));
         // Redirect event.
         $sink = $this->redirectEvents();
         choice_user_submit_response($optionids[3], $this->choice, $user->id, $this->course, $this->cm);
         $events = $sink->get_events();
+        $answer = $DB->get_record('choice_answers', ['userid' => $user->id, 'choiceid' => $this->choice->id]);
 
         // Data checking.
         $this->assertCount(1, $events);
-        $this->assertInstanceOf('\mod_choice\event\answer_submitted', $events[0]);
+        $this->assertInstanceOf('\mod_choice\event\answer_created', $events[0]);
         $this->assertEquals($user->id, $events[0]->userid);
+        $this->assertEquals($user->id, $events[0]->relateduserid);
         $this->assertEquals(context_module::instance($this->choice->cmid), $events[0]->get_context());
+        $this->assertEquals($answer->id, $events[0]->objectid);
         $this->assertEquals($this->choice->id, $events[0]->other['choiceid']);
-        $this->assertEquals(array($optionids[3]), $events[0]->other['optionid']);
-        $expected = array($this->course->id, "choice", "choose", 'view.php?id=' . $this->cm->id, $this->choice->id, $this->cm->id);
-        $this->assertEventLegacyLogData($expected, $events[0]);
+        $this->assertEquals($optionids[3], $events[0]->other['optionid']);
+        $this->assertEventContextNotUsed($events[0]);
+        $sink->close();
+    }
+
+    /**
+     * Test to ensure that event data is being stored correctly.
+     */
+    public function test_answer_submitted_by_another_user() {
+        global $DB, $USER;
+        // Generate user data.
+        $user = $this->getDataGenerator()->create_user();
+
+        $optionids = array_keys($DB->get_records('choice_options', array('choiceid' => $this->choice->id)));
+        // Redirect event.
+        $sink = $this->redirectEvents();
+        choice_user_submit_response($optionids[3], $this->choice, $user->id, $this->course, $this->cm);
+        $events = $sink->get_events();
+        $answer = $DB->get_record('choice_answers', ['userid' => $user->id, 'choiceid' => $this->choice->id]);
+
+        // Data checking.
+        $this->assertCount(1, $events);
+        $this->assertInstanceOf('\mod_choice\event\answer_created', $events[0]);
+        $this->assertEquals($USER->id, $events[0]->userid);
+        $this->assertEquals($user->id, $events[0]->relateduserid);
+        $this->assertEquals(context_module::instance($this->choice->cmid), $events[0]->get_context());
+        $this->assertEquals($answer->id, $events[0]->objectid);
+        $this->assertEquals($this->choice->id, $events[0]->other['choiceid']);
+        $this->assertEquals($optionids[3], $events[0]->other['optionid']);
         $this->assertEventContextNotUsed($events[0]);
         $sink->close();
     }
@@ -91,11 +121,12 @@ class mod_choice_events_testcase extends advanced_testcase {
     /**
      * Test to ensure that multiple choice data is being stored correctly.
      */
-    public function test_answer_submitted_multiple() {
+    public function test_answer_created_multiple() {
         global $DB;
 
         // Generate user data.
         $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
 
         // Create multiple choice.
         $choice = $this->getDataGenerator()->create_module('choice', array('course' => $this->course->id,
@@ -110,17 +141,28 @@ class mod_choice_events_testcase extends advanced_testcase {
         $sink = $this->redirectEvents();
         choice_user_submit_response($submittedoptionids, $choice, $user->id, $this->course, $cm);
         $events = $sink->get_events();
+        $answers = $DB->get_records('choice_answers', ['userid' => $user->id, 'choiceid' => $choice->id], 'id');
+        $answers = array_values($answers);
 
         // Data checking.
-        $this->assertCount(1, $events);
-        $this->assertInstanceOf('\mod_choice\event\answer_submitted', $events[0]);
+        $this->assertCount(2, $events);
+        $this->assertInstanceOf('\mod_choice\event\answer_created', $events[0]);
         $this->assertEquals($user->id, $events[0]->userid);
+        $this->assertEquals($user->id, $events[0]->relateduserid);
         $this->assertEquals(context_module::instance($choice->cmid), $events[0]->get_context());
+        $this->assertEquals($answers[0]->id, $events[0]->objectid);
         $this->assertEquals($choice->id, $events[0]->other['choiceid']);
-        $this->assertEquals($submittedoptionids, $events[0]->other['optionid']);
-        $expected = array($this->course->id, "choice", "choose", 'view.php?id=' . $cm->id, $choice->id, $cm->id);
-        $this->assertEventLegacyLogData($expected, $events[0]);
+        $this->assertEquals($optionids[1], $events[0]->other['optionid']);
         $this->assertEventContextNotUsed($events[0]);
+
+        $this->assertInstanceOf('\mod_choice\event\answer_created', $events[1]);
+        $this->assertEquals($user->id, $events[1]->userid);
+        $this->assertEquals($user->id, $events[1]->relateduserid);
+        $this->assertEquals(context_module::instance($choice->cmid), $events[1]->get_context());
+        $this->assertEquals($answers[1]->id, $events[1]->objectid);
+        $this->assertEquals($choice->id, $events[1]->other['choiceid']);
+        $this->assertEquals($optionids[3], $events[1]->other['optionid']);
+        $this->assertEventContextNotUsed($events[1]);
         $sink->close();
     }
 
@@ -129,7 +171,7 @@ class mod_choice_events_testcase extends advanced_testcase {
      *
      * @expectedException coding_exception
      */
-    public function test_answer_submitted_other_exception() {
+    public function test_answer_created_other_exception() {
         // Generate user data.
         $user = $this->getDataGenerator()->create_user();
 
@@ -141,7 +183,7 @@ class mod_choice_events_testcase extends advanced_testcase {
         $eventdata['other'] = array();
 
         // Make sure content identifier is always set.
-        $event = \mod_choice\event\answer_submitted::create($eventdata);
+        $event = \mod_choice\event\answer_created::create($eventdata);
         $event->trigger();
         $this->assertEventContextNotUsed($event);
     }
@@ -153,53 +195,41 @@ class mod_choice_events_testcase extends advanced_testcase {
         global $DB;
         // Generate user data.
         $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
 
         $optionids = array_keys($DB->get_records('choice_options', array('choiceid' => $this->choice->id)));
 
         // Create the first answer.
         choice_user_submit_response($optionids[2], $this->choice, $user->id, $this->course, $this->cm);
+        $oldanswer = $DB->get_record('choice_answers', ['userid' => $user->id, 'choiceid' => $this->choice->id]);
 
         // Redirect event.
         $sink = $this->redirectEvents();
         // Now choose a different answer.
         choice_user_submit_response($optionids[3], $this->choice, $user->id, $this->course, $this->cm);
+        $newanswer = $DB->get_record('choice_answers', ['userid' => $user->id, 'choiceid' => $this->choice->id]);
 
         $events = $sink->get_events();
 
         // Data checking.
-        $this->assertCount(1, $events);
-        $this->assertInstanceOf('\mod_choice\event\answer_updated', $events[0]);
+        $this->assertCount(2, $events);
+        $this->assertInstanceOf('\mod_choice\event\answer_deleted', $events[0]);
         $this->assertEquals($user->id, $events[0]->userid);
         $this->assertEquals(context_module::instance($this->choice->cmid), $events[0]->get_context());
+        $this->assertEquals($oldanswer->id, $events[0]->objectid);
         $this->assertEquals($this->choice->id, $events[0]->other['choiceid']);
-        $this->assertEquals($optionids[3], $events[0]->other['optionid']);
-        $expected = array($this->course->id, "choice", "choose again", 'view.php?id=' . $this->cm->id,
-                $this->choice->id, $this->cm->id);
-        $this->assertEventLegacyLogData($expected, $events[0]);
+        $this->assertEquals($optionids[2], $events[0]->other['optionid']);
         $this->assertEventContextNotUsed($events[0]);
-        $sink->close();
-    }
 
-    /**
-     * Test custom validations for answer_updated event.
-     *
-     * @expectedException coding_exception
-     */
-    public function test_answer_updated_other_exception() {
-        // Generate user data.
-        $user = $this->getDataGenerator()->create_user();
+        $this->assertInstanceOf('\mod_choice\event\answer_created', $events[1]);
+        $this->assertEquals($user->id, $events[1]->userid);
+        $this->assertEquals(context_module::instance($this->choice->cmid), $events[1]->get_context());
+        $this->assertEquals($newanswer->id, $events[1]->objectid);
+        $this->assertEquals($this->choice->id, $events[1]->other['choiceid']);
+        $this->assertEquals($optionids[3], $events[1]->other['optionid']);
+        $this->assertEventContextNotUsed($events[1]);
 
-        $eventdata = array();
-        $eventdata['context'] = $this->context;
-        $eventdata['objectid'] = 2;
-        $eventdata['userid'] = $user->id;
-        $eventdata['courseid'] = $this->course->id;
-        $eventdata['other'] = array();
-
-        // Make sure content identifier is always set.
-        $event = \mod_choice\event\answer_updated::create($eventdata);
-        $event->trigger();
-        $this->assertEventContextNotUsed($event);
+        $sink->close();
     }
 
     /**
@@ -216,7 +246,7 @@ class mod_choice_events_testcase extends advanced_testcase {
         choice_user_submit_response($optionids[2], $this->choice, $user->id, $this->course, $this->cm);
         // Get the users response.
         $answer = $DB->get_record('choice_answers', array('userid' => $user->id, 'choiceid' => $this->choice->id),
-                '*', $strictness = IGNORE_MULTIPLE);
+            '*', $strictness = IGNORE_MULTIPLE);
 
         // Redirect event.
         $sink = $this->redirectEvents();
@@ -269,7 +299,7 @@ class mod_choice_events_testcase extends advanced_testcase {
         $this->assertEquals($USER->id, $event[0]->userid);
         $this->assertEquals(context_module::instance($this->choice->cmid), $event[0]->get_context());
         $expected = array($this->course->id, "choice", "report", 'report.php?id=' . $this->context->instanceid,
-                $this->choice->id, $this->context->instanceid);
+            $this->choice->id, $this->context->instanceid);
         $this->assertEventLegacyLogData($expected, $event[0]);
         $this->assertEventContextNotUsed($event[0]);
         $sink->close();
@@ -341,7 +371,7 @@ class mod_choice_events_testcase extends advanced_testcase {
         $this->assertEquals($USER->id, $event[0]->userid);
         $this->assertEquals(context_module::instance($this->choice->cmid), $event[0]->get_context());
         $expected = array($this->course->id, "choice", "view", 'view.php?id=' . $this->context->instanceid,
-                $this->choice->id, $this->context->instanceid);
+            $this->choice->id, $this->context->instanceid);
         $this->assertEventLegacyLogData($expected, $event[0]);
         $this->assertEventContextNotUsed($event[0]);
         $sink->close();
index d737e15..8327fcc 100644 (file)
@@ -47,6 +47,8 @@ class mod_choice_generator extends testing_module_generator {
             $record->option[] = 'Beer';
             $record->option[] = 'Wine';
             $record->option[] = 'Spirits';
+        } else if (!is_array($record->option)) {
+            $record->option = preg_split('/\s*,\s*/', trim($record->option), -1, PREG_SPLIT_NO_EMPTY);
         }
         return parent::create_instance($record, (array)$options);
     }
index b43dede..40d80d4 100644 (file)
@@ -1,6 +1,15 @@
 This files describes API changes in /mod/choice/*,
 information provided here is intended especially for developers.
 
+=== 3.2 ===
+
+* Events mod_choice\event\answer_submitted and mod_choice\event\answer_updated
+  are no longer triggered. Observers listening to these events must instead listen
+  to mod_choice\event\answer_created and mod_choice\event\answer_deleted that are
+  triggered for each option that is selected or unselected. User whose choice was
+  modified can be found in $event->relateduserid (this does not have to be the
+  user who performs the action).
+
 === 3.0 ===
 
 * External function mod_choice_external::get_choices_by_courses returned parameter "name" and
index 59cc9df..332de9d 100644 (file)
@@ -5,8 +5,9 @@ require_once("lib.php");
 require_once($CFG->libdir . '/completionlib.php');
 
 $id         = required_param('id', PARAM_INT);                 // Course Module ID
-$action     = optional_param('action', '', PARAM_ALPHA);
-$attemptids = optional_param_array('attemptid', array(), PARAM_INT); // array of attempt ids for delete action
+$action     = optional_param('action', '', PARAM_ALPHANUMEXT);
+$attemptids = optional_param_array('attemptid', array(), PARAM_INT); // Get array of responses to delete or modify.
+$userids    = optional_param_array('userid', array(), PARAM_INT); // Get array of users whose choices need to be modified.
 $notify     = optional_param('notify', '', PARAM_ALPHA);
 
 $url = new moodle_url('/mod/choice/view.php', array('id'=>$id));
@@ -52,12 +53,20 @@ $PAGE->set_title($choice->name);
 $PAGE->set_heading($course->fullname);
 
 /// Submit any new data if there is any
-if (data_submitted() && is_enrolled($context, NULL, 'mod/choice:choose') && confirm_sesskey()) {
+if (data_submitted() && !empty($action) && confirm_sesskey()) {
     $timenow = time();
-    if (has_capability('mod/choice:deleteresponses', $context) && $action == 'delete') {
-        //some responses need to be deleted
-        choice_delete_responses($attemptids, $choice, $cm, $course); //delete responses.
-        redirect("view.php?id=$cm->id");
+    if (has_capability('mod/choice:deleteresponses', $context)) {
+        if ($action === 'delete') {
+            // Some responses need to be deleted.
+            choice_delete_responses($attemptids, $choice, $cm, $course);
+            redirect("view.php?id=$cm->id");
+        }
+        if (preg_match('/^choose_(\d+)$/', $action, $actionmatch)) {
+            // Modify responses of other users.
+            $newoptionid = (int)$actionmatch[1];
+            choice_modify_responses($userids, $attemptids, $newoptionid, $choice, $cm, $course);
+            redirect("view.php?id=$cm->id");
+        }
     }
 
     // Redirection after all POSTs breaks block editing, we need to be more specific!
@@ -72,7 +81,7 @@ if (data_submitted() && is_enrolled($context, NULL, 'mod/choice:choose') && conf
         throw new moodle_exception($reason, 'choice', '', $warnings[$reason]);
     }
 
-    if ($answer) {
+    if ($answer && is_enrolled($context, null, 'mod/choice:choose')) {
         choice_user_submit_response($answer, $choice, $USER->id, $course, $cm);
         redirect(new moodle_url('/mod/choice/view.php',
             array('id' => $cm->id, 'notify' => 'choicesaved', 'sesskey' => sesskey())));
@@ -192,11 +201,6 @@ if (!$choiceformshown) {
 
 // print the results at the bottom of the screen
 if (choice_can_view_results($choice, $current, $choiceopen)) {
-
-    if (!empty($choice->showunanswered)) {
-        $choice->option[0] = get_string('notanswered', 'choice');
-        $choice->maxanswers[0] = 0;
-    }
     $results = prepare_choice_show_results($choice, $course, $cm, $allresponses);
     $renderer = $PAGE->get_renderer('mod_choice');
     echo $renderer->display_result($results);
index a72ebe1..e922359 100644 (file)
@@ -196,6 +196,14 @@ class data_field_latlong extends data_field_base {
         return false;
     }
 
+    function update_content_import($recordid, $value, $name='') {
+        $values = explode(" ", $value, 2);
+
+        foreach ($values as $index => $value) {
+            $this->update_content($recordid, $value, $name . '_' . $index);
+        }
+    }
+
     function update_content($recordid, $value, $name='') {
         global $DB;
 
index da87cb5..5218911 100644 (file)
@@ -159,6 +159,14 @@ class data_field_url extends data_field_base {
         return false;
     }
 
+    function update_content_import($recordid, $value, $name='') {
+        $values = explode(" ", $value, 2);
+
+        foreach ($values as $index => $value) {
+            $this->update_content($recordid, $value, $name . '_' . $index);
+        }
+    }
+
     function update_content($recordid, $value, $name='') {
         global $DB;
 
index 9ab7a26..27b1010 100644 (file)
@@ -109,12 +109,19 @@ if (!$formdata = $form->get_data()) {
         if (!$fieldnames = $cir->get_columns()) {
             print_error('cannotreadtmpfile', 'error');
         }
+        $fieldnames = array_flip($fieldnames);
         // check the fieldnames are valid
-        $fields = $DB->get_records('data_fields', array('dataid'=>$data->id), '', 'name, id, type');
+        $rawfields = $DB->get_records('data_fields', array('dataid' => $data->id), '', 'name, id, type');
+        $fields = array();
         $errorfield = '';
-        foreach ($fieldnames as $name) {
-            if (!isset($fields[$name])) {
+        foreach ($fieldnames as $name => $id) {
+            if (!isset($rawfields[$name])) {
                 $errorfield .= "'$name' ";
+            } else {
+                $field = $rawfields[$name];
+                require_once("$CFG->dirroot/mod/data/field/$field->type/field.class.php");
+                $classname = 'data_field_' . $field->type;
+                $fields[$name] = new $classname($field, $data, $cm);
             }
         }
 
@@ -126,58 +133,23 @@ if (!$formdata = $form->get_data()) {
         $recordsadded = 0;
         while ($record = $cir->next()) {
             if ($recordid = data_add_record($data, 0)) {  // add instance to data_record
-                $fields = $DB->get_records('data_fields', array('dataid'=>$data->id), '', 'name, id, type');
-
-                // Insert new data_content fields with NULL contents:
                 foreach ($fields as $field) {
-                    $content = new stdClass();
-                    $content->recordid = $recordid;
-                    $content->fieldid = $field->id;
-                    $DB->insert_record('data_content', $content);
-                }
-                // Fill data_content with the values imported from the CSV file:
-                foreach ($record as $key => $value) {
-                    $name = $fieldnames[$key];
-                    $field = $fields[$name];
-                    $content = new stdClass();
-                    $content->fieldid = $field->id;
-                    $content->recordid = $recordid;
-                    if ($field->type == 'textarea') {
-                        // the only field type where HTML is possible
-                        $value = clean_param($value, PARAM_CLEANHTML);
+                    $fieldid = $fieldnames[$field->field->name];
+                    if (isset($record[$fieldid])) {
+                        $value = $record[$fieldid];
                     } else {
-                        // remove potential HTML:
-                        $patterns[] = '/</';
-                        $replacements[] = '&lt;';
-                        $patterns[] = '/>/';
-                        $replacements[] = '&gt;';
-                        $value = preg_replace($patterns, $replacements, $value);
+                        $value = '';
                     }
-                    // for now, only for "latlong" and "url" fields, but that should better be looked up from
-                    // $CFG->dirroot . '/mod/data/field/' . $field->type . '/field.class.php'
-                    // once there is stored how many contents the field can have.
-                    if ($field->type == 'latlong') {
-                        $values = explode(" ", $value, 2);
-                        // The lat, long values might be in a different float format.
-                        $content->content  = unformat_float($values[0]);
-                        $content->content1 = unformat_float($values[1]);
-                    } else if ($field->type == 'url') {
-                        $values = explode(" ", $value, 2);
-                        $content->content  = $values[0];
-                        if (!empty($content->content) && (strpos($content->content, '://') === false)
-                                && (strpos($content->content, '/') !== 0)) {
-                            $content->content = 'http://' . $content->content;
-                        }
-                        // The url field doesn't always have two values (unforced autolinking).
-                        if (count($values) > 1) {
-                            $content->content1 = $values[1];
-                        }
+
+                    if (method_exists($field, 'update_content_import')) {
+                        $field->update_content_import($recordid, $value, 'field_' . $field->field->id);
                     } else {
+                        $content = new stdClass();
+                        $content->fieldid = $field->field->id;
                         $content->content = $value;
+                        $content->recordid = $recordid;
+                        $DB->insert_record('data_content', $content);
                     }
-                    $oldcontent = $DB->get_record('data_content', array('fieldid'=>$field->id, 'recordid'=>$recordid));
-                    $content->id = $oldcontent->id;
-                    $DB->update_record('data_content', $content);
                 }
                 $recordsadded++;
                 print get_string('added', 'moodle', $recordsadded) . ". " . get_string('entry', 'data') . " (ID $recordid)<br />\n";
diff --git a/mod/data/upgrade.txt b/mod/data/upgrade.txt
new file mode 100644 (file)
index 0000000..7d8019b
--- /dev/null
@@ -0,0 +1,7 @@
+This files describes API changes in /mod/data - plugins,
+information provided here is intended especially for developers.
+
+=== 3.2 ===
+
+* New hook - update_content_import - Can be implemented by field subplugins data_field_* class
+    This can be used to pre-process data from a csv file before it is inserted into the database.
index 3f4f8a1..099dea0 100644 (file)
@@ -119,4 +119,43 @@ class restore_lesson_activity_task extends restore_activity_task {
 
         return $rules;
     }
+
+
+    /**
+     * Re-map the dependency and activitylink information
+     * If a depency or activitylink has no mapping in the backup data then it could either be a duplication of a
+     * lesson, or a backup/restore of a single lesson. We have no way to determine which and whether this is the
+     * same site and/or course. Therefore we try and retrieve a mapping, but fallback to the original value if one
+     * was not found. We then test to see whether the value found is valid for the course being restored into.
+     */
+    public function after_restore() {
+        global $DB;
+
+        $lesson = $DB->get_record('lesson', array('id' => $this->get_activityid()), 'id, course, dependency, activitylink');
+        $updaterequired = false;
+
+        if (!empty($lesson->dependency)) {
+            $updaterequired = true;
+            if ($newitem = restore_dbops::get_backup_ids_record($this->get_restoreid(), 'lesson', $lesson->dependency)) {
+                $lesson->dependency = $newitem->newitemid;
+            }
+            if (!$DB->record_exists('lesson', array('id' => $lesson->dependency, 'course' => $lesson->course))) {
+                $lesson->dependency = 0;
+            }
+        }
+
+        if (!empty($lesson->activitylink)) {
+            $updaterequired = true;
+            if ($newitem = restore_dbops::get_backup_ids_record($this->get_restoreid(), 'course_module', $lesson->activitylink)) {
+                $lesson->activitylink = $newitem->newitemid;
+            }
+            if (!$DB->record_exists('course_modules', array('id' => $lesson->activitylink, 'course' => $lesson->course))) {
+                $lesson->activitylink = 0;
+            }
+        }
+
+        if ($updaterequired) {
+            $DB->update_record('lesson', $lesson);
+        }
+    }
 }
index 38a33a4..1edbf1c 100644 (file)
@@ -330,32 +330,5 @@ class restore_lesson_activity_structure_step extends restore_activity_structure_
         }
 
         $DB->execute($sql);
-
-        // Re-map the dependency and activitylink information
-        // If a depency or activitylink has no mapping in the backup data then it could either be a duplication of a
-        // lesson, or a backup/restore of a single lesson. We have no way to determine which and whether this is the
-        // same site and/or course. Therefore we try and retrieve a mapping, but fallback to the original value if one
-        // was not found. We then test to see whether the value found is valid for the course being restored into.
-        $lesson = $DB->get_record('lesson', array('id' => $this->task->get_activityid()), 'id, course, dependency, activitylink');
-        $updaterequired = false;
-        if (!empty($lesson->dependency)) {
-            $updaterequired = true;
-            $lesson->dependency = $this->get_mappingid('lesson', $lesson->dependency, $lesson->dependency);
-            if (!$DB->record_exists('lesson', array('id' => $lesson->dependency, 'course' => $lesson->course))) {
-                $lesson->dependency = 0;
-            }
-        }
-
-        if (!empty($lesson->activitylink)) {
-            $updaterequired = true;
-            $lesson->activitylink = $this->get_mappingid('course_module', $lesson->activitylink, $lesson->activitylink);
-            if (!$DB->record_exists('course_modules', array('id' => $lesson->activitylink, 'course' => $lesson->course))) {
-                $lesson->activitylink = 0;
-            }
-        }
-
-        if ($updaterequired) {
-            $DB->update_record('lesson', $lesson);
-        }
     }
 }
index 94ba3aa..b784f8e 100644 (file)
@@ -572,9 +572,7 @@ class lesson_display_answer_form_matching extends moodleform {
                     // Temporary fixed until MDL-38885 gets integrated
                     $mform->setType('response', PARAM_TEXT);
                 }
-                $context = context_module::instance($PAGE->cm->id);
-                $answer->answer = file_rewrite_pluginfile_urls($answer->answer, 'pluginfile.php', $context->id,
-                        'mod_lesson', 'page_answers', $answer->id);
+                $answer = lesson_page_type_matching::rewrite_answers_urls($answer);
                 $mform->addElement('select', $responseid, format_text($answer->answer,$answer->answerformat,$options), $responseoptions, $disabled);
                 $mform->setType($responseid, PARAM_TEXT);
                 if ($hasattempt) {
index 8ce8f09..f8dd81f 100644 (file)
@@ -416,6 +416,7 @@ class lesson_display_answer_form_truefalse extends moodleform {
                 $ansid = 'answer_id';
             }
 
+            $answer = lesson_page_type_truefalse::rewrite_answers_urls($answer);
             $radiobuttons[] = $mform->createElement('radio', $ansid, null,
                 format_text($answer->answer, $answer->answerformat, $options), $answer->id, $disabled);
 
index df3c78e..77bbda6 100644 (file)
@@ -66,6 +66,7 @@ class moodle1_mod_scorm_handler extends moodle1_mod_handler {
                         'timeopen' => '0',
                         'timeclose' => '0',
                         'introformat' => '0',
+                        'completionstatusallscos' => 0,
                     ),
                     'renamefields' => array(
                         'summary' => 'intro'