Merge branch 'MDL-59448-master' of https://github.com/xow/moodle
authorDavid Monllao <davidm@moodle.com>
Tue, 8 Aug 2017 02:25:06 +0000 (04:25 +0200)
committerDavid Monllao <davidm@moodle.com>
Tue, 8 Aug 2017 02:25:06 +0000 (04:25 +0200)
61 files changed:
admin/cli/mysql_collation.php
admin/settings/analytics.php
analytics/classes/local/analyser/base.php
analytics/classes/local/time_splitting/equal_parts.php
analytics/classes/model.php
analytics/tests/prediction_test.php
auth/ldap/auth.php
auth/ldap/tests/plugin_test.php
blocks/participants/tests/behat/block_participants_course.feature
course/modlib.php
course/tests/behat/activity_navigation.feature [new file with mode: 0644]
course/tests/behat/activity_navigation_with_restrictions.feature [new file with mode: 0644]
course/tests/behat/rename_roles.feature
grade/report/grader/lib.php
grade/report/lib.php
grade/tests/behat/grade_hidden_items.feature
group/classes/output/user_groups_editable.php
group/tests/behat/create_groups.feature
lang/en/analytics.php
lang/en/form.php
lang/en/moodle.php
lib/classes/event/config_log_created.php [new file with mode: 0644]
lib/classes/lock/installation_lock_factory.php [new file with mode: 0644]
lib/classes/lock/lock_config.php
lib/classes/output/icon_system_fontawesome.php
lib/datalib.php
lib/db/install.xml
lib/db/upgrade.php
lib/filestorage/file_storage.php
lib/filestorage/stored_file.php
lib/form/tests/behat/filetypes.feature [deleted file]
lib/form/tests/behat/multi_select_dependencies.feature [deleted file]
lib/form/tests/fixtures/filetypes.php [deleted file]
lib/form/tests/fixtures/multi_select_dependencies.php [deleted file]
lib/grade/grade_grade.php
lib/tests/admintree_test.php
lib/tests/statslib_test.php
lib/upgrade.txt
mod/assign/lang/en/assign.php
mod/assign/mod_form.php
mod/assign/submission/file/tests/behat/file_type_restriction.feature
mod/forum/user.php
mod/lesson/continue.php
mod/lesson/lib.php
mod/lesson/locallib.php
mod/lesson/tests/lib_test.php
mod/lti/launch.php
mod/lti/view.php
repository/boxnet/lib.php
repository/dropbox/lib.php
repository/equella/lib.php
repository/filesystem/lib.php
repository/lib.php
repository/upgrade.txt
theme/boost/scss/moodle/question.scss
theme/bootstrapbase/less/moodle/question.less
theme/bootstrapbase/style/moodle.css
user/lib.php
user/tests/behat/bulk_editenrolment.feature
user/tests/userlib_test.php
version.php

index ae3c709..ba8a49e 100644 (file)
@@ -203,7 +203,7 @@ if (!empty($options['collation'])) {
                 $DB->change_database_structure($sql);
             } else {
                 echo "ERROR (unknown column type: $column->type)\n";
-                $error++;
+                $errors++;
                 continue;
             }
             echo "CONVERTED\n";
index a1ac0b1..17724a3 100644 (file)
@@ -84,7 +84,7 @@ if ($hassiteconfig) {
             $timesplittingoptions[$key] = $timesplitting->get_name();
         }
         $settings->add(new admin_setting_configmultiselect('analytics/timesplittings',
-            new lang_string('enabledtimesplittings', 'analytics'), new lang_string('enabledtimesplittings_help', 'analytics'),
+            new lang_string('enabledtimesplittings', 'analytics'), new lang_string('timesplittingmethod_help', 'analytics'),
             $timesplittingdefaults, $timesplittingoptions)
         );
 
index c80e66e..cd95efa 100644 (file)
@@ -376,8 +376,8 @@ abstract class base {
             // All ranges are used when we are calculating data for training.
             $ranges = $timesplitting->get_all_ranges();
         } else {
-            // Only some ranges can be used for prediction (it depends on the time range where we are right now).
-            $ranges = $this->get_prediction_ranges($timesplitting);
+            // The latest range that has not yet been used for prediction (it depends on the time range where we are right now).
+            $ranges = $this->get_most_recent_prediction_range($timesplitting);
         }
 
         // There is no need to keep track of the evaluated samples and ranges as we always evaluate the whole dataset.
