Merge branch 'MDL-57431-master' of git://github.com/lameze/moodle
authorDamyon Wiese <damyon@moodle.com>
Mon, 18 Dec 2017 04:51:33 +0000 (12:51 +0800)
committerDamyon Wiese <damyon@moodle.com>
Mon, 18 Dec 2017 04:51:33 +0000 (12:51 +0800)
84 files changed:
.gitattributes
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/templates/models_list.mustache
admin/tool/behat/cli/util.php
admin/tool/httpsreplace/tests/httpsreplace_test.php
admin/tool/lp/templates/manage_competencies_page.mustache
analytics/classes/model.php
analytics/tests/model_test.php
auth/ldap/lang/en/auth_ldap.php
auth/ldap/settings.php
auth/mnet/auth.php
backup/cc/entity11.resource.class.php
blocks/course_list/block_course_list.php
blocks/myoverview/classes/output/main.php
calendar/externallib.php
calendar/lib.php
cohort/classes/external/cohort_summary_exporter.php
competency/classes/api.php
competency/classes/competency.php
competency/classes/template_competency.php
competency/classes/user_competency_course.php
competency/classes/user_competency_plan.php
competency/tests/external_test.php
completion/classes/external.php
completion/criteria/completion_criteria_activity.php
course/renderer.php
enrol/imsenterprise/tests/imsenterprise_test.php
enrol/tests/enrollib_test.php
enrol/upgrade.txt
files/classes/converter.php
files/tests/converter_test.php
grade/tests/edittreelib_test.php
grade/tests/importlib_test.php
grade/tests/querylib_test.php
grade/tests/report_graderlib_test.php
grade/tests/reportlib_test.php
grade/tests/reportuserlib_test.php
install/lang/eu/admin.php
install/lang/ig/langconfig.php [new file with mode: 0644]
install/lang/pcm/langconfig.php [new file with mode: 0644]
lib/accesslib.php
lib/authlib.php
lib/classes/access/get_user_capability_course_helper.php
lib/classes/hub/api.php
lib/classes/hub/registration.php
lib/classes/plugininfo/gradingform.php
lib/classes/session/redis.php
lib/classes/task/delete_unconfirmed_users_task.php
lib/db/install.xml
lib/db/upgrade.php
lib/dml/moodle_database.php
lib/dml/oci_native_moodle_package.sql
lib/enrollib.php
lib/filestorage/stored_file.php
lib/form/tags.php
lib/myprofilelib.php
lib/navigationlib.php
lib/tests/accesslib_test.php
lib/tests/gradelib_test.php
mod/assign/feedback/editpdf/lang/en/assignfeedback_editpdf.php
mod/assign/feedback/editpdf/locallib.php
mod/assign/feedback/editpdf/settings.php
mod/assign/submission/file/locallib.php
mod/assign/submission/onlinetext/locallib.php
mod/data/lang/en/data.php
mod/data/lib.php
mod/data/tests/lib_test.php
mod/lesson/lang/en/lesson.php
mod/lesson/lib.php
mod/lesson/locallib.php
mod/lesson/tests/lib_test.php
mod/quiz/index.php
question/type/multianswer/questiontype.php
search/engine/solr/classes/engine.php
search/engine/solr/classes/schema.php
search/engine/solr/version.php
search/tests/behat/behat_search.php
theme/boost/scss/moodle/modules.scss
theme/boost/templates/core_form/element-tags-inline.mustache
theme/boost/templates/core_form/element-tags.mustache
theme/bootstrapbase/less/moodle/modules.less
theme/bootstrapbase/style/moodle.css
version.php