@@ -385,12 +385,12 @@ abstract class base {
 
             if (empty($ranges)) {
                 $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
-                $result->message = get_string('nonewdata', 'analytics');
+                $result->message = get_string('noranges', 'analytics');
                 return $result;
             }
 
-            // We skip all samples that are already part of a training dataset, even if they have noe been used for training yet.
-            $sampleids = $this->filter_out_train_samples($sampleids, $timesplitting);
+            // We skip all samples that are already part of a training dataset, even if they have not been used for prediction.
+            $this->filter_out_train_samples($sampleids, $timesplitting);
 
             if (count($sampleids) === 0) {
                 $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
@@ -400,13 +400,19 @@ abstract class base {
 
             // Only when processing data for predictions.
             if ($target === false) {
-                // We also filter out ranges that have already been used for predictions.
-                $ranges = $this->filter_out_prediction_ranges($ranges, $timesplitting);
+                // We also filter out samples and ranges that have already been used for predictions.
+                $this->filter_out_prediction_samples_and_ranges($sampleids, $ranges, $timesplitting);
+            }
+
+            if (count($sampleids) === 0) {
+                $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
+                $result->message = get_string('nonewdata', 'analytics');
+                return $result;
             }
 
             if (count($ranges) === 0) {
                 $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
-                $result->message = get_string('nonewtimeranges', 'analytics');
+                $result->message = get_string('nonewranges', 'analytics');
                 return $result;
             }
         }
@@ -469,7 +475,7 @@ abstract class base {
             if ($target) {
                 $this->save_train_samples($sampleids, $timesplitting, $file);
             } else {
-                $this->save_prediction_ranges($ranges, $timesplitting);
+                $this->save_prediction_samples($sampleids, $ranges, $timesplitting);
             }
         }
 
@@ -480,25 +486,28 @@ abstract class base {
     }
 
     /**
-     * Returns the ranges of a time splitting that can be used to predict.
+     * Returns the most recent range that can be used to predict.
      *
      * @param \core_analytics\local\time_splitting\base $timesplitting
      * @return array
      */
-    protected function get_prediction_ranges($timesplitting) {
+    protected function get_most_recent_prediction_range($timesplitting) {
 
         $now = time();
+        $ranges = $timesplitting->get_all_ranges();
+
+        // Opposite order as we are interested in the last range that can be used for prediction.
+        arsort($ranges);
 
         // We already provided the analysable to the time splitting method, there is no need to feed it back.
-        $predictionranges = array();
-        foreach ($timesplitting->get_all_ranges() as $rangeindex => $range) {
+        foreach ($ranges as $rangeindex => $range) {
             if ($timesplitting->ready_to_predict($range)) {
                 // We need to maintain the same indexes.
-                $predictionranges[$rangeindex] = $range;
+                return array($rangeindex => $range);
             }
         }
 
-        return $predictionranges;
+        return array();
     }
 
     /**
@@ -506,9 +515,8 @@ abstract class base {
      *
      * @param int[] $sampleids
      * @param \core_analytics\local\time_splitting\base $timesplitting
-     * @return int[]
      */
-    protected function filter_out_train_samples($sampleids, $timesplitting) {
+    protected function filter_out_train_samples(&$sampleids, $timesplitting) {
         global $DB;
 
         $params = array('modelid' => $this->modelid, 'analysableid' => $timesplitting->get_analysable()->get_id(),
@@ -526,32 +534,43 @@ abstract class base {
                 $sampleids = array_diff_key($sampleids, $usedsamples);
             }
         }
-
-        return $sampleids;
     }
 
     /**
      * Filters out samples that have already been used for prediction.
      *
+     * @param int[] $sampleids
      * @param array $ranges
      * @param \core_analytics\local\time_splitting\base $timesplitting
-     * @return int[]
      */
-    protected function filter_out_prediction_ranges($ranges, $timesplitting) {
+    protected function filter_out_prediction_samples_and_ranges(&$sampleids, &$ranges, $timesplitting) {
         global $DB;
 
+        if (count($ranges) > 1) {
+            throw new \coding_exception('$ranges argument should only contain one range');
+        }
+
+        $rangeindex = key($ranges);
+
         $params = array('modelid' => $this->modelid, 'analysableid' => $timesplitting->get_analysable()->get_id(),
-            'timesplitting' => $timesplitting->get_id());
+            'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex);
+        $predictedrange = $DB->get_record('analytics_predict_samples', $params);
 
-        $predictedranges = $DB->get_records('analytics_predict_ranges', $params);
-        foreach ($predictedranges as $predictedrange) {
-            if (!empty($ranges[$predictedrange->rangeindex])) {
-                unset($ranges[$predictedrange->rangeindex]);
-            }
+        if (!$predictedrange) {
+            // Nothing to filter out.
+            return;
         }
 
-        return $ranges;
+        $predictedrange->sampleids = json_decode($predictedrange->sampleids, true);
+        $missingsamples = array_diff_key($sampleids, $predictedrange->sampleids);
+        if (count($missingsamples) === 0) {
+            // All samples already calculated.
+            unset($ranges[$rangeindex]);
+            return;
+        }
 
+        // Replace the list of samples by the one excluding samples that already got predictions at this range.
+        $sampleids = $missingsamples;
     }
 
     /**
@@ -560,7 +579,7 @@ abstract class base {
      * @param int[] $sampleids
      * @param \core_analytics\local\time_splitting\base $timesplitting
      * @param \stored_file $file
-     * @return bool
+     * @return void
      */
     protected function save_train_samples($sampleids, $timesplitting, $file) {
         global $DB;
@@ -574,28 +593,40 @@ abstract class base {
         $trainingsamples->sampleids = json_encode($sampleids);
         $trainingsamples->timecreated = time();
 
-        return $DB->insert_record('analytics_train_samples', $trainingsamples);
+        $DB->insert_record('analytics_train_samples', $trainingsamples);
     }
 
     /**
      * Saves samples that have just been used for prediction.
      *
+     * @param int[] $sampleids
      * @param array $ranges
      * @param \core_analytics\local\time_splitting\base $timesplitting
      * @return void
      */
-    protected function save_prediction_ranges($ranges, $timesplitting) {
+    protected function save_prediction_samples($sampleids, $ranges, $timesplitting) {
         global $DB;
 
-        $predictionrange = new \stdClass();
-        $predictionrange->modelid = $this->modelid;
-        $predictionrange->analysableid = $timesplitting->get_analysable()->get_id();
-        $predictionrange->timesplitting = $timesplitting->get_id();
-        $predictionrange->timecreated = time();
+        if (count($ranges) > 1) {
+            throw new \coding_exception('$ranges argument should only contain one range');
+        }
+
+        $rangeindex = key($ranges);
 
-        foreach ($ranges as $rangeindex => $unused) {
-            $predictionrange->rangeindex = $rangeindex;
-            $DB->insert_record('analytics_predict_ranges', $predictionrange);
+        $params = array('modelid' => $this->modelid, 'analysableid' => $timesplitting->get_analysable()->get_id(),
+            'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex);
+        if ($predictionrange = $DB->get_record('analytics_predict_samples', $params)) {
+            // Append the new samples used for prediction.
+            $prevsamples = json_decode($predictionrange->sampleids, true);
+            $predictionrange->sampleids = json_encode($prevsamples + $sampleids);
+            $predictionrange->timemodified = time();
+            $DB->update_record('analytics_predict_samples', $predictionrange);
+        } else {
+            $predictionrange = (object)$params;
+            $predictionrange->sampleids = json_encode($sampleids);
+            $predictionrange->timecreated = time();
+            $predictionrange->timemodified = $predictionrange->timecreated;
+            $DB->insert_record('analytics_predict_samples', $predictionrange);
         }
     }
 }
index 090e693..14e8834 100644 (file)
@@ -60,8 +60,8 @@ abstract class equal_parts extends base {
 
             // Check the end of the previous time range.
             if ($i > 0 && $start === $ranges[$i - 1]['end']) {
-                // We deduct 1 second from the previous end so each timestamp only belongs to 1 range.
-                $ranges[$i - 1]['end'] = $ranges[$i - 1]['end'] - 1;
+                // We add 1 second so each timestamp only belongs to 1 range.
+                $start = $start + 1;
             }
 
             if ($i === ($nparts - 1)) {
index 985d655..aae6682 100644 (file)
@@ -1013,7 +1013,7 @@ class model {
      */
     public function any_prediction_obtained() {
         global $DB;
-        return $DB->record_exists('analytics_predict_ranges',
+        return $DB->record_exists('analytics_predict_samples',
             array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting));
     }
 
@@ -1317,8 +1317,8 @@ class model {
     private function clear_model() {
         global $DB;
 
-        $DB->delete_records('analytics_predict_ranges', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
+        $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
 
index a0d278b..c778a5c 100644 (file)
@@ -31,6 +31,8 @@ require_once(__DIR__ . '/fixtures/test_indicator_random.php');
 require_once(__DIR__ . '/fixtures/test_target_shortname.php');
 require_once(__DIR__ . '/fixtures/test_static_target_shortname.php');
 
+require_once(__DIR__ . '/../../course/lib.php');
+
 /**
  * Unit tests for evaluation, training and prediction.
  *
@@ -81,7 +83,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         }
 
         // 1 range for each analysable.
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
         $this->assertCount(2, $predictedranges);
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
@@ -91,7 +93,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
 
         // No new generated files nor records as there are no new courses available.
         $model->predict();
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
         $this->assertCount(2, $predictedranges);
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
@@ -104,11 +106,11 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      *
      * @dataProvider provider_ml_training_and_prediction
      * @param string $timesplittingid
-     * @param int $npredictedranges
+     * @param int $predictedrangeindex
      * @param string $predictionsprocessorclass
      * @return void
      */
-    public function test_ml_training_and_prediction($timesplittingid, $npredictedranges, $predictionsprocessorclass) {
+    public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $predictionsprocessorclass) {
         global $DB;
 
         $this->resetAfterTest(true);
@@ -176,22 +178,75 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             $this->assertEquals($correct[$sampleid], $predictiondata->prediction);
         }
 
-        // 2 ranges will be predicted.
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
-        $this->assertCount($npredictedranges, $predictedranges);
+        // 1 range will be predicted.
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
+        $this->assertCount(1, $predictedranges);
+        foreach ($predictedranges as $predictedrange) {
+            $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
+            $sampleids = json_decode($predictedrange->sampleids, true);
+            $this->assertCount(2, $sampleids);
+            $this->assertContains($course1->id, $sampleids);
+            $this->assertContains($course2->id, $sampleids);
+        }
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
-        // 2 predictions for each range.
-        $this->assertEquals(2 * $npredictedranges, $DB->count_records('analytics_predictions',
+        // 2 predictions.
+        $this->assertEquals(2, $DB->count_records('analytics_predictions',
             array('modelid' => $model->get_id())));
 
         // No new generated files nor records as there are no new courses available.
         $model->predict();
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
-        $this->assertCount($npredictedranges, $predictedranges);
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
+        $this->assertCount(1, $predictedranges);
+        foreach ($predictedranges as $predictedrange) {
+            $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
+        }
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
-        $this->assertEquals(2 * $npredictedranges, $DB->count_records('analytics_predictions',
+        $this->assertEquals(2, $DB->count_records('analytics_predictions',
+            array('modelid' => $model->get_id())));
+
+        // New samples that can be used for prediction.
+        $courseparams = $params + array('shortname' => 'cccccc', 'fullname' => 'cccccc', 'visible' => 0);
+        $course3 = $this->getDataGenerator()->create_course($courseparams);
+        $courseparams = $params + array('shortname' => 'dddddd', 'fullname' => 'dddddd', 'visible' => 0);
+        $course4 = $this->getDataGenerator()->create_course($courseparams);
+
+        $result = $model->predict();
+
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
+        $this->assertCount(1, $predictedranges);
+        foreach ($predictedranges as $predictedrange) {
+            $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
+            $sampleids = json_decode($predictedrange->sampleids, true);
+            $this->assertCount(4, $sampleids);
+            $this->assertContains($course1->id, $sampleids);
+            $this->assertContains($course2->id, $sampleids);
+            $this->assertContains($course3->id, $sampleids);
+            $this->assertContains($course4->id, $sampleids);
+        }
+        $this->assertEquals(2, $DB->count_records('analytics_used_files',
+            array('modelid' => $model->get_id(), 'action' => 'predicted')));
+        $this->assertEquals(4, $DB->count_records('analytics_predictions',
+            array('modelid' => $model->get_id())));
+
+        // New visible course (for training).
+        $course5 = $this->getDataGenerator()->create_course(array('shortname' => 'aaa', 'fullname' => 'aa'));
+        $course6 = $this->getDataGenerator()->create_course();
+        $result = $model->train();
+        $this->assertEquals(2, $DB->count_records('analytics_used_files',
+            array('modelid' => $model->get_id(), 'action' => 'trained')));
+
+        // Update one of the courses to not visible, it should be used again for prediction.
+        $course5->visible = 0;
+        update_course($course5);
+
+        $model->predict();
+        $this->assertEquals(1, $DB->count_records('analytics_predict_samples',
+            array('modelid' => $model->get_id())));
+        $this->assertEquals(2, $DB->count_records('analytics_used_files',
+            array('modelid' => $model->get_id(), 'action' => 'predicted')));
+        $this->assertEquals(4, $DB->count_records('analytics_predictions',
             array('modelid' => $model->get_id())));
 
         set_config('enabled_stores', '', 'tool_log');
@@ -205,8 +260,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      */
     public function provider_ml_training_and_prediction() {
         $cases = array(
-            'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 1),
-            'quarters' => array('\core\analytics\time_splitting\quarters', 4)
+            'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 0),
+            'quarters' => array('\core\analytics\time_splitting\quarters', 3)
         );
 
         // We need to test all system prediction processors.
index dcfc2c5..6cf1f28 100644 (file)
@@ -928,7 +928,7 @@ class auth_plugin_ldap extends auth_plugin_base {
 
                 $id = user_create_user($user, false);
                 echo "\t"; print_string('auth_dbinsertuser', 'auth_db', array('name'=>$user->username, 'id'=>$id)); echo "\n";
-                $euser = $DB->get_record('user', array('id' => $id));
+                $user = $DB->get_record('user', array('id' => $id));
 
                 if (!empty($this->config->forcechangepassword)) {
                     set_user_preference('auth_forcepasswordchange', 1, $id);
index d50f67d..f746583 100644 (file)
@@ -110,7 +110,7 @@ class auth_ldap_plugin_testcase extends advanced_testcase {
         set_config('user_attribute', 'cn', 'auth_ldap');
         set_config('memberattribute', 'memberuid', 'auth_ldap');
         set_config('memberattribute_isdn', 0, 'auth_ldap');
-        set_config('creators', 'cn=creators,'.$topdn, 'auth_ldap');
+        set_config('coursecreatorcontext', 'cn=creators,'.$topdn, 'auth_ldap');
         set_config('removeuser', AUTH_REMOVEUSER_KEEP, 'auth_ldap');
 
         set_config('field_map_email', 'mail', 'auth_ldap');
index a850602..5fa4a0b 100644 (file)
@@ -29,7 +29,7 @@ Feature: People Block used in a course
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I click on "Participants" "link" in the "People" "block"
-    Then I should see "All participants" in the "#page-content" "css_element"
+    Then I should see "Participants" in the "#page-content" "css_element"
 
   Scenario: Student without permission can not view participants link
     Given the following "permission overrides" exist:
index d4cc82b..4d2348e 100644 (file)
@@ -60,8 +60,10 @@ function add_moduleinfo($moduleinfo, $course, $mform = null) {
     $newcm->module           = $moduleinfo->module;
     $newcm->instance         = 0; // Not known yet, will be updated later (this is similar to restore code).
     $newcm->visible          = $moduleinfo->visible;
-    $newcm->visibleoncoursepage = $moduleinfo->visibleoncoursepage;
     $newcm->visibleold       = $moduleinfo->visible;
+    if (isset($moduleinfo->visibleoncoursepage)) {
+        $newcm->visibleoncoursepage = $moduleinfo->visibleoncoursepage;
+    }
     if (isset($moduleinfo->cmidnumber)) {
         $newcm->idnumber         = $moduleinfo->cmidnumber;
     }
diff --git a/course/tests/behat/activity_navigation.feature b/course/tests/behat/activity_navigation.feature
new file mode 100644 (file)
index 0000000..dff4435
--- /dev/null
@@ -0,0 +1,289 @@
+@core @core_course
+Feature: Activity navigation
+  In order to quickly switch between activities
+  As a user
+  I need to use the activity navigation controls in activities
+
+  Background:
+    Given the following "users" exist:
+      | username  | firstname  | lastname  | email                 |
+      | teacher1  | Teacher    | 1         | teacher1@example.com  |
+      | student1  | Student    | 1         | student1@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1        | topics |
+      | Course 2 | C2        | topics |
+    And the following "course enrolments" exist:
+      | user      | course  | role            |
+      | student1  | C1      | student         |
+      | teacher1  | C1      | editingteacher  |
+      | student1  | C2      | student         |
+    And the following "activities" exist:
+      | activity   | name         | intro                       | course | idnumber  | section |
+      | assign     | Assignment 1 | Test assignment description | C1     | assign1   | 0       |
+      | book       | Book 1       | Test book description       | C1     | book1     | 0       |
+      | chat       | Chat 1       | Test chat description       | C1     | chat1     | 0       |
+      | choice     | Choice 1     | Test choice description     | C1     | choice1   | 1       |
+      | data       | Database 1   | Test database description   | C1     | data1     | 1       |
+      | feedback   | Feedback 1   | Test feedback description   | C1     | feedback1 | 1       |
+      | folder     | Folder 1     | Test folder description     | C1     | folder1   | 2       |
+      | forum      | Forum 1      | Test forum description      | C1     | forum1    | 2       |
+      | glossary   | Glossary 1   | Test glossary description   | C1     | glossary1 | 2       |
+      | imscp      | Imscp 1      | Test imscp description      | C1     | imscp1    | 3       |
+      | label      | Label 1      | Test label description      | C1     | label1    | 3       |
+      | lesson     | Lesson 1     | Test lesson description     | C1     | lesson1   | 3       |
+      | lti        | Lti 1        | Test lti description        | C1     | lti1      | 4       |
+      | page       | Page 1       | Test page description       | C1     | page1     | 4       |
+      | quiz       | Quiz 1       | Test quiz description       | C1     | quiz1     | 4       |
+      | resource   | Resource 1   | Test resource description   | C1     | resource1 | 5       |
+      | scorm      | Scorm 1      | Test scorm description      | C1     | scorm1    | 5       |
+      | survey     | Survey 1     | Test survey description     | C1     | survey1   | 5       |
+      | url        | Url 1        | Test url description        | C1     | url1      | 6       |
+      | wiki       | Wiki 1       | Test wiki description       | C1     | wiki1     | 6       |
+      | workshop   | Workshop 1   | Test workshop description   | C1     | workshop1 | 6       |
+      | assign     | Assignment 1 | Test assignment description | C2     | assign21  | 0       |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | allowstealth | 1 |
+    And I am on "Course 1" course homepage with editing mode on
+    # Stealth activity.
+    And I click on "Hide" "link" in the "Forum 1" activity
+    And I click on "Make available" "link" in the "Forum 1" activity
+    # Hidden activity.
+    And I click on "Hide" "link" in the "Glossary 1" activity
+    # Hidden section.
+    And I hide section "5"
+    # Set up book.
+    And I follow "Book 1"
+    And I should see "Add new chapter"
+    And I set the following fields to these values:
+      | Chapter title | Chapter 1                             |
+      | Content       | In the beginning... blah, blah, blah. |
+    And I press "Save changes"
+    And I log out
+
+  Scenario: Step through activities in the course as a teacher.
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    When I follow "Assignment 1"
+    # The first activity won't have the previous activity link.
+    Then "#prev-activity-link" "css_element" should not exist
+    And I should see "Book 1" in the "#next-activity-link" "css_element"
+    And I follow "Book 1"
+    And I should see "Assignment" in the "#prev-activity-link" "css_element"
+    And I should see "Chat 1" in the "#next-activity-link" "css_element"
+    And I follow "Chat 1"
+    And I should see "Book 1" in the "#prev-activity-link" "css_element"
+    And I should see "Choice 1" in the "#next-activity-link" "css_element"
+    And I follow "Choice 1"
+    And I should see "Chat 1" in the "#prev-activity-link" "css_element"
+    And I should see "Database 1" in the "#next-activity-link" "css_element"
+    And I follow "Database 1"
+    And I should see "Choice 1" in the "#prev-activity-link" "css_element"
+    And I should see "Feedback 1" in the "#next-activity-link" "css_element"
+    And I follow "Feedback 1"
+    And I should see "Database 1" in the "#prev-activity-link" "css_element"
+    # The next link will be Folder 1 because Forum 1 is in stealth mode.
+    And I should see "Folder 1" in the "#next-activity-link" "css_element"
+    And I follow "Folder 1"
+    And I should see "Feedback 1" in the "#prev-activity-link" "css_element"
+    # Hidden activity will have a '(hidden)' text within the activity link.
+    And I should see "Glossary 1 (hidden)" in the "#next-activity-link" "css_element"
+    And I follow "Glossary 1 (hidden)"
+    And I should see "Folder 1" in the "#prev-activity-link" "css_element"
+    And I should see "Imscp 1" in the "#next-activity-link" "css_element"
+    And I follow "Imscp 1"
+    And I should see "Glossary 1" in the "#prev-activity-link" "css_element"
+    # The next link will be Lesson 1 because Label 1 doesn't have a view URL.
+    And I should see "Lesson 1" in the "#next-activity-link" "css_element"
+    And I follow "Lesson 1"
+    And I should see "Imscp 1" in the "#prev-activity-link" "css_element"
+    And I should see "Lti 1" in the "#next-activity-link" "css_element"
+    And I follow "Lti 1"
+    And I should see "Lesson 1" in the "#prev-activity-link" "css_element"
+    And I should see "Page 1" in the "#next-activity-link" "css_element"
+    And I follow "Page 1"
+    And I should see "Lti 1" in the "#prev-activity-link" "css_element"
+    And I should see "Quiz 1" in the "#next-activity-link" "css_element"
+    And I follow "Quiz 1"
+    And I should see "Page 1" in the "#prev-activity-link" "css_element"
+    # Hidden sections will have the activities render with the '(hidden)' text.
+    And I should see "Resource 1 (hidden)" in the "#next-activity-link" "css_element"
+    And I follow "Resource 1 (hidden)"
+    And I should see "Quiz 1" in the "#prev-activity-link" "css_element"
+    And I should see "Scorm 1 (hidden)" in the "#next-activity-link" "css_element"
+    And I follow "Scorm 1 (hidden)"
+    And I should see "Resource 1 (hidden)" in the "#prev-activity-link" "css_element"
+    And I should see "Survey 1 (hidden)" in the "#next-activity-link" "css_element"
+    And I follow "Survey 1 (hidden)"
+    And I should see "Scorm 1 (hidden)" in the "#prev-activity-link" "css_element"
+    And I should see "Url 1" in the "#next-activity-link" "css_element"
+    And I follow "Url 1"
+    And I should see "Survey 1 (hidden)" in the "#prev-activity-link" "css_element"
+    And I should see "Wiki 1" in the "#next-activity-link" "css_element"
+    And I follow "Wiki 1"
+    And I should see "Url 1" in the "#prev-activity-link" "css_element"
+    And I should see "Workshop 1" in the "#next-activity-link" "css_element"
+    And I follow "Workshop 1"
+    And I should see "Wiki 1" in the "#prev-activity-link" "css_element"
+    # The last activity won't have the next activity link.
+    And "#next-activity-link" "css_element" should not exist
+
+  Scenario: Step through activities in the course as a student.
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    When I follow "Assignment 1"
+    # The first activity won't have the previous activity link.
+    Then "#prev-activity-link" "css_element" should not exist
+    And I should see "Book 1" in the "#next-activity-link" "css_element"
+    And I follow "Book 1"
+    And I should see "Assignment" in the "#prev-activity-link" "css_element"
+    And I should see "Chat 1" in the "#next-activity-link" "css_element"
+    And I follow "Chat 1"
+    And I should see "Book 1" in the "#prev-activity-link" "css_element"
+    And I should see "Choice 1" in the "#next-activity-link" "css_element"
+    And I follow "Choice 1"
+    And I should see "Chat 1" in the "#prev-activity-link" "css_element"
+    And I should see "Database 1" in the "#next-activity-link" "css_element"
+    And I follow "Database 1"
+    And I should see "Choice 1" in the "#prev-activity-link" "css_element"
+    And I should see "Feedback 1" in the "#next-activity-link" "css_element"
+    And I follow "Feedback 1"
+    And I should see "Database 1" in the "#prev-activity-link" "css_element"
+    # The next link will be Folder 1 because Forum 1 is in stealth mode.
+    And I should see "Folder 1" in the "#next-activity-link" "css_element"
+    And I follow "Folder 1"
+    And I should see "Feedback 1" in the "#prev-activity-link" "css_element"
+    # The next link will be Imscp 1 because hidden activities are not shown to students.
+    And I should see "Imscp 1" in the "#next-activity-link" "css_element"
+    And I follow "Imscp 1"
+    And I should see "Folder 1" in the "#prev-activity-link" "css_element"
+    # The next link will be Lesson 1 because Label 1 doesn't have a view URL.
+    And I should see "Lesson 1" in the "#next-activity-link" "css_element"
+    And I follow "Lesson 1"
+    And I should see "Imscp 1" in the "#prev-activity-link" "css_element"
+    And I should see "Lti 1" in the "#next-activity-link" "css_element"
+    And I follow "Lti 1"
+    And I should see "Lesson 1" in the "#prev-activity-link" "css_element"
+    And I should see "Page 1" in the "#next-activity-link" "css_element"
+    And I follow "Page 1"
+    And I should see "Lti 1" in the "#prev-activity-link" "css_element"
+    And I should see "Quiz 1" in the "#next-activity-link" "css_element"
+    And I follow "Quiz 1"
+    And I should see "Page 1" in the "#prev-activity-link" "css_element"
+    # Hidden sections will have the activities hidden so the links won't be available to students.
+    And I should see "Url 1" in the "#next-activity-link" "css_element"
+    And I follow "Url 1"
+    And I should see "Quiz 1" in the "#prev-activity-link" "css_element"
+    And I should see "Wiki 1" in the "#next-activity-link" "css_element"
+    And I follow "Wiki 1"
+    And I should see "Url 1" in the "#prev-activity-link" "css_element"
+    And I should see "Workshop 1" in the "#next-activity-link" "css_element"
+    And I follow "Workshop 1"
+    And I should see "Wiki 1" in the "#prev-activity-link" "css_element"
+    # The last activity won't have the next activity link.
+    And "#next-activity-link" "css_element" should not exist
+
+  Scenario: Jump to another activity as a teacher
+    Given I log in as "teacher1"
+    When I am on "Course 1" course homepage
+    And I follow "Assignment 1"
+    Then "Jump to..." "field" should exist
+    # The current activity will not be listed.
+    And the "Jump to..." select box should not contain "Assignment 1"
+    # Stealth activities will not be listed.
+    And the "Jump to..." select box should not contain "Forum 1"
+    # Resources without view URL (e.g. labels) will not be listed.
+    And the "Jump to..." select box should not contain "Label 1"
+    # Check drop down menu contents.
+    And the "Jump to..." select box should contain "Book 1"
+    And the "Jump to..." select box should contain "Chat 1"
+    And the "Jump to..." select box should contain "Choice 1"
+    And the "Jump to..." select box should contain "Database 1"
+    And the "Jump to..." select box should contain "Feedback 1"
+    And the "Jump to..." select box should contain "Folder 1"
+    And the "Jump to..." select box should contain "Imscp 1"
+    And the "Jump to..." select box should contain "Lesson 1"
+    And the "Jump to..." select box should contain "Lti 1"
+    And the "Jump to..." select box should contain "Page 1"
+    And the "Jump to..." select box should contain "Quiz 1"
+    And the "Jump to..." select box should contain "Url 1"
+    And the "Jump to..." select box should contain "Wiki 1"
+    And the "Jump to..." select box should contain "Workshop 1"
+    # Hidden activities will be rendered with a '(hidden)' text.
+    And the "Jump to..." select box should contain "Glossary 1 (hidden)"
+    # Activities in hidden sections will be rendered with a '(hidden)' text.
+    And the "Jump to..." select box should contain "Resource 1 (hidden)"
+    And the "Jump to..." select box should contain "Scorm 1 (hidden)"
+    And the "Jump to..." select box should contain "Survey 1 (hidden)"
+    # Jump to an activity somewhere in the middle.
+    When I select "Page 1" from the "Jump to..." singleselect
+    Then I should see "Page 1"
+    And I should see "Lti 1" in the "#prev-activity-link" "css_element"
+    And I should see "Quiz 1" in the "#next-activity-link" "css_element"
+    # Jump to the first activity.
+    And I select "Assignment 1" from the "Jump to..." singleselect
+    And I should see "Book 1" in the "#next-activity-link" "css_element"
+    But "#prev-activity-link" "css_element" should not exist
+    # Jump to the last activity.
+    And I select "Workshop 1" from the "Jump to..." singleselect
+    And I should see "Wiki 1" in the "#prev-activity-link" "css_element"
+    But "#next-activity-link" "css_element" should not exist
+    # Jump to a hidden activity.
+    And I select "Glossary 1" from the "Jump to..." singleselect
+    And I should see "Folder 1" in the "#prev-activity-link" "css_element"
+    And I should see "Imscp 1" in the "#next-activity-link" "css_element"
+
+  Scenario: Jump to another activity as a student
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Assignment 1"
+    And "Jump to..." "field" should exist
+    # The current activity will not be listed.
+    And the "Jump to..." select box should not contain "Assignment 1"
+    # Stealth activities will not be listed for students.
+    And the "Jump to..." select box should not contain "Forum 1"
+    # Resources without view URL (e.g. labels) will not be listed.
+    And the "Jump to..." select box should not contain "Label 1"
+    # Hidden activities will not be listed for students.
+    And the "Jump to..." select box should not contain "Glossary 1"
+    # Activities in hidden sections will not be listed for students.
+    And the "Jump to..." select box should not contain "Resource 1"
+    And the "Jump to..." select box should not contain "Scorm 1"
+    And the "Jump to..." select box should not contain "Survey 1"
+    # Only activities visible to students will be listed.
+    And the "Jump to..." select box should contain "Book 1"
+    And the "Jump to..." select box should contain "Chat 1"
+    And the "Jump to..." select box should contain "Choice 1"
+    And the "Jump to..." select box should contain "Database 1"
+    And the "Jump to..." select box should contain "Feedback 1"
+    And the "Jump to..." select box should contain "Folder 1"
+    And the "Jump to..." select box should contain "Imscp 1"
+    And the "Jump to..." select box should contain "Lesson 1"
+    And the "Jump to..." select box should contain "Lti 1"
+    And the "Jump to..." select box should contain "Page 1"
+    And the "Jump to..." select box should contain "Quiz 1"
+    And the "Jump to..." select box should contain "Url 1"
+    And the "Jump to..." select box should contain "Wiki 1"
+    And the "Jump to..." select box should contain "Workshop 1"
+    # Jump to an activity somewhere in the middle.
+    When I select "Page 1" from the "Jump to..." singleselect
+    Then I should see "Page 1"
+    And I should see "Lti 1" in the "#prev-activity-link" "css_element"
+    And I should see "Quiz 1" in the "#next-activity-link" "css_element"
+    # Jump to the first activity.
+    And I select "Assignment 1" from the "Jump to..." singleselect
+    And I should see "Book 1" in the "#next-activity-link" "css_element"
+    But "#prev-activity-link" "css_element" should not exist
+    # Jump to the last activity.
+    And I select "Workshop 1" from the "Jump to..." singleselect
+    And I should see "Wiki 1" in the "#prev-activity-link" "css_element"
+    But "#next-activity-link" "css_element" should not exist
+
+  Scenario: Open an activity in a course that only has a single activity
+    Given I log in as "student1"
+    And I am on "Course 2" course homepage
+    And I follow "Assignment 1"
+    Then "#prev-activity-link" "css_element" should not exist
+    And "#next-activity-link" "css_element" should not exist
+    And "Jump to..." "field" should not exist
diff --git a/course/tests/behat/activity_navigation_with_restrictions.feature b/course/tests/behat/activity_navigation_with_restrictions.feature
new file mode 100644 (file)
index 0000000..e8b44ef
--- /dev/null
@@ -0,0 +1,62 @@
+@core @core_course
+Feature: Activity navigation involving activities with access restrictions
+  In order to quickly switch to another activity that has access restrictions
+  As a student
+  I need to be able to use the activity navigation feature to access the activity after satisfying its access conditions
+
+  Background:
+    Given the following "users" exist:
+      | username  | firstname  | lastname  | email                 |
+      | teacher1  | Teacher    | 1         | teacher1@example.com  |
+      | student1  | Student    | 1         | student1@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname | format | enablecompletion |
+      | Course 1 | C1        | topics | 1                |
+    And the following "course enrolments" exist:
+      | user      | course  | role            |
+      | student1  | C1      | student         |
+      | teacher1  | C1      | editingteacher  |
+    And the following "activities" exist:
+      | activity  | name    | intro                   | course | idnumber | section |
+      | page      | Page 1  | Test page description 1 | C1     | page1    | 0       |
+      | page      | Page 2  | Test page description 2 | C1     | page2    | 0       |
+      | page      | Page 3  | Test page description 3 | C1     | page3    | 0       |
+      | page      | Page 4  | Test page description 4 | C1     | page4    | 0       |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    # Set completion for Page 2.
+    And I open "Page 2" actions menu
+    And I click on "Edit settings" "link" in the "Page 2" activity
+    And I expand all fieldsets
+    And I set the field "Completion tracking" to "Show activity as complete when conditions are met"
+    And I set the following fields to these values:
+      | Completion tracking | Show activity as complete when conditions are met |
+      | Require view        | 1                                                 |
+    And I press "Save and return to course"
+    # Require Page 2 to be completed first before Page 3 can be accessed.
+    And I open "Page 3" actions menu
+    And I click on "Edit settings" "link" in the "Page 3" activity
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I set the field "Activity or resource" to "Page 2"
+    And I press "Save and return to course"
+    And I log out
+
+  @javascript
+  Scenario: Activity navigation involving activities with access restrictions
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    When I follow "Page 1"
+    Then I should see "Page 2" in the "#next-activity-link" "css_element"
+    # Activity that has access restriction should not show up in the dropdown.
+    And the "Jump to..." select box should not contain "Page 3"
+    And I select "Page 4" from the "Jump to..." singleselect
+    # Page 2 should be shown in the previous link since Page 3 is not yet available.
+    And I should see "Page 2" in the "#prev-activity-link" "css_element"
+    And the "Jump to..." select box should not contain "Page 3"
+    # Navigate to Page 2.
+    And I follow "Page 2"
+    # Since Page 2 has now been viewed and deemed completed, Page 3 can now be accessed.
+    And I should see "Page 3" in the "#next-activity-link" "css_element"
+    And the "Jump to..." select box should contain "Page 3"
index c16ead4..8c8a5e8 100644 (file)
@@ -30,9 +30,10 @@ Feature: Rename roles within a course
     Then "Tutor" "button" should exist
     And "Learner" "button" should exist
     And I navigate to course participants
-    And the "roleid" select box should contain "Tutor"
-    And the "roleid" select box should contain "Learner"
-    And the "roleid" select box should not contain "Student"
+    And I open the autocomplete suggestions list
+    And I should see "Role: Tutor" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Role: Learner" in the ".form-autocomplete-suggestions" "css_element"
+    And I should not see "Role: Student" in the ".form-autocomplete-suggestions" "css_element"
     And I am on "Course 1" course homepage
     And I navigate to "Edit settings" in current page administration
     And I set the following fields to these values:
@@ -44,5 +45,6 @@ Feature: Rename roles within a course
     And "Student" "button" should exist
     And "Learner" "button" should not exist
     And I navigate to course participants
-    And the "roleid" select box should contain "Non-editing teacher"
-    And the "roleid" select box should contain "Student"
+    And I open the autocomplete suggestions list
+    And I should see "Role: Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Role: Student" in the ".form-autocomplete-suggestions" "css_element"
index c4b6e63..b4de80a 100644 (file)
@@ -974,7 +974,7 @@ class grade_report_grader extends grade_report {
                 $usergrades = $this->allgrades[$userid];
                 $hidingaffected = grade_grade::get_hiding_affected($usergrades, $allgradeitems);
                 $altered = $hidingaffected['altered'];
-                $unknown = $hidingaffected['unknown'];
+                $unknown = $hidingaffected['unknowngrades'];
                 unset($hidingaffected);
             }
 
@@ -996,7 +996,7 @@ class grade_report_grader extends grade_report {
                 // Get the decimal points preference for this item
                 $decimalpoints = $item->get_decimals();
 
-                if (in_array($itemid, $unknown)) {
+                if (array_key_exists($itemid, $unknown)) {
                     $gradeval = null;
                 } else if (array_key_exists($itemid, $altered)) {
                     $gradeval = $altered[$itemid];
index d925ef7..45c652e 100644 (file)
@@ -525,14 +525,14 @@ abstract class grade_report {
                     $aggregationweight = null;
                 }
             }
-        } else if (!empty($hiding_affected['unknown'][$course_item->id])) {
+        } else if (array_key_exists($course_item->id, $hiding_affected['unknowngrades'])) {
             //not sure whether or not this item depends on a hidden item
             if (!$this->showtotalsifcontainhidden[$courseid]) {
                 //hide the grade
                 $finalgrade = null;
             } else {
                 //use reprocessed marks that exclude hidden items
-                $finalgrade = $hiding_affected['unknown'][$course_item->id];
+                $finalgrade = $hiding_affected['unknowngrades'][$course_item->id];
 
                 if (array_key_exists($course_item->id, $hiding_affected['alteredgrademin'])) {
                     $grademin = $hiding_affected['alteredgrademin'][$course_item->id];
index ca3b518..e761671 100644 (file)
@@ -27,6 +27,14 @@ Feature: Student and teacher's view of aggregated grade items is consistent when
       | assign | C1 | a3 | Test assignment three | Submit something! | Sub category 2 | 100 |
       | assign | C1 | a4 | Test assignment four | Submit something! | Sub category 2 | 100 |
     And I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I navigate to "Setup > Gradebook setup" in the course gradebook
+    And I press "Add grade item"
+    And I set the following fields to these values:
+      | Item name | calculated |
+    And I press "Save changes"
+    And I set "=[[a4]]/2" calculation for grade item "calculated" with idnumbers:
+      | Sub category 1 | sub1 |
     And I navigate to "Overview report" node in "Site administration > Grades > Report settings"
     And I set the field "s__grade_report_overview_showtotalsifcontainhidden" to "Show totals excluding hidden items"
     And I navigate to "User report" node in "Site administration > Grades > Report settings"
index f4927b5..831189a 100644 (file)
@@ -75,7 +75,7 @@ class user_groups_editable extends \core\output\inplace_editable {
         $options = [];
 
         foreach ($coursegroups as $group) {
-            $options[$group->id] = $group->name;
+            $options[$group->id] = format_string($group->name, true, ['context' => $this->context]);
         }
         $this->edithint = get_string('editusersgroupsa', 'group', fullname($user));
         $this->editlabel = get_string('editusersgroupsa', 'group', fullname($user));
index 4642d4d..aa11ab9 100644 (file)
@@ -47,11 +47,16 @@ Feature: Organize students into groups
     And the "members" select box should contain "Student 3"
     And the "members" select box should not contain "Student 0"
     And I navigate to course participants
-    And I set the field "Separate groups" to "Group 1"
+    And I open the autocomplete suggestions list
+    And I click on "Group: Group 1" item in the autocomplete list
+    And I press "Filter"
     And I should see "Student 0"
     And I should see "Student 1"
     And I should not see "Student 2"
-    And I set the field "Separate groups" to "Group 2"
+    And I click on "Group: Group 1" "text" in the ".form-autocomplete-selection" "css_element"
+    And I open the autocomplete suggestions list
+    And I click on "Group: Group 2" item in the autocomplete list
+    And I press "Filter"
     And I should see "Student 2"
     And I should see "Student 3"
     And I should not see "Student 0"
index 2dbae2c..81fa1bc 100644 (file)
@@ -30,7 +30,6 @@ $string['analyticslogstore_help'] = 'The log store that will be used by the anal
 $string['analyticssettings'] = 'Analytics settings';
 $string['coursetoolong'] = 'The course is too long';
 $string['enabledtimesplittings'] = 'Time splitting methods';
-$string['enabledtimesplittings_help'] = 'The time splitting method divides the course duration in parts, the predictions engine will run at the end of these parts. It is recommended that you only enable the time splitting methods you could be interested on using; the evaluation process will iterate through all of them so the more time splitting methods to go through the slower the evaluation process will be.';
 $string['erroralreadypredict'] = '{$a} file has already been used to predict';
 $string['errorcannotreaddataset'] = 'Dataset file {$a} can not be read';
 $string['errorcannotwritedataset'] = 'Dataset file {$a} can not be written';
@@ -60,15 +59,17 @@ $string['insightinfomessagehtml'] = 'The system generated some insights for you:
 $string['invalidtimesplitting'] = 'Model with id {$a} needs a time splitting method before it can be used to train';
 $string['invalidanalysablefortimesplitting'] = 'It can not be analysed using {$a} time splitting method';
 $string['nocourses'] = 'No courses to analyse';
-$string['nodata'] = 'No data available';
 $string['modeloutputdir'] = 'Models output directory';
 $string['modeloutputdirinfo'] = 'Directory where prediction processors store all evaluation info. Useful for debugging and research.';
 $string['noevaluationbasedassumptions'] = 'Models based on assumptions can not be evaluated';
+$string['nodata'] = 'No data to analyse';
 $string['noinsightsmodel'] = 'This model does not generate insights';
 $string['noinsights'] = 'No insights reported';
 $string['nonewdata'] = 'No new data available';
+$string['nonewranges'] = 'No new predictions yet';
 $string['nonewtimeranges'] = 'No new time ranges, nothing to predict';
 $string['nopredictionsyet'] = 'No predictions available yet';
+$string['noranges'] = 'No predictions yet';
 $string['notrainingbasedassumptions'] = 'Models based on assumptions do not need training';
 $string['novaliddata'] = 'No valid data available';
 $string['novalidsamples'] = 'No valid samples available';
index b899919..288c4b6 100644 (file)
@@ -42,7 +42,7 @@ $string['err_numeric'] = 'You must enter a number here.';
 $string['err_rangelength'] = 'You must enter between {$a->format[0]} and {$a->format[1]} characters here.';
 $string['err_required'] = 'You must supply a value here.';
 $string['err_wrongfileextension'] = 'Some files ({$a->wrongfiles}) cannot be uploaded. Only file types {$a->whitelist} are allowed.';
-$string['filesofthesetypes'] = 'Accepted files types:';
+$string['filesofthesetypes'] = 'Accepted file types:';
 $string['filetypesany'] = 'All file types';
 $string['filetypesnotall'] = 'It is not allowed to select \'All file types\' here';
 $string['filetypesnotwhitelisted'] = 'These file types are not allowed here: {$a}';
index e8928a9..72b71da 100644 (file)
@@ -728,6 +728,7 @@ $string['errorwhenconfirming'] = 'You are not confirmed yet because an error occ
 $string['eventcommentcreated'] = 'Comment created';
 $string['eventcommentdeleted'] = 'Comment deleted';
 $string['eventcommentsviewed'] = 'Comments viewed';
+$string['eventconfiglogcreated'] = 'Config log created';
 $string['eventcoursecategorycreated'] = 'Category created';
 $string['eventcoursecategorydeleted'] = 'Category deleted';
 $string['eventcoursecategoryupdated'] = 'Category updated';
diff --git a/lib/classes/event/config_log_created.php b/lib/classes/event/config_log_created.php
new file mode 100644 (file)
index 0000000..31366b1
--- /dev/null
@@ -0,0 +1,118 @@
+<?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/>.
+
+/**
+ * Config log created.
+ *
+ * @package    core
+ * @copyright  2017 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Event class for when an admin config log is created.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string name: name of config setting
+ *      - string plugin: name of plugin
+ *      - string oldvalue: previous value
+ *      - string value: new value
+ * }
+ *
+ * @package    core
+ * @copyright  2017 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class config_log_created extends base {
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'config_log';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventconfiglogcreated');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        $name = $this->other['name'];
+        $plugin = isset($this->other['plugin']) ? $this->other['plugin'] : 'core';
+        $value = isset($this->other['value']) ? $this->other['value'] : 'Not set';
+        $oldvalue = isset($this->other['oldvalue']) ? $this->other['oldvalue'] : 'Not set';
+        return "The user with id '$this->userid' changed the config setting '$name' for component '$plugin' " .
+               "from '$oldvalue' to '$value'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/admin/index.php');
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+        if (!array_key_exists('plugin', $this->other)) {
+            throw new \coding_exception('The \'plugin\' value must be set in other.');
+        }
+        if (!array_key_exists('oldvalue', $this->other)) {
+            throw new \coding_exception('The \'oldvalue\' value must be set in other.');
+        }
+        if (!array_key_exists('value', $this->other)) {
+            throw new \coding_exception('The \'value\' value must be set in other.');
+        }
+    }
+
+    public static function get_objectid_mapping() {
+        // Config log is not mappable.
+        return array('db' => 'config_log', 'restore' => base::NOT_MAPPED);
+    }
+
+    public static function get_other_mapping() {
+        return false;
+    }
+}
diff --git a/lib/classes/lock/installation_lock_factory.php b/lib/classes/lock/installation_lock_factory.php
new file mode 100644 (file)
index 0000000..5200cab
--- /dev/null
@@ -0,0 +1,127 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Lock factory for use during installation.
+ *
+ * @package    core
+ * @category   lock
+ * @copyright  Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\lock;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Lock factory for use during installation.
+ *
+ * @package   core
+ * @category  lock
+ * @copyright  Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class installation_lock_factory implements lock_factory {
+
+    /**
+     * Create this lock factory.
+     *
+     * @param string $type - The type, e.g. cron, cache, session
+     */
+    public function __construct($type) {
+    }
+
+    /**
+     * Return information about the blocking behaviour of the lock type on this platform.
+     *
+     * @return boolean - False if attempting to get a lock will block indefinitely.
+     */
+    public function supports_timeout() {
+        return true;
+    }
+
+    /**
+     * This lock type will be automatically released when a process ends.
+     *
+     * @return boolean - True
+     */
+    public function supports_auto_release() {
+        return true;
+    }
+
+    /**
+     * This lock factory is only available during the initial installation.
+     * To use it at any other time would be potentially dangerous.
+     *
+     * @return boolean
+     */
+    public function is_available() {
+        return during_initial_install();
+    }
+
+    /**
+     * Multiple locks for the same resource cannot be held from a single process.
+     *
+     * @return boolean - False
+     */
+    public function supports_recursion() {
+        return false;
+    }
+
+    /**
+     * Get some info that might be useful for debugging.
+     * @return boolean - string
+     */
+    protected function get_debug_info() {
+        return 'host:' . php_uname('n') . ', pid:' . getmypid() . ', time:' . time();
+    }
+
+    /**
+     * Get a lock within the specified timeout or return false.
+     *
+     * @param string $resource - The identifier for the lock. Should use frankenstyle prefix.
+     * @param int $timeout - The number of seconds to wait for a lock before giving up.
+     * @param int $maxlifetime - Unused by this lock type.
+     * @return boolean - true if a lock was obtained.
+     */
+    public function get_lock($resource, $timeout, $maxlifetime = 86400) {
+        return new lock($resource, $this);
+    }
+
+    /**
+     * Release a lock that was previously obtained with @lock.
+     *
+     * @param lock $lock - A lock obtained from this factory.
+     * @return boolean - true if the lock is no longer held (including if it was never held).
+     */
+    public function release_lock(lock $lock) {
+        return true;
+    }
+
+    /**
+     * Extend a lock that was previously obtained with @lock.
+     *
+     * @param lock $lock - not used
+     * @param int $maxlifetime - not used
+     * @return boolean - true if the lock was extended.
+     */
+    public function extend_lock(lock $lock, $maxlifetime = 86400) {
+        // Not supported by this factory.
+        return false;
+    }
+
+}
index 43541a6..06fddf4 100644 (file)
@@ -48,7 +48,9 @@ class lock_config {
         global $CFG, $DB;
         $lockfactory = null;
 
-        if (isset($CFG->lock_factory) && $CFG->lock_factory != 'auto') {
+        if (during_initial_install()) {
+            $lockfactory = new \core\lock\installation_lock_factory($type);
+        } else if (isset($CFG->lock_factory) && $CFG->lock_factory != 'auto') {
             if (!class_exists($CFG->lock_factory)) {
                 // In this case I guess it is not safe to continue. Different cluster nodes could end up using different locking
                 // types because of an installation error.
index 28ad0cf..b1085ca 100644 (file)
@@ -371,7 +371,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:t/switch_whole' => 'fa-square-o',
             'core:t/unblock' => 'fa-commenting',
             'core:t/unlocked' => 'fa-unlock-alt',
-            'core:t/unlock' => 'fa-unlock',
+            'core:t/unlock' => 'fa-lock',
             'core:t/up' => 'fa-arrow-up',
             'core:t/user' => 'fa-user',
             'core:t/viewdetails' => 'fa-list',
index df48d6d..96b4dcd 100644 (file)
@@ -1573,13 +1573,28 @@ function add_to_config_log($name, $oldvalue, $value, $plugin) {
     global $USER, $DB;
 
     $log = new stdClass();
-    $log->userid       = during_initial_install() ? 0 :$USER->id; // 0 as user id during install
+    // Use 0 as user id during install.
+    $log->userid       = during_initial_install() ? 0 : $USER->id;
     $log->timemodified = time();
     $log->name         = $name;
     $log->oldvalue  = $oldvalue;
     $log->value     = $value;
     $log->plugin    = $plugin;
-    $DB->insert_record('config_log', $log);
+
+    $id = $DB->insert_record('config_log', $log);
+
+    $event = core\event\config_log_created::create(array(
+            'objectid' => $id,
+            'userid' => $log->userid,
+            'context' => \context_system::instance(),
+            'other' => array(
+                'name' => $log->name,
+                'oldvalue' => $log->oldvalue,
+                'value' => $log->value,
+                'plugin' => $log->plugin
+            )
+        ));
+    $event->trigger();
 }
 
 /**
index b83f318..768f7f2 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20170502" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20170721" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="target" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="indicators" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timesplitting" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
-        <FIELD NAME="score" TYPE="number" LENGTH="10" DECIMALS="5" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="score" TYPE="number" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="5"/>
         <FIELD NAME="info" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="dir" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <INDEX NAME="modelid" UNIQUE="false" FIELDS="modelid" COMMENT="Index on modelid"/>
       </INDEXES>
     </TABLE>
-
     <TABLE NAME="analytics_predictions" COMMENT="Predictions">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="sampleid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="rangeindex" TYPE="int" LENGTH="5" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="prediction" TYPE="int" LENGTH="2" NOTNULL="true" SEQUENCE="false"/>
-        <FIELD NAME="predictionscore" TYPE="number" LENGTH="10" DECIMALS="5" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="predictionscore" TYPE="number" LENGTH="10" NOTNULL="true" SEQUENCE="false" DECIMALS="5"/>
         <FIELD NAME="calculations" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
         <INDEX NAME="modelidandanalysableidandtimesplitting" UNIQUE="false" FIELDS="modelid, analysableid, timesplitting" COMMENT="Index on modelid and analysableid and timesplitting"/>
       </INDEXES>
     </TABLE>
-    <TABLE NAME="analytics_predict_ranges" COMMENT="Time ranges already used for predictions.">
+    <TABLE NAME="analytics_predict_samples" COMMENT="Samples already used for predictions.">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="modelid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="analysableid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timesplitting" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="rangeindex" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="sampleids" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       </KEYS>
       <INDEXES>
-        <INDEX NAME="modelidandanalysableidandtimesplitting" UNIQUE="false" FIELDS="modelid, analysableid, timesplitting" COMMENT="Index on modelid and analysableid and timesplitting"/>
+        <INDEX NAME="modelidandanalysableidandtimesplittingandrangeindex" UNIQUE="false" FIELDS="modelid, analysableid, timesplitting, rangeindex" COMMENT="Index on modelid and analysableid and timesplitting"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="analytics_used_files" COMMENT="Files that have already been used for training and prediction.">
       </INDEXES>
     </TABLE>
   </TABLES>
-</XMLDB>
+</XMLDB>
\ No newline at end of file
index 36d4845..638044f 100644 (file)
@@ -2255,5 +2255,58 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017072700.02);
     }
 
+    if ($oldversion < 2017080700.01) {
+
+        // Get the table by its previous name.
+        $table = new xmldb_table('analytics_predict_ranges');
+        if ($dbman->table_exists($table)) {
+
+            // We can only accept this because we are in master.
+            $DB->delete_records('analytics_predictions');
+            $DB->delete_records('analytics_used_files', array('action' => 'predicted'));
+            $DB->delete_records('analytics_predict_ranges');
+
+            // Define field sampleids to be added to analytics_predict_ranges (renamed below to analytics_predict_samples).
+            $field = new xmldb_field('sampleids', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null, 'rangeindex');
+
+            // Conditionally launch add field sampleids.
+            if (!$dbman->field_exists($table, $field)) {
+                $dbman->add_field($table, $field);
+            }
+
+            // Define field timemodified to be added to analytics_predict_ranges (renamed below to analytics_predict_samples).
+            $field = new xmldb_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'timecreated');
+
+            // Conditionally launch add field timemodified.
+            if (!$dbman->field_exists($table, $field)) {
+                $dbman->add_field($table, $field);
+            }
+
+            // Rename the table to its new name.
+            $dbman->rename_table($table, 'analytics_predict_samples');
+        }
+
+        $table = new xmldb_table('analytics_predict_samples');
+
+        $index = new xmldb_index('modelidandanalysableidandtimesplitting', XMLDB_INDEX_NOTUNIQUE,
+            array('modelid', 'analysableid', 'timesplitting'));
+
+        // Conditionally launch drop index.
+        if ($dbman->index_exists($table, $index)) {
+            $dbman->drop_index($table, $index);
+        }
+
+        $index = new xmldb_index('modelidandanalysableidandtimesplittingandrangeindex', XMLDB_INDEX_NOTUNIQUE,
+            array('modelid', 'analysableid', 'timesplitting', 'rangeindex'));
+
+        // Conditionally launch add index.
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017080700.01);
+    }
+
     return true;
 }
index 4e878f8..a9bfea5 100644 (file)
@@ -1318,7 +1318,7 @@ class file_storage {
         $newrecord->status       = empty($filerecord->status) ? 0 : $filerecord->status;
         $newrecord->sortorder    = $filerecord->sortorder;
 
-        list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_file_to_pool($pathname);
+        list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_file_to_pool($pathname, null, $newrecord);
 
         $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
 
@@ -1432,7 +1432,7 @@ class file_storage {
         $newrecord->status       = empty($filerecord->status) ? 0 : $filerecord->status;
         $newrecord->sortorder    = $filerecord->sortorder;
 
-        list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_string_to_pool($content);
+        list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_string_to_pool($content, $newrecord);
         if (empty($filerecord->mimetype)) {
             $newrecord->mimetype = $this->filesystem->mimetype_from_hash($newrecord->contenthash, $newrecord->filename);
         } else {
@@ -1456,6 +1456,30 @@ class file_storage {
         return $this->get_file_instance($newrecord);
     }
 
+    /**
+     * Synchronise stored file from file.
+     *
+     * @param stored_file $file Stored file to synchronise.
+     * @param string $path Path to the file to synchronise from.
+     * @param stdClass $filerecord The file record from the database.
+     */
+    public function synchronise_stored_file_from_file(stored_file $file, $path, $filerecord) {
+        list($contenthash, $filesize) = $this->add_file_to_pool($path, null, $filerecord);
+        $file->set_synchronized($contenthash, $filesize);
+    }
+
+    /**
+     * Synchronise stored file from string.
+     *
+     * @param stored_file $file Stored file to synchronise.
+     * @param string $content File content.
+     * @param stdClass $filerecord The file record from the database.
+     */
+    public function synchronise_stored_file_from_string(stored_file $file, $content, $filerecord) {
+        list($contenthash, $filesize) = $this->add_string_to_pool($content, $filerecord);
+        $file->set_synchronized($contenthash, $filesize);
+    }
+
     /**
      * Create a new alias/shortcut file from file reference information
      *
@@ -1570,7 +1594,7 @@ class file_storage {
             } else {
                 // External file doesn't have content in moodle.
                 // So we create an empty file for it.
-                list($filerecord->contenthash, $filerecord->filesize, $newfile) = $this->add_string_to_pool(null);
+                list($filerecord->contenthash, $filerecord->filesize, $newfile) = $this->add_string_to_pool(null, $filerecord);
             }
         }
 
@@ -1759,10 +1783,12 @@ class file_storage {
      * Add file content to sha1 pool.
      *
      * @param string $pathname path to file
-     * @param string $contenthash sha1 hash of content if known (performance only)
+     * @param string|null $contenthash sha1 hash of content if known (performance only)
+     * @param stdClass|null $newrecord New file record
      * @return array (contenthash, filesize, newfile)
      */
-    public function add_file_to_pool($pathname, $contenthash = NULL) {
+    public function add_file_to_pool($pathname, $contenthash = null, $newrecord = null) {
+        $this->call_before_file_created_plugin_functions($newrecord, $pathname);
         return $this->filesystem->add_file_from_path($pathname, $contenthash);
     }
 
@@ -1772,10 +1798,27 @@ class file_storage {
      * @param string $content file content - binary string
      * @return array (contenthash, filesize, newfile)
      */
-    public function add_string_to_pool($content) {
+    public function add_string_to_pool($content, $newrecord = null) {
+        $this->call_before_file_created_plugin_functions($newrecord, null, $content);
         return $this->filesystem->add_file_from_string($content);
     }
 
+    /**
+     * before_file_created hook.
+     *
+     * @param stdClass|null $newrecord New file record.
+     * @param string|null $pathname Path to file.
+     * @param string|null $content File content.
+     */
+    protected function call_before_file_created_plugin_functions($newrecord, $pathname = null, $content = null) {
+        $pluginsfunction = get_plugins_with_function('before_file_created');
+        foreach ($pluginsfunction as $plugintype => $plugins) {
+            foreach ($plugins as $pluginfunction) {
+                $pluginfunction($newrecord, ['pathname' => $pathname, 'content' => $content]);
+            }
+        }
+    }
+
     /**
      * Serve file content using X-Sendfile header.
      * Please make sure that all headers are already sent
index e1c3e73..1331246 100644 (file)
@@ -588,6 +588,24 @@ class stored_file {
         return $this->fs->create_directory($this->file_record->contextid, $this->file_record->component, $this->file_record->filearea, $this->file_record->itemid, $filepath);
     }
 
+    /**
+     * Set synchronised content from file.
+     *
+     * @param string $path Path to the file.
+     */
+    public function set_synchronised_content_from_file($path) {
+        $this->fs->synchronise_stored_file_from_file($this, $path, $this->file_record);
+    }
+
+    /**
+     * Set synchronised content from content.
+     *
+     * @param string $content File content.
+     */
+    public function set_synchronised_content_from_string($content) {
+        $this->fs->synchronise_stored_file_from_string($this, $content, $this->file_record);
+    }
+
     /**
      * Synchronize file if it is a reference and needs synchronizing
      *
diff --git a/lib/form/tests/behat/filetypes.feature b/lib/form/tests/behat/filetypes.feature
deleted file mode 100644 (file)
index 653e50d..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-@core_form
-Feature: There is a form element allowing to select filetypes
-  In order to test the filetypes field
-  As an admin
-  I need a test form that makes use of the filetypes field
-
-  Background:
-    Given the following "courses" exist:
-      | fullname | shortname | format |
-      | Course 1 | C1        | topics |
-    And the following "activities" exist:
-      | activity   | name | intro                                                              | course | idnumber |
-      | label      | L1   | <a href="../lib/form/tests/fixtures/filetypes.php">FixtureLink</a> | C1     | label1   |
-    And I log in as "admin"
-    And I am on "Course 1" course homepage
-    And I follow "FixtureLink"
-
-  Scenario: File types can be provided via direct input with JavaScript disabled
-    Given I set the field "Choose from all file types" to ".png .gif .jpg"
-    When I press "Save changes"
-    Then the field "Choose from all file types" matches value ".png .gif .jpg"
-
-  @javascript
-  Scenario: File types can be provided via direct input with JavaScript enabled
-    Given I set the field "Choose from all file types" to ".png .gif .jpg"
-    When I press "Save changes"
-    Then the field "Choose from all file types" matches value ".png .gif .jpg"
-
-  Scenario: File types are validated to be known, unless the field allows unknown be provided
-    Given I set the field "Choose from all file types" to ".pdf .doesnoexist"
-    And I set the field "Choose from a limited set" to "doc docx pdf rtf"
-    And I set the field "Unknown file types are allowed here" to ".neverminditdoesnotexist"
-    When I press "Save changes"
-    Then I should see "Unknown file types: .doesnoexist"
-    And I should see "These file types are not allowed here: .doc, .docx, .rtf"
-    And I should see "It is not allowed to select 'All file types' here"
-    And I should not see "Unknown file types: .neverminditdoesnotexist"
-
-  @javascript @_file_upload
-  Scenario: File manager element implicitly validates submitted files
-    # We can't directly upload the invalid file here as the upload repository would throw an exception.
-    # So instead we try to trick the filemanager, to be finally stopped by the implicit validation.
-    And I upload "lib/tests/fixtures/empty.txt" file to "Picky file manager" filemanager
-    And I follow "empty.txt"
-    And I set the field "Name" to "renamed.exe"
-    And I press "Update"
-    When I press "Save changes"
-    Then I should see "Some files (renamed.exe) cannot be uploaded. Only file types .txt are allowed."
diff --git a/lib/form/tests/behat/multi_select_dependencies.feature b/lib/form/tests/behat/multi_select_dependencies.feature
deleted file mode 100644 (file)
index 0abf192..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-@core_form @javascript @_bug_phantomjs
-Feature: Forms with a multi select field dependency
-  In order to test multi select field dependency
-  As an admin
-  I need forms field which depends on multiple select options
-
-  Scenario: Field should be enabled only when all select options are selected
-    # Get to the fixture page.
-    Given the following "courses" exist:
-      | fullname | shortname | format |
-      | Course 1 | C1        | topics |
-    And the following "activities" exist:
-      | activity   | name | intro                                                                               | course | idnumber |
-      | label      | L1   | <a href="../lib/form/tests/fixtures/multi_select_dependencies.php">FixtureLink</a> | C1     | label1   |
-    And I log in as "admin"
-    And I am on "Course 1" course homepage
-    When I follow "FixtureLink"
-    Then the "Enter your name" "field" should be disabled
-    And I set the field "Choose one or more directions" to "South,West"
-    Then the "Enter your name" "field" should be enabled
-    And I set the field "Choose one or more directions" to "West"
-    Then the "Enter your name" "field" should be disabled
-    And I set the field "Choose one or more directions" to "North,West"
-    Then the "Enter your name" "field" should be disabled
\ No newline at end of file
diff --git a/lib/form/tests/fixtures/filetypes.php b/lib/form/tests/fixtures/filetypes.php
deleted file mode 100644 (file)
index 8477743..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-<?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/>.
-
-/**
- * Fixture script for Behat test for testing the filetypes element.
- *
- * @package     core_form
- * @category    test
- * @copyright   2017 David Mudr├ík <david@moodle.com>
- * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-// No login check is expected here as this is for Behat tests only.
-// @codingStandardsIgnoreLine
-require(__DIR__.'/../../../../config.php');
-require_once($CFG->libdir.'/formslib.php');
-
-// Behat test fixture only.
-defined('BEHAT_SITE_RUNNING') || die('Only available on Behat test server');
-
-/**
- * Defines a test form to be used in automatic tests.
- *
- * @copyright 2017 David Mudrak <david@moodle.com>
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class test_form extends moodleform {
-
-    /**
-     * Defines the form fields.
-     */
-    public function definition() {
-        $mform = $this->_form;
-
-        $mform->addElement('filetypes', 'filetypes0', 'Choose from all file types');
-        $mform->setDefault('filetypes0', '.pdf');
-
-        $mform->addElement('filetypes', 'filetypes1', 'Choose from a limited set',
-            ['onlytypes' => array('.pdf', 'web_image', 'image')]);
-
-        $mform->addElement('filetypes', 'filetypes2', 'Choose without "all"',
-            ['allowall' => false]);
-
-        $mform->addElement('filetypes', 'filetypes3', 'Unknown file types are allowed here',
-            ['allowunknown' => true]);
-
-        $mform->addElement('filemanager', 'fileman1', 'Picky file manager', null, ['accepted_types' => '.txt']);
-
-        $this->add_action_buttons(false);
-    }
-}
-
-$PAGE->set_context(context_system::instance());
-$PAGE->set_url('/lib/form/tests/fixtures/filetypes.php');
-$PAGE->set_title('Filetypes element test page');
-
-$form = new test_form($PAGE->url);
-$formdata = $form->get_data();
-
-echo $OUTPUT->header();
-echo $OUTPUT->heading($PAGE->title);
-$form->display();
-echo $OUTPUT->footer();
\ No newline at end of file
diff --git a/lib/form/tests/fixtures/multi_select_dependencies.php b/lib/form/tests/fixtures/multi_select_dependencies.php
deleted file mode 100644 (file)
index a93dfd3..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-<?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/>.
-
-/**
- * Fixture for Behat test for testing multiple select dependencies.
- *
- * @package core_form
- * @copyright 2016 Rajesh Taneja <rajesh@moodle.com>
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-require(__DIR__ . '/../../../../config.php');
-require_once($CFG->libdir . '/formslib.php');
-
-// Behat test fixture only.
-defined('BEHAT_SITE_RUNNING') || die('Only available on Behat test server');
-
-/**
- * Form for testing multiple select dependencies.
- *
- * @package core_form
- * @copyright 2016 Rajesh Taneja <rajesh@moodle.com>
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class test_form extends moodleform {
-
-    /**
-     * Form definition.
-     */
-    public function definition() {
-
-        $mform = $this->_form;
-
-        $labels = array('North', 'Est', 'South', 'West');
-        $select = $mform->addElement('select', 'mselect_name', 'Choose one or more directions', $labels);
-        $select->setMultiple(true);
-
-        $mform->addElement('text', 'text_name', 'Enter your name');
-        $mform->setType('text_name', PARAM_RAW);
-
-        $mform->disabledIf('text_name', 'mselect_name[]', 'neq', array(2, 3));
-
-        $this->add_action_buttons($cancel = true, $submitlabel = null);
-    }
-}
-
-$PAGE->set_context(context_system::instance());
-$PAGE->set_url('/lib/form/tests/fixtures/multi_select_dependencies.php');
-$PAGE->set_title('multi_select_dependencies');
-
-$mform = new test_form(new moodle_url('/lib/form/tests/fixtures/multi_select_dependencies.php'));
-
-echo $OUTPUT->header();
-$mform->display();
-echo $OUTPUT->footer();
\ No newline at end of file
index eb57b0d..f2b30af 100644 (file)
@@ -727,8 +727,9 @@ class grade_grade extends grade_object {
      *
      * @param array $grade_grades all course grades of one user, & used for better internal caching
      * @param array $grade_items array of grade items, & used for better internal caching
-     * @return array This is an array of 3 arrays:
-     *      unknown => list of item ids that may be affected by hiding (with the calculated grade as the value)
+     * @return array This is an array of following arrays:
+     *      unknown => list of item ids that may be affected by hiding (with the ITEM ID as both the key and the value) - for BC with old gradereport plugins
+     *      unknowngrades => list of item ids that may be affected by hiding (with the calculated grade as the value)
      *      altered => list of item ids that are definitely affected by hiding (with the calculated grade as the value)
      *      alteredgrademax => for each item in altered or unknown, the new value of the grademax
      *      alteredgrademin => for each item in altered or unknown, the new value of the grademin
@@ -779,6 +780,7 @@ class grade_grade extends grade_object {
 
         if (!$hiddenfound) {
             return array('unknown' => array(),
+                         'unknowngrades' => array(),
                          'altered' => array(),
                          'alteredgrademax' => array(),
                          'alteredgrademin' => array(),
@@ -795,10 +797,10 @@ class grade_grade extends grade_object {
         for($i=0; $i<$max; $i++) {
             $found = false;
             foreach($todo as $key=>$do) {
-                $hidden_precursors = array_intersect($dependson[$do], $unknown);
+                $hidden_precursors = array_intersect($dependson[$do], array_keys($unknown));
                 if ($hidden_precursors) {
                     // this item depends on hidden grade indirectly
-                    $unknown[$do] = $do;
+                    $unknown[$do] = $grade_grades[$do]->finalgrade;
                     unset($todo[$key]);
                     $found = true;
                     continue;
@@ -828,7 +830,7 @@ class grade_grade extends grade_object {
                         ) {
                             // This is a grade item that is not a category or course and has been affected by grade hiding.
                             // I guess this means it is a calculation that needs to be recalculated.
-                            $unknown[$do] = $do;
+                            $unknown[$do] = $grade_grades[$do]->finalgrade;
                             unset($todo[$key]);
                             $found = true;
                             continue;
@@ -953,7 +955,8 @@ class grade_grade extends grade_object {
             }
         }
 
-        return array('unknown' => $unknown,
+        return array('unknown' => array_combine(array_keys($unknown), array_keys($unknown)), // Left for BC in case some gradereport plugins expect it.
+                     'unknowngrades' => $unknown,
                      'altered' => $altered,
                      'alteredgrademax' => $alteredgrademax,
                      'alteredgrademin' => $alteredgrademin,
index f6d581d..5db8d37 100644 (file)
@@ -111,6 +111,43 @@ class core_admintree_testcase extends advanced_testcase {
         $tree->add('root', new admin_category('bar', 'Bar'), '');
     }
 
+    /**
+     * Test that changes to config trigger events.
+     */
+    public function test_config_log_created_event() {
+        global $DB;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $adminroot = new admin_root(true);
+        $adminroot->add('root', $one = new admin_category('one', 'One'));
+        $page = new admin_settingpage('page', 'Page');
+        $page->add(new admin_setting_configtext('text1', 'Text 1', '', ''));
+        $page->add(new admin_setting_configpasswordunmask('pass1', 'Password 1', '', ''));
+        $adminroot->add('one', $page);
+
+        $sink = $this->redirectEvents();
+        $data = array('s__text1' => 'sometext', 's__pass1' => '');
+        $this->save_config_data($adminroot, $data);
+
+        $events = $sink->get_events();
+        $sink->close();
+        $event = array_pop($events);
+        $this->assertInstanceOf('\core\event\config_log_created', $event);
+
+        $sink = $this->redirectEvents();
+        $data = array('s__text1'=>'other', 's__pass1'=>'nice password');
+        $count = $this->save_config_data($adminroot, $data);
+
+        $events = $sink->get_events();
+        $sink->close();
+        $event = array_pop($events);
+        $this->assertInstanceOf('\core\event\config_log_created', $event);
+        // Verify password was nuked.
+        $this->assertNotEquals($event->other['value'], 'nice password');
+
+    }
+
     /**
      * Testing whether a configexecutable setting is executable.
      */
index 66089a4..31b99d1 100644 (file)
@@ -337,15 +337,29 @@ class core_statslib_testcase extends advanced_testcase {
 
         $this->assertEquals($firstoldtime, stats_get_start_from('daily'));
 
+        $time = time() - 5;
         \core_tests\event\create_executed::create(array('context' => context_system::instance()))->trigger();
+        $DB->set_field('logstore_standard_log', 'timecreated', $time++, [
+                'eventname' => '\\core_tests\\event\\create_executed',
+            ]);
+
         \core_tests\event\read_executed::create(array('context' => context_system::instance()))->trigger();
+        $DB->set_field('logstore_standard_log', 'timecreated', $time++, [
+                'eventname' => '\\core_tests\\event\\read_executed',
+            ]);
+
         \core_tests\event\update_executed::create(array('context' => context_system::instance()))->trigger();
+        $DB->set_field('logstore_standard_log', 'timecreated', $time++, [
+                'eventname' => '\\core_tests\\event\\update_executed',
+            ]);
+
         \core_tests\event\delete_executed::create(array('context' => context_system::instance()))->trigger();
+        $DB->set_field('logstore_standard_log', 'timecreated', $time++, [
+                'eventname' => '\\core_tests\\event\\delete_executed',
+            ]);
 
-        // Fake the origin of events.
         $DB->set_field('logstore_standard_log', 'origin', 'web', array());
-
-        $logs = $DB->get_records('logstore_standard_log');
+        $logs = $DB->get_records('logstore_standard_log', null, 'timecreated ASC');
         $this->assertCount(4, $logs);
 
         $firstnew = reset($logs);
index bf81ed5..77e180a 100644 (file)
@@ -41,6 +41,7 @@ information provided here is intended especially for developers.
 * New optional parameter 'closeSuggestionsOnSelect' for the enhance() function for form-autocomplete. Setting this to true will
   close the suggestions popup immediately after an option has been selected. If not specified, it defaults to true for single-select
   elements and false for multiple-select elements.
+* user_can_view_profile() now also checks the moodle/user:viewalldetails capability.
 
 === 3.3.1 ===
 
index 65caaf7..ec4d368 100644 (file)
@@ -252,6 +252,8 @@ $string['grading'] = 'Grading';
 $string['gradingchangessaved'] = 'The grade changes were saved';
 $string['gradingduedate'] = 'Remind me to grade by';
 $string['gradingduedate_help'] = 'The expected date that marking of the submissions should be completed by. This date is used to prioritise dashboard notifications for teachers.';
+$string['gradingdueduedatevalidation'] = 'Remind me to grade by date cannot be earlier than the due date.';
+$string['gradingduefromdatevalidation'] = 'Remind me to grade by date cannot be earlier than the allow submissions from date.';
 $string['gradechangessaveddetail'] = 'The changes to the grade and feedback were saved';
 $string['gradingmethodpreview'] = 'Grading criteria';
 $string['gradingoptions'] = 'Options';
index 3a23272..7f88c07 100644 (file)
@@ -238,6 +238,14 @@ class mod_assign_mod_form extends moodleform_mod {
                 $errors['cutoffdate'] = get_string('cutoffdatefromdatevalidation', 'assign');
             }
         }
+        if ($data['gradingduedate']) {
+            if ($data['allowsubmissionsfromdate'] && $data['allowsubmissionsfromdate'] > $data['gradingduedate']) {
+                $errors['gradingduedate'] = get_string('gradingduefromdatevalidation', 'assign');
+            }
+            if ($data['duedate'] && $data['duedate'] > $data['gradingduedate']) {
+                $errors['gradingduedate'] = get_string('gradingdueduedatevalidation', 'assign');
+            }
+        }
         if ($data['blindmarking'] && $data['attemptreopenmethod'] == ASSIGN_ATTEMPT_REOPEN_METHOD_UNTILPASS) {
             $errors['attemptreopenmethod'] = get_string('reopenuntilpassincompatiblewithblindmarking', 'assign');
         }
index 9fd2ad4..cab3f8f 100644 (file)
@@ -35,13 +35,10 @@ Feature: In an assignment, limit submittable file types
     And I press "Save and display"
     And I navigate to "Edit settings" in current page administration
     And the field "Accepted file types" matches value "image/png,spreadsheet"
-    And I set the field "Accepted file types" to ""
-    And I press "Choose"
-    And I set the field "Image files" to "1"
-    And I press "Save changes"
+    And I set the field "Accepted file types" to "image"
     And I press "Save and display"
     And I navigate to "Edit settings" in current page administration
-    Then the field "Accepted file types" matches value "image"
+    Then I should see "Image files"
 
   @javascript @_file_upload
   Scenario: Uploading permitted file types for an assignment
@@ -52,7 +49,7 @@ Feature: In an assignment, limit submittable file types
     And I am on "Course 1" course homepage
     And I follow "Test assignment name"
     When I press "Add submission"
-    And I should see "Files of these types may be added to the submission"
+    And I should see "Accepted file types"
     And I should see "Image (PNG)"
     And I should see "Spreadsheet files"
     And I should see "Text file"
@@ -73,7 +70,7 @@ Feature: In an assignment, limit submittable file types
     And I am on "Course 1" course homepage
     And I follow "Test assignment name"
     When I press "Add submission"
-    And I should not see "Files of these types may be added to the submission"
+    And I should not see "Accepted file types"
     And I upload "lib/tests/fixtures/gd-logo.png" file to "File submissions" filemanager
     And I upload "lib/tests/fixtures/tabfile.csv" file to "File submissions" filemanager
     And I press "Save changes"
index 5922e55..b3c7f17 100644 (file)
@@ -135,8 +135,7 @@ if (empty($result->posts)) {
     // In either case we need to decide whether we can show personal information
     // about the requested user to the current user so we will execute some checks
 
-    // TODO - Remove extra cap check once MDL-59172 is resolved.
-    $canviewuser = user_can_view_profile($user, null, $usercontext) || has_capability('moodle/user:viewalldetails', $usercontext);
+    $canviewuser = user_can_view_profile($user, null, $usercontext);
 
     // Prepare the page title
     $pagetitle = get_string('noposts', 'mod_forum');
index a743dc7..934e9f5 100644 (file)
@@ -92,7 +92,7 @@ if ($lesson->ongoing && !$reviewmode) {
     echo $lessonoutput->ongoing_score($lesson);
 }
 if (!$reviewmode) {
-    echo $result->feedback;
+    echo format_text($result->feedback, FORMAT_MOODLE, array('context' => $context));
 }
 
 // User is modifying attempts - save button and some instructions
index 8562aeb..7558f1b 100644 (file)
@@ -1616,16 +1616,22 @@ function lesson_check_updates_since(cm_info $cm, $from, $filter = array()) {
             $updates->userpagesviewed->itemids = array_keys($pagesviewed);
         }
 
-        $select = 'lessonid = ? AND completed > ? ' . $insql;
+        $select = 'lessonid = ? AND completed > ?';
+        if (!empty($insql)) {
+            $select .= ' AND userid ' . $insql;
+        }
         $grades = $DB->get_records_select('lesson_grades', $select, $params, '', 'id');
         if (!empty($grades)) {
             $updates->usergrades->updated = true;
             $updates->usergrades->itemids = array_keys($grades);
         }
 
-        $select = 'lessonid = ? AND (starttime > ? OR lessontime > ? OR timemodifiedoffline > ?) ' . $insql;
+        $select = 'lessonid = ? AND (starttime > ? OR lessontime > ? OR timemodifiedoffline > ?)';
         $params = array($cm->instance, $from, $from, $from);
-        $params = array_merge($params, $inparams);
+        if (!empty($insql)) {
+            $select .= ' AND userid ' . $insql;
+            $params = array_merge($params, $inparams);
+        }
         $timers = $DB->get_records_select('lesson_timer', $select, $params, '', 'id');
         if (!empty($timers)) {
             $updates->usertimers->updated = true;
index 7c7c379..8256004 100644 (file)
@@ -4091,13 +4091,8 @@ abstract class lesson_page extends lesson_base {
 
                 $result->feedback .= $OUTPUT->box(format_text($this->get_contents(), $this->properties->contentsformat, $options),
                         'generalbox boxaligncenter');
-                if (isset($result->studentanswerformat)) {
-                    // This is the student's answer so it should be cleaned.
-                    $studentanswer = format_text($result->studentanswer, $result->studentanswerformat,
-                            array('context' => $context, 'para' => true));
-                } else {
-                    $studentanswer = format_string($result->studentanswer);
-                }
+                $studentanswer = format_text($result->studentanswer, $result->studentanswerformat,
+                        array('context' => $context, 'para' => true));
                 $result->feedback .= '<div class="correctanswer generalbox"><em>'
                         . get_string("youranswer", "lesson").'</em> : ' . $studentanswer;
                 if (isset($result->responseformat)) {
@@ -4476,6 +4471,7 @@ abstract class lesson_page extends lesson_base {
         $result->response        = '';
         $result->newpageid       = 0;       // stay on the page
         $result->studentanswer   = '';      // use this to store student's answer(s) in order to display it on feedback page
+        $result->studentanswerformat = FORMAT_MOODLE;
         $result->userresponse    = null;
         $result->feedback        = '';
         $result->nodefaultresponse  = false; // Flag for redirecting when default feedback is turned off
index f0e8ef1..5cd91a2 100644 (file)
@@ -91,14 +91,27 @@ class mod_lesson_lib_testcase extends advanced_testcase {
 
         $this->resetAfterTest();
         $this->setAdminUser();
-        $course = $this->getDataGenerator()->create_course();
+        $course = new stdClass();
+        $course->groupmode = SEPARATEGROUPS;
+        $course->groupmodeforce = true;
+        $course = $this->getDataGenerator()->create_course($course);
 
         // Create user.
-        $student = self::getDataGenerator()->create_user();
+        $studentg1 = self::getDataGenerator()->create_user();
+        $teacherg1 = self::getDataGenerator()->create_user();
+        $studentg2 = self::getDataGenerator()->create_user();
 
         // User enrolment.
         $studentrole = $DB->get_record('role', array('shortname' => 'student'));
-        $this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id, 'manual');
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($studentg1->id, $course->id, $studentrole->id, 'manual');
+        $this->getDataGenerator()->enrol_user($teacherg1->id, $course->id, $teacherrole->id, 'manual');
+        $this->getDataGenerator()->enrol_user($studentg2->id, $course->id, $studentrole->id, 'manual');
+
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        groups_add_member($group1, $studentg1);
+        groups_add_member($group2, $studentg2);
 
         $this->setCurrentTimeStart();
         $record = array(
@@ -136,8 +149,23 @@ class mod_lesson_lib_testcase extends advanced_testcase {
         $this->assertTrue($updates->answers->updated);
         $this->assertCount(2, $updates->answers->itemids);
 
-        // Now, do something in the lesson.
-        $this->setUser($student);
+        // Now, do something in the lesson with the two users.
+        $this->setUser($studentg1);
+        mod_lesson_external::launch_attempt($lesson->id);
+        $data = array(
+            array(
+                'name' => 'answerid',
+                'value' => $DB->get_field('lesson_answers', 'id', array('pageid' => $tfrecord->id, 'jumpto' => -1)),
+            ),
+            array(
+                'name' => '_qf__lesson_display_answer_form_truefalse',
+                'value' => 1,
+            )
+        );
+        mod_lesson_external::process_page($lesson->id, $tfrecord->id, $data);
+        mod_lesson_external::finish_attempt($lesson->id);
+
+        $this->setUser($studentg2);
         mod_lesson_external::launch_attempt($lesson->id);
         $data = array(
             array(
@@ -152,6 +180,7 @@ class mod_lesson_lib_testcase extends advanced_testcase {
         mod_lesson_external::process_page($lesson->id, $tfrecord->id, $data);
         mod_lesson_external::finish_attempt($lesson->id);
 
+        $this->setUser($studentg1);
         $updates = lesson_check_updates_since($cm, $onehourago);
 
         // Check question attempts, timers and new grades.
@@ -163,6 +192,33 @@ class mod_lesson_lib_testcase extends advanced_testcase {
 
         $this->assertTrue($updates->timers->updated);
         $this->assertCount(1, $updates->timers->itemids);
+
+        // Now, as teacher, check that I can see the two users (even in separate groups).
+        $this->setUser($teacherg1);
+        $updates = lesson_check_updates_since($cm, $onehourago);
+        $this->assertTrue($updates->userquestionattempts->updated);
+        $this->assertCount(2, $updates->userquestionattempts->itemids);
+
+        $this->assertTrue($updates->usergrades->updated);
+        $this->assertCount(2, $updates->usergrades->itemids);
+
+        $this->assertTrue($updates->usertimers->updated);
+        $this->assertCount(2, $updates->usertimers->itemids);
+
+        // Now, teacher can't access all groups.
+        groups_add_member($group1, $teacherg1);
+        assign_capability('moodle/site:accessallgroups', CAP_PROHIBIT, $teacherrole->id, context_module::instance($cm->id));
+        accesslib_clear_all_caches_for_unit_testing();
+        $updates = lesson_check_updates_since($cm, $onehourago);
+        // I will see only the studentg1 updates.
+        $this->assertTrue($updates->userquestionattempts->updated);
+        $this->assertCount(1, $updates->userquestionattempts->itemids);
+
+        $this->assertTrue($updates->usergrades->updated);
+        $this->assertCount(1, $updates->usergrades->itemids);
+
+        $this->assertTrue($updates->usertimers->updated);
+        $this->assertCount(1, $updates->usertimers->itemids);
     }
 
     public function test_lesson_core_calendar_provide_event_action_open() {
index 56f25af..ca27416 100644 (file)
@@ -51,6 +51,7 @@ require_once($CFG->dirroot.'/mod/lti/lib.php');
 require_once($CFG->dirroot.'/mod/lti/locallib.php');
 
 $id = required_param('id', PARAM_INT); // Course Module ID.
+$triggerview = optional_param('triggerview', 1, PARAM_BOOL);
 
 $cm = get_coursemodule_from_id('lti', $id, 0, false, MUST_EXIST);
 $lti = $DB->get_record('lti', array('id' => $cm->instance), '*', MUST_EXIST);
@@ -62,7 +63,9 @@ require_login($course, true, $cm);
 require_capability('mod/lti:view', $context);
 
 // Completion and trigger events.
-lti_view($lti, $course, $cm, $context);
+if ($triggerview) {
+    lti_view($lti, $course, $cm, $context);
+}
 
 $lti->cmid = $cm->id;
 lti_launch_tool($lti);
index cc0c255..224b219 100644 (file)
@@ -53,6 +53,7 @@ require_once($CFG->dirroot.'/mod/lti/locallib.php');
 
 $id = optional_param('id', 0, PARAM_INT); // Course Module ID, or
 $l  = optional_param('l', 0, PARAM_INT);  // lti ID.
+$forceview = optional_param('forceview', 0, PARAM_BOOL);
 
 if ($l) {  // Two ways to specify the module.
     $lti = $DB->get_record('lti', array('id' => $l), '*', MUST_EXIST);
@@ -89,11 +90,16 @@ if ($launchcontainer == LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS) {
     $PAGE->set_pagelayout('frametop'); // Most frametops don't include footer, and pre-post blocks.
     $PAGE->blocks->show_only_fake_blocks(); // Disable blocks for layouts which do include pre-post blocks.
 } else if ($launchcontainer == LTI_LAUNCH_CONTAINER_REPLACE_MOODLE_WINDOW) {
-    redirect('launch.php?id=' . $cm->id);
+    if (!$forceview) {
+        $url = new moodle_url('/mod/lti/launch.php', array('id' => $cm->id));
+        redirect($url);
+    }
 } else {
     $PAGE->set_pagelayout('incourse');
 }
 
+lti_view($lti, $course, $cm, $context);
+
 $pagetitle = strip_tags($course->shortname.': '.format_string($lti->name));
 $PAGE->set_title($pagetitle);
 $PAGE->set_heading($course->fullname);
@@ -111,18 +117,20 @@ if ($lti->showdescriptionlaunch && $lti->intro) {
 }
 
 if ( $launchcontainer == LTI_LAUNCH_CONTAINER_WINDOW ) {
-    echo "<script language=\"javascript\">//<![CDATA[\n";
-    echo "window.open('launch.php?id=".$cm->id."','lti-".$cm->id."');";
-    echo "//]]\n";
-    echo "</script>\n";
-    echo "<p>".get_string("basiclti_in_new_window", "lti")."</p>\n";
+    if (!$forceview) {
+        echo "<script language=\"javascript\">//<![CDATA[\n";
+        echo "window.open('launch.php?id=" . $cm->id . "&triggerview=0','lti-" . $cm->id . "');";
+        echo "//]]\n";
+        echo "</script>\n";
+        echo "<p>".get_string("basiclti_in_new_window", "lti")."</p>\n";
+    }
     $url = new moodle_url('/mod/lti/launch.php', array('id' => $cm->id));
     echo html_writer::start_tag('p');
     echo html_writer::link($url, get_string("basiclti_in_new_window_open", "lti"), array('target' => '_blank'));
     echo html_writer::end_tag('p');
 } else {
     // Request the launch content with an iframe tag.
-    echo '<iframe id="contentframe" height="600px" width="100%" src="launch.php?id='.$cm->id.'"></iframe>';
+    echo '<iframe id="contentframe" height="600px" width="100%" src="launch.php?id='.$cm->id.'&triggerview=0"></iframe>';
 
     // Output script to make the iframe tag be as large as possible.
     $resize = '
index 195cd23..88b8b58 100644 (file)
@@ -430,9 +430,7 @@ class repository_boxnet extends repository {
             $result = $c->download_one($url, null, array('filepath' => $path, 'timeout' => $CFG->repositorysyncimagetimeout));
             $info = $c->get_info();
             if ($result === true && isset($info['http_code']) && $info['http_code'] == 200) {
-                $fs = get_file_storage();
-                list($contenthash, $filesize, $newfile) = $fs->add_file_to_pool($path);
-                $file->set_synchronized($contenthash, $filesize);
+                $file->set_synchronised_content_from_file($path);
                 return true;
             }
         }
index 8396503..0f7f209 100644 (file)
@@ -662,9 +662,7 @@ class repository_dropbox extends repository {
                     ]);
                 $info = $c->get_info();
                 if ($result === true && isset($info['http_code']) && $info['http_code'] == 200) {
-                    $fs = get_file_storage();
-                    list($contenthash, $filesize, ) = $fs->add_file_to_pool($saveas);
-                    $file->set_synchronized($contenthash, $filesize);
+                    $file->set_synchronised_content_from_file($saveas);
                     return true;
                 }
             } catch (Exception $e) {
index fbd9c84..cfffc8c 100644 (file)
@@ -214,9 +214,7 @@ class repository_equella extends repository {
             $path = $this->prepare_file('');
             $result = $c->download_one($url, null, array('filepath' => $path, 'followlocation' => true, 'timeout' => $CFG->repositorysyncimagetimeout));
             if ($result === true) {
-                $fs = get_file_storage();
-                list($contenthash, $filesize, $newfile) = $fs->add_file_to_pool($path);
-                $file->set_synchronized($contenthash, $filesize);
+                $file->set_synchronised_content_from_file($path);
                 return true;
             }
         } else {
index 1fa5e7c..ce8502f 100644 (file)
@@ -577,7 +577,9 @@ class repository_filesystem extends repository {
                     $filesize = filesize($filepath);
                 } else {
                     // Copy file into moodle filepool (used to generate an image thumbnail).
-                    list($contenthash, $filesize, $newfile) = $fs->add_file_to_pool($filepath);
+                    $file->set_timemodified(filemtime($filepath));
+                    $file->set_synchronised_content_from_file($filepath);
+                    return true;
                 }
             } else {
                 // Update only file size so file will NOT be copied into moodle filepool.
index c6dfb5c..6f6759c 100644 (file)
@@ -1757,9 +1757,7 @@ abstract class repository implements cacheable_object {
                 try {
                     $fileinfo = $this->get_file($file->get_reference());
                     if (isset($fileinfo['path'])) {
-                        list($contenthash, $filesize, $newfile) = $fs->add_file_to_pool($fileinfo['path']);
-                        // set this file and other similar aliases synchronised
-                        $file->set_synchronized($contenthash, $filesize);
+                        $file->set_synchronised_content_from_file($fileinfo['path']);
                     } else {
                         throw new moodle_exception('errorwhiledownload', 'repository', '', '');
                     }
index 2fccc91..da9fa22 100644 (file)
@@ -3,6 +3,10 @@ information provided here is intended especially for developers. Full
 details of the repository API are available on Moodle docs:
 http://docs.moodle.org/dev/Repository_API
 
+=== 3.4 ===
+Repositories should no longer directly call file_system#add_file_to_pool or file_system#add_string_to_pool
+instead they should call the stored_file method, set_synchronised_content_from_file or set_synchronised_content_from_string
+
 === 3.3 ===
 The skydrive repository is deprecated - please migrate to the newer onedrive repository.
 
index 6f5a0e2..c306f04 100644 (file)
     label {
         margin: 0;
     }
+
+    .header {
+        text-align: left;
+    }
 }
 
 #page-mod-quiz-edit {
index c9f37d4..024e920 100644 (file)
     label {
         margin: 0;
     }
+    .header {
+        text-align: left;
+    }
 }
 #page-mod-quiz-edit {
     div.questionbankwindow div.header {
index e93a190..066dc47 100644 (file)
@@ -9138,6 +9138,9 @@ a.ygtvspacer:hover {
 #categoryquestions label {
   margin: 0;
 }
+#categoryquestions .header {
+  text-align: left;
+}
 #page-mod-quiz-edit div.questionbankwindow div.header {
   margin: 0;
 }
index 83044f2..b8b4a63 100644 (file)
@@ -1143,7 +1143,7 @@ function user_can_view_profile($user, $course = null, $usercontext = null) {
         $usercontext = context_user::instance($user->id);
     }
     // Number 3.
-    if (has_capability('moodle/user:viewdetails', $usercontext)) {
+    if (has_capability('moodle/user:viewdetails', $usercontext) || has_capability('moodle/user:viewalldetails', $usercontext)) {
         return true;
     }
 
index 7a85f72..b69391e 100644 (file)
@@ -23,7 +23,7 @@ Feature: Bulk enrolments
   Scenario: Bulk edit enrolments
     When I log in as "admin"
     And I am on "Course 1" course homepage
-    And I follow "Participants"
+    And I navigate to course participants
     And I press "Select all"
     And I set the field "With selected users..." to "Edit selected user enrolments"
     And I set the field "Alter status" to "Suspended"
@@ -36,7 +36,7 @@ Feature: Bulk enrolments
   Scenario: Bulk delete enrolments
     When I log in as "admin"
     And I am on "Course 1" course homepage
-    And I follow "Participants"
+    And I navigate to course participants
     And I press "Select all"
     And I set the field "With selected users..." to "Delete selected user enrolments"
     And I press "Unenrol users"
index 0683cba..3e88074 100644 (file)
@@ -576,6 +576,15 @@ class core_userliblib_testcase extends advanced_testcase {
         $this->setUser($user5);
         $this->assertTrue(user_can_view_profile($user4));
 
+        // Test the user:viewalldetails cap check using the course creator role which, by default, can't see student profiles.
+        $this->setUser($user7);
+        $this->assertFalse(user_can_view_profile($user4));
+        assign_capability('moodle/user:viewalldetails', CAP_ALLOW, $coursecreatorrole->id, context_system::instance()->id, true);
+        reload_all_capabilities();
+        $this->assertTrue(user_can_view_profile($user4));
+        unassign_capability('moodle/user:viewalldetails', $coursecreatorrole->id, $coursecontext->id);
+        reload_all_capabilities();
+
         $CFG->coursecontact = null;
 
         // Visitor (Not a guest user, userid=0).
index dc4cc84..b44ec9d 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2017080400.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2017080700.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.