index fb39fe0..8afacce 100644 (file)
@@ -1,4 +1,5 @@
 **/yui/build/** -diff
 **/amd/build/** -diff
+lib/dml/oci_native_moodle_package.sql text eol=lf
 theme/bootstrapbase/style/editor.css -diff
 theme/bootstrapbase/style/moodle.css -diff
index 0e8d4e8..cbd0a60 100644 (file)
@@ -119,6 +119,9 @@ class models_list implements \renderable, \templatable {
                     debugging("The time splitting method '{$modeldata->timesplitting}' should include a '{$identifier}_help'
                         string to describe its purpose.", DEBUG_DEVELOPER);
                 }
+            } else {
+                $helpicon = new \help_icon('timesplittingnotdefined', 'tool_analytics');
+                $modeldata->timesplittinghelp = $helpicon->export_for_template($output);
             }
 
             // Has this model generated predictions?.
@@ -207,19 +210,22 @@ class models_list implements \renderable, \templatable {
             }
 
             // Enable / disable.
-            if ($model->is_enabled()) {
-                $action = 'disable';
-                $text = get_string('disable');
-                $icontype = 't/block';
-            } else {
-                $action = 'enable';
-                $text = get_string('enable');
-                $icontype = 'i/checked';
+            if ($model->is_enabled() || !empty($modeldata->timesplitting)) {
+                // If there is no timesplitting method set, the model can not be enabled.
+                if ($model->is_enabled()) {
+                    $action = 'disable';
+                    $text = get_string('disable');
+                    $icontype = 't/block';
+                } else {
+                    $action = 'enable';
+                    $text = get_string('enable');
+                    $icontype = 'i/checked';
+                }
+                $urlparams['action'] = $action;
+                $url = new \moodle_url('model.php', $urlparams);
+                $icon = new \action_menu_link_secondary($url, new \pix_icon($icontype, $text), $text);
+                $actionsmenu->add($icon);
             }
-            $urlparams['action'] = $action;
-            $url = new \moodle_url('model.php', $urlparams);
-            $icon = new \action_menu_link_secondary($url, new \pix_icon($icontype, $text), $text);
-            $actionsmenu->add($icon);
 
             // Export training data.
             if (!$model->is_static() && $model->is_trained()) {
index 68ca347..47def2b 100644 (file)
@@ -85,6 +85,8 @@ $string['previouspage'] = 'Previous page';
 $string['samestartdate'] = 'Current start date is good';
 $string['sameenddate'] = 'Current end date is good';
 $string['target'] = 'Target';
+$string['timesplittingnotdefined'] = 'Time splitting is not defined.';
+$string['timesplittingnotdefined_help'] = 'You need to select a time-splitting method before enabling the model.';
 $string['trainandpredictmodel'] = 'Training model and calculating predictions';
 $string['trainingprocessfinished'] = 'Training process finished';
 $string['trainingresults'] = 'Training results';
index 8eb6211..efdc4ca 100644 (file)
                     {{/timesplitting}}
                     {{^timesplitting}}
                         {{#str}}notdefined, tool_analytics{{/str}}
+                        {{#timesplittinghelp}}
+                            {{>core/help_icon}}
+                        {{/timesplittinghelp}}
                     {{/timesplitting}}
                 </td>
                 <td>
index 8efa4ed..e88125b 100644 (file)
@@ -398,7 +398,7 @@ function print_combined_install_output($processes) {
     // Show process name in first row.
     foreach ($processes as $name => $process) {
         // If we don't have enough space to show full run name then show runX.
-        if ($lengthofprocessline < strlen($name + 2)) {
+        if ($lengthofprocessline < strlen($name) + 2) {
             $name = substr($name, -5);
         }
         // One extra padding as we are adding | separator for rest of the data.
index c10ea2f..e3599c6 100644 (file)
@@ -47,12 +47,12 @@ class httpsreplace_test extends \advanced_testcase {
             "Test image from another site should be replaced" => [
                 "content" => '<img src="' . $this->getExternalTestFileUrl('/test.jpg', false) . '">',
                 "outputregex" => '/UPDATE/',
-                "expectedcontent" => '<img src="' . $this->getExternalTestFileUrl('/test.jpg', true) . '">',
+                "expectedcontent" => '<img src="' . $this->get_converted_http_link('/test.jpg') . '">',
             ],
             "Test object from another site should be replaced" => [
                 "content" => '<object data="' . $this->getExternalTestFileUrl('/test.swf', false) . '">',
                 "outputregex" => '/UPDATE/',
-                "expectedcontent" => '<object data="' . $this->getExternalTestFileUrl('/test.swf', true) . '">',
+                "expectedcontent" => '<object data="' . $this->get_converted_http_link('/test.swf') . '">',
             ],
             "Test image from a site with international name should be replaced" => [
                 "content" => '<img src="http://中国互联网络信息中心.中国/logosy/201706/W01.png">',
@@ -82,7 +82,7 @@ class httpsreplace_test extends \advanced_testcase {
             "Search for params should be case insensitive" => [
                 "content" => '<object DATA="' . $this->getExternalTestFileUrl('/test.swf', false) . '">',
                 "outputregex" => '/UPDATE/',
-                "expectedcontent" => '<object DATA="' . $this->getExternalTestFileUrl('/test.swf', true) . '">',
+                "expectedcontent" => '<object DATA="' . $this->get_converted_http_link('/test.swf') . '">',
             ],
             "URL should be case insensitive" => [
                 "content" => '<object data="HTTP://some.site/path?query">',
@@ -93,7 +93,7 @@ class httpsreplace_test extends \advanced_testcase {
                 "content" => '<img alt="A picture" src="' . $this->getExternalTestFileUrl('/test.png', false) .
                     '" width="1”><p style="font-size: \'20px\'"></p>',
                 "outputregex" => '/UPDATE/',
-                "expectedcontent" => '<img alt="A picture" src="' . $this->getExternalTestFileUrl('/test.png', true) .
+                "expectedcontent" => '<img alt="A picture" src="' . $this->get_converted_http_link('/test.png') .
                     '" width="1”><p style="font-size: \'20px\'"></p>',
             ],
             "Broken URL should not be changed" => [
@@ -113,11 +113,25 @@ class httpsreplace_test extends \advanced_testcase {
                     $this->getExternalTestFileUrl('/test.jpg', false) . '"></a>',
                 "outputregex" => '/UPDATE/',
                 "expectedcontent" => '<a href="' . $this->getExternalTestFileUrl('/test.png', false) . '"><img src="' .
-                    $this->getExternalTestFileUrl('/test.jpg', true) . '"></a>',
+                    $this->get_converted_http_link('/test.jpg') . '"></a>',
             ],
         ];
     }
 
+    /**
+     * Convert the HTTP external test file URL to use HTTPS.
+     *
+     * Note: We *must not* use getExternalTestFileUrl with the True option
+     * here, becase it is reasonable to have only one of these set due to
+     * issues with SSL certificates.
+     *
+     * @param   string  $path Path to be rewritten
+     * @return  string
+     */
+    protected function get_converted_http_link($path) {
+        return preg_replace('/^http:/', 'https:', $this->getExternalTestFileUrl($path, false));
+    }
+
     /**
      * Test upgrade_http_links
      * @param string $content Example content that we'll attempt to replace.
@@ -152,7 +166,7 @@ class httpsreplace_test extends \advanced_testcase {
         // Get the http url, since the default test wwwroot is https.
         $wwwrootdomain = 'www.example.com';
         $wwwroothttp = preg_replace('/^https:/', 'http:', $CFG->wwwroot);
-        $testdomain = 'download.moodle.org';
+        $testdomain = $this->get_converted_http_link('');
         return [
             "Test image from an available site so shouldn't be reported" => [
                 "content" => '<img src="' . $this->getExternalTestFileUrl('/test.jpg', false) . '">',
index bf43b5d..e99f7c5 100644 (file)
@@ -64,6 +64,7 @@
                                 <li>
                                     <a href="#">{{#str}}edit{{/str}}</a><b class="caret"></b>
                                     <ul class="dropdown dropdown-menu">
+                                    {{#canmanage}}
                                     <li class="dropdown-item">
                                         <a href="#" data-action="edit">
                                             {{#pix}}t/edit{{/pix}} {{#str}}edit{{/str}}
                                             {{#pix}}t/down{{/pix}} {{#str}}movedown{{/str}}
                                         </a>
                                     </li>
+                                    {{/canmanage}}
                                     <li class="dropdown-item">
                                         <a href="#" data-action="linkedcourses">
                                             {{#pix}}t/viewdetails{{/pix}} {{#str}}linkedcourses, tool_lp{{/str}}
                                         </a>
                                     </li>
+                                    {{#canmanage}}
                                     <li class="dropdown-item">
                                         <a href="#" data-action="relatedcompetencies">
                                             {{#pix}}t/add{{/pix}} {{#str}}addcrossreferencedcompetency, tool_lp{{/str}}
                                             {{#pix}}t/edit{{/pix}} {{#str}}competencyrule, tool_lp{{/str}}
                                         </a>
                                     </li>
+                                    {{/canmanage}}
                                 </ul>
                             </li>
                         </ul>
                 <p data-region="competencyinfo">
                     {{#str}}nocompetencyselected, tool_lp{{/str}}
                 </p>
-                {{#canmanage}}
                 <div data-region="competencyactions">
+                    {{#canmanage}}
                     <button class="btn btn-secondary" data-action="add">{{#pix}}t/add{{/pix}} <span data-region="term"></span></button>
+                    {{/canmanage}}
                 </div>
-                {{/canmanage}}
             </div>
         </div>
     </div>
index d881645..0bee650 100644 (file)
@@ -1013,6 +1013,10 @@ class model {
             if (!$this->is_static()) {
                 $this->model->trained = 0;
             }
+        } else if (empty($this->model->timesplitting)) {
+            // A valid timesplitting method needs to be supplied before a model can be enabled.
+            throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
+
         }
 
         // Purge pages with insights as this may change things.
index 2685b64..d5d0153 100644 (file)
@@ -226,7 +226,7 @@ class analytics_model_testcase extends advanced_testcase {
         $this->model->mark_as_trained();
         $this->assertEquals($originaluniqueid, $this->model->get_unique_id());
 
-        $this->model->enable();
+        $this->model->enable('\core\analytics\time_splitting\deciles');
         $this->assertEquals($originaluniqueid, $this->model->get_unique_id());
 
         // Wait 1 sec so the timestamp changes.
index fdb9541..843e1b0 100644 (file)
@@ -41,7 +41,7 @@ $string['auth_ldapdescription'] = 'This method provides authentication against a
                                   entry in its database. This module can read user attributes from LDAP and prefill
                                   wanted fields in Moodle.  For following logins only the username and
                                   password are checked.';
-$string['auth_ldap_expiration_desc'] = 'Select No to disable expired password checking or LDAP to read passwordexpiration time directly from LDAP';
+$string['auth_ldap_expiration_desc'] = 'Select \'{$a->no}\' to disable expired password checking or \'{$a->ldapserver}\' to read the password expiration time directly from the LDAP server';
 $string['auth_ldap_expiration_key'] = 'Expiration';
 $string['auth_ldap_expiration_warning_desc'] = 'Number of days before password expiration warning is issued.';
 $string['auth_ldap_expiration_warning_key'] = 'Expiration warning';
index af1ddf7..43fb8eb 100644 (file)
@@ -185,12 +185,24 @@ if ($ADMIN->fulltree) {
                 new lang_string('auth_ldap_passwdexpire_settings', 'auth_ldap'), ''));
 
         // Password Expiration.
+
+        // Create the description lang_string object.
+        $strno = get_string('no');
+        $strldapserver = get_string('pluginname', 'auth_ldap');
+        $langobject = new stdClass();
+        $langobject->no = $strno;
+        $langobject->ldapserver = $strldapserver;
+        $description = new lang_string('auth_ldap_expiration_desc', 'auth_ldap', $langobject);
+
+        // Now create the options.
         $expiration = array();
-        $expiration['0'] = 'no';
-        $expiration['1'] = 'LDAP';
+        $expiration['0'] = $strno;
+        $expiration['1'] = $strldapserver;
+
+        // Add the setting.
         $settings->add(new admin_setting_configselect('auth_ldap/expiration',
                 new lang_string('auth_ldap_expiration_key', 'auth_ldap'),
-                new lang_string('auth_ldap_expiration_desc', 'auth_ldap'), 0 , $expiration));
+                $description, 0 , $expiration));
 
         // Password Expiration warning.
         $settings->add(new admin_setting_configtext('auth_ldap/expiration_warning',
index 1f269f7..b3b22f5 100644 (file)
@@ -382,7 +382,7 @@ class auth_plugin_mnet extends auth_plugin_base {
             // with info so that the IDP can maintain mnetservice_enrol_enrolments
             $mnetrequest->add_param($remoteuser->username);
             $fields = 'id, category, sortorder, fullname, shortname, idnumber, summary, startdate, visible';
-            $courses = enrol_get_users_courses($localuser->id, false, $fields, 'visible DESC,sortorder ASC');
+            $courses = enrol_get_users_courses($localuser->id, false, $fields);
             if (is_array($courses) && !empty($courses)) {
                 // Second request to do the JOINs that we'd have done
                 // inside enrol_get_users_courses() if we had been allowed
index bc5bb5a..7bb6da7 100644 (file)
@@ -99,7 +99,7 @@ class cc11_resource extends entities11 {
                                 $link = 'http://invalidurldetected/';
                             }
                         } else {
-                            $link = $rawlink;
+                            $link = htmlspecialchars(trim($rawlink), ENT_COMPAT, 'UTF-8', false);
                         }
                     }
                 }
index 36003f7..10cbf23 100644 (file)
@@ -57,14 +57,7 @@ class block_course_list extends block_list {
 
         if (empty($CFG->disablemycourses) and isloggedin() and !isguestuser() and
           !(has_capability('moodle/course:update', context_system::instance()) and $adminseesall)) {    // Just print My Courses
-            // As this is producing navigation sort order should default to $CFG->navsortmycoursessort instead
-            // of using the default.
-            if (!empty($CFG->navsortmycoursessort)) {
-                $sortorder = 'visible DESC, ' . $CFG->navsortmycoursessort . ' ASC';
-            } else {
-                $sortorder = 'visible DESC, sortorder ASC';
-            }
-            if ($courses = enrol_get_my_courses(NULL, $sortorder)) {
+            if ($courses = enrol_get_my_courses()) {
                 foreach ($courses as $course) {
                     $coursecontext = context_course::instance($course->id);
                     $linkcss = $course->visible ? "" : " class=\"dimmed\" ";
index 2435f54..2850637 100644 (file)
@@ -63,7 +63,7 @@ class main implements renderable, templatable {
     public function export_for_template(renderer_base $output) {
         global $USER;
 
-        $courses = enrol_get_my_courses('*', 'fullname ASC');
+        $courses = enrol_get_my_courses('*');
         $coursesprogress = [];
 
         foreach ($courses as $course) {
index 049f13d..7592536 100644 (file)
@@ -503,7 +503,7 @@ class core_calendar_external extends external_api {
             $params['aftereventid'] = null;
         }
 
-        $courses = enrol_get_my_courses('*', 'visible DESC,sortorder ASC', 0, [$courseid]);
+        $courses = enrol_get_my_courses('*', null, 0, [$courseid]);
         $courses = array_values($courses);
 
         if (empty($courses)) {
@@ -588,7 +588,7 @@ class core_calendar_external extends external_api {
         }
 
         $renderer = $PAGE->get_renderer('core_calendar');
-        $courses = enrol_get_my_courses('*', 'visible DESC,sortorder ASC', 0, $params['courseids']);
+        $courses = enrol_get_my_courses('*', null, 0, $params['courseids']);
         $courses = array_values($courses);
 
         if (empty($courses)) {
index 45c1785..0a8408f 100644 (file)
@@ -249,10 +249,6 @@ class calendar_event {
         }
 
         $this->properties = $data;
-
-        if (empty($data->context)) {
-            $this->properties->context = $this->calculate_context();
-        }
     }
 
     /**
@@ -343,6 +339,24 @@ class calendar_event {
         return $context;
     }
 
+    /**
+     * Returns the context for this event. The context is calculated
+     * the first time is is requested and then stored in a member
+     * variable to be returned each subsequent time.
+     *
+     * This is a magical getter function that will be called when
+     * ever the context property is accessed, e.g. $event->context.
+     *
+     * @return context
+     */
+    protected function get_context() {
+        if (!isset($this->properties->context)) {
+            $this->properties->context = $this->calculate_context();
+        }
+
+        return $this->properties->context;
+    }
+
     /**
      * Returns an array of editoroptions for this event.
      *
@@ -367,7 +381,7 @@ class calendar_event {
             // Check if we have already resolved the context for this event.
             if ($this->editorcontext === null) {
                 // Switch on the event type to decide upon the appropriate context to use for this event.
-                $this->editorcontext = $this->properties->context;
+                $this->editorcontext = $this->get_context();
                 if (!calendar_is_valid_eventtype($this->properties->eventtype)) {
                     return clean_text($this->properties->description, $this->properties->format);
                 }
@@ -433,7 +447,7 @@ class calendar_event {
 
         // Prepare event data.
         $eventargs = array(
-            'context' => $this->properties->context,
+            'context' => $this->get_context(),
             'objectid' => $this->properties->id,
             'other' => array(
                 'repeatid' => empty($this->properties->repeatid) ? 0 : $this->properties->repeatid,
@@ -485,7 +499,7 @@ class calendar_event {
                 // were set when calculate_context() was called from the constructor.
                 if ($usingeditor) {
                     $this->properties->context = $this->calculate_context();
-                    $this->editorcontext = $this->properties->context;
+                    $this->editorcontext = $this->get_context();
                 }
 
                 $editor = $this->properties->description;
@@ -512,7 +526,7 @@ class calendar_event {
 
             // Log the event entry.
             $eventargs['objectid'] = $this->properties->id;
-            $eventargs['context'] = $this->properties->context;
+            $eventargs['context'] = $this->get_context();
             $event = \core\event\calendar_event_created::create($eventargs);
             $event->trigger();
 
@@ -681,7 +695,7 @@ class calendar_event {
 
         // Trigger an event for the delete action.
         $eventargs = array(
-            'context' => $this->properties->context,
+            'context' => $this->get_context(),
             'objectid' => $this->properties->id,
             'other' => array(
                 'repeatid' => empty($this->properties->repeatid) ? 0 : $this->properties->repeatid,
@@ -715,7 +729,7 @@ class calendar_event {
 
         // If the editor context hasn't already been set then set it now.
         if ($this->editorcontext === null) {
-            $this->editorcontext = $this->properties->context;
+            $this->editorcontext = $this->get_context();
         }
 
         // If the context has been set delete all associated files.
@@ -774,10 +788,10 @@ class calendar_event {
 
                 if ($properties->eventtype === 'site') {
                     // Site context.
-                    $this->editorcontext = $this->properties->context;
+                    $this->editorcontext = $this->get_context();
                 } else if ($properties->eventtype === 'user') {
                     // User context.
-                    $this->editorcontext = $this->properties->context;
+                    $this->editorcontext = $this->get_context();
                 } else if ($properties->eventtype === 'group' || $properties->eventtype === 'course') {
                     // First check the course is valid.
                     $course = $DB->get_record('course', array('id' => $properties->courseid));
@@ -785,7 +799,7 @@ class calendar_event {
                         print_error('invalidcourse');
                     }
                     // Course context.
-                    $this->editorcontext = $this->properties->context;
+                    $this->editorcontext = $this->get_context();
                     // We have a course and are within the course context so we had
                     // better use the courses max bytes value.
                     $this->editoroptions['maxbytes'] = $course->maxbytes;
@@ -793,7 +807,7 @@ class calendar_event {
                     // First check the course is valid.
                     \coursecat::get($properties->categoryid, MUST_EXIST, true);
                     // Course context.
-                    $this->editorcontext = $this->properties->context;
+                    $this->editorcontext = $this->get_context();
                     // We have a course and are within the course context so we had
                     // better use the courses max bytes value.
                     $this->editoroptions['maxbytes'] = $course->maxbytes;
@@ -869,7 +883,7 @@ class calendar_event {
 
         // Prepare event data.
         $eventargs = array(
-            'context' => $this->properties->context,
+            'context' => $this->get_context(),
             'objectid' => $this->properties->id,
             'other' => array(
                 'repeatid' => empty($this->properties->repeatid) ? 0 : $this->properties->repeatid,
@@ -939,7 +953,7 @@ class calendar_event {
 
         if ($this->editorcontext === null) {
             // Switch on the event type to decide upon the appropriate context to use for this event.
-            $this->editorcontext = $this->properties->context;
+            $this->editorcontext = $this->get_context();
 
             if (!calendar_is_valid_eventtype($this->properties->eventtype)) {
                 // We don't have a context here, do a normal format_text.
index 44d8e25..55e7529 100644 (file)
@@ -52,6 +52,16 @@ class cohort_summary_exporter extends \core\external\exporter {
                 'default' => '',
                 'null' => NULL_ALLOWED
             ),
+            'description' => array(
+                'type' => PARAM_TEXT,
+                'default' => '',
+                'null' => NULL_ALLOWED
+            ),
+            'descriptionformat' => array(
+                'type' => PARAM_INT,
+                'default' => FORMAT_HTML,
+                'null' => NULL_ALLOWED
+            ),
             'visible' => array(
                 'type' => PARAM_BOOL,
             )
index fc16c0c..9d04083 100644 (file)
@@ -165,7 +165,7 @@ class api {
         require_capability('moodle/competency:competencymanage', $competency->get_context());
 
         // Reset the sortorder, use reorder instead.
-        $competency->set('sortorder', null);
+        $competency->set('sortorder', 0);
         $competency->create();
 
         \core\event\competency_created::create_from_competency($competency)->trigger();
index c537508..9e417e9 100644 (file)
@@ -76,7 +76,7 @@ class competency extends persistent {
                 'default' => FORMAT_HTML
             ),
             'sortorder' => array(
-                'default' => null,
+                'default' => 0,
                 'type' => PARAM_INT
             ),
             'parentid' => array(
index 3e0c8f8..8c6df0b 100644 (file)
@@ -53,7 +53,7 @@ class template_competency extends persistent {
             ),
             'sortorder' => array(
                 'type' => PARAM_INT,
-                'default' => null,
+                'default' => 0,
             ),
         );
     }
index 392e1c5..ad97f6c 100644 (file)
@@ -282,7 +282,6 @@ class user_competency_course extends persistent {
               ORDER BY p.timesproficient ASC, c.id DESC';
 
         $results = $DB->get_records_sql($sql, $params, $skip, $limit);
-        $a = $DB->get_records_sql('SELECT * from {' . self::TABLE . '}');
 
         $comps = array();
         foreach ($results as $r) {
index 3880a2a..5b53589 100644 (file)
@@ -66,7 +66,7 @@ class user_competency_plan extends persistent {
             ),
             'sortorder' => array(
                 'type' => PARAM_INT,
-                'default' => null,
+                'default' => 0,
             ),
         );
     }
index f8a09e0..f0b50c8 100644 (file)
@@ -281,8 +281,7 @@ class core_competency_external_testcase extends externallib_advanced_testcase {
             'idnumber' => 'idnumber' . $number,
             'description' => 'description' . $number,
             'descriptionformat' => FORMAT_HTML,
-            'competencyframeworkid' => $frameworkid,
-            'sortorder' => 0
+            'competencyframeworkid' => $frameworkid
         );
         $result = external::create_competency($competency);
         return (object) external_api::clean_returnvalue(external::create_competency_returns(), $result);
@@ -294,8 +293,7 @@ class core_competency_external_testcase extends externallib_advanced_testcase {
             'shortname' => 'shortname' . $number,
             'idnumber' => 'idnumber' . $number,
             'description' => 'description' . $number,
-            'descriptionformat' => FORMAT_HTML,
-            'sortorder' => 0
+            'descriptionformat' => FORMAT_HTML
         );
         $result = external::update_competency($competency);
         return external_api::clean_returnvalue(external::update_competency_returns(), $result);
index b610108..24384e9 100644 (file)
@@ -249,7 +249,7 @@ class core_completion_external extends external_api {
 
         $completion = new completion_info($course);
         $activities = $completion->get_activities();
-        $progresses = $completion->get_progress_all();
+        $progresses = $completion->get_progress_all('u.id = :uid', ['uid' => $params['userid']]);
         $userprogress = $progresses[$user->id];
 
         $results = array();
index 29c570b..592c84e 100644 (file)
@@ -279,7 +279,8 @@ class completion_criteria_activity extends completion_criteria {
             $details['requirement'][] = get_string('markingyourselfcomplete', 'completion');
         } elseif ($cm->completion == COMPLETION_TRACKING_AUTOMATIC) {
             if ($cm->completionview) {
-                $details['requirement'][] = get_string('viewingactivity', 'completion', $this->module);
+                $modulename = core_text::strtolower(get_string('modulename', $this->module));
+                $details['requirement'][] = get_string('viewingactivity', 'completion', $modulename);
             }
 
             if (!is_null($cm->completiongradeitemnumber)) {
index 64b2575..d759837 100644 (file)
@@ -1988,13 +1988,7 @@ class core_course_renderer extends plugin_renderer_base {
         }
 
         $output = '';
-        if (!empty($CFG->navsortmycoursessort)) {
-            // sort courses the same as in navigation menu
-            $sortorder = 'visible DESC,'. $CFG->navsortmycoursessort.' ASC';
-        } else {
-            $sortorder = 'visible DESC,sortorder ASC';
-        }
-        $courses  = enrol_get_my_courses('summary, summaryformat', $sortorder);
+        $courses  = enrol_get_my_courses('summary, summaryformat');
         $rhosts   = array();
         $rcourses = array();
         if (!empty($CFG->mnet_dispatcher_mode) && $CFG->mnet_dispatcher_mode==='strict') {
index e3ed112..dd8d9a8 100644 (file)
@@ -206,7 +206,7 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $this->set_xml_file(array($imsuser));
 
         $this->imsplugin->cron();
-        $this->assertEquals(1, $DB->get_field('user', 'deleted', array('id' => $user->id), '*', MUST_EXIST));
+        $this->assertEquals(1, $DB->get_field('user', 'deleted', array('id' => $user->id), MUST_EXIST));
     }
 
     /**
@@ -227,7 +227,7 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $this->set_xml_file(array($imsuser));
 
         $this->imsplugin->cron();
-        $this->assertEquals(0, $DB->get_field('user', 'deleted', array('id' => $user->id), '*', MUST_EXIST));
+        $this->assertEquals(0, $DB->get_field('user', 'deleted', array('id' => $user->id), MUST_EXIST));
     }
 
     /**
index d52dc6f..1678462 100644 (file)
@@ -55,10 +55,24 @@ class core_enrollib_testcase extends advanced_testcase {
 
         $category1 = $this->getDataGenerator()->create_category(array('visible'=>0));
         $category2 = $this->getDataGenerator()->create_category();
-        $course1 = $this->getDataGenerator()->create_course(array('category'=>$category1->id));
-        $course2 = $this->getDataGenerator()->create_course(array('category'=>$category2->id));
-        $course3 = $this->getDataGenerator()->create_course(array('category'=>$category2->id, 'visible'=>0));
-        $course4 = $this->getDataGenerator()->create_course(array('category'=>$category2->id));
+
+        $course1 = $this->getDataGenerator()->create_course(array(
+            'shortname' => 'Z',
+            'category' => $category1->id,
+        ));
+        $course2 = $this->getDataGenerator()->create_course(array(
+            'shortname' => 'X',
+            'category' => $category2->id,
+        ));
+        $course3 = $this->getDataGenerator()->create_course(array(
+            'shortname' => 'Y',
+            'category' => $category2->id,
+            'visible' => 0,
+        ));
+        $course4 = $this->getDataGenerator()->create_course(array(
+            'shortname' => 'W',
+            'category' => $category2->id,
+        ));
 
         $maninstance1 = $DB->get_record('enrol', array('courseid'=>$course1->id, 'enrol'=>'manual'), '*', MUST_EXIST);
         $DB->set_field('enrol', 'status', ENROL_INSTANCE_DISABLED, array('id'=>$maninstance1->id));
@@ -150,6 +164,18 @@ class core_enrollib_testcase extends advanced_testcase {
 
         $courses = enrol_get_all_users_courses($user2->id, false, null, 'id DESC');
         $this->assertEquals(array($course3->id, $course2->id, $course1->id), array_keys($courses));
+
+        // Make sure that implicit sorting defined in navsortmycoursessort is respected.
+
+        $CFG->navsortmycoursessort = 'shortname';
+
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course2->id, $course3->id, $course1->id), array_keys($courses));
+
+        // But still the explicit sorting takes precedence over the implicit one.
+
+        $courses = enrol_get_all_users_courses($user1->id, false, null, 'shortname DESC');
+        $this->assertEquals(array($course1->id, $course3->id, $course2->id), array_keys($courses));
     }
 
     public function test_enrol_user_sees_own_courses() {
@@ -590,15 +616,15 @@ class core_enrollib_testcase extends advanced_testcase {
         // Create test user and 4 courses, two of which have guest access enabled.
         $user = $this->getDataGenerator()->create_user();
         $course1 = $this->getDataGenerator()->create_course(
-                (object)array('shortname' => 'Z',
+                (object)array('shortname' => 'X',
                 'enrol_guest_status_0' => ENROL_INSTANCE_DISABLED,
                 'enrol_guest_password_0' => ''));
         $course2 = $this->getDataGenerator()->create_course(
-                (object)array('shortname' => 'Y',
+                (object)array('shortname' => 'Z',
                 'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED,
                 'enrol_guest_password_0' => ''));
         $course3 = $this->getDataGenerator()->create_course(
-                (object)array('shortname' => 'X',
+                (object)array('shortname' => 'Y',
                 'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED,
                 'enrol_guest_password_0' => 'frog'));
         $course4 = $this->getDataGenerator()->create_course(
@@ -645,10 +671,19 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertObjectHasAttribute('summary', $courses[$course3->id]);
         $this->assertObjectHasAttribute('summaryformat', $courses[$course3->id]);
 
-        // Check sort parameter still works.
-        $courses = enrol_get_my_courses(null, 'shortname', 0, [], true);
+        // By default, courses are ordered by sortorder - which by default is most recent first.
+        $courses = enrol_get_my_courses(null, null, 0, [], true);
         $this->assertEquals([$course3->id, $course2->id, $course1->id], array_keys($courses));
 
+        // Make sure that implicit sorting defined in navsortmycoursessort is respected.
+        $CFG->navsortmycoursessort = 'shortname';
+        $courses = enrol_get_my_courses(null, null, 0, [], true);
+        $this->assertEquals([$course1->id, $course3->id, $course2->id], array_keys($courses));
+
+        // But still the explicit sorting takes precedence over the implicit one.
+        $courses = enrol_get_my_courses(null, 'shortname DESC', 0, [], true);
+        $this->assertEquals([$course2->id, $course3->id, $course1->id], array_keys($courses));
+
         // Check filter parameter still works.
         $courses = enrol_get_my_courses(null, 'id', 0, [$course2->id, $course3->id, $course4->id], true);
         $this->assertEquals([$course2->id, $course3->id], array_keys($courses));
index 528fe37..09ff3ba 100644 (file)
@@ -1,6 +1,11 @@
 This files describes API changes in /enrol/* - plugins,
 information provided here is intended especially for developers.
 
+=== 3.5 ===
+
+* Default sorting in enrol_get_my_courses(), enrol_get_all_users_courses() and enrol_get_users_courses() now respects
+  the site setting "navsortmycoursessort" and should be consistently used when displaying the courses in the UI.
+
 === 3.4 ===
 
 * render_course_enrolment_users_table method has been removed from the renderer. The enrolled users page is now
index 32793d3..da8c5ba 100644 (file)
@@ -127,7 +127,7 @@ class converter {
         if ($status === conversion::STATUS_PENDING || $status === conversion::STATUS_FAILED) {
             // The current status is either pending or failed.
             // Attempt to pick up a new converter and convert the document.
-            $from = \core_filetypes::get_file_extension($file->get_mimetype());
+            $from = pathinfo($file->get_filename(), PATHINFO_EXTENSION);
             $converters = $this->get_document_converter_classes($from, $format);
             $currentconverter = $this->get_next_converter($converters, $conversion->get('converter'));
 
@@ -225,9 +225,9 @@ class converter {
             return false;
         }
 
-        $from = \core_filetypes::get_file_extension($file->get_mimetype());
+        $from = pathinfo($file->get_filename(), PATHINFO_EXTENSION);
         if (!$from) {
-            // No mime type could be found. Unable to determine converter.
+            // No file extension could be found. Unable to determine converter.
             return false;
         }
 
index 950d593..e345c1e 100644 (file)
@@ -341,7 +341,7 @@ class core_files_converter_testcase extends advanced_testcase {
     }
 
     /**
-     * Test the can_convert_storedfile_to function with a file with indistinguished mimetype.
+     * Test the can_convert_storedfile_to function with a file with a known mimetype and extension.
      */
     public function test_can_convert_storedfile_to_docx() {
         $returnvalue = (object) [];
@@ -352,8 +352,7 @@ class core_files_converter_testcase extends advanced_testcase {
 
         $types = \core_filetypes::get_types();
 
-        // A file with filename '.' is a directory.
-        $file = $this->get_stored_file('example content', 'example', [
+        $file = $this->get_stored_file('example content', 'example.docx', [
                 'mimetype' => $types['docx']['type'],
             ]);
 
index cb96fa0..b24608d 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * Unit tests for grade/edit/tree/lib.php.
  *
- * @pacakge  core_grade
+ * @package  core_grades
  * @category phpunit
  * @author   Andrew Davis
  * @license  http://www.gnu.org/copyleft/gpl.html GNU Public License
index 0acea9e..234e6a4 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * Unit tests for grade/import/lib.php.
  *
- * @package   core_grade
+ * @package   core_grades
  * @category  phpunit
  * @copyright 2015 Adrian Greeve <adrian@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU Public License
index c284123..28e6cde 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * Unit tests for grade quering
  *
- * @pacakge   core_grade
+ * @package   core_grades
  * @category  phpunit
  * @copyright 2011 Petr Skoda {@link http://skodak.org}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU Public License
index d65e285..75f52c3 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * Unit tests for grade/report/user/lib.php.
  *
- * @package  core_grade
+ * @package  core_grades
  * @category phpunit
  * @copyright 2012 Andrew Davis
  * @license  http://www.gnu.org/copyleft/gpl.html GNU Public License
index bade609..d8928e5 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * Unit tests for grade/report/lib.php.
  *
- * @pacakge  core_grade
+ * @package  core_grades
  * @category phpunit
  * @author   Andrew Davis
  * @license  http://www.gnu.org/copyleft/gpl.html GNU Public License
index 7bc3289..94ae292 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * Unit tests for grade/report/user/lib.php.
  *
- * @package  core_grade
+ * @package  core_grades
  * @category phpunit
  * @copyright 2012 Andrew Davis
  * @license  http://www.gnu.org/copyleft/gpl.html GNU Public License
index e751fee..a25438e 100644 (file)
@@ -41,3 +41,4 @@ Mesedez, erabili --laguntza aukera.';
 $string['cliyesnoprompt'] = 'idatzi b (bai esateko) edo e (ez esateko)';
 $string['environmentrequireinstall'] = 'derrigorrezkoa da instalatuta eta gaituta izatea';
 $string['environmentrequireversion'] = '{$a->needed} bertsioa beharrezkoa da eta zu {$a->current} ari zara egikaritzen';
+$string['upgradekeyset'] = 'Eguneraketa-kodea (utzi hutsik kodea erabili nahi ez baduzu)';
diff --git a/install/lang/ig/langconfig.php b/install/lang/ig/langconfig.php
new file mode 100644 (file)
index 0000000..41f3b6c
--- /dev/null
@@ -0,0 +1,33 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'Igbo';
diff --git a/install/lang/pcm/langconfig.php b/install/lang/pcm/langconfig.php
new file mode 100644 (file)
index 0000000..5eb603a
--- /dev/null
@@ -0,0 +1,33 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'Pidgin';
index bd400e4..856a809 100644 (file)
@@ -319,8 +319,7 @@ function get_role_definitions_uncached(array $roleids) {
     $sql = "SELECT ctx.path, rc.roleid, rc.capability, rc.permission
               FROM {role_capabilities} rc
               JOIN {context} ctx ON rc.contextid = ctx.id
-             WHERE rc.roleid $sql
-          ORDER BY ctx.path, rc.roleid, rc.capability";
+             WHERE rc.roleid $sql";
     $rs = $DB->get_recordset_sql($sql, $params);
 
     foreach ($rs as $rd) {
@@ -334,6 +333,15 @@ function get_role_definitions_uncached(array $roleids) {
     }
 
     $rs->close();
+
+    // Sometimes (e.g. get_user_capability_course_helper::get_capability_info_at_each_context)
+    // we process role definitinons in a way that requires we see parent contexts
+    // before child contexts. This sort ensures that works (and is faster than
+    // sorting in the SQL query).
+    foreach ($rdefs as $roleid => $rdef) {
+        ksort($rdefs[$roleid]);
+    }
+
     return $rdefs;
 }
 
index 44b3221..e1735fd 100644 (file)
@@ -1049,7 +1049,7 @@ function display_auth_lock_options($settings, $auth, $userfields, $helptext, $ma
             // We are mapping to a remote field here.
             // Mapping.
             $settings->add(new admin_setting_configtext("auth_{$auth}/field_map_{$field}",
-                    get_string('auth_fieldmapping', 'auth', $fieldname), '', '', PARAM_ALPHANUMEXT, 30));
+                    get_string('auth_fieldmapping', 'auth', $fieldname), '', '', PARAM_RAW, 30));
 
             // Update local.
             $settings->add(new admin_setting_configselect("auth_{$auth}/field_updatelocal_{$field}",
index 80b5b45..93dccfa 100644 (file)
@@ -39,7 +39,10 @@ class get_user_capability_course_helper {
      * an array of capability values at each relevant context for the given user and capability.
      *
      * This is organised by the effective context path (the one at which the capability takes
-     * effect) and then by role id.
+     * effect) and then by role id. Note, however, that the resulting array only has
+     * the information that will be needed later. If there are Prohibits present in some
+     * roles, then they cannot be overridden by other roles or role overrides in lower contexts,
+     * therefore, such information, if any, is absent from the results.
      *
      * @param int $userid User id
      * @param string $capability Capability e.g. 'moodle/course:view'
@@ -58,42 +61,101 @@ class get_user_capability_course_helper {
         }
         $rdefs = get_role_definitions(array_keys($roleids));
 
+        // A prohibit in any relevant role prevents the capability
+        // in that context and all subcontexts. We need to track that.
+        // Here, the array keys are the paths where there is a prohibit the values are the role id.
+        $prohibitpaths = [];
+
         // Get data for required capability at each context path where the user has a role that can
         // affect it.
-        $systemcontext = \context_system::instance();
         $pathroleperms = [];
-        foreach ($accessdata['ra'] as $userpath => $roles) {
+        foreach ($accessdata['ra'] as $rapath => $roles) {
+
             foreach ($roles as $roleid) {
                 // Get role definition for that role.
-                foreach ($rdefs[$roleid] as $rolepath => $caps) {
+                foreach ($rdefs[$roleid] as $rdefpath => $caps) {
                     // Ignore if this override/definition doesn't refer to the relevant cap.
                     if (!array_key_exists($capability, $caps)) {
                         continue;
                     }
 
-                    // Check path is /1 or matches a path the user has.
-                    if ($rolepath === '/' . $systemcontext->id) {
-                        // Note /1 is listed first in the array so this entry will be overridden
-                        // if there is an override for the role on this actual level.
-                        $effectivepath = $userpath;
-                    } else if (preg_match('~^' . $userpath . '($|/)~', $rolepath)) {
-                        $effectivepath = $rolepath;
+                    // Check a role definition or override above ra.
+                    if (self::path_is_above($rdefpath, $rapath)) {
+                        // Note that $rdefs is sorted by path, so if a more specific override
+                        // exists, it will be processed later and override this one.
+                        $effectivepath = $rapath;
+                    } else if (self::path_is_above($rapath, $rdefpath)) {
+                        $effectivepath = $rdefpath;
                     } else {
                         // Not inside an area where the user has the role, so ignore.
                         continue;
                     }
 
+                    // Check for already seen prohibits in higher context. Overrides can't change that.
+                    if (self::any_path_is_above($prohibitpaths, $effectivepath)) {
+                        continue;
+                    }
+
+                    // This is a releavant role assignment / permission combination. Save it.
                     if (!array_key_exists($effectivepath, $pathroleperms)) {
                         $pathroleperms[$effectivepath] = [];
                     }
                     $pathroleperms[$effectivepath][$roleid] = $caps[$capability];
+
+                    // Update $prohibitpaths if necessary.
+                    if ($caps[$capability] == CAP_PROHIBIT) {
+                        // First remove any lower-context prohibits that might have come from other roles.
+                        foreach ($prohibitpaths as $otherprohibitpath => $notused) {
+                            if (self::path_is_above($effectivepath, $otherprohibitpath)) {
+                                unset($prohibitpaths[$otherprohibitpath]);
+                            }
+                        }
+                        $prohibitpaths[$effectivepath] = $roleid;
+                    }
                 }
             }
         }
 
+        // Finally, if a later role had a higher-level prohibit that an earlier role,
+        // there may be more bits we can prune - but don't prune the prohibits!
+        foreach ($pathroleperms as $effectivepath => $roleperms) {
+            if ($roleid = self::any_path_is_above($prohibitpaths, $effectivepath)) {
+                unset($pathroleperms[$effectivepath]);
+                $pathroleperms[$effectivepath][$roleid] = CAP_PROHIBIT;
+            }
+        }
+
         return $pathroleperms;
     }
 
+    /**
+     * Test if a context path $otherpath is the same as, or underneath, $parentpath.
+     *
+     * @param string $parentpath the path of the parent context.
+     * @param string $otherpath the path of another context.
+     * @return bool true if $otherpath is underneath (or equal to) $parentpath.
+     */
+    protected static function path_is_above($parentpath, $otherpath) {
+        return preg_match('~^' . $parentpath . '($|/)~', $otherpath);
+    }
+
+    /**
+     * Test if a context path $otherpath is the same as, or underneath, any of $prohibitpaths.
+     *
+     * @param array $prohibitpaths array keys are context paths.
+     * @param string $otherpath the path of another context.
+     * @return int releavant $roleid if $otherpath is underneath (or equal to)
+     *      any of the $prohibitpaths, 0 otherwise (so, can be used as a bool).
+     */
+    protected static function any_path_is_above($prohibitpaths, $otherpath) {
+        foreach ($prohibitpaths as $prohibitpath => $roleid) {
+            if (self::path_is_above($prohibitpath, $otherpath)) {
+                return $roleid;
+            }
+        }
+        return 0;
+    }
+
     /**
      * Calculates a permission tree based on an array of information about role permissions.
      *
index fe6e112..2c66b58 100644 (file)
@@ -67,36 +67,7 @@ class api {
             registration::require_registration();
         }
 
-        if (extension_loaded('xmlrpc')) {
-            // Use XMLRPC protocol.
-            return self::call_xmlrpc($token, $function, $data);
-        } else {
-            // Use REST.
-            return self::call_rest($token, $function, $data);
-        }
-    }
-
-    /**
-     * Performs REST request to moodle.net (using GET method)
-     *
-     * @param string $token
-     * @param string $function
-     * @param array $data
-     * @return mixed
-     * @throws moodle_exception
-     */
-    protected static function call_xmlrpc($token, $function, array $data) {
-        global $CFG;
-        require_once($CFG->dirroot . "/webservice/xmlrpc/lib.php");
-
-        $serverurl = HUB_MOODLEORGHUBURL . "/local/hub/webservice/webservices.php";
-        $xmlrpcclient = new webservice_xmlrpc_client($serverurl, $token);
-        try {
-            return $xmlrpcclient->call($function, $data);
-        } catch (\Exception $e) {
-            // Function webservice_xmlrpc_client::call() can throw Exception, wrap it into moodle_exception.
-            throw new moodle_exception('errorws', 'hub', '', $e->getMessage());
-        }
+        return self::call_rest($token, $function, $data);
     }
 
     /**
index 7dc8be6..187ab85 100644 (file)
@@ -158,8 +158,8 @@ class registration {
         $cleanhuburl = clean_param(HUB_MOODLEORGHUBURL, PARAM_ALPHANUMEXT);
         foreach (self::FORM_FIELDS as $field) {
             $siteinfo[$field] = get_config('hub', 'site_'.$field.'_' . $cleanhuburl);
-            if ($siteinfo[$field] === false && array_key_exists($field, $defaults)) {
-                $siteinfo[$field] = $defaults[$field];
+            if ($siteinfo[$field] === false) {
+                $siteinfo[$field] = array_key_exists($field, $defaults) ? $defaults[$field] : null;
             }
         }
 
index 77137af..8c01012 100644 (file)
@@ -31,6 +31,30 @@ defined('MOODLE_INTERNAL') || die();
 class gradingform extends base {
 
     public function is_uninstall_allowed() {
-        return false;
+        return true;
+    }
+
+    /**
+     * Pre-uninstall hook.
+     * This is intended for disabling of plugin, some DB table purging, etc.
+     */
+    public function uninstall_cleanup() {
+        global $DB;
+
+        // Find all definitions and templates.
+        $definitions = $DB->get_fieldset_select('grading_definitions', 'id', 'method = ?', [$this->name]);
+        if ($definitions) {
+            // Delete instances and definitions. Deleting instance will not delete grades because they were
+            // already pushed to the module and gradebook.
+            list($sqld, $paramsd) = $DB->get_in_or_equal($definitions);
+            $DB->delete_records_select('grading_instances', 'definitionid ' . $sqld, $paramsd);
+            $DB->delete_records_select('grading_definitions', 'id ' . $sqld, $paramsd);
+        }
+        // Delete templates for this grading method.
+        $DB->delete_records_select('grading_areas', 'component = ? AND activemethod = ?', array('core_grading', $this->name));
+        // Update the remaining grading areas to use simple grading method instead of this grading method.
+        $DB->execute('UPDATE {grading_areas} SET activemethod = NULL WHERE activemethod = ?', [$this->name]);
+
+        parent::uninstall_cleanup();
     }
 }
index 780a3f4..fefab50 100644 (file)
@@ -157,39 +157,61 @@ class redis extends handler {
             throw new exception('redissessionhandlerproblem', 'error');
         }
 
-        try {
-            // One second timeout was chosen as it is long for connection, but short enough for a user to be patient.
-            if (!$this->connection->connect($this->host, $this->port, 1)) {
-                throw new RedisException('Unable to connect to host.');
-            }
+        // MDL-59866: Add retries for connections (up to 5 times) to make sure it goes through.
+        $counter = 1;
+        $maxnumberofretries = 5;
+
+        while ($counter <= $maxnumberofretries) {
+
+            try {
+
+                $delay = rand(100000, 500000);
 
-            if ($this->auth !== '') {
-                if (!$this->connection->auth($this->auth)) {
-                    throw new RedisException('Unable to authenticate.');
+                // One second timeout was chosen as it is long for connection, but short enough for a user to be patient.
+                if (!$this->connection->connect($this->host, $this->port, 1, null, $delay)) {
+                    throw new RedisException('Unable to connect to host.');
                 }
-            }
 
-            if (!$this->connection->setOption(\Redis::OPT_SERIALIZER, $this->serializer)) {
-                throw new RedisException('Unable to set Redis PHP Serializer option.');
-            }
+                if ($this->auth !== '') {
+                    if (!$this->connection->auth($this->auth)) {
+                        throw new RedisException('Unable to authenticate.');
+                    }
+                }
 
-            if ($this->prefix !== '') {
-                // Use custom prefix on sessions.
-                if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) {
-                    throw new RedisException('Unable to set Redis Prefix option.');
+                if (!$this->connection->setOption(\Redis::OPT_SERIALIZER, $this->serializer)) {
+                    throw new RedisException('Unable to set Redis PHP Serializer option.');
                 }
-            }
-            if ($this->database !== 0) {
-                if (!$this->connection->select($this->database)) {
-                    throw new RedisException('Unable to select Redis database '.$this->database.'.');
+
+                if ($this->prefix !== '') {
+                    // Use custom prefix on sessions.
+                    if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) {
+                        throw new RedisException('Unable to set Redis Prefix option.');
+                    }
+                }
+                if ($this->database !== 0) {
+                    if (!$this->connection->select($this->database)) {
+                        throw new RedisException('Unable to select Redis database '.$this->database.'.');
+                    }
                 }
+                $this->connection->ping();
+                return true;
+            } catch (RedisException $e) {
+                $logstring = "Failed to connect (try {$counter} out of {$maxnumberofretries}) to redis ";
+                $logstring .= "at {$this->host}:{$this->port}, error returned was: {$e->getMessage()}";
+
+                // @codingStandardsIgnoreStart
+                error_log($logstring);
+                // @codingStandardsIgnoreEnd
             }
-            $this->connection->ping();
-            return true;
-        } catch (RedisException $e) {
-            error_log('Failed to connect to redis at '.$this->host.':'.$this->port.', error returned was: '.$e->getMessage());
-            return false;
+
+            $counter++;
+
+            // Introduce a random sleep between 100ms and 500ms.
+            usleep(rand(100000, 500000));
         }
+
+        // We have exhausted our retries, time to give up.
+        return false;
     }
 
     /**
index d5c3939..b9aa9a3 100644 (file)
@@ -51,8 +51,8 @@ class delete_unconfirmed_users_task extends scheduled_task {
             $cuttime = $timenow - ($CFG->deleteunconfirmed * 3600);
             $rs = $DB->get_recordset_sql ("SELECT *
                                              FROM {user}
-                                            WHERE confirmed = 0 AND firstaccess > 0
-                                                  AND firstaccess < ? AND deleted = 0", array($cuttime));
+                                            WHERE confirmed = 0 AND timecreated > 0
+                                                  AND timecreated < ? AND deleted = 0", array($cuttime));
             foreach ($rs as $user) {
                 delete_user($user); // We MUST delete user properly first.
                 $DB->delete_records('user', array('id' => $user->id)); // This is a bloody hack, but it might work.
index c1a4efb..821cf37 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20171026" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20171205" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <KEY NAME="categoryid" TYPE="foreign" FIELDS="categoryid" REFTABLE="course_categories" REFFIELDS="id"/>
+        <KEY NAME="subscriptionid" TYPE="foreign" FIELDS="subscriptionid" REFTABLE="event_subscriptions" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="courseid" UNIQUE="false" FIELDS="courseid"/>
         <INDEX NAME="userid" UNIQUE="false" FIELDS="userid"/>
         <INDEX NAME="timestart" UNIQUE="false" FIELDS="timestart"/>
         <INDEX NAME="timeduration" UNIQUE="false" FIELDS="timeduration"/>
+        <INDEX NAME="uuid" UNIQUE="false" FIELDS="uuid"/>
         <INDEX NAME="type-timesort" UNIQUE="false" FIELDS="type, timesort"/>
         <INDEX NAME="groupid-courseid-categoryid-visible-userid" UNIQUE="false" FIELDS="groupid, courseid, categoryid, visible, userid" COMMENT="used for calendar view"/>
       </INDEXES>
       </KEYS>
     </TABLE>
   </TABLES>
-</XMLDB>
+</XMLDB>
\ No newline at end of file
index c346a9a..31019f5 100644 (file)
@@ -1837,5 +1837,27 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017111300.02);
     }
 
+    if ($oldversion < 2017121200.00) {
+
+        // Define key subscriptionid (foreign) to be added to event.
+        $table = new xmldb_table('event');
+        $key = new xmldb_key('subscriptionid', XMLDB_KEY_FOREIGN, array('subscriptionid'), 'event_subscriptions', array('id'));
+
+        // Launch add key subscriptionid.
+        $dbman->add_key($table, $key);
+
+        // Define index uuid (not unique) to be added to event.
+        $table = new xmldb_table('event');
+        $index = new xmldb_index('uuid', XMLDB_INDEX_NOTUNIQUE, array('uuid'));
+
+        // Conditionally launch add index uuid.
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017121200.00);
+    }
+
     return true;
 }
index 1b72c00..a16b933 100644 (file)
@@ -2402,21 +2402,25 @@ abstract class moodle_database {
 
         // Enclose the column name by the proper quotes if it's a reserved word.
         $columnname = $this->get_manager()->generator->getEncQuoted($column->name);
+
+        $searchsql = $this->sql_like($columnname, '?');
+        $searchparam = '%'.$this->sql_like_escape($search).'%';
+
         $sql = "UPDATE {".$table."}
                        SET $columnname = REPLACE($columnname, ?, ?)
-                     WHERE $columnname IS NOT NULL";
+                     WHERE $searchsql";
 
         if ($column->meta_type === 'X') {
-            $this->execute($sql, array($search, $replace));
+            $this->execute($sql, array($search, $replace, $searchparam));
 
         } else if ($column->meta_type === 'C') {
             if (core_text::strlen($search) < core_text::strlen($replace)) {
                 $colsize = $column->max_length;
                 $sql = "UPDATE {".$table."}
                        SET $columnname = " . $this->sql_substr("REPLACE(" . $columnname . ", ?, ?)", 1, $colsize) . "
-                     WHERE $columnname IS NOT NULL";
+                     WHERE $searchsql";
             }
-            $this->execute($sql, array($search, $replace));
+            $this->execute($sql, array($search, $replace, $searchparam));
         }
     }
 
index 850fb5f..1d6dec9 100644 (file)
@@ -30,6 +30,8 @@
  *  - bit ops: To provide cross-db bitwise operations to be used by the
  *             sql_bitXXX() helper functions
  *  - one space hacks: One space empty string substitute hacks.
+ *
+ * Moodle will not parse this file correctly if it uses Windows line endings.
  */
 
 CREATE OR REPLACE PACKAGE MOODLELIB AS
index d69094b..1a73b2d 100644 (file)
@@ -544,26 +544,32 @@ function enrol_add_course_navigation(navigation_node $coursenode, $course) {
 /**
  * Returns list of courses current $USER is enrolled in and can access
  *
- * - $fields is an array of field names to ADD
- *   so name the fields you really need, which will
- *   be added and uniq'd
+ * The $fields param is a list of field names to ADD so name just the fields you really need,
+ * which will be added and uniq'd.
  *
  * If $allaccessible is true, this will additionally return courses that the current user is not
  * enrolled in, but can access because they are open to the user for other reasons (course view
  * permission, currently viewing course as a guest, or course allows guest access without
  * password).
  *
- * @param string|array $fields
- * @param string $sort
+ * @param string|array $fields Extra fields to be returned (array or comma-separated list).
+ * @param string|null $sort Comma separated list of fields to sort by, defaults to respecting navsortmycoursessort.
  * @param int $limit max number of courses
  * @param array $courseids the list of course ids to filter by
  * @param bool $allaccessible Include courses user is not enrolled in, but can access
  * @return array
  */
-function enrol_get_my_courses($fields = null, $sort = 'visible DESC,sortorder ASC',
-          $limit = 0, $courseids = [], $allaccessible = false) {
+function enrol_get_my_courses($fields = null, $sort = null, $limit = 0, $courseids = [], $allaccessible = false) {
     global $DB, $USER, $CFG;
 
+    if ($sort === null) {
+        if (empty($CFG->navsortmycoursessort)) {
+            $sort = 'visible DESC, sortorder ASC';
+        } else {
+            $sort = 'visible DESC, '.$CFG->navsortmycoursessort.' ASC';
+        }
+    }
+
     // Guest account does not have any enrolled courses.
     if (!$allaccessible && (isguestuser() or !isloggedin())) {
         return array();
@@ -584,7 +590,7 @@ function enrol_get_my_courses($fields = null, $sort = 'visible DESC,sortorder AS
     } else if (is_array($fields)) {
         $fields = array_unique(array_merge($basefields, $fields));
     } else {
-        throw new coding_exception('Invalid $fileds parameter in enrol_get_my_courses()');
+        throw new coding_exception('Invalid $fields parameter in enrol_get_my_courses()');
     }
     if (in_array('*', $fields)) {
         $fields = array('*');
@@ -788,19 +794,19 @@ function enrol_get_course_description_texts($course) {
 
 /**
  * Returns list of courses user is enrolled into.
- * (Note: use enrol_get_all_users_courses if you want to use the list wihtout any cap checks )
  *
- * - $fields is an array of fieldnames to ADD
- *   so name the fields you really need, which will
- *   be added and uniq'd
+ * Note: Use {@link enrol_get_all_users_courses()} if you need the list without any capability checks.
  *
- * @param int $userid
- * @param bool $onlyactive return only active enrolments in courses user may see
- * @param string|array $fields
- * @param string $sort
+ * The $fields param is a list of field names to ADD so name just the fields you really need,
+ * which will be added and uniq'd.
+ *
+ * @param int $userid User whose courses are returned, defaults to the current user.
+ * @param bool $onlyactive Return only active enrolments in courses user may see.
+ * @param string|array $fields Extra fields to be returned (array or comma-separated list).
+ * @param string|null $sort Comma separated list of fields to sort by, defaults to respecting navsortmycoursessort.
  * @return array
  */
-function enrol_get_users_courses($userid, $onlyactive = false, $fields = NULL, $sort = 'visible DESC,sortorder ASC') {
+function enrol_get_users_courses($userid, $onlyactive = false, $fields = null, $sort = null) {
     global $DB;
 
     $courses = enrol_get_all_users_courses($userid, $onlyactive, $fields, $sort);
@@ -877,19 +883,27 @@ function enrol_user_sees_own_courses($user = null) {
 }
 
 /**
- * Returns list of courses user is enrolled into without any capability checks
- * - $fields is an array of fieldnames to ADD
- *   so name the fields you really need, which will
- *   be added and uniq'd
+ * Returns list of courses user is enrolled into without performing any capability checks.
  *
- * @param int $userid
- * @param bool $onlyactive return only active enrolments in courses user may see
- * @param string|array $fields
- * @param string $sort
+ * The $fields param is a list of field names to ADD so name just the fields you really need,
+ * which will be added and uniq'd.
+ *
+ * @param int $userid User whose courses are returned, defaults to the current user.
+ * @param bool $onlyactive Return only active enrolments in courses user may see.
+ * @param string|array $fields Extra fields to be returned (array or comma-separated list).
+ * @param string|null $sort Comma separated list of fields to sort by, defaults to respecting navsortmycoursessort.
  * @return array
  */
-function enrol_get_all_users_courses($userid, $onlyactive = false, $fields = NULL, $sort = 'visible DESC,sortorder ASC') {
-    global $DB;
+function enrol_get_all_users_courses($userid, $onlyactive = false, $fields = null, $sort = null) {
+    global $CFG, $DB;
+
+    if ($sort === null) {
+        if (empty($CFG->navsortmycoursessort)) {
+            $sort = 'visible DESC, sortorder ASC';
+        } else {
+            $sort = 'visible DESC, '.$CFG->navsortmycoursessort.' ASC';
+        }
+    }
 
     // Guest account does not have any courses
     if (isguestuser($userid) or empty($userid)) {
@@ -912,7 +926,7 @@ function enrol_get_all_users_courses($userid, $onlyactive = false, $fields = NUL
     } else if (is_array($fields)) {
         $fields = array_unique(array_merge($basefields, $fields));
     } else {
-        throw new coding_exception('Invalid $fileds parameter in enrol_get_my_courses()');
+        throw new coding_exception('Invalid $fields parameter in enrol_get_all_users_courses()');
     }
     if (in_array('*', $fields)) {
         $fields = array('*');
index 36f186f..c1a4254 100644 (file)
@@ -304,7 +304,9 @@ class stored_file {
         $filerecord->filesize = $newfile->get_filesize();
         $filerecord->referencefileid = $newfile->get_referencefileid();
         $filerecord->userid = $newfile->get_userid();
+        $oldcontenthash = $this->get_contenthash();
         $this->update($filerecord);
+        $this->filesystem->remove_file($oldcontenthash);
     }
 
     /**
index b62eaf9..ef3719f 100644 (file)
@@ -244,7 +244,11 @@ class MoodleQuickForm_tags extends MoodleQuickForm_autocomplete {
      */
     public function exportValue(&$submitValues, $assoc = false) {
         if (!$this->is_tagging_enabled()) {
-            return $assoc ? array($this->getName() => array()) : array();
+            return $this->_prepareValue([], $assoc);
+        }
+        if ($this->_findValue($submitValues) === '_qf__force_multiselect_submission') {
+            // Nothing was selected.
+            return $this->_prepareValue([], $assoc);
         }
 
         return parent::exportValue($submitValues, $assoc);
@@ -257,6 +261,7 @@ class MoodleQuickForm_tags extends MoodleQuickForm_autocomplete {
             $url = new moodle_url('/tag/manage.php', array('tc' => $this->get_tag_collection()));
             $context['managestandardtagsurl'] = $url->out(false);
         }
+        $context['nameraw'] = $this->getName();
 
         return $context;
     }
index c873280..5ce9104 100644 (file)
@@ -225,7 +225,7 @@ function core_myprofile_navigation(core_user\output\myprofile\tree $tree, $user,
 
     if (!isset($hiddenfields['mycourses'])) {
         $showallcourses = optional_param('showallcourses', 0, PARAM_INT);
-        if ($mycourses = enrol_get_all_users_courses($user->id, true, null, 'visible DESC, sortorder ASC')) {
+        if ($mycourses = enrol_get_all_users_courses($user->id, true, null)) {
             $shown = 0;
             $courselisting = html_writer::start_tag('ul');
             foreach ($mycourses as $mycourse) {
index 9f22f18..16d7b63 100644 (file)
@@ -2920,14 +2920,7 @@ class global_navigation extends navigation_node {
 
         $limit = (int) $CFG->navcourselimit;
 
-        $sortorder = 'visible DESC';
-        // Prevent undefined $CFG->navsortmycoursessort errors.
-        if (empty($CFG->navsortmycoursessort)) {
-            $CFG->navsortmycoursessort = 'sortorder';
-        }
-        // Append the chosen sortorder.
-        $sortorder = $sortorder . ',' . $CFG->navsortmycoursessort . ' ASC';
-        $courses = enrol_get_my_courses('*', $sortorder);
+        $courses = enrol_get_my_courses('*');
         $flatnavcourses = [];
 
         // Go through the courses and see which ones we want to display in the flatnav.
index 9e27b4b..a4d16e5 100644 (file)
@@ -1707,6 +1707,25 @@ class core_accesslib_testcase extends advanced_testcase {
         $generator = $this->getDataGenerator();
         $cap = 'moodle/course:view';
 
+        // The structure being created here is this:
+        //
+        // All tests work with the single capability 'moodle/course:view'.
+        //
+        //             ROLE DEF/OVERRIDE                        ROLE ASSIGNS
+        //    Role:  Allow    Prohib    Empty   Def user      u1  u2  u3  u4   u5  u6  u7  u8
+        // System    ALLOW    PROHIBIT                            A   E   A+E
+        //   cat1                       ALLOW
+        //     C1                               (ALLOW)                            P
+        //     C2             ALLOW                                                    E   P
+        //     cat2                     PREVENT
+        //       C3                     ALLOW                                      E
+        //       C4
+        //   Misc.                                                             A
+        //     C5    PREVENT                                                       A
+        //     C6                       PROHIBIT
+        //
+        // Front-page and guest role stuff from the end of this test not included in the diagram.
+
         // Create a role which allows course:view and one that prohibits it, and one neither.
         $allowroleid = $generator->create_role();
         $prohibitroleid = $generator->create_role();
@@ -1720,6 +1739,7 @@ class core_accesslib_testcase extends advanced_testcase {
         $cat2 = $generator->create_category(['parent' => $cat1->id]);
 
         // Create six courses - two in cat1, two in cat2, and two in default category.
+        // Shortnames are used for a sorting test. Otherwise they are not significant.
         $c1 = $generator->create_course(['category' => $cat1->id, 'shortname' => 'Z']);
         $c2 = $generator->create_course(['category' => $cat1->id, 'shortname' => 'Y']);
         $c3 = $generator->create_course(['category' => $cat2->id, 'shortname' => 'X']);
@@ -1741,6 +1761,8 @@ class core_accesslib_testcase extends advanced_testcase {
                 context_course::instance($c6->id)->id);
         assign_capability($cap, CAP_ALLOW, $emptyroleid,
                 context_course::instance($c3->id)->id);
+        assign_capability($cap, CAP_ALLOW, $prohibitroleid,
+                context_course::instance($c2->id)->id);
 
         // User 1 has no roles except default user role.
         $u1 = $generator->create_user();
@@ -1800,6 +1822,22 @@ class core_accesslib_testcase extends advanced_testcase {
         $courses = get_user_capability_course($cap, $u6->id, true, '', 'id');
         $this->assert_course_ids([$c3->id], $courses);
 
+        // User 7 has empty role in C2.
+        $u7 = $generator->create_user();
+        role_assign($emptyroleid, $u7->id, context_course::instance($c2->id)->id);
+
+        // Should get C1 by the default user role override, and C2 by the cat1 level override.
+        $courses = get_user_capability_course($cap, $u7->id, true, '', 'id');
+        $this->assert_course_ids([$c1->id, $c2->id], $courses);
+
+        // User 8 has prohibit role as system context, to verify that prohibits can't be overridden.
+        $u8 = $generator->create_user();
+        role_assign($prohibitroleid, $u8->id, context_course::instance($c2->id)->id);
+
+        // Should get C1 by the default user role override, no other courses because the prohibit cannot be overridden.
+        $courses = get_user_capability_course($cap, $u8->id, true, '', 'id');
+        $this->assert_course_ids([$c1->id], $courses);
+
         // Admin user gets everything....
         $courses = get_user_capability_course($cap, get_admin()->id, true, '', 'id');
         $this->assert_course_ids([SITEID, $c1->id, $c2->id, $c3->id, $c4->id, $c5->id, $c6->id],
index 2b2744a..f178315 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * Unit tests for /lib/gradelib.php.
  *
- * @package   core_grade
+ * @package   core_grades
  * @category  phpunit
  * @copyright 2012 Andrew Davis
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
index 6aec217..fe6a7ec 100644 (file)
@@ -39,6 +39,8 @@ $string['command'] = 'Command:';
 $string['commentcontextmenu'] = 'Comment context menu';
 $string['couldnotsavepage'] = 'Could not save page {$a}';
 $string['currentstamp'] = 'Stamp';
+$string['default'] = 'Enabled by default';
+$string['default_help'] = 'If set, this feedback method will be enabled by default for all new assignments.';
 $string['deleteannotation'] = 'Delete annotation';
 $string['deletecomment'] = 'Delete comment';
 $string['deletefeedback'] = 'Delete feedback PDF';
index c8bc5cf..5da7e4b 100644 (file)
@@ -332,12 +332,11 @@ class assign_feedback_editpdf extends assign_feedback_plugin {
     }
 
     /**
-     * Automatically enable or disable editpdf feedback plugin based on
-     * whether the ghostscript path is set correctly.
+     * Determine if ghostscript is available and working.
      *
      * @return bool
      */
-    public function is_enabled() {
+    public function is_available() {
         if ($this->enabledcache === null) {
             $testpath = assignfeedback_editpdf\pdf::test_gs_path(false);
             $this->enabledcache = ($testpath->status == assignfeedback_editpdf\pdf::GSPATH_OK);
@@ -345,12 +344,12 @@ class assign_feedback_editpdf extends assign_feedback_plugin {
         return $this->enabledcache;
     }
     /**
-     * Automatically hide the setting for the editpdf feedback plugin.
+     * Prevent enabling this plugin if ghostscript is not available.
      *
      * @return bool false
      */
     public function is_configurable() {
-        return false;
+        return $this->is_available();
     }
 
     /**
index 674a21f..64355fd 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
+// Enabled by default.
+$settings->add(new admin_setting_configcheckbox('assignfeedback_editpdf/default',
+                   new lang_string('default', 'assignfeedback_editpdf'),
+                   new lang_string('default_help', 'assignfeedback_editpdf'), 1));
+
 // Stamp files setting.
 $name = 'assignfeedback_editpdf/stamps';
 $title = get_string('stamps','assignfeedback_editpdf');
index 8a82199..2a02a85 100644 (file)
@@ -260,7 +260,7 @@ class assign_submission_file extends assign_submission_plugin {
         $groupid = 0;
         // Get the group name as other fields are not transcribed in the logs and this information is important.
         if (empty($submission->userid) && !empty($submission->groupid)) {
-            $groupname = $DB->get_field('groups', 'name', array('id' => $submission->groupid), '*', MUST_EXIST);
+            $groupname = $DB->get_field('groups', 'name', array('id' => $submission->groupid), MUST_EXIST);
             $groupid = $submission->groupid;
         } else {
             $params['relateduserid'] = $submission->userid;
index d75de5a..1a03ee5 100644 (file)
@@ -239,7 +239,7 @@ class assign_submission_onlinetext extends assign_submission_plugin {
         $groupid = 0;
         // Get the group name as other fields are not transcribed in the logs and this information is important.
         if (empty($submission->userid) && !empty($submission->groupid)) {
-            $groupname = $DB->get_field('groups', 'name', array('id' => $submission->groupid), '*', MUST_EXIST);
+            $groupname = $DB->get_field('groups', 'name', array('id' => $submission->groupid), MUST_EXIST);
             $groupid = $submission->groupid;
         } else {
             $params['relateduserid'] = $submission->userid;
index 3e6851f..30db083 100644 (file)
@@ -62,6 +62,7 @@ $string['cannotadd'] = 'Can not add entries!';
 $string['cannotdeletepreset'] = 'Error deleting a preset!';
 $string['cannotoverwritepreset'] = 'Error overwriting preset';
 $string['cannotunziptopreset'] = 'Cannot unzip to the preset directory';
+$string['closebeforeopen'] = 'You have specified an end date before the start date.';
 $string['columns'] = 'columns';
 $string['comment'] = 'Comment';
 $string['commentdeleted'] = 'Comment deleted';
@@ -284,6 +285,7 @@ $string['numberrssarticles'] = 'Entries in the RSS feed';
 $string['numnotapproved'] = 'Pending';
 $string['numrecords'] = '{$a} entries';
 $string['ods'] = '<acronym title="OpenDocument Spreadsheet">ODS</acronym> (OpenOffice)';
+$string['openafterclose'] = 'You have specified an open date after the close date';
 $string['optionaldescription'] = 'Short description (optional)';
 $string['optionalfilename'] = 'Filename (optional)';
 $string['other'] = 'Other';
index 3f46920..c150d23 100644 (file)
@@ -4554,3 +4554,117 @@ function mod_data_get_completion_active_rule_descriptions($cm) {
     }
     return $descriptions;
 }
+
+/**
+ * This function calculates the minimum and maximum cutoff values for the timestart of
+ * the given event.
+ *
+ * It will return an array with two values, the first being the minimum cutoff value and
+ * the second being the maximum cutoff value. Either or both values can be null, which
+ * indicates there is no minimum or maximum, respectively.
+ *
+ * If a cutoff is required then the function must return an array containing the cutoff
+ * timestamp and error string to display to the user if the cutoff value is violated.
+ *
+ * A minimum and maximum cutoff return value will look like:
+ * [
+ *     [1505704373, 'The due date must be after the sbumission start date'],
+ *     [1506741172, 'The due date must be before the cutoff date']
+ * ]
+ *
+ * @param calendar_event $event The calendar event to get the time range for
+ * @param stdClass $instance The module instance to get the range from
+ * @return array
+ */
+function mod_data_core_calendar_get_valid_event_timestart_range(\calendar_event $event, \stdClass $instance) {
+    $mindate = null;
+    $maxdate = null;
+
+    if ($event->eventtype == DATA_EVENT_TYPE_OPEN) {
+        // The start time of the open event can't be equal to or after the
+        // close time of the database activity.
+        if (!empty($instance->timeavailableto)) {
+            $maxdate = [
+                $instance->timeavailableto,
+                get_string('openafterclose', 'data')
+            ];
+        }
+    } else if ($event->eventtype == DATA_EVENT_TYPE_CLOSE) {
+        // The start time of the close event can't be equal to or earlier than the
+        // open time of the database activity.
+        if (!empty($instance->timeavailablefrom)) {
+            $mindate = [
+                $instance->timeavailablefrom,
+                get_string('closebeforeopen', 'data')
+            ];
+        }
+    }
+
+    return [$mindate, $maxdate];
+}
+
+/**
+ * This function will update the data module according to the
+ * event that has been modified.
+ *
+ * It will set the timeopen or timeclose value of the data instance
+ * according to the type of event provided.
+ *
+ * @throws \moodle_exception
+ * @param \calendar_event $event
+ * @param stdClass $data The module instance to get the range from
+ */
+function mod_data_core_calendar_event_timestart_updated(\calendar_event $event, \stdClass $data) {
+    global $DB;
+
+    if (empty($event->instance) || $event->modulename != 'data') {
+        return;
+    }
+
+    if ($event->instance != $data->id) {
+        return;
+    }
+
+    if (!in_array($event->eventtype, [DATA_EVENT_TYPE_OPEN, DATA_EVENT_TYPE_CLOSE])) {
+        return;
+    }
+
+    $courseid = $event->courseid;
+    $modulename = $event->modulename;
+    $instanceid = $event->instance;
+    $modified = false;
+
+    $coursemodule = get_fast_modinfo($courseid)->instances[$modulename][$instanceid];
+    $context = context_module::instance($coursemodule->id);
+
+    // The user does not have the capability to modify this activity.
+    if (!has_capability('moodle/course:manageactivities', $context)) {
+        return;
+    }
+
+    if ($event->eventtype == DATA_EVENT_TYPE_OPEN) {
+        // If the event is for the data activity opening then we should
+        // set the start time of the data activity to be the new start
+        // time of the event.
+        if ($data->timeavailablefrom != $event->timestart) {
+            $data->timeavailablefrom = $event->timestart;
+            $data->timemodified = time();
+            $modified = true;
+        }
+    } else if ($event->eventtype == DATA_EVENT_TYPE_CLOSE) {
+        // If the event is for the data activity closing then we should
+        // set the end time of the data activity to be the new start
+        // time of the event.
+        if ($data->timeavailableto != $event->timestart) {
+            $data->timeavailableto = $event->timestart;
+            $modified = true;
+        }
+    }
+
+    if ($modified) {
+        $data->timemodified = time();
+        $DB->update_record('data', $data);
+        $event = \core\event\course_module_updated::create_from_cm($coursemodule, $context);
+        $event->trigger();
+    }
+}
index 351879b..69e93f6 100644 (file)
@@ -1431,9 +1431,10 @@ class mod_data_lib_testcase extends advanced_testcase {
      * @param int $courseid
      * @param int $instanceid The data id.
      * @param string $eventtype The event type. eg. DATA_EVENT_TYPE_OPEN.
+     * @param int|null $timestart The start timestamp for the event
      * @return bool|calendar_event
      */
-    private function create_action_event($courseid, $instanceid, $eventtype) {
+    private function create_action_event($courseid, $instanceid, $eventtype, $timestart = null) {
         $event = new stdClass();
         $event->name = 'Calendar event';
         $event->modulename  = 'data';
@@ -1441,7 +1442,11 @@ class mod_data_lib_testcase extends advanced_testcase {
         $event->instance = $instanceid;
         $event->type = CALENDAR_EVENT_TYPE_ACTION;
         $event->eventtype = $eventtype;
-        $event->timestart = time();
+        if ($timestart) {
+            $event->timestart = $timestart;
+        } else {
+            $event->timestart = time();
+        }
 
         return calendar_event::create($event);
     }
@@ -1483,4 +1488,288 @@ class mod_data_lib_testcase extends advanced_testcase {
         $this->assertEquals(mod_data_get_completion_active_rule_descriptions($moddefaults), $activeruledescriptions);
         $this->assertEquals(mod_data_get_completion_active_rule_descriptions(new stdClass()), []);
     }
+
+    /**
+     * An unknown event type should not change the data instance.
+     */
+    public function test_mod_data_core_calendar_event_timestart_updated_unknown_event() {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $datagenerator = $generator->get_plugin_generator('mod_data');
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $data = $datagenerator->create_instance(['course' => $course->id]);
+        $data->timeavailablefrom = $timeopen;
+        $data->timeavailableto = $timeclose;
+        $DB->update_record('data', $data);
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'data',
+            'instance' => $data->id,
+            'eventtype' => DATA_EVENT_TYPE_OPEN . "SOMETHING ELSE",
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        mod_data_core_calendar_event_timestart_updated($event, $data);
+        $data = $DB->get_record('data', ['id' => $data->id]);
+        $this->assertEquals($timeopen, $data->timeavailablefrom);
+        $this->assertEquals($timeclose, $data->timeavailableto);
+    }
+
+    /**
+     * A DATA_EVENT_TYPE_OPEN event should update the timeavailablefrom property of the data activity.
+     */
+    public function test_mod_data_core_calendar_event_timestart_updated_open_event() {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $datagenerator = $generator->get_plugin_generator('mod_data');
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $timemodified = 1;
+        $newtimeopen = $timeopen - DAYSECS;
+        $data = $datagenerator->create_instance(['course' => $course->id]);
+        $data->timeavailablefrom = $timeopen;
+        $data->timeavailableto = $timeclose;
+        $data->timemodified = $timemodified;
+        $DB->update_record('data', $data);
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'data',
+            'instance' => $data->id,
+            'eventtype' => DATA_EVENT_TYPE_OPEN,
+            'timestart' => $newtimeopen,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        // Trigger and capture the event when adding a contact.
+        $sink = $this->redirectEvents();
+        mod_data_core_calendar_event_timestart_updated($event, $data);
+        $triggeredevents = $sink->get_events();
+        $moduleupdatedevents = array_filter($triggeredevents, function($e) {
+            return is_a($e, 'core\event\course_module_updated');
+        });
+        $data = $DB->get_record('data', ['id' => $data->id]);
+
+        // Ensure the timeavailablefrom property matches the event timestart.
+        $this->assertEquals($newtimeopen, $data->timeavailablefrom);
+        // Ensure the timeavailableto isn't changed.
+        $this->assertEquals($timeclose, $data->timeavailableto);
+        // Ensure the timemodified property has been changed.
+        $this->assertNotEquals($timemodified, $data->timemodified);
+        // Confirm that a module updated event is fired when the module is changed.
+        $this->assertNotEmpty($moduleupdatedevents);
+    }
+
+    /**
+     * A DATA_EVENT_TYPE_CLOSE event should update the timeavailableto property of the data activity.
+     */
+    public function test_mod_data_core_calendar_event_timestart_updated_close_event() {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $datagenerator = $generator->get_plugin_generator('mod_data');
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $timemodified = 1;
+        $newtimeclose = $timeclose + DAYSECS;
+        $data = $datagenerator->create_instance(['course' => $course->id]);
+        $data->timeavailablefrom = $timeopen;
+        $data->timeavailableto = $timeclose;
+        $data->timemodified = $timemodified;
+        $DB->update_record('data', $data);
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'data',
+            'instance' => $data->id,
+            'eventtype' => DATA_EVENT_TYPE_CLOSE,
+            'timestart' => $newtimeclose,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        // Trigger and capture the event when adding a contact.
+        $sink = $this->redirectEvents();
+        mod_data_core_calendar_event_timestart_updated($event, $data);
+        $triggeredevents = $sink->get_events();
+        $moduleupdatedevents = array_filter($triggeredevents, function($e) {
+            return is_a($e, 'core\event\course_module_updated');
+        });
+        $data = $DB->get_record('data', ['id' => $data->id]);
+
+        // Ensure the timeavailableto property matches the event timestart.
+        $this->assertEquals($newtimeclose, $data->timeavailableto);
+        // Ensure the timeavailablefrom isn't changed.
+        $this->assertEquals($timeopen, $data->timeavailablefrom);
+        // Ensure the timemodified property has been changed.
+        $this->assertNotEquals($timemodified, $data->timemodified);
+        // Confirm that a module updated event is fired when the module is changed.
+        $this->assertNotEmpty($moduleupdatedevents);
+    }
+
+    /**
+     * An unknown event type should not have any limits.
+     */
+    public function test_mod_data_core_calendar_get_valid_event_timestart_range_unknown_event() {
+        global $CFG;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $data = new \stdClass();
+        $data->timeavailablefrom = $timeopen;
+        $data->timeavailableto = $timeclose;
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'data',
+            'instance' => 1,
+            'eventtype' => DATA_EVENT_TYPE_OPEN . "SOMETHING ELSE",
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        list ($min, $max) = mod_data_core_calendar_get_valid_event_timestart_range($event, $data);
+        $this->assertNull($min);
+        $this->assertNull($max);
+    }
+
+    /**
+     * The open event should be limited by the data's timeclose property, if it's set.
+     */
+    public function test_mod_data_core_calendar_get_valid_event_timestart_range_open_event() {
+        global $CFG;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $data = new \stdClass();
+        $data->timeavailablefrom = $timeopen;
+        $data->timeavailableto = $timeclose;
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'data',
+            'instance' => 1,
+            'eventtype' => DATA_EVENT_TYPE_OPEN,
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        // The max limit should be bounded by the timeclose value.
+        list ($min, $max) = mod_data_core_calendar_get_valid_event_timestart_range($event, $data);
+        $this->assertNull($min);
+        $this->assertEquals($timeclose, $max[0]);
+
+        // No timeclose value should result in no upper limit.
+        $data->timeavailableto = 0;
+        list ($min, $max) = mod_data_core_calendar_get_valid_event_timestart_range($event, $data);
+        $this->assertNull($min);
+        $this->assertNull($max);
+    }
+
+    /**
+     * The close event should be limited by the data's timeavailablefrom property, if it's set.
+     */
+    public function test_mod_data_core_calendar_get_valid_event_timestart_range_close_event() {
+        global $CFG;
+
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $data = new \stdClass();
+        $data->timeavailablefrom = $timeopen;
+        $data->timeavailableto = $timeclose;
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'data',
+            'instance' => 1,
+            'eventtype' => DATA_EVENT_TYPE_CLOSE,
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        // The max limit should be bounded by the timeclose value.
+        list ($min, $max) = mod_data_core_calendar_get_valid_event_timestart_range($event, $data);
+        $this->assertEquals($timeopen, $min[0]);
+        $this->assertNull($max);
+
+        // No timeavailableto value should result in no upper limit.
+        $data->timeavailablefrom = 0;
+        list ($min, $max) = mod_data_core_calendar_get_valid_event_timestart_range($event, $data);
+        $this->assertNull($min);
+        $this->assertNull($max);
+    }
 }
index 4bc9b93..c425235 100644 (file)
@@ -391,6 +391,7 @@ $string['ongoing_help'] = 'If enabled, each page will display the student\'s cur
 $string['ongoingcustom'] = 'You have earned {$a->score} point(s) out of {$a->currenthigh} point(s) thus far.';
 $string['ongoingnormal'] = 'You have answered {$a->correct} correctly out of {$a->viewed} attempts.';
 $string['onpostperpage'] = 'Only one posting per grade';
+$string['openafterclose'] = 'You have specified an open date after the close date';
 $string['options'] = 'Options';
 $string['or'] = 'OR';
 $string['ordered'] = 'Ordered';
index 96931c5..fb55485 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
+// Event types.
+define('LESSON_EVENT_TYPE_OPEN', 'open');
+define('LESSON_EVENT_TYPE_CLOSE', 'close');
+
 /* Do not include any libraries here! */
 
 /**
@@ -1746,3 +1750,117 @@ function mod_lesson_get_completion_active_rule_descriptions($cm) {
     }
     return $descriptions;
 }
+
+/**
+ * This function calculates the minimum and maximum cutoff values for the timestart of
+ * the given event.
+ *
+ * It will return an array with two values, the first being the minimum cutoff value and
+ * the second being the maximum cutoff value. Either or both values can be null, which
+ * indicates there is no minimum or maximum, respectively.
+ *
+ * If a cutoff is required then the function must return an array containing the cutoff
+ * timestamp and error string to display to the user if the cutoff value is violated.
+ *
+ * A minimum and maximum cutoff return value will look like:
+ * [
+ *     [1505704373, 'The due date must be after the start date'],
+ *     [1506741172, 'The due date must be before the cutoff date']
+ * ]
+ *
+ * @param calendar_event $event The calendar event to get the time range for
+ * @param stdClass $instance The module instance to get the range from
+ * @return array
+ */
+function mod_lesson_core_calendar_get_valid_event_timestart_range(\calendar_event $event, \stdClass $instance) {
+    $mindate = null;
+    $maxdate = null;
+
+    if ($event->eventtype == LESSON_EVENT_TYPE_OPEN) {
+        // The start time of the open event can't be equal to or after the
+        // close time of the lesson activity.
+        if (!empty($instance->deadline)) {
+            $maxdate = [
+                $instance->deadline,
+                get_string('openafterclose', 'lesson')
+            ];
+        }
+    } else if ($event->eventtype == LESSON_EVENT_TYPE_CLOSE) {
+        // The start time of the close event can't be equal to or earlier than the
+        // open time of the lesson activity.
+        if (!empty($instance->available)) {
+            $mindate = [
+                $instance->available,
+                get_string('closebeforeopen', 'lesson')
+            ];
+        }
+    }
+
+    return [$mindate, $maxdate];
+}
+
+/**
+ * This function will update the lesson module according to the
+ * event that has been modified.
+ *
+ * It will set the available or deadline value of the lesson instance
+ * according to the type of event provided.
+ *
+ * @throws \moodle_exception
+ * @param \calendar_event $event
+ * @param stdClass $lesson The module instance to get the range from
+ */
+function mod_lesson_core_calendar_event_timestart_updated(\calendar_event $event, \stdClass $lesson) {
+    global $DB;
+
+    if (empty($event->instance) || $event->modulename != 'lesson') {
+        return;
+    }
+
+    if ($event->instance != $lesson->id) {
+        return;
+    }
+
+    if (!in_array($event->eventtype, [LESSON_EVENT_TYPE_OPEN, LESSON_EVENT_TYPE_CLOSE])) {
+        return;
+    }
+
+    $courseid = $event->courseid;
+    $modulename = $event->modulename;
+    $instanceid = $event->instance;
+    $modified = false;
+
+    $coursemodule = get_fast_modinfo($courseid)->instances[$modulename][$instanceid];
+    $context = context_module::instance($coursemodule->id);
+
+    // The user does not have the capability to modify this activity.
+    if (!has_capability('moodle/course:manageactivities', $context)) {
+        return;
+    }
+
+    if ($event->eventtype == LESSON_EVENT_TYPE_OPEN) {
+        // If the event is for the lesson activity opening then we should
+        // set the start time of the lesson activity to be the new start
+        // time of the event.
+        if ($lesson->available != $event->timestart) {
+            $lesson->available = $event->timestart;
+            $lesson->timemodified = time();
+            $modified = true;
+        }
+    } else if ($event->eventtype == LESSON_EVENT_TYPE_CLOSE) {
+        // If the event is for the lesson activity closing then we should
+        // set the end time of the lesson activity to be the new start
+        // time of the event.
+        if ($lesson->deadline != $event->timestart) {
+            $lesson->deadline = $event->timestart;
+            $modified = true;
+        }
+    }
+
+    if ($modified) {
+        $lesson->timemodified = time();
+        $DB->update_record('lesson', $lesson);
+        $event = \core\event\course_module_updated::create_from_cm($coursemodule, $context);
+        $event->trigger();
+    }
+}
index 5eb0341..d6fcf50 100644 (file)
@@ -61,10 +61,6 @@ define("LESSON_MAX_EVENT_LENGTH", "432000");
 /** Answer format is HTML */
 define("LESSON_ANSWER_HTML", "HTML");
 
-// Event types.
-define('LESSON_EVENT_TYPE_OPEN', 'open');
-define('LESSON_EVENT_TYPE_CLOSE', 'close');
-
 //////////////////////////////////////////////////////////////////////////////////////
 /// Any other lesson functions go here.  Each of them must have a name that
 /// starts with lesson_
@@ -3031,7 +3027,7 @@ class lesson extends lesson_base {
                         $this->add_message(get_string('numberofpagesviewednotice', 'lesson', $a));
                     }
 
-                    if (!$reviewmode && !$this->properties->retake) {
+                    if (!$reviewmode && $this->properties->ongoing) {
                         $this->add_message(get_string("numberofcorrectanswers", "lesson", $gradeinfo->earned), 'notify');
                         if ($this->properties->grade != GRADE_TYPE_NONE) {
                             $a = new stdClass;
index 5cd91a2..a122c0f 100644 (file)
@@ -441,4 +441,286 @@ class mod_lesson_lib_testcase extends advanced_testcase {
         $this->assertEquals(mod_lesson_get_completion_active_rule_descriptions($moddefaults), $activeruledescriptions);
         $this->assertEquals(mod_lesson_get_completion_active_rule_descriptions(new stdClass()), []);
     }
+
+    /**
+     * An unknown event type should not change the lesson instance.
+     */
+    public function test_mod_lesson_core_calendar_event_timestart_updated_unknown_event() {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $lessongenerator = $generator->get_plugin_generator('mod_lesson');
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $lesson = $lessongenerator->create_instance(['course' => $course->id]);
+        $lesson->available = $timeopen;
+        $lesson->deadline = $timeclose;
+        $DB->update_record('lesson', $lesson);
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'lesson',
+            'instance' => $lesson->id,
+            'eventtype' => LESSON_EVENT_TYPE_OPEN . "SOMETHING ELSE",
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        mod_lesson_core_calendar_event_timestart_updated($event, $lesson);
+        $lesson = $DB->get_record('lesson', ['id' => $lesson->id]);
+        $this->assertEquals($timeopen, $lesson->available);
+        $this->assertEquals($timeclose, $lesson->deadline);
+    }
+
+    /**
+     * A LESSON_EVENT_TYPE_OPEN event should update the available property of the lesson activity.
+     */
+    public function test_mod_lesson_core_calendar_event_timestart_updated_open_event() {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $lessongenerator = $generator->get_plugin_generator('mod_lesson');
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $timemodified = 1;
+        $newtimeopen = $timeopen - DAYSECS;
+        $lesson = $lessongenerator->create_instance(['course' => $course->id]);
+        $lesson->available = $timeopen;
+        $lesson->deadline = $timeclose;
+        $lesson->timemodified = $timemodified;
+        $DB->update_record('lesson', $lesson);
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'lesson',
+            'instance' => $lesson->id,
+            'eventtype' => LESSON_EVENT_TYPE_OPEN,
+            'timestart' => $newtimeopen,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        // Trigger and capture the event when adding a contact.
+        $sink = $this->redirectEvents();
+        mod_lesson_core_calendar_event_timestart_updated($event, $lesson);
+        $triggeredevents = $sink->get_events();
+        $moduleupdatedevents = array_filter($triggeredevents, function($e) {
+            return is_a($e, 'core\event\course_module_updated');
+        });
+        $lesson = $DB->get_record('lesson', ['id' => $lesson->id]);
+
+        // Ensure the available property matches the event timestart.
+        $this->assertEquals($newtimeopen, $lesson->available);
+
+        // Ensure the deadline isn't changed.
+        $this->assertEquals($timeclose, $lesson->deadline);
+
+        // Ensure the timemodified property has been changed.
+        $this->assertNotEquals($timemodified, $lesson->timemodified);
+
+        // Confirm that a module updated event is fired when the module is changed.
+        $this->assertNotEmpty($moduleupdatedevents);
+    }
+
+    /**
+     * A LESSON_EVENT_TYPE_CLOSE event should update the deadline property of the lesson activity.
+     */
+    public function test_mod_lesson_core_calendar_event_timestart_updated_close_event() {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $lessongenerator = $generator->get_plugin_generator('mod_lesson');
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $timemodified = 1;
+        $newtimeclose = $timeclose + DAYSECS;
+        $lesson = $lessongenerator->create_instance(['course' => $course->id]);
+        $lesson->available = $timeopen;
+        $lesson->deadline = $timeclose;
+        $lesson->timemodified = $timemodified;
+        $DB->update_record('lesson', $lesson);
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'lesson',
+            'instance' => $lesson->id,
+            'eventtype' => LESSON_EVENT_TYPE_CLOSE,
+            'timestart' => $newtimeclose,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+        // Trigger and capture the event when adding a contact.
+        $sink = $this->redirectEvents();
+        mod_lesson_core_calendar_event_timestart_updated($event, $lesson);
+        $triggeredevents = $sink->get_events();
+        $moduleupdatedevents = array_filter($triggeredevents, function($e) {
+            return is_a($e, 'core\event\course_module_updated');
+        });
+        $lesson = $DB->get_record('lesson', ['id' => $lesson->id]);
+        // Ensure the deadline property matches the event timestart.
+        $this->assertEquals($newtimeclose, $lesson->deadline);
+        // Ensure the available isn't changed.
+        $this->assertEquals($timeopen, $lesson->available);
+        // Ensure the timemodified property has been changed.
+        $this->assertNotEquals($timemodified, $lesson->timemodified);
+        // Confirm that a module updated event is fired when the module is changed.
+        $this->assertNotEmpty($moduleupdatedevents);
+    }
+
+    /**
+     * An unknown event type should not have any limits.
+     */
+    public function test_mod_lesson_core_calendar_get_valid_event_timestart_range_unknown_event() {
+        global $CFG;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $lesson = new \stdClass();
+        $lesson->available = $timeopen;
+        $lesson->deadline = $timeclose;
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'lesson',
+            'instance' => 1,
+            'eventtype' => LESSON_EVENT_TYPE_OPEN . "SOMETHING ELSE",
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        list ($min, $max) = mod_lesson_core_calendar_get_valid_event_timestart_range($event, $lesson);
+        $this->assertNull($min);
+        $this->assertNull($max);
+    }
+
+    /**
+     * The open event should be limited by the lesson's deadline property, if it's set.
+     */
+    public function test_mod_lesson_core_calendar_get_valid_event_timestart_range_open_event() {
+        global $CFG;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $lesson = new \stdClass();
+        $lesson->available = $timeopen;
+        $lesson->deadline = $timeclose;
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'lesson',
+            'instance' => 1,
+            'eventtype' => LESSON_EVENT_TYPE_OPEN,
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        // The max limit should be bounded by the timeclose value.
+        list ($min, $max) = mod_lesson_core_calendar_get_valid_event_timestart_range($event, $lesson);
+        $this->assertNull($min);
+        $this->assertEquals($timeclose, $max[0]);
+
+        // No timeclose value should result in no upper limit.
+        $lesson->deadline = 0;
+        list ($min, $max) = mod_lesson_core_calendar_get_valid_event_timestart_range($event, $lesson);
+        $this->assertNull($min);
+        $this->assertNull($max);
+    }
+
+    /**
+     * The close event should be limited by the lesson's available property, if it's set.
+     */
+    public function test_mod_lesson_core_calendar_get_valid_event_timestart_range_close_event() {
+        global $CFG;
+        require_once($CFG->dirroot . "/calendar/lib.php");
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $timeopen = time();
+        $timeclose = $timeopen + DAYSECS;
+        $lesson = new \stdClass();
+        $lesson->available = $timeopen;
+        $lesson->deadline = $timeclose;
+
+        // Create a valid event.
+        $event = new \calendar_event([
+            'name' => 'Test event',
+            'description' => '',
+            'format' => 1,
+            'courseid' => $course->id,
+            'groupid' => 0,
+            'userid' => 2,
+            'modulename' => 'lesson',
+            'instance' => 1,
+            'eventtype' => LESSON_EVENT_TYPE_CLOSE,
+            'timestart' => 1,
+            'timeduration' => 86400,
+            'visible' => 1
+        ]);
+
+        // The max limit should be bounded by the timeclose value.
+        list ($min, $max) = mod_lesson_core_calendar_get_valid_event_timestart_range($event, $lesson);
+        $this->assertEquals($timeopen, $min[0]);
+        $this->assertNull($max);
+
+        // No deadline value should result in no upper limit.
+        $lesson->available = 0;
+        list ($min, $max) = mod_lesson_core_calendar_get_valid_event_timestart_range($event, $lesson);
+        $this->assertNull($min);
+        $this->assertNull($max);
+    }
 }
index b8ff44b..93f2287 100644 (file)
@@ -43,20 +43,8 @@ $event->trigger();
 
 // Print the header.
 $strquizzes = get_string("modulenameplural", "quiz");
-$streditquestions = '';
-$editqcontexts = new question_edit_contexts($coursecontext);
-if ($editqcontexts->have_one_edit_tab_cap('questions')) {
-    $streditquestions =
-            "<form target=\"_parent\" method=\"get\" action=\"$CFG->wwwroot/question/edit.php\">
-               <div>
-               <input type=\"hidden\" name=\"courseid\" value=\"$course->id\" />
-               <input type=\"submit\" value=\"".get_string("editquestions", "quiz")."\" />
-               </div>
-             </form>";
-}
 $PAGE->navbar->add($strquizzes);
 $PAGE->set_title($strquizzes);
-$PAGE->set_button($streditquestions);
 $PAGE->set_heading($course->fullname);
 echo $OUTPUT->header();
 echo $OUTPUT->heading($strquizzes, 2);
index 998a568..9f51c6d 100644 (file)
@@ -46,7 +46,7 @@ class qtype_multianswer extends question_type {
 
         // Get relevant data indexed by positionkey from the multianswers table.
         $sequence = $DB->get_field('question_multianswer', 'sequence',
-                array('question' => $question->id), '*', MUST_EXIST);
+                array('question' => $question->id), MUST_EXIST);
 
         $wrappedquestions = $DB->get_records_list('question', 'id',
                 explode(',', $sequence), 'id ASC');
index 1df982f..8ca37e2 100644 (file)
@@ -100,6 +100,13 @@ class engine extends \core_search\engine {
      */
     protected $skippeddocs = 0;
 
+    /**
+     * Solr server major version.
+     *
+     * @var int
+     */
+    protected $solrmajorversion = null;
+
     /**
      * Initialises the search engine configuration.
      *
@@ -889,6 +896,9 @@ class engine extends \core_search\engine {
 
         $url = $this->get_connection_url('/update/extract');
 
+        // Return results as XML.
+        $url->param('wt', 'xml');
+
         // This will prevent solr from automatically making fields for every tika output.
         $url->param('uprefix', 'ignored_');
 
@@ -1122,12 +1132,18 @@ class engine extends \core_search\engine {
      * @return int
      */
     public function get_solr_major_version() {
+        if ($this->solrmajorversion !== null) {
+            return $this->solrmajorversion;
+        }
+
         // We should really ping first the server to see if the specified indexname is valid but
         // we want to minimise solr server requests as they are expensive. system() emits a warning
         // if it can not connect to the configured index in the configured server.
         $systemdata = @$this->get_search_client()->system();
         $solrversion = $systemdata->getResponse()->offsetGet('lucene')->offsetGet('solr-spec-version');
-        return intval(substr($solrversion, 0, strpos($solrversion, '.')));
+        $this->solrmajorversion = intval(substr($solrversion, 0, strpos($solrversion, '.')));
+
+        return $this->solrmajorversion;
     }
 
     /**
index f9e79cc..86bd777 100644 (file)
@@ -86,8 +86,7 @@ class schema {
      */
     public function can_setup_server() {
 
-        $engine = new \search_solr\engine();
-        $status = $engine->is_server_configured();
+        $status = $this->engine->is_server_configured();
         if ($status !== true) {
             return $status;
         }
@@ -95,7 +94,7 @@ class schema {
         // At this stage we know that the server is properly configured with a valid host:port and indexname.
         // We're not too concerned about repeating the SolrClient::system() call (already called in
         // is_server_configured) because this is just a setup script.
-        if ($engine->get_solr_major_version() < 5) {
+        if ($this->engine->get_solr_major_version() < 5) {
             // Schema setup script only available for 5.0 onwards.
             return get_string('schemasetupfromsolr5', 'search_solr');
         }
@@ -182,11 +181,13 @@ class schema {
             if (!isset($data['type']) || !isset($data['stored']) || !isset($data['indexed'])) {
                 throw new \coding_exception($fieldname . ' does not define all required field params: type, stored and indexed.');
             }
+            $type = $this->doc_field_to_solr_field($data['type']);
+
             // Changing default multiValued value to false as we want to match values easily.
             $params = array(
                 'add-field' => array(
                     'name' => $fieldname,
-                    'type' => ($data['type'] === 'text' ? 'text_general' : $data['type']),
+                    'type' => $type,
                     'stored' => $data['stored'],
                     'multiValued' => false,
                     'indexed' => $data['indexed']
@@ -245,6 +246,7 @@ class schema {
                     // All these field attributes are set when fields are added through this script and should
                     // be returned and match the defined field's values.
 
+                    $expectedsolrfield = $this->doc_field_to_solr_field($data['type']);
                     if (empty($results->field) || !isset($results->field->type) ||
                             !isset($results->field->multiValued) || !isset($results->field->indexed) ||
                             !isset($results->field->stored)) {
@@ -252,14 +254,13 @@ class schema {
                         throw new \moodle_exception('errorcreatingschema', 'search_solr', '',
                             get_string('schemafieldautocreated', 'search_solr', $fieldname));
 
-                    } else if (($results->field->type !== $data['type'] &&
-                                ($data['type'] !== 'text' || $results->field->type !== 'text_general')) ||
-                                $results->field->multiValued !== false ||
-                                $results->field->indexed !== $data['indexed'] ||
-                                $results->field->stored !== $data['stored']) {
+                    } else if ($results->field->type !== $expectedsolrfield ||
+                            $results->field->multiValued !== false ||
+                            $results->field->indexed !== $data['indexed'] ||
+                            $results->field->stored !== $data['stored']) {
 
-                            throw new \moodle_exception('errorcreatingschema', 'search_solr', '',
-                                get_string('schemafieldautocreated', 'search_solr', $fieldname));
+                        throw new \moodle_exception('errorcreatingschema', 'search_solr', '',
+                            get_string('schemafieldautocreated', 'search_solr', $fieldname));
                     } else {
                         // The field already exists and it is properly defined, no need to create it.
                         unset($fields[$fieldname]);
@@ -309,4 +310,34 @@ class schema {
         }
 
     }
+
+    /**
+     * Returns the solr field type from the document field type string.
+     *
+     * @param string $datatype
+     * @return string
+     */
+    private function doc_field_to_solr_field($datatype) {
+        $type = $datatype;
+
+        $solrversion = $this->engine->get_solr_major_version();
+
+        switch($datatype) {
+            case 'text':
+                $type = 'text_general';
+                break;
+            case 'int':
+                if ($solrversion >= 7) {
+                    $type = 'pint';
+                }
+                break;
+            case 'tdate':
+                if ($solrversion >= 7) {
+                    $type = 'pdate';
+                }
+                break;
+        }
+
+        return $type;
+    }
 }
index 2bc7457..6f4658f 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version = 2017111300;
+$plugin->version = 2017111700;
 $plugin->requires = 2017110800;
 $plugin->component = 'search_solr';
index b6d209b..206819d 100644 (file)
@@ -96,7 +96,7 @@ class behat_search extends behat_base {
 
             // Find the specified activity.
             $idnumber = $input['idnumber'];
-            $cmid = $DB->get_field('course_modules', 'id', ['idnumber' => $idnumber], '*', IGNORE_MISSING);
+            $cmid = $DB->get_field('course_modules', 'id', ['idnumber' => $idnumber], IGNORE_MISSING);
             if (!$cmid) {
                 throw new Exception('Cannot find activity with idnumber: ' . $idnumber);
             }
index 7c1976b..7f40642 100644 (file)
@@ -237,6 +237,12 @@ div#dock {
 .path-mod-lesson .invisiblefieldset.fieldsetfix {
     display: block;
 }
+.path-mod-lesson .answeroption .checkbox label p {
+    display: inline;
+}
+#page-mod-lesson-view .branchbuttoncontainer .singlebutton button[type="submit"] {
+    white-space: normal;
+}
 
 .path-mod-wiki .wiki_headingtitle,
 .path-mod-wiki .midpad,
index d4a79dc..5552bac 100644 (file)
@@ -1,6 +1,7 @@
 {{< core_form/element-template-inline }}
     {{$element}}
         {{^element.frozen}}
+        <input type="hidden" name="{{element.nameraw}}" value="_qf__force_multiselect_submission">
         <select class="custom-select {{#error}}form-control-danger{{/error}}" name="{{element.name}}"
             id="{{element.id}}"
             {{#element.multiple}}multiple{{/element.multiple}}
index c0e910e..5f95675 100644 (file)
@@ -1,6 +1,7 @@
 {{< core_form/element-template }}
     {{$element}}
         {{^element.frozen}}
+        <input type="hidden" name="{{element.nameraw}}" value="_qf__force_multiselect_submission">
         <select class="custom-select {{#error}}form-control-danger{{/error}}" name="{{element.name}}"
             id="{{element.id}}"
             {{#element.multiple}}multiple{{/element.multiple}}
index 796edf1..7a7a6b5 100644 (file)
@@ -400,6 +400,10 @@ div#dock {
 .path-mod-lesson .answeroptiongroup .felement label p:last-child {
     margin-bottom: 0;
 }
+.path-mod-lesson .answeroption .fradio label p,
+.path-mod-lesson .answeroption .fcheckbox label p {
+    display: inline;
+}
 
 /**
  * Style for view.php
@@ -407,6 +411,9 @@ div#dock {
 #page-mod-lesson-view .password-form .submitbutton {
     display: inline;
 }
+#page-mod-lesson-view .branchbuttoncontainer .singlebutton input[type="submit"] {
+    white-space: normal;
+}
 .path-mod-lesson .reviewessay {
     width: 40%;
     border: 1px solid #ddd;
index 940f2cc..2abeb79 100644 (file)
@@ -17571,12 +17571,19 @@ div#dock {
 .path-mod-lesson .answeroptiongroup .felement label p:last-child {
   margin-bottom: 0;
 }
+.path-mod-lesson .answeroption .fradio label p,
+.path-mod-lesson .answeroption .fcheckbox label p {
+  display: inline;
+}
 /**
  * Style for view.php
  **/
 #page-mod-lesson-view .password-form .submitbutton {
   display: inline;
 }
+#page-mod-lesson-view .branchbuttoncontainer .singlebutton input[type="submit"] {
+  white-space: normal;
+}
 .path-mod-lesson .reviewessay {
   width: 40%;
   border: 1px solid #ddd;
index d75328c..454b481 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2017120800.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2017121400.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 
-$release  = '3.5dev (Build: 20171208)'; // Human-friendly version name
+$release  = '3.5dev (Build: 20171214)'; // Human-friendly version name
 
 $branch   = '35';                       // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